diff --git a/api/openapi.json b/api/openapi.json index 8363c9ede..fca079812 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -293,11 +293,6 @@ "CloneExecution": { "additionalProperties": false, "properties": { - "cloneRequestId": { - "format": "uuid", - "nullable": true, - "type": "string" - }, "createdAt": { "format": "date-time", "type": "string" @@ -349,7 +344,6 @@ }, "workspaceId": { "format": "uuid", - "nullable": true, "type": "string" } }, @@ -357,7 +351,6 @@ "executionId", "operationId", "workspaceId", - "cloneRequestId", "nodeId", "input", "result", @@ -369,89 +362,35 @@ ], "type": "object" }, - "CloneOperation": { + "CloneInput": { "additionalProperties": false, "properties": { - "branch": { - "type": "string" - }, - "createdAt": { - "format": "date-time", - "type": "string" - }, - "executionId": { - "nullable": true, - "type": "string" - }, - "nodeId": { - "nullable": true, - "type": "string" - }, - "operationId": { - "format": "uuid", - "type": "string" - }, - "repository": { - "type": "string" - }, - "requestId": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/CloneState" - }, - "updatedAt": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "operationId", - "requestId", - "repository", - "branch", - "executionId", - "nodeId", - "state", - "createdAt", - "updatedAt" - ], - "type": "object" - }, - "CloneState": { - "additionalProperties": false, - "properties": { - "commit": { + "baseCommit": { + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, "kind": { "enum": [ - "pending", - "succeeded", - "failed" + "clone" ], "type": "string" }, - "path": { + "repositoryUrl": { "type": "string" }, - "reason": { - "enum": [ - "sourceUnavailable", - "branchNotFound", - "destinationConflict", - "operationFailed", - "interrupted", - "unspecified" - ], + "snapshotRef": { "type": "string" }, - "retainedPath": { + "workspaceRef": { "type": "string" } }, "required": [ - "kind" + "kind", + "repositoryUrl", + "snapshotRef", + "baseCommit", + "workspaceRef" ], "type": "object" }, @@ -644,14 +583,6 @@ "format": "date-time", "type": "string" }, - "credentialRefId": { - "format": "uuid", - "nullable": true, - "type": "string" - }, - "defaultBranch": { - "type": "string" - }, "deletedAt": { "format": "date-time", "nullable": true, @@ -671,13 +602,6 @@ "format": "uuid", "type": "string" }, - "repositoryUrl": { - "type": "string" - }, - "secretRef": { - "nullable": true, - "type": "string" - }, "spaceId": { "format": "uuid", "type": "string" @@ -697,14 +621,10 @@ "ownerUserId", "spaceId", "name", - "repositoryUrl", - "defaultBranch", - "credentialRefId", "lifecycle", "version", "createdAt", - "deletedAt", - "secretRef" + "deletedAt" ], "type": "object" }, @@ -2978,6 +2898,9 @@ "OperationRequest": { "additionalProperties": false, "properties": { + "clone": { + "$ref": "#/components/schemas/CloneInput" + }, "desiredRevision": { "format": "int64", "type": "integer" @@ -4710,6 +4633,9 @@ "Snapshot": { "additionalProperties": false, "properties": { + "cloneInput": { + "$ref": "#/components/schemas/CloneInput" + }, "clones": { "items": { "$ref": "#/components/schemas/CloneExecution" @@ -10302,443 +10228,6 @@ ] } }, - "/api/v1/tenants/{tid}/clones": { - "get": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", - "operationId": "get_api_v1_tenants_tid_clones", - "parameters": [ - { - "in": "path", - "name": "tid", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Exclusive UUID cursor; Agent sessions use descending (createdAt, id), other lists use ascending stable ordering.", - "in": "query", - "name": "after", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/CloneOperation" - }, - "type": "array" - }, - "nextCursor": { - "type": "string" - } - }, - "required": [ - "items", - "nextCursor" - ], - "type": "object" - } - } - }, - "description": "Successful command or resource response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Resource absent or outside authorized tenant/owner scope" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" - }, - "428": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version precondition required" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Internal error; no SQL or secret details are exposed" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" - } - }, - "security": [ - { - "serviceCredential": [], - "userCredential": [] - } - ], - "summary": "GET /api/v1/tenants/:tid/clones", - "tags": [ - "clones" - ] - }, - "post": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", - "operationId": "post_api_v1_tenants_tid_clones", - "parameters": [ - { - "in": "path", - "name": "tid", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", - "in": "header", - "name": "Idempotency-Key", - "required": true, - "schema": { - "maxLength": 200, - "minLength": 1, - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "branch": { - "type": "string" - }, - "repository": { - "type": "string" - }, - "requestId": { - "type": "string" - } - }, - "required": [ - "requestId", - "repository", - "branch" - ], - "type": "object" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloneOperation" - } - } - }, - "description": "Successful command or resource response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Resource absent or outside authorized tenant/owner scope" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" - }, - "428": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version precondition required" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Internal error; no SQL or secret details are exposed" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" - } - }, - "security": [ - { - "serviceCredential": [], - "userCredential": [] - } - ], - "summary": "POST /api/v1/tenants/:tid/clones", - "tags": [ - "clones" - ] - } - }, - "/api/v1/tenants/{tid}/clones/{cloneId}": { - "get": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", - "operationId": "get_api_v1_tenants_tid_clones_cloneId", - "parameters": [ - { - "in": "path", - "name": "tid", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "in": "path", - "name": "cloneId", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloneOperation" - } - } - }, - "description": "Successful command or resource response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Resource absent or outside authorized tenant/owner scope" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" - }, - "428": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Version precondition required" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Internal error; no SQL or secret details are exposed" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" - } - }, - "security": [ - { - "serviceCredential": [], - "userCredential": [] - } - ], - "summary": "GET /api/v1/tenants/:tid/clones/:cloneId", - "tags": [ - "clones" - ] - } - }, "/api/v1/tenants/{tid}/collaboration/forms/{formRef}": { "get": { "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", @@ -23849,7 +23338,7 @@ ] }, "post": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous; the main Workspace clones the Binding's first snapshot and waits for it. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", "operationId": "post_api_v1_tenants_tid_projects", "parameters": [ { @@ -25755,7 +25244,7 @@ }, "/api/v1/tenants/{tid}/projects/{pid}/repository/publishes": { "post": { - "description": "Creates a PublishOperation: fast-forward targetBranch of the Binding's repository from expectedOldOid (40/64 zeros creates the branch) to sourceCommit, taken from sourceRef (any Workspace branch or delivery ref of this Project, confirmed through the Git Server ref query) or from revisionId. Fields are immutable. A personal credential is approved by its owner only, a team credential by a tenant admin; when the requester is the approver it starts pending. The credential must have proven publish (409 credential_publish_unverified). 400 nothing_to_publish when expectedOldOid equals sourceCommit; 409 source_ref_not_found.", + "description": "Creates a PublishOperation: fast-forward targetBranch of the Binding's repository from expectedOldOid (40/64 zeros creates the branch) to sourceCommit, taken from sourceRef (any Workspace branch or delivery ref of this Project, confirmed through the Git Server ref query) or from revisionId (the Revision's delivery ref and final commit verified when it was registered; 409 revision_not_publishable for a Revision without a delivery ref). Fields are immutable. A personal credential is approved by its owner only, a team credential by a tenant admin; when the requester is the approver it starts pending. The credential must have proven publish (409 credential_publish_unverified). 400 nothing_to_publish when expectedOldOid equals sourceCommit; 409 source_ref_not_found.", "operationId": "post_api_v1_tenants_tid_projects_pid_repository_publishes", "parameters": [ { @@ -26434,7 +25923,7 @@ ] }, "post": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Creates one isolated Workspace and Task display identity. title/baseRef required; baseRef becomes the Workspace's requestedRef, which its Node clones; HEAD means the Project's defaultBranch. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Creates one isolated Workspace and Task display identity. title is required. The Workspace clones the Project's current snapshot of its Binding branch: baseRef may be omitted, empty, HEAD or that branch, and any other value is 400 base_ref_not_synced with nothing recorded. The branch becomes the Workspace's requestedRef, the short name of its Git Server branch refs/heads/workspaces/\u003cworkspaceId\u003e/\u003crequestedRef\u003e. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", "operationId": "post_api_v1_tenants_tid_projects_pid_workspaces", "parameters": [ { @@ -28166,7 +27655,7 @@ ] }, "post": { - "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Project collection scoped to the tenant's sole collaboration space; active tenant membership is required. Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Project collection scoped to the tenant's sole collaboration space; active tenant membership is required. Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous; the main Workspace clones the Binding's first snapshot and waits for it. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", "operationId": "post_api_v1_tenants_tid_spaces_spaceId_projects", "parameters": [ { @@ -32612,6 +32101,13 @@ "dependencies": { "additionalProperties": false, "properties": { + "gitServer": { + "enum": [ + "configured", + "unconfigured" + ], + "type": "string" + }, "objectStore": { "enum": [ "configured", @@ -32621,7 +32117,8 @@ } }, "required": [ - "objectStore" + "objectStore", + "gitServer" ], "type": "object" }, @@ -33907,7 +33404,7 @@ }, "/internal/v1/operations/claim": { "post": { - "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay.", + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay. A clone step is handed out only with its cloneInput, frozen once per operation from the Project's current Binding snapshot; without a snapshot the operation is parked as retry_wait snapshot_not_ready while the Binding's latest sync is unfinished, fails as repository_sync_failed once it ended unsuccessfully, and is blocked as git_server_unconfigured without a Git Server. delete_project is not handed out to quiesce while a sync or publish of the Project is unfinished (retry_wait repository_operation_running). A parked operation never empties the claim: the next claimable one is returned.", "operationId": "post_internal_v1_operations_claim", "requestBody": { "content": { @@ -34042,7 +33539,7 @@ }, "/internal/v1/operations/{oid}/advance": { "post": { - "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. The clone step requires the operation's latest clone execution (registered over gRPC) to have succeeded on the current Node; it records the baseline commit and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation.", + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. Entering the clone step freezes its cloneInput, or parks or fails the operation as claim does. The clone step requires the operation's latest clone execution (registered over gRPC with exactly the frozen input) to have succeeded on the current Node with exactly the input's baseCommit; it records that commit as the baseline and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation.", "operationId": "post_internal_v1_operations_oid_advance", "parameters": [ { diff --git a/cmd/git-server-preset/README.en.md b/cmd/git-server-preset/README.en.md index acecde9f2..e346a1602 100644 --- a/cmd/git-server-preset/README.en.md +++ b/cmd/git-server-preset/README.en.md @@ -11,7 +11,7 @@ wires dependencies and sets the exit code. | Subcommand | Effect | |---|---| -| `snapshot --project (--commit \| --synthetic )` | Writes `snapshots/` and the tracking ref, records a `succeeded` sync and makes it the current snapshot; `--synthetic` first creates a deterministic history of n commits | +| `snapshot --project (--commit \| --synthetic )` | Writes `snapshots/` and the tracking ref, advances the Binding's unfinished sync (such as the one a new Project starts with) to `succeeded` or records a new `succeeded` sync when there is none, and makes it the current snapshot; `--synthetic` first creates a deterministic history of n commits | | `ref --project --ref --commit ` | Writes one ref of any namespace (for example a Workspace branch to publish from); the commit must already be in the repository | | `operation --project --type sync\|publish --state [--error-code ]` | Writes one Operation in the given state for waiting, failure and deletion scenarios; terminal states get `finished_at`. A sync moves the Binding's unfinished sync (such as the one written when the Project was created) to the given state and writes a new row only when there is none, so every sync state can be preset; a publish is always a new row | | `check --project ` | Contract assertion: do the Git Server's refs and Cloud's records satisfy the contract | diff --git a/cmd/git-server-preset/README.md b/cmd/git-server-preset/README.md index f9b1df107..df960288d 100644 --- a/cmd/git-server-preset/README.md +++ b/cmd/git-server-preset/README.md @@ -8,7 +8,7 @@ | 子命令 | 作用 | |---|---| -| `snapshot --project (--commit \| --synthetic )` | 写入 `snapshots/` 与跟踪 ref,登记一行 `succeeded` 的同步,推进当前快照;`--synthetic` 先生成有 n 个提交的确定性历史 | +| `snapshot --project (--commit \| --synthetic )` | 写入 `snapshots/` 与跟踪 ref,把该 Binding 未结束的同步(例如建 Project 时的那一行)推进为 `succeeded`,没有时登记一行新的 `succeeded` 同步,并推进当前快照;`--synthetic` 先生成有 n 个提交的确定性历史 | | `ref --project --ref --commit ` | 写入任意 namespace 的一个 ref(例如作为发布源的 Workspace 分支);提交必须已在仓库中 | | `operation --project --type sync\|publish --state [--error-code ]` | 写入一行给定状态的 Operation,用于等待、失败、删除等场景;终态写 `finished_at`。同步改写该 Binding 未结束的同步(例如建 Project 时的那一行)为给定状态,没有时才新写一行,因此任何同步状态都能预置;发布总是新写一行 | | `check --project ` | 契约断言:Git Server 上的 ref 与 Cloud 记录是否符合契约 | diff --git a/cmd/server/main.go b/cmd/server/main.go index 44894043a..b3c41a5f7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -104,6 +104,10 @@ func run() (runErr error) { if e != nil { return e } + store.GitServer, e = cfg.GitServer.Open() + if e != nil { + return e + } configureCollaboration(store, cfg.Collaboration.DevelopmentFixtures, log) // The B side of the Agent Run seam: Cloud's IssueRun/Thread lifecycle is driven by these five // callbacks, each of which runs inside the control-plane transaction that just persisted the diff --git a/cmd/simulator/README.en.md b/cmd/simulator/README.en.md index 02cc4bcc4..6e87e0c2b 100644 --- a/cmd/simulator/README.en.md +++ b/cmd/simulator/README.en.md @@ -8,13 +8,14 @@ - **Durable demo fixture**: Initializes a real local Git repository fixture under `.local/demo/fixture` with initial commits and branches. - **In-process cloud server**: Launches an ephemeral `httptest.Server` serving the complete Gin router, backed by a real PostgreSQL database. +- **Git Server double**: Keeps Project repositories under `.local/demo/git` in the Git Server volume layout and, standing in for the external line, records the fixture commit as the demo Project's snapshot (`SeedSnapshot`); the external repository URL is never contacted. - **Substrate simulation**: Launches a local HTTP server exposing Substrate storage and effect simulation under `.local/demo/substrate`. - **Ephemeral cryptography**: Generates in-memory Ed25519 keypairs for `gateway`, `controller`, `node`, and `user` roles to sign and verify short-lived JWT tokens without external IdP infrastructure. - **Controller execution loop**: 1. Bootstraps a demo tenant and user. 2. Acquires a controller lease via `/internal/v1/controller-lease/acquire`. 3. Dispatches a project creation request via the public API (`POST /api/v1/tenants/{tid}/projects`). - 4. Simulates Controller queue draining: executes the effect plan (scheduling sandboxes, registering nodes, and cloning the repository into the Workspace's data through a loopback gRPC `ExecutionService`). + 4. Simulates Controller queue draining: executes the effect plan (scheduling sandboxes, registering nodes, registering Cloud's frozen clone input through a loopback gRPC `ExecutionService`, and cloning the snapshot from the Git Server double into the Workspace's data). 5. Verifies that the workspace reaches `ready` state and queries it through the public API. 6. Releases the controller lease cleanly. 7. Emits JSON summary to `stdout`. @@ -26,4 +27,4 @@ See [cmd overview](../README.en.md), [Simulator internals](../../internal/simulator/README.en.md), and [Workspace lifecycle](../../docs/runtime/workspaces.en.md). -The in-process simulator explicitly uses the development-only retired clone fixture. Its plugin selection remains durable pending because no real plugin executor is available. It is not evidence of runtime control, management authentication or workload isolation; use cluster Compose and the real acceptance script. +The in-process simulator's Node clones snapshots only from the Git Server double. Its plugin selection remains durable pending because no real plugin executor is available. It is not evidence of runtime control, management authentication or workload isolation; use cluster Compose and the real acceptance script. diff --git a/cmd/simulator/README.md b/cmd/simulator/README.md index 438a3dd9e..b43571ead 100644 --- a/cmd/simulator/README.md +++ b/cmd/simulator/README.md @@ -8,13 +8,14 @@ - **持久化演示夹具**:在 `.local/demo/fixture` 下初始化真实的本地 Git 仓库测试夹具,并包含初始提交与分支。 - **进程内 Cloud 服务**:拉起基于真实 PostgreSQL 数据库的临时 `httptest.Server`,提供完整的 Gin 路由服务。 +- **Git Server 替身**:在 `.local/demo/git` 下按 Git Server 卷布局保存 Project 仓库,并代替外部线把夹具提交登记为演示 Project 的快照(`SeedSnapshot`);外部仓库 URL 从不访问。 - **Substrate 模拟**:在 `.local/demo/substrate` 下启动本地 HTTP 服务,提供 Substrate 存储和 Effect 模拟。 - **临时密钥材料**:在内存中为 `gateway`、`controller`、`node` 和 `user` 四种角色生成 Ed25519 密钥对,以便在不依赖外部 IdP 基础设施的情况下签发和验证短期 JWT token。 - **Controller 驱动执行主循环**: 1. 引导配置演示租户和用户。 2. 通过 `/internal/v1/controller-lease/acquire` 获取 Controller 独占租约。 3. 通过公开 API 发起项目创建请求(`POST /api/v1/tenants/{tid}/projects`)。 - 4. 模拟 Controller 清空队列:执行 Effect 计划(调度沙箱、注册节点,并通过 loopback gRPC `ExecutionService` 把仓库 clone 到 Workspace 数据中)。 + 4. 模拟 Controller 清空队列:执行 Effect 计划(调度沙箱、注册节点,并通过 loopback gRPC `ExecutionService` 登记 Cloud 冻结的 clone 输入,把快照从 Git Server 替身 clone 到 Workspace 数据中)。 5. 验证 Workspace 成功达到 `ready` 就绪状态,并通过公开 API 查询校验。 6. 正常释放 Controller 租约。 7. 向 `stdout` 输出 JSON 摘要。 @@ -26,4 +27,4 @@ 参见 [cmd 入口总览](../README.md)、[模拟器内部实现](../../internal/simulator/README.md) 与 [Workspace 生命周期](../../docs/runtime/workspaces.md)。 -进程内模拟器显式使用仅供开发的旧 clone 测试夹具;插件选择因缺少真实执行器保留持久 pending(待执行)。它不证明运行时独占、管理认证或工作负载隔离;这些保障须通过 cluster Compose 和真实验收脚本验证。 +进程内模拟器的 Node 只从 Git Server 替身取快照;插件选择因缺少真实执行器保留持久 pending(待执行)。它不证明运行时独占、管理认证或工作负载隔离;这些保障须通过 cluster Compose 和真实验收脚本验证。 diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 28ab46992..25c8d7e3c 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -51,7 +51,7 @@ func run() error { if e != nil { return e } - store, e := core.NewDevelopmentStore(db) + store, e := core.NewStore(db) if e != nil { return e } @@ -95,7 +95,14 @@ func run() error { gin.SetMode(gin.ReleaseMode) cloud := httptest.NewServer(router.NewDevelopment(store, auth, zap.NewNop())) defer cloud.Close() - substrate, e := simulator.NewSubstrate(filepath.Join(absolute, "substrate"), map[string]string{"https://example.invalid/repo.git": repo}) + // The Git Server double holds the Project repositories; Workspaces clone its snapshots and + // deliveries push to it. The external repository URL below is never contacted. + gitServer, e := simulator.NewGitServer(filepath.Join(absolute, "git")) + if e != nil { + return e + } + store.GitServer = gitServer.Config() + substrate, e := simulator.NewSubstrate(filepath.Join(absolute, "substrate"), gitServer) if e != nil { return e } @@ -125,7 +132,7 @@ func run() error { return e } defer func() { _ = conn.Close() }() - controller := &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: controlpb.NewExecutionServiceClient(conn)} + controller := &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: controlpb.NewExecutionServiceClient(conn), Git: gitServer} controller.AgentRuns = controlpb.NewAgentRunServiceClient(conn) controller.AgentNode, e = simulator.NewAgentNode(filepath.Join(*root, "agent-node")) if e != nil { @@ -140,6 +147,10 @@ func run() error { if e != nil || status != 202 { return fmt.Errorf("create: %d %v %v", status, e, created) } + // Stand in for the external line's first sync so the main Workspace has a snapshot to clone. + if _, e := gitServer.SeedSnapshot(ctx, store.Pool, created.O("resource").S("id"), repo); e != nil { + return e + } if e := controller.Drain(ctx); e != nil { return e } diff --git a/configs/config.yaml b/configs/config.yaml index 9f9691b5b..f6c2e7d04 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -132,6 +132,22 @@ object_store: secret_access_key_file: "" upload_grant_ttl: 15m +git_server: + # The Git Server holding every Project repository (Revision D2). Workspaces clone Binding + # snapshots from it and Revisions are delivered to it; Cloud writes `repository_base_url` into + # clone, session and delivery inputs and verifies refs through the ref query listener with its own + # client certificate. Leaving `repository_base_url` empty runs without a Git Server: clone steps + # block as git_server_unconfigured and deliveries are skipped. The key is a file reference, never a + # value here. + # Overrides: CLOUD_GIT_SERVER_REPOSITORY_BASE_URL / _REF_QUERY_URL / _CA_FILE / _CLIENT_CERT_FILE / + # _CLIENT_KEY_FILE / _REF_QUERY_TIMEOUT. + repository_base_url: "" + ref_query_url: "" + ca_file: "" + client_cert_file: "" + client_key_file: "" + ref_query_timeout: 10s + repository: # The external Git line (cloud/repo-executor, identity-access Credential Store, cloud/project sync # and publish). Disabled, Projects are created but no sync runs and Git credentials cannot be diff --git a/docs/agent-runs/issue-runs.en.md b/docs/agent-runs/issue-runs.en.md index 6536eaeef..72a55f673 100644 --- a/docs/agent-runs/issue-runs.en.md +++ b/docs/agent-runs/issue-runs.en.md @@ -100,7 +100,7 @@ written together with the phase on entering `releasing`, and accompanied by a ti Whether the output was saved is stated by `result.deliveryState`: `saved` (a Revision with changes was registered), `unchanged` (a Revision without changes was registered), `failed` (delivery was given up), -`skipped` (the deployment has no object storage, or the run was cancelled before any session). `failed` +`skipped` (the deployment has no object storage or no Git Server, or the run was cancelled before any session). `failed` and `cancelled` runs still try to save their output and can have a Revision too. On the timeline an Agent run has one `run.enqueued` activity, and on entering `releasing` a diff --git a/docs/agent-runs/issue-runs.md b/docs/agent-runs/issue-runs.md index 399958162..2e96044f0 100644 --- a/docs/agent-runs/issue-runs.md +++ b/docs/agent-runs/issue-runs.md @@ -77,7 +77,7 @@ Cloud 在接管上一阶段证据的事务里写入。 | `agent_failed`(Agent 失败)、`interrupted`(Node 中断或结果未知) | `failed` | 成果是否保存由 `result.deliveryState` 表达:`saved`(登记了有改动的 Revision)、`unchanged`(登记了没有改动的 Revision)、 -`failed`(交付被放弃)、`skipped`(部署没有对象存储,或会话开始前就被取消)。`failed` 和 `cancelled` 的运行同样会尽量保存成果, +`failed`(交付被放弃)、`skipped`(部署没有对象存储或 Git Server,或会话开始前就被取消)。`failed` 和 `cancelled` 的运行同样会尽量保存成果, 也可以有 Revision。 时间线上,Agent 运行有一条 `run.enqueued` 活动,进入 `releasing` 时再追加 `run.completed`、`run.failed` 或 `run.cancelled`, diff --git a/docs/agent-runs/revisions.en.md b/docs/agent-runs/revisions.en.md index 474c0b416..4b3eeebd5 100644 --- a/docs/agent-runs/revisions.en.md +++ b/docs/agent-runs/revisions.en.md @@ -3,62 +3,90 @@ English | [中文](revisions.md) A Revision is what an Agent run saves: the final commit (after the Agent's own commits the Node may add -an internal snapshot commit for remaining uncommitted changes), an incremental bundle relative to this -run's clone base commit, and the session JSONL. A Revision is registered even when nothing changed — it -simply has no bundle. +an internal snapshot commit for remaining uncommitted changes), the **delivery ref** the Node gets by pushing +that commit to the Project repository, and the session JSONL. Code is delivered only through the +[Git Server](../repositories/git-server.en.md); the session JSONL lives in object storage. A Revision is +registered even when nothing changed, and it pushes and registers its own delivery ref just the same. -A Revision is registered only after Cloud itself has checked the objects in object storage, and the run +A Revision is registered only after Cloud itself has checked the delivery ref and the session JSONL, and the run Workspace is released only after the Revision is registered (or delivery was explicitly given up). The run lifecycle is in [Agent IssueRuns](issue-runs.en.md). -## Object storage +The Git delivery and resume described here implement `approved` decisions (see Related decisions at the end). -Cloud owns the object storage configuration, issues scoped upload and download grants, and verifies objects -before registering them; the Node only ever receives one-shot grants and never holds storage credentials. +## Deployment dependencies + +A delivery needs both object storage (the session JSONL) and the Git Server (the code). When either is not +configured, the delivery settles as `skipped` at declaration and the run goes straight to release; the reason names +object storage first (`object_store_unconfigured`), then the Git Server (`git_server_unconfigured`). `GET /healthz` +reports each of them as `configured` or `unconfigured`; this does not mean they are online right now and reveals +no address or credential. + +### Object storage + +Cloud owns the object storage configuration, issues scoped upload grants, and verifies objects before registering +them; the Node only ever receives one-shot grants and never holds storage credentials. | Setting (`object_store.*`) | Description | |---|---| -| `enabled` | When off, no grant is issued; deliveries settle as `skipped` at declaration and the run goes straight to release | +| `enabled` | When off, no grant is issued | | `endpoint` | The private address Cloud uses to reach storage, for verification and reads | -| `public_endpoint` | Optional; the address signed into upload and download grants when Nodes reach storage at a different address than Cloud | +| `public_endpoint` | Optional; the address signed into upload grants when Nodes reach storage at a different address than Cloud | | `region`, `bucket`, `path_style` | Parameters of the S3-compatible store; MinIO and RustFS need `path_style: true` | | `access_key_id_file`, `secret_access_key_file` | Credential **file** paths; read into process memory at startup and never present in configuration, environment variables or logs | | `upload_grant_ttl` | Grant lifetime, 15 minutes by default, between 1 second and 7 days | At startup Cloud signs one grant against each address as a configuration self-check, without any network -request; a misconfiguration stops the server from starting. `GET /healthz` reports object storage as -`configured` or `unconfigured`; this does not mean the store is online right now and reveals no address or -credential. Signing uses a built-in AWS SigV4 implementation (`internal/objectstore`) with no extra -dependency. +request; a misconfiguration stops the server from starting. Signing uses a built-in AWS SigV4 implementation +(`internal/objectstore`) with no extra dependency. + +### Git Server + +Cloud's `git_server.*` settings give it the repository address sandboxes reach (written into clone, session and +delivery inputs), and it verifies delivery refs through the Git Server's ref query listener with its own client +certificate (settings in the [configuration reference](../operations/configuration.en.md#cloud-server), ref queries +in [Git Server](../repositories/git-server.en.md#ref-queries)). ## The delivery flow ```text -session terminal ──▶ Cloud declares a delivery work item (fixed input and object keys) +session terminal ──▶ Cloud declares a delivery work item (fixed input: JSONL key, repository, delivery ref, Workspace branch) + │ +Controller registers the dispatch ──▶ Node creates the internal snapshot commit and the session JSONL │ -Controller registers the dispatch ──▶ Node creates the internal snapshot, bundle and session JSONL +Node pushes the final commit to the delivery ref ──▶ the Git Server authorizes the push from current state │ -Controller ──GrantRevisionUpload──▶ Cloud issues upload grants (held in memory only) +Controller ──GrantRevisionUpload──▶ Cloud issues the JSONL upload grant (held in memory only) │ -Node uploads the objects ──▶ reports a terminal: revision_delivered / revision_unchanged / revision_failed +Node uploads the JSONL ──▶ reports a terminal: revision_delivered / revision_unchanged / revision_failed │ -Cloud: verify objects outside a transaction ──▶ inside one: re-check, record the verdict, register, advance the run +Cloud: outside a transaction, check the delivery ref by ref query and the JSONL by HEAD ──▶ inside one: re-check, record the verdict, register, advance the run ``` **1. Declaration.** When the session execution has a terminal state the run enters `delivering`, and Cloud declares a `deliver_revision` work item in the same transaction. Its input is fixed: the base commit (equal to -the run Workspace's `base_commit_id`), the identities of the session and checkout executions, the prior -Revision (copied from the session input), and three locations decided by the **server**: +the run Workspace's `base_commit_id`), the identities of the session and checkout executions, the prior Revision +(copied from the session input, with only its ID and final commit), and locations decided by the **server**: -- the bundle object key `revisions/{tenant}/{run}/{work}/revision.bundle`; - the session JSONL object key `revisions/{tenant}/{run}/{work}/session.jsonl`; -- the Revision ref `refs/ora/revisions/{run}`. +- the repository on the Git Server, `gitRepositoryUrl` (`git_server.repository_base_url` plus `/{tenant}/{project}.git`); +- this attempt's own delivery ref `refs/ora/deliveries/{work}`; +- the run Workspace's branch on the Git Server, `workspaceRef`, taken from the input of the clone execution that made + the checkout; +- the local ref `refs/ora/revisions/{run}` the Node writes in its checkout, which never appears on the Git Server. + +The object key and the delivery ref both include the work item ID, so every retry is a new work item with a new key +and a new delivery ref, and no caller can name an object or ref another attempt already wrote. -Object keys include the work item ID, so every retry is a new work item with a new set of keys, and no caller -can name an object another attempt already wrote. +**2. Push.** The Node pushes the final commit to the delivery ref and may also fast-forward `workspaceRef` to it. The +Git Server lets it through only while the run is `delivering` and the delivery execution is live; the delivery ref +can only be created, and `workspaceRef` can only be created or fast-forwarded and must be a branch of the Workspace the +certificate names (see [Git Server](../repositories/git-server.en.md#requests-and-authorization)). Cloud verifies the +delivery ref only, not `workspaceRef`. -**2. Upload grants.** The Controller calls `GrantRevisionUpload` to obtain grants for the delivery execution, -optionally submitting each object's SHA-256. Each grant covers one object key, one method and one time window: +**3. Upload grant.** The Controller calls `GrantRevisionUpload` to obtain the session JSONL grant for the delivery +execution, optionally submitting its SHA-256; a digest may only name the JSONL key the input fixed, and any other key +is `400 invalid_upload_checksum`. The grant covers one object key, one method and one time window: - every `PUT` is signed with `If-None-Match: *`, so the store only allows the first creation, and even a grant still within its lifetime cannot overwrite an object that was already verified; @@ -66,70 +94,94 @@ optionally submitting each object's SHA-256. Each grant covers one object key, o an upload whose content does not match; - a grant is a short-lived bearer credential, **not** an execution input: it only travels in memory and between processes and is never written to the database, files, environment variables, command arguments or logs. - Refreshing a grant never changes the fixed input or object keys. - -**3. The result.** The Node reports one of three terminals: `revision_delivered` (changes, with bundle and JSONL), -`revision_unchanged` (no changes, JSONL only) and `revision_failed` (with a reason from a closed set: -`session_not_settled`, `checkout_unavailable`, `snapshot_failed`, `bundle_failed`, `history_unavailable`, -`upload_failed`). - -**4. Verification and registration.** A successful declaration is handled in two steps: - -- **Outside a transaction**: a short transaction first checks the service identity, lease, execution identity and - object shapes, and that the object keys, base commit and Revision ref equal the frozen input; then every object - is checked with a `HEAD` against the private address, comparing its size and the SHA-256 the store kept. A store - that returns no digest (because it never received a checksum) also fails verification. A network failure - returns a retryable error, the Node event is not acknowledged, and it can be replayed later. + Refreshing a grant never changes the fixed input or object key. + +**4. The result.** The Node reports one of three terminals: + +- `revision_delivered`: the final commit equals neither the base commit nor the prior Revision's final commit; +- `revision_unchanged`: the final commit equals the base commit, or the final commit of the resumed prior Revision; +- `revision_failed`: with a reason from the closed set `session_not_settled`, `checkout_unavailable`, + `snapshot_failed`, `history_unavailable`, `upload_failed`, `push_rejected`, `push_failed`. The former + `bundle_failed` (value 4) is `reserved` in the wire enum; a result carrying it is refused like any reason outside + the closed set. + +A declared result that does not match whether the run changed anything, or that still carries a bundle, is +`409 result_conflict`. + +**5. Verification and registration.** A successful declaration is handled in two steps: + +- **Outside a transaction**: a short transaction first checks the service identity, lease, execution identity and the + JSONL's shape, and that the JSONL key, base commit and local Revision ref equal the frozen input; then the Git + Server's ref query reads the commit the delivery ref names, which must equal the declared final commit; then the + JSONL is checked with a `HEAD` against the private address, comparing its size and the SHA-256 the store kept. A + missing delivery ref, one naming another commit, a mismatched JSONL or a store that returns no digest (because it + never received a checksum) all fail verification. An unreachable Git Server or store returns a retryable `503` + (`git_server_unavailable`, `object_store_unavailable`): nothing is taken over, the Node event is not acknowledged, + and it can be replayed later. A delivery ref can only be created, never moved or deleted, so the commit seen here is + the one it names from registration on. - **Inside a transaction**: Cloud checks again that the run has not ended, the run Workspace is still ready, runtime control still belongs to this run, and the current Node and sandbox are still the delivery's target; it - then writes the verification verdict (`revision_verifications`), registers the `revisions` row when verification - passed, and calls the business hook that moves the run to `releasing`. The original Node result, receipt, - verdict, Revision and business transition commit in one transaction. + then writes the verification verdict (`revision_verifications`), registers the `revisions` row with its + `delivery_ref` when verification passed, and calls the business hook that moves the run to `releasing`. The + original Node result, receipt, verdict, Revision and business transition commit in one transaction. When verification fails, Cloud records its own verdict `verification_failed`, keeps the Node's original declaration, registers no Revision, and retries delivery with backoff; only Cloud can write this reason, and a -Node cannot claim it. Replaying an execution that was already settled never contacts storage again. Delivery retry -and give-up limits are in [Agent IssueRuns](issue-runs.en.md#limits). +Node cannot claim it. Replaying an execution that was already settled never contacts the Git Server or storage +again. Delivery retry and give-up limits are in [Agent IssueRuns](issue-runs.en.md#limits). + +Each delivery ref belongs to at most one Revision (the partial unique index `revisions_delivery_ref`). Bundle-shaped +Revisions registered before the switch to Git are kept as they are, and a database constraint keeps the two shapes +mutually exclusive (migration `0054`). ## Resuming a prior Revision When an Agent is `@`-mentioned again on the same Issue, the new run still gets a brand-new run Workspace, but -before the Agent starts the Node restores the output of the Issue's latest Revision into the checkout, so the Agent -continues from where the last run left off. +before the Agent starts the Node fetches the delivery ref of the Issue's latest Revision from the Git Server and +restores it into the checkout, so the Agent continues from where the last run left off. - **Selection happens once at session start and is fixed**: the candidates are Revisions registered by **other** - runs of the same tenant and Issue that carry a bundle (their own or one reused from a prior Revision), and the - most recently registered one wins. A candidate whose Project or repository URL differs from this run is not - resumed (`project_changed`); a candidate previously marked as not resumable is not resumed either - (`resume_refused`); there is no fallback to an older candidate. The chosen Revision is written into the session - input's `priorRevision` and the run's `resumeRevisionId` and never changes afterwards. -- **Download grant**: the session execution obtains a read-only `GET` grant for the prior bundle through - `GrantRevisionDownload`, for exactly the object key the session input fixed. + runs of the same tenant and Issue that have a delivery ref and a final commit different from their base commit, + and the most recently registered one wins. A Revision that only holds its base commit would take the new checkout + back to older code, and a bundle Revision from before the switch to Git has no ref to fetch; neither is a + candidate. A candidate whose Project or external repository URL differs from this run is not resumed + (`project_changed`); a candidate previously marked as not resumable is not resumed (`resume_refused`); a candidate + whose Git Server repository is not the one this checkout was cloned from, or any candidate when no Git Server is + configured, is not resumed (`repository_changed`); there is no fallback to an older candidate. The chosen Revision + is written into the session input's `priorRevision` (`revisionId`, `finalCommit`, `baseCommit`, + `gitRepositoryUrl`, `deliveryRef`) and the run's `resumeRevisionId` and never changes afterwards. +- **Reads are authorized by the Git Server**: a live Agent session can read the whole Project repository, so fetching + the delivery ref needs no extra grant; object storage issues no download grant for resuming any more. - **Failing to restore fails the run**: if restoring is impossible the session ends with `agent_failed`, the run - fails and the Agent does not start, but delivery still happens. Only the permanent failure "the remote history - was rewritten and the base commit can no longer be fetched" (`prior_revision_base_unavailable`) marks that - Revision as not resumable, so the next run starts from scratch. -- **Only the latest one is needed**: a delivery bundle is always made relative to this run's clone base commit, - and resumed commits build on the prior final commit, so the new bundle naturally contains every commit not yet in - the remote. A resumed run whose final commit equals the prior one (no new changes) is recorded as `unchanged` and - reuses the prior bundle. + fails and the Agent does not start, but delivery still happens. Only the permanent failure "the delivery ref cannot + be fetched" (`prior_revision_base_unavailable`) marks that Revision as not resumable, so the next run starts from + scratch; a run that already asked to cancel is exempt, because the Git Server refuses a cancelled run's reads with + the same answer it gives for a missing ref, so the failure says nothing about the Revision. +- **Only the latest one is needed**: resumed commits build on the prior final commit, so the new delivery ref + naturally contains every earlier commit. A resumed run whose final commit equals the prior one (no new changes) is + recorded as `unchanged`; it still has its own delivery ref and reuses nothing from the prior. - **Runs on one Issue are assumed to be serial**, and Cloud does not enforce it: two overlapping runs may pick the same prior Revision, and the next run resumes only the one registered later. -## Public reads +The session input also carries a `workspaceDirectory`: up to 100 of the Project's Workspaces as of session start +(newest first; run Workspaces stay listed after deletion because their branches remain on the Git Server), each with +its Workspace ID and kind (`main`, `isolated`, `run`), plus the Issue, run and run status for run Workspaces. A +WorkflowRun's Workspace is not listed: its sessions have no Git Server access and the directory has no fields for it. +The directory is a reference for the Agent in the session only, never refreshed and never used for authorization. -The run resource's `revision` field projects the registered Revision's metadata: `id`, `baseCommit`, `finalCommit`, -`changed` (whether this run stored a bundle of its own), `bundleSize`, `historySize`, `priorRevisionId` and -`createdAt`. It is `null` for a run that registered no Revision; `result.deliveryState` says why. Object keys, the -Revision ref and digests are not public, and the public API offers no bundle download. The session JSONL archive can -be read as described in [Session reads](session-reads.en.md). +## Public reads -The proposal to receive deliveries through the Git Server and register Revisions from delivery refs is still under -decision; see [Git Server](../repositories/git-server.en.md#ref-queries). +The run resource's `revision` field projects the registered Revision's metadata (fields are defined by +[`api/openapi.json`](../../api/openapi.json)). `changed` is Cloud's own verification verdict: `true` when the run +delivered new commits, `false` when it ended on its base commit or on the prior's final commit; for a Revision +registered before the switch to Git it means "this run stored a bundle of its own". `bundleSize` is `null` for a +Revision delivered over Git. The field is `null` for a run that registered no Revision; `result.deliveryState` says +why. Object keys, the delivery ref, the Revision ref and digests are not public, and the public API offers no code +download. The session JSONL archive can be read as described in [Session reads](session-reads.en.md). ## Related decisions - [Cloud owns the object storage configuration and issues scoped upload grants; a Revision is registered only after Cloud verifies the objects](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/0-cloud-owned-object-store-and-verified-revisions.md) - [A new run on the same Issue first resumes its latest Revision, then starts the Agent](https://github.com/ora-space/specs/blob/main/decisions/cloud/issue-run/20261010-resume-run-from-latest-issue-revision.md) -- [Session and delivery inputs gain a prior Revision, plus `GrantRevisionDownload`](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261010-revision-restore-contract.md) -- [A Revision's code changes are delivered only through the Git Server](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261012-git-server-delivery-and-verified-delivery-refs.md) (proposed) +- [A Revision's code changes are delivered only through the Git Server: Cloud registers the delivery ref after a ref query, and resuming restores only from the prior's own delivery ref](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261017-git-native-delivery-refs-and-resume.md) (approved; it supersedes the 20261012 Git Server delivery decision) +- [The clone input becomes a Git Server snapshot input; the Git Server derives write rules from the execution kind; delivery and session inputs drop bundles and the download grant](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md) (approved) diff --git a/docs/agent-runs/revisions.md b/docs/agent-runs/revisions.md index 072e1530f..431f3736a 100644 --- a/docs/agent-runs/revisions.md +++ b/docs/agent-runs/revisions.md @@ -3,102 +3,146 @@ [English](revisions.en.md) | 中文 Revision 是一次 Agent 运行保存下来的成果:最终提交(Agent 自己的提交之后,Node 可能为剩余未提交的改动追加一个内部快照提交)、 -相对本次 clone 基础提交的增量 bundle,以及会话 JSONL。没有任何改动时也登记 Revision,只是没有 bundle。 +Node 把最终提交推送到 Project 仓库后得到的**交付 ref**,以及会话 JSONL。代码只经 [Git Server](../repositories/git-server.md) +交付,会话 JSONL 存在对象存储里。没有任何改动时也登记 Revision,同样推送并登记自己的交付 ref。 -Revision 只在 Cloud 亲自核对过对象存储里的对象之后才登记;运行 Workspace 只在 Revision 登记之后(或交付被明确放弃之后)才释放。 -运行生命周期见 [Agent IssueRun](issue-runs.md)。 +Revision 只在 Cloud 亲自核对过交付 ref 和会话 JSONL 之后才登记;运行 Workspace 只在 Revision 登记之后(或交付被明确放弃之后) +才释放。运行生命周期见 [Agent IssueRun](issue-runs.md)。 -## 对象存储 +本文描述的 Git 交付与续接实现的是已 `approved` 的决策(见文末相关决策)。 -Cloud 自己持有对象存储的配置,签发限定的上传和下载授权,并在登记前核验对象;Node 只拿到一次性的授权,从不持有存储凭据。 +## 依赖的部署组件 + +交付同时需要对象存储(会话 JSONL)和 Git Server(代码)。任一个没有配置时,交付在声明时就以 `skipped` 结算,运行直接进入释放: +原因先看对象存储(`object_store_unconfigured`),再看 Git Server(`git_server_unconfigured`)。`GET /healthz` 分别报告二者是 +`configured` 还是 `unconfigured`,不代表它们此刻在线,也不暴露地址或凭据。 + +### 对象存储 + +Cloud 自己持有对象存储的配置,签发限定的上传授权,并在登记前核验对象;Node 只拿到一次性的授权,从不持有存储凭据。 | 配置(`object_store.*`) | 说明 | |---|---| -| `enabled` | 关闭时不签发任何授权;交付在声明时就以 `skipped` 结算,运行直接进入释放 | +| `enabled` | 关闭时不签发任何授权 | | `endpoint` | Cloud 访问存储的私有地址,用于核验和读取 | -| `public_endpoint` | 可选;Node 与 Cloud 访问存储的地址不同时,签进上传和下载授权的地址 | +| `public_endpoint` | 可选;Node 与 Cloud 访问存储的地址不同时,签进上传授权的地址 | | `region`、`bucket`、`path_style` | S3 兼容存储的参数,MinIO、RustFS 一类需要 `path_style: true` | | `access_key_id_file`、`secret_access_key_file` | 凭据**文件**路径;启动时读入进程内存,不出现在配置、环境变量或日志里 | | `upload_grant_ttl` | 授权有效期,默认 15 分钟,必须在 1 秒到 7 天之间 | -启动时用两个地址各签一次授权做配置自检,不发网络请求;配置有误时 server 拒绝启动。`GET /healthz` 报告对象存储是 `configured` -还是 `unconfigured`,不代表存储此刻在线,也不暴露地址或凭据。签名使用自带的 AWS SigV4 实现(`internal/objectstore`),不引入 -额外依赖。 +启动时用两个地址各签一次授权做配置自检,不发网络请求;配置有误时 server 拒绝启动。签名使用自带的 AWS SigV4 实现 +(`internal/objectstore`),不引入额外依赖。 + +### Git Server + +Cloud 用 `git_server.*` 配置得到沙盒访问的仓库地址(写进 clone、会话和交付输入),并用自己的客户端证书经 Git Server 的 ref 查询 +监听核验交付 ref(配置见 [配置参考](../operations/configuration.md#cloud-server),ref 查询见 +[Git Server](../repositories/git-server.md#ref-查询))。 ## 交付流程 ```text -会话终态 ──▶ Cloud 声明交付工作项(固定输入与对象键) +会话终态 ──▶ Cloud 声明交付工作项(固定输入:JSONL 对象键、仓库地址、交付 ref、Workspace 分支) + │ +Controller 登记派发 ──▶ Node 生成内部快照提交与会话 JSONL │ -Controller 登记派发 ──▶ Node 生成内部快照、bundle 与会话 JSONL +Node 推送最终提交到交付 ref ──▶ Git Server 按当前状态授权这次推送 │ -Controller ──GrantRevisionUpload──▶ Cloud 签发上传授权(只在内存中流转) +Controller ──GrantRevisionUpload──▶ Cloud 签发 JSONL 的上传授权(只在内存中流转) │ -Node 上传对象 ──▶ 报告终态:revision_delivered / revision_unchanged / revision_failed +Node 上传 JSONL ──▶ 报告终态:revision_delivered / revision_unchanged / revision_failed │ -Cloud:事务外核验对象 ──▶ 事务内复核、记录核验结论、登记 Revision、推进运行 +Cloud:事务外经 ref 查询核验交付 ref、HEAD 核验 JSONL ──▶ 事务内复核、记录核验结论、登记 Revision、推进运行 ``` **1. 声明。** 会话执行有了终态,运行进入 `delivering`,Cloud 在同一事务里声明一个 `deliver_revision` 工作项。输入是固定的:基础 -提交(等于运行 Workspace 的 `base_commit_id`)、会话与 checkout 执行的身份、前序 Revision(从会话输入复制),以及三个由**服务端** -确定的位置: +提交(等于运行 Workspace 的 `base_commit_id`)、会话与 checkout 执行的身份、前序 Revision(从会话输入复制,只带 ID 与最终提交), +以及由**服务端**确定的位置: -- bundle 对象键 `revisions/{tenant}/{run}/{work}/revision.bundle`; - 会话 JSONL 对象键 `revisions/{tenant}/{run}/{work}/session.jsonl`; -- Revision ref `refs/ora/revisions/{run}`。 +- Git Server 上的仓库地址 `gitRepositoryUrl`(`git_server.repository_base_url` 加 `/{tenant}/{project}.git`); +- 本次尝试专属的交付 ref `refs/ora/deliveries/{work}`; +- 运行 Workspace 在 Git Server 上的分支 `workspaceRef`,取自做出这个 checkout 的 clone 执行的输入; +- Node 在 checkout 里写的本地 ref `refs/ora/revisions/{run}`,它不出现在 Git Server 上。 -对象键包含工作项 ID,所以每次重试都是一个新的工作项、一组新的对象键,没有调用方能够指定另一次尝试已经写过的对象。 +对象键和交付 ref 都包含工作项 ID,所以每次重试都是一个新的工作项、一个新的对象键和一个新的交付 ref,没有调用方能够指定另一次 +尝试已经写过的对象或 ref。 -**2. 上传授权。** Controller 调用 `GrantRevisionUpload` 为交付执行取得授权,可以同时提交每个对象的 SHA-256。每个授权只覆盖一个对象键、 -一个方法、一段时限: +**2. 推送。** Node 把最终提交推送到交付 ref,可以顺带把它快进到 `workspaceRef`。Git Server 只在运行处于 `delivering`、交付执行 +仍然有效时放行,交付 ref 只能新建,`workspaceRef` 只能新建或快进,而且必须是证书所属 Workspace 的分支(见 +[Git Server](../repositories/git-server.md#请求与授权))。Cloud 只核验交付 ref,不核验 `workspaceRef`。 + +**3. 上传授权。** Controller 调用 `GrantRevisionUpload` 为交付执行取得会话 JSONL 的授权,可以同时提交它的 SHA-256;提交的摘要 +只能针对输入固定的 JSONL 对象键,其他键返回 `400 invalid_upload_checksum`。授权只覆盖一个对象键、一个方法、一段时限: - 每个 `PUT` 都签入 `If-None-Match: *`,存储侧只允许首次创建,仍在有效期内的授权也不能覆盖已经验证过的对象; - 提交了摘要时,摘要以 `x-amz-checksum-sha256` 签进请求,内容不符时存储拒绝上传; - 授权是短期的持有者凭据,**不是**执行输入:它只在内存和进程间流转,不写数据库、文件、环境变量、命令参数或日志。刷新授权不会改变 固定的输入或对象键。 -**3. 结果。** Node 报告的终态是三者之一:`revision_delivered`(有改动,带 bundle 与 JSONL)、`revision_unchanged`(没有改动,只有 JSONL)、 -`revision_failed`(失败原因属于封闭集合:`session_not_settled`、`checkout_unavailable`、`snapshot_failed`、`bundle_failed`、 -`history_unavailable`、`upload_failed`)。 +**4. 结果。** Node 报告的终态是三者之一: + +- `revision_delivered`:最终提交既不等于基础提交,也不等于前序 Revision 的最终提交; +- `revision_unchanged`:最终提交等于基础提交,或者等于续接的前序 Revision 的最终提交; +- `revision_failed`:失败原因属于封闭集合 `session_not_settled`、`checkout_unavailable`、`snapshot_failed`、`history_unavailable`、 + `upload_failed`、`push_rejected`、`push_failed`。原来的 `bundle_failed`(值 4)在线上枚举里是 `reserved`,带这个值的结果按封闭集合之外的原因拒绝。 + +声明的结果与改动情况不符,或者还带着 bundle,返回 `409 result_conflict`。 -**4. 核验与登记。** 成功的声明分两步处理: +**5. 核验与登记。** 成功的声明分两步处理: -- **事务外**:先在短事务里核对服务身份、租约、执行身份、对象形状,以及对象键、基础提交、Revision ref 与冻结输入一致;然后对每个对象 - 向私有地址发 `HEAD`,比对大小和存储保存的 SHA-256。存储没有返回摘要(它从没收到过校验和)同样算核验失败。网络故障返回可重试的 - 错误,不确认 Node 事件,之后可以重放。 +- **事务外**:先在短事务里核对服务身份、租约、执行身份、JSONL 的形状,以及 JSONL 对象键、基础提交、本地 Revision ref 与冻结输入 + 一致;然后经 Git Server 的 ref 查询读出交付 ref 指向的提交,它必须等于声明的最终提交;再对 JSONL 向私有地址发 `HEAD`,比对大小 + 和存储保存的 SHA-256。交付 ref 不存在、指向其他提交、JSONL 不符或存储没有返回摘要(它从没收到过校验和),都算核验失败。 + Git Server 或对象存储不可达返回可重试的 `503`(`git_server_unavailable`、`object_store_unavailable`),不接管、不确认 Node 事件, + 之后可以重放。交付 ref 只能新建、不能移动或删除,所以核验时看到的提交就是登记之后它一直指向的提交。 - **事务内**:再次检查运行没有结束、运行 Workspace 仍就绪、运行时控制仍属于这个运行、当前 Node 与沙盒仍是交付的目标;然后写入核验 - 结论(`revision_verifications`),核验通过时登记 `revisions` 行,再调用业务钩子把运行推进到 `releasing`。原始的 Node 结果、收据、 - 核验结论、Revision 和业务转换在同一个事务里提交。 + 结论(`revision_verifications`),核验通过时登记 `revisions` 行并记下 `delivery_ref`,再调用业务钩子把运行推进到 `releasing`。 + 原始的 Node 结果、收据、核验结论、Revision 和业务转换在同一个事务里提交。 核验不通过时,Cloud 记录自己的结论 `verification_failed`,保留 Node 的原始声明,不登记 Revision,交付按退避重试;这个原因只能由 Cloud -写出,Node 无法声称它。已经结算过的执行再重放时,不会重新访问存储。交付的重试与放弃上限见 [Agent IssueRun](issue-runs.md#上限)。 +写出,Node 无法声称它。已经结算过的执行再重放时,不会重新访问 Git Server 或存储。交付的重试与放弃上限见 +[Agent IssueRun](issue-runs.md#上限)。 + +每个交付 ref 至多属于一个 Revision(部分唯一索引 `revisions_delivery_ref`)。改走 Git 之前登记的 bundle 形状的 Revision 原样保留, +数据库约束让两种形状互斥(迁移 `0054`)。 ## 续接前序 Revision -同一个 Issue 上再次 @Agent 时,新运行仍然得到全新的运行 Workspace,但在 Agent 启动之前,Node 先把该 Issue 最近一个 Revision 的成果 -恢复进 checkout,Agent 因此接着上一次的成果工作。 - -- **选择在会话启动时一次完成并固定**:候选是同一租户、同一 Issue 的**其他**运行登记的、带 bundle(自己存了或沿用了前序的)的 Revision, - 按登记时间取最新的一个。候选的 Project 或仓库地址与本次运行不同时不续接(`project_changed`);候选曾被标记为不可续接时也不续接 - (`resume_refused`);不回退到更早的候选。选中的 Revision 写进会话输入的 `priorRevision` 和运行的 `resumeRevisionId`,之后不再改变。 -- **下载授权**:会话执行用 `GrantRevisionDownload` 取得前序 bundle 的只读 `GET` 授权,对象键就是会话输入固定的那个。 -- **恢复失败就失败**:恢复不了时会话以 `agent_failed` 结束,运行失败、不启动 Agent,但照常经过交付。只有"远端历史被改写、基础提交再也 - 取不到"(`prior_revision_base_unavailable`)这种永久失败会把该 Revision 标为不可续接,下一次运行从头开始。 -- **只需要最近一个**:交付的 bundle 始终相对本次 clone 的基础提交生成,续接后的提交建立在前序最终提交之上,所以新 bundle 自然包含此前 - 所有尚未进入远端的提交。续接运行的最终提交与前序相同(没有新改动)时记为 `unchanged`,并沿用前序的 bundle。 +同一个 Issue 上再次 @Agent 时,新运行仍然得到全新的运行 Workspace,但在 Agent 启动之前,Node 先从 Git Server 取回该 Issue 最近 +一个 Revision 的交付 ref,恢复进 checkout,Agent 因此接着上一次的成果工作。 + +- **选择在会话启动时一次完成并固定**:候选是同一租户、同一 Issue 的**其他**运行登记的、带交付 ref 且最终提交不同于基础提交的 + Revision,按登记时间取最新的一个。只停在基础提交上的 Revision 会把新 checkout 带回旧代码,改走 Git 之前的 bundle Revision 没有 + 可取的 ref,二者都不是候选。候选的 Project 或外部仓库地址与本次运行不同时不续接(`project_changed`);候选曾被标记为不可续接时 + 不续接(`resume_refused`);本次 checkout 不是从候选所在的 Git Server 仓库 clone 的、或者没有配置 Git Server 时不续接 + (`repository_changed`);不回退到更早的候选。选中的 Revision 写进会话输入的 `priorRevision` + (`revisionId`、`finalCommit`、`baseCommit`、`gitRepositoryUrl`、`deliveryRef`)和运行的 `resumeRevisionId`,之后不再改变。 +- **读取经 Git Server 授权**:存活的 Agent 会话可以读取整个 Project 仓库,取回交付 ref 不需要额外的授权;对象存储不再为续接签发 + 任何下载授权。 +- **恢复失败就失败**:恢复不了时会话以 `agent_failed` 结束,运行失败、不启动 Agent,但照常经过交付。只有交付 ref 取不回来 + (`prior_revision_base_unavailable`)这种永久失败会把该 Revision 标为不可续接,下一次运行从头开始;运行已经请求取消时例外, + 因为 Git Server 对已取消运行的读取给出的拒绝与 ref 不存在相同,这次失败不能说明 Revision 本身有问题。 +- **只需要最近一个**:续接后的提交建立在前序最终提交之上,新的交付 ref 自然包含此前所有的提交。续接运行的最终提交与前序相同 + (没有新改动)时记为 `unchanged`,它同样有自己的交付 ref,不沿用前序的任何东西。 - **同一 Issue 的运行假定是串行的**,Cloud 不强制:两个重叠的运行可能选中同一个前序 Revision,下一次运行只会续接其中登记较晚的那个。 -## 公开读取 +会话输入另带一份 `workspaceDirectory`:会话开始时该 Project 最多 100 个 Workspace(最新在前,运行 Workspace 删除后仍列出,因为它们 +的分支还在 Git Server 上),每项带 Workspace ID、类型(`main`、`isolated`、`run`),运行 Workspace 另带 Issue、运行与运行状态。 +WorkflowRun 的 Workspace 不列入:它的会话没有 Git Server 访问,目录里也没有它的字段。目录只供会话里的 Agent 参考,不刷新,也从不用于 +授权。 -运行资源的 `revision` 字段投影已登记 Revision 的元数据:`id`、`baseCommit`、`finalCommit`、`changed`(本次是否存了自己的 bundle)、 -`bundleSize`、`historySize`、`priorRevisionId`、`createdAt`。没有登记 Revision 的运行该字段为 `null`,原因看 `result.deliveryState`。 -对象键、Revision ref 和摘要都不公开,公开 API 也不提供 bundle 下载。会话 JSONL 归档可以按 [会话读取](session-reads.md) 读取。 +## 公开读取 -由 Git Server 接收交付、以交付 ref 作为登记依据的方案仍在决策中,见 [Git Server](../repositories/git-server.md#ref-查询)。 +运行资源的 `revision` 字段投影已登记 Revision 的元数据(字段以 [`api/openapi.json`](../../api/openapi.json) 为准)。`changed` 是 Cloud +自己的核验结论:本次运行交付了新的提交时为 `true`,停在基础提交或前序最终提交上时为 `false`;对改走 Git 之前登记的 Revision,它表示 +"本次存了自己的 bundle"。经 Git 交付的 Revision 的 `bundleSize` 为 `null`。没有登记 Revision 的运行该字段为 `null`,原因看 +`result.deliveryState`。对象键、交付 ref、Revision ref 和摘要都不公开,公开 API 也不提供代码下载。会话 JSONL 归档可以按 +[会话读取](session-reads.md) 读取。 ## 相关决策 - [Cloud 持有对象存储配置并签发限定上传授权,Revision 在 Cloud 校验对象后才登记](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/0-cloud-owned-object-store-and-verified-revisions.md) - [同一 Issue 的新运行先续接最近一次 Revision,再启动 Agent](https://github.com/ora-space/specs/blob/main/decisions/cloud/issue-run/20261010-resume-run-from-latest-issue-revision.md) -- [会话与交付执行输入增加前序 Revision,新增 `GrantRevisionDownload`](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261010-revision-restore-contract.md) -- [Revision 的代码改动只经 Git Server 交付](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261012-git-server-delivery-and-verified-delivery-refs.md)(proposed) +- [Revision 的代码改动只经 Git Server 交付:Cloud 经 ref 查询核验后登记交付 ref,续作只从前序自己的交付 ref 恢复](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261017-git-native-delivery-refs-and-resume.md)(approved;它取代 20261012 的 Git Server 交付决策) +- [clone 输入替换为 Git Server 快照输入;Git Server 按执行类型给出写入规则;交付与会话输入去掉 bundle,删除下载授权](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md)(approved) diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 1be0ebdf2..615624a5a 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -36,6 +36,8 @@ PostgreSQL ▼ │ drives the Sandbox Server to create and delete sandboxes and holds sessions with the Nodes inside them. - Sandboxes can reach only model-gateway, git-server's repository listener and the object storage public address; they cannot reach Cloud, the Gateway or the database directly. +- The Cloud server reaches only git-server's ref query listener, with its own client certificate, to check a delivery + ref before registering a Revision (see [Git Server](repositories/git-server.en.md#ref-queries)). - External Git is reached only by the Repo Executor; a token submitted by the browser goes only to the Credential Store, and Cloud reads credential metadata and issues short-lived, single-use authorizations without ever seeing plaintext. @@ -49,7 +51,7 @@ PostgreSQL ▼ │ | Credential Store (`cmd/credential-store`) | This repository | Independent custody of external Git tokens: browser submission, executor redemption, Cloud management and revocation; ciphertext only | | git-credential-ora (`cmd/git-credential-ora`) | This repository | The Repo Executor's fixed Git credential helper; delivers the credential only when the requested repository equals the staged scope | | PostgreSQL | — | The only authoritative business store | -| Object storage | — | Revision bundles and session JSONL | +| Object storage | — | Revision session JSONL (Revisions registered before the switch to Git also have bundles) | | Controller (`ora-controller`) | desktop | Dials out to Cloud only; claims work, performs Substrate effects, holds Node sessions and relays events and commands | | Sandbox Server | cluster | The Docker implementation of the Substrate: creates and terminates sandboxes per Workspace and deletes data | | Node (`ora-node`) | desktop | The executor inside the sandbox: clones, plugin installs, Agent sessions, Revision delivery | @@ -99,7 +101,7 @@ operation in one transaction and answers `202`. Then: ```text Controller claims the operation ──▶ plans a sandbox_ensure effect ──▶ Sandbox Server creates the sandbox - ──▶ handshake with the Node, reported as registered ──▶ clone execution registered and dispatched ──▶ Node clones + ──▶ handshake with the Node, reported as registered ──▶ clone execution registered and dispatched ──▶ Node clones the Binding's snapshot from the Git Server ──▶ plugin execution registered and dispatched ──▶ Cloud opens admission, marks the Workspace ready and the operation succeeded in one transaction ``` @@ -117,8 +119,8 @@ comment transaction: create the IssueRun (pin the Agent plugin version, the pers ──▶ dispatch loop: create the run Workspace (a disposable isolated Workspace) ──▶ once ready: write the Thread's first prompt and release the session work item ──▶ the Node starts the Agent session ──▶ Thread: session records stream back to Cloud one by one, and the user can keep appending messages - ──▶ session ends: release the delivery work item ──▶ the Node uploads the bundle and session JSONL - ──▶ Cloud checks the objects outside a transaction and registers the Revision inside one ──▶ deletes the run Workspace + ──▶ session ends: release the delivery work item ──▶ the Node pushes the final commit to a delivery ref on the Git Server and uploads the session JSONL + ──▶ Cloud checks the delivery ref by ref query and the JSONL outside a transaction and registers the Revision inside one ──▶ deletes the run Workspace ``` The execution result and the saved output are recorded separately; startup, delivery and deletion each have a waiting limit; diff --git a/docs/architecture.md b/docs/architecture.md index 53dbd1bf4..046124b10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,7 @@ PostgreSQL ▼ │ - 浏览器只和 Gateway 通信;Gateway 为每个请求现签两份内部 JWT,再转发给 Cloud server。 - Controller 只主动拨出到 Cloud 的 gRPC 控制监听,Cloud 从不连接 Controller;Controller 驱动 Sandbox Server 建删沙盒,并与沙盒里的 Node 建立会话。 - 沙盒只能到达 model-gateway、git-server 的仓库监听和对象存储的公开地址,不能直接访问 Cloud、Gateway 或数据库。 +- Cloud server 只用自己的客户端证书访问 git-server 的 ref 查询监听,在登记 Revision 之前核对交付 ref(见 [Git Server](repositories/git-server.md#ref-查询))。 - 外部 Git 只由 Repo Executor 访问;浏览器提交的 token 只进入 Credential Store,Cloud 只读凭据元数据并签发短期的一次性授权。 | 参与方 | 仓库 | 职责 | @@ -43,7 +44,7 @@ PostgreSQL ▼ │ | Credential Store(`cmd/credential-store`) | 本仓库 | 独立保管外部 Git token:浏览器提交、执行器兑换、Cloud 管理与撤销;只保存密文 | | git-credential-ora(`cmd/git-credential-ora`) | 本仓库 | Repo Executor 的固定 Git credential helper,只在请求的仓库等于暂存作用域时交付凭据 | | PostgreSQL | — | 唯一的业务权威存储 | -| 对象存储 | — | Revision 的 bundle 与会话 JSONL | +| 对象存储 | — | Revision 的会话 JSONL(改走 Git 之前登记的 Revision 另有 bundle) | | Controller(`ora-controller`) | desktop | 只拨出到 Cloud;领取工作,执行 Substrate effect,与 Node 建会话并转发事件与命令 | | Sandbox Server | cluster | Substrate 的 Docker 实现:按 Workspace 创建、终止沙盒,删除数据 | | Node(`ora-node`) | desktop | 沙盒里的执行端:clone、插件安装、Agent 会话、Revision 交付 | @@ -79,7 +80,7 @@ PostgreSQL ▼ │ ```text Controller 领取操作 ──▶ 计划 sandbox_ensure effect ──▶ Sandbox Server 建沙盒 - ──▶ 与 Node 握手并代报注册 ──▶ 登记并派发 clone 执行 ──▶ Node clone + ──▶ 与 Node 握手并代报注册 ──▶ 登记并派发 clone 执行 ──▶ Node 从 Git Server 上 Binding 的快照 clone ──▶ 登记并派发插件执行 ──▶ Cloud 在同一事务里打开准入、标记 Workspace 就绪、操作成功 ``` @@ -95,8 +96,8 @@ Controller 领取操作 ──▶ 计划 sandbox_ensure effect ──▶ Sandbox ──▶ 派发循环:建运行 Workspace(一次性的 isolated Workspace) ──▶ 就绪后:写 Thread 首条提问,放出会话工作项 ──▶ Node 启动 Agent 会话 ──▶ Thread:会话记录逐条流回 Cloud,用户可以继续追加消息 - ──▶ 会话结束:放出交付工作项 ──▶ Node 上传 bundle 与会话 JSONL - ──▶ Cloud 在事务外核对对象,事务内登记 Revision ──▶ 删除运行 Workspace + ──▶ 会话结束:放出交付工作项 ──▶ Node 把最终提交推送到 Git Server 的交付 ref,上传会话 JSONL + ──▶ Cloud 在事务外经 ref 查询核对交付 ref、核对 JSONL,事务内登记 Revision ──▶ 删除运行 Workspace ``` 执行结果与成果保存分开记录,启动、交付、删除各有等待上限,未知结果只请求结束、保留残留责任(见 [Agent IssueRun](agent-runs/issue-runs.md))。 diff --git a/docs/core/persistence.en.md b/docs/core/persistence.en.md index c2f475f99..245dd3d65 100644 --- a/docs/core/persistence.en.md +++ b/docs/core/persistence.en.md @@ -20,14 +20,11 @@ HTTP conventions are in [HTTP API conventions](http-api.en.md). tables and runs no migrations. GORM is used solely to open the pool (`internal/repository`); every business query is hand-written, parameterized SQL. - The `Store` carries the collaborators needed at run time: the object storage configuration - `ObjectStore`, the workflow asset downloader `WorkflowAssets`, the collaboration ports + `ObjectStore`, the Git Server repository address and ref query `GitServer`, the workflow asset downloader `WorkflowAssets`, the collaboration ports (`Directory`, `Forms` and others, see [Issue collaboration](../collaboration/interactions.en.md)), the IssueRun business hooks and window settings, the SSE event hub `Events` and the Controller signal hub `Signals`. `cmd/*` assembles them at startup; core never reads configuration or environment variables itself. -- `core.NewDevelopmentStore` exists only for test fixtures and the in-process simulator. It - additionally opens a retired, unscoped clone contract and must never be used by the production - server. ## Transactions: `transact` @@ -90,7 +87,7 @@ discarded on rollback: | Collected item | Action after commit | |---|---| | A newly queued or retried Workspace operation | `ControlHub` sends `OperationAvailable` | -| A new clone request or execution work item | `ControlHub` sends `WorkAvailable` | +| A new execution work item (an Agent session or a Revision delivery) | `ControlHub` sends `WorkAvailable` | | A new Thread command | `ControlHub` sends `ThreadCommandAvailable` | | A business change inside a Space | `SpaceHub` sends an invalidation event to that Space's SSE subscribers | diff --git a/docs/core/persistence.md b/docs/core/persistence.md index b52a37b24..574062b06 100644 --- a/docs/core/persistence.md +++ b/docs/core/persistence.md @@ -15,12 +15,10 @@ git-server 都不持有另一份业务状态。`internal/core` 是唯一直接 - `core.NewStore(db)` 只从注入的 GORM 句柄取出 `*sql.DB` 连接池,不建表、不迁移。GORM 只用于建立 连接池(`internal/repository`),业务 SQL 全部是手写的参数化语句。 -- `Store` 上挂着运行期需要的协作者:对象存储配置 `ObjectStore`、工作流资产下载 `WorkflowAssets`、 +- `Store` 上挂着运行期需要的协作者:对象存储配置 `ObjectStore`、Git Server 的仓库地址与 ref 查询 `GitServer`、工作流资产下载 `WorkflowAssets`、 协作端口(`Directory`、`Forms` 等,见 [Issue 协作](../collaboration/interactions.md))、IssueRun 的 业务钩子和时间窗口配置、SSE 事件 hub `Events` 与 Controller 信号 hub `Signals`。它们都由 `cmd/*` 在启动时组装,core 不自行读取配置或环境变量。 -- `core.NewDevelopmentStore` 只给测试夹具和进程内模拟器使用,它额外打开一条已退役的无作用域 - clone 契约;生产 server 不得使用它。 ## 事务:`transact` @@ -73,7 +71,7 @@ Commit | 收集项 | 提交后的动作 | |---|---| | 新排队或重试的 Workspace 操作 | `ControlHub` 发 `OperationAvailable` | -| 新的 clone 请求、执行工作项 | `ControlHub` 发 `WorkAvailable` | +| 新的执行工作项(Agent 会话、Revision 交付) | `ControlHub` 发 `WorkAvailable` | | 新的 Thread 命令 | `ControlHub` 发 `ThreadCommandAvailable` | | Space 内的业务变化 | `SpaceHub` 向该 Space 的 SSE 订阅者发失效事件 | diff --git a/docs/development/local-development.en.md b/docs/development/local-development.en.md index 222b09fa4..8341f286d 100644 --- a/docs/development/local-development.en.md +++ b/docs/development/local-development.en.md @@ -71,8 +71,10 @@ To start parts separately: `task run` (Cloud), `task run:gateway` (Gateway), `ta ## Demos without a Controller - **The simulator**: `task simulate` (`go run ./cmd/simulator`) starts loopback doubles of Cloud and the Substrate in process, - creates a test tenant, a simulated sandbox and Node, really clones the repository into the main Workspace's own data - directory, and finally prints a ready Workspace. Disk data lives in `.local/demo/`, database records are kept, and every + creates a test tenant, a simulated sandbox and Node, prepares a snapshot in the Git Server double (on-disk repositories laid + out like the Git Server volume, recording the demo Project's snapshot in place of the external line), really clones that + snapshot into the main Workspace's own data directory, and finally prints a ready Workspace. The Node double fetches only + from the Git Server double and never reaches an external repository. Disk data lives in `.local/demo/`, database records are kept, and every run creates a new demo tenant. It uses temporary signing keys generated in process, holds no production credentials, starts no real Agent or plugin executor, and proves nothing about runtime exclusivity, management authentication or workload isolation. @@ -80,8 +82,8 @@ To start parts separately: `task run` (Cloud), `task run:gateway` (Gateway), `ta server signs the JWTs, meant only for manually checking the Issue endpoints; `frontend/` is the real frontend. `cmd/ora-web` is an early development edge server for the frontend; day-to-day development uses the Gateway. -These entry points are for development only; the development constructors they use, such as `router.NewDevelopment` and -`core.NewDevelopmentStore`, must never appear in a production process. +These entry points are for development only; the development constructors they use, such as `router.NewDevelopment`, must never appear in a +production process. ## The frontend diff --git a/docs/development/local-development.md b/docs/development/local-development.md index ba63757c9..423ca2456 100644 --- a/docs/development/local-development.md +++ b/docs/development/local-development.md @@ -57,13 +57,14 @@ Cloud 仍强制 TLS 1.3 双向认证,缺少配置仍拒绝启动。本地生 ## 不依赖 Controller 的演示 -- **模拟器**:`task simulate`(`go run ./cmd/simulator`)在进程内启动 loopback 的 Cloud 和 Substrate 替身,创建测试租户、模拟沙盒和 Node,把仓库 - 真实 clone 进 main Workspace 自己的数据目录,最后输出一个就绪的 Workspace。磁盘数据在 `.local/demo/`,数据库记录保留,每次运行创建新的演示 +- **模拟器**:`task simulate`(`go run ./cmd/simulator`)在进程内启动 loopback 的 Cloud 和 Substrate 替身,创建测试租户、模拟沙盒和 Node,在 Git + Server 替身(按 Git Server 卷布局存放的磁盘仓库,代替外部线登记演示 Project 的快照)里准备快照,再把该快照真实 clone 进 main Workspace + 自己的数据目录,最后输出一个就绪的 Workspace。Node 替身只从 Git Server 替身取快照,不访问外部仓库。磁盘数据在 `.local/demo/`,数据库记录保留,每次运行创建新的演示 租户。它使用进程内生成的临时签名密钥,没有生产凭据,不启动真实的 Agent 或插件执行器,也不证明运行时独占、管理认证或工作负载隔离。 - **看板演示**:`cmd/demo-issue-board-web`(`scripts/demo-issue-board-web.sh`)是单文件 HTML 的 Issue 看板演示,服务端签 JWT,只用于手工验证 Issue 接口;`frontend/` 才是正式的前端。`cmd/ora-web` 是早期的前端开发边缘服务,日常开发使用 Gateway。 -这些入口都只用于开发,它们使用的 `router.NewDevelopment`、`core.NewDevelopmentStore` 等开发构造函数不能出现在生产进程里。 +这些入口都只用于开发,它们使用的 `router.NewDevelopment` 等开发构造函数不能出现在生产进程里。 ## 前端 diff --git a/docs/operations/configuration.en.md b/docs/operations/configuration.en.md index 88cadbf26..1c33675a5 100644 --- a/docs/operations/configuration.en.md +++ b/docs/operations/configuration.en.md @@ -53,6 +53,9 @@ Each process in this repository has its own configuration sources: | `issue_runs.delivery_give_up_after`, `delivery_unreachable_after` | `2h`, `30m` | Give-up limits for delivery and deletion; `delivery_unreachable_after` is also the window that decides whether a Node is still there, and an in-flight workflow session settles on it; non-positive values take the defaults | | `workflow.session_turn_timeout` | `2h` | The initial turn's window for a workflow session: once the execution row's `created_at` is older than this with neither a committed result nor a successful `turnEnded`, the node fails as `session_turn_deadline_exceeded`; timed per execution row, non-positive values take the default, see [Workflows](../workflows/workflows.en.md#scheduling) | | `object_store.*` | `enabled: false` | See [Revisions](../agent-runs/revisions.en.md#object-storage): `endpoint`, `public_endpoint`, `region`, `bucket`, `path_style`, `access_key_id_file`, `secret_access_key_file`, `upload_grant_ttl` | +| `git_server.repository_base_url` | empty | The Git Server repository base URL sandboxes reach (`https`, no userinfo, query, fragment or trailing slash), written into clone, session and delivery inputs; empty runs without a Git Server: clone steps stay `blocked` (`git_server_unconfigured`) and deliveries settle as `skipped` | +| `git_server.ref_query_url`, `ca_file`, `client_cert_file`, `client_key_file` | empty | Required with `repository_base_url`: the ref query listener's `https://host[:port]`, the CA to verify it, and Cloud's client certificate and private key files. Startup only reads the files and makes no network call; an unreachable Git Server or a mismatched certificate only makes verification unavailable and never stops startup. See [Git Server](../repositories/git-server.en.md#ref-queries) | +| `git_server.ref_query_timeout` | `10s` | Timeout of one ref query; a non-positive value takes the default | | `repository.enabled` | `false` | Enables the external Git line and the credential routes; with it off Projects are still created, but no sync runs and credential creation answers `503`; see [The external Git line](../repositories/external-git.en.md) | | `repository.executor_url`, `executor_ca_file`, `dispatcher_cert_file`, `dispatcher_key_file` | URL defaults to `https://repo-executor:8471`; files required | The Repo Executor's task listener and Cloud's dispatcher mTLS client identity | | `repository.authorization_key_file` | Required | The Ed25519 (PKCS#8) key signing execution authorizations; must differ from `submission_key_file` | @@ -65,6 +68,10 @@ Each process in this repository has its own configuration sources: With `repository.enabled` true, startup checks that the two private keys differ, that the ingress URL is set, and loads three sets of mTLS client material (executor, Credential Store, Git Server) without making any network call. +Both sections reach the same Git Server ref query listener (`GET /internal/v1/refs`), each with its own client: `git_server.*` +serves Workspace clone inputs and Revision delivery verification, and `repository.git_refs_*` serves the repository worker, +publish source checks and the Workspace branch list. + `cloudctl` reads the same configuration but only uses the database connection; it needs a migration account with rights to create tables. diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 6be3da9b9..98be74a73 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -48,6 +48,9 @@ | `issue_runs.delivery_give_up_after`、`delivery_unreachable_after` | `2h`、`30m` | 交付与删除的放弃上限;`delivery_unreachable_after` 同时是"Node 是否还在"的判定窗口,工作流运行里进行中的会话也按它结算;非正值取默认 | | `workflow.session_turn_timeout` | `2h` | 工作流会话初始回合的窗口:执行行 `created_at` 超过它、既没有提交结果也没有成功的 `turnEnded` 时,节点以 `session_turn_deadline_exceeded` 失败;按执行行计时,非正值取默认,见 [工作流](../workflows/workflows.md#调度) | | `object_store.*` | `enabled: false` | 见 [Revision](../agent-runs/revisions.md#对象存储):`endpoint`、`public_endpoint`、`region`、`bucket`、`path_style`、`access_key_id_file`、`secret_access_key_file`、`upload_grant_ttl` | +| `git_server.repository_base_url` | 空 | 沙盒访问的 Git Server 仓库 base URL(`https`,不带用户信息、查询、片段和末尾斜杠),写进 clone、会话和交付输入;为空即不使用 Git Server:clone 步骤停在 `blocked`(`git_server_unconfigured`),交付以 `skipped` 结算 | +| `git_server.ref_query_url`、`ca_file`、`client_cert_file`、`client_key_file` | 空 | 设置了 `repository_base_url` 时必填:ref 查询监听的 `https://host[:port]`、校验它的 CA,以及 Cloud 的客户端证书和私钥文件。启动时只读入文件、不发网络请求;Git Server 不可达或证书不符只让核验暂不可用,不阻止启动。见 [Git Server](../repositories/git-server.md#ref-查询) | +| `git_server.ref_query_timeout` | `10s` | 单次 ref 查询的超时;非正值取默认 | | `repository.enabled` | `false` | 打开外部 Git 线与凭据路由;关闭时 Project 仍可创建,但没有同步,凭据创建返回 `503`,见 [外部 Git 线](../repositories/external-git.md) | | `repository.executor_url`、`executor_ca_file`、`dispatcher_cert_file`、`dispatcher_key_file` | URL 默认 `https://repo-executor:8471`,文件必填 | Repo Executor 的任务监听,以及 Cloud 调度方的 mTLS 客户端身份 | | `repository.authorization_key_file` | 必填 | 签发执行授权的 Ed25519 私钥(PKCS#8),必须与 `submission_key_file` 不同 | @@ -60,6 +63,8 @@ 启动时 `repository.enabled` 为真会校验两把私钥不同、Ingress URL 非空,并加载三组 mTLS 客户端材料(执行器、Credential Store、Git Server),不发起任何网络请求。 +两组配置都访问 Git Server 的同一个 ref 查询监听(`GET /internal/v1/refs`),各用自己的客户端:`git_server.*` 供 Workspace clone 输入与 Revision 交付核验使用,`repository.git_refs_*` 供仓库 worker、发布来源核对与 Workspace 分支列表使用。 + `cloudctl` 读取同一份配置,只用到数据库连接;它需要拥有建表权限的迁移账号。 ## Gateway diff --git a/docs/operations/deployment.en.md b/docs/operations/deployment.en.md index 81eaeb095..3f9b5d305 100644 --- a/docs/operations/deployment.en.md +++ b/docs/operations/deployment.en.md @@ -20,7 +20,7 @@ is described in the [configuration reference](configuration.en.md). | Credential Store | `/app/credential-store` | Independent custody of credentials: browser submission, executor redemption, Cloud management, revocation and expiry sweeping; ciphertext only | | cloudctl | `/app/cloudctl` | Migrations, first-tenant bootstrap, credential reference registration; run once, not a service | | PostgreSQL 17 | — | The only business authority | -| S3-compatible object storage | — | Revision objects (bundles, session JSONL); optional | +| S3-compatible object storage | — | Revision session JSONL (Revisions from before the switch to Git also have bundles); optional, no Revision is delivered without it | Outside Cloud, cluster also deploys the Controller (desktop's `ora-controller`), the Sandbox Server (cluster's own Substrate implementation) and the Node image (desktop's `ora-node`). @@ -94,7 +94,7 @@ because of the unknown migration, so a rollback has to deal with the database as | Process | Materials needed | |---|---| -| Cloud server | The Gateway's two verification public keys (`auth.keys`); the control listener's service certificate, private key and Controller client CA; object storage credential files; the directory machine key on corporate deployments | +| Cloud server | The Gateway's two verification public keys (`auth.keys`); the control listener's service certificate, private key and Controller client CA; object storage credential files; the CA for the Git Server ref query and Cloud's client certificate and private key; the directory machine key on corporate deployments | | Gateway | The service and user signing private keys; the PKCE key; the chosen provider's client secret; model-gateway's public CA | | model-gateway | Its service certificate and key; the CA verifying Node model client certificates; the encryption master key; the Gateway's verification public keys (through Cloud's configuration) | | git-server | Its service certificate and key; the CA of sandbox git-access certificates; the CA of the Repo Executor certificate; Cloud's ref query client certificate | @@ -142,7 +142,7 @@ does not change the snapshot already loaded. The policy is in | Process | Check | |---|---| -| Cloud server | `GET /healthz`: an unreachable database returns `503 database_unavailable`; reports whether object storage is configured, which does not mean storage is online. The response also carries `X-Ora-Egress-Schema` and `X-Ora-Egress-Digest` | +| Cloud server | `GET /healthz`: an unreachable database returns `503 database_unavailable`; reports whether object storage and the Git Server are configured (`objectStore`, `gitServer`), which does not mean they are online. The response also carries `X-Ora-Egress-Schema` and `X-Ora-Egress-Digest` | | Gateway | `GET /healthz`: PostgreSQL liveness | | model-gateway | `model-gateway -healthcheck`: verifies HTTPS and the public CA normally and checks database readiness, without reading user keys, the master key or the egress digest. `GET /healthz` on the credential listener is what carries the same `X-Ora-Egress-Schema` and `X-Ora-Egress-Digest` as Cloud | | git-server | `git-server -healthcheck`: calls `/healthz` on the ref listener and verifies the server certificate against the deployment CA, without reading private keys | @@ -187,7 +187,7 @@ scope. | PostgreSQL | All business state | | The model-gateway master key volume | Without it the key ciphertexts in the database cannot be decrypted; **back it up together with the database** | | The git-server repository volume | Project repositories (`GIT_SERVER_REPOSITORY_ROOT`) | -| The object storage bucket | Revision bundles and session JSONL | +| The object storage bucket | Revision session JSONL (and bundles registered before the switch to Git) | | The Credential Store credential volume and KEK keyring | External Git credential ciphertext and the key that opens it; **back them up together**, since losing the keyring makes the volume unreadable | | Certificate and key volumes | Each process's identity material; the Repo Executor has nothing to back up, as its task directory is a tmpfs | diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 51efc928f..0445fe67b 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -18,7 +18,7 @@ | Credential Store | `/app/credential-store` | 凭据的独立保管:浏览器提交、执行器兑换、Cloud 管理、撤销与过期清扫;只保存密文 | | cloudctl | `/app/cloudctl` | 迁移、首个租户引导、凭据引用登记;一次性运行,不是服务 | | PostgreSQL 17 | — | 唯一的业务权威 | -| S3 兼容对象存储 | — | Revision 对象(bundle、会话 JSONL);可选 | +| S3 兼容对象存储 | — | Revision 的会话 JSONL(改走 Git 之前的 Revision 另有 bundle);可选,没有它时不交付 Revision | Cloud 之外,cluster 还部署 Controller(desktop 的 `ora-controller`)、Sandbox Server(cluster 自己的 Substrate 实现)和 Node 镜像(desktop 的 `ora-node`)。 @@ -80,7 +80,7 @@ cluster 的 Compose 把服务分在四个网络上,生产部署应保持同样 | 进程 | 需要的材料 | |---|---| -| Cloud server | Gateway 的两个验证公钥(`auth.keys`);控制监听的服务证书、私钥、Controller 客户端 CA;对象存储凭据文件;企业部署的目录机器密钥 | +| Cloud server | Gateway 的两个验证公钥(`auth.keys`);控制监听的服务证书、私钥、Controller 客户端 CA;对象存储凭据文件;Git Server ref 查询的 CA 与 Cloud 的客户端证书、私钥;企业部署的目录机器密钥 | | Gateway | service 与 user 两把签名私钥;PKCE 密钥;所选 provider 的 client secret;model-gateway 的公开 CA | | model-gateway | 服务证书与私钥;校验 Node 模型客户端证书的 CA;加密主密钥;Gateway 的验证公钥(经 Cloud 配置) | | git-server | 服务证书与私钥;沙盒 git-access 证书的 CA;Repo Executor 证书的 CA;Cloud 的 ref 查询客户端证书 | @@ -120,7 +120,7 @@ Cloud server、全部 model-gateway 与全部 Repo Executor 只读挂载同一 | 进程 | 检查 | |---|---| -| Cloud server | `GET /healthz`:数据库不可达返回 `503 database_unavailable`;报告对象存储是否已配置,不代表存储在线。响应头还有 `X-Ora-Egress-Schema` 与 `X-Ora-Egress-Digest` | +| Cloud server | `GET /healthz`:数据库不可达返回 `503 database_unavailable`;报告对象存储与 Git Server 是否已配置(`objectStore`、`gitServer`),不代表它们在线。响应头还有 `X-Ora-Egress-Schema` 与 `X-Ora-Egress-Digest` | | Gateway | `GET /healthz`:PostgreSQL 探活 | | model-gateway | `model-gateway -healthcheck`:正常校验 HTTPS 和公开 CA,检查数据库就绪,不读取用户 Key 或主密钥,也不报告出网摘要。凭据监听上的 `GET /healthz` 才带与 Cloud 相同的 `X-Ora-Egress-Schema` 与 `X-Ora-Egress-Digest` | | git-server | `git-server -healthcheck`:访问 ref 监听的 `/healthz`,用部署 CA 校验服务端证书,不读取私钥 | @@ -158,7 +158,7 @@ Controller 信号只在进程内传递:浏览器只能收到它所连实例上 | PostgreSQL | 全部业务状态 | | model-gateway 主密钥卷 | 没有它,数据库里的 Key 密文无法解密;**必须与数据库一起备份** | | git-server 仓库卷 | Project 仓库(`GIT_SERVER_REPOSITORY_ROOT`) | -| 对象存储桶 | Revision 的 bundle 与会话 JSONL | +| 对象存储桶 | Revision 的会话 JSONL(以及改走 Git 之前登记的 bundle) | | Credential Store 凭据卷与 KEK keyring | 外部 Git 凭据的密文和它的密钥;**必须一起备份**,丢失 keyring 就无法解密卷里的凭据 | | 证书与密钥卷 | 各进程的身份材料;Repo Executor 没有需要备份的数据,任务目录是 tmpfs | diff --git a/docs/repositories/external-git.en.md b/docs/repositories/external-git.en.md index 6604428d0..2d770fd6d 100644 --- a/docs/repositories/external-git.en.md +++ b/docs/repositories/external-git.en.md @@ -65,12 +65,15 @@ snapshot present is recorded `applied`. A publish (`repository_publish`) is the only way Ora writes an external repository, and only a user can create one explicitly. Its fields are immutable after creation: the source ref and commit (a -workspace branch, a delivery ref, or an existing Revision), the target branch, the expected old value +workspace branch, a delivery ref, or an existing Revision, which contributes the delivery ref and final +commit Cloud verified when registering it; a bundle-shaped Revision without a delivery ref is refused +with `revision_not_publishable`), the target branch, the expected old value `expectedOldOid`, and the credential reference; `expectedOldOid` must differ from the source commit. -1. Cloud first confirms through the ref query that the source ref really names that commit, then - creates the Operation. Whether the source commit is reachable from the source ref is checked by - the executor, because Cloud reads no Git objects. +1. Cloud first confirms through the ref query that the source ref exists (refusing with + `409 source_ref_not_found` when it does not), then creates the Operation; it does not compare the + source ref's current commit. Whether the source commit is reachable from the source ref is checked + by the executor, because Cloud reads no Git objects. 2. The credential's current version must already have the `publish` capability. A personal credential is approved by its owner; when a tenant administrator publishes with somebody else's personal credential, the owner approves on `awaiting_approval` and an administrator cannot approve on their @@ -125,10 +128,10 @@ the Credential Store; no Cloud path ever sees it. A credential supports new remote work only when it is `stored`, available, scoped to the Project's repository and has the required capability; Cloud evaluates this both when creating work and when -issuing the authorization. A Project's Workspace clone still clones directly from the external -repository URL: public repositories clone anonymously, while a Project with a credential refuses new -remote work with `repository_credential_executor_unavailable` until the Node-side credential -provider exists, and never falls back to an anonymous clone or a global credential. +issuing the authorization. A Workspace clone never reaches the external repository and uses no +credential: it clones only the Binding's current snapshot (see +[Workspace lifecycle](../runtime/workspaces.en.md#the-clone-step-clones-the-binding-snapshot)), so a +Project with a credential clones exactly like one with a public repository. ## Authorization and least privilege diff --git a/docs/repositories/external-git.md b/docs/repositories/external-git.md index 4daa6c2b3..f8456b16c 100644 --- a/docs/repositories/external-git.md +++ b/docs/repositories/external-git.md @@ -44,11 +44,12 @@ ## 发布 发布(`repository_publish`)是 Ora 写外部仓库的唯一方式,只能由用户显式创建。它的字段在创建后不可变:源 ref 与提交 -(工作区分支、交付 ref,或一个已有 Revision)、目标分支、期望旧值 `expectedOldOid` 和凭据引用;`expectedOldOid` 必须与 +(工作区分支、交付 ref,或一个已有 Revision:取它登记时核验过的交付 ref 与最终提交,没有交付 ref 的 bundle 形态 +Revision 以 `revision_not_publishable` 拒绝)、目标分支、期望旧值 `expectedOldOid` 和凭据引用;`expectedOldOid` 必须与 源提交不同。 -1. Cloud 先经 ref 查询确认源 ref 确实指向这个提交,再创建 Operation。源提交是否可从源 ref 到达由执行器检查,因为 Cloud - 不读取 Git 对象。 +1. Cloud 先经 ref 查询确认源 ref 存在(不存在时以 `409 source_ref_not_found` 拒绝),再创建 Operation;它不比对源 ref + 的当前提交。源提交是否可从源 ref 到达由执行器检查,因为 Cloud 不读取 Git 对象。 2. 凭据的当前版本必须已具备 `publish` 能力。个人凭据由所有者批准;租户管理员用别人的个人凭据发布时需要所有者在 `awaiting_approval` 上批准,管理员不能代批。团队凭据由租户管理员直接发布。批准把字段与凭据版本绑定,有效期 24 小时; 期间凭据出现新版本时,这次发布以 `credential_changed` 取消或拒绝。 @@ -80,8 +81,9 @@ token 从浏览器直接进入 Credential Store,任何 Cloud 路径都不会 `invalid`;个人凭据的所有者离开租户后,凭据被冻结。 凭据只有在 `stored`、可用、作用域与 Project 的仓库一致且具备所需能力时才能支撑新的远端工作,这个判断在创建工作和签发 -授权时各做一次。Project 的 Workspace clone 目前仍直接从外部仓库地址克隆:公开仓库可以匿名克隆,带凭据的项目在 Node 侧 -凭据接入之前会以 `repository_credential_executor_unavailable` 拒绝新的远端工作,绝不退化成匿名克隆或使用全局凭据。 +授权时各做一次。Workspace 的 clone 不访问外部仓库,也不使用凭据:它只从 Binding 的当前快照 clone(见 +[Workspace 生命周期](../runtime/workspaces.md#clone-步骤从-binding-快照-clone)),所以带凭据的 Project 与公开仓库的 Project +clone 方式相同。 ## 授权与最小权限 diff --git a/docs/repositories/git-server.en.md b/docs/repositories/git-server.en.md index f3990a855..47f26d9d6 100644 --- a/docs/repositories/git-server.en.md +++ b/docs/repositories/git-server.en.md @@ -31,9 +31,9 @@ side, then the Git Server's listeners, identities and authorization rules. The Repo Executor that performs repository Operations is this repository's `cmd/repo-executor`; it receives Git access by Operation state, a sync writes only this Binding's snapshot prefix and tracking ref, and a publish only reads its source. The whole line's sync, publish, credential and -settlement rules are in [The external Git line](external-git.en.md). A Workspace's `clone` step still -clones directly from the external repository URL: public repositories clone anonymously, and a -Project with a credential refuses new remote work until the Node-side credential provider exists. +settlement rules are in [The external Git line](external-git.en.md). A Workspace's `clone` step clones only the +Binding's current snapshot (see [Workspace lifecycle](../runtime/workspaces.en.md#the-clone-step-clones-the-binding-snapshot)): +Nodes never reach the external repository, and repository credentials take part in no Node work. ## Listeners @@ -62,16 +62,29 @@ grant: the whole Project repository may be read, plus the refs or prefixes that with the rule fixed by its namespace. The repository is the isolation boundary, so reads hide no ref inside it. -**Grants for sandbox executions.** The execution must belong to this runtime, be unfinished and not +**Grants for Agent session and delivery executions.** The execution must belong to this runtime, be unfinished and not force-stopped; the runtime must be at its current generation and ready, and the Project `active`; the user who triggered the run must still be able to use the runtime; the current Node must be the execution's target Node; and runtime control must be the run's `maintenance` with a binding confirmed to this sandbox. On top of that: -| Execution | Run phase | Grant | +| Execution | Run phase | Writable refs | |---|---|---| -| `deliver_revision` | `delivering` | May only create `refs/ora/deliveries/` | -| `agent_session` | `starting` or `running`, no cancellation requested, Thread not ending | Read-only, and only for a session restoring a prior Revision; other sessions start from a clone of the external repository and have no reason to reach the Git Server | +| `agent_session` | `starting` or `running`, no cancellation requested, Thread not ending | Branches under `refs/heads/workspaces//` (fast-forward rule); the prefix comes from the certificate, never from the request | +| `deliver_revision` | `delivering` | Create the delivery ref its input fixed, `refs/ora/deliveries/`; also create or fast-forward the input's `workspaceRef`, provided it belongs to the certificate's Workspace | + +Plugin executions are always refused and never reach the repository. A WorkflowRun's Agent session is refused too in +the first phase: grants only cover executions on an IssueRun's run Workspace, so an `agent_session` on a WorkflowRun's +Workspace gets the same `403`. + +**Grants for clone executions.** The clone execution of a Workspace operation's `clone` step is read-only and +valid only while the operation is `running` in the `clone` step: the execution has no result and is not +force-stopped; the Workspace is at the certificate's generation and not deleted; the Project is `provisioning` or +`active` (a main Workspace clones while its Project is being created); the repository URL in the execution's input +names this tenant and Project; the operation's actor can still use the runtime; no force-stop is in effect; and the +current Node is the execution's target Node. It does not require a ready Workspace or open admission, because cloning +happens before admission opens. The grant ends as soon as the operation leaves the `clone` step or the execution has a +result. **Grants for the Repo Executor.** The named Operation must be in this tenant and Project, belong to the Project's Binding and be `running`, and the Project must not be deleted (`provisioning`, `active` and @@ -88,7 +101,7 @@ whether a repository, execution or Node exists. A database failure returns `503` request through. Authorization runs in a read-only consistent snapshot without core's global lock, so Git traffic does not serialize with control-plane writes. -Ending the execution, replacing the sandbox, cancelling the run, disabling the triggering user's +Ending the execution, a clone operation leaving its `clone` step, replacing the sandbox, cancelling the run, disabling the triggering user's account, or cancelling or ending the Operation all take effect on the **next request**, including a push already inside pre-receive. A long fetch is not re-authorized mid-transfer. @@ -104,9 +117,8 @@ push already inside pre-receive. A long fetch is not re-authorized mid-transfer. - `pre-receive` resolves the same grant **again** before any ref is written and checks every ref against its rule. No ref can be deleted and no ref outside the table can be written; if any single update is not accepted, the whole push is refused. -- Sandbox executions only produce the delivery rule; an external-line sync produces the snapshot - prefix and this Binding's tracking ref. The source of grants for workspace branches is not wired in - yet. +- Sandbox executions produce the Workspace branch and delivery rules; an external-line sync produces the + snapshot prefix and this Binding's tracking ref. - Repository policy and the hook path are applied through `GIT_CONFIG_*` environment variables on every git process rather than written into the repository, so neither a restart nor an old repository can weaken them. Both repository listeners refuse ref deletion; the repository listener also refuses @@ -124,11 +136,20 @@ The ref query listener is open only to Cloud's fixed identity and is read-only: - `GET ...?prefix=`: the refs and commits inside one namespace instance (one delivery, all of one Binding's refs or its snapshot or tracking refs, one Workspace). -Cloud calls the ref query in three places: after a sync reports `applied` to verify the snapshot and -tracking refs, before creating a publish to confirm the source ref, and when listing a Workspace's -collaboration branches as publish sources. Using delivery refs as the basis for registering Revisions -is still under decision (see the proposed ADR below); Revisions are still verified and registered -from [bundles in object storage](../agent-runs/revisions.en.md). +The Cloud server calls the ref query with the address from its `git_server.*` settings and its own client +certificate (`internal/gitrefquery`, using `?ref=` only): outside any database transaction it checks that a +Revision's delivery ref names the final commit the Node declared, then registers the Revision (see +[Revisions](../agent-runs/revisions.en.md#the-delivery-flow)). Only `404 ref_not_found` counts as the ref being +absent; any other response, a transport failure or a timeout makes verification unavailable for now and it is retried, +never concluded. The query never follows redirects. Without a configured Git Server, clone steps stay `blocked` +(`git_server_unconfigured`), deliveries settle as `skipped`, and Cloud makes no ref query through this client. + +This delivery path implements an `approved` Revision decision (see below). + +The external Git line calls the ref query through a second client (`internal/gitrefs`) configured by +`repository.git_refs_*`, in three places: after a sync reports `applied` to verify the snapshot and tracking +refs, before creating a publish to confirm the source ref, and when listing a Workspace's collaboration +branches as publish sources (see [The external Git line](external-git.en.md)). ## Operations @@ -147,4 +168,6 @@ from [bundles in object storage](../agent-runs/revisions.en.md). - [Git Server is the Project's code hub: a separate HTTPS service where certificates only authenticate and authorization is decided per request from Cloud's current state](https://github.com/ora-space/specs/blob/main/decisions/cloud/git-server/0-project-code-hub-git-server-over-https.md) (approved) - [Git Server becomes every Project's code hub: ref namespaces, rule-set grants and the contract for two kinds of writer](https://github.com/ora-space/specs/blob/main/decisions/cloud/git-server/20261016-project-repository-as-code-hub-and-ref-contract.md) (approved) -- [A Revision's code changes are delivered only through the Git Server](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261012-git-server-delivery-and-verified-delivery-refs.md) (proposed) +- [A Workspace's clone step consumes the Binding's current snapshot](https://github.com/ora-space/specs/blob/main/decisions/cloud/operation/20261016-clone-step-consumes-binding-snapshot.md) (approved) +- [The clone input becomes a Git Server snapshot input; the Git Server derives write rules from the execution kind](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md) (approved) +- [A Revision's code changes are delivered only through the Git Server, and Cloud registers the delivery ref after a ref query](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261017-git-native-delivery-refs-and-resume.md) (approved; supersedes the 20261012 delivery decision) diff --git a/docs/repositories/git-server.md b/docs/repositories/git-server.md index 932c769ed..c4df51409 100644 --- a/docs/repositories/git-server.md +++ b/docs/repositories/git-server.md @@ -21,8 +21,9 @@ Git Server(`cmd/git-server`,实现在 `internal/gitserver`)为每个 Proje 执行仓库 Operation 的 Repo Executor 是本仓库的 `cmd/repo-executor`,它按 Operation 状态获得 Git 访问授权;同步只写入本 Binding 的快照前缀和跟踪 ref,发布只读取源。整条线的同步、发布、凭据与收敛规则见 -[外部 Git 线](external-git.md)。Workspace 的 `clone` 步骤仍然直接从外部仓库地址克隆:公开仓库可以匿名克隆,带凭据的项目在 -Node 侧凭据接入之前拒绝新的远端工作。 +[外部 Git 线](external-git.md)。Workspace 的 `clone` 步骤只从 Binding 的当前快照 clone(见 +[Workspace 生命周期](../runtime/workspaces.md#clone-步骤从-binding-快照-clone)),Node 不接触外部仓库,仓库凭据也不参与任何 +Node 工作。 ## 监听 @@ -47,14 +48,22 @@ Node 侧凭据接入之前拒绝新的远端工作。 缺头、带错头、两个都带都返回 `400`。core 把请求解析成一份授权:可以读取整个 Project 仓库,以及可以写入的 ref 或前缀,每一项 带上所在 namespace 固定的规则。仓库就是隔离边界,读取时不隐藏仓库内的任何 ref。 -**沙盒执行的授权。** 执行必须属于这个运行时、未结束、未被强停,运行时处于当前代次且就绪,Project 处于 `active`;触发运行的 +**Agent 会话与交付执行的授权。** 执行必须属于这个运行时、未结束、未被强停,运行时处于当前代次且就绪,Project 处于 `active`;触发运行的 用户仍然能使用这个运行时;当前 Node 就是执行的目标 Node,运行时控制处于该运行的 `maintenance` 且已确认绑定到这个沙盒。在此 基础上: -| 执行 | 运行阶段 | 授权 | +| 执行 | 运行阶段 | 可写入的 ref | |---|---|---| -| `deliver_revision` | `delivering` | 只能新建 `refs/ora/deliveries/<交付工作项 ID>` | -| `agent_session` | `starting` 或 `running`,未请求取消,Thread 未在结束 | 只读,而且只给需要恢复前序 Revision 的会话;其他会话从外部仓库克隆,没有理由访问 Git Server | +| `agent_session` | `starting` 或 `running`,未请求取消,Thread 未在结束 | `refs/heads/workspaces/<证书里的 workspaceId>/` 下的分支(快进规则);前缀取自证书,从不取自请求 | +| `deliver_revision` | `delivering` | 新建输入固定的交付 ref `refs/ora/deliveries/<交付工作项 ID>`;另可把输入的 `workspaceRef` 新建或快进,前提是它属于证书里的 Workspace | + +插件执行一律拒绝,不能访问仓库。WorkflowRun 的 Agent 会话第一阶段同样拒绝:授权只认 IssueRun 的运行 Workspace 上的执行,WorkflowRun +的 Workspace 上的 `agent_session` 得到同一个 `403`。 + +**clone 执行的授权。** Workspace 操作 `clone` 步骤的 clone 执行只读,并且只在操作仍处于 `running` 的 `clone` 步骤时有效:执行 +没有结果、未被强停;Workspace 处于证书里的代次、未删除;Project 处于 `provisioning` 或 `active`(main Workspace 在 Project 创建中 +clone);执行输入的仓库地址指向这个租户和 Project;操作的发起者仍能使用这个运行时;没有强停;当前 Node 就是执行的目标 Node。它 +不要求 Workspace 已就绪或已开放准入,因为 clone 发生在准入打开之前。操作离开 `clone` 步骤或执行有了结果,授权随即失效。 **Repo Executor 的授权。** 请求指定的 Operation 必须在这个租户和 Project 下、属于该 Project 的 Binding、处于 `running`,Project 未删除(`provisioning`、`active`、`deleting` 都可以,因为首次同步发生在创建中,删除开始时正在运行的同步也要能收尾): @@ -67,7 +76,7 @@ Node 侧凭据接入之前拒绝新的远端工作。 **结果。** 任何拒绝都是同一个 `403`,仓库不存在也一样,沙盒无法借此探测仓库、执行或 Node 是否存在。数据库故障返回 `503`, 绝不放行。授权在只读的一致性快照里完成,不取 core 的全局锁,Git 流量不与控制面写入串行。 -结束执行、替换沙盒、取消运行、停用触发者账号、取消或结束 Operation,都会在**下一个请求**生效,包括已经进入 pre-receive 的推送。 +结束执行、clone 操作离开 `clone` 步骤、替换沙盒、取消运行、停用触发者账号、取消或结束 Operation,都会在**下一个请求**生效,包括已经进入 pre-receive 的推送。 长时间的 fetch 不在传输中途重新授权。 ## ref namespace 与写入规则 @@ -81,7 +90,7 @@ Node 侧凭据接入之前拒绝新的远端工作。 - `pre-receive` 在写入任何 ref 之前**重新**解析同一份授权,逐个 ref 核对规则。任何 ref 都不能删除,也不能写表外的 ref;只要有 一个更新不被接受,整次推送拒绝。 -- 沙盒执行只产生交付规则;外部线的同步 Operation 产生快照前缀和本 Binding 的跟踪 ref。工作区分支的授权来源尚未接入。 +- 沙盒执行产生 Workspace 分支与交付两种规则;外部线的同步 Operation 产生快照前缀和本 Binding 的跟踪 ref。 - 仓库策略和 hook 路径通过每个 git 进程的 `GIT_CONFIG_*` 环境变量生效,不写进仓库,所以重启或旧仓库都无法削弱它们。两个仓库 监听都禁止删除 ref;仓库监听同时禁止非快进,服务仓库监听放开非快进,让跟踪 ref 能跟随外部的 force-push,其他 namespace 的 非快进仍由 hook 拒绝。 @@ -94,9 +103,15 @@ ref 查询监听只对 Cloud 的固定身份开放,只读: - `GET ...?ref=`:返回任意 namespace 里一个 ref 指向的提交; - `GET ...?prefix=<前缀>`:列出一个 namespace 实例内的 ref 与提交(一个交付、一个 Binding 的全部、快照或跟踪 ref、一个 Workspace)。 -Cloud 在三个地方调用 ref 查询:同步报告 `applied` 后核对快照与跟踪 ref,创建发布前确认源 ref,以及列出 Workspace 的协作 -分支供选择发布源。把交付 ref 作为 Revision 登记依据的方案还在决策中(见下方 proposed 的 ADR);当前的 Revision 仍按 -[对象存储里的 bundle](../agent-runs/revisions.md) 校验登记。 +Cloud server 用 `git_server.*` 配置里的地址和自己的客户端证书调用 ref 查询(`internal/gitrefquery`,只用 `?ref=`),在任何数据库 +事务之外核验 Revision 的交付 ref 指向 Node 声明的最终提交,再登记 Revision(见 [Revision](../agent-runs/revisions.md#交付流程))。 +只有 `404 ref_not_found` 算作 ref 确实不存在;其他响应、传输失败或超时都让核验暂不可用、之后重试,不作结论。查询不跟随重定向。 +没有配置 Git Server 时,clone 步骤停在 `blocked`(`git_server_unconfigured`),交付以 `skipped` 结算,Cloud 也不经这个客户端调用 ref 查询。 + +这条交付路径实现的是已 `approved` 的 Revision 决策(见文末)。 + +外部 Git 线用 `repository.git_refs_*` 配置的另一个客户端(`internal/gitrefs`)在三个地方调用 ref 查询:同步报告 `applied` +后核对快照与跟踪 ref,创建发布前确认源 ref,以及列出 Workspace 的协作分支供选择发布源(见 [外部 Git 线](external-git.md))。 ## 运维 @@ -109,4 +124,6 @@ Cloud 在三个地方调用 ref 查询:同步报告 `applied` 后核对快照 - [Git Server 是 Project 的代码中心:独立 HTTPS 服务,证书只认证身份,授权按 Cloud 当前状态逐请求判定](https://github.com/ora-space/specs/blob/main/decisions/cloud/git-server/0-project-code-hub-git-server-over-https.md)(approved) - [Git Server 成为每个 Project 的代码中心:ref namespace、规则集授权与两类写入者的契约](https://github.com/ora-space/specs/blob/main/decisions/cloud/git-server/20261016-project-repository-as-code-hub-and-ref-contract.md)(approved) -- [Revision 的代码改动只经 Git Server 交付](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261012-git-server-delivery-and-verified-delivery-refs.md)(proposed) +- [Workspace 的 clone 步骤改为消费 Binding 当前快照](https://github.com/ora-space/specs/blob/main/decisions/cloud/operation/20261016-clone-step-consumes-binding-snapshot.md)(approved) +- [clone 输入替换为 Git Server 快照输入;Git Server 按执行类型给出写入规则](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md)(approved) +- [Revision 的代码改动只经 Git Server 交付,Cloud 经 ref 查询核验后登记交付 ref](https://github.com/ora-space/specs/blob/main/decisions/cloud/revision/20261017-git-native-delivery-refs-and-resume.md)(approved;取代 20261012 的交付决策) diff --git a/docs/runtime/controller-integration.en.md b/docs/runtime/controller-integration.en.md index 3797c116e..550d58a6f 100644 --- a/docs/runtime/controller-integration.en.md +++ b/docs/runtime/controller-integration.en.md @@ -113,7 +113,7 @@ The gRPC status code is the primary classification; `ErrorDetail{ErrorCode}` ref | `WorkspaceOperationService` | Workspace lifecycle operations: `ClaimOperation`, `PlanEffect`, `RecordEffectResult`, `AdvanceOperation`, `DeferOperation`, `RecordProvisioningFact`, `ListLiveSandboxes` | | `NodeReportService` | The Controller reports the Nodes it holds sessions with: `RegisterNode`, `ReportNodeStatus`, `EndNode`, `ReportNodeIdle` | | `ExecutionService` | The execution registry: `ClaimWork`, `RecordDispatch`, `TakeOverNodeEvent`, `RecordQueriedResult`, `GetDispatch`, `ListPendingDispatches` | -| `AgentRunService` | Agent runs: `TakeOverThreadEvents`, `ClaimThreadCommands`, `RecordThreadCommandDelivered`, `GrantRevisionUpload`, `GrantRevisionDownload` | +| `AgentRunService` | Agent runs: `TakeOverThreadEvents`, `ClaimThreadCommands`, `RecordThreadCommandDelivered`, `GrantRevisionUpload` | | `RuntimeControlService` | Runtime control: `ListBindings`, `AcknowledgeBinding`, `GetExecutionPermit`, `GetEffectPermit`, `ListForceStops`, `ConfirmForceStop` | | `ControlSignalService` | The `Watch` signal stream | @@ -122,9 +122,16 @@ The gRPC status code is the primary classification; `ErrorDetail{ErrorCode}` ref - `ClaimOperation` claims the oldest runnable operation (`queued`, a due `retry_wait`, or one already `running`), sets it `running` with the current epoch and **increments its version**, which fences any worker still holding an old snapshot; it is a write, not a pure read. The returned snapshot holds - everything the operation needs: the Project (with its credential reference), every related Workspace, - sandbox, Node and effect, and the clone and plugin executions this operation already has, so a - recovering Controller finds the original execution instead of registering a second one. + everything the operation needs: the Project's identity and lifecycle (no external repository URL, + default branch or credential reference), every related Workspace, sandbox, Node and effect, and the + clone and plugin executions this operation already has, so a recovering Controller finds the original + execution instead of registering a second one. An operation in the `clone` step also carries the + `clone_input` Cloud froze from the Binding's current snapshot (the Git Server repository URL, snapshot + ref, `base_commit` and `workspace_ref`); the Controller forwards it verbatim to `RecordDispatch` and the + Node and never dispatches a clone step without it. Operations Cloud cannot hand out yet (a `clone` step + without a snapshot, a `delete_project` while a repository Operation is unfinished) are parked in the + same transaction, and the claim returns the next runnable operation instead (see + [Workspace lifecycle](workspaces.en.md#the-clone-step-clones-the-binding-snapshot)). - `RecordProvisioningFact` records late real facts against the original operation, effect and execution identities after a run Workspace creation was abandoned, and reopens nothing (see [Bounded abandonment of a blocked creation](../agent-runs/startup-settlement.en.md#bounded-abandonment-of-a-blocked-creation)). @@ -148,7 +155,8 @@ generation and live sandbox. | `install_plugins`, `remove_plugins` | The `plugin` step of a Workspace operation | `node_executions` | | `agent_session`, `deliver_revision` | Execution work items declared by an Agent run (`execution_work`) | `node_executions` | -- `ClaimWork` is a **pure read**: it returns the oldest claimable work item, and ownership is decided +- `ClaimWork` is a **pure read**: it returns the oldest claimable Agent session or delivery work item (a clone is + never a work item; it is claimed with its operation), and ownership is decided only in `RecordDispatch`'s transaction, so a Controller that crashes between claiming and registering leaves nothing to recover. - For Agent session and Revision delivery work owned by an IssueRun (a work item carrying `run_id`), @@ -175,8 +183,8 @@ generation and live sandbox. of sessions and deliveries use the same sequence and cannot overtake events not yet taken over. **`AgentRunService`** carries the session side of an Agent run: batched Thread event takeover, -claiming and acknowledging delivery of Thread commands, and issuing object storage upload and download -grants for deliveries. Semantics are in [Threads](../agent-runs/threads.en.md) and +claiming and acknowledging delivery of Thread commands, and issuing the object storage upload grant for a +delivery's session JSONL; code is delivered through the Git Server, and there is no download grant. Semantics are in [Threads](../agent-runs/threads.en.md) and [Revisions](../agent-runs/revisions.en.md). **`RuntimeControlService`** delivers runtime control bindings, issues execution and effect permits and @@ -185,7 +193,10 @@ carries out force-stops; see [Runtime control](runtime-control.en.md). ## Changing the contract - `buf.yaml` uses `STANDARD` lint and `FILE`-level breaking checks: `v1` accepts only non-breaking - changes, and a breaking change is expressed as a parallel `v2` package. + changes, and a breaking change is expressed as a parallel `v2` package. The exception: the switch to + Git-native Workspace code (cloning from Git Server snapshots, Revisions delivered over Git, no bundles) + deleted fields, messages and RPCs in `v1`, so `buf.yaml` carries a one-time `ignore_only` exemption + that is removed in the next change to that file. - After editing a `.proto`, run `task proto:lint` and `task proto:breaking`, then `task proto:generate`, and commit the generated output with the change. `task check` and CI run lint, breaking and drift checks on the generated code. @@ -203,4 +214,5 @@ carries out force-stops; see [Runtime control](runtime-control.en.md). - [Runtime control is authorized by Cloud, delivered by the Controller, and fenced at the Node's real entry points](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260927-fenced-runtime-control-delivery.md) - [Internal Control gRPC adds plugin, Agent session and Revision delivery executions](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260928-agent-run-executions-thread-and-upload-grants.md) - [Session and delivery inputs gain a prior Revision, plus `GrantRevisionDownload`](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261010-revision-restore-contract.md) +- [The clone input becomes a Git Server snapshot input forwarded verbatim by the Controller; delivery and session inputs drop bundles and `GrantRevisionDownload` is removed](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md) (approved; the current contract implements it) - [Agent declarations travel with the `install_plugins` input (the install-input part of the Agent configuration metadata decision)](https://github.com/ora-space/specs/blob/main/decisions/cloud/plugin-marketplace/20261017-agent-configuration-metadata.md) diff --git a/docs/runtime/controller-integration.md b/docs/runtime/controller-integration.md index a2ad12fb3..77410ca4b 100644 --- a/docs/runtime/controller-integration.md +++ b/docs/runtime/controller-integration.md @@ -92,7 +92,7 @@ gRPC 状态码是主分类,`ErrorDetail{ErrorCode}` 作为 `google.rpc.Status` | `WorkspaceOperationService` | Workspace 生命周期操作:`ClaimOperation`、`PlanEffect`、`RecordEffectResult`、`AdvanceOperation`、`DeferOperation`、`RecordProvisioningFact`、`ListLiveSandboxes` | | `NodeReportService` | Controller 代报它持有会话的 Node:`RegisterNode`、`ReportNodeStatus`、`EndNode`、`ReportNodeIdle` | | `ExecutionService` | 执行登记:`ClaimWork`、`RecordDispatch`、`TakeOverNodeEvent`、`RecordQueriedResult`、`GetDispatch`、`ListPendingDispatches` | -| `AgentRunService` | Agent 运行:`TakeOverThreadEvents`、`ClaimThreadCommands`、`RecordThreadCommandDelivered`、`GrantRevisionUpload`、`GrantRevisionDownload` | +| `AgentRunService` | Agent 运行:`TakeOverThreadEvents`、`ClaimThreadCommands`、`RecordThreadCommandDelivered`、`GrantRevisionUpload` | | `RuntimeControlService` | 运行时控制:`ListBindings`、`AcknowledgeBinding`、`GetExecutionPermit`、`GetEffectPermit`、`ListForceStops`、`ConfirmForceStop` | | `ControlSignalService` | `Watch` 信号流 | @@ -100,8 +100,11 @@ gRPC 状态码是主分类,`ErrorDetail{ErrorCode}` 作为 `google.rpc.Status` - `ClaimOperation` 领取最早的一个可执行操作(`queued`、到期的 `retry_wait`,或已经 `running` 的),把它置为 `running` 并记录 当前 epoch,**版本加一**,从而让还拿着旧快照的工作者失效;它是一次写入,不是纯读。返回的快照包含该操作所需的全部内容: - Project(含凭据引用)、全部相关 Workspace、沙盒、Node、effect,以及这个操作已有的 clone 和插件执行,恢复中的 Controller - 据此找到原来的执行,而不是登记第二个。 + Project 的身份与生命周期(不带外部仓库地址、默认分支或凭据引用)、全部相关 Workspace、沙盒、Node、effect,以及这个操作 + 已有的 clone 和插件执行,恢复中的 Controller 据此找到原来的执行,而不是登记第二个。处于 `clone` 步骤的操作还带着 Cloud 从 + Binding 当前快照冻结的 `clone_input`(Git Server 仓库地址、快照 ref、`base_commit`、`workspace_ref`),Controller 原样转交给 + `RecordDispatch` 和 Node,没有它就不派发。Cloud 还不能交出的操作(没有快照的 `clone` 步骤、仓库 Operation 未结束时的 + `delete_project`)在同一事务里被停放,领取继续返回下一个可执行的操作(见 [Workspace 生命周期](workspaces.md#clone-步骤从-binding-快照-clone))。 - `RecordProvisioningFact` 在运行 Workspace 的创建被放弃之后,按原操作、effect、执行身份补记迟到的真实事实,不重开任何东西(见 [创建 blocked 的有界放弃](../agent-runs/startup-settlement.md#创建-blocked-的有界放弃))。 - `ListLiveSandboxes` 是租约持有者的只读列表,列出应当持有 Node 会话的沙盒(属于当前代次、未在终止、ensure 已成功),附带 @@ -119,7 +122,7 @@ gRPC 状态码是主分类,`ErrorDetail{ErrorCode}` 作为 `google.rpc.Status` | `install_plugins`、`remove_plugins` | Workspace 操作的 `plugin` 步骤 | `node_executions` | | `agent_session`、`deliver_revision` | Agent 运行声明的执行工作项(`execution_work`) | `node_executions` | -- `ClaimWork` 是**纯读**:返回最早的一个可领取的工作项,归属在 `RecordDispatch` 的事务里才决定,所以 Controller 在领取和 +- `ClaimWork` 是**纯读**:返回最早的一个可领取的 Agent 会话或交付工作项(clone 从来不是工作项,只随操作领取),归属在 `RecordDispatch` 的事务里才决定,所以 Controller 在领取和 登记之间崩溃不会留下需要恢复的东西。 - 属于 IssueRun 的会话与交付工作(工作项带 `run_id`)在其 Run 进入 `releasing`、`done` 或被软删除后,`ClaimWork` 跳过这些未登记工作;有 residual 的 Run 也不参与领取。WorkflowRun 的会话工作带 `workflow_run_id`,不受 IssueRun @@ -135,14 +138,16 @@ gRPC 状态码是主分类,`ErrorDetail{ErrorCode}` 作为 `google.rpc.Status` - `TakeOverNodeEvent` 按执行身份和**连续序号**接管 Node 事件并保存收据,重复事件必须与原收据逐字节一致;会话和交付的终态事件 也走同一个序列,不能越过尚未接管的事件。 -**`AgentRunService`** 承载 Agent 运行的会话面:批量接管 Thread 事件、领取并确认投递 Thread 命令、为交付执行签发对象存储的 -上传和下载授权。语义见 [Thread](../agent-runs/threads.md) 与 [Revision](../agent-runs/revisions.md)。 +**`AgentRunService`** 承载 Agent 运行的会话面:批量接管 Thread 事件、领取并确认投递 Thread 命令、为交付执行签发会话 JSONL 的 +对象存储上传授权;代码经 Git Server 交付,没有下载授权。语义见 [Thread](../agent-runs/threads.md) 与 [Revision](../agent-runs/revisions.md)。 **`RuntimeControlService`** 送达运行时控制绑定、签发执行和 effect 许可、执行强停,见 [运行时控制](runtime-control.md)。 ## 修改契约 - `buf.yaml` 使用 `STANDARD` lint 和 `FILE` 级 breaking 检查:`v1` 只接受非破坏性修改,破坏性变更用并列的 `v2` package 表达。 + 例外:改为 Git 原生的 Workspace 代码(从 Git Server 快照 clone、Revision 经 Git 交付、去掉 bundle)在 `v1` 里删除了字段、消息 + 和 RPC,`buf.yaml` 为此带一次性的 `ignore_only` 豁免,下一次修改该文件时移除。 - 修改 `.proto` 后运行 `task proto:lint` 和 `task proto:breaking`,再运行 `task proto:generate`,把生成物和改动放在同一个提交里。 `task check` 与 CI 都执行 lint、breaking 和生成物漂移检查。 - 合入 main 的那个提交就是消费方可以锁定的契约版本。desktop 以 submodule 锁定 Cloud 的提交并生成自己的客户端,不复制 @@ -158,4 +163,5 @@ gRPC 状态码是主分类,`ErrorDetail{ErrorCode}` 作为 `google.rpc.Status` - [运行时控制由 Cloud 授权、Controller 派发、Node 在真实入口持久隔离旧资格](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260927-fenced-runtime-control-delivery.md) - [Internal Control gRPC 增加插件、Agent 会话与 Revision 交付三类执行](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260928-agent-run-executions-thread-and-upload-grants.md) - [会话与交付执行输入增加前序 Revision,新增 `GrantRevisionDownload`](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261010-revision-restore-contract.md) +- [clone 输入替换为 Git Server 快照输入并由 Controller 原样转交;交付与会话输入去掉 bundle,删除 `GrantRevisionDownload`](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md)(approved;当前契约按它实现) - [Agent 声明随 `install_plugins` 输入携带(Agent 配置元数据决策的安装输入部分)](https://github.com/ora-space/specs/blob/main/decisions/cloud/plugin-marketplace/20261017-agent-configuration-metadata.md) diff --git a/docs/runtime/runtime-control.en.md b/docs/runtime/runtime-control.en.md index a4ce62752..85f1ba97b 100644 --- a/docs/runtime/runtime-control.en.md +++ b/docs/runtime/runtime-control.en.md @@ -135,9 +135,9 @@ immediately, even while an ordinary operation is under way or a page is using it Cloud file, terminal and full Agent product entry points are not open; they must first join the same use-permission, exclusive-control and execution-permit boundaries. Real plugin execution is already -done by the Node (see [Plugin marketplace](../plugins/marketplace.en.md)), but private repository -access that needs a project credential is still not open (see -[Workspace lifecycle](workspaces.en.md#public-workspace-endpoints)). +done by the Node (see [Plugin marketplace](../plugins/marketplace.en.md)). Nodes talk only to the Git Server and use no +repository credential; the external repository is synced by the Repo Executor outside this repository (see +[Workspace lifecycle](workspaces.en.md#public-workspace-endpoints) and [Git Server](../repositories/git-server.en.md)). ## Related decisions diff --git a/docs/runtime/runtime-control.md b/docs/runtime/runtime-control.md index 57203a0cf..64bae7437 100644 --- a/docs/runtime/runtime-control.md +++ b/docs/runtime/runtime-control.md @@ -101,8 +101,8 @@ Controller 租约到期不能由缓存的许可延长。 ## 尚未开放的入口 云端的文件、终端和完整 Agent 产品入口没有开放;它们必须先接入同样的使用权限、独占资格和执行许可边界。插件的真实执行已经由 -Node 承担(见 [插件市场](../plugins/marketplace.md)),但需要项目凭据的私有仓库访问仍未开放(见 -[Workspace 生命周期](workspaces.md#workspace-的公开接口))。 +Node 承担(见 [插件市场](../plugins/marketplace.md))。Node 只与 Git Server 通信,不使用仓库凭据;外部仓库由仓库外的 Repo Executor +同步(见 [Workspace 生命周期](workspaces.md#workspace-的公开接口) 与 [Git Server](../repositories/git-server.md))。 ## 相关决策 diff --git a/docs/runtime/workspaces.en.md b/docs/runtime/workspaces.en.md index 0449d608f..9a1d2c38d 100644 --- a/docs/runtime/workspaces.en.md +++ b/docs/runtime/workspaces.en.md @@ -37,9 +37,11 @@ by the Controller on its behalf. and as a credential foreign key; it is **not** an additional access grant — every current member of the tenant can see and use the Project. - The external repository is recorded on its single repository Binding: URL, default branch and an - optional credential reference. Only `https` or `ssh` URLs are accepted, without query, fragment or - embedded password. The default branch must be a concrete branch name, not `HEAD`, because Cloud - never reads the remote repository and the Node only clones literal branches. + optional credential reference (see [Git Server](../repositories/git-server.en.md)). Only `https` or + `ssh` URLs are accepted, without query, fragment or embedded password. The default branch must be a + concrete branch name, not `HEAD` (`400 default_branch_required`), and a name Git accepts in a ref + (`400 invalid_ref`), because Cloud never reads the remote repository, the Binding syncs exactly this + literal branch, and it is also the short name of each Workspace's branch on the Git Server. - `lifecycle`: `provisioning` (being created) → `active` → `deleting` → `deleted`. When the creating operation reaches a terminal state, success or failure, the Project becomes `active`: what failed is its main Workspace, not the Project. A deletion returns to `active` when a Node refuses to stop. @@ -54,8 +56,12 @@ by the Controller on its behalf. - Each Workspace has its own persistent data, mounted as the Node's home by every generation of its sandbox. Stopping keeps the data; only deletion removes it. Workspaces share no volume, and there is no shared Project repository or linked worktree. -- `requested_ref` is the branch to clone; `base_commit_id` is the commit the clone produced. When - creating an isolated Workspace, `baseRef` set to `HEAD` means the Project's default branch. +- `requested_ref` equals the Project's default branch (the Binding branch). It is the short name of the + Workspace's branch on the Git Server, `refs/heads/workspaces//`, and the + name of the checkout's local branch; `base_commit_id` is the snapshot commit the clone checked out. When + creating an isolated Workspace, `baseRef` may only be omitted, empty, `HEAD` or that branch; any other + value returns `400 base_ref_not_synced` and records nothing, since the snapshot holds only the Binding + branch and another name would get that branch's content. - `creator_user_id` is the user who actually requested the creation. Use of the runtime's content is judged by it, not by the Project owner (see [Runtime control](runtime-control.en.md)). - State is described by three groups of fields: @@ -114,7 +120,7 @@ The `plugin` step is skipped when there are no plugins to handle. What lets each |---|---|---| | `sandbox` | Plans and performs a `sandbox_ensure` effect to create this generation's sandbox | The effect succeeded and its result carries the sandbox ID and the `nodeId` the Node must present | | `node` | Completes the handshake with the sandbox's Node and reports it registered | The current generation's unterminated sandbox has an initialized, connected Node with a heartbeat within 30 seconds, and its `nodeId` matches the ensure result | -| `clone` | Registers and dispatches one clone execution and takes over the Node's result | The latest clone succeeded on the current Node with a real 40- or 64-character commit, which is written to `base_commit_id` | +| `clone` | Registers and dispatches one clone execution with the frozen snapshot input and takes over the Node's result | The latest clone succeeded on the current Node with exactly the input's `baseCommit`, which is written to `base_commit_id` | | `plugin` | Registers and dispatches the plugin install or removal execution | Every item in the frozen plugin set has a result. Item failures are recorded on the instances and do not keep the Workspace from becoming ready | | `quiesce` | Asks the Node to check its own activity atomically and report idleness | No execution is unfinished, and when a sandbox is alive the Node has proven idleness for the current `admission_epoch` | | `terminate` | Plans and performs `sandbox_terminate` | The effect succeeded with `terminated=true`, i.e. the old process is confirmed never to touch the storage again | @@ -124,6 +130,37 @@ The `plugin` step is skipped when there are no plugins to handle. What lets each the operation's success, and the Node is checked once more right before, so admission never opens on a Node that has already gone away. +### The clone step clones the Binding snapshot + +A Workspace never clones the external repository; it clones only the current snapshot of the Project's Binding +on the Git Server: + +- **The input is frozen once.** The transaction that moves the operation into the `clone` step (and every later + claim of a `clone` step not yet frozen) builds the input from the Binding's current snapshot and writes it to + `operations.request.clone`: the repository on the Git Server (`git_server.repository_base_url` plus + `//.git`), the snapshot ref `refs/ora/upstreams//snapshots/`, its commit + `baseCommit`, and the Workspace branch `refs/heads/workspaces//`. Each operation is + frozen once: retries after a failure clone the same snapshot, and later snapshot updates do not change it. +- **Forwarded verbatim.** The snapshot a Controller claims carries this input as `cloneInput`, and `RecordDispatch` + must register exactly it (anything else is a conflict). The Node fetches only that snapshot ref from the Git + Server, and the result's commit must equal `baseCommit`, otherwise `400 base_commit_mismatch` and nothing is + recorded. In the claimed snapshot the Project carries only its identity and lifecycle: no external repository + URL, default branch or credential reference. +- **No snapshot, no dispatch.** While the Binding has no snapshot, Cloud decides by the Binding's latest sync, in the + same transaction as the claim or the step entry, without consuming a clone attempt: while that sync is unfinished + the operation goes to `retry_wait` (`snapshot_not_ready`, claimable again after 10 seconds); once it ended without + success the operation `failed` (`repository_sync_failed`; it records only the sync Operation's ID and no copy of + its error code; the public operation result omits that ID, and a run's `preparation.syncErrorCode` reads the + recorded sync's own code), and a failed `create_project` still leaves the Project `active`. Without a configured + Git Server the operation is `blocked` (`git_server_unconfigured`). A parked operation never empties the claim: the + claim moves on to the next claimable operation. Cloud never falls back to the external repository and never starts + a sync here; creating a run Workspace never syncs implicitly. +- **Public reads do not expose the input.** The public operation resource leaves out the frozen clone input. + +When Cloud parks a run Workspace creation as `blocked`, it freezes the creation's abandonment deadline in the same +transaction, just as a Controller deferral to `blocked` does (see +[Bounded startup](../agent-runs/startup-settlement.en.md#bounded-abandonment-of-a-blocked-creation)). + **Key rules.** - **Clone retries are bounded.** After a Node reports a failed clone, the Controller defers with @@ -149,7 +186,9 @@ Node that has already gone away. - **Deleting a Project closes all its Workspaces at once.** Only administrators may delete. If any Workspace of the Project has activity or is held by a page, the whole deletion is refused; there is no partial deletion. Deletion starts only after syncs or publishes already running on the Project - repository have ended (see [Git Server](../repositories/git-server.en.md)). + repository have ended: while one is unfinished, the claim parks `delete_project` as `retry_wait` + (`repository_operation_running`, claimable again after 10 seconds) without counting an attempt (see + [Git Server](../repositories/git-server.en.md)). ## Effects: durable intent for external side effects @@ -194,14 +233,13 @@ Neither start nor restart clones again; both mount the existing data. **Repository credentials.** A Project can reference one of the creator's own Git credential references (`credential_refs`, which hold infrastructure references, never secret values) at creation. -Production has no executor that can resolve a credential by reference yet, so any new remote Git -operation that needs a credential is refused explicitly with -`409 repository_credential_executor_unavailable`; it never degrades to anonymous access or a global -credential. Anonymous public repositories work as usual. +It is recorded on the Binding for the Repo Executor that syncs the external repository. Repository +credentials gate no Node work: clone, Agent sessions and delivery talk only to the Git Server, Nodes +never reach the external repository, and the snapshot a Controller claims carries no credential reference. -The tenant-level `/tenants/:tid/clones` is an early, standalone clone request API. In production new -requests return `410 runtime_scope_required` and only historical reads remain; Workspace clones always -go through the `clone` step above. +The tenant-level standalone clone request API (`/tenants/:tid/clones`) is gone; migration `0053` dropped +its request table and the clone executions that belonged to no Workspace. Every clone is the `clone` step +of a Workspace operation. ## Related decisions @@ -212,3 +250,4 @@ go through the `clone` step above. - [Internal Control gRPC adds Workspace operations and Controller-reported Nodes](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260926-workspace-operations-and-controller-reported-nodes.md) - [Projects belong to a Space: membership replaces personal ownership as the visibility and authorization boundary](https://github.com/ora-space/specs/blob/main/decisions/cloud/project/0-project-space-scoping.md) - [Repository credentials are managed as team, personal or unknown attribution](https://github.com/ora-space/specs/blob/main/decisions/cloud/project/20260927-repository-credential-continuity.md) (approved) +- [A Workspace's clone step consumes the Binding's current snapshot; tenant-level clones are removed](https://github.com/ora-space/specs/blob/main/decisions/cloud/operation/20261016-clone-step-consumes-binding-snapshot.md) (approved; the clone step section above describes its implementation) diff --git a/docs/runtime/workspaces.md b/docs/runtime/workspaces.md index 9e4b43984..e86bccf68 100644 --- a/docs/runtime/workspaces.md +++ b/docs/runtime/workspaces.md @@ -30,9 +30,10 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按 - 属于租户和它唯一的 Space,`owner_user_id` 是创建者,作为持久归属和凭据外键保留;它**不**是额外的访问授权, 租户的所有当前成员都能看到并使用 Project。 -- 外部仓库记录在它唯一的仓库 Binding 上:地址、默认分支、可选的凭据引用。仓库地址只接受 `https` 或 `ssh`, - 不允许带查询、片段或内嵌密码;默认分支必须是一个具体的分支名,不接受 `HEAD`,因为 Cloud 不读取远端仓库, - Node 也只 clone 字面分支。 +- 外部仓库记录在它唯一的仓库 Binding 上:地址、默认分支、可选的凭据引用(见 [Git Server](../repositories/git-server.md))。 + 仓库地址只接受 `https` 或 `ssh`,不允许带查询、片段或内嵌密码;默认分支必须是一个具体的分支名,不接受 `HEAD` + (`400 default_branch_required`),并且必须是 Git 接受的 ref 名(`400 invalid_ref`),因为 Cloud 不读取远端仓库, + Binding 只同步这一个字面分支,它也是 Workspace 在 Git Server 上分支的短名。 - 生命周期 `lifecycle`:`provisioning`(创建中)→ `active` → `deleting` → `deleted`。创建操作进入终态时,无论 成功还是失败,Project 都进入 `active`:失败的是它的 main Workspace,不是 Project。删除在 Node 拒绝停止时回到 `active`。 @@ -46,8 +47,10 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按 - 每个 Workspace 有自己的持久数据,被它的每一代沙盒挂载为 Node 的 home;停止保留数据,只有删除才删数据。 Workspace 之间不共享卷,也没有共享的 Project 仓库或 linked worktree。 -- `requested_ref` 是要 clone 的分支,`base_commit_id` 是 clone 后得到的基础提交。创建 isolated Workspace 时 - `baseRef` 写 `HEAD` 表示 Project 的默认分支。 +- `requested_ref` 等于 Project 的默认分支(Binding 分支),是 Workspace 在 Git Server 上分支 + `refs/heads/workspaces//` 的短名,也是 checkout 里本地分支的名字;`base_commit_id` 是 + clone 的快照提交。创建 isolated Workspace 时 `baseRef` 只能省略、为空、写 `HEAD` 或写这个分支,其他值返回 + `400 base_ref_not_synced`,不登记任何东西:快照里只有 Binding 分支,叫别的名字会得到这个分支的内容。 - `creator_user_id` 是真正发起创建的用户。运行时内容的使用权限按它判定,而不是按 Project 的 owner(见 [运行时控制](runtime-control.md))。 - 状态由三组字段描述: @@ -100,7 +103,7 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按 |---|---|---| | `sandbox` | 计划并执行 `sandbox_ensure` effect,创建这一代沙盒 | effect 成功,结果里带回沙盒 ID 和 Node 必须出示的 `nodeId` | | `node` | 与沙盒里的 Node 握手,报告它已注册 | 当前代次、未终止的沙盒上有一个已初始化、已连接、30 秒内有心跳的 Node,且 `nodeId` 与 ensure 结果一致 | -| `clone` | 登记并派发一次 clone 执行,接管 Node 的结果 | 最近一次 clone 在当前 Node 上成功,带回真实的 40 或 64 位提交;提交写入 `base_commit_id` | +| `clone` | 按冻结的快照输入登记并派发一次 clone 执行,接管 Node 的结果 | 最近一次 clone 在当前 Node 上成功,带回的提交等于输入的 `baseCommit`;提交写入 `base_commit_id` | | `plugin` | 登记并派发插件安装或移除执行 | 冻结的插件集合里每一项都有结果。单项失败记在实例上,不阻止 Workspace 就绪 | | `quiesce` | 请 Node 原子地检查自身活动并报告空闲 | 没有未结束的执行;有存活沙盒时,Node 已针对当前 `admission_epoch` 给出空闲证明 | | `terminate` | 计划并执行 `sandbox_terminate` | effect 成功且 `terminated=true`,即旧进程确认不会再访问存储 | @@ -109,6 +112,28 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按 "Workspace 就绪"(`observed_state=ready`、`admission_open=true`)和操作成功在同一个事务里提交,提交前再检查一次 Node,保证准入不会在 Node 已经离开时打开。 +### clone 步骤从 Binding 快照 clone + +Workspace 不从外部仓库 clone,只从 Project 的 Binding 在 Git Server 上的当前快照 clone: + +- **输入冻结一次。** 操作进入 `clone` 步骤的事务(以及之后每次领取尚未冻结的 `clone` 步骤)从 Binding 当前快照生成输入,写进 + `operations.request.clone`:Git Server 上的仓库地址(`git_server.repository_base_url` 加 `//.git`)、快照 ref + `refs/ora/upstreams//snapshots/`、它的提交 `baseCommit`,以及 Workspace 分支 + `refs/heads/workspaces//`。每个操作只冻结一次,失败后的重试 clone 同一个快照,之后快照更新也不改变它。 +- **原样转交。** Controller 领取到的快照以 `cloneInput` 带出这份输入,`RecordDispatch` 必须原样登记(不一致即冲突);Node 只从 + Git Server 取这个快照 ref,结果的提交必须等于 `baseCommit`,否则 `400 base_commit_mismatch`,不登记。领取到的快照里 Project + 只有身份与生命周期,不带外部仓库地址、默认分支或凭据引用。 +- **没有快照时不派发。** Binding 还没有快照时,Cloud 在领取或进入步骤的同一事务里按 Binding 最近一次同步决定,不消耗 clone 次数: + 同步尚未结束时操作进入 `retry_wait`(`snapshot_not_ready`,10 秒后再领取);同步已经以非成功终态结束时操作 `failed` + (`repository_sync_failed`,只记录该同步 Operation 的 ID,不另存错误码;公开的操作结果不带这个 ID,运行的 + `preparation.syncErrorCode` 按记录的 ID 取该同步自己的错误码),失败的 `create_project` 照常让 Project 进入 + `active`。没有配置 Git Server 时操作停在 `blocked`(`git_server_unconfigured`)。被停放的操作不会让这次领取落空,领取继续找下一个 + 可领取的操作。Cloud 从不回退到外部仓库,也不在这里触发同步;创建运行 Workspace 不会隐式同步。 +- **公开读取不暴露输入。** 公开的操作资源里没有冻结的 clone 输入。 + +运行 Workspace 的创建被 Cloud 以 `blocked` 停放时,与 Controller 延期为 `blocked` 一样,在同一事务里冻结它的放弃截止时间(见 +[有界启动](../agent-runs/startup-settlement.md#创建-blocked-的有界放弃))。 + **几条关键规则。** - **clone 有界重试。** Node 报告 clone 失败后,Controller 以 `clone_failed` 延期,Cloud 按第 1、2 次失败后等待 @@ -124,7 +149,8 @@ Node,保证准入不会在 Node 已经离开时打开。 - **Node 断连关闭准入。** Controller 报告 Node 断开时,就绪的 Workspace 进入 `unavailable` 并关闭准入;同一个 已初始化的 Node 重新连上后恢复可用,但被撤回的页面操作资格不会因此恢复。 - **删除 Project 一次关闭所有 Workspace。** 只有管理员能删除;Project 下任何一个 Workspace 有活动或被页面占用, - 整个删除就被拒绝,不做部分删除。删除需要等 Project 仓库上已经在运行的同步或发布结束后才开始(见 + 整个删除就被拒绝,不做部分删除。删除需要等 Project 仓库上已经在运行的同步或发布结束后才开始:还有未结束的 + Operation 时,领取把 `delete_project` 停放为 `retry_wait`(`repository_operation_running`,10 秒后再领取),不计次数(见 [Git Server](../repositories/git-server.md))。 ## effect:外部副作用的持久意图 @@ -163,11 +189,11 @@ Controller 对 Substrate 的每个动作都先在 Cloud 计划成一行 `externa 原有数据。 **仓库凭据。** 创建 Project 时可以引用一条属于自己的 Git 凭据引用(`credential_refs`,只保存基础设施引用,不保存 -密钥值)。但生产环境还没有能按引用解析凭据的执行端,所以任何需要凭据的新远程 Git 操作都明确拒绝为 -`409 repository_credential_executor_unavailable`,不会降级成匿名访问或改用全局凭据;匿名的公开仓库照常工作。 +密钥值),它记在 Binding 上,供同步外部仓库的 Repo Executor 使用。仓库凭据不控制任何 Node 工作:clone、Agent 会话与交付 +只与 Git Server 通信,Node 不接触外部仓库,Controller 拿到的快照也不带凭据引用。 -租户级的 `/tenants/:tid/clones` 是早期的独立 clone 请求接口,生产环境里新建请求返回 `410 runtime_scope_required`, -只保留历史读取;Workspace 的 clone 一律走上面的 `clone` 步骤。 +租户级的独立 clone 请求接口(`/tenants/:tid/clones`)已删除,迁移 `0053` 删除了它的请求表和不属于任何 Workspace 的 clone +执行;每个 clone 都是 Workspace 操作的 `clone` 步骤。 ## 相关决策 @@ -178,3 +204,4 @@ Controller 对 Substrate 的每个动作都先在 Cloud 计划成一行 `externa - [Internal Control gRPC 增加 Workspace 操作与 Controller 代报 Node](https://github.com/ora-space/specs/blob/main/decisions/cloud/controller-integration/20260926-workspace-operations-and-controller-reported-nodes.md) - [Project 归属 Space:以成员身份取代个人所有作为可见与授权边界](https://github.com/ora-space/specs/blob/main/decisions/cloud/project/0-project-space-scoping.md) - [仓库访问凭据按团队、个人或未知归属管理](https://github.com/ora-space/specs/blob/main/decisions/cloud/project/20260927-repository-credential-continuity.md)(approved) +- [Workspace 的 clone 步骤改为消费 Binding 当前快照,删除租户级 clone](https://github.com/ora-space/specs/blob/main/decisions/cloud/operation/20261016-clone-step-consumes-binding-snapshot.md)(approved;本文 clone 步骤一节描述的就是它的实现) diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 4956f59ed..0c2c17566 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -132,7 +132,9 @@ seconds): Results win: a committed result settles by its own reason, an unreachable Node is still `node_unavailable`, and only when neither holds does the window apply. A result that arrives after the window already settled the run is kept on the execution row and never rewrites the terminal run. Once the run reaches a terminal state, Cloud declares the deletion - of its Workspace. + of its Workspace. In the first phase this session has no Git Server + access (reads and writes get `403`), and its Workspace is not listed in an IssueRun session's Workspace directory; + see [Git Server](../repositories/git-server.en.md). - **A workflow session is single-turn, and Cloud decides when it ends.** Once the initial turn's `turnEnded` receipt arrives (with a `stop_reason` other than `cancelled`), the scheduler ends the session through the existing `EndSession` command relay; the session result the Node reports remains the node's only terminal evidence. If the diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 4d628886b..a8108484a 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -90,7 +90,8 @@ WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce `workflow.session_turn_timeout`(默认 2 小时)后节点以 `session_turn_deadline_exceeded` 失败,运行进入终态。这也是 Cloud 自己下的 判断,按执行行计时(重试产生的新执行行重新计时),只使用数据库时钟,回放、重启和副本推进都不会重置。判断顺序是结果优先: 已提交的结果按它自己的原因结算,Node 不可达仍报 `node_unavailable`,两者都不成立时才轮到窗口;窗口把运行带入终态之后才到达的结果 - 保留在执行行上,不改写已经终态的运行。运行进入终态后,Cloud 声明删除这次运行的 Workspace。 + 保留在执行行上,不改写已经终态的运行。运行进入终态后,Cloud 声明删除这次运行的 Workspace。第一阶段这个会话没有 Git Server 访问(读写都返回 `403`),它的 Workspace 也不进 IssueRun 会话的 Workspace + 目录,见 [Git Server](../repositories/git-server.md)。 - **工作流会话是单回合的,结束由 Cloud 决定。** 初始回合的 `turnEnded` 回执(`stop_reason` 不是 `cancelled`)到达后,调度器通过既有的 `EndSession` 命令让 Node 结束会话;Node 上报的会话执行结果仍是节点终态的唯一证据。结束请求放出后超过确认窗口仍没有会话终态时,节点以 `session_end_deadline_exceeded` 失败,运行进入终态并释放 Workspace——不会再有永久 `running` 的运行。 diff --git a/frontend/src/README.en.md b/frontend/src/README.en.md index 3c3ee45b4..ce4193b7d 100644 --- a/frontend/src/README.en.md +++ b/frontend/src/README.en.md @@ -22,7 +22,6 @@ Browser entry point and composition root. This layer only wires things together: | `api/` | **Generated** by orval; never hand-edited, see [`../README.en.md`](../README.en.md). | | `components/ui/` | Presentational primitives (shadcn/ui) with no business meaning. | | `features/auth/` | Session and login boundary: gateway login/logout, the `/api/v1/me` probe, the 401 policy, the route gate. | -| `features/clones/` | The 仓库 (repositories) page: submits clones through the public clones API, follows the results Nodes report, and never resubmits an unconfirmed request as a new one. | | `features/onboarding/` | First-workspace creation screen. | | `features/model-connections/` | Private model connections, credential writes and defaults for new sessions at `settings/model-connections`. | | `features/spaces/` | Collaboration space adapter: tenant/space resolution, space APIs, SSE subscription. | diff --git a/frontend/src/README.md b/frontend/src/README.md index 71d9a374d..fbcbef7fd 100644 --- a/frontend/src/README.md +++ b/frontend/src/README.md @@ -22,7 +22,6 @@ | `api/` | **生成物**(orval),禁止手改,见 [`../README.md`](../README.md)。 | | `components/ui/` | 无业务语义的展示原语(shadcn/ui)。 | | `features/auth/` | 会话与登录边界:Gateway 登录/登出、`/api/v1/me` 探测、401 策略、路由门禁。 | -| `features/clones/` | 「仓库」页:经公开 clones API 提交 clone、跟踪 Node 回报的结果,并保证待确认请求不会被重复提交。 | | `features/onboarding/` | 首个工作区创建页。 | | `features/model-connections/` | 当前用户私有的模型连接、写入凭据与新会话默认模型;路由为 `settings/model-connections`。 | | `features/spaces/` | 协作空间接入层:租户/空间解析、空间 API、SSE 订阅。 | diff --git a/frontend/src/api/clones/clones.ts b/frontend/src/api/clones/clones.ts deleted file mode 100644 index 4e540e903..000000000 --- a/frontend/src/api/clones/clones.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * Generated by orval 🍺 - * Do not edit manually. - * Ora Cloud phase one - * Authoritative PostgreSQL core. Simulation is separate; no production Controller/Node/Kubernetes implementation is implied. - * OpenAPI spec version: 1.0.0 - */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; -import type { - DataTag, - DefinedInitialDataOptions, - DefinedUseQueryResult, - MutationFunction, - QueryClient, - QueryFunction, - QueryKey, - UndefinedInitialDataOptions, - UseMutationOptions, - UseMutationResult, - UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; - -import type { - CloneOperation, - Error, - GetApiV1TenantsTidClones200, - GetApiV1TenantsTidClonesParams, - PostApiV1TenantsTidClonesBody -} from '../generated.schemas'; - -import { customInstance } from '../../lib/api-client'; -import type { ErrorType } from '../../lib/api-client'; - - -type SecondParameter unknown> = Parameters[1]; - - - -const withQueryKey = (query: T, queryKey: K): T & { queryKey: K } => { - const result = { queryKey } as T & { queryKey: K }; - for (const key of Object.keys(query)) { - // The explicit queryKey always wins, matching the previous - // `{ ...query, queryKey }` spread where it was set last. - if (key === 'queryKey') continue; - Object.defineProperty(result, key, { - enumerable: true, - configurable: true, - get: () => (query as Record)[key], - }); - } - return result; -}; - -/** - * Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination. - * @summary GET /api/v1/tenants/:tid/clones - */ -export const getApiV1TenantsTidClones = ( - tid: string, - params?: GetApiV1TenantsTidClonesParams, - options?: SecondParameter,signal?: AbortSignal -) => { - - - return customInstance( - {url: `/api/v1/tenants/${tid}/clones`, method: 'GET', - params, signal - }, - options); - } - - - - -export const getGetApiV1TenantsTidClonesQueryKey = (tid: string, - params?: GetApiV1TenantsTidClonesParams,) => { - return [ - `/api/v1/tenants/${tid}/clones`, ...(params ? [params] : []) - ] as const; - } - - -export const getGetApiV1TenantsTidClonesQueryOptions = >, TError = ErrorType>(tid: string, - params?: GetApiV1TenantsTidClonesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetApiV1TenantsTidClonesQueryKey(tid,params); - - - - const queryFn: QueryFunction>> = ({ signal }) => getApiV1TenantsTidClones(tid,params, requestOptions, signal); - - - - - - return { queryKey, queryFn, enabled: tid !== null && tid !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type GetApiV1TenantsTidClonesQueryResult = NonNullable>> -export type GetApiV1TenantsTidClonesQueryError = ErrorType - - -export function useGetApiV1TenantsTidClones>, TError = ErrorType>( - tid: string, - params: undefined | GetApiV1TenantsTidClonesParams, options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetApiV1TenantsTidClones>, TError = ErrorType>( - tid: string, - params?: GetApiV1TenantsTidClonesParams, options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetApiV1TenantsTidClones>, TError = ErrorType>( - tid: string, - params?: GetApiV1TenantsTidClonesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -/** - * @summary GET /api/v1/tenants/:tid/clones - */ - -export function useGetApiV1TenantsTidClones>, TError = ErrorType>( - tid: string, - params?: GetApiV1TenantsTidClonesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetApiV1TenantsTidClonesQueryOptions(tid,params,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return withQueryKey(query, queryOptions.queryKey); -} - - - - - - -/** - * Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination. - * @summary POST /api/v1/tenants/:tid/clones - */ -export const postApiV1TenantsTidClones = ( - tid: string, - postApiV1TenantsTidClonesBody: PostApiV1TenantsTidClonesBody, - options?: SecondParameter,signal?: AbortSignal -) => { - - - return customInstance( - {url: `/api/v1/tenants/${tid}/clones`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: postApiV1TenantsTidClonesBody, signal - }, - options); - } - - - - -export const getPostApiV1TenantsTidClonesMutationKey = () => ['postApiV1TenantsTidClones'] as const; - -export const getPostApiV1TenantsTidClonesMutationOptions = , - TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,PostApiV1TenantsTidClonesMutationVariables, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,PostApiV1TenantsTidClonesMutationVariables, TContext> => { - -const mutationKey = getPostApiV1TenantsTidClonesMutationKey(); -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, PostApiV1TenantsTidClonesMutationVariables> = (props) => { - const {tid,data} = props ?? {}; - - return postApiV1TenantsTidClones(tid,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type PostApiV1TenantsTidClonesMutationResult = NonNullable>> - export type PostApiV1TenantsTidClonesMutationBody = PostApiV1TenantsTidClonesBody - export type PostApiV1TenantsTidClonesMutationError = ErrorType - export type PostApiV1TenantsTidClonesMutationVariables = {tid: string;data: PostApiV1TenantsTidClonesBody} - - /** - * @summary POST /api/v1/tenants/:tid/clones - */ -export const usePostApiV1TenantsTidClones = , - TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,PostApiV1TenantsTidClonesMutationVariables, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - PostApiV1TenantsTidClonesMutationVariables, - TContext - > => { - return useMutation(getPostApiV1TenantsTidClonesMutationOptions(options), queryClient); - } - /** - * Public requests require a gateway service credential plus a caller-bound user credential. Active tenant membership is checked before lookup. Shared projects expose safe runtime summaries; runtime content and use require the verified creator or current tenant administrator. Conflicting mutations additionally require an effective server-confirmed control session. Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination. - * @summary GET /api/v1/tenants/:tid/clones/:cloneId - */ -export const getApiV1TenantsTidClonesCloneId = ( - tid: string, - cloneId: string, - options?: SecondParameter,signal?: AbortSignal -) => { - - - return customInstance( - {url: `/api/v1/tenants/${tid}/clones/${cloneId}`, method: 'GET', signal - }, - options); - } - - - - -export const getGetApiV1TenantsTidClonesCloneIdQueryKey = (tid: string, - cloneId: string,) => { - return [ - `/api/v1/tenants/${tid}/clones/${cloneId}` - ] as const; - } - - -export const getGetApiV1TenantsTidClonesCloneIdQueryOptions = >, TError = ErrorType>(tid: string, - cloneId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetApiV1TenantsTidClonesCloneIdQueryKey(tid,cloneId); - - - - const queryFn: QueryFunction>> = ({ signal }) => getApiV1TenantsTidClonesCloneId(tid,cloneId, requestOptions, signal); - - - - - - return { queryKey, queryFn, enabled: tid !== null && tid !== undefined && cloneId !== null && cloneId !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type GetApiV1TenantsTidClonesCloneIdQueryResult = NonNullable>> -export type GetApiV1TenantsTidClonesCloneIdQueryError = ErrorType - - -export function useGetApiV1TenantsTidClonesCloneId>, TError = ErrorType>( - tid: string, - cloneId: string, options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetApiV1TenantsTidClonesCloneId>, TError = ErrorType>( - tid: string, - cloneId: string, options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetApiV1TenantsTidClonesCloneId>, TError = ErrorType>( - tid: string, - cloneId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -/** - * @summary GET /api/v1/tenants/:tid/clones/:cloneId - */ - -export function useGetApiV1TenantsTidClonesCloneId>, TError = ErrorType>( - tid: string, - cloneId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetApiV1TenantsTidClonesCloneIdQueryOptions(tid,cloneId,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return withQueryKey(query, queryOptions.queryKey); -} - - - - - - diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index 6d17e8ae2..3517c7018 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -164,8 +164,6 @@ export type CloneExecutionInput = { [key: string]: unknown }; export type CloneExecutionResult = { [key: string]: unknown } | null; export interface CloneExecution { - /** @nullable */ - cloneRequestId: string | null; createdAt: string; /** @nullable */ credentialRefId?: string | null; @@ -182,51 +180,23 @@ export interface CloneExecution { /** @nullable */ terminatedByForceStopId: string | null; updatedAt: string; - /** @nullable */ - workspaceId: string | null; + workspaceId: string; } -export type CloneStateKind = typeof CloneStateKind[keyof typeof CloneStateKind]; - - -export const CloneStateKind = { - pending: 'pending', - succeeded: 'succeeded', - failed: 'failed', -} as const; - -export type CloneStateReason = typeof CloneStateReason[keyof typeof CloneStateReason]; +export type CloneInputKind = typeof CloneInputKind[keyof typeof CloneInputKind]; -export const CloneStateReason = { - sourceUnavailable: 'sourceUnavailable', - branchNotFound: 'branchNotFound', - destinationConflict: 'destinationConflict', - operationFailed: 'operationFailed', - interrupted: 'interrupted', - unspecified: 'unspecified', +export const CloneInputKind = { + clone: 'clone', } as const; -export interface CloneState { - commit?: string; - kind: CloneStateKind; - path?: string; - reason?: CloneStateReason; - retainedPath?: string; -} - -export interface CloneOperation { - branch: string; - createdAt: string; - /** @nullable */ - executionId: string | null; - /** @nullable */ - nodeId: string | null; - operationId: string; - repository: string; - requestId: string; - state: CloneState; - updatedAt: string; +export interface CloneInput { + /** @pattern ^([0-9a-f]{40}|[0-9a-f]{64})$ */ + baseCommit: string; + kind: CloneInputKind; + repositoryUrl: string; + snapshotRef: string; + workspaceRef: string; } export type CollaborationTargetType = typeof CollaborationTargetType[keyof typeof CollaborationTargetType]; @@ -308,17 +278,11 @@ export interface ContextRef { export interface ControllerProject { createdAt: string; /** @nullable */ - credentialRefId: string | null; - defaultBranch: string; - /** @nullable */ deletedAt: string | null; id: string; lifecycle: string; name: string; ownerUserId: string; - repositoryUrl: string; - /** @nullable */ - secretRef: string | null; spaceId: string; tenantId: string; version: number; @@ -1414,6 +1378,7 @@ export type OperationRequestPluginsItem = { [key: string]: unknown }; export type OperationRequestPrevious = {[key: string]: Workspace}; export interface OperationRequest { + clone?: CloneInput; desiredRevision?: number; pluginId?: string; pluginMeta?: OperationRequestPluginMetaItem[]; @@ -2136,6 +2101,7 @@ export interface SlotMapping { export type SnapshotPluginInput = { [key: string]: unknown }; export interface Snapshot { + cloneInput?: CloneInput; clones: CloneExecution[]; effects: Effect[]; nodes: Node[]; @@ -3033,29 +2999,6 @@ export type GetApiV1TenantsTidAgentSessions200 = { nextCursor: string; }; -export type GetApiV1TenantsTidClonesParams = { -/** - * @minimum 1 - * @maximum 100 - */ -limit?: number; -/** - * Exclusive UUID cursor; Agent sessions use descending (createdAt, id), other lists use ascending stable ordering. - */ -after?: string; -}; - -export type GetApiV1TenantsTidClones200 = { - items: CloneOperation[]; - nextCursor: string; -}; - -export type PostApiV1TenantsTidClonesBody = { - branch: string; - repository: string; - requestId: string; -}; - export type GetApiV1TenantsTidCollaborationFormsFormRefParams = { /** * Issue the form is being configured for. Tailors the descriptor with the platform fields (repository, branch, prompt, snapshotId) and prefills them from the issue's project repository and the workflow's Start prompt. An unknown or foreign issue is 404. @@ -4461,6 +4404,14 @@ export type PostApiV1TenantsTidWorkspacesWidStop202 = { resource: Workspace; }; +export type Health200DependenciesGitServer = typeof Health200DependenciesGitServer[keyof typeof Health200DependenciesGitServer]; + + +export const Health200DependenciesGitServer = { + configured: 'configured', + unconfigured: 'unconfigured', +} as const; + export type Health200DependenciesObjectStore = typeof Health200DependenciesObjectStore[keyof typeof Health200DependenciesObjectStore]; @@ -4470,6 +4421,7 @@ export const Health200DependenciesObjectStore = { } as const; export type Health200Dependencies = { + gitServer: Health200DependenciesGitServer; objectStore: Health200DependenciesObjectStore; }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 153092359..b53c0db71 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,3 @@ -export * from './clones/clones'; export * from './git-credentials/git-credentials'; export * from './health/health'; export * from './internal/internal'; diff --git a/frontend/src/api/internal/internal.ts b/frontend/src/api/internal/internal.ts index 312a043e2..722b01c05 100644 --- a/frontend/src/api/internal/internal.ts +++ b/frontend/src/api/internal/internal.ts @@ -666,7 +666,7 @@ export const usePostInternalV1NodesTicketsTicketFinish = , return useMutation(getPostInternalV1OperationsClaimMutationOptions(options), queryClient); } /** - * Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. The clone step requires the operation's latest clone execution (registered over gRPC) to have succeeded on the current Node; it records the baseline commit and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation. + * Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. Entering the clone step freezes its cloneInput, or parks or fails the operation as claim does. The clone step requires the operation's latest clone execution (registered over gRPC with exactly the frozen input) to have succeeded on the current Node with exactly the input's baseCommit; it records that commit as the baseline and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation. * @summary advance */ export const postInternalV1OperationsOidAdvance = ( diff --git a/frontend/src/api/projects/projects.ts b/frontend/src/api/projects/projects.ts index 478e20bcd..1d2a7b573 100644 --- a/frontend/src/api/projects/projects.ts +++ b/frontend/src/api/projects/projects.ts @@ -161,7 +161,7 @@ export function useGetApiV1TenantsTidProjects/. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination. * @summary POST /api/v1/tenants/:tid/projects/:pid/workspaces */ export const postApiV1TenantsTidProjectsPidWorkspaces = ( diff --git a/frontend/src/components/layout/README.en.md b/frontend/src/components/layout/README.en.md index 28d304a49..67b2e1e8c 100644 --- a/frontend/src/components/layout/README.en.md +++ b/frontend/src/components/layout/README.en.md @@ -4,8 +4,8 @@ This module composes the sidebar, page header, and route outlet. Authentication is not decided here: the router wraps `DashboardLayout` in `RequireSession` from `features/auth`, which sends a 401 to the login page while preserving the current in-app path, reports a 403 (disabled account) in place, and offers a retry for transient failures. Only once the session is confirmed does this module resolve `:workspaceSlug` against the member's real spaces (`CurrentSpaceProvider`) and subscribe to that space's event stream; a member with no space is sent to `/onboarding`, and an unknown or archived slug falls back to the first space. -`AppSidebar` reads the current user through `useSession` and lists every tenant space returned by `/me/spaces`. After switching, resource calls use the selected space's `tenantId`; "new workspace" provisions a new tenant. It also offers sign-out and, when the gateway lists `github`, GitHub logout. Business navigation may still use simulated data, but this module must never store tokens or duplicate authentication state. +`AppSidebar` reads the current user through `useSession` and lists every tenant space returned by `/me/spaces`. After switching, resource calls use the selected space's `tenantId`; "new workspace" provisions a new tenant. It also offers sign-out and, when the gateway lists `github`, GitHub logout. The work navigation has exactly three entries: issues, projects and workflows (there is no separate 仓库 (repositories) page: repositories are bound by projects and Workspaces fetch from Git Server snapshots). Business navigation may still use simulated data, but this module must never store tokens or duplicate authentication state. -Tests cover current-user display, space resolution and fallback, event-stream subscription, sign-out and the GitHub sign-out. +Tests cover the work navigation entries, current-user display, space resolution and fallback, event-stream subscription, sign-out and the GitHub sign-out. Session analysis sits directly below Workflows in the sidebar's Work group, uses `/w/:workspaceSlug/sessions`, and links to Agent details at `/sessions/agent/:issueId/:runId`. `workspacePaths` owns these links. Layout tests and `session-routes.test.tsx` cover the sidebar order and real tenant route boundary. `features/sessions`, `session-data` and `session-timeline` implement the feature. diff --git a/frontend/src/components/layout/README.md b/frontend/src/components/layout/README.md index 48275262b..3f3493562 100644 --- a/frontend/src/components/layout/README.md +++ b/frontend/src/components/layout/README.md @@ -4,8 +4,8 @@ 本模块组合侧边栏、页面页眉和路由出口。认证不在这里做:路由把 `DashboardLayout` 包在 `features/auth` 的 `RequireSession` 里,401 转到登录页并保留当前站内路径,403 就地提示账号停用,临时故障就地提示重试。本模块只在会话已确认后把 `:workspaceSlug` 解析为成员真实加入的空间(`CurrentSpaceProvider`),订阅该空间的事件流;没有任何空间时转到 `/onboarding`,未知或已归档的 slug 回落到第一个空间。 -`AppSidebar` 通过 `useSession` 读取当前用户,列出 `/me/spaces` 返回的所有租户空间。切换后以所选空间的 `tenantId` 访问资源;“新建工作区”创建新租户。它还提供退出登录,以及 Gateway 列出 `github` 时的 GitHub 注销。业务导航仍可使用模拟数据,但不得在本模块保存 token 或复制认证状态。 +`AppSidebar` 通过 `useSession` 读取当前用户,列出 `/me/spaces` 返回的所有租户空间。切换后以所选空间的 `tenantId` 访问资源;“新建工作区”创建新租户。它还提供退出登录,以及 Gateway 列出 `github` 时的 GitHub 注销。工作导航只有任务、项目、工作流三项(没有独立的「仓库」页:仓库由项目绑定,Workspace 从 Git Server 快照获取)。业务导航仍可使用模拟数据,但不得在本模块保存 token 或复制认证状态。 -测试覆盖当前用户展示、空间解析与回落、事件流订阅、退出与 GitHub 注销。 +测试覆盖工作导航条目、当前用户展示、空间解析与回落、事件流订阅、退出与 GitHub 注销。 会话分析入口位于侧栏“工作”分组的“工作流”正下方,路径为 `/w/:workspaceSlug/sessions`,Agent 详情为 `/sessions/agent/:issueId/:runId`。路径由 `workspacePaths` 统一生成;侧栏入口顺序与真实租户路由分别由布局测试、`session-routes.test.tsx` 覆盖。详情功能由 `features/sessions`、`session-data`、`session-timeline` 提供。 diff --git a/frontend/src/components/layout/app-sidebar.test.tsx b/frontend/src/components/layout/app-sidebar.test.tsx index 575cbde01..18344d5e7 100644 --- a/frontend/src/components/layout/app-sidebar.test.tsx +++ b/frontend/src/components/layout/app-sidebar.test.tsx @@ -108,6 +108,18 @@ describe('AppSidebar workspace switcher', () => { }) }) + it('lists exactly the work entries, with no repositories page', async () => { + installTwoSpaces() + renderDashboard('/w/cloud-dev/issues') + await screen.findByText('Issues screen') + + const issues = screen.getByRole('link', { name: '任务' }) + const group = issues.closest('[data-sidebar="group"]') + const labels = Array.from(group?.querySelectorAll('a') ?? [], (link) => link.textContent) + expect(labels).toEqual(['任务', '项目', '工作流', '会话分析']) + expect(screen.queryByRole('link', { name: /仓库/ })).not.toBeInTheDocument() + }) + it('offers the plugin entry beside skills and routes to the plugin page', async () => { installTwoSpaces() const user = userEvent.setup() diff --git a/frontend/src/components/layout/app-sidebar.tsx b/frontend/src/components/layout/app-sidebar.tsx index f5ffb5e76..6dabf6a0b 100644 --- a/frontend/src/components/layout/app-sidebar.tsx +++ b/frontend/src/components/layout/app-sidebar.tsx @@ -5,7 +5,6 @@ import { ChevronDown, CircuitBoard, Cog, - FolderGit2, Inbox, Layers, ListTodo, @@ -55,7 +54,6 @@ import { workspacePaths } from '@/lib/paths' const workNav = [ { to: (p: ReturnType) => p.issues, label: '任务', icon: Layers }, { to: (p: ReturnType) => p.projects, label: '项目', icon: CircuitBoard }, - { to: (p: ReturnType) => p.repositories, label: '仓库', icon: FolderGit2 }, { to: (p: ReturnType) => p.workflows, label: '工作流', icon: Workflow }, { to: (p: ReturnType) => p.sessions, label: '会话分析', icon: Activity }, ] diff --git a/frontend/src/features/clones/README.en.md b/frontend/src/features/clones/README.en.md deleted file mode 100644 index 3840d7d95..000000000 --- a/frontend/src/features/clones/README.en.md +++ /dev/null @@ -1,45 +0,0 @@ -# clones: the 仓库 (repositories) page - -[中文](README.md) | [English](README.en.md) - -## Responsibility - -The page behind the 仓库 sidebar entry. It submits clones through the public clones API (`/api/v1/tenants/{tid}/clones`); a Controller claims them and a Node runs `git clone` onto its disk. The page only shows the facts Cloud records. It owns: - -- listing the clones the current member submitted in the tenant behind the current space, newest `createdAt` first. Cloud shows each submitter only their own records, so every space of one tenant shows the same list; -- polling every 2 seconds while a clone is pending or the last read failed, and stopping once every clone is terminal; -- writing the submission identity (`requestId`, repository, branch) to the tab's sessionStorage before sending, and clearing it only once Cloud accepted or refused it. After a lost response or a reload the only action offered is "resend the original request". - -Not owned: projects (`features/projects`, a separate operation model), session and login (`features/auth`), tenant and space resolution (`features/spaces`), private-repository credentials. - -## Files - -| File | Purpose | -|---|---| -| `api.ts` | `useCloneList` (the polling list query), `useSubmitClone` (submission; `mutationHeaders` from `features/spaces` sets `Idempotency-Key` to the `requestId`, and fault codes are read with `faultCode` from `lib/api-client`), and the pure `newestFirst`, `awaitsResult`, `classifySubmitFailure` | -| `pending.ts` | The sessionStorage boundary: `pendingSubmissionKey` (one slot per tenant and user), `readPendingSubmission`, `writePendingSubmission` | -| `use-clone-submission.ts` | The submission state machine: store the identity before sending, keep or clear it by failure kind, treat a listed `requestId` as accepted | -| `status.ts` | The stage derived from Cloud's facts (queued / dispatched / succeeded / failed), badge variants, and failure-reason labels | -| `messages.ts` | Submission notices and `rejectionMessage`, which explains a refusal by its fault code | -| `clone-dialog.tsx` | The "Clone 仓库" dialog: a form for a new submission, or a read-only summary of the unconfirmed one with "重试原请求" | -| `clone-list.tsx` | The results table: repository and branch, stage badge, commit / Node path or failure reason / retained path, submission time | -| `repositories-page.tsx` | Page composition: requests nothing until the space and session resolved; remounts per storage key so a user or tenant switch reads its own unconfirmed submission | -| `*.test.ts(x)` | See Testing | - -## Dependencies - -Depends on: `src/api` (the generated clones client), `lib/api-client` (`faultCode`), `features/auth/session` (user id), `features/spaces/current-space` (tenant id), `features/spaces/api` (`mutationHeaders`), `components/ui`, `components/layout/page-header`, `components/common/dialog-form-field`, TanStack Query. - -May be used by: `routes.tsx` (`/w/:workspaceSlug/repositories`). - -## Invariants - -- Pending only means "no terminal fact yet". It is never shown as failure, timeout or progress. Queued and dispatched differ only by whether an `executionId` was recorded. -- An unconfirmed submission may only be resent unchanged. Its identity is cleared only when Cloud refused it outright (400/403/404/409/422) or accepted it. A network failure, 5xx, 401 or 429 keeps it. -- The storage key must include both the tenant id and the user id. Otherwise, after another account signs in to the same tab, that account could resend the previous account's request. -- When storage cannot be read (it throws, or holds corrupt content), no new identity is minted; that state is never treated as "nothing pending". -- Repository and branch are trimmed before sending. Cloud refuses any whitespace or control character; the client-side checks only give earlier feedback. - -## Testing - -`pending.test.ts` covers slot isolation, the read/write round trip and every unreadable case. `api.test.ts` covers ordering, the polling predicate, failure classification and refusal messages. `repositories-page.test.tsx` uses MSW to cover: listed facts and ordering (including the interrupted-failure label and retained path); a trimmed submission whose idempotency key equals its `requestId`; an unchanged resend after a lost response; restoring after a reload without resending; confirming once the list shows the request; clearing and explaining a refusal; never exposing another user's unconfirmed submission; polling that recovers from a failed read and stops at terminal states; and sending nothing when storage cannot be read or written. The polling test fakes only `setInterval` and keeps every other timer real, so MSW and findBy do not wait on each other. diff --git a/frontend/src/features/clones/README.md b/frontend/src/features/clones/README.md deleted file mode 100644 index 97cb3d322..000000000 --- a/frontend/src/features/clones/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# clones:「仓库」页 - -[中文](README.md) | [English](README.en.md) - -## 职责 - -侧边栏「仓库」对应的页面:经公开 clones API(`/api/v1/tenants/{tid}/clones`)提交 clone,由 Controller 领取、Node 执行 `git clone` 落盘,页面只展示 Cloud 记录的事实。它负责: - -- 列出当前成员在当前空间所属租户下提交的 clone(Cloud 只让提交者本人看到自己的记录,同一租户的不同空间看到的是同一份列表),按 `createdAt` 倒序; -- 有 pending 或上次查询失败时每 2 秒轮询一次,全部到终态后停止; -- 提交前把请求身份(`requestId`、仓库、分支)写进当前标签页的 sessionStorage,确认被接受或被拒绝后才清除,回复丢失或刷新后只能「重试原请求」。 - -不负责:项目(`features/projects`,那是另一套 operation 模型)、会话与登录(`features/auth`)、租户与空间解析(`features/spaces`)、私有仓库凭据。 - -## 文件 - -| 文件 | 说明 | -|---|---| -| `api.ts` | `useCloneList`(带轮询的列表查询)、`useSubmitClone`(提交,经 `features/spaces` 的 `mutationHeaders` 以 `requestId` 作 `Idempotency-Key`;故障码经 `lib/api-client` 的 `faultCode` 读取),以及纯函数 `newestFirst`、`awaitsResult`、`classifySubmitFailure` | -| `pending.ts` | sessionStorage 边界:`pendingSubmissionKey`(按租户和用户分槽)、`readPendingSubmission`、`writePendingSubmission` | -| `use-clone-submission.ts` | 提交状态机:先存身份再发送,按失败类型保留或清除,列表里出现该 `requestId` 即视为已接受 | -| `status.ts` | 由事实推导的阶段(排队中/已派发/已完成/失败)、徽章样式与失败原因文案 | -| `messages.ts` | 提交提示与按故障码解释拒绝原因的 `rejectionMessage` | -| `clone-dialog.tsx` | 「Clone 仓库」对话框:新提交的表单,或待确认提交的只读摘要加「重试原请求」 | -| `clone-list.tsx` | 结果表格:仓库与分支、阶段徽章、commit/Node 路径或失败原因/残留路径、提交时间 | -| `repositories-page.tsx` | 页面组合:空间和会话就绪前不发任何请求;按存储键重挂载,切换用户或租户时读取各自的待确认提交 | -| `*.test.ts(x)` | 见下文「测试」 | - -## 依赖与使用 - -依赖:`src/api`(生成的 clones 客户端)、`lib/api-client`(`faultCode`)、`features/auth/session`(用户 id)、`features/spaces/current-space`(租户 id)、`features/spaces/api`(`mutationHeaders`)、`components/ui`、`components/layout/page-header`、`components/common/dialog-form-field`、TanStack Query。 - -可被依赖:`routes.tsx`(`/w/:workspaceSlug/repositories`)。 - -## 不变量 - -- pending 只表示「还没有终态事实」,不能显示成失败、超时或进度。排队中与已派发的区别只看 `executionId` 是否已记录。 -- 同一个待确认提交只能原样重发。只有 Cloud 明确拒绝(400/403/404/409/422)或已接受时才清除身份;断网、5xx、401、429 都保留。 -- 存储键必须同时包含租户 id 和用户 id,否则同一标签页里换账号登录后,可能用新账号重发旧账号的请求。 -- 存储读不出来时(抛异常或内容损坏)拒绝生成新的身份,不能当作「没有待确认提交」。 -- 分支和仓库地址在发送前 trim。Cloud 会拒绝任何含空白或控制字符的输入,前端校验只用于尽早提示。 - -## 测试 - -`pending.test.ts` 覆盖存储槽隔离、读写往返和各种不可读情况。`api.test.ts` 覆盖排序、轮询判断、失败分类和拒绝文案。`repositories-page.test.tsx` 用 MSW 覆盖:列表事实与排序(含中断失败的文案与残留路径)、trim 后提交且幂等键等于 `requestId`、回复丢失后原样重发、刷新恢复而不自动重发、列表出现即确认、拒绝后清除并解释、不暴露其他用户的待确认提交、轮询在失败后自动恢复并在终态后停止、存储不可读或不可写时不发请求。轮询测试只伪造 `setInterval`,其余计时保持真实,避免与 MSW 和 findBy 互相等待。 diff --git a/frontend/src/features/clones/api.test.ts b/frontend/src/features/clones/api.test.ts deleted file mode 100644 index 506b58988..000000000 --- a/frontend/src/features/clones/api.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AxiosError, AxiosHeaders } from 'axios' -import { describe, expect, it } from 'vitest' -import type { CloneOperation } from '@/api/generated.schemas' -import { rejectionMessage } from './messages' -import { awaitsResult, classifySubmitFailure, newestFirst } from './api' - -function operation(operationId: string, createdAt: string, kind: 'pending' | 'succeeded') { - return { - operationId, - requestId: `request-${operationId}`, - repository: 'https://github.com/octocat/Hello-World', - branch: 'master', - executionId: null, - nodeId: null, - state: { kind }, - createdAt, - updatedAt: createdAt, - } satisfies CloneOperation -} - -function httpError(status: number, data: unknown) { - const config = { headers: new AxiosHeaders() } - return new AxiosError('request failed', 'ERR_BAD_REQUEST', config, undefined, { - status, - statusText: '', - data, - headers: {}, - config, - }) -} - -describe('newestFirst', () => { - it('orders by creation time whatever order Cloud paged in, breaking ties by id', () => { - const ordered = newestFirst([ - operation('b', '2026-09-23T08:00:00Z', 'pending'), - operation('c', '2026-09-23T09:00:00+01:00', 'pending'), - operation('a', '2026-09-23T08:00:00Z', 'pending'), - operation('d', '2026-09-23T10:00:00Z', 'pending'), - ]) - expect(ordered.map((o) => o.operationId)).toEqual(['d', 'a', 'b', 'c']) - }) -}) - -describe('awaitsResult', () => { - it('is true only while some operation has no terminal fact yet', () => { - expect(awaitsResult(undefined)).toBe(false) - expect(awaitsResult([operation('a', '2026-09-23T08:00:00Z', 'succeeded')])).toBe(false) - expect( - awaitsResult([ - operation('a', '2026-09-23T08:00:00Z', 'succeeded'), - operation('b', '2026-09-23T08:00:00Z', 'pending'), - ]), - ).toBe(true) - }) -}) - -describe('classifySubmitFailure', () => { - it.each([400, 403, 404, 409, 422])('treats a %i refusal as rejected with its code', (status) => { - expect(classifySubmitFailure(httpError(status, { code: 'invalid_ref' }))).toEqual({ - kind: 'rejected', - code: 'invalid_ref', - }) - }) - - it.each([401, 429, 500, 502, 503])('keeps acceptance unknown after a %i', (status) => { - expect(classifySubmitFailure(httpError(status, { code: 'x' }))).toEqual({ - kind: 'unconfirmed', - }) - }) - - it('keeps acceptance unknown when no response arrived or the error is not HTTP', () => { - const lost = new AxiosError('Network Error', 'ERR_NETWORK') - expect(classifySubmitFailure(lost)).toEqual({ kind: 'unconfirmed' }) - expect(classifySubmitFailure(new Error('boom'))).toEqual({ kind: 'unconfirmed' }) - }) - - it('reports a refusal without a fault body as rejected with no code', () => { - expect(classifySubmitFailure(httpError(400, ''))).toEqual({ - kind: 'rejected', - code: undefined, - }) - }) -}) - -describe('rejectionMessage', () => { - it('explains known fault codes and shows unknown ones verbatim', () => { - expect(rejectionMessage('invalid_ref')).toContain('分支名无效') - expect(rejectionMessage('brand_new_rule')).toBe('提交被拒绝:brand_new_rule') - expect(rejectionMessage(undefined)).toBe('提交被拒绝:未知原因') - }) -}) diff --git a/frontend/src/features/clones/api.ts b/frontend/src/features/clones/api.ts deleted file mode 100644 index abb270e99..000000000 --- a/frontend/src/features/clones/api.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { isAxiosError } from 'axios' -import { - getApiV1TenantsTidClones, - getGetApiV1TenantsTidClonesQueryKey, - postApiV1TenantsTidClones, -} from '@/api/clones/clones' -import { - CloneStateKind, - type Error as ApiError, - type CloneOperation, - type GetApiV1TenantsTidClones200, -} from '@/api/generated.schemas' -import type { CloneSubmission } from '@/features/clones/pending' -import { mutationHeaders } from '@/features/spaces/api' -import { faultCode, type ErrorType } from '@/lib/api-client' - -/** The single page the list reads; the backend caps a page at 100. */ -export const CLONE_LIST_LIMIT = 100 - -/** How often the list is re-read while a clone awaits its result or the last read failed. */ -export const CLONE_POLL_INTERVAL_MS = 2000 - -const LIST_PARAMS = { limit: CLONE_LIST_LIMIT } - -/** The member's clone operations as the page shows them. */ -export interface CloneList { - /** Newest submission first. */ - operations: CloneOperation[] - /** True when Cloud holds more operations than the one page read. */ - truncated: boolean -} - -/** - * Orders operations newest first. Cloud pages clones by ascending random UUID, which carries no - * time order, so the order is always derived here from `createdAt`, with the id as a tiebreak. - */ -export function newestFirst(operations: CloneOperation[]): CloneOperation[] { - return operations.toSorted( - (a, b) => - Date.parse(b.createdAt) - Date.parse(a.createdAt) || - a.operationId.localeCompare(b.operationId), - ) -} - -/** - * True while any operation still awaits its result. Pending means "no terminal fact yet", never - * failure, so it is the only state worth polling for. - */ -export function awaitsResult(operations: CloneOperation[] | undefined): boolean { - return operations?.some((operation) => operation.state.kind === CloneStateKind.pending) ?? false -} - -function toCloneList(page: GetApiV1TenantsTidClones200): CloneList { - return { operations: newestFirst(page.items), truncated: page.nextCursor !== '' } -} - -/** - * The signed-in member's clone operations in `tenantId`; Cloud shows each member only their own - * submissions. Polls while a result is outstanding or the last read failed, and stops once every - * operation is terminal. A failed re-read keeps the last facts in `data`. - */ -export function useCloneList(tenantId: string | undefined) { - return useQuery({ - queryKey: getGetApiV1TenantsTidClonesQueryKey(tenantId ?? '', LIST_PARAMS), - queryFn: ({ signal }) => - getApiV1TenantsTidClones(tenantId ?? '', LIST_PARAMS, undefined, signal), - enabled: !!tenantId, - select: toCloneList, - refetchInterval: (query) => - query.state.status === 'error' || awaitsResult(query.state.data?.items) - ? CLONE_POLL_INTERVAL_MS - : false, - }) -} - -/** - * Submits one clone request. The request id doubles as the idempotency key, so a resend lands - * in both of Cloud's replay layers. The list is re-read after every attempt, failed ones - * included: a lost response may still have been accepted. - */ -export function useSubmitClone(tenantId: string) { - const queryClient = useQueryClient() - return useMutation, CloneSubmission>({ - mutationFn: (submission) => - postApiV1TenantsTidClones(tenantId, submission, { - headers: mutationHeaders(submission.requestId), - }), - onSettled: () => - queryClient.invalidateQueries({ queryKey: [`/api/v1/tenants/${tenantId}/clones`] }), - }) -} - -/** - * Why a submission failed. `rejected` means Cloud answered and refused it, so nothing was - * accepted and the identity may be dropped; `unconfirmed` means acceptance is unknown (no - * response, an expired session, a server or gateway failure) and the identity must be kept. - */ -export type SubmitFailure = { kind: 'rejected'; code: string | undefined } | { kind: 'unconfirmed' } - -// Refusals a resend would only repeat. 401 and 429 are refusals too, but the same member resends -// the same request unchanged after signing in again or waiting, so the identity is kept for them. -const REFUSAL_STATUSES = new Set([400, 403, 404, 409, 422]) - -/** Classifies a failed submission; see {@link SubmitFailure}. */ -export function classifySubmitFailure(error: unknown): SubmitFailure { - const status = isAxiosError(error) ? error.response?.status : undefined - if (status === undefined || !REFUSAL_STATUSES.has(status)) return { kind: 'unconfirmed' } - return { kind: 'rejected', code: faultCode(error) } -} diff --git a/frontend/src/features/clones/clone-dialog.tsx b/frontend/src/features/clones/clone-dialog.tsx deleted file mode 100644 index 669c581bc..000000000 --- a/frontend/src/features/clones/clone-dialog.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState } from 'react' -import { DialogFormField } from '@/components/common/dialog-form-field' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { STORAGE_UNREADABLE_NOTICE } from '@/features/clones/messages' -import type { CloneSubmission } from '@/features/clones/pending' -import type { CloneInput, CloneSubmissionFlow } from '@/features/clones/use-clone-submission' - -/** - * Quick feedback mirroring Cloud's clone source rules; Cloud stays authoritative and explains any - * refusal by its fault code. - */ -function repositoryHint(value: string): string | undefined { - const trimmed = value.trim() - if (trimmed === '' || /^(https|ssh):\/\/\S+$/.test(trimmed)) return undefined - return '仅支持 https:// 或 ssh:// 地址,且不能包含空白' -} - -function branchHint(value: string): string | undefined { - const trimmed = value.trim() - if (trimmed === '') return undefined - if (trimmed === 'HEAD' || trimmed.startsWith('refs/') || /\s/.test(trimmed)) { - return '请填写分支短名,如 main' - } - return undefined -} - -/** Repository and branch fields for a new submission. */ -function CloneFields({ - onSubmit, - busy, - blocked, -}: { - onSubmit: (input: CloneInput) => void - busy: boolean - blocked: boolean -}) { - const [repository, setRepository] = useState('') - const [branch, setBranch] = useState('main') - const repositoryError = repositoryHint(repository) - const branchError = branchHint(branch) - const submittable = - repository.trim() !== '' && - branch.trim() !== '' && - !repositoryError && - !branchError && - !busy && - !blocked - - return ( -
{ - e.preventDefault() - if (submittable) onSubmit({ repository, branch }) - }} - className="space-y-4" - > - - - - - ) -} - -/** The unconfirmed submission, read-only, with the only safe action: resend it unchanged. */ -function UnconfirmedSubmission({ - submission, - busy, - onRetry, -}: { - submission: CloneSubmission - busy: boolean - onRetry: () => void -}) { - return ( -
-
-

尚未确认的提交

-

{submission.repository}

-

{submission.branch}

-
- -
- ) -} - -/** - * Dialog for cloning a repository. While the tab holds an unconfirmed submission it offers only - * to resend that submission, never a new one; it closes once Cloud accepted a submission, and the - * list shows the result. - */ -export function CloneDialog({ - open, - onOpenChange, - flow, -}: { - open: boolean - onOpenChange: (open: boolean) => void - flow: CloneSubmissionFlow -}) { - async function send(input: CloneInput) { - if (await flow.submit(input)) onOpenChange(false) - } - const pending = flow.pending - const blocked = pending.status === 'unreadable' - - return ( - - - - Clone 仓库 - - 由 Node clone 指定分支并保留完整历史,结果显示在列表中。 - - - {pending.status === 'unconfirmed' ? ( - void send(pending.submission)} - /> - ) : ( - void send(input)} busy={flow.busy} blocked={blocked} /> - )} - {blocked &&

{STORAGE_UNREADABLE_NOTICE}

} - {flow.notice &&

{flow.notice}

} -
-
- ) -} diff --git a/frontend/src/features/clones/clone-list.tsx b/frontend/src/features/clones/clone-list.tsx deleted file mode 100644 index 0f9e1490b..000000000 --- a/frontend/src/features/clones/clone-list.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { format } from 'date-fns' -import { Badge } from '@/components/ui/badge' -import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import type { CloneOperation } from '@/api/generated.schemas' -import { CLONE_LIST_LIMIT } from '@/features/clones/api' -import { - CLONE_FAILURE_LABELS, - CLONE_STAGE_LABELS, - CLONE_STAGE_VARIANT, - cloneStage, -} from '@/features/clones/status' - -const SKELETON_KEYS = ['one', 'two', 'three', 'four'] -const SHORT_COMMIT_LENGTH = 12 - -/** - * The facts Cloud holds for one operation. Pending never reads as failure or progress: queued - * waits for a Controller to claim it, dispatched waits for the Node's result. - */ -function CloneOutcome({ operation }: { operation: CloneOperation }) { - const { state } = operation - const stage = cloneStage(operation) - if (stage === 'queued') return

等待 Controller 领取

- if (stage === 'dispatched') { - return

已交给 Node {operation.nodeId},等待结果

- } - if (stage === 'succeeded') { - return ( -
-

- {state.commit?.slice(0, SHORT_COMMIT_LENGTH)} -

-

{state.path}

-
- ) - } - return ( -
-

{CLONE_FAILURE_LABELS[state.reason ?? 'unspecified']}

- {state.retainedPath && ( -

残留已保留:{state.retainedPath}

- )} -
- ) -} - -function CloneRow({ operation }: { operation: CloneOperation }) { - const stage = cloneStage(operation) - return ( - - -

{operation.repository}

-

{operation.branch}

-
- - {CLONE_STAGE_LABELS[stage]} - - - - - - {format(new Date(operation.createdAt), 'M月d日 HH:mm')} - -
- ) -} - -/** - * The member's clone operations. `operations` is undefined until the first read settles; a - * failed re-read keeps the last facts visible under `stale`, since Cloud's state did not change - * just because the page could not read it. - */ -export function CloneList({ - operations, - truncated, - stale, -}: { - operations: CloneOperation[] | undefined - truncated: boolean - stale: boolean -}) { - return ( -
- {stale && ( -

- 暂时无法查询 clone 状态,恢复后会自动更新;已接受的 clone 不受影响。 -

- )} - {operations === undefined && !stale && ( -
- {SKELETON_KEYS.map((key) => ( - - ))} -
- )} - {operations?.length === 0 && ( -

- 还没有 clone 过仓库。点击右上角「Clone 仓库」开始。 -

- )} - {operations !== undefined && operations.length > 0 && ( - - - - 仓库 - 状态 - 结果 - 提交时间 - - - - {operations.map((operation) => ( - - ))} - -
- )} - {truncated && ( -

仅显示其中 {CLONE_LIST_LIMIT} 条记录。

- )} -
- ) -} diff --git a/frontend/src/features/clones/messages.ts b/frontend/src/features/clones/messages.ts deleted file mode 100644 index 3130337ab..000000000 --- a/frontend/src/features/clones/messages.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** Acceptance is unknown; the same identity is kept so a resend cannot clone twice. */ -export const UNCONFIRMED_NOTICE = '尚未确认服务端是否已接受。可以重试原请求,不会重复 clone。' - -/** The identity could not be recorded, so nothing was sent. */ -export const STORAGE_WRITE_FAILED_NOTICE = '无法写入浏览器存储,请求未发送。请检查浏览器存储设置。' - -/** The outcome is known but the tab cannot forget the identity, so it stays locked. */ -export const STORAGE_CLEAR_FAILED_NOTICE = - '无法清除本地待确认的请求。请恢复浏览器存储后重试原请求。' - -/** Stored state is unreadable, so the tab refuses to mint a new identity. */ -export const STORAGE_UNREADABLE_NOTICE = - '无法读取本地待确认的请求,已停止提交。请检查浏览器存储设置后刷新页面。' - -const REJECTION_MESSAGES: Record = { - invalid_repository_url: '仓库地址无效:仅支持 https:// 或 ssh://,不能包含凭据、查询参数或空白。', - invalid_ref: '分支名无效:请填写分支短名(如 main),不能是 HEAD、以 refs/ 开头或包含空白。', - invalid_clone_request: '请求无效,请重新填写。', - idempotency_conflict: '请求身份与原输入冲突,未创建新的 clone。', - membership_required: '你不是当前组织的成员,无法提交 clone。', - user_disabled: '账号已被停用,无法提交 clone。', -} - -/** - * Explains a refusal by its stable fault code. Unknown codes are shown verbatim so a new - * backend rule is still diagnosable. - */ -export function rejectionMessage(code: string | undefined): string { - const known = code === undefined ? undefined : REJECTION_MESSAGES[code] - return known ?? `提交被拒绝:${code ?? '未知原因'}` -} diff --git a/frontend/src/features/clones/pending.test.ts b/frontend/src/features/clones/pending.test.ts deleted file mode 100644 index cfabc4e74..000000000 --- a/frontend/src/features/clones/pending.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - pendingSubmissionKey, - readPendingSubmission, - writePendingSubmission, - type CloneSubmission, -} from './pending' - -const KEY = pendingSubmissionKey('tenant-a', 'user-a') -const SUBMISSION: CloneSubmission = { - requestId: 'request-1', - repository: 'https://github.com/octocat/Hello-World', - branch: 'master', -} - -beforeEach(() => sessionStorage.clear()) -afterEach(() => vi.restoreAllMocks()) - -describe('pendingSubmissionKey', () => { - it('gives every tenant and user of one tab a separate slot', () => { - const keys = new Set([ - pendingSubmissionKey('tenant-a', 'user-a'), - pendingSubmissionKey('tenant-a', 'user-b'), - pendingSubmissionKey('tenant-b', 'user-a'), - ]) - expect(keys.size).toBe(3) - }) -}) - -describe('readPendingSubmission', () => { - it('reads nothing pending from an empty slot', () => { - expect(readPendingSubmission(KEY)).toEqual({ status: 'none' }) - }) - - it('returns exactly the submission written before sending, without extra fields', () => { - sessionStorage.setItem(KEY, JSON.stringify({ ...SUBMISSION, extra: 'ignored' })) - expect(readPendingSubmission(KEY)).toEqual({ status: 'unconfirmed', submission: SUBMISSION }) - }) - - it.each([ - ['malformed JSON', '{'], - ['a value of the wrong shape', JSON.stringify({ requestId: 1, repository: 'x', branch: 'y' })], - ['a non-object value', JSON.stringify('request-1')], - ])('treats %s as unreadable rather than as nothing pending', (_label, raw) => { - sessionStorage.setItem(KEY, raw) - expect(readPendingSubmission(KEY)).toEqual({ status: 'unreadable' }) - }) - - it('treats storage that throws as unreadable', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('storage denied') - }) - expect(readPendingSubmission(KEY)).toEqual({ status: 'unreadable' }) - }) -}) - -describe('writePendingSubmission', () => { - it('stores a submission and forgets it on null', () => { - writePendingSubmission(KEY, SUBMISSION) - expect(readPendingSubmission(KEY)).toEqual({ status: 'unconfirmed', submission: SUBMISSION }) - writePendingSubmission(KEY, null) - expect(sessionStorage.getItem(KEY)).toBeNull() - }) - - it('propagates a refused write so the caller never sends an unremembered identity', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('quota exceeded') - }) - expect(() => writePendingSubmission(KEY, SUBMISSION)).toThrow('quota exceeded') - }) -}) diff --git a/frontend/src/features/clones/pending.ts b/frontend/src/features/clones/pending.ts deleted file mode 100644 index 2633cd6dd..000000000 --- a/frontend/src/features/clones/pending.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { PostApiV1TenantsTidClonesBody } from '@/api/generated.schemas' - -/** - * One clone submission exactly as the browser sends it. `requestId` is the caller's durable - * identity: resending the same triple returns the original operation instead of cloning twice. - */ -export type CloneSubmission = PostApiV1TenantsTidClonesBody - -/** - * What the tab remembers about a submission it has not seen accepted or refused. `unreadable` - * means the tab cannot tell whether a request is in flight, so no new identity may be minted. - */ -export type PendingSubmission = - | { status: 'none' } - | { status: 'unconfirmed'; submission: CloneSubmission } - | { status: 'unreadable' } - -const KEY_PREFIX = 'ora.clones.pending.v1' - -/** - * Storage key of one member's unconfirmed submission. Scoping by tenant and user keeps a - * submission typed under one account from being resent by another account signed in to the - * same tab. - */ -export function pendingSubmissionKey(tenantId: string, userId: string): string { - return `${KEY_PREFIX}:${tenantId}:${userId}` -} - -function isSubmission(value: unknown): value is CloneSubmission { - return ( - typeof value === 'object' && - value !== null && - 'requestId' in value && - typeof value.requestId === 'string' && - 'repository' in value && - typeof value.repository === 'string' && - 'branch' in value && - typeof value.branch === 'string' - ) -} - -/** - * Reads the unconfirmed submission stored under `key` in the tab's session storage. Storage that - * throws, or holds anything but a well-formed submission, reads as `unreadable` rather than - * `none`: forgetting it could mint a second identity for work Cloud already accepted. - */ -export function readPendingSubmission(key: string): PendingSubmission { - let raw: string | null - try { - raw = window.sessionStorage.getItem(key) - } catch { - return { status: 'unreadable' } - } - if (raw === null) return { status: 'none' } - try { - const value: unknown = JSON.parse(raw) - if (isSubmission(value)) { - const { requestId, repository, branch } = value - return { status: 'unconfirmed', submission: { requestId, repository, branch } } - } - } catch { - // Malformed JSON is reported below exactly like a well-formed value of the wrong shape. - } - return { status: 'unreadable' } -} - -/** - * Stores `submission` under `key` before it is sent, or forgets it (`null`) once Cloud's answer - * is known. - * - * @throws When session storage refuses the write; callers keep the submission locked instead of - * sending an identity the tab could not remember. - */ -export function writePendingSubmission(key: string, submission: CloneSubmission | null): void { - if (submission) window.sessionStorage.setItem(key, JSON.stringify(submission)) - else window.sessionStorage.removeItem(key) -} diff --git a/frontend/src/features/clones/repositories-page.test.tsx b/frontend/src/features/clones/repositories-page.test.tsx deleted file mode 100644 index 82e89fc32..000000000 --- a/frontend/src/features/clones/repositories-page.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { act, screen, waitFor, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { http, HttpResponse } from 'msw' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { CloneOperation } from '@/api/generated.schemas' -import { installCloudSpaceHandlers, TEST_TENANT_ID, TEST_USER_ID } from '@/test/cloud-handlers' -import { renderWithProviders } from '@/test/render' -import { server } from '@/test/msw-server' -import { CLONE_POLL_INTERVAL_MS } from './api' -import { - STORAGE_UNREADABLE_NOTICE, - STORAGE_WRITE_FAILED_NOTICE, - UNCONFIRMED_NOTICE, -} from './messages' -import { pendingSubmissionKey, readPendingSubmission, type CloneSubmission } from './pending' -import { RepositoriesPage } from './repositories-page' - -const CLONES = `/api/v1/tenants/${TEST_TENANT_ID}/clones` -const KEY = pendingSubmissionKey(TEST_TENANT_ID, TEST_USER_ID) -const REPOSITORY = 'https://github.com/octocat/Hello-World' -const COMMIT = '7fd1a60b01f91b314f59955a4e4d4e80d8edf11d' - -function operation(operationId: string, overrides: Partial = {}): CloneOperation { - return { - operationId, - requestId: `request-${operationId}`, - repository: REPOSITORY, - branch: 'master', - executionId: null, - nodeId: null, - state: { kind: 'pending' }, - createdAt: '2026-09-23T08:00:00Z', - updatedAt: '2026-09-23T08:00:00Z', - ...overrides, - } -} - -/** Serves the list from `items`, which a test may change between reads; counts every read. */ -function serveClones(items: CloneOperation[], nextCursor = '') { - const reads = { count: 0 } - server.use( - http.get(CLONES, () => { - reads.count += 1 - return HttpResponse.json({ items, nextCursor }) - }), - ) - return reads -} - -/** Accepts submissions like Cloud: records them, lists them, answers 202. */ -function acceptClones(items: CloneOperation[]) { - const sent: { body: unknown; key: string | null }[] = [] - server.use( - http.post(CLONES, async ({ request }) => { - const body: unknown = await request.json() - sent.push({ body, key: request.headers.get('Idempotency-Key') }) - const accepted = operation(`op-${sent.length}`) - items.push(accepted) - return HttpResponse.json(accepted, { status: 202 }) - }), - ) - return sent -} - -function renderPage() { - return renderWithProviders(, { slug: 'cloud-dev' }) -} - -async function openDialog(user: ReturnType) { - await user.click(await screen.findByRole('button', { name: /Clone 仓库/ })) - return screen.findByRole('dialog') -} - -async function fillAndSubmit( - user: ReturnType, - { repository, branch }: { repository: string; branch: string }, -) { - const dialog = await openDialog(user) - await user.type(within(dialog).getByLabelText(/仓库 URL/), repository) - const branchInput = within(dialog).getByLabelText('分支') - await user.clear(branchInput) - await user.type(branchInput, branch) - await user.click(within(dialog).getByRole('button', { name: '提交' })) - return dialog -} - -const advancePoll = () => act(() => vi.advanceTimersByTimeAsync(CLONE_POLL_INTERVAL_MS)) - -function storePending(key: string, submission: CloneSubmission) { - sessionStorage.setItem(key, JSON.stringify(submission)) -} - -beforeEach(() => sessionStorage.clear()) -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe('RepositoriesPage', () => { - it('lists the facts Cloud reported, newest first', async () => { - installCloudSpaceHandlers('member') - serveClones( - [ - operation('queued', { createdAt: '2026-09-23T08:00:00Z' }), - operation('failed', { - createdAt: '2026-09-23T11:00:00Z', - executionId: 'exec-f', - nodeId: 'node-a', - state: { kind: 'failed', reason: 'branchNotFound', retainedPath: '/srv/left' }, - }), - operation('done', { - createdAt: '2026-09-23T10:00:00Z', - executionId: 'exec-d', - nodeId: 'node-a', - state: { kind: 'succeeded', commit: COMMIT, path: '/srv/repositories/done' }, - }), - operation('dispatched', { - createdAt: '2026-09-23T09:00:00Z', - executionId: 'exec-p', - nodeId: 'node-a', - }), - operation('interrupted', { - createdAt: '2026-09-23T07:00:00Z', - executionId: 'exec-i', - nodeId: 'node-a', - state: { kind: 'failed', reason: 'interrupted', retainedPath: '/srv/cut' }, - }), - ], - 'more', - ) - renderPage() - - await screen.findByText('等待 Controller 领取') - const rows = screen.getAllByRole('row').slice(1) - expect(rows.map((row) => within(row).getAllByRole('cell')[1]?.textContent)).toEqual([ - '失败', - '已完成', - '已派发', - '排队中', - '失败', - ]) - const outcomes = rows.map((row) => within(row).getAllByRole('cell')[2]?.textContent) - expect(outcomes).toEqual([ - '找不到指定分支残留已保留:/srv/left', - `${COMMIT.slice(0, 12)}/srv/repositories/done`, - '已交给 Node node-a,等待结果', - '等待 Controller 领取', - '执行被中断,可重新提交残留已保留:/srv/cut', - ]) - expect(screen.getByText('仅显示其中 100 条记录。')).toBeInTheDocument() - }) - - it('sends a trimmed submission whose request id is also its idempotency key, then lists it', async () => { - installCloudSpaceHandlers('member') - const items: CloneOperation[] = [] - serveClones(items) - const sent = acceptClones(items) - const user = userEvent.setup() - renderPage() - - await screen.findByText(/还没有 clone 过仓库/) - await fillAndSubmit(user, { repository: ` ${REPOSITORY} `, branch: ' master ' }) - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - expect(sent).toEqual([ - { - body: { requestId: expect.any(String), repository: REPOSITORY, branch: 'master' }, - key: expect.any(String), - }, - ]) - expect(sent[0]?.body).toEqual(expect.objectContaining({ requestId: sent[0]?.key })) - expect(await screen.findByText('等待 Controller 领取')).toBeInTheDocument() - expect(sessionStorage.getItem(KEY)).toBeNull() - }) - - it('keeps the request id when the response is lost and resends it unchanged', async () => { - installCloudSpaceHandlers('member') - const items: CloneOperation[] = [] - serveClones(items) - const sent = acceptClones(items) - const lost: unknown[] = [] - server.use( - http.post( - CLONES, - async ({ request }) => { - lost.push(await request.json()) - return HttpResponse.error() - }, - { once: true }, - ), - ) - const user = userEvent.setup() - renderPage() - - const dialog = await fillAndSubmit(user, { repository: REPOSITORY, branch: 'master' }) - expect(await within(dialog).findByText(UNCONFIRMED_NOTICE)).toBeInTheDocument() - expect(within(dialog).getByText('尚未确认的提交')).toBeInTheDocument() - expect(readPendingSubmission(KEY)).toEqual({ status: 'unconfirmed', submission: lost[0] }) - - await user.click(within(dialog).getByRole('button', { name: '重试原请求' })) - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - expect(sent.map((s) => s.body)).toEqual(lost) - expect(sessionStorage.getItem(KEY)).toBeNull() - }) - - it('restores an unconfirmed submission after a reload without sending it', async () => { - installCloudSpaceHandlers('member') - serveClones([]) - const sent = acceptClones([]) - storePending(KEY, { requestId: 'kept', repository: REPOSITORY, branch: 'master' }) - const user = userEvent.setup() - renderPage() - - await user.click(await screen.findByRole('button', { name: '查看并重试' })) - const dialog = await screen.findByRole('dialog') - expect(within(dialog).getByText(REPOSITORY)).toBeInTheDocument() - expect(within(dialog).getByRole('button', { name: '重试原请求' })).toBeInTheDocument() - expect(sent).toEqual([]) - }) - - it('confirms an unconfirmed submission once Cloud lists its request id', async () => { - installCloudSpaceHandlers('member') - serveClones([operation('accepted', { requestId: 'kept' })]) - storePending(KEY, { requestId: 'kept', repository: REPOSITORY, branch: 'master' }) - renderPage() - - await screen.findByText('等待 Controller 领取') - await waitFor(() => expect(sessionStorage.getItem(KEY)).toBeNull()) - expect(screen.queryByRole('button', { name: '查看并重试' })).not.toBeInTheDocument() - }) - - it('drops a refused submission and explains the fault code', async () => { - installCloudSpaceHandlers('member') - serveClones([]) - server.use( - http.post(CLONES, () => - HttpResponse.json({ code: 'invalid_ref', params: {}, requestId: 'r' }, { status: 400 }), - ), - ) - const user = userEvent.setup() - renderPage() - - const dialog = await fillAndSubmit(user, { repository: REPOSITORY, branch: 'feature' }) - expect(await within(dialog).findByText(/分支名无效/)).toBeInTheDocument() - expect(sessionStorage.getItem(KEY)).toBeNull() - expect(within(dialog).getByRole('button', { name: '提交' })).toBeInTheDocument() - }) - - it("never offers another member's unconfirmed submission", async () => { - installCloudSpaceHandlers('member') - serveClones([]) - storePending(pendingSubmissionKey(TEST_TENANT_ID, 'someone-else'), { - requestId: 'theirs', - repository: REPOSITORY, - branch: 'master', - }) - const user = userEvent.setup() - renderPage() - - const dialog = await openDialog(user) - expect(within(dialog).getByLabelText(/仓库 URL/)).toHaveValue('') - expect(screen.queryByRole('button', { name: '查看并重试' })).not.toBeInTheDocument() - }) - - it('polls while a clone awaits its result, survives a failed read and stops once terminal', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - installCloudSpaceHandlers('member') - const answers = [ - () => HttpResponse.json({ items: [operation('a')], nextCursor: '' }), - () => HttpResponse.json({ code: 'unavailable', params: {}, requestId: 'r' }, { status: 503 }), - () => - HttpResponse.json({ - items: [operation('a', { state: { kind: 'succeeded', commit: COMMIT, path: '/p' } })], - nextCursor: '', - }), - ] - let reads = 0 - server.use( - http.get(CLONES, () => { - const answer = answers[Math.min(reads, answers.length - 1)] - reads += 1 - return answer ? answer() : HttpResponse.error() - }), - ) - renderPage() - - expect(await screen.findByText('等待 Controller 领取')).toBeInTheDocument() - await advancePoll() - expect(await screen.findByRole('alert')).toBeInTheDocument() - expect(screen.getByText('等待 Controller 领取')).toBeInTheDocument() - await advancePoll() - expect(await screen.findByText('/p')).toBeInTheDocument() - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - await advancePoll() - await advancePoll() - expect(reads).toBe(3) - }) - - it('refuses to mint a new identity when session storage cannot be read', async () => { - installCloudSpaceHandlers('member') - serveClones([]) - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('storage denied') - }) - const user = userEvent.setup() - renderPage() - - const dialog = await openDialog(user) - await user.type(within(dialog).getByLabelText(/仓库 URL/), REPOSITORY) - expect(within(dialog).getByText(STORAGE_UNREADABLE_NOTICE)).toBeInTheDocument() - expect(within(dialog).getByRole('button', { name: '提交' })).toBeDisabled() - }) - - it('sends nothing when the identity cannot be written first', async () => { - installCloudSpaceHandlers('member') - serveClones([]) - const sent = acceptClones([]) - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('quota exceeded') - }) - const user = userEvent.setup() - renderPage() - - const dialog = await fillAndSubmit(user, { repository: REPOSITORY, branch: 'master' }) - expect(await within(dialog).findByText(STORAGE_WRITE_FAILED_NOTICE)).toBeInTheDocument() - expect(sent).toEqual([]) - }) - - it('requests nothing and offers no clone until the space resolved', () => { - renderPage() - - expect(screen.getByText('仓库')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Clone 仓库/ })).not.toBeInTheDocument() - }) -}) diff --git a/frontend/src/features/clones/repositories-page.tsx b/frontend/src/features/clones/repositories-page.tsx deleted file mode 100644 index c024bb853..000000000 --- a/frontend/src/features/clones/repositories-page.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Plus } from 'lucide-react' -import { useState } from 'react' -import { PageHeader } from '@/components/layout/page-header' -import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { useSession } from '@/features/auth/session' -import { useCloneList } from '@/features/clones/api' -import { CloneDialog } from '@/features/clones/clone-dialog' -import { CloneList } from '@/features/clones/clone-list' -import { pendingSubmissionKey } from '@/features/clones/pending' -import { useCloneSubmission } from '@/features/clones/use-clone-submission' -import { useCurrentSpace } from '@/features/spaces/current-space' - -/** One member's clones page once the tenant and user are known. */ -function RepositoriesView({ tenantId, userId }: { tenantId: string; userId: string }) { - const list = useCloneList(tenantId) - const flow = useCloneSubmission({ tenantId, userId, operations: list.data?.operations }) - const [dialogOpen, setDialogOpen] = useState(false) - - return ( - <> - setDialogOpen(true)}> - - Clone 仓库 - - } - /> - -
-

- 仅你本人可见。关闭页面不会取消已接受的 clone;失败时的残留目录不会自动删除。 -

- {flow.pending.status === 'unconfirmed' && ( -
- 有一个 clone 请求尚未确认是否被接受。 - -
- )} - -
- - ) -} - -/** - * The 仓库 page: clone a repository onto a Node and follow the result. Clones belong to the - * submitting member within the tenant behind the current space, so the list does not change - * between spaces of one tenant. Nothing is requested until the route slug resolved to a joined - * space and the session names the member. - */ -export function RepositoriesPage({ slug }: { slug: string }) { - const { tenantId, space } = useCurrentSpace() - const { session } = useSession() - const userId = session.status === 'signed-in' ? session.user.id : undefined - - return ( -
- {space?.slug === slug && tenantId && userId ? ( - // Keyed by the storage key: another tenant or user must re-read its own pending submission. - - ) : ( - <> - -
- -
- - )} -
- ) -} diff --git a/frontend/src/features/clones/status.ts b/frontend/src/features/clones/status.ts deleted file mode 100644 index b142fe38a..000000000 --- a/frontend/src/features/clones/status.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { VariantProps } from 'class-variance-authority' -import type { badgeVariants } from '@/components/ui/badge' -import type { CloneOperation, CloneStateReason } from '@/api/generated.schemas' - -/** - * Where a clone stands, derived only from Cloud's facts. Cloud reports queued and dispatched - * requests both as `pending`; a recorded `executionId` is what tells them apart. There is no - * "running" stage: Cloud does not know whether Git is running. - */ -export type CloneStage = 'queued' | 'dispatched' | 'succeeded' | 'failed' - -/** Maps an operation onto its {@link CloneStage}. */ -export function cloneStage(operation: CloneOperation): CloneStage { - if (operation.state.kind === 'succeeded') return 'succeeded' - if (operation.state.kind === 'failed') return 'failed' - return operation.executionId === null ? 'queued' : 'dispatched' -} - -/** Badge text per stage. */ -export const CLONE_STAGE_LABELS: Record = { - queued: '排队中', - dispatched: '已派发', - succeeded: '已完成', - failed: '失败', -} - -/** Badge variant per stage; only a terminal failure uses the destructive style. */ -export const CLONE_STAGE_VARIANT: Record< - CloneStage, - NonNullable['variant']> -> = { - queued: 'outline', - dispatched: 'secondary', - succeeded: 'default', - failed: 'destructive', -} - -/** Member-facing text for each failure reason the Node reports. */ -export const CLONE_FAILURE_LABELS: Record = { - sourceUnavailable: '仓库暂不可用', - branchNotFound: '找不到指定分支', - destinationConflict: '目标目录冲突', - operationFailed: 'Git 操作失败', - interrupted: '执行被中断,可重新提交', - unspecified: '未说明原因', -} diff --git a/frontend/src/features/clones/use-clone-submission.ts b/frontend/src/features/clones/use-clone-submission.ts deleted file mode 100644 index 592135a39..000000000 --- a/frontend/src/features/clones/use-clone-submission.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { useEffect, useState } from 'react' -import type { CloneOperation } from '@/api/generated.schemas' -import { classifySubmitFailure, useSubmitClone } from '@/features/clones/api' -import { - rejectionMessage, - STORAGE_CLEAR_FAILED_NOTICE, - STORAGE_WRITE_FAILED_NOTICE, - UNCONFIRMED_NOTICE, -} from '@/features/clones/messages' -import { - pendingSubmissionKey, - readPendingSubmission, - writePendingSubmission, - type CloneSubmission, - type PendingSubmission, -} from '@/features/clones/pending' - -/** Repository and branch as the member typed them; the flow trims them before sending. */ -export interface CloneInput { - repository: string - branch: string -} - -/** The submission state machine one member's clones page drives. */ -export interface CloneSubmissionFlow { - /** The tab's unconfirmed submission, if any; while one exists every submit resends it. */ - pending: PendingSubmission - /** The latest outcome worth telling the member; cleared when a new attempt starts. */ - notice: string | null - /** True while a submission is on the wire. */ - busy: boolean - /** - * Sends a new submission, or resends the unconfirmed one unchanged (then `input` is ignored). - * Resolves `true` once Cloud accepted it; never rejects. - */ - submit: (input: CloneInput) => Promise -} - -function newSubmission(input: CloneInput): CloneSubmission { - return { - requestId: crypto.randomUUID(), - repository: input.repository.trim(), - branch: input.branch.trim(), - } -} - -/** - * Owns one member's clone submissions in one tenant. The identity is written to session storage - * before it is sent and forgotten only once Cloud's answer is known, so a lost response or a - * reload resends the same request instead of cloning twice. Callers remount the owning component - * when the tenant or user changes, which re-reads storage under the new key. - * - * `operations` is the member's current list: seeing the unconfirmed request id there proves Cloud - * accepted it, which confirms the submission without a resend. - */ -export function useCloneSubmission({ - tenantId, - userId, - operations, -}: { - tenantId: string - userId: string - operations: CloneOperation[] | undefined -}): CloneSubmissionFlow { - const key = pendingSubmissionKey(tenantId, userId) - const [pending, setPending] = useState(() => readPendingSubmission(key)) - const [notice, setNotice] = useState(null) - const mutation = useSubmitClone(tenantId) - - function forget() { - try { - writePendingSubmission(key, null) - setPending({ status: 'none' }) - } catch { - setNotice(STORAGE_CLEAR_FAILED_NOTICE) - } - } - - // Cloud lists the request id only if it accepted that exact submission, so a listed one is - // confirmed whatever storage still says. Deriving it here rather than storing it means a stale - // entry that storage refuses to drop is re-derived as confirmed on every read. - const listed = - pending.status === 'unconfirmed' && - (operations?.some((operation) => operation.requestId === pending.submission.requestId) ?? false) - const current: PendingSubmission = listed ? { status: 'none' } : pending - useEffect(() => { - if (!listed) return - try { - writePendingSubmission(key, null) - } catch { - // Harmless: the listed request id keeps this submission confirmed after any reload. - } - }, [listed, key]) - - async function submit(input: CloneInput): Promise { - if (current.status === 'unreadable' || mutation.isPending) return false - const submission = current.status === 'unconfirmed' ? current.submission : newSubmission(input) - setNotice(null) - try { - writePendingSubmission(key, submission) - } catch { - setNotice(STORAGE_WRITE_FAILED_NOTICE) - return false - } - setPending({ status: 'unconfirmed', submission }) - try { - await mutation.mutateAsync(submission) - } catch (error) { - const failure = classifySubmitFailure(error) - if (failure.kind === 'unconfirmed') { - setNotice(UNCONFIRMED_NOTICE) - return false - } - setNotice(rejectionMessage(failure.code)) - forget() - return false - } - forget() - return true - } - - return { pending: current, notice, busy: mutation.isPending, submit } -} diff --git a/frontend/src/features/issues/thread/README.en.md b/frontend/src/features/issues/thread/README.en.md index 3c6f20a9b..ed2f94330 100644 --- a/frontend/src/features/issues/thread/README.en.md +++ b/frontend/src/features/issues/thread/README.en.md @@ -19,9 +19,9 @@ Not owned: creating and listing runs (`useRuns` in `features/issues/api`), SSE c | `thread-messages.tsx` | Presentational: the message list (`role=list`, named "会话消息") and the "加载更早" button | | `thread-row.tsx` | Safe Markdown, collapsed execution context, thoughts and expandable tool input/output | | `thread-composer.tsx` | The "给 Agent 发送消息" textarea, "发送", and "结束会话" with a confirm step | -| `run-delivery.tsx` | The Revision delivery line under the Thread (`role=status`, named "Revision 交付"): a registered Revision shows its short commit and whether files changed (or, for a resumed run with no new commits, that it reused the resumed work); otherwise `result.deliveryState` explains a skip or failure, and an ended session whose run has not settled shows saving | +| `run-delivery.tsx` | The Revision delivery line under the Thread (`role=status`, named "Revision 交付"): a registered Revision shows its short commit and whether the run delivered new commits (or, for a resumed run with no new commits, that it reused the resumed work); otherwise `result.deliveryState` explains a skip or failure, and an ended session whose run has not settled shows saving; Revisions are delivered over Git, so no bundle size is shown | | `run-resume.tsx` | The resume line above the Thread (`role=note`, named "续接"): when the run resumed an earlier Revision of the same Issue, it finds that Revision among the Issue's runs and shows its short commit; a fresh run renders nothing | -| `run-preparation.tsx` | `RunPreparation` displays server-projected environment, clone, Agent preparation/start phases, the three-attempt clone budget and retry waiting; `SessionFailure` renders only allowlisted safe codes, with no waiting/idle presentation after failure | +| `run-preparation.tsx` | `RunPreparation` displays server-projected environment, clone, Agent preparation/start phases, the three-attempt clone budget and retry waiting; `snapshot_not_ready` shows as waiting for the Project's first repository sync (a wait, not a failure), and `repository_sync_failed` shows as a failure whatever the stage, with `syncErrorCode` appended only when it is a snake_case code (other values are dropped); `SessionFailure` renders only allowlisted safe codes, with no waiting/idle presentation after failure | | `thread-failure.ts` | `sessionFailureMessage`: shared finite failure messages for status and durable entries; unknown fields become fixed messages without provider diagnostics | | `thread-panel.tsx` | `IssueThreadPanel`: run selection, the "Agent 会话" heading, the state badge, the waiting notice and composition | | `*.test.ts(x)` | Pure-function unit tests and MSW integration tests | @@ -40,7 +40,7 @@ Node entries expose their actual `turnId`; tool calls and updates use that ident - A sent message's returned entry is **not** merged into the cache: its seq may be ahead of entries not read yet, and advancing the window past them would skip them forever. Only a re-read picks it up, in order. - An event's `lastSeq` is a hint and is never used as a cursor. - A GET 404 means the session is not declared yet; it is a normal waiting state (polled every 2.5s), not an error. Once declared, a 5s fallback poll runs in every state but `ended`, in case SSE drops. -- A Revision shows public metadata only (commits, changed, sizes); run settlement publishes no space event, so the line relies on `useRuns` polling while an agent run is unsettled. +- A Revision shows public metadata only (commits, changed); a Git-delivered Revision has a null `bundleSize` and the delivery line never shows a size; run settlement publishes no space event, so the line relies on `useRuns` polling while an agent run is unsettled. - Sending and ending are disabled in `ending` / `ended` (the server's 409 `thread_closed` rule); each submission's idempotency key stays stable across its retries. Server-provided `canAppend` / `canEnd` control actions: only the initiator may append model requests, administrators may end, and other members remain read-only. The panel shows frozen connection/model metadata and member display names, never credentials. A revoked model authorization leaves the old run read-only. Reconfiguring the connection does not reopen that Thread; the send fault asks the user to end it and launch a new task. @@ -49,4 +49,4 @@ Task handoff barriers and pauses also disable appending to an old Thread. Direct ## Testing -`thread-panel.test.tsx` drives the panel against a fake Thread backend with the server's cursor semantics (tail / after / before): tail load, 404 → appears, load older, send (including thread_closed), end session, and an SSE event triggering an incremental read. The 404 → appears case relies on the real 2.5s poll and takes about 3 seconds. +`run-preparation.test.tsx` renders each preparation state directly, covering the snapshot wait, the failed repository sync and the filtering of its error code. `thread-panel.test.tsx` drives the panel against a fake Thread backend with the server's cursor semantics (tail / after / before): tail load, 404 → appears, load older, send (including thread_closed), end session, and an SSE event triggering an incremental read. The 404 → appears case relies on the real 2.5s poll and takes about 3 seconds. diff --git a/frontend/src/features/issues/thread/README.md b/frontend/src/features/issues/thread/README.md index 4ee20f1e1..ffd1b4290 100644 --- a/frontend/src/features/issues/thread/README.md +++ b/frontend/src/features/issues/thread/README.md @@ -20,9 +20,9 @@ Issue 详情页主体的「Agent 会话」:默认读取最近一次 Agent Run | `thread-row.tsx` | 安全 Markdown、折叠执行上下文、思考和可展开的工具输入输出 | | `thread-composer.tsx` | 输入框「给 Agent 发送消息」、「发送」与带确认步骤的「结束会话」 | | `thread-panel.tsx` | `IssueThreadPanel`:选 run、标题「Agent 会话」、状态徽章、等待提示与组合 | -| `run-delivery.tsx` | 会话下方的 Revision 交付一行(`role=status`,名称「Revision 交付」):已登记的 Revision 显示短提交与是否有改动(续接后无新提交时说明沿用了续接的成果),否则按 `result.deliveryState` 说明跳过或失败,会话已结束但运行未结算时显示保存中 | +| `run-delivery.tsx` | 会话下方的 Revision 交付一行(`role=status`,名称「Revision 交付」):已登记的 Revision 显示短提交与是否交付了新提交(续接后无新提交时说明沿用了续接的成果);Revision 经 Git 交付,不显示 bundle 大小,否则按 `result.deliveryState` 说明跳过或失败,会话已结束但运行未结算时显示保存中 | | `run-resume.tsx` | 会话上方的续接一行(`role=note`,名称「续接」):运行续接了同一 Issue 之前的 Revision 时,从该 Issue 的运行列表中找到它并显示短提交;全新运行不渲染 | -| `run-preparation.tsx` | `RunPreparation` 显示服务器提供的环境、克隆、Agent 准备和启动阶段,以及三次克隆预算、重试等待;`SessionFailure` 仅显示白名单安全错误码,失败不继续显示等待或空闲 | +| `run-preparation.tsx` | `RunPreparation` 显示服务器提供的环境、克隆、Agent 准备和启动阶段,以及三次克隆预算、重试等待;`snapshot_not_ready` 显示为等待项目首次同步仓库(等待而非失败),`repository_sync_failed` 无论阶段都显示为失败,并附上形如 snake_case 的 `syncErrorCode`(其他值丢弃);`SessionFailure` 仅显示白名单安全错误码,失败不继续显示等待或空闲 | | `thread-failure.ts` | `sessionFailureMessage`:状态提示与持久失败条目共用的有限错误分类文案;未知字段替换为固定提示,不显示供应商诊断 | | `*.test.ts(x)` | 纯函数单测与 MSW 集成测试 | @@ -40,7 +40,7 @@ Node 条目公开其真实 `turnId`,工具调用与更新优先按该轮次关 - 发送成功后**不**把返回的 entry 直接并入缓存:它的 seq 可能领先于尚未读到的条目,提前推进窗口会永久跳过它们;只靠重新读取按序拿到。 - 事件里的 `lastSeq` 只是提示,从不当作游标。 - GET 404 表示会话尚未声明,是正常的等待状态(每 2.5s 轮询),不是错误;已声明后在非 `ended` 状态下每 5s 兜底轮询,防止 SSE 掉线。 -- Revision 只显示公开元数据(提交、是否有改动、大小);运行结算没有空间事件,依赖 `useRuns` 在 agent 运行未结束时的轮询刷新。 +- Revision 只显示公开元数据(提交、是否有改动);Git 交付的 Revision `bundleSize` 为 null,交付行从不显示大小;运行结算没有空间事件,依赖 `useRuns` 在 agent 运行未结束时的轮询刷新。 - `ending` / `ended` 时禁用发送与结束(与服务端 409 `thread_closed` 规则一致);发送与结束各自的幂等键在同一次提交的重试间保持不变。服务端返回 `canAppend` / `canEnd` 决定操作:只有发起者可追加模型请求,管理员可结束,其他成员只读。面板展示冻结的连接/模型摘要和成员显示名称,不显示密钥。 模型授权失效后,旧运行保持只读;重新配置连接不会重新开放旧会话,页面提示结束后重新发起任务。 @@ -49,4 +49,4 @@ Node 条目公开其真实 `turnId`,工具调用与更新优先按该轮次关 ## 测试 -`thread-panel.test.tsx` 用一个具有服务端游标语义(tail / after / before)的假 Thread 后端驱动面板,覆盖尾部加载、404→出现、加载更早、发送(含 thread_closed)、结束会话以及 SSE 事件触发增量读取。404→出现依赖真实的 2.5s 轮询,单条用例约 3 秒。 +`run-preparation.test.tsx` 直接渲染各准备状态,覆盖快照等待、仓库同步失败及其错误码过滤。`thread-panel.test.tsx` 用一个具有服务端游标语义(tail / after / before)的假 Thread 后端驱动面板,覆盖尾部加载、404→出现、加载更早、发送(含 thread_closed)、结束会话以及 SSE 事件触发增量读取。404→出现依赖真实的 2.5s 轮询,单条用例约 3 秒。 diff --git a/frontend/src/features/issues/thread/run-delivery.test.tsx b/frontend/src/features/issues/thread/run-delivery.test.tsx index 331c93737..c9e2f82e3 100644 --- a/frontend/src/features/issues/thread/run-delivery.test.tsx +++ b/frontend/src/features/issues/thread/run-delivery.test.tsx @@ -19,25 +19,31 @@ const revision = { id: 'rev-1', baseCommit: 'a'.repeat(40), finalCommit: '1234567890abcdef1234567890abcdef12345678', - bundleSize: 2048, + bundleSize: null, historySize: 512, createdAt: '2026-10-09T00:00:00Z', } describe('deliverySummary', () => { - it('names the short final commit and the bundle size of a changed Revision', () => { + it('names the short final commit of a Git-delivered changed Revision without any size', () => { expect(deliverySummary({ ...base, revision: { ...revision, changed: true } }, true)).toBe( - '已保存 Revision 1234567(含改动,bundle 2.0 KB)', + '已保存 Revision 1234567(含改动)', ) }) - it('says an unchanged Revision saved no file changes', () => { + it('never shows a bundle size, even for a legacy Revision that reports one', () => { expect( deliverySummary( - { ...base, revision: { ...revision, changed: false, bundleSize: null } }, + { ...base, revision: { ...revision, changed: true, bundleSize: 2048 } }, true, ), - ).toBe('已保存 Revision 1234567(无文件改动)') + ).toBe('已保存 Revision 1234567(含改动)') + }) + + it('says an unchanged Revision saved no file changes', () => { + expect(deliverySummary({ ...base, revision: { ...revision, changed: false } }, true)).toBe( + '已保存 Revision 1234567(无文件改动)', + ) }) it('says a resumed run without new commits reused the resumed Revision', () => { @@ -45,7 +51,7 @@ describe('deliverySummary', () => { deliverySummary( { ...base, - revision: { ...revision, changed: false, bundleSize: null, priorRevisionId: 'rev-0' }, + revision: { ...revision, changed: false, priorRevisionId: 'rev-0' }, }, true, ), diff --git a/frontend/src/features/issues/thread/run-delivery.tsx b/frontend/src/features/issues/thread/run-delivery.tsx index e8c1b80a4..637852150 100644 --- a/frontend/src/features/issues/thread/run-delivery.tsx +++ b/frontend/src/features/issues/thread/run-delivery.tsx @@ -1,16 +1,10 @@ import type { IssueRun } from '@/features/issues/types' -/** Renders a byte count the way the delivery line needs it: B, KB or MB with one decimal. */ -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` -} - /** * The one-line summary of what an agent run saved, or null while there is * nothing to say. A registered Revision wins: its short final commit and - * whether the run changed files, or reused the Revision it resumed. Without one, `result.deliveryState` explains + * whether the run delivered new commits, or reused the Revision it resumed. Git-delivered Revisions + * have no bundle (`bundleSize` is null), so the line never shows a size. Without one, `result.deliveryState` explains * why (skipped without an object store, failed after retries), and a run whose * session ended but has not settled yet is still saving. */ @@ -19,9 +13,9 @@ export function deliverySummary(run: IssueRun, threadEnded: boolean): string | n if (revision) { const commit = revision.finalCommit.slice(0, 7) if (revision.changed) { - return `已保存 Revision ${commit}(含改动,bundle ${formatBytes(revision.bundleSize ?? 0)})` + return `已保存 Revision ${commit}(含改动)` } - // A resumed run that added nothing reuses the resumed Revision's bundle instead of storing one. + // A resumed run that added no commits reuses the resumed Revision instead of delivering one. return revision.priorRevisionId ? `已保存 Revision ${commit}(无新改动,沿用续接的成果)` : `已保存 Revision ${commit}(无文件改动)` diff --git a/frontend/src/features/issues/thread/run-preparation.test.tsx b/frontend/src/features/issues/thread/run-preparation.test.tsx new file mode 100644 index 000000000..64df24654 --- /dev/null +++ b/frontend/src/features/issues/thread/run-preparation.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { RunPreparation as Preparation } from '@/api/generated.schemas' +import { RunPreparation, SessionFailure } from './run-preparation' + +const base: Preparation = { + stage: 'clone', + cloneAttempts: 0, + maxCloneAttempts: 3, + retryAt: null, + errorCode: null, + syncErrorCode: null, +} + +describe('RunPreparation', () => { + it('shows a missing snapshot as a wait for the first sync, not as a failure', () => { + render( + , + ) + expect(screen.getByRole('status')).toHaveTextContent( + '正在等待项目首次同步仓库,同步完成后自动继续。', + ) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows a failed repository sync as a failure naming the sync error code', () => { + render( + , + ) + expect(screen.getByRole('alert')).toHaveTextContent( + '项目仓库同步失败,无法获取仓库(repository_sync_failed) 同步错误码:remote_auth_failed', + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('treats a failed repository sync as a failure even before the stage says failed', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent( + '项目仓库同步失败,无法获取仓库(repository_sync_failed)', + ) + }) + + it('keeps showing clone attempts and the retry wait while cloning', () => { + render( + , + ) + expect(screen.getByRole('status')).toHaveTextContent( + '正在获取仓库… 尝试 2 / 3 等待重试,稍后自动继续。', + ) + }) + + it('waits for the session when there is no progress yet', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('等待 Agent 会话启动…') + }) +}) + +describe('SessionFailure', () => { + it('drops a sync error code that is not a short snake_case classification', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent( + /^项目仓库同步失败,无法获取仓库(repository_sync_failed)$/, + ) + }) + + it('replaces an unknown failure code with the fixed agent failure', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent('Agent 会话失败(agent_failed)') + }) +}) diff --git a/frontend/src/features/issues/thread/run-preparation.tsx b/frontend/src/features/issues/thread/run-preparation.tsx index d42275b8e..e55d13cff 100644 --- a/frontend/src/features/issues/thread/run-preparation.tsx +++ b/frontend/src/features/issues/thread/run-preparation.tsx @@ -1,11 +1,36 @@ import type { RunPreparation as Preparation } from '@/api/generated.schemas' import { sessionFailureMessage } from './thread-failure' -/** Displays only known safe classifications, never arbitrary server/provider error text. */ -export function SessionFailure({ code }: { code: string }) { +/** The clone step ended because the Project's latest repository sync failed. */ +const REPOSITORY_SYNC_FAILED = 'repository_sync_failed' + +/** The Workspace waits for the Project's first repository sync to produce a snapshot. */ +const SNAPSHOT_NOT_READY = 'snapshot_not_ready' + +/** Sync error codes are short snake_case classifications; anything else is never displayed. */ +const SAFE_SYNC_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/ + +function safeSyncErrorCode(code: unknown): string | null { + return typeof code === 'string' && SAFE_SYNC_ERROR_CODE.test(code) ? code : null +} + +/** + * Displays only known safe classifications, never arbitrary server/provider error text. + * `syncErrorCode` is the failed repository sync's code; it is shown only when it is a short + * snake_case code, so an unexpected value is dropped rather than rendered. + */ +export function SessionFailure({ + code, + syncErrorCode, +}: { + code: string + syncErrorCode?: string | null +}) { + const syncCode = safeSyncErrorCode(syncErrorCode) return (

{sessionFailureMessage(code)} + {syncCode && <> 同步错误码:{syncCode}}

) } @@ -20,10 +45,25 @@ const STAGE_LABELS: Record = { cancelled: '运行已取消', } -/** Shows authoritative preparation phases and bounded clone attempts before declaration. */ +/** + * Shows authoritative preparation phases and bounded clone attempts before declaration. A + * missing snapshot is a wait, not a failure; a failed repository sync is a failure whatever the + * stage says, because no retry of the clone step can succeed without a new sync. + */ export function RunPreparation({ progress }: { progress?: Preparation | null }) { - if (progress?.stage === 'failed') - return + if (progress?.stage === 'failed' || progress?.errorCode === REPOSITORY_SYNC_FAILED) + return ( + + ) + if (progress?.errorCode === SNAPSHOT_NOT_READY) + return ( +

+ 正在等待项目首次同步仓库,同步完成后自动继续。 +

+ ) return (

{STAGE_LABELS[progress?.stage ?? 'waiting']} diff --git a/frontend/src/features/issues/thread/thread-failure.ts b/frontend/src/features/issues/thread/thread-failure.ts index 4184924b8..01a2e3824 100644 --- a/frontend/src/features/issues/thread/thread-failure.ts +++ b/frontend/src/features/issues/thread/thread-failure.ts @@ -10,6 +10,7 @@ const FAILURE_LABELS: Record = { model_access_expired: '模型授权已过期', thread_unavailable: '会话记录不可用', clone_attempts_exhausted: '获取仓库失败,自动重试次数已用尽', + repository_sync_failed: '项目仓库同步失败,无法获取仓库', } /** Unknown data is replaced by a fixed code; provider diagnostics never become display text. */ diff --git a/frontend/src/features/issues/types.ts b/frontend/src/features/issues/types.ts index b5c3ea452..8f133f8c9 100644 --- a/frontend/src/features/issues/types.ts +++ b/frontend/src/features/issues/types.ts @@ -118,7 +118,7 @@ export interface RunRevision { changed: boolean bundleSize: number | null historySize: number - /** The Revision the run resumed; with `changed: false` the run reused that Revision's bundle. */ + /** The Revision the run resumed; with `changed: false` the run delivered no commits beyond it. */ priorRevisionId?: string | null createdAt: string } diff --git a/frontend/src/lib/README.en.md b/frontend/src/lib/README.en.md index 5be9955d7..d2e007928 100644 --- a/frontend/src/lib/README.en.md +++ b/frontend/src/lib/README.en.md @@ -17,7 +17,7 @@ Utilities shared by generated code and components that contain neither React nor | `navigation.test.ts` | Verifies the replace-and-restore semantics. | | `pagination.ts` / `pagination.test.ts` | Walks every cursor page so spaces, members, and applications are not silently truncated; tests cross-page ordering. | | `paths.test.ts` | Verifies `safeReturnTo`'s rejections, narrowing of private join links, `loginPath` encoding and the `/w/` prefix in every workspace route. | -| `paths.ts` | The reserved workspace prefix `WORKSPACE_ROUTE_PREFIX` (`/w`) with its router pattern `WORKSPACE_ROUTE_PATTERN`, the workspace route builder `workspacePaths` (including the resource detail builders `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`, plus the plugin and repository routes and the settings routes `gitCredentials` and `gitIdentity`), the login route `loginPath`, narrowing of an untrusted `returnTo` (`safeReturnTo`: single leading slash, no `//`, no backslash or control character, at most 2048 chars), the space address preview `workspaceUrlPrefix` (`host/w/`, the fixed part shown in front of the slug input), and construction/validation of same-origin invitation and application links (`joinUrl` / `joinedLinkPath`). | +| `paths.ts` | The reserved workspace prefix `WORKSPACE_ROUTE_PREFIX` (`/w`) with its router pattern `WORKSPACE_ROUTE_PATTERN`, the workspace route builder `workspacePaths` (including the resource detail builders `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`, plus the plugin route and the settings routes `gitCredentials` and `gitIdentity`), the login route `loginPath`, narrowing of an untrusted `returnTo` (`safeReturnTo`: single leading slash, no `//`, no backslash or control character, at most 2048 chars), the space address preview `workspaceUrlPrefix` (`host/w/`, the fixed part shown in front of the slug input), and construction/validation of same-origin invitation and application links (`joinUrl` / `joinedLinkPath`). | | `mock-api-client.ts` | Axios client for the MSW-mocked domain (`/mock-api/*`), kept separate from the real-backend generated client. It rewrites the real space slug to the seeded demo workspace so pages without a backend keep showing demo data in any space until they gain real API counterparts. The mock domain has no authentication. | | `utils.ts` | Re-exports `cn` (Tailwind-aware class merging); shadcn components import it via `@/lib/utils`. | diff --git a/frontend/src/lib/README.md b/frontend/src/lib/README.md index 0e2037afe..817b4efaf 100644 --- a/frontend/src/lib/README.md +++ b/frontend/src/lib/README.md @@ -17,7 +17,7 @@ | `navigation.test.ts` | 验证替换与还原语义。 | | `pagination.ts` / `pagination.test.ts` | 顺序读取所有游标页,确保空间、成员和申请列表不会只显示首页;测试跨页顺序。 | | `paths.test.ts` | 验证 `safeReturnTo` 的拒绝规则、私有加入链接的收窄、`loginPath` 的编码与所有工作区路由的 `/w/` 前缀。 | -| `paths.ts` | 保留的工作区前缀 `WORKSPACE_ROUTE_PREFIX`(`/w`)及其路由模式 `WORKSPACE_ROUTE_PATTERN`、工作区路由构造 `workspacePaths`(含资源详情页构造器 `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`,以及插件与仓库路由、设置下的凭据路由 `gitCredentials` 与 Git 身份路由 `gitIdentity`)、登录路由 `loginPath`、不受信 `returnTo` 的收窄 `safeReturnTo`(单个前导 `/`、不允许 `//`、反斜杠或控制字符、最长 2048)、空间地址预览 `workspaceUrlPrefix`(`host/w/`,显示在 slug 输入框前的固定部分),以及同源邀请/申请链接的构造 `joinUrl` 与校验 `joinedLinkPath`。 | +| `paths.ts` | 保留的工作区前缀 `WORKSPACE_ROUTE_PREFIX`(`/w`)及其路由模式 `WORKSPACE_ROUTE_PATTERN`、工作区路由构造 `workspacePaths`(含资源详情页构造器 `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`,以及插件路由、设置下的凭据路由 `gitCredentials` 与 Git 身份路由 `gitIdentity`)、登录路由 `loginPath`、不受信 `returnTo` 的收窄 `safeReturnTo`(单个前导 `/`、不允许 `//`、反斜杠或控制字符、最长 2048)、空间地址预览 `workspaceUrlPrefix`(`host/w/`,显示在 slug 输入框前的固定部分),以及同源邀请/申请链接的构造 `joinUrl` 与校验 `joinedLinkPath`。 | | `mock-api-client.ts` | MSW mock 域(`/mock-api/*`)的 axios 客户端,与真实后端生成客户端分离。把真实 space slug 重写为 demo 种子 workspace,使尚无后端的页面在任意 Space 下继续显示演示数据,直到它们接入真实 API。mock 域没有认证。 | | `utils.ts` | 重新导出 `cn`(Tailwind 感知的类名合并),shadcn 组件通过 `@/lib/utils` 引用。 | diff --git a/frontend/src/lib/paths.test.ts b/frontend/src/lib/paths.test.ts index 383f33693..858f7b6a6 100644 --- a/frontend/src/lib/paths.test.ts +++ b/frontend/src/lib/paths.test.ts @@ -49,7 +49,6 @@ describe('workspacePaths', () => { expect(p.skills).toBe('/w/acme/skills') expect(p.plugins).toBe('/w/acme/plugins') expect(p.members).toBe('/w/acme/settings/members') - expect(p.repositories).toBe('/w/acme/repositories') expect(p.gitCredentials).toBe('/w/acme/settings/git-credentials') expect(p.gitIdentity).toBe('/w/acme/settings/git-identity') expect(p.modelConnections).toBe('/w/acme/settings/model-connections') diff --git a/frontend/src/lib/paths.ts b/frontend/src/lib/paths.ts index ebca08401..dbe4bd100 100644 --- a/frontend/src/lib/paths.ts +++ b/frontend/src/lib/paths.ts @@ -30,7 +30,6 @@ export function workspacePaths(slug: string) { agentSession: (issueId: string, runId: string) => `${base}/sessions/agent/${issueId}/${runId}`, projects: `${base}/projects`, projectDetail: (id: string) => `${base}/projects/${id}`, - repositories: `${base}/repositories`, workflows: `${base}/workflows`, workflowDetail: (id: string) => `${base}/workflows/${id}`, workflowRun: (workflowId: string, runId: string) => diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index be6cdc907..f8592f1eb 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -17,7 +17,6 @@ import { JoinContinue, JoinPage } from '@/features/onboarding/join-page' import { PluginsPage } from '@/features/plugins/plugins-page' import { ProjectDetailPage } from '@/features/projects/project-detail-page' import { ProjectsPage } from '@/features/projects/projects-page' -import { RepositoriesPage } from '@/features/clones/repositories-page' import { RuntimesPage } from '@/features/runtimes/runtimes-page' import { GeneralSettingsPage } from '@/features/settings/general-settings-page' import { GitCredentialsPage } from '@/features/repositories/credential-page' @@ -125,7 +124,6 @@ export const routes: RouteObject[] = [ { path: 'my-issues', element: }, { path: 'projects', element: }, { path: 'projects/:projectId', element: }, - { path: 'repositories', element: }, { path: 'workflows', element: }, { path: 'workflows/:workflowId', diff --git a/integration/README.en.md b/integration/README.en.md index d19fce705..c57a6f1c9 100644 --- a/integration/README.en.md +++ b/integration/README.en.md @@ -7,11 +7,13 @@ ## Test categories and coverage - **`cloud_test.go`**: End-to-end lifecycle verification: - - Project and Workspace creation through sandbox → node → clone, with a real Git clone into each Workspace's own data, and `one_main` constraint enforcement. + - Project and Workspace creation through sandbox → node → clone, with a real Git clone of a Git Server double snapshot into each Workspace's own data, and `one_main` constraint enforcement. - Workspace state progression (`provisioning` $\rightarrow$ `ready` $\rightarrow$ `stopped` $\rightarrow$ `deleted`). - Controller leasing, epoch fencing, and operation claiming/advancing. - Idempotency replay and conflict detection. - **`workspace_lifecycle_test.go`**: retained effect kinds per step, clone failure/retry and unknown-outcome blocking, and Workspace data deletion waiting for termination. +- **`clone_snapshot_test.go`**: the clone step consuming the Binding's current snapshot (git-server-preset fixtures stand in for the external line): waiting for the first sync then freezing the input, failing with the sync's failure, claims passing a parked operation, frozen input outliving snapshot updates, isolated `baseRef` limited to the synced branch, run Workspaces never syncing, delete_project waiting for a repository Operation, blocking without a Git Server, and tenant-level clones being gone. +- **`git_server_grants_test.go`**: Git Server authorization: a clone execution reads only during its step, and an Agent session writes only its own Workspace's branches. - **`workspace_operations_grpc_test.go`**: the Controller driving a Workspace through `WorkspaceOperationService` / `NodeReportService`, Node incarnation replacement, and `OperationAvailable` delivery. - **`endpoints_test.go`**: Comprehensive route and parameter matrix testing for all public and internal control endpoints. - **`contract_test.go`**: End-to-end OpenAPI contract validation against live HTTP responses. @@ -49,6 +51,8 @@ Upgrade and authorization regression coverage: `tenant_upstream_migration_test.g Revision tests directly cover real uploads, checksum/size/missing objects, expired-grant refresh, post-I/O fencing, transaction failures, replay, both doubles restarting and historical upgrades. Hook tests cover ready/failed, Thread, session end, delivery and deletion committing/rolling back with control evidence. Enable mandatory storage/network environments for full `task test` and `task test:race` too; skipped S3 is not acceptance. See [Testing and gates](../docs/development/testing.en.md#s3) for the environment. +When restart acceptance replaces the Controller/Node, it preserves the same Git Server double to verify frozen history uploads and grant refresh after the delivery ref has been pushed. + ## Test-scoped contract validation Each test process loads, validates and compiles the OpenAPI router once, then shares it read-only. diff --git a/integration/README.md b/integration/README.md index 0f524520f..210981ac0 100644 --- a/integration/README.md +++ b/integration/README.md @@ -7,11 +7,13 @@ ## 测试分类与覆盖范围 - **`cloud_test.go`**:端到端完整生命周期验证: - - Project 与 Workspace 经 sandbox → node → clone 创建,真实 Git clone 到各 Workspace 自己的数据中,以及 `one_main` 约束执行。 + - Project 与 Workspace 经 sandbox → node → clone 创建,从 Git Server 替身的快照真实 Git clone 到各 Workspace 自己的数据中,以及 `one_main` 约束执行。 - Workspace 状态流转(`provisioning` $\rightarrow$ `ready` $\rightarrow$ `stopped` $\rightarrow$ `deleted`)。 - Controller 租约获取、Epoch 栅栏隔离以及操作任务认领/推进。 - 幂等性重放与冲突检测。 - **`workspace_lifecycle_test.go`**:各步骤只计划保留的 effect、clone 失败重试与结果未知时阻塞、Workspace 数据删除等待终止。 +- **`clone_snapshot_test.go`**:clone 步骤消费 Binding 当前快照(git-server-preset 夹具代替外部线):等待首次同步后冻结输入、同步失败时 operation 失败、领取越过停放的 operation、冻结输入不随快照更新、isolated `baseRef` 只能是已同步分支、run Workspace 不触发同步、delete_project 等待仓库 Operation、未配置 Git Server 时 blocked、租户级 clone 已移除。 +- **`git_server_grants_test.go`**:Git Server 授权:clone execution 只在自己的步骤内只读,Agent 会话只能写自己 Workspace 的分支。 - **`workspace_operations_grpc_test.go`**:Controller 通过 `WorkspaceOperationService`/`NodeReportService` 驱动 Workspace、Node incarnation 替换以及 `OperationAvailable` 投递。 - **`endpoints_test.go`**:针对所有公开端点和内部控制端点进行全面的路由与参数矩阵测试。 - **`contract_test.go`**:对照实际 HTTP 响应数据结构,进行端到端 OpenAPI 契约验证。 @@ -49,6 +51,8 @@ task test:race Revision 测试直接覆盖真实上传、checksum/大小/缺失、授权过期刷新、外部校验后围栏、事务失败与重放、双替身重启及历史升级。业务钩子测试验证 ready/failed、Thread、会话结束、交付、删除与控制证据同提交/回滚。完整 `task test`、`task test:race` 也应启用这些强制环境,避免把跳过 S3 当作已验收。环境变量见 [测试与门禁](../docs/development/testing.md#s3)。 +重启验收替换 Controller/Node 时保留同一个 Git Server 替身,验证交付 ref 已推送后仍能恢复冻结的 history 上传及授权刷新。 + ## 契约验证的测试作用域 同一测试进程只解析、验证和编译一次 OpenAPI 路由表,随后只读共享;每个 fixture 仍持有自己 diff --git a/integration/agent_run_boundary_test.go b/integration/agent_run_boundary_test.go index 55094042c..f37b079c2 100644 --- a/integration/agent_run_boundary_test.go +++ b/integration/agent_run_boundary_test.go @@ -135,7 +135,7 @@ func TestExecutionIdentityCannotCrossRegistries(t *testing.T) { ctx := asController(f.client.Subject) _, err := f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "reuse-clone", Epoch: f.controller.Epoch, OperationId: run, ExecutionId: cloneID, NodeId: node, Input: &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_AgentSession{AgentSession: &controlpb.AgentSessionSpec{AgentPluginId: "official/hello-world", AgentPluginVersion: "1.0.0", CheckoutExecutionId: "checkout-1", GitIdentity: &controlpb.GitIdentity{Name: "Ada", Email: "ada@example.invalid"}, InitialTurn: &controlpb.UserTurn{TurnId: "turn-1", Content: []*controlpb.ContentBlock{{Block: &controlpb.ContentBlock_Text{Text: &controlpb.TextContent{Text: "fix the bug"}}}}}}}}}) expectStatus(t, err, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - _, err = f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "reuse-session", Epoch: f.controller.Epoch, OperationId: operation, ExecutionId: execution, NodeId: node, Input: &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{Repository: "https://example.invalid/repo.git", Branch: "main"}}}}) + _, err = f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "reuse-session", Epoch: f.controller.Epoch, OperationId: operation, ExecutionId: execution, NodeId: node, Input: cloneInputSpec(seedCloneInput(f.tid, uuid.NewString(), uuid.NewString(), f.commit))}) expectStatus(t, err, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) } diff --git a/integration/agent_run_control_test.go b/integration/agent_run_control_test.go index 1f5a8de34..102f4ba55 100644 --- a/integration/agent_run_control_test.go +++ b/integration/agent_run_control_test.go @@ -219,7 +219,7 @@ func TestThreadCommandsAndUploadGrantsAreNotLostOrStored(t *testing.T) { _, e = f.executions.TakeOverNodeEvent(ctx, &controlpb.TakeOverNodeEventRequest{SubmissionId: "end-session", Epoch: f.controller.Epoch, OperationId: run, ExecutionId: "session-1", Sequence: 1, Result: ended, Event: []byte("end")}) must(t, e) f.store.ObjectStore = &objectstore.Config{Endpoint: "http://127.0.0.1:9000", Region: "us-east-1", Bucket: "revisions", PathStyle: true, AccessKeyID: "test-access", SecretAccessKey: "test-secret"} - delivery := core.Object{"kind": "deliver_revision", "sessionExecutionId": "session-1", "checkoutExecutionId": "checkout-1", "baseCommit": f.commit, "revisionRef": "refs/ora/revisions/1", "bundleKey": "revisions/t/r/w/revision.bundle", "historyKey": "revisions/t/r/w/session.jsonl"} + delivery := core.Object{"kind": "deliver_revision", "sessionExecutionId": "session-1", "checkoutExecutionId": f.checkoutOf(wid), "baseCommit": f.commit, "revisionRef": "refs/ora/revisions/1", "historyKey": "revisions/t/r/w/session.jsonl"} _, e = f.store.EnqueueExecutionWork(t.Context(), run, "deliver_revision", delivery, core.Object{"workspaceId": wid, "sandboxInstanceId": sandbox, "nodeId": node}, time.Time{}) must(t, e) claimed, e = f.executions.ClaimWork(ctx, &controlpb.ClaimWorkRequest{Epoch: f.controller.Epoch}) @@ -228,7 +228,7 @@ func TestThreadCommandsAndUploadGrantsAreNotLostOrStored(t *testing.T) { must(t, e) granted, e := runs.GrantRevisionUpload(ctx, &controlpb.GrantRevisionUploadRequest{Epoch: f.controller.Epoch, ExecutionId: "delivery-1"}) must(t, e) - if len(granted.GetGrants()) != 2 || !strings.Contains(granted.GetGrants()[0].GetUrl(), "X-Amz-Signature=") { + if len(granted.GetGrants()) != 1 || !strings.Contains(granted.GetGrants()[0].GetUrl(), "X-Amz-Signature=") { t.Fatalf("grants = %v", granted.GetGrants()) } if stored := storedGrantCount(t, f); stored != 0 { diff --git a/integration/agent_run_delivery_test.go b/integration/agent_run_delivery_test.go index aa33fff5e..ae324c440 100644 --- a/integration/agent_run_delivery_test.go +++ b/integration/agent_run_delivery_test.go @@ -51,6 +51,7 @@ import ( "github.com/wanglongan587/cloud/internal/controlpb" "github.com/wanglongan587/cloud/internal/core" "github.com/wanglongan587/cloud/internal/objectstore" + "github.com/wanglongan587/cloud/internal/simulator" ) // controllerClaims is the lease holder the seeded Thread scenes install (`ctrl-a`), which every @@ -92,13 +93,10 @@ type revisionStore struct { } // storedMeasurements answers the size and digest a store that received the Node's upload would report -// for one key. Cloud fixes each key's shape per D2 (`.../revision.bundle`, `.../session.jsonl`), so -// the key itself says which object it is; the values are the ones this file's own result builders -// declare, which is what keeps the fixture's declaration and the store's metadata one fact. -func storedMeasurements(key string) (int64, string) { - if strings.HasSuffix(key, "/revision.bundle") { - return revisionBundleSize, revisionBundleSHA - } +// for one key. The only object a delivery stores is the session history (Revision D1); the values are +// the ones this file's own result builders declare, which is what keeps the fixture's declaration and +// the store's metadata one fact. +func storedMeasurements(string) (int64, string) { return revisionHistorySize, revisionHistorySHA } @@ -210,22 +208,32 @@ func (f *fixture) deliveryInputOf(execution string) core.Object { return out } -// deliveredResult renders the success outcome a Node reports after uploading a bundle, measured -// against the attempt's own input: the Revision ref and the baseline it was dispatched with, the two -// keys Cloud assigned it, and a size and digest for each. +// deliveredResult renders the success outcome a Node reports after pushing its final commit to the +// attempt's delivery ref and uploading the history, measured against the attempt's own input: the +// Revision ref and the baseline it was dispatched with, the history key Cloud assigned it, and a size +// and digest for it. Whether the push landed is the Git Server's fact, which a case states with push. func deliveredResult(scene liveThreadScene, in core.Object, final string) *controlpb.ExecutionResult { return &controlpb.ExecutionResult{ Node: &controlpb.NodeIdentity{NodeId: scene.nodeID, NodeIncarnationId: "inc-" + scene.nodeID[:8]}, Outcome: &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{ FinalCommit: final, BaseCommit: in.S("baseCommit"), RevisionRef: in.S("revisionRef"), - Bundle: &controlpb.StoredObject{Key: in.S("bundleKey"), Size: revisionBundleSize, Sha256: revisionBundleSHA}, History: &controlpb.StoredObject{Key: in.S("historyKey"), Size: revisionHistorySize, Sha256: revisionHistorySHA}, }}, } } +// push records that the attempt's delivery ref names final on the Git Server, as the Node's accepted +// push leaves it. Without it the ref query answers ref_not_found, which is a verification failure. +func (f *fixture) push(in core.Object, final string) { + f.t.Helper() + var tenant, project string + must(f.t, f.store.Pool.QueryRow(`SELECT w.tenant_id,w.project_id FROM execution_work ew JOIN workspaces w ON w.issue_run_id=ew.run_id + WHERE ew.input->>'deliveryRef'=$1`, in.S("deliveryRef")).Scan(&tenant, &project)) + f.refs.pin(tenant, project, in.S("deliveryRef"), final) +} + // unchangedResult renders the outcome for a session that changed nothing: the final commit equals the -// baseline and only the history was uploaded, so the result declares no bundle at all. +// baseline, which the Node still pushed to its delivery ref, and the history was uploaded. func unchangedResult(scene liveThreadScene, in core.Object) *controlpb.ExecutionResult { return &controlpb.ExecutionResult{ Node: &controlpb.NodeIdentity{NodeId: scene.nodeID, NodeIncarnationId: "inc-" + scene.nodeID[:8]}, @@ -240,15 +248,11 @@ func unchangedResult(scene liveThreadScene, in core.Object) *controlpb.Execution // digest is 64 hex digits by contract; the values themselves are arbitrary, because nothing in Cloud // compares a digest against content — the object store does, and the fixture's double answers for it. const ( - revisionBundleSize = 4096 revisionHistorySize = 512 revisionFinalCommit = "1111111111111111111111111111111111111111" ) -var ( - revisionBundleSHA = strings.Repeat("a", 64) - revisionHistorySHA = strings.Repeat("b", 64) -) +var revisionHistorySHA = strings.Repeat("b", 64) // revisionRow is one `revisions` row in its typed column form, or nil when Cloud registered none. // The columns are read individually rather than as JSON because the assertions compare them against @@ -263,7 +267,9 @@ type revisionRow struct { BaseCommit string FinalCommit string RevisionRef string - // The bundle exists exactly when the checkout changed: empty and nil together. + // DeliveryRef is the verified ref on the Git Server; every Git Revision has one. + DeliveryRef string + // A Git Revision never has a bundle: these stay empty and nil together. BundleKey string BundleSize *int64 BundleSHA256 string @@ -280,19 +286,19 @@ func (f *fixture) revisionRow(runID string) *revisionRow { return nil } var row revisionRow - var bundleKey, bundleSHA sql.NullString + var bundleKey, bundleSHA, deliveryRef sql.NullString var bundleSize sql.NullInt64 var expires sql.NullTime must(f.t, f.store.Pool.QueryRow(` SELECT id::text, tenant_id::text, run_id::text, workspace_id::text, project_id::text, - repository_url, base_commit, final_commit, revision_ref, + repository_url, base_commit, final_commit, revision_ref, delivery_ref, bundle_key, bundle_size, bundle_sha256, history_key, history_size, history_sha256, expires_at FROM revisions WHERE run_id=$1`, runID). Scan(&row.ID, &row.TenantID, &row.RunID, &row.WorkspaceID, &row.ProjectID, - &row.RepositoryURL, &row.BaseCommit, &row.FinalCommit, &row.RevisionRef, + &row.RepositoryURL, &row.BaseCommit, &row.FinalCommit, &row.RevisionRef, &deliveryRef, &bundleKey, &bundleSize, &bundleSHA, &row.HistoryKey, &row.HistorySize, &row.HistorySHA256, &expires)) - row.BundleKey, row.BundleSHA256 = bundleKey.String, bundleSHA.String + row.BundleKey, row.BundleSHA256, row.DeliveryRef = bundleKey.String, bundleSHA.String, deliveryRef.String if bundleSize.Valid { size := bundleSize.Int64 row.BundleSize = &size @@ -739,24 +745,27 @@ func TestDeliveryExecutionClaimCarriesTheFixedSpecAndMovesNothing(t *testing.T) if got := in.S("revisionRef"); got != "refs/ora/revisions/"+scene.runID { t.Fatalf("revisionRef = %q, want the name Cloud owns for this run", got) } - // The per-attempt object keys (D2): both live under the run's own prefix, so no two runs and no - // two attempts can overwrite each other's objects. + // The per-attempt locations (Revision D1): the history key lives under the run's own prefix and + // the delivery ref is the attempt's own, so no two runs and no two attempts can overwrite each + // other's evidence. There is no bundle key any more. prefix := "revisions/" + scene.tenantID + "/" + scene.runID + "/" - if got := in.S("bundleKey"); !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, "/revision.bundle") { - t.Fatalf("bundleKey = %q, want a per-attempt key under %s", got, prefix) - } if got := in.S("historyKey"); !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, "/session.jsonl") { t.Fatalf("historyKey = %q, want a per-attempt key under %s", got, prefix) } - // The third segment is the delivery attempt Cloud drew before this work item existed (D2). Both - // keys name the same one, and it is Cloud's own attempt id rather than the run or the execution: - // the Controller picks the execution id later, so a key read here cannot contain it. - attempt := strings.TrimSuffix(strings.TrimPrefix(in.S("bundleKey"), prefix), "/revision.bundle") - if attempt == "" || attempt == scene.runID { - t.Fatalf("the attempt segment %q must be Cloud's own attempt id, not the run", attempt) + if _, ok := in["bundleKey"]; ok { + t.Fatalf("a Git delivery input carries no bundle key: %v", in) } - if got := strings.TrimSuffix(strings.TrimPrefix(in.S("historyKey"), prefix), "/session.jsonl"); got != attempt { - t.Fatalf("both object keys must name the same attempt: %q vs %q", got, attempt) + // The attempt is the work item itself: its id names both the history object and the delivery ref. + attempt := strings.TrimSuffix(strings.TrimPrefix(in.S("historyKey"), prefix), "/session.jsonl") + if attempt != work.S("id") || in.S("deliveryRef") != core.GitDeliveryRefPrefix+attempt { + t.Fatalf("history key %q and delivery ref %q must both name the work item %s", in.S("historyKey"), in.S("deliveryRef"), work.S("id")) + } + project, _ := f.runPlacement(scene.runID) + if got, want := in.S("gitRepositoryUrl"), simulator.GitServerBaseURL+"/"+scene.tenantID+"/"+project+".git"; got != want { + t.Fatalf("gitRepositoryUrl = %q, want the Project's Git Server repository %q", got, want) + } + if got, want := in.S("workspaceRef"), core.GitWorkspaceRefPrefix(f.runWorkspaceID(scene.runID))+"main"; got != want { + t.Fatalf("workspaceRef = %q, want the run Workspace's branch from its clone input %q", got, want) } if got := work.O("target").S("nodeId"); got != scene.nodeID { t.Fatalf("the delivery target node = %q, want the run Workspace's Node %q", got, scene.nodeID) @@ -764,9 +773,9 @@ func TestDeliveryExecutionClaimCarriesTheFixedSpecAndMovesNothing(t *testing.T) execution := "exec-delivery-" + work.S("id")[:8] f.registerDelivery(t, scene, work, execution) - // The keys were drawn before any execution existed and are never rewritten to name it: a key that - // carried the execution id could not have been written into the frozen input at all. - for _, key := range []string{in.S("bundleKey"), in.S("historyKey")} { + // The locations were drawn before any execution existed and are never rewritten to name it: one + // that carried the execution id could not have been written into the frozen input at all. + for _, key := range []string{in.S("deliveryRef"), in.S("historyKey")} { if strings.Contains(key, execution) { t.Fatalf("the per-attempt key %q names the execution id, which did not exist when Cloud drew it", key) } @@ -850,14 +859,14 @@ func TestFailedDeliveryKeepsDeliveringAndReleasesOneBackoffRetry(t *testing.T) { } // One logical delivery: everything but the per-attempt keys is identical. The frozen input is the // control plane's own spelling (proto DeliverRevisionSpec), which is what a Controller reads. - for _, key := range []string{"sessionExecutionId", "checkoutExecutionId", "baseCommit", "revisionRef"} { + for _, key := range []string{"sessionExecutionId", "checkoutExecutionId", "baseCommit", "revisionRef", "gitRepositoryUrl", "workspaceRef"} { if attempts[0].S(key) != attempts[1].S(key) { t.Fatalf("a retry must not become a new logical delivery: %s changed (%q → %q)", key, attempts[0].S(key), attempts[1].S(key)) } } - if attempts[0].S("bundleKey") == attempts[1].S("bundleKey") || attempts[0].S("historyKey") == attempts[1].S("historyKey") { - t.Fatalf("a retry must not reuse the failed attempt's object keys, got %q / %q", - attempts[0].S("bundleKey"), attempts[0].S("historyKey")) + if attempts[0].S("deliveryRef") == attempts[1].S("deliveryRef") || attempts[0].S("historyKey") == attempts[1].S("historyKey") { + t.Fatalf("a retry must not reuse the failed attempt's delivery ref or object key, got %q / %q", + attempts[0].S("deliveryRef"), attempts[0].S("historyKey")) } // The run stayed delivering, so nothing released it: no delete intent exists yet. if got := deleteOperations(f.runOperations(scene.runID)); len(got) != 0 { @@ -1197,6 +1206,7 @@ func TestFullLifecycleSessionEndToDoneSkipsNoPhase(t *testing.T) { // confirms the objects it declared before registering the row. The success is what releases the // run here — the give-up path is D5's fallback, not the mainline. in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) if _, e := f.deliveryTerminal(scene, execution, "", 1, deliveredResult(scene, in, revisionFinalCommit), "revision_delivered"); e != nil { t.Fatalf("the verified delivery must be taken over: %v", e) @@ -1236,46 +1246,35 @@ func TestFullLifecycleSessionEndToDoneSkipsNoPhase(t *testing.T) { } } -// The delivery mainline of Cloud Revision D4: a Node reports a Revision and Cloud -// confirms the objects it declared, registers the row, releases the run and declares the delete — all -// in the one takeover transaction, with the two object checks that precede it. +// The delivery mainline of Cloud Revision D3: a Node reports a Revision and Cloud +// confirms the delivery ref through the Git Server's ref query and the history through the object +// store, registers the row with its delivery ref, releases the run and declares the delete — all in +// the one takeover transaction, with the two checks that precede it. // -// Both wire shapes are covered, because they differ in the one thing the row has to get right: a -// delivered checkout carries a bundle, an unchanged one carries none and its final commit IS the -// baseline it was dispatched with. +// Both wire shapes are covered, and they verify the same evidence: an unchanged checkout also pushed +// its final commit — which IS the baseline it was dispatched with — to its own delivery ref. func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { cases := []struct { name string outcome string deliveryState string - bundle bool + changed bool + final func(core.Object) string result func(liveThreadScene, core.Object) *controlpb.ExecutionResult - // verified are the objects Cloud must spend a HEAD on, in the merged control plane's own - // order: the history first, because every outcome declares one, and the bundle second, only - // for a changed checkout. D4 fixes the *set* of objects to verify ("对声明的每个对象调用 - // HEAD"), not the sequence, so what this pins is which keys Cloud asks about. - verified func(core.Object) []core.Object }{ { - name: "a changed checkout declares the bundle and the history", outcome: "revision_delivered", - deliveryState: "saved", bundle: true, + name: "a changed checkout pushed new commits", outcome: "revision_delivered", + deliveryState: "saved", changed: true, + final: func(core.Object) string { return revisionFinalCommit }, result: func(s liveThreadScene, in core.Object) *controlpb.ExecutionResult { return deliveredResult(s, in, revisionFinalCommit) }, - verified: func(in core.Object) []core.Object { - return []core.Object{ - {"key": in.S("historyKey"), "size": int64(revisionHistorySize), "sha256": revisionHistorySHA}, - {"key": in.S("bundleKey"), "size": int64(revisionBundleSize), "sha256": revisionBundleSHA}, - } - }, }, { - name: "an unchanged checkout declares only the history", outcome: "revision_unchanged", + name: "an unchanged checkout pushed its baseline", outcome: "revision_unchanged", deliveryState: "unchanged", + final: func(in core.Object) string { return in.S("baseCommit") }, result: unchangedResult, - verified: func(in core.Object) []core.Object { - return []core.Object{{"key": in.S("historyKey"), "size": int64(revisionHistorySize), "sha256": revisionHistorySHA}} - }, }, } for _, c := range cases { @@ -1286,15 +1285,19 @@ func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { execution := f.deliverRevision(t, scene) in := f.deliveryInputOf(execution) before := f.runVersion(scene.runID) + f.push(in, c.final(in)) if _, e := f.deliveryTerminal(scene, execution, "p5-revision-saved", 1, c.result(scene, in), c.outcome); e != nil { t.Fatalf("a verified delivery must be taken over: %v", e) } - // Step 2 ran before the transaction opened, over exactly the objects the result declares — - // and only those: the grants path is a different action and must not have run. - if got, want := fmt.Sprint(objects.probed()), fmt.Sprint(c.verified(in)); got != want { - t.Fatalf("Cloud must verify exactly the declared objects:\n got %s\nwant %s", got, want) + // Step 2 ran before the transaction opened, over exactly the declared evidence: the + // attempt's own delivery ref on the Git Server and the history object, and only those. + if got, want := fmt.Sprint(f.refs.queried()), fmt.Sprint([]string{in.S("deliveryRef")}); got != want { + t.Fatalf("Cloud must verify exactly the attempt's delivery ref:\n got %s\nwant %s", got, want) + } + if got, want := fmt.Sprint(objects.probed()), fmt.Sprint([]core.Object{{"key": in.S("historyKey"), "size": int64(revisionHistorySize), "sha256": revisionHistorySHA}}); got != want { + t.Fatalf("Cloud must verify exactly the declared history:\n got %s\nwant %s", got, want) } // Signing a grant is local cryptography and sends nothing, so the store's only requests are // the probes above: the takeover never asked the store for an upload capability, and the @@ -1320,8 +1323,8 @@ func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { t.Fatalf("the Revision must carry the run Workspace's placement: got ws=%s project=%s repo=%q, want %s/%s/%q", row.WorkspaceID, row.ProjectID, row.RepositoryURL, wid, projectID, repositoryURL) } - if row.BaseCommit != in.S("baseCommit") || row.RevisionRef != in.S("revisionRef") { - t.Fatalf("the Revision must carry the attempt's own baseline and ref, got %s/%s", row.BaseCommit, row.RevisionRef) + if row.BaseCommit != in.S("baseCommit") || row.RevisionRef != in.S("revisionRef") || row.DeliveryRef != in.S("deliveryRef") { + t.Fatalf("the Revision must carry the attempt's own baseline, revision ref and delivery ref, got %s/%s/%s", row.BaseCommit, row.RevisionRef, row.DeliveryRef) } // D5: the public run read projects the Revision as metadata only — commits, whether the // run changed anything and the object sizes, never a key, ref, digest or URL. @@ -1338,13 +1341,13 @@ func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { } revision := view.O("revision") if revision.S("id") != row.ID || revision.S("finalCommit") != row.FinalCommit || revision.S("baseCommit") != row.BaseCommit || - revision.B("changed") != c.bundle || revision.N("historySize") != revisionHistorySize { + revision.B("changed") != c.changed || revision.N("historySize") != revisionHistorySize { t.Fatalf("GET %s must project the registered Revision, got %v", read, revision) } - if c.bundle != (revision["bundleSize"] != nil) { - t.Fatalf("bundleSize must be present exactly for a changed checkout, got %v", revision) + if size, ok := revision["bundleSize"]; !ok || size != nil { + t.Fatalf("a Git Revision projects bundleSize as null, got %v", revision) } - for _, private := range []string{in.S("bundleKey"), in.S("historyKey"), in.S("revisionRef"), revisionHistorySHA} { + for _, private := range []string{in.S("deliveryRef"), in.S("gitRepositoryUrl"), in.S("historyKey"), in.S("revisionRef"), revisionHistorySHA} { if strings.Contains(mustJSON(t, view), private) { t.Fatalf("GET %s leaks %q", read, private) } @@ -1357,25 +1360,12 @@ func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { if row.ExpiresAt != nil { t.Fatalf("expiry is an explicit non-goal: expires_at must stay NULL, got %v", row.ExpiresAt) } - if c.bundle { - if row.BundleKey != in.S("bundleKey") || row.BundleSHA256 != revisionBundleSHA { - t.Fatalf("a changed checkout must record the bundle it declared, got %v", row) - } - if row.BundleSize == nil || *row.BundleSize != revisionBundleSize { - t.Fatalf("bundle_size = %v, want %d", row.BundleSize, revisionBundleSize) - } - if row.FinalCommit != revisionFinalCommit { - t.Fatalf("final_commit = %q, want the delivered commit %q", row.FinalCommit, revisionFinalCommit) - } - } else { - // The bundle columns are all-or-nothing and NULL together when nothing changed, so "no - // bundle" is a fact of the row rather than a sentinel size or an empty digest. - if row.BundleKey != "" || row.BundleSHA256 != "" || row.BundleSize != nil { - t.Fatalf("an unchanged delivery must record no bundle, got %v", row) - } - if row.FinalCommit != in.S("baseCommit") { - t.Fatalf("an unchanged delivery's final commit IS the baseline, got %q", row.FinalCommit) - } + // A Git Revision never has a bundle, changed or not (Revision invariant 4). + if row.BundleKey != "" || row.BundleSHA256 != "" || row.BundleSize != nil { + t.Fatalf("a Git delivery must record no bundle, got %v", row) + } + if row.FinalCommit != c.final(in) { + t.Fatalf("final_commit = %q, want the delivered commit %q", row.FinalCommit, c.final(in)) } // The settlement: phase, D4's result pair and D3's status, all from the same transaction. @@ -1432,17 +1422,16 @@ func TestRevisionDeliveredIsVerifiedRegisteredAndReleasesTheRun(t *testing.T) { // payload* is therefore part of the contract here and not a detail: it is what makes an exact replay // of the original payload compare equal and stay a no-op. // -// The store's verdict is definitive here, not a transport failure: it holds every declared object but -// answers for the bundle with metadata that disagrees with the declaration, so the object Cloud was -// told to expect is not the object that is there. A store that is merely unreachable is the other -// case, and is a refusal that settles nothing (see the transient test below). +// The Git Server's answer is definitive here, not a transport failure: the ref query says the +// attempt's delivery ref does not exist, so the commit the Node claims to have pushed is not there. A +// Git Server or store that is merely unreachable is the other case, and is a refusal that settles +// nothing (see the transient tests below). func TestRevisionVerificationFailureIsCloudsOwnVerdict(t *testing.T) { f := setup(t) - objects := f.useObjectStore() + f.useObjectStore() scene := deliveringScene(t, f) execution := f.deliverRevision(t, scene) in := f.deliveryInputOf(execution) - objects.breakObject(in.S("bundleKey")) res := deliveredResult(scene, in, revisionFinalCommit) if _, e := f.deliveryTerminal(scene, execution, "p5-verification-failed", 1, res, "revision_delivered"); e != nil { @@ -1503,6 +1492,123 @@ func TestRevisionVerificationFailureIsCloudsOwnVerdict(t *testing.T) { } } +// Revision D3 step 4: a delivery ref naming another commit, or a history object that disagrees with +// its declaration, is as definitive as a missing ref; each is Cloud's verification failure. +func TestRevisionVerificationFailsOnAnyDisagreeingEvidence(t *testing.T) { + for _, c := range []struct { + name string + stage func(f *fixture, objects *revisionStore, in core.Object) + }{ + {"the delivery ref names another commit", func(f *fixture, _ *revisionStore, in core.Object) { + f.push(in, strings.Repeat("9", 40)) + }}, + {"the history disagrees with its declaration", func(f *fixture, objects *revisionStore, in core.Object) { + f.push(in, revisionFinalCommit) + objects.breakObject(in.S("historyKey")) + }}, + } { + t.Run(c.name, func(t *testing.T) { + f := setup(t) + objects := f.useObjectStore() + scene := deliveringScene(t, f) + execution := f.deliverRevision(t, scene) + in := f.deliveryInputOf(execution) + c.stage(f, objects, in) + if _, e := f.deliveryTerminal(scene, execution, "", 1, deliveredResult(scene, in, revisionFinalCommit), "revision_delivered"); e != nil { + t.Fatalf("an unverifiable delivery must be taken over as Cloud's own verdict: %v", e) + } + if row := f.revisionRow(scene.runID); row != nil { + t.Fatalf("no Revision may be registered for evidence Cloud could not confirm, got %v", row) + } + if verdict := f.revisionVerification(execution); verdict.S("outcome") != "failed" || verdict.S("reason") != "verification_failed" { + t.Fatalf("Cloud's verdict must be failed{verification_failed}, got %v", verdict) + } + if got := len(f.deliveryAttempts(scene.runID)); got != 2 { + t.Fatalf("a failed verification must release exactly one retry, got %d attempts", got) + } + }) + } +} + +// Revision D3 step 2: a Git Server Cloud cannot reach proves nothing about the push. The takeover is +// refused as unavailable with nothing written, and the same submission settles once it answers. +func TestDeliveryWithAnUnreachableGitServerIsRefusedTransiently(t *testing.T) { + f := setup(t) + f.useObjectStore() + scene := deliveringScene(t, f) + execution := f.deliverRevision(t, scene) + in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) + res := deliveredResult(scene, in, revisionFinalCommit) + before := f.runVersion(scene.runID) + + f.refs.setUnavailable(true) + _, e := f.deliveryTerminal(scene, execution, "git-server-down", 1, res, "revision_delivered") + expectStatus(t, e, codes.Unavailable, controlpb.ErrorCode_ERROR_CODE_UNAVAILABLE) + if got := f.nodeResult(execution); len(got) != 0 || f.revisionVerification(execution) != nil || len(f.receipts(execution)) != 0 { + t.Fatalf("a refused verification must write nothing, got result %v", got) + } + if got := f.runVersion(scene.runID); got != before || len(f.deliveryAttempts(scene.runID)) != 1 { + t.Fatalf("a refused verification must not touch the run or release a retry, version %d want %d", got, before) + } + + f.refs.setUnavailable(false) + if _, e := f.deliveryTerminal(scene, execution, "git-server-down", 1, res, "revision_delivered"); e != nil { + t.Fatalf("the retry after the Git Server came back must settle the delivery: %v", e) + } + if row := f.revisionRow(scene.runID); row == nil || row.DeliveryRef != in.S("deliveryRef") { + t.Fatalf("the retry must register the Revision with its delivery ref, got %v", row) + } +} + +// Revision D2: a delivery needs the Git Server as well as the object store. A deployment without a +// Git Server releases no delivery work at all: the run's delivery is recorded as skipped with +// git_server_unconfigured and the run is released at once, with no Revision and nothing to claim. +func TestDeliveryWithoutAGitServerIsSkipped(t *testing.T) { + f := setup(t) + f.bindBusinessHooks() + f.useObjectStore() + scene := seedLiveThreadScene(t, f) + scene.start(t, f) + f.runningThread(t, scene) + // The business hook is where the reason goes; record what the control plane hands it + // (after the scene, which binds the hooks itself). + var ( + mu sync.Mutex + settlements []string + ) + settle := f.store.OnDeliverySettled + f.store.OnDeliverySettled = func(ctx context.Context, tx *sql.Tx, run, execution string, settled core.Object) error { + mu.Lock() + settlements = append(settlements, settled.S("outcome")+"/"+settled.S("reason")) + mu.Unlock() + return settle(ctx, tx, run, execution, settled) + } + f.store.GitServer = nil + f.sessionEndOK(t, scene, "", 2, controlpb.AgentSessionEndReason_AGENT_SESSION_END_REASON_USER_ENDED) + if got := len(f.deliveryWork(scene.runID)); got != 0 { + t.Fatalf("no delivery work may be released without a Git Server, got %d", got) + } + mu.Lock() + settled := fmt.Sprint(settlements) + mu.Unlock() + if f.scalar(`SELECT count(*) FROM revision_delivery_skips WHERE run_id=$1`, scene.runID) != 1 || settled != "[skipped/git_server_unconfigured]" { + t.Fatalf("the skipped delivery must be recorded once with its reason, settled %s", settled) + } + if got := f.runPhase(scene.runID); got != "releasing" { + t.Fatalf("a skipped delivery releases the run, got phase %q", got) + } + if got := f.runResult(scene.runID); got.S("deliveryState") != "skipped" || f.revisionRow(scene.runID) != nil { + t.Fatalf("a skipped delivery registers no Revision, got result %v", got) + } + // Enqueuing again, as a retry or a replay would, stays skipped and still releases nothing. + out, e := f.store.EnqueueExecutionWork(t.Context(), scene.runID, "deliver_revision", core.Object{"kind": "deliver_revision"}, core.Object{"workspaceId": "w", "sandboxInstanceId": "s", "nodeId": "n"}, time.Time{}) + must(t, e) + if !out.B("skipped") || len(f.deliveryWork(scene.runID)) != 0 { + t.Fatalf("a repeated enqueue must stay skipped, got %v", out) + } +} + // A store Cloud cannot reach is a different verdict from a store that disagrees. The // endpoint is contacted between two transactions, so a delivery result that arrives while the store is // down must be refused as transient: nothing is settled, nothing is receipted, and the Node's next @@ -1518,6 +1624,7 @@ func TestDeliveryWithAnUnreachableObjectStoreIsRefusedTransiently(t *testing.T) scene := deliveringScene(t, f) execution := f.deliverRevision(t, scene) in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) res := deliveredResult(scene, in, revisionFinalCommit) before := f.runVersion(scene.runID) @@ -1600,11 +1707,11 @@ func TestRevisionResultContradictingItsInputNeverReachesTheObjectStore(t *testin {"base_commit", func(r *controlpb.RevisionDelivered, _ core.Object) { r.BaseCommit = strings.Repeat("f", 40) }, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT}, - {"bundle_key", func(r *controlpb.RevisionDelivered, in core.Object) { - r.Bundle.Key = in.S("historyKey") + {"final_commit_on_base", func(r *controlpb.RevisionDelivered, in core.Object) { + r.FinalCommit = in.S("baseCommit") }, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT}, {"history_key", func(r *controlpb.RevisionDelivered, in core.Object) { - r.History.Key = in.S("bundleKey") + r.History.Key = strings.TrimSuffix(in.S("historyKey"), "session.jsonl") + "other.jsonl" }, codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT}, } for _, c := range cases { @@ -1624,6 +1731,9 @@ func TestRevisionResultContradictingItsInputNeverReachesTheObjectStore(t *testin if probed := objects.probed(); len(probed) != 0 { t.Fatalf("the input comparison must precede the probes: %d object(s) probed for a result Cloud could already refuse (%v)", len(probed), probed) } + if queried := f.refs.queried(); len(queried) != 0 { + t.Fatalf("the input comparison must precede the ref query, queried %v", queried) + } if row := f.revisionRow(scene.runID); row != nil { t.Fatalf("no Revision may be registered for a result that contradicts its input, got %v", row) } @@ -1740,6 +1850,29 @@ func TestNodeMayNotReportCloudsOwnVerificationVerdict(t *testing.T) { expectStatus(t, refusal(), codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) } +// Revision D6: bundle delivery is gone and the wire reserves its old failure value 4. A Node built +// against an older contract that still sends it gets the same refusal as any reason outside the closed +// set, and nothing about the attempt is recorded. +func TestNodeMayNotReportTheReservedBundleFailure(t *testing.T) { + f := setup(t) + scene := deliveringScene(t, f) + execution := f.deliverRevision(t, scene) + before := f.runVersion(scene.runID) + + res := revisionFailedResult(scene, controlpb.RevisionFailureReason(4)) + expectStatus(t, errOnly(f.deliveryTerminal(scene, execution, "", 1, res, "revision_failed")), + codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) + if got := f.receipts(execution); len(got) != 0 { + t.Fatalf("a refused result must write no receipt, got %v", got) + } + if got := f.nodeResult(execution); len(got) != 0 { + t.Fatalf("a refused result must write no durable result, got %v", got) + } + if got := f.runVersion(scene.runID); got != before { + t.Fatalf("a refused result must not write the run: version %d → %d", before, got) + } +} + // D4's last clause and invariant 9 at the integration boundary: a delivery result that // arrives after the run left `delivering` decides nothing. The delivered shape is the one that // matters, because it is the only one that would otherwise register a Revision. @@ -1817,9 +1950,6 @@ func TestLateDeliveredRevisionRegistersNothingOnAReleasedRun(t *testing.T) { // an earlier attempt already registered without restating fifteen columns. type deliveryRevisionRow struct { finalCommit string - // withBundle selects the changed-checkout shape: the three bundle columns set together, from the - // same input the delivered attempt carries. - withBundle bool } // seedRevision writes the Revision an earlier attempt left for this run, through the same schema the @@ -1838,14 +1968,12 @@ func (f *fixture) seedRevision(t *testing.T, scene liveThreadScene, in core.Obje id := uuid.NewString() wid := f.runWorkspaceID(scene.runID) projectID, repositoryURL := f.runPlacement(scene.runID) - var bundleKey, bundleSHA *string - var bundleSize *int64 outcome := "unchanged" - if row.withBundle { - key, size, sha := in.S("bundleKey"), int64(revisionBundleSize), revisionBundleSHA - bundleKey, bundleSize, bundleSHA = &key, &size, &sha + if row.finalCommit != in.S("baseCommit") { outcome = "delivered" } + // The earlier attempt verified its own delivery ref, never this attempt's. + deliveryRef := core.GitDeliveryRefPrefix + uuid.NewString() prior := "exec-prior-" + uuid.NewString() input, e := json.Marshal(in) must(t, e) @@ -1860,13 +1988,11 @@ func (f *fixture) seedRevision(t *testing.T, scene liveThreadScene, in core.Obje must(t, e) _, e = f.store.Pool.Exec(` INSERT INTO revisions(id, execution_id, tenant_id, run_id, workspace_id, project_id, repository_url, - base_commit, final_commit, revision_ref, - bundle_key, bundle_size, bundle_sha256, + base_commit, final_commit, revision_ref, delivery_ref, history_key, history_size, history_sha256) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, id, prior, scene.tenantID, scene.runID, wid, projectID, repositoryURL, - in.S("baseCommit"), row.finalCommit, in.S("revisionRef"), - bundleKey, bundleSize, bundleSHA, + in.S("baseCommit"), row.finalCommit, in.S("revisionRef"), deliveryRef, in.S("historyKey"), revisionHistorySize, revisionHistorySHA) must(t, e) return id @@ -1899,21 +2025,21 @@ func TestRevisionRegistrationIsUniquePerRunAndRollsBackAConflictingAttempt(t *te }{ { name: "the same outcome an earlier attempt already registered", - registered: deliveryRevisionRow{finalCommit: revisionFinalCommit, withBundle: true}, + registered: deliveryRevisionRow{finalCommit: revisionFinalCommit}, delivered: func(scene liveThreadScene, in core.Object) *controlpb.ExecutionResult { return deliveredResult(scene, in, revisionFinalCommit) }, }, { name: "a different final commit", - registered: deliveryRevisionRow{finalCommit: strings.Repeat("c", 40), withBundle: true}, + registered: deliveryRevisionRow{finalCommit: strings.Repeat("c", 40)}, delivered: func(scene liveThreadScene, in core.Object) *controlpb.ExecutionResult { return deliveredResult(scene, in, revisionFinalCommit) }, }, { - name: "an unchanged delivery against a Revision that saved a bundle", - registered: deliveryRevisionRow{finalCommit: revisionFinalCommit, withBundle: true}, + name: "an unchanged delivery against a Revision that delivered new commits", + registered: deliveryRevisionRow{finalCommit: revisionFinalCommit}, delivered: func(scene liveThreadScene, in core.Object) *controlpb.ExecutionResult { return unchangedResult(scene, in) }, @@ -1929,6 +2055,7 @@ func TestRevisionRegistrationIsUniquePerRunAndRollsBackAConflictingAttempt(t *te before := f.runVersion(scene.runID) registeredID := f.seedRevision(t, scene, in, c.registered) res := c.delivered(scene, in) + f.push(in, res.GetRevisionDelivered().GetFinalCommit()+res.GetRevisionUnchanged().GetFinalCommit()) takeover := errOnly(f.deliveryTerminal(scene, execution, "p5-revision-conflict", 1, res, "revision_delivered")) // A constraint violation carries no Fault of its own, so it takes the mapping's safe @@ -1988,7 +2115,7 @@ func TestRevisionRegistrationIsUniquePerRunAndRollsBackAConflictingAttempt(t *te scene := deliveringScene(t, f) execution := f.deliverRevision(t, scene) in := f.deliveryInputOf(execution) - registeredID := f.seedRevision(t, scene, in, deliveryRevisionRow{finalCommit: revisionFinalCommit, withBundle: true}) + registeredID := f.seedRevision(t, scene, in, deliveryRevisionRow{finalCommit: revisionFinalCommit}) target := f.deliveryTarget(scene.runID) if _, e := f.store.EnqueueExecutionWork(context.Background(), scene.runID, "deliver_revision", in, target, time.Time{}); e == nil { @@ -2006,15 +2133,16 @@ func TestRevisionRegistrationIsUniquePerRunAndRollsBackAConflictingAttempt(t *te }) } -// D2/D3's upload grants over the contract the Controller actually speaks. Cloud signs -// one presigned PUT per object key of that attempt's frozen input, and each grant names exactly one -// key and one method: the URL carries the capability, so nothing the Node does can widen it to another -// object, and nothing is persisted — the grants exist only in the reply. +// D2/D3's upload grants over the contract the Controller actually speaks. The only +// object a Git delivery stores is the session history (contract D3), so Cloud signs exactly one +// presigned PUT, for the attempt's history key, and refuses a checksum for any other key: the URL +// carries the capability, so nothing the Node does can widen it to another object, and nothing is +// persisted — the grant exists only in the reply. // // The refusals are D3's ordered set, asserted here because the Controller's retry policy depends on // which one it gets: UNAVAILABLE means "a capability is missing, keep the attempt", 404 means "Cloud // never registered this execution", and 409 means "this attempt can no longer upload anything". -func TestGrantRevisionUploadSignsOneGrantPerObjectKey(t *testing.T) { +func TestGrantRevisionUploadSignsOnlyTheHistoryKey(t *testing.T) { f := setup(t) objects := f.useObjectStore() scene := deliveringScene(t, f) @@ -2031,10 +2159,10 @@ func TestGrantRevisionUploadSignsOneGrantPerObjectKey(t *testing.T) { out, e := grant(execution) must(t, e) - if len(out.GetGrants()) != 2 { - t.Fatalf("one attempt gets one grant per object key of its input, got %d", len(out.GetGrants())) + if len(out.GetGrants()) != 1 { + t.Fatalf("one attempt gets one grant, for its history key, got %d", len(out.GetGrants())) } - wantKeys := []string{in.S("bundleKey"), in.S("historyKey")} + wantKeys := []string{in.S("historyKey")} for i, g := range out.GetGrants() { if g.GetObjectKey() != wantKeys[i] { t.Fatalf("grant %d names %q, want the input's own key %q", i, g.GetObjectKey(), wantKeys[i]) @@ -2077,7 +2205,7 @@ func TestGrantRevisionUploadSignsOneGrantPerObjectKey(t *testing.T) { executionRow, workInput := f.deliveryRows(execution) repeat, e := grant(execution) must(t, e) - if len(repeat.GetGrants()) != 2 { + if len(repeat.GetGrants()) != 1 { t.Fatalf("an attempt that has not reported can ask for its grants again, got %d", len(repeat.GetGrants())) } if got, want := repeat.GetGrants()[0].GetObjectKey(), wantKeys[0]; got != want { @@ -2099,6 +2227,17 @@ func TestGrantRevisionUploadSignsOneGrantPerObjectKey(t *testing.T) { t.Fatalf("the schema must have nowhere to persist an upload grant, got %d column(s)", got) } + // A checksum may bind only the history key: there is no other object a delivery could upload. + for _, key := range []string{strings.TrimSuffix(in.S("historyKey"), "session.jsonl") + "revision.bundle", "revisions/elsewhere/session.jsonl"} { + _, e := client.GrantRevisionUpload(asController("ctrl-a"), &controlpb.GrantRevisionUploadRequest{Epoch: 1, ExecutionId: execution, Checksums: map[string]string{key: revisionHistorySHA}}) + expectStatus(t, e, codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) + } + bound, e := client.GrantRevisionUpload(asController("ctrl-a"), &controlpb.GrantRevisionUploadRequest{Epoch: 1, ExecutionId: execution, Checksums: map[string]string{in.S("historyKey"): revisionHistorySHA}}) + must(t, e) + if len(bound.GetGrants()) != 1 || bound.GetGrants()[0].GetObjectKey() != in.S("historyKey") || bound.GetGrants()[0].GetHeaders()["x-amz-checksum-sha256"] == "" { + t.Fatalf("a checksum-bound grant names the history key and binds its digest, got %v", bound.GetGrants()) + } + // Unknown execution: Cloud never registered it. A session execution gets the same answer, because // the merged control plane resolves an upload subject as "a registered deliver_revision execution" // and reports anything else as not-found rather than distinguishing "Cloud has no such execution" @@ -2191,7 +2330,7 @@ func TestDeliveryReplayMatrix(t *testing.T) { expectStatus(t, errOnly(f.deliveryTerminal(scene, execution, "", 1, result, "revision_failed/something_else")), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) expectStatus(t, errOnly(f.deliveryTerminal(scene, execution, "", 1, - revisionFailedResult(scene, controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_BUNDLE_FAILED), event)), + revisionFailedResult(scene, controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE), event)), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) if got := fmt.Sprint(f.deliveryAttempts(scene.runID)); got != fmt.Sprint(attempts) { diff --git a/integration/agent_run_resume_test.go b/integration/agent_run_resume_test.go index b6bd6f03d..c38db6c1b 100644 --- a/integration/agent_run_resume_test.go +++ b/integration/agent_run_resume_test.go @@ -1,10 +1,11 @@ package integration // Resuming a new IssueRun from its Issue's latest Revision (specs -// decisions/cloud/issue-run/20261010-resume-run-from-latest-issue-revision.md, contract decision -// controller-integration/20261010-revision-restore-contract.md). Every case drives the production -// paths: session start through StartQueuedAgentSessionsOnce, dispatch through clone_claim and -// clone_dispatch, session end and delivery through TakeOverNodeEvent, grants through gRPC. +// decisions/cloud/issue-run/20261010-resume-run-from-latest-issue-revision.md as changed by +// cloud/revision/20261017-git-native-delivery-refs-and-resume.md D7 and +// cloud/controller-integration/20261016-snapshot-clone-input-and-execution-ref-grants.md D8). Every +// case drives the production paths: session start through StartQueuedAgentSessionsOnce, dispatch +// through clone_claim and clone_dispatch, session end and delivery through TakeOverNodeEvent. import ( "database/sql" @@ -21,18 +22,18 @@ import ( ) const ( - // The base commit a follow-up run's Workspace was cloned at: the remote moved on since the first - // run, which is what makes "the bundle stays relative to this run's own base" observable. + // The base commit a follow-up run's Workspace was cloned at: the snapshot moved on since the first + // run, which is what makes "the delivery stays relative to this run's own base" observable. resumedBaseCommit = "3333333333333333333333333333333333333333" // A commit the resumed Agent made on top of the prior Revision. resumedFinalCommit = "4444444444444444444444444444444444444444" ) -// savedRevision is a run whose delivery registered a Revision with its own bundle. +// savedRevision is a run whose delivery registered a Revision with its own verified delivery ref. type savedRevision struct { - scene liveThreadScene - revisionID string - bundleKey string + scene liveThreadScene + revisionID string + deliveryRef string } // savedRun drives one run through session, end and a verified delivery that changed the checkout, @@ -42,14 +43,15 @@ func savedRun(t *testing.T, f *fixture) savedRevision { scene := deliveringScene(t, f) execution := f.deliverRevision(t, scene) in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) if _, e := f.deliveryTerminal(scene, execution, "", 1, deliveredResult(scene, in, revisionFinalCommit), "revision_delivered"); e != nil { t.Fatalf("the first run's delivery must register a Revision: %v", e) } row := f.revisionRow(scene.runID) - if row == nil || row.BundleKey == "" { - t.Fatal("the first run must store a bundle of its own") + if row == nil || row.DeliveryRef != in.S("deliveryRef") { + t.Fatal("the first run must register its own delivery ref") } - return savedRevision{scene: scene, revisionID: row.ID, bundleKey: row.BundleKey} + return savedRevision{scene: scene, revisionID: row.ID, deliveryRef: row.DeliveryRef} } // seedFollowUpRun seeds a second run of the prior scene's Issue in exactly the state @@ -57,6 +59,13 @@ func savedRun(t *testing.T, f *fixture) savedRevision { // resumedBaseCommit, a maintenance binding and a connected Node. With otherProject the Workspace // belongs to a different project of the same tenant, as after the Issue was moved. func seedFollowUpRun(t *testing.T, f *fixture, prior liveThreadScene, otherProject bool) liveThreadScene { + t.Helper() + return seedFollowUpRunFrom(t, f, prior, otherProject, "") +} + +// seedFollowUpRunFrom is seedFollowUpRun whose Workspace was cloned from cloneBase instead of the +// configured Git Server, as after the deployment moved its Git Server; "" keeps the configured one. +func seedFollowUpRunFrom(t *testing.T, f *fixture, prior liveThreadScene, otherProject bool, cloneBase string) liveThreadScene { t.Helper() var agent, project, user, input string must(t, f.store.Pool.QueryRow(` @@ -64,6 +73,7 @@ func seedFollowUpRun(t *testing.T, f *fixture, prior liveThreadScene, otherProje FROM issue_runs r JOIN workspaces w ON w.issue_run_id = r.id WHERE r.id=$1`, prior.runID).Scan(&agent, &project, &user, &input)) runID, workspaceID, nodeID, sandboxID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() operationID, cloneExecution := uuid.NewString(), "exec-baseline-"+workspaceID[:8] + cloneInput := seedCloneInput(prior.tenantID, project, workspaceID, resumedBaseCommit) tx, e := f.store.Pool.Begin() must(t, e) exec := func(name, q string, args ...any) { @@ -72,24 +82,27 @@ func seedFollowUpRun(t *testing.T, f *fixture, prior liveThreadScene, otherProje t.Fatalf("seed %s: %v", name, err) } } - repository := "https://example.invalid/takeover.git" if otherProject { moved := uuid.NewString() - repository = "https://example.invalid/moved.git" + repository := "https://example.invalid/moved.git" exec("project", `WITH p AS (INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,lifecycle) VALUES($1,$2,$3,$4,'moved','active') RETURNING id,tenant_id) INSERT INTO repository_bindings(id,tenant_id,project_id,repository_url,branch) SELECT gen_random_uuid(),tenant_id,id,$5,'main' FROM p`, moved, prior.tenantID, user, prior.spaceID, repository) exec("main-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), prior.tenantID, user, moved) exec("issue", `UPDATE issues SET project_ref=$2 WHERE id=$1`, prior.issueID, moved) project = moved + cloneInput = seedCloneInput(prior.tenantID, project, workspaceID, resumedBaseCommit) + } + if cloneBase != "" { + cloneInput["repositoryUrl"] = cloneBase + "/" + prior.tenantID + "/" + project + ".git" } exec("run", `INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,status,phase,input) VALUES($1,$2,$3,'agent',$4,'dispatched','starting',$5)`, runID, prior.tenantID, prior.issueID, agent, input) - exec("run-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,admission_open,runtime_generation,requested_ref,issue_run_id) VALUES($1,$2,$3,$3,'verified_request',$4,'isolated','running','ready',true,1,'HEAD',$5)`, workspaceID, prior.tenantID, user, project, runID) + exec("run-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,admission_open,runtime_generation,requested_ref,issue_run_id) VALUES($1,$2,$3,$3,'verified_request',$4,'isolated','running','ready',true,1,'main',$5)`, workspaceID, prior.tenantID, user, project, runID) exec("task", `INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'follow-up task')`, uuid.NewString(), workspaceID) exec("run-workspace-bind", `UPDATE issue_runs SET workspace_id=$2, version=version+1, updated_at=now() WHERE id=$1`, runID, workspaceID) exec("run-workspace-operation", `INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash,controller_epoch) VALUES($1,$2,$3,$4,$5,'create_workspace','succeeded','done','{}',$6,'baseline-hash',1)`, operationID, prior.tenantID, user, project, workspaceID, "run-workspace-"+workspaceID[:8]) exec("run-workspace-clone", `INSERT INTO clone_executions(execution_id,operation_id,workspace_id,node_id,node_operation_id,input,result,dispatched_epoch) VALUES($1,$2,$3,$4,$5,$6,$7,1)`, cloneExecution, operationID, workspaceID, nodeID, cloneExecution, - mustJSON(t, core.Object{"kind": "clone", "repositoryUrl": repository, "branch": "main"}), + mustJSON(t, cloneInput), mustJSON(t, core.Object{"node": core.Object{"nodeId": nodeID, "nodeIncarnationId": "inc-" + nodeID[:8]}, "outcome": "clone_ready", "path": "/work", "commit": resumedBaseCommit})) exec("run-workspace-baseline", `UPDATE workspaces SET base_commit_id=$2, version=version+1 WHERE id=$1`, workspaceID, resumedBaseCommit) exec("run-runtime-control", `INSERT INTO runtime_controls(workspace_id,state,maintenance_run_id,control_epoch,binding_confirmed,input_closed) VALUES($1,'maintenance',$2,2,false,false)`, workspaceID, runID) @@ -137,13 +150,8 @@ func (f *fixture) runResumeRevision(runID string) string { return id.String } -func (f *fixture) grantRevisionDownload(execution string) (*controlpb.GrantRevisionDownloadResponse, error) { - return controlpb.NewAgentRunServiceClient(f.controlConn).GrantRevisionDownload(asController("ctrl-a"), - &controlpb.GrantRevisionDownloadRequest{Epoch: 1, ExecutionId: execution}) -} - // resumedUnchangedResult is what a Node reports when a resumed run ends on its prior Revision's final -// commit: no bundle, only the history (contract decision D4). +// commit: it still pushes that commit to its own delivery ref (contract decision D4, Revision D7). func resumedUnchangedResult(scene liveThreadScene, in core.Object, final string) *controlpb.ExecutionResult { return &controlpb.ExecutionResult{ Node: &controlpb.NodeIdentity{NodeId: scene.nodeID, NodeIncarnationId: "inc-" + scene.nodeID[:8]}, @@ -162,8 +170,9 @@ func (f *fixture) endResumedSession(t *testing.T, scene liveThreadScene) string return f.deliverRevision(t, scene) } -// Resume D1/D4 and contract D1/D2: a follow-up run fixes the Issue's latest Revision into its session -// spec and its first prompt, and only that session may read exactly that bundle. +// Resume D1/D4, Revision D7 and contract D8: a follow-up run fixes the Issue's latest Revision into its +// session spec and its first prompt, naming the Git Server repository its checkout was cloned from, +// the prior's own delivery ref and the prior's base commit. func TestFollowUpRunResumesTheIssueLatestRevision(t *testing.T) { f := setup(t) first := savedRun(t, f) @@ -179,9 +188,10 @@ func TestFollowUpRunResumesTheIssueLatestRevision(t *testing.T) { if prior.S("revisionId") != first.revisionID || prior.S("finalCommit") != revisionFinalCommit { t.Fatalf("priorRevision = %v, want the first run's Revision %s at %s", prior, first.revisionID, revisionFinalCommit) } - bundle := prior.O("bundle") - if bundle.S("key") != first.bundleKey || bundle.N("size") != revisionBundleSize || bundle.S("sha256") != revisionBundleSHA { - t.Fatalf("priorRevision.bundle = %v, want the verified object %s", bundle, first.bundleKey) + var clone string + must(t, f.store.Pool.QueryRow(`SELECT e.input->>'repositoryUrl' FROM clone_executions e JOIN issue_runs r ON r.workspace_id=e.workspace_id WHERE r.id=$1`, scene.runID).Scan(&clone)) + if prior.S("deliveryRef") != first.deliveryRef || prior.S("baseCommit") != seedRunWorkspaceCommit || prior.S("gitRepositoryUrl") != clone || prior["bundle"] != nil { + t.Fatalf("priorRevision = %v, want the first run's delivery ref %s, its base and the clone's repository %s", prior, first.deliveryRef, clone) } if got := f.runResumeRevision(scene.runID); got != first.revisionID { t.Fatalf("resume_revision_id = %q, want %s", got, first.revisionID) @@ -191,23 +201,6 @@ func TestFollowUpRunResumesTheIssueLatestRevision(t *testing.T) { resume.S("finalCommit") != revisionFinalCommit || resume.S("baseCommit") != seedRunWorkspaceCommit { t.Fatalf("the first prompt's resume context = %v", resume) } - - grants, e := f.grantRevisionDownload(scene.executionID) - must(t, e) - if len(grants.GetGrants()) != 1 { - t.Fatalf("want exactly one download grant, got %d", len(grants.GetGrants())) - } - grant := grants.GetGrants()[0] - if grant.GetObjectKey() != first.bundleKey || grant.GetMethod() != "GET" || !strings.Contains(grant.GetUrl(), first.bundleKey) || grant.GetExpiresAt() == nil { - t.Fatalf("download grant = key %q method %q", grant.GetObjectKey(), grant.GetMethod()) - } - if _, ok := grant.GetHeaders()["if-none-match"]; ok { - t.Fatal("a read grant must not carry the create-only condition") - } - // The first run's ended session has a result, and has no prior: neither may read anything. - if _, e := f.grantRevisionDownload(first.scene.executionID); status.Code(e) == codes.OK { - t.Fatal("a session execution with a result must not receive a download grant") - } } // Resume D1/D4: a run of an Issue without any Revision keeps today's prompt and spec exactly. @@ -222,14 +215,12 @@ func TestRunWithoutHistoryStartsFresh(t *testing.T) { if _, ok := f.firstPromptContext(scene.runID)["resume"]; ok { t.Fatal("a run without history must keep its first prompt free of a resume context") } - if _, e := f.grantRevisionDownload(scene.executionID); status.Code(e) == codes.OK { - t.Fatal("a session without priorRevision must not receive a download grant") - } } -// Resume D5 and contract D4: a resumed run that added nothing is unchanged and reuses the prior -// bundle; the next run reaches that bundle in one hop. -func TestResumedRunWithoutNewCommitsReusesThePriorBundle(t *testing.T) { +// Contract D4 and Revision D7: a resumed run that added nothing is unchanged, yet it pushed its final +// commit — the prior's — to its own delivery ref, so the next run resumes it directly with no hop to +// the prior's evidence. +func TestResumedRunWithoutNewCommitsHasItsOwnDeliveryRef(t *testing.T) { f := setup(t) first := savedRun(t, f) second := seedFollowUpRun(t, f, first.scene, false) @@ -238,12 +229,13 @@ func TestResumedRunWithoutNewCommitsReusesThePriorBundle(t *testing.T) { in := f.deliveryInputOf(execution) prior := in.O("priorRevision") - if prior.S("revisionId") != first.revisionID || prior.S("finalCommit") != revisionFinalCommit || prior["bundle"] != nil { - t.Fatalf("the delivery's priorRevision = %v, want the session's prior without bundle", prior) + if prior.S("revisionId") != first.revisionID || prior.S("finalCommit") != revisionFinalCommit || len(prior) != 2 { + t.Fatalf("the delivery's priorRevision = %v, want only the session prior's identity and final commit", prior) } if in.S("baseCommit") != resumedBaseCommit { t.Fatalf("the delivery stays relative to this run's own base, got %q", in.S("baseCommit")) } + f.push(in, revisionFinalCommit) // Ending anywhere else than base or prior is not "unchanged". if _, e := f.deliveryTerminal(second, execution, "", 1, resumedUnchangedResult(second, in, resumedFinalCommit), "unchanged-elsewhere"); status.Code(e) == codes.OK { t.Fatal("an unchanged result off both the base and the prior final commit must be refused") @@ -252,12 +244,12 @@ func TestResumedRunWithoutNewCommitsReusesThePriorBundle(t *testing.T) { t.Fatalf("a resumed run ending on the prior final commit is unchanged: %v", e) } row := f.revisionRow(second.runID) - if row == nil || row.BundleKey != "" || row.FinalCommit != revisionFinalCommit || row.BaseCommit != resumedBaseCommit { + if row == nil || row.DeliveryRef != in.S("deliveryRef") || row.BundleKey != "" || row.FinalCommit != revisionFinalCommit || row.BaseCommit != resumedBaseCommit { t.Fatalf("the resumed unchanged Revision = %+v", row) } priorID, holder, _ := f.resumeColumns(second.runID) - if priorID != first.revisionID || holder != first.revisionID { - t.Fatalf("prior/holder = %s/%s, want both %s", priorID, holder, first.revisionID) + if priorID != first.revisionID || holder != "" { + t.Fatalf("prior/holder = %s/%s, want %s and no borrowed bundle", priorID, holder, first.revisionID) } if got := f.runResult(second.runID).S("deliveryState"); got != "unchanged" { t.Fatalf("deliveryState = %q, want unchanged", got) @@ -266,24 +258,64 @@ func TestResumedRunWithoutNewCommitsReusesThePriorBundle(t *testing.T) { third := seedFollowUpRun(t, f, second, false) third.start(t, f) next := f.sessionInput(third.executionID).O("priorRevision") - if next.S("revisionId") != row.ID || next.O("bundle").S("key") != first.bundleKey { - t.Fatalf("the third run must resume the second Revision through the first's bundle, got %v", next) + if next.S("revisionId") != row.ID || next.S("deliveryRef") != row.DeliveryRef || next.S("finalCommit") != revisionFinalCommit { + t.Fatalf("the third run must resume the second Revision through its own delivery ref, got %v", next) } } -// Resume D5: a resumed run that committed stores its own bundle and names its prior. -func TestResumedRunWithNewCommitsStoresItsOwnBundle(t *testing.T) { +// Revision D7: a Revision that ended on its own base commit holds nothing a later run could resume, +// and one registered before Git delivery has no ref to fetch; neither is a candidate. +func TestRevisionsWithoutNewCommitsOrRefsAreNotResumed(t *testing.T) { + f := setup(t) + f.bindBusinessHooks() + scene := deliveringScene(t, f) + execution := f.deliverRevision(t, scene) + in := f.deliveryInputOf(execution) + f.push(in, in.S("baseCommit")) + if _, e := f.deliveryTerminal(scene, execution, "", 1, unchangedResult(scene, in), "unchanged"); e != nil { + t.Fatalf("the unchanged delivery must register: %v", e) + } + next := seedFollowUpRun(t, f, scene, false) + next.start(t, f) + if prior := f.sessionInput(next.executionID)["priorRevision"]; prior != nil { + t.Fatalf("a Revision that never left its base must not be resumed, got %v", prior) + } + if _, ok := f.firstPromptContext(next.runID)["resume"]; ok { + t.Fatal("no candidate means no resume context at all") + } +} + +// Contract D8: a prior Revision is fetched from the repository the checkout was cloned from or not at +// all. A Workspace cloned from another Git Server address starts fresh and says why. +func TestRunClonedFromAnotherRepositoryStartsFresh(t *testing.T) { + f := setup(t) + first := savedRun(t, f) + moved := seedFollowUpRunFrom(t, f, first.scene, false, "https://git-server.elsewhere.invalid") + moved.start(t, f) + if prior := f.sessionInput(moved.executionID)["priorRevision"]; prior != nil { + t.Fatalf("a prior in another repository than the checkout's must not be resumed, got %v", prior) + } + resume := f.firstPromptContext(moved.runID).O("resume") + if resume.S("skippedRevisionId") != first.revisionID || resume.S("reason") != "repository_changed" { + t.Fatalf("resume context = %v, want repository_changed", resume) + } +} + +// Resume D5 as changed by Revision D7: a resumed run that committed delivers its own ref and names +// its prior. +func TestResumedRunWithNewCommitsDeliversItsOwnRef(t *testing.T) { f := setup(t) first := savedRun(t, f) second := seedFollowUpRun(t, f, first.scene, false) second.start(t, f) execution := f.endResumedSession(t, second) in := f.deliveryInputOf(execution) + f.push(in, resumedFinalCommit) if _, e := f.deliveryTerminal(second, execution, "", 1, deliveredResult(second, in, resumedFinalCommit), "revision_delivered"); e != nil { t.Fatalf("the resumed delivery must register: %v", e) } row := f.revisionRow(second.runID) - if row == nil || row.BundleKey != in.S("bundleKey") || row.FinalCommit != resumedFinalCommit { + if row == nil || row.DeliveryRef != in.S("deliveryRef") || row.FinalCommit != resumedFinalCommit { t.Fatalf("the resumed changed Revision = %+v", row) } priorID, holder, _ := f.resumeColumns(second.runID) @@ -295,21 +327,30 @@ func TestResumedRunWithNewCommitsStoresItsOwnBundle(t *testing.T) { } } -// Resume D3: only a base commit gone from the remote refuses the prior Revision; the next run then -// starts fresh and says why. Any other restore failure leaves it selectable. +// Resume D3 as changed by Revision D7: only a prior delivery ref the Git Server could not serve +// refuses the prior Revision, and not when the run had already asked to cancel — the Git Server +// refuses a cancelled run's reads with the same answer. The next run then starts fresh and says why. +// Any other restore failure leaves it selectable. func TestRestoreFailuresAndTheNextRunSelection(t *testing.T) { for _, tc := range []struct { - detail string - refused bool + name string + detail string + cancelled bool + refused bool }{ - {"prior_revision_base_unavailable", true}, - {"prior_revision_unavailable", false}, + {"prior_revision_base_unavailable", "prior_revision_base_unavailable", false, true}, + {"prior_revision_unavailable", "prior_revision_unavailable", false, false}, + {"base unavailable after a cancel request", "prior_revision_base_unavailable", true, false}, } { - t.Run(tc.detail, func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { f := setup(t) first := savedRun(t, f) second := seedFollowUpRun(t, f, first.scene, false) second.start(t, f) + if tc.cancelled { + _, e := f.store.Pool.Exec(`UPDATE issue_runs SET cancel_requested_at=now() WHERE id=$1`, second.runID) + must(t, e) + } if _, e := f.sessionEnd(second, "", 1, controlpb.AgentSessionEndReason_AGENT_SESSION_END_REASON_AGENT_FAILED, tc.detail, ""); e != nil { t.Fatalf("a restore failure is a session end: %v", e) } @@ -320,8 +361,14 @@ func TestRestoreFailuresAndTheNextRunSelection(t *testing.T) { } // The failed run's own delivery ends on its base: an ordinary unchanged Revision without a // prior, which is never a candidate. + if tc.cancelled { + // A cancelled run delivers nothing worth resuming; the next run is what matters. + _, e := f.store.Pool.Exec(`UPDATE issue_runs SET cancel_requested_at=NULL WHERE id=$1`, second.runID) + must(t, e) + } execution := f.deliverRevision(t, second) in := f.deliveryInputOf(execution) + f.push(in, in.S("baseCommit")) if _, e := f.deliveryTerminal(second, execution, "", 1, unchangedResult(second, in), "unchanged"); e != nil { t.Fatalf("the failed run's delivery must settle: %v", e) } @@ -364,7 +411,8 @@ func TestRunOfAMovedIssueStartsFresh(t *testing.T) { } } -// Resume D4/D5: the public projection says whether a run stored a bundle and what it resumed. +// Resume D4 and Revision D8: the public projection says whether a run delivered new commits and what +// it resumed; a Git Revision has no bundle size. func TestPublicRunProjectsResume(t *testing.T) { f := setup(t) first := savedRun(t, f) @@ -372,6 +420,7 @@ func TestPublicRunProjectsResume(t *testing.T) { second.start(t, f) execution := f.endResumedSession(t, second) in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) if _, e := f.deliveryTerminal(second, execution, "", 1, resumedUnchangedResult(second, in, revisionFinalCommit), "unchanged-on-prior"); e != nil { t.Fatalf("delivery: %v", e) } @@ -384,32 +433,37 @@ func TestPublicRunProjectsResume(t *testing.T) { t.Fatalf("resumeRevisionId = %v", out["resumeRevisionId"]) } revision := out.O("revision") - if revision.B("changed") || revision.S("priorRevisionId") != first.revisionID { - t.Fatalf("revision projection = %v, want unchanged with prior %s", revision, first.revisionID) + if revision.B("changed") || revision.S("priorRevisionId") != first.revisionID || revision["bundleSize"] != nil { + t.Fatalf("revision projection = %v, want unchanged with prior %s and no bundle size", revision, first.revisionID) } } -// Migration 0033: the database itself refuses a reused bundle without a prior, a row that both owns -// and reuses a bundle, and any refusal reason other than the base being gone. -func TestRevisionResumeColumnsKeepTheBundleShape(t *testing.T) { +// Migrations 0033 and 0037: the database itself refuses a Git Revision that also names a bundle or a +// borrowed one, a malformed delivery ref, a second Revision claiming the same delivery ref, and any +// refusal reason other than the prior being unavailable. +func TestRevisionShapeKeepsGitAndBundleRowsApart(t *testing.T) { f := setup(t) first := savedRun(t, f) second := seedFollowUpRun(t, f, first.scene, false) second.start(t, f) execution := f.endResumedSession(t, second) in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) if _, e := f.deliveryTerminal(second, execution, "", 1, resumedUnchangedResult(second, in, revisionFinalCommit), "unchanged-on-prior"); e != nil { t.Fatalf("delivery: %v", e) } - reused := f.revisionRow(second.runID).ID + resumed := f.revisionRow(second.runID).ID for name, q := range map[string]string{ - "reused bundle without prior": `UPDATE revisions SET prior_revision_id=NULL WHERE id='` + reused + `'`, - "own and reused bundle": `UPDATE revisions SET bundle_revision_id='` + reused + `' WHERE id='` + first.revisionID + `'`, - "unknown refusal reason": `UPDATE revisions SET resume_refused_at=now(), resume_refused_reason='other' WHERE id='` + first.revisionID + `'`, - "refusal without reason": `UPDATE revisions SET resume_refused_at=now() WHERE id='` + first.revisionID + `'`, + "git row with a bundle": `UPDATE revisions SET bundle_key='k', bundle_size=1, bundle_sha256='` + strings.Repeat("a", 64) + `' WHERE id='` + first.revisionID + `'`, + "git row borrowing a bundle": `UPDATE revisions SET bundle_revision_id='` + first.revisionID + `' WHERE id='` + resumed + `'`, + "malformed delivery ref": `UPDATE revisions SET delivery_ref='refs/heads/main' WHERE id='` + first.revisionID + `'`, + "two rows claiming one ref": `UPDATE revisions SET delivery_ref=(SELECT delivery_ref FROM revisions WHERE id='` + first.revisionID + `') WHERE id='` + resumed + `'`, + "unknown refusal reason": `UPDATE revisions SET resume_refused_at=now(), resume_refused_reason='other' WHERE id='` + first.revisionID + `'`, + "refusal without reason": `UPDATE revisions SET resume_refused_at=now() WHERE id='` + first.revisionID + `'`, + "git shape without its ref and a changed checkout": `UPDATE revisions SET delivery_ref=NULL WHERE id='` + first.revisionID + `'`, } { if _, e := f.store.Pool.Exec(q); e == nil { - t.Fatalf("%s must violate a 0033 constraint", name) + t.Fatalf("%s must violate a revisions constraint", name) } } } diff --git a/integration/agent_run_thread_takeover_test.go b/integration/agent_run_thread_takeover_test.go index b6a1f56cd..487087d34 100644 --- a/integration/agent_run_thread_takeover_test.go +++ b/integration/agent_run_thread_takeover_test.go @@ -36,7 +36,7 @@ import ( ) // seedRunWorkspaceCommit is the 40-hex commit the seeded run Workspace's clone produced, i.e. the -// baseline every Revision bundle is relative to. It is the shape commitID validates, so a scene that +// baseline every Revision is delivered against. It is the shape commitID validates, so a scene that // seeds it can never be the reason a delivery input is rejected. const seedRunWorkspaceCommit = "0123456789abcdef0123456789abcdef01234567" @@ -139,7 +139,7 @@ func seedStartingAgentRun(t *testing.T, store *core.Store) (runID, workspaceID, // insertWorkspace always records the resolved trigger actor with 'verified_request' evidence. A // row with the column left NULL is a state createRunWorkspace cannot produce, and the delete would // then enqueue an operation with an empty actor. - exec("run-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,admission_open,runtime_generation,requested_ref,issue_run_id) VALUES($1,$2,$3,$4,'verified_request',$5,'isolated','running','ready',true,1,'HEAD',$6)`, workspaceID, tenant, user, user, project, runID) + exec("run-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,admission_open,runtime_generation,requested_ref,issue_run_id) VALUES($1,$2,$3,$4,'verified_request',$5,'isolated','running','ready',true,1,'main',$6)`, workspaceID, tenant, user, user, project, runID) exec("task", `INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'takeover task')`, uuid.NewString(), workspaceID) exec("run-workspace-bind", `UPDATE issue_runs SET workspace_id=$2, version=version+1, updated_at=now() WHERE id=$1`, runID, workspaceID) // The run Workspace's baseline. A Workspace observed `ready` reached that state through its @@ -151,7 +151,7 @@ func seedStartingAgentRun(t *testing.T, store *core.Store) (runID, workspaceID, baselineOperationID, tenant, user, project, workspaceID, "run-workspace-"+workspaceID[:8]) exec("run-workspace-clone", `INSERT INTO clone_executions(execution_id,operation_id,workspace_id,node_id,node_operation_id,input,result,dispatched_epoch) VALUES($1,$2,$3,$4,$5,$6,$7,1)`, baselineExecutionID, baselineOperationID, workspaceID, nodeID, baselineExecutionID, - mustJSON(t, core.Object{"kind": "clone", "repositoryUrl": "https://example.invalid/takeover.git", "branch": "main"}), + mustJSON(t, seedCloneInput(tenant, project, workspaceID, seedRunWorkspaceCommit)), mustJSON(t, core.Object{"node": core.Object{"nodeId": nodeID, "nodeIncarnationId": "inc-" + nodeID[:8]}, "outcome": "clone_ready", "path": "/work", "commit": seedRunWorkspaceCommit})) exec("run-workspace-baseline", `UPDATE workspaces SET base_commit_id=$2, version=version+1 WHERE id=$1`, workspaceID, seedRunWorkspaceCommit) // The run's runtime binding, in exactly the state the create_workspace operation's last step diff --git a/integration/clone_api_test.go b/integration/clone_api_test.go deleted file mode 100644 index 2dbc90502..000000000 --- a/integration/clone_api_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "strconv" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - - "github.com/wanglongan587/cloud/internal/core" -) - -// control runs one clone control action as the lease-holding simulator controller, through the -// same Store.Control transaction the gRPC ExecutionService uses. The public API test needs a -// dispatch and a result on record; how they arrive over gRPC is covered by control_grpc_test. -func (f *fixture) control(action, submission string, body core.Object) core.Object { - f.t.Helper() - body["epoch"] = f.controller.Epoch - out, e := f.store.Control(context.Background(), &core.ControlRequest{Action: action, SubmissionID: submission, Body: body, Service: &core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: f.client.Subject}, Kind: "service", Role: "controller"}}) - must(f.t, e) - return out -} - -func (f *fixture) clone(oid string) core.Object { - return f.call("GET", f.path("/clones/"+oid), nil, "", 200) -} - -func text(v any) string { b, _ := json.Marshal(v); return string(b) } - -func TestPublicClonesAcceptOnceAndReflectExecutionFacts(t *testing.T) { - f := setup(t) - signals, cancel, ok := f.store.Signals.Subscribe() - if !ok { - t.Fatal("control hub refused subscription") - } - defer cancel() - body := core.Object{"requestId": uuid.NewString(), "repository": "https://example.invalid/repo.git", "branch": "main"} - - // Every public mutation needs the idempotency key; acceptance is 202 with a pending view. - f.call("POST", f.path("/clones"), body, "", 400) - accepted := f.call("POST", f.path("/clones"), body, "clone-1", 202) - oid := accepted.S("operationId") - if _, e := uuid.Parse(oid); e != nil || accepted.S("requestId") != body.S("requestId") || accepted.S("repository") != body.S("repository") || accepted.S("branch") != "main" || accepted.O("state").S("kind") != "pending" || accepted["executionId"] != nil || accepted["nodeId"] != nil { - t.Fatal("acceptance view", accepted) - } - select { - case signal := <-signals: - if signal.Kind != core.SignalWorkAvailable || signal.OperationID != oid { - t.Fatal("work signal", signal) - } - case <-time.After(5 * time.Second): - t.Fatal("no work signal after acceptance") - } - - // The request identity is durable across keys; a changed input under it conflicts, and the - // idempotency key itself still rejects a changed body. Neither repeat signals new work. - if again := f.call("POST", f.path("/clones"), body, "clone-2", 202); again.S("operationId") != oid { - t.Fatal("request identity created another request", again) - } - f.call("POST", f.path("/clones"), core.Object{"requestId": body.S("requestId"), "repository": body.S("repository"), "branch": "other"}, "clone-3", 409) - f.call("POST", f.path("/clones"), core.Object{"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "main"}, "clone-1", 409) - select { - case signal := <-signals: - t.Fatal("repeat signaled work", signal) - default: - } - - // Cloud refuses what no Controller could dispatch, so nothing can sit queued forever. - for i, bad := range []core.Object{ - {"requestId": uuid.NewString(), "repository": "file:///srv/repo.git", "branch": "main"}, - {"requestId": uuid.NewString(), "repository": "https://user@example.invalid/repo.git", "branch": "main"}, - {"requestId": uuid.NewString(), "repository": "ssh://git:secret@example.invalid/repo.git", "branch": "main"}, - {"requestId": uuid.NewString(), "repository": "https://example.invalid/", "branch": "main"}, - {"requestId": uuid.NewString(), "repository": "https://example.invalid/repo.git?x=1", "branch": "main"}, - {"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "HEAD"}, - {"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "refs/heads/main"}, - {"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "feature..x"}, - {"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "feature.lock"}, - {"requestId": "", "repository": body.S("repository"), "branch": "main"}, - } { - f.call("POST", f.path("/clones"), bad, "bad-"+strconv.Itoa(i), 400) - } - // The branch reaches Git verbatim: whitespace that validRef would trim, and inner tabs or control - // characters, are refused at acceptance instead of blocking the Controller's queue head. - for i, branch := range []string{" main", "main ", "main\n", "ma\tin", "ma\x7fin", " main"} { - bad := core.Object{"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": branch} - if fault := f.call("POST", f.path("/clones"), bad, "bad-branch-"+strconv.Itoa(i), 400); fault.S("code") != "invalid_ref" { - t.Fatalf("branch %q: want invalid_ref, got %v", branch, fault) - } - } - - // Reads: the list and the single view agree with the accepted view; unknown ids are absent. - list := f.call("GET", f.path("/clones"), nil, "", 200) - if items := list["items"].([]any); len(items) != 1 || core.Object(items[0].(map[string]any)).S("operationId") != oid { - t.Fatal("listing", list) - } - if one := f.clone(oid); text(one) != text(accepted) { - t.Fatal("single view differs from acceptance", one, accepted) - } - f.call("GET", f.path("/clones/"+uuid.NewString()), nil, "", 404) - f.call("GET", f.path("/clones/not-a-uuid"), nil, "", 404) - - // Another member of the tenant sees nothing: visibility follows the submitting user. - f.user.Subject = "member" - member := f.call("GET", "/api/v1/me", nil, "", 200) - f.user.Subject = "alice" - f.addMemberID(member.S("id"), "member") - f.user.Subject = "member" - if others := f.call("GET", f.path("/clones"), nil, "", 200); len(others["items"].([]any)) != 0 { - t.Fatal("clone visible to another member", others) - } - f.call("GET", f.path("/clones/"+oid), nil, "", 404) - f.user.Subject = "alice" - - // A Controller claims, registers the dispatch and takes over the Node result; the public view - // reflects each durable fact without exposing the receipt. - if claimed := f.control("clone_claim", "", core.Object{}); claimed.O("request").S("id") != oid { - t.Fatal("claim", claimed) - } - input := core.Object{"kind": "clone", "repositoryUrl": body.S("repository"), "branch": "main"} - f.control("clone_dispatch", uuid.NewString(), core.Object{"operationId": oid, "executionId": "exec-1", "nodeId": "node-a", "input": input}) - dispatched := f.clone(oid) - if dispatched.S("executionId") != "exec-1" || dispatched.S("nodeId") != "node-a" || dispatched.O("state").S("kind") != "pending" { - t.Fatal("dispatched view", dispatched) - } - node := core.Object{"nodeId": "node-a", "nodeIncarnationId": "incarnation-1"} - f.control("clone_takeover", uuid.NewString(), core.Object{"operationId": oid, "executionId": "exec-1", "sequence": 0, "event": "e30=", "result": core.Object{"node": node, "outcome": "clone_ready", "path": "/srv/checkout", "commit": f.commit}}) - if done := f.clone(oid); text(done.O("state")) != text(core.Object{"kind": "succeeded", "path": "/srv/checkout", "commit": f.commit}) { - t.Fatal("succeeded view", done) - } - - // A failed execution surfaces the Controller's reason in the browser vocabulary. - failing := f.call("POST", f.path("/clones"), core.Object{"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "missing"}, "clone-4", 202) - fid := failing.S("operationId") - f.control("clone_dispatch", uuid.NewString(), core.Object{"operationId": fid, "executionId": "exec-2", "nodeId": "node-a", "input": core.Object{"kind": "clone", "repositoryUrl": body.S("repository"), "branch": "missing"}}) - f.control("clone_takeover", uuid.NewString(), core.Object{"operationId": fid, "executionId": "exec-2", "sequence": 0, "event": "e30=", "result": core.Object{"node": node, "outcome": "clone_failed", "reason": "CLONE_FAILURE_REASON_BRANCH_NOT_FOUND", "retainedPath": "/srv/retained"}}) - if failed := f.clone(fid); text(failed.O("state")) != text(core.Object{"kind": "failed", "reason": "branchNotFound", "retainedPath": "/srv/retained"}) { - t.Fatal("failed view", failed) - } - // An attempt terminated before Git's own verdict surfaces as interrupted, keeping its directory. - cut := f.call("POST", f.path("/clones"), core.Object{"requestId": uuid.NewString(), "repository": body.S("repository"), "branch": "main"}, "clone-5", 202) - cid := cut.S("operationId") - f.control("clone_dispatch", uuid.NewString(), core.Object{"operationId": cid, "executionId": "exec-3", "nodeId": "node-a", "input": input}) - f.control("clone_takeover", uuid.NewString(), core.Object{"operationId": cid, "executionId": "exec-3", "sequence": 0, "event": "e30=", "result": core.Object{"node": node, "outcome": "clone_failed", "reason": "CLONE_FAILURE_REASON_INTERRUPTED", "retainedPath": "/srv/cut"}}) - if interrupted := f.clone(cid); text(interrupted.O("state")) != text(core.Object{"kind": "failed", "reason": "interrupted", "retainedPath": "/srv/cut"}) { - t.Fatal("interrupted view", interrupted) - } - if all := f.call("GET", f.path("/clones?limit=10"), nil, "", 200); len(all["items"].([]any)) != 3 { - t.Fatal("listing after completion", all) - } -} diff --git a/integration/clone_snapshot_test.go b/integration/clone_snapshot_test.go new file mode 100644 index 000000000..48314b2c5 --- /dev/null +++ b/integration/clone_snapshot_test.go @@ -0,0 +1,386 @@ +package integration + +// The clone step consumes the Binding's current snapshot (specs +// decisions/cloud/operation/20261016-clone-step-consumes-binding-snapshot.md). Every case drives the +// production claim, advance and dispatch paths through the simulator Controller or the JSON and gRPC +// control surfaces, against real PostgreSQL and the Git Server double's repositories on disk; the +// git-server-preset fixtures stand in for the external line. + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/gitpreset" + "github.com/wanglongan587/cloud/internal/simulator" +) + +// preset is the git-server-preset tooling over the fixture's database and Git Server double. +func (f *fixture) preset() *gitpreset.Preset { + return &gitpreset.Preset{DB: f.store.Pool, Root: f.git.Root, Git: "git"} +} + +// operationRow reads the columns the clone-step decisions are stated in. +func (f *fixture) operationRow(id string) core.Object { + f.t.Helper() + var state, step, code, result string + must(f.t, f.store.Pool.QueryRow(`SELECT state,step,COALESCE(error_code,''),result::text FROM operations WHERE id=$1`, id).Scan(&state, &step, &code, &result)) + return core.Object{"state": state, "step": step, "errorCode": code, "result": result} +} + +// due makes a parked operation claimable now, standing in for its retry delay elapsing. +func (f *fixture) due(id string) { + f.t.Helper() + _, err := f.store.Pool.Exec(`UPDATE operations SET retry_at=clock_timestamp()-interval '1 second' WHERE id=$1 AND state='retry_wait'`, id) + must(f.t, err) +} + +// D1, D2, D3, invariants 1–3, 5 and 6: while the Binding's first sync has not ended the clone step +// waits without registering an execution or spending an attempt, a later claim rechecks and keeps +// waiting, and once the snapshot is recorded the same operation freezes it once and completes. +func TestCloneStepWaitsForTheFirstSyncThenFreezesTheSnapshot(t *testing.T) { + f := setup(t) + f.withoutSnapshot = true + created := f.create("await-snapshot") + pid, wid, oid := created.O("resource").S("id"), created.O("workspace").S("id"), created.O("operation").S("id") + f.drain() + waiting := func(when string) { + t.Helper() + op := f.operationRow(oid) + if op.S("state") != "retry_wait" || op.S("errorCode") != "snapshot_not_ready" || op.S("step") != "clone" { + t.Fatalf("%s: the clone step must wait for the snapshot, got %v", when, op) + } + if f.scalar("SELECT count(*) FROM clone_executions WHERE operation_id=$1", oid) != 0 || f.frozenClone(oid) != nil { + t.Fatalf("%s: waiting registers no execution and freezes no input", when) + } + if w := f.ws(wid); w.B("admissionOpen") { + t.Fatalf("%s: admission opened without a clone: %v", when, w) + } + } + waiting("entering the step") + f.due(oid) + f.drain() + waiting("a later claim") + + commit := f.syncProject(pid) + f.due(oid) + f.drain() + if op := f.operationRow(oid); op.S("state") != "succeeded" { + t.Fatalf("once the snapshot exists the operation completes, got %v", op) + } + binding, branch := f.bindingOf(pid).S("id"), f.bindingOf(pid).S("branch") + want := core.Object{ + "kind": "clone", "repositoryUrl": simulator.GitServerBaseURL + "/" + f.tid + "/" + pid + ".git", + "snapshotRef": core.GitSnapshotRef(binding, commit), "baseCommit": commit, "workspaceRef": core.GitWorkspaceRefPrefix(wid) + branch, + } + if got := f.frozenClone(oid); mustJSON(t, got) != mustJSON(t, want) { + t.Fatalf("request.clone = %v, want %v", got, want) + } + // One execution, the first attempt, carrying the frozen input verbatim (jsonb equality). + if f.scalar("SELECT count(*) FROM clone_executions e JOIN operations o ON o.id=e.operation_id WHERE e.operation_id=$1 AND e.input=o.request->'clone'", oid) != 1 || + f.scalar("SELECT count(*) FROM clone_executions WHERE operation_id=$1", oid) != 1 { + t.Fatal("exactly one clone execution must carry the frozen input") + } + w := f.ws(wid) + if w.S("baseCommitId") != commit || w.S("requestedRef") != branch || !w.B("admissionOpen") { + t.Fatalf("the Workspace's baseline is the snapshot commit on the Binding branch, got %v", w) + } +} + +// D2: once the Binding's latest sync ended without success the waiting step fails with that sync +// recorded, and a failed create_project still leaves the Project usable. A sync the external line +// replays in the same transaction it ends the failed one keeps the step waiting instead. +func TestCloneStepEndsWithTheBindingSyncFailure(t *testing.T) { + f := setup(t) + f.withoutSnapshot = true + created := f.create("sync-replayed") + pid, oid := created.O("resource").S("id"), created.O("operation").S("id") + f.drain() + binding := f.bindingOf(pid).S("id") + // The automatic replay: the failed sync's settlement creates its successor in one transaction. + tx, err := f.store.Pool.Begin() + must(t, err) + _, err = tx.Exec(`UPDATE repository_operations SET state='failed',error_code='upstream_unreachable',finished_at=clock_timestamp() WHERE binding_id=$1 AND state='pending'`, binding) + must(t, err) + _, err = tx.Exec(`INSERT INTO repository_operations(id,type,tenant_id,project_id,binding_id,state,trigger) VALUES(gen_random_uuid(),'repository_sync',$1,$2,$3,'pending','lease_expired')`, f.tid, pid, binding) + must(t, err) + must(t, tx.Commit()) + f.due(oid) + f.drain() + if op := f.operationRow(oid); op.S("state") != "retry_wait" || op.S("errorCode") != "snapshot_not_ready" { + t.Fatalf("a replayed sync keeps the step waiting, got %v", op) + } + + failed, err := f.preset().Operation(context.Background(), pid, "sync", "failed", "credential_rejected") + must(t, err) + f.due(oid) + f.drain() + op := f.operationRow(oid) + if op.S("state") != "failed" || op.S("errorCode") != "repository_sync_failed" { + t.Fatalf("a failed latest sync ends the step, got %v", op) + } + recorded := core.Object{} + must(t, json.Unmarshal([]byte(op.S("result")), &recorded)) + // Only the sync's ID is recorded; its code stays on the sync Operation itself. + if recorded.S("syncOperationId") != failed["operationId"] || len(recorded) != 1 { + t.Fatalf("the failure must record only the sync's ID: %s, want %v", op.S("result"), failed["operationId"]) + } + // The public view leaves the internal sync Operation's ID to Cloud and keeps no copy of its code. + if public := f.call("GET", f.path("/operations/"+oid), nil, "", 200).O("result"); len(public) != 0 { + t.Fatalf("the public operation must carry neither the sync's ID nor a copy of its code, got %v", public) + } + if f.scalar("SELECT count(*) FROM clone_executions WHERE operation_id=$1", oid) != 0 { + t.Fatal("a failed sync must never fall back to a clone") + } + if f.scalar("SELECT count(*) FROM projects WHERE id=$1 AND lifecycle='active'", pid) != 1 { + t.Fatal("a failed create_project must leave the Project active") + } + if w := f.ws(created.O("workspace").S("id")); w.B("admissionOpen") || w.S("observedState") != "unavailable" { + t.Fatalf("the main Workspace stays closed: %v", w) + } +} + +// D2: the claim decides by one consistent read of the snapshot and the latest sync. The external +// line records a successful sync and its snapshot in one transaction without taking any lock the +// claim holds; here that transaction also holds repository_operations exclusively until the claim +// has started reading, so it commits between the claim's view of the snapshot table and its view of +// the sync. A claim that read them in two statements would see no snapshot and then the successful +// sync, and fail a step that has a snapshot to clone; one statement sees both or neither. +func TestCloneStepReadsTheSnapshotAndItsSyncTogether(t *testing.T) { + f := setup(t) + f.withoutSnapshot = true + created := f.create("consistent-read") + pid, oid := created.O("resource").S("id"), created.O("operation").S("id") + f.drain() + if op := f.operationRow(oid); op.S("errorCode") != "snapshot_not_ready" { + t.Fatalf("the step must first wait for the snapshot, got %v", op) + } + binding := f.bindingOf(pid).S("id") + f.due(oid) + + writer, err := f.store.Pool.Begin() + must(t, err) + defer func() { _ = writer.Rollback() }() + var sync string + must(t, writer.QueryRow(`UPDATE repository_operations SET state='succeeded',finished_at=clock_timestamp() WHERE binding_id=$1 AND state='pending' RETURNING id`, binding).Scan(&sync)) + _, err = writer.Exec(`INSERT INTO repository_snapshots(binding_id,tenant_id,project_id,commit_oid,snapshot_ref,sync_operation_id) VALUES($1,$2,$3,$4,$5,$6)`, binding, f.tid, pid, f.commit, core.GitSnapshotRef(binding, f.commit), sync) + must(t, err) + _, err = writer.Exec(`LOCK TABLE repository_operations IN ACCESS EXCLUSIVE MODE`) + must(t, err) + + type answer struct { + body core.Object + status int + err error + } + claimed := make(chan answer, 1) + go func() { + o, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/operations/claim", "controller", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: f.client.Subject}}, &f.user, "", core.Object{"epoch": f.controller.Epoch}) + claimed <- answer{o, status, e} + }() + // The claim is now blocked on the writer's lock, past any read it already made. + deadline := time.Now().Add(30 * time.Second) + for f.scalar(`SELECT count(*) FROM pg_locks WHERE relation='repository_operations'::regclass AND NOT granted`) == 0 { + if time.Now().After(deadline) { + t.Fatal("the claim never reached the Binding's syncs") + } + time.Sleep(10 * time.Millisecond) + } + must(t, writer.Commit()) + got := <-claimed + must(t, got.err) + if got.status != http.StatusOK || got.body.O("operation").S("id") != oid { + t.Fatalf("the claim must hand out the step with its snapshot: %d %v", got.status, got.body) + } + if op := f.operationRow(oid); op.S("state") != "running" || op.S("step") != "clone" { + t.Fatalf("a successful sync recorded with its snapshot must not fail the step, got %v", op) + } + if frozen := f.frozenClone(oid); frozen.S("baseCommit") != f.commit { + t.Fatalf("the step froze %v, want the snapshot %s", frozen, f.commit) + } +} + +// D2 and invariant 4: a waiter at the head of the queue never empties a claim; the same claim parks +// it again and hands out the next claimable operation. +func TestClaimHandsOutTheNextOperationPastAWaiter(t *testing.T) { + f := setup(t) + f.withoutSnapshot = true + waiter := f.create("head-waiter").O("operation").S("id") + f.drain() + f.withoutSnapshot = false + other := f.call("POST", f.path("/projects"), core.Object{"name": "Second", "repositoryUrl": "https://example.invalid/second.git", "defaultBranch": "main"}, "behind-waiter", 202).O("operation").S("id") + f.due(waiter) + claimed := f.internal("/internal/v1/operations/claim", core.Object{"epoch": f.controller.Epoch}, 200) + if got := claimed.O("operation").S("id"); got != other { + t.Fatalf("the claim must hand out the operation behind the waiter, got %q want %q", got, other) + } + if op := f.operationRow(waiter); op.S("state") != "retry_wait" || op.S("errorCode") != "snapshot_not_ready" { + t.Fatalf("the waiter is parked again in the same claim, got %v", op) + } + if f.scalar("SELECT count(*) FROM operations WHERE id=$1 AND retry_at>clock_timestamp()", waiter) != 1 { + t.Fatal("the waiter must wait again before its next recheck") + } +} + +// D1, invariant 2: the input is frozen once per operation. A retry after a failed attempt clones the +// same snapshot even when the Binding's current snapshot moved meanwhile, while a Workspace created +// afterwards freezes the new one. +func TestFrozenCloneInputOutlivesSnapshotUpdates(t *testing.T) { + f := setup(t) + created := f.create("frozen-input") + pid, wid, oid := created.O("resource").S("id"), created.O("workspace").S("id"), created.O("operation").S("id") + f.substrate.SetFault("clone", "fail") + if err := f.controller.Drain(context.Background()); err == nil { + t.Fatal("the fixture did not fail the first clone") + } + frozen := f.frozenClone(oid) + if frozen.S("baseCommit") != f.commit { + t.Fatalf("the first attempt froze %v, want the first snapshot %s", frozen, f.commit) + } + moved, err := f.preset().Snapshot(context.Background(), pid, "", 3) + must(t, err) + f.substrate.SetFault("clone", "") + f.due(oid) + f.drain() + if got := f.frozenClone(oid); mustJSON(t, got) != mustJSON(t, frozen) { + t.Fatalf("a retry must not re-freeze the input: %v, was %v", got, frozen) + } + if f.scalar("SELECT count(DISTINCT input::text) FROM clone_executions WHERE operation_id=$1", oid) != 1 || f.scalar("SELECT count(*) FROM clone_executions WHERE operation_id=$1", oid) != 2 { + t.Fatal("every attempt of one operation carries the same input") + } + if f.ws(wid).S("baseCommitId") != f.commit { + t.Fatal("the main Workspace's baseline is the snapshot it froze") + } + isolated := f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "Later"}, "after-update", 202) + f.drain() + if got := f.frozenClone(isolated.O("operation").S("id")); got.S("baseCommit") != moved["commit"] { + t.Fatalf("a later Workspace freezes the current snapshot %v, got %v", moved["commit"], got) + } + if f.ws(wid).S("baseCommitId") != f.commit { + t.Fatal("a later snapshot must not touch an existing Workspace's baseline") + } +} + +// D3: an isolated Workspace starts from the Binding's branch. An absent, empty or HEAD baseRef means +// it; any other branch is refused before anything is recorded. +func TestIsolatedWorkspaceBaseRefMustBeTheSyncedBranch(t *testing.T) { + f := setup(t) + created := f.create("base-ref") + pid := created.O("resource").S("id") + f.drain() + workspaces, operations := f.scalar("SELECT count(*) FROM workspaces"), f.scalar("SELECT count(*) FROM operations") + refused := f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "Feature", "baseRef": "feature"}, "base-feature", 400) + if refused.S("code") != "base_ref_not_synced" || f.scalar("SELECT count(*) FROM workspaces") != workspaces || f.scalar("SELECT count(*) FROM operations") != operations { + t.Fatalf("another branch must be refused with nothing recorded: %v", refused) + } + for i, body := range []core.Object{{"title": "Omitted"}, {"title": "Empty", "baseRef": ""}, {"title": "Head", "baseRef": "HEAD"}, {"title": "Main", "baseRef": "main"}} { + accepted := f.call("POST", f.path("/projects/"+pid+"/workspaces"), body, "base-ok-"+string(rune('a'+i)), 202) + if got := f.ws(accepted.O("resource").S("id")).S("requestedRef"); got != "main" { + t.Fatalf("%v: requestedRef = %q, want the Binding branch", body, got) + } + f.drain() + } +} + +// D4, invariant 7: creating a run Workspace starts no sync and clones the current snapshot as it is. +func TestRunWorkspaceUsesTheCurrentSnapshotWithoutSyncing(t *testing.T) { + f := setup(t) + created := f.create("run-no-sync") + pid := created.O("resource").S("id") + f.drain() + snapshot := func() string { + var row string + must(t, f.store.Pool.QueryRow(`SELECT to_jsonb(s)::text FROM repository_snapshots s WHERE project_id=$1`, pid).Scan(&row)) + return row + } + before, syncs := snapshot(), f.scalar("SELECT count(*) FROM repository_operations WHERE project_id=$1", pid) + run := f.insertAgentRun(t, pid) + wid := f.readyRunWorkspace(t, run) + if snapshot() != before || f.scalar("SELECT count(*) FROM repository_operations WHERE project_id=$1", pid) != syncs { + t.Fatal("a run Workspace must not trigger or record a sync") + } + var input string + must(t, f.store.Pool.QueryRow(`SELECT o.request->'clone'->>'baseCommit' FROM operations o WHERE o.workspace_id=$1 AND o.kind='create_workspace'`, wid).Scan(&input)) + if input != f.commit || f.scalar("SELECT count(*) FROM workspaces WHERE id=$1 AND base_commit_id=$2", wid, f.commit) != 1 { + t.Fatalf("the run Workspace clones the current snapshot, got %s", input) + } +} + +// D6, invariant 9: delete_project is not handed to a Controller while a sync or publish of the +// Project is running; it waits without failing and proceeds once the Operation ended. +func TestDeleteProjectWaitsForARunningRepositoryOperation(t *testing.T) { + for _, kind := range []string{"sync", "publish"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + created := f.create("delete-waits-" + kind) + pid, wid := created.O("resource").S("id"), created.O("workspace").S("id") + f.drain() + if kind == "publish" { + // A publish Operation always names a credential: the preset takes the Binding's. + credential := f.storedGitCredential(f.uid, "team", "https://example.invalid/repo") + must(t, firstErr(f.store.Pool.Exec("UPDATE repository_bindings SET credential_ref_id=$2 WHERE project_id=$1", pid, credential))) + } + running, err := f.preset().Operation(context.Background(), pid, kind, "running", "") + must(t, err) + p := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + f.releaseRuntimeControl(wid) + deletion := f.call("DELETE", f.path("/projects/"+pid), core.Object{"version": p.N("version")}, "delete-waits-delete-"+kind, 202) + oid := deletion.O("operation").S("id") + f.drain() + if op := f.operationRow(oid); op.S("state") != "retry_wait" || op.S("errorCode") != "repository_operation_running" || op.S("step") != "quiesce" { + t.Fatalf("delete_project must wait for the running %s before quiescing, got %v", kind, op) + } + if f.scalar("SELECT count(*) FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", wid) != 1 { + t.Fatalf("the sandbox must not be terminated while the %s runs", kind) + } + // The external line ends the Operation it was running. + _, err = f.store.Pool.Exec(`UPDATE repository_operations SET state='succeeded',finished_at=clock_timestamp() WHERE id=$1`, running["operationId"]) + must(t, err) + f.due(oid) + f.drain() + if op := f.operationRow(oid); op.S("state") != "succeeded" || op.S("step") != "done" { + t.Fatalf("delete_project proceeds once the %s ended, got %v", kind, op) + } + }) + } +} + +// D1: a deployment without a Git Server cannot freeze a clone input; the step blocks for good. +func TestCloneStepBlocksWithoutAGitServer(t *testing.T) { + f := setup(t) + f.store.GitServer = nil + oid := f.create("no-git-server").O("operation").S("id") + f.drain() + if op := f.operationRow(oid); op.S("state") != "blocked" || op.S("errorCode") != "git_server_unconfigured" || op.S("step") != "clone" { + t.Fatalf("without a Git Server the clone step blocks, got %v", op) + } + if f.scalar("SELECT count(*) FROM clone_executions WHERE operation_id=$1", oid) != 0 { + t.Fatal("no clone may be dispatched without a Git Server") + } +} + +// D5, invariant 8: Cloud has no tenant-level clone entry point or storage. +func TestTenantLevelClonesAreGone(t *testing.T) { + f := setup(t) + for _, method := range []string{http.MethodPost, http.MethodGet} { + req, err := http.NewRequestWithContext(t.Context(), method, f.cloud.URL+f.path("/clones"), strings.NewReader(`{"requestId":"r","repository":"https://example.invalid/repo.git","branch":"main"}`)) + must(t, err) + res, err := http.DefaultClient.Do(req) + must(t, err) + res.Body.Close() + if res.StatusCode != http.StatusNotFound { + t.Fatalf("%s /clones answered %d, want 404", method, res.StatusCode) + } + } + var table *string + must(t, f.store.Pool.QueryRow(`SELECT to_regclass('clone_requests')::text`).Scan(&table)) + if table != nil { + t.Fatalf("clone_requests still exists: %s", *table) + } +} diff --git a/integration/cloud_test.go b/integration/cloud_test.go index 835774a31..9ea356db9 100644 --- a/integration/cloud_test.go +++ b/integration/cloud_test.go @@ -51,10 +51,18 @@ type fixture struct { tid, uid, root, commit string cloud *httptest.Server external *httptest.Server - pgConfig *pgx.ConnConfig - executions controlpb.ExecutionServiceClient - controlConn *grpc.ClientConn - controlSessions map[string]string + // git is the Git Server double: the Project repositories Workspaces clone snapshots from and + // deliveries push to. refs answers Cloud's ref query from it, or from refs a case pins. + git *simulator.GitServer + refs *gitRefs + // source is the local repository whose HEAD every Project created through f.call is synced to, + // standing in for the external line's first sync. withoutSnapshot leaves new Projects unsynced. + source string + withoutSnapshot bool + pgConfig *pgx.ConnConfig + executions controlpb.ExecutionServiceClient + controlConn *grpc.ClientConn + controlSessions map[string]string // objects is the fixture's one object-store double. It is created on first use and reused, so a // test that installs storage and a helper that needs storage configured name the same endpoint // and the same recorded probes. @@ -106,7 +114,7 @@ func setup(t *testing.T) *fixture { pool, config := testSchema(t, "test_") db, e := gorm.Open(postgres.New(postgres.Config{Conn: pool}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) must(t, e) - store, e := core.NewDevelopmentStore(db) + store, e := core.NewStore(db) must(t, e) // Wire the dev/demo collaboration fixtures so the mock execution path is exercised. Workflow // targets and their form descriptors stay database-backed: the fixtures layer agent/team in front @@ -142,18 +150,22 @@ func setup(t *testing.T) *fixture { runGit(t, "-C", repo, "add", ".") runGit(t, "-C", repo, "commit", "-m", "fixture") commit := runGit(t, "-C", repo, "rev-parse", "HEAD") - substrate, e := simulator.NewSubstrate(filepath.Join(root, "substrate"), map[string]string{"https://example.invalid/repo.git": repo}) + git, e := simulator.NewGitServer(filepath.Join(root, "git")) + must(t, e) + refs := &gitRefs{git: git, pinned: map[string]string{}} + store.GitServer = &core.GitServer{RepositoryBaseURL: simulator.GitServerBaseURL, Refs: refs} + substrate, e := simulator.NewSubstrate(filepath.Join(root, "substrate"), git) must(t, e) external := httptest.NewServer(substrate) t.Cleanup(external.Close) client := &simulator.Client{URL: cloud.URL, Credentials: credentials, HTTP: &http.Client{Timeout: 10 * time.Second}, Subject: "controller-a"} - f := &fixture{t: t, store: store, client: client, substrate: substrate, root: root, commit: commit, cloud: cloud, user: core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "alice"}, Source: "corp", DisplayName: "Alice"}} + f := &fixture{t: t, store: store, client: client, substrate: substrate, root: root, commit: commit, cloud: cloud, git: git, refs: refs, source: repo, user: core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "alice"}, Source: "corp", DisplayName: "Alice"}} bootstrap, e := store.Bootstrap(context.Background(), "Test tenant", "corp", "alice", "Alice") must(t, e) f.tid, f.uid = bootstrap.S("tenantId"), bootstrap.S("userId") f.controlConn = controlConn(t, store) f.executions = controlpb.NewExecutionServiceClient(f.controlConn) - f.controller = &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: f.executions} + f.controller = &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: f.executions, Git: git} f.controller.AgentRuns = controlpb.NewAgentRunServiceClient(f.controlConn) f.controller.AgentNode, e = simulator.NewAgentNode(filepath.Join(root, "agent-node")) must(t, e) @@ -260,6 +272,11 @@ func (f *fixture) call(method, path string, body core.Object, key string, want i if status != want { f.t.Fatalf("%s %s: want %d got %d %v", method, path, want, status, o) } + // A replayed creation already has its snapshot; only a new Project gets its first sync. + if pid := o.O("resource").S("id"); method == "POST" && strings.HasSuffix(path, "/projects") && status == 202 && !f.withoutSnapshot && + f.scalar("SELECT count(*) FROM repository_snapshots WHERE project_id=$1", pid) == 0 { + f.syncProject(pid) + } return o } func (f *fixture) path(s string) string { return "/api/v1/tenants/" + f.tid + s } diff --git a/integration/contract_test.go b/integration/contract_test.go index f169196e7..93e137ecb 100644 --- a/integration/contract_test.go +++ b/integration/contract_test.go @@ -99,13 +99,13 @@ func TestRestartRecreatesCloudSubstrateAndController(t *testing.T) { must(t, f.store.Pool.Close()) auth, e := core.NewAuthenticator("ora-cloud", f.client.Credentials.Trust) must(t, e) - reopened := &core.Store{Pool: stdlib.OpenDB(*f.pgConfig)} + reopened := &core.Store{Pool: stdlib.OpenDB(*f.pgConfig), GitServer: f.store.GitServer} t.Cleanup(func() { reopened.Pool.Close() }) must(t, reopened.CheckSchema(context.Background())) f.store = reopened cloud := httptest.NewServer(router.NewDevelopment(reopened, auth, zap.NewNop())) t.Cleanup(cloud.Close) - substrate, e := simulator.NewSubstrate(f.substrate.Root, f.substrate.Repositories) + substrate, e := simulator.NewSubstrate(f.substrate.Root, f.substrate.Git) must(t, e) external := httptest.NewServer(substrate) t.Cleanup(external.Close) @@ -117,7 +117,7 @@ func TestRestartRecreatesCloudSubstrateAndController(t *testing.T) { must(t, e) _, e = f.store.Pool.Exec("UPDATE operations SET retry_at=clock_timestamp()-interval '1 second' WHERE id=$1", created.O("operation").S("id")) must(t, e) - controller := &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: controlpb.NewExecutionServiceClient(controlConn(t, reopened))} + controller := &simulator.Controller{Client: client, SubstrateURL: external.URL, Executions: controlpb.NewExecutionServiceClient(controlConn(t, reopened)), Git: f.git} must(t, controller.Acquire(context.Background())) must(t, controller.Drain(context.Background())) if f.ws(wid).S("observedState") != "ready" || f.scalar("SELECT count(*) FROM sandbox_instances WHERE workspace_id=$1", wid) != 1 || f.scalar("SELECT count(*) FROM external_effects WHERE workspace_id=$1 AND kind='sandbox_ensure'", wid) != 1 { diff --git a/integration/control_grpc_test.go b/integration/control_grpc_test.go index 1a5ec91d7..c99518e73 100644 --- a/integration/control_grpc_test.go +++ b/integration/control_grpc_test.go @@ -11,7 +11,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -32,7 +31,7 @@ func newControlHarness(t *testing.T) *controlHarness { pool, _ := testSchema(t, "grpc_") db, e := gorm.Open(postgres.New(postgres.Config{Conn: pool}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) must(t, e) - store, e := core.NewDevelopmentStore(db) // This fixture exercises the retired legacy clone registry only. + store, e := core.NewStore(db) must(t, e) must(t, store.Migrate(context.Background())) return &controlHarness{store: store, conn: controlConn(t, store)} @@ -106,131 +105,9 @@ func TestControlGRPCLeaseHolderAndFencing(t *testing.T) { expect(e, codes.FailedPrecondition, controlpb.ErrorCode_ERROR_CODE_STALE_CONTROLLER) } -// The clone loop over the contract: Cloud-accepted work is claimed, registered before dispatch, -// taken over with its exact receipt, and every write replays under its submission identity. -func TestControlGRPCCloneLoopWithSubmissionReplay(t *testing.T) { - h := newControlHarness(t) - ctx := context.Background() - bootstrap, e := h.store.Bootstrap(ctx, "Clone tenant", "corp", "alice", "Alice") - must(t, e) - tid, uid := bootstrap.S("tenantId"), bootstrap.S("userId") - lease := controlpb.NewControllerLeaseServiceClient(h.conn) - client := controlpb.NewExecutionServiceClient(h.conn) - holder := asController("controller-a") - acquired, e := lease.AcquireLease(holder, &controlpb.AcquireLeaseRequest{}) - must(t, e) - epoch := acquired.GetLease().GetEpoch() - - // Nothing queued yet; claiming is a read, never an error. - empty, e := client.ClaimWork(holder, &controlpb.ClaimWorkRequest{SubmissionId: "s-claim-0", Epoch: epoch}) - must(t, e) - if empty.GetItem() != nil { - t.Fatalf("claimed work from an empty queue: %v", empty.GetItem()) - } - request, e := h.store.EnqueueClone(ctx, tid, uid, "browser-request", "https://example.invalid/repo.git", "main") - must(t, e) - again, e := h.store.EnqueueClone(ctx, tid, uid, "browser-request", "https://example.invalid/repo.git", "main") - must(t, e) - if again.S("id") != request.S("id") { - t.Fatalf("repeated request must return the original: %v vs %v", again, request) - } - if _, e = h.store.EnqueueClone(ctx, tid, uid, "browser-request", "https://example.invalid/repo.git", "other"); core.ErrorCode(e).Code != "idempotency_conflict" { - t.Fatalf("changed input under the same request id must conflict: %v", e) - } - claimed, e := client.ClaimWork(holder, &controlpb.ClaimWorkRequest{SubmissionId: "s-claim-1", Epoch: epoch}) - must(t, e) - if claimed.GetItem().GetOperationId() != request.S("id") || claimed.GetItem().GetInput().GetClone().GetBranch() != "main" { - t.Fatalf("unexpected work item: %v", claimed.GetItem()) - } - // A stale epoch cannot claim or write. - _, e = client.ClaimWork(holder, &controlpb.ClaimWorkRequest{SubmissionId: "s-claim-2", Epoch: epoch + 1}) - expectStatus(t, e, codes.FailedPrecondition, controlpb.ErrorCode_ERROR_CODE_STALE_CONTROLLER) - - dispatch := &controlpb.RecordDispatchRequest{SubmissionId: "s-dispatch", Epoch: epoch, OperationId: request.S("id"), ExecutionId: "exec-1", NodeId: "node-a", Input: claimed.GetItem().GetInput()} - first, e := client.RecordDispatch(holder, dispatch) - must(t, e) - if first.GetRecord().GetExecutionId() != "exec-1" || first.GetRecord().Result != nil { - t.Fatalf("unexpected dispatch record: %v", first.GetRecord()) - } - // Lost reply: same submission, same content replays the recorded response without a second effect. - replay, e := client.RecordDispatch(holder, dispatch) - must(t, e) - if replay.GetRecord().GetExecutionId() != "exec-1" { - t.Fatalf("replay must return the original record: %v", replay.GetRecord()) - } - // Same submission identity with different content is a conflict, and the original stands. - altered, _ := proto.Clone(dispatch).(*controlpb.RecordDispatchRequest) - altered.ExecutionId = "exec-2" - _, e = client.RecordDispatch(holder, altered) - expectStatus(t, e, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - // A second execution for the same request is refused even under a new submission. - altered.SubmissionId = "s-dispatch-2" - _, e = client.RecordDispatch(holder, altered) - expectStatus(t, e, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - // Once dispatched the request is no longer claimable. - drained, e := client.ClaimWork(holder, &controlpb.ClaimWorkRequest{SubmissionId: "s-claim-3", Epoch: epoch}) - must(t, e) - if drained.GetItem() != nil { - t.Fatalf("dispatched request was offered again: %v", drained.GetItem()) - } - _, e = client.ListPendingDispatches(context.Background(), &controlpb.ListPendingDispatchesRequest{NodeId: "node-a"}) - expectStatus(t, e, codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_UNSPECIFIED) - pending, e := client.ListPendingDispatches(asController("controller-b"), &controlpb.ListPendingDispatchesRequest{NodeId: "node-a"}) - must(t, e) - if len(pending.GetRecords()) != 1 || pending.GetRecords()[0].GetExecutionId() != "exec-1" { - t.Fatalf("recovery read must see the pending execution without a lease: %v", pending.GetRecords()) - } - - node := &controlpb.NodeIdentity{NodeId: "node-a", NodeIncarnationId: "incarnation-1"} - ready := &controlpb.ExecutionResult{Node: node, Outcome: &controlpb.ExecutionResult_CloneReady{CloneReady: &controlpb.CloneReady{Path: "/node/checkout", Commit: "0123456789abcdef0123456789abcdef01234567"}}} - takeover := &controlpb.TakeOverNodeEventRequest{SubmissionId: "s-takeover", Epoch: epoch, OperationId: request.S("id"), ExecutionId: "exec-1", Sequence: 1, Result: ready, Event: []byte("event-1")} - taken, e := client.TakeOverNodeEvent(holder, takeover) - must(t, e) - if taken.GetRecord().GetResult().GetCloneReady().GetCommit() != ready.GetCloneReady().GetCommit() { - t.Fatalf("takeover did not record the result: %v", taken.GetRecord()) - } - replayed, e := client.TakeOverNodeEvent(holder, takeover) - must(t, e) - if replayed.GetRecord().GetResult().GetCloneReady().GetPath() != "/node/checkout" { - t.Fatalf("takeover replay must return the original record: %v", replayed.GetRecord()) - } - // A replayed event with the same sequence but different content must not be acknowledged. - forged, _ := proto.Clone(takeover).(*controlpb.TakeOverNodeEventRequest) - forged.SubmissionId, forged.Event = "s-takeover-forged", []byte("event-1-forged") - _, e = client.TakeOverNodeEvent(holder, forged) - expectStatus(t, e, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - // A conflicting fact for the same execution leaves the original untouched. - failed := &controlpb.ExecutionResult{Node: node, Outcome: &controlpb.ExecutionResult_CloneFailed{CloneFailed: &controlpb.CloneFailed{Reason: controlpb.CloneFailureReason_CLONE_FAILURE_REASON_OPERATION_FAILED}}} - _, e = client.RecordQueriedResult(holder, &controlpb.RecordQueriedResultRequest{SubmissionId: "s-queried-conflict", Epoch: epoch, OperationId: request.S("id"), ExecutionId: "exec-1", Result: failed}) - expectStatus(t, e, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - queried, e := client.RecordQueriedResult(holder, &controlpb.RecordQueriedResultRequest{SubmissionId: "s-queried", Epoch: epoch, OperationId: request.S("id"), ExecutionId: "exec-1", Result: ready}) - must(t, e) - if queried.GetRecord().GetResult().GetCloneReady().GetCommit() != ready.GetCloneReady().GetCommit() { - t.Fatalf("identical queried result must be idempotent: %v", queried.GetRecord()) - } - got, e := client.GetDispatch(asController("controller-b"), &controlpb.GetDispatchRequest{ExecutionId: "exec-1"}) - must(t, e) - if got.GetRecord().GetOperationId() != request.S("id") || got.GetRecord().GetResult() == nil { - t.Fatalf("dispatch lookup lost the result: %v", got.GetRecord()) - } - _, e = client.GetDispatch(holder, &controlpb.GetDispatchRequest{ExecutionId: "absent"}) - expectStatus(t, e, codes.NotFound, controlpb.ErrorCode_ERROR_CODE_NOT_FOUND) - settled, e := client.ListPendingDispatches(holder, &controlpb.ListPendingDispatchesRequest{}) - must(t, e) - if len(settled.GetRecords()) != 0 { - t.Fatalf("completed execution still pending: %v", settled.GetRecords()) - } - var state string - var receipts int - must(t, h.store.Pool.QueryRow("SELECT state FROM clone_requests WHERE id=$1", request.S("id")).Scan(&state)) - must(t, h.store.Pool.QueryRow("SELECT count(*) FROM clone_event_receipts WHERE execution_id='exec-1'").Scan(&receipts)) - if state != "succeeded" || receipts != 1 { - t.Fatalf("request state %q with %d receipts; want succeeded with exactly one receipt", state, receipts) - } -} - -// Watch is an accelerator: it needs the current epoch to open, delivers WorkAvailable after a clone -// request commits, and ends cleanly with Drain when Cloud shuts the hub. +// Watch is an accelerator: it needs the current epoch to open, delivers the hints Cloud publishes, +// and ends cleanly with Drain when Cloud shuts the hub. Which commits publish which hint is covered +// with the real producers (TestGRPCWatchDeliversOperationAvailable and the run work tests). func TestControlGRPCWatchDeliversWorkAndDrain(t *testing.T) { h := newControlHarness(t) ctx := context.Background() @@ -255,12 +132,12 @@ func TestControlGRPCWatchDeliversWorkAndDrain(t *testing.T) { // The server sends headers once the subscription exists; nothing published after that is missed. _, e = stream.Header() must(t, e) - request, e := h.store.EnqueueClone(ctx, bootstrap.S("tenantId"), bootstrap.S("userId"), "watched", "https://example.invalid/repo.git", "main") - must(t, e) + run := bootstrap.S("tenantId") + h.store.Signals.Publish(core.ControlSignal{Kind: core.SignalWorkAvailable, OperationID: run}) msg, e := stream.Recv() must(t, e) - if msg.GetWorkAvailable() == nil || msg.GetWorkAvailable().GetOperationId() != request.S("id") { - t.Fatalf("expected WorkAvailable for %s, got %v", request.S("id"), msg) + if msg.GetWorkAvailable() == nil || msg.GetWorkAvailable().GetOperationId() != run { + t.Fatalf("expected WorkAvailable for %s, got %v", run, msg) } h.store.Signals.Drain() msg, e = stream.Recv() diff --git a/integration/endpoints_test.go b/integration/endpoints_test.go index 535860908..37902d942 100644 --- a/integration/endpoints_test.go +++ b/integration/endpoints_test.go @@ -28,7 +28,7 @@ func TestHealthReportsOptionalObjectStoreWithoutDisclosingConfiguration(t *testi err = json.NewDecoder(response.Body).Decode(&actual) response.Body.Close() must(t, err) - if response.StatusCode != http.StatusOK || len(actual) != 2 || actual.S("status") != "ok" || len(actual.O("dependencies")) != 1 || actual.O("dependencies").S("objectStore") != want { + if response.StatusCode != http.StatusOK || len(actual) != 2 || actual.S("status") != "ok" || len(actual.O("dependencies")) != 2 || actual.O("dependencies").S("objectStore") != want || actual.O("dependencies").S("gitServer") != "configured" { t.Fatal("health must report only readiness and optional storage configuration") } } diff --git a/integration/external_git_test.go b/integration/external_git_test.go index 0521f8368..160152109 100644 --- a/integration/external_git_test.go +++ b/integration/external_git_test.go @@ -122,8 +122,17 @@ func (f *fixture) leasedSync() (string, core.RepositoryTask) { return pid, tasks[0] } +// manualSync asks for a sync of an active Project the way a member does and returns its ID. +func (f *fixture) manualSync(pid string) string { + f.t.Helper() + return f.gitCall("POST", f.path("/projects/"+pid+"/repository/syncs"), nil, "", 202).O("resource").S("id") +} + func TestRepositorySyncLeaseFencingReplayAndResyncAreAtomic(t *testing.T) { f := setup(t) + // These cases drive the Project's own first sync through the repository worker, so the + // fixture must not settle it from its local source repository. + f.withoutSnapshot = true ctx := context.Background() pid, task := f.leasedSync() // Pending/leased can be reclaimed, but stale generations must never start. @@ -189,6 +198,9 @@ func TestRepositorySyncSystemSuccessorRecordsSkipReason(t *testing.T) { } { t.Run(c.name, func(t *testing.T) { f := setup(t) + // These cases drive the Project's own first sync through the repository worker, so the + // fixture must not settle it from its local source repository. + f.withoutSnapshot = true ctx := context.Background() pid, task := f.leasedSync() must(t, firstErr(f.store.Pool.Exec("UPDATE projects SET lifecycle=$2 WHERE id=$1", pid, c.lifecycle))) @@ -223,8 +235,11 @@ func TestRepositorySyncSystemSuccessorRecordsSkipReason(t *testing.T) { func TestRepositorySyncConcurrentLeasesAndIdempotentTrigger(t *testing.T) { f := setup(t) + // The fixture settles the Project's first sync, so the Project is active and the sync the + // workers race for is one a member asked for. pid := f.create("concurrent-sync").O("resource").S("id") f.drain() + f.manualSync(pid) var wg sync.WaitGroup results := make(chan []core.RepositoryTask, 2) errs := make(chan error, 2) @@ -251,7 +266,7 @@ func TestRepositorySyncConcurrentLeasesAndIdempotentTrigger(t *testing.T) { t.Fatalf("concurrent workers leased %d times", count) } op := f.gitCall("POST", f.path("/projects/"+pid+"/repository/syncs"), nil, "", 200).O("resource") - if op.S("state") != "leased" || f.scalar("SELECT count(*) FROM repository_operations") != 1 { + if op.S("state") != "leased" || f.scalar("SELECT count(*) FROM repository_operations WHERE state IN ('pending','leased','running')") != 1 { t.Fatalf("manual trigger duplicated sync: %v", op) } } diff --git a/integration/git_native_support_test.go b/integration/git_native_support_test.go new file mode 100644 index 000000000..263fe405f --- /dev/null +++ b/integration/git_native_support_test.go @@ -0,0 +1,121 @@ +package integration + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "sync" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/controlpb" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" +) + +// gitRefs is Cloud's ref query in the fixture. It answers from the Git Server double's repositories, +// unless a case pins a ref to a commit (for seeded scenes whose commits exist nowhere) or makes the +// Git Server unreachable. Every query is recorded, so a case can assert which refs Cloud verified +// and that a contradictory declaration never reached the Git Server. +type gitRefs struct { + git *simulator.GitServer + mu sync.Mutex + pinned map[string]string + unavailable bool + queries []string +} + +func (g *gitRefs) ResolveGitRef(ctx context.Context, tenantID, projectID, ref string) (string, error) { + g.mu.Lock() + g.queries = append(g.queries, ref) + unavailable, commit, ok := g.unavailable, "", false + if !unavailable { + commit, ok = g.pinned[tenantID+"/"+projectID+"/"+ref] + } + g.mu.Unlock() + switch { + case unavailable: + return "", errors.New("git server unreachable") + case ok: + return commit, nil + } + return g.git.ResolveGitRef(ctx, tenantID, projectID, ref) +} + +// pin makes ref name commit in the Project repository, as a push the Git Server accepted would. +func (g *gitRefs) pin(tenantID, projectID, ref, commit string) { + g.mu.Lock() + defer g.mu.Unlock() + g.pinned[tenantID+"/"+projectID+"/"+ref] = commit +} + +func (g *gitRefs) setUnavailable(unavailable bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.unavailable = unavailable +} + +func (g *gitRefs) queried() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string(nil), g.queries...) +} + +// syncProject stands in for the external line's first sync of a new Project: the source +// repository's HEAD becomes the Binding's current snapshot and the Project's pending sync succeeds. +func (f *fixture) syncProject(projectID string) string { + f.t.Helper() + commit, err := f.git.SeedSnapshot(context.Background(), f.store.Pool, projectID, f.source) + must(f.t, err) + return commit +} + +// seedCloneInput is the frozen clone input a Workspace of a seeded scene was cloned with: a snapshot +// of commit in the Project's Git Server repository, checked out on the Workspace's main branch. +func seedCloneInput(tenantID, projectID, workspaceID, commit string) core.Object { + return core.Object{ + "kind": "clone", + "repositoryUrl": simulator.GitServerBaseURL + "/" + tenantID + "/" + projectID + ".git", + "snapshotRef": core.GitSnapshotRef(uuid.NewString(), commit), + "baseCommit": commit, + "workspaceRef": core.GitWorkspaceRefPrefix(workspaceID) + "main", + } +} + +// cloneInputSpec is the wire form of a durable clone input. +func cloneInputSpec(input core.Object) *controlpb.ExecutionInput { + return &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{ + RepositoryUrl: input.S("repositoryUrl"), SnapshotRef: input.S("snapshotRef"), BaseCommit: input.S("baseCommit"), WorkspaceRef: input.S("workspaceRef"), + }}} +} + +// checkoutOf is the successful clone execution that produced a Workspace's checkout: the one a +// delivery input must name, because the delivery takes the Workspace branch from its input. +func (f *fixture) checkoutOf(workspaceID string) string { + f.t.Helper() + var execution string + must(f.t, f.store.Pool.QueryRow(`SELECT execution_id FROM clone_executions WHERE workspace_id=$1 AND result->>'outcome'='clone_ready' ORDER BY created_at DESC LIMIT 1`, workspaceID).Scan(&execution)) + return execution +} + +// pushedDelivery records that a registered delivery's ref names final, as an accepted push leaves it. +func (f *fixture) pushedDelivery(execution, final string) { + f.t.Helper() + var tenant, project, ref string + must(f.t, f.store.Pool.QueryRow(`SELECT w.tenant_id,w.project_id,e.input->>'deliveryRef' FROM node_executions e JOIN workspaces w ON w.id=e.workspace_id WHERE e.execution_id=$1`, execution).Scan(&tenant, &project, &ref)) + f.refs.pin(tenant, project, ref, final) +} + +// frozenClone is the clone input Cloud froze into an operation's clone step, or nil before that. +func (f *fixture) frozenClone(operationID string) core.Object { + f.t.Helper() + var raw sql.NullString + must(f.t, f.store.Pool.QueryRow(`SELECT (request->'clone')::text FROM operations WHERE id=$1`, operationID).Scan(&raw)) + if !raw.Valid { + return nil + } + out := core.Object{} + must(f.t, json.Unmarshal([]byte(raw.String), &out)) + return out +} diff --git a/integration/git_operation_access_test.go b/integration/git_operation_access_test.go index 3eca01c96..22d30bce7 100644 --- a/integration/git_operation_access_test.go +++ b/integration/git_operation_access_test.go @@ -40,6 +40,7 @@ func startOperation(t *testing.T, f *fixture, id string) { // mismatched tenant or Project is the same denial. func TestGitOperationAccessFollowsOperationAndProjectLifecycle(t *testing.T) { f := setup(t) + f.withoutSnapshot = true pid := f.create("operation-access").O("resource").S("id") bid := f.bindingOf(pid).S("id") ctx := context.Background() @@ -93,6 +94,7 @@ func TestGitServerAuthorizesExecutorSyncFromCloudState(t *testing.T) { t.Fatal("git is required for the Git Server boundary") } f := setup(t) + f.withoutSnapshot = true pid := f.create("executor-sync").O("resource").S("id") bid := f.bindingOf(pid).S("id") var sync string diff --git a/integration/git_server_grants_test.go b/integration/git_server_grants_test.go new file mode 100644 index 000000000..bd55c55f8 --- /dev/null +++ b/integration/git_server_grants_test.go @@ -0,0 +1,309 @@ +package integration + +// Contract D5 (specs decisions/cloud/controller-integration/20261016-snapshot-clone-input-and- +// execution-ref-grants.md) through a real git client and a real Git Server: a clone execution reads +// for exactly as long as its operation is in the clone step and writes nothing; an Agent session +// reads the whole Project repository and creates or fast-forwards only its own Workspace's branches, +// until the session ends; a certificate of an earlier sandbox generation is refused even when every +// other condition holds. + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/controlpb" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" +) + +func TestGitServerGrantsACloneExecutionReadOnlyDuringItsStep(t *testing.T) { + f := setup(t) + created := f.create("clone-grant") + pid, wid, oid := created.O("resource").S("id"), created.O("workspace").S("id"), created.O("operation").S("id") + at := f.stepTo(oid, "clone") + frozen := f.frozenClone(oid) + var node string + var generation int64 + must(t, f.store.Pool.QueryRow(`SELECT n.node_id,w.runtime_generation FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id JOIN workspaces w ON w.id=s.workspace_id WHERE w.id=$1 AND n.ended_at IS NULL`, wid).Scan(&node, &generation)) + ctx := asController(f.client.Subject) + _, err := f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "clone-grant", Epoch: at.N("epoch"), OperationId: oid, ExecutionId: "clone-grant-1", NodeId: node, Input: cloneInputSpec(frozen)}) + must(t, err) + + server := startGitServer(t, f, f.git.Root) + server.sandbox(t, "main", f.tid, wid, generation) + remote := server.remote(f.tid, pid) + // The main Workspace clones while its Project is still provisioning. + if f.scalar("SELECT count(*) FROM projects WHERE id=$1 AND lifecycle='provisioning'", pid) != 1 { + t.Fatal("the scene must clone during create_project") + } + listed, err := server.git(t.TempDir(), "main", "clone-grant-1", "ls-remote", remote) + if err != nil || !strings.Contains(listed, frozen.S("snapshotRef")) { + t.Fatalf("the clone execution must read the snapshot during its step: %v %s", err, listed) + } + checkout := t.TempDir() + if out, err := server.git(checkout, "main", "", "init", "--quiet"); err != nil { + t.Fatal(out) + } + if out, err := server.git(checkout, "main", "clone-grant-1", "fetch", "--quiet", remote, frozen.S("snapshotRef")); err != nil { + t.Fatalf("the clone execution must fetch its snapshot: %s", out) + } + if out, err := server.git(checkout, "main", "clone-grant-1", "push", remote, "FETCH_HEAD:"+frozen.S("workspaceRef")); err == nil { + t.Fatalf("a clone execution pushed: %s", out) + } + if got := serverRef(t, f.git.Root, f.tid, pid, frozen.S("workspaceRef")); got != "" { + t.Fatalf("a refused push left %s at %s", frozen.S("workspaceRef"), got) + } + + // The clone succeeds and the operation leaves the clone step: the execution reads no more. + ready := &controlpb.ExecutionResult{Node: &controlpb.NodeIdentity{NodeId: node, NodeIncarnationId: "inc"}, Outcome: &controlpb.ExecutionResult_CloneReady{CloneReady: &controlpb.CloneReady{Path: "/checkout", Commit: frozen.S("baseCommit")}}} + _, err = f.executions.RecordQueriedResult(ctx, &controlpb.RecordQueriedResultRequest{SubmissionId: "clone-grant-result", Epoch: at.N("epoch"), OperationId: oid, ExecutionId: "clone-grant-1", Result: ready}) + must(t, err) + if out, err := server.git(t.TempDir(), "main", "clone-grant-1", "ls-remote", remote); err == nil || !strings.Contains(out, "403") { + t.Fatalf("an execution with a result still read the repository: %v %s", err, out) + } + _, err = f.store.Pool.Exec(`UPDATE clone_executions SET result=NULL WHERE execution_id='clone-grant-1'`) + must(t, err) + _, err = f.store.Pool.Exec(`UPDATE operations SET step='plugin' WHERE id=$1`, oid) + must(t, err) + if out, err := server.git(t.TempDir(), "main", "clone-grant-1", "ls-remote", remote); err == nil || !strings.Contains(out, "403") { + t.Fatalf("an execution whose operation left the clone step still read the repository: %v %s", err, out) + } +} + +// Contract D5: a clone execution's read is decided again on every request. It ends when the +// operation's actor can no longer use the runtime, when a force stop is pending on the Workspace or +// has terminated the execution, and it is never granted for an input naming another repository. +func TestGitServerDeniesACloneExecutionItsRuntimeNoLongerAllows(t *testing.T) { + f := setup(t) + created := f.create("clone-denials") + pid, wid, oid := created.O("resource").S("id"), created.O("workspace").S("id"), created.O("operation").S("id") + at := f.stepTo(oid, "clone") + frozen := f.frozenClone(oid) + var node, actor string + var generation int64 + must(t, f.store.Pool.QueryRow(`SELECT n.node_id,w.runtime_generation FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id JOIN workspaces w ON w.id=s.workspace_id WHERE w.id=$1 AND n.ended_at IS NULL`, wid).Scan(&node, &generation)) + must(t, f.store.Pool.QueryRow(`SELECT actor_user_id FROM operations WHERE id=$1`, oid).Scan(&actor)) + _, err := f.executions.RecordDispatch(asController(f.client.Subject), &controlpb.RecordDispatchRequest{SubmissionId: "clone-denials", Epoch: at.N("epoch"), OperationId: oid, ExecutionId: "clone-denials-1", NodeId: node, Input: cloneInputSpec(frozen)}) + must(t, err) + scope := core.GitRuntimeScope{TenantID: f.tid, WorkspaceID: wid, Generation: generation} + authorize := func() error { + _, err := f.store.AuthorizeGitAccess(t.Context(), scope, "clone-denials-1", pid) + return err + } + exec := func(q string, args ...any) { + t.Helper() + _, err := f.store.Pool.Exec(q, args...) + must(t, err) + } + if grant, err := f.store.AuthorizeGitAccess(t.Context(), scope, "clone-denials-1", pid); err != nil || len(grant.Writable) != 0 { + t.Fatalf("the clone execution must read, and only read, during its step: %v %v", grant, err) + } + // A second administrator lets the tenant stay active while the actor's account is disabled. + admin := uuid.NewString() + exec(`INSERT INTO users(id,display_name,status) VALUES($1,'Second admin','active')`, admin) + exec(`INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, f.tid, admin) + stop := uuid.NewString() + for _, c := range []struct { + name string + apply, revert func() + }{ + { + "the actor's account is disabled", + func() { exec(`UPDATE users SET status='disabled' WHERE id=$1`, actor) }, + func() { exec(`UPDATE users SET status='active' WHERE id=$1`, actor) }, + }, + { + "a force stop is pending on the Workspace", + func() { + exec(`INSERT INTO runtime_force_stops(id,tenant_id,workspace_id,actor_user_id,reason,state,control_epoch,runtime_generation) VALUES($1,$2,$3,$4,'stop','registered',0,$5)`, stop, f.tid, wid, actor, generation) + }, + func() { exec(`UPDATE runtime_force_stops SET state='succeeded',confirmed_at=now() WHERE id=$1`, stop) }, + }, + { + "a force stop terminated the execution", + func() { + exec(`UPDATE clone_executions SET terminated_by_force_stop_id=$2 WHERE execution_id=$1`, "clone-denials-1", stop) + }, + func() { + exec(`UPDATE clone_executions SET terminated_by_force_stop_id=NULL WHERE execution_id=$1`, "clone-denials-1") + }, + }, + { + "the input names another repository", + func() { + exec(`UPDATE clone_executions SET input=jsonb_set(input,'{repositoryUrl}',to_jsonb($2::text)) WHERE execution_id=$1`, "clone-denials-1", simulator.GitServerBaseURL+"/"+f.tid+"/"+uuid.NewString()+".git") + }, + func() { + exec(`UPDATE clone_executions SET input=jsonb_set(input,'{repositoryUrl}',to_jsonb($2::text)) WHERE execution_id=$1`, "clone-denials-1", frozen.S("repositoryUrl")) + }, + }, + } { + c.apply() + var fault *core.Fault + if err := authorize(); !errors.As(err, &fault) || fault.Code != "git_access_denied" { + t.Fatalf("%s: the clone execution must be denied, got %v", c.name, err) + } + c.revert() + if err := authorize(); err != nil { + t.Fatalf("%s: undoing it must restore the read, got %v", c.name, err) + } + } +} + +// sessionGitScene is a live Agent session execution on a run Workspace whose Project repository is +// served by the scene's Git Server. +type sessionGitScene struct { + server *gitServerHarness + run, execution, workspace, project, base string + remote string +} + +func newSessionGitScene(t *testing.T, f *fixture) sessionGitScene { + t.Helper() + run, execution, _, _ := f.registeredSession(t, "session-grant") + var wid, pid string + var generation int64 + must(t, f.store.Pool.QueryRow(`SELECT w.id,w.project_id,w.runtime_generation FROM workspaces w WHERE w.issue_run_id=$1`, run).Scan(&wid, &pid, &generation)) + _, err := f.store.Pool.Exec(`UPDATE issue_runs SET phase='running',status='running' WHERE id=$1`, run) + must(t, err) + // The sandbox is the Workspace's second generation, so a certificate of the first one is a + // well-formed certificate of a replaced sandbox rather than a malformed one. + _, err = f.store.Pool.Exec(`UPDATE sandbox_instances SET generation=generation+1 WHERE workspace_id=$1 AND terminated_at IS NULL`, wid) + must(t, err) + _, err = f.store.Pool.Exec(`UPDATE workspaces SET runtime_generation=runtime_generation+1 WHERE id=$1`, wid) + must(t, err) + generation++ + server := startGitServer(t, f, f.git.Root) + server.sandbox(t, "run", f.tid, wid, generation) + server.sandbox(t, "stale", f.tid, wid, generation-1) + return sessionGitScene{server: server, run: run, execution: execution, workspace: wid, project: pid, base: f.commit, remote: server.remote(f.tid, pid)} +} + +func TestGitServerGrantsASessionItsOwnWorkspaceBranches(t *testing.T) { + f := setup(t) + s := newSessionGitScene(t, f) + git := s.server.git + checkout := t.TempDir() + if out, err := git(checkout, "run", "", "init", "--quiet"); err != nil { + t.Fatal(out) + } + // Every session reads the whole Project repository, whether or not it resumes a Revision. + listed, err := git(checkout, "run", s.execution, "ls-remote", s.remote) + if err != nil || !strings.Contains(listed, "refs/ora/upstreams/") { + t.Fatalf("a session must read the Project repository: %v %s", err, listed) + } + if out, err := git(checkout, "run", s.execution, "fetch", "--quiet", s.remote, "refs/ora/upstreams/*:refs/remotes/ora/upstreams/*"); err != nil { + t.Fatalf("fetch upstream: %s", out) + } + if out, err := git(checkout, "run", "", "checkout", "--quiet", "-B", "main", s.base); err != nil { + t.Fatal(out) + } + if out, err := git(checkout, "run", "", "commit", "--quiet", "--allow-empty", "-m", "session work"); err != nil { + t.Fatal(out) + } + own := core.GitWorkspaceRefPrefix(s.workspace) + "main" + if out, err := git(checkout, "run", s.execution, "push", s.remote, "HEAD:"+own); err != nil { + t.Fatalf("a session must create its own Workspace branch: %s", out) + } + if out, err := git(checkout, "run", s.execution, "push", s.remote, "HEAD:"+core.GitWorkspaceRefPrefix(s.workspace)+"attempt"); err != nil { + t.Fatalf("a session may open more branches under its own prefix: %s", out) + } + project := s.project + pushed := serverRef(t, f.git.Root, f.tid, project, own) + + // Another Workspace's branch, an upstream ref, a delivery ref or a ref outside the namespaces: refused. + other := core.GitWorkspaceRefPrefix(uuid.NewString()) + "main" + for _, ref := range []string{other, "refs/ora/upstreams/" + uuid.NewString() + "/heads/main", core.GitDeliveryRefPrefix + uuid.NewString(), "refs/heads/main"} { + if out, err := git(checkout, "run", s.execution, "push", s.remote, "HEAD:"+ref); err == nil { + t.Fatalf("a session wrote %s: %s", ref, out) + } + } + // A non-fast-forward of its own branch is refused and leaves the branch where it was. + if out, err := git(checkout, "run", s.execution, "push", s.remote, "+"+s.base+":"+own); err == nil { + t.Fatalf("a non-fast-forward push was accepted: %s", out) + } + if got := serverRef(t, f.git.Root, f.tid, project, own); got != pushed { + t.Fatalf("a refused push moved %s to %s", own, got) + } + if got := serverRef(t, f.git.Root, f.tid, project, other); got != "" { + t.Fatalf("a refused push created %s", other) + } + + // A certificate of an earlier generation of this very Workspace is refused, every other condition + // still holding. + if out, err := git(t.TempDir(), "stale", s.execution, "ls-remote", s.remote); err == nil || !strings.Contains(out, "403") { + t.Fatalf("a stale-generation certificate reached the repository: %v %s", err, out) + } + + // The session ending ends its access on the next request. + _, err = f.store.Pool.Exec(`UPDATE node_executions SET result='{"outcome":"agent_session_ended"}' WHERE execution_id=$1`, s.execution) + must(t, err) + if out, err := git(checkout, "run", s.execution, "push", s.remote, "HEAD:"+own); err == nil || !strings.Contains(out, "403") { + t.Fatalf("an ended session still pushed: %v %s", err, out) + } + if got := serverRef(t, f.git.Root, f.tid, project, own); got != pushed { + t.Fatalf("an ended session moved %s", own) + } +} + +// Contract D5: a WorkflowRun's Agent session gets no Git Server access in the first phase. The scene +// starts from a session the Git Server grants and moves the same execution, work item, Workspace and +// runtime reservation to a WorkflowRun of the same Project, changing nothing else about the bound +// runtime: the read and the push it was granted are now both refused with the uniform 403. +func TestGitServerDeniesAWorkflowRunSession(t *testing.T) { + f := setup(t) + s := newSessionGitScene(t, f) + git := s.server.git + checkout := t.TempDir() + if out, err := git(checkout, "run", "", "init", "--quiet"); err != nil { + t.Fatal(out) + } + if out, err := git(checkout, "run", s.execution, "ls-remote", s.remote); err != nil { + t.Fatalf("the IssueRun session must read the repository before the move: %s", out) + } + + workflow, snapshot, workflowRun := uuid.NewString(), uuid.NewString(), uuid.NewString() + tx, err := f.store.Pool.Begin() + must(t, err) + defer func() { _ = tx.Rollback() }() + for _, q := range []struct { + query string + args []any + }{ + {`INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'git denial','{}'::jsonb)`, []any{workflow, f.tid}}, + {`INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1','{}'::jsonb)`, []any{snapshot, f.tid, workflow}}, + {`INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) + SELECT $1,$2,$3,$4,'git denial','running','{}'::jsonb,creator_user_id,project_id FROM workspaces WHERE id=$5`, []any{workflowRun, f.tid, workflow, snapshot, s.workspace}}, + {`UPDATE workspaces SET issue_run_id=NULL, workflow_run_id=$2 WHERE id=$1`, []any{s.workspace, workflowRun}}, + {`UPDATE execution_work SET run_id=NULL, workflow_run_id=$2, workflow_node_id='agent' WHERE id=(SELECT work_id FROM node_executions WHERE execution_id=$1)`, []any{s.execution, workflowRun}}, + {`UPDATE node_executions SET operation_id=$2, workflow_node_id='agent' WHERE execution_id=$1`, []any{s.execution, workflowRun}}, + {`UPDATE runtime_controls SET maintenance_run_id=$2 WHERE workspace_id=$1 AND maintenance_run_id=$3`, []any{s.workspace, workflowRun, s.run}}, + } { + // Each statement moves exactly one row, so the scene really is the granted runtime relabelled. + res, err := tx.Exec(q.query, q.args...) + if err != nil { + t.Fatalf("move the session to a WorkflowRun: %v", err) + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + t.Fatalf("move the session to a WorkflowRun: %s touched %d rows (%v), want 1", q.query, n, err) + } + } + must(t, tx.Commit()) + + if out, err := git(t.TempDir(), "run", s.execution, "ls-remote", s.remote); err == nil || !strings.Contains(out, "403") { + t.Fatalf("a WorkflowRun session read the repository: %v %s", err, out) + } + if out, err := git(checkout, "run", "", "commit", "--quiet", "--allow-empty", "-m", "workflow work"); err != nil { + t.Fatal(out) + } + own := core.GitWorkspaceRefPrefix(s.workspace) + "main" + if out, err := git(checkout, "run", s.execution, "push", s.remote, "HEAD:"+own); err == nil || !strings.Contains(out, "403") { + t.Fatalf("a WorkflowRun session pushed its Workspace branch: %v %s", err, out) + } + if got := serverRef(t, f.git.Root, f.tid, s.project, own); got != "" { + t.Fatalf("a refused WorkflowRun push created %s at %s", own, got) + } +} diff --git a/integration/git_server_test.go b/integration/git_server_test.go index d09894742..6d2fcfd86 100644 --- a/integration/git_server_test.go +++ b/integration/git_server_test.go @@ -22,7 +22,10 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/gitrefquery" "github.com/wanglongan587/cloud/internal/gitserver" ) @@ -90,48 +93,11 @@ func TestGitServerAuthorizesDeliveryPushFromCloudState(t *testing.T) { must(t, f.store.Pool.QueryRow(`SELECT w.id,w.tenant_id,w.project_id,w.runtime_generation,e.work_id FROM node_executions e JOIN workspaces w ON w.id=e.workspace_id WHERE e.execution_id=$1`, execution).Scan(&workspace, &tenant, &project, &generation, &work)) f.acknowledgeRuntime(t, workspace) - pki := newGitPKI(t) - pki.issue(t, "server", "", x509.ExtKeyUsageServerAuth) - pki.issue(t, "sandbox", "spiffe://ora.local/git-access/tenant/"+tenant+"/runtime/"+workspace+"/generation/"+strconv.FormatInt(generation, 10), x509.ExtKeyUsageClientAuth) - pki.issue(t, "stale", "spiffe://ora.local/git-access/tenant/"+tenant+"/runtime/"+workspace+"/generation/"+strconv.FormatInt(generation-1, 10), x509.ExtKeyUsageClientAuth) - cloud := pki.issue(t, "cloud", "spiffe://ora.local/cloud/git-ref-query", x509.ExtKeyUsageClientAuth) - deployment := gitserver.Config{CertificateFile: filepath.Join(pki.dir, "server.pem"), PrivateKeyFile: filepath.Join(pki.dir, "server-key.pem"), SandboxCAFile: filepath.Join(pki.dir, "ca.pem"), CloudClientCertFile: filepath.Join(pki.dir, "cloud.pem")} - - // The hook is the shipped command, so the pre-receive path under test is the deployed one. - hook := filepath.Join(t.TempDir(), "git-server") - build := exec.Command("go", "build", "-o", hook, "../cmd/git-server") - if out, err := build.CombinedOutput(); err != nil { - t.Fatalf("build git-server: %v: %s", err, out) - } - cloudLeaf, err := deployment.CloudClient() - must(t, err) - service, err := gitserver.New(&gitserver.Options{Store: f.store, RepositoryRoot: filepath.Join(t.TempDir(), "repositories"), GitProgram: "git", CloudClient: cloudLeaf, Hook: []string{hook, "-pre-receive"}, Health: f.store.Pool.PingContext}) - must(t, err) - repoTLS, err := deployment.RepositoryTLS() - must(t, err) - refTLS, err := deployment.RefTLS() - must(t, err) - repoServer := httptest.NewUnstartedServer(http.HandlerFunc(service.RepositoryHandler)) - repoServer.TLS = repoTLS - repoServer.StartTLS() - refServer := httptest.NewUnstartedServer(http.HandlerFunc(service.RefHandler)) - refServer.TLS = refTLS - refServer.StartTLS() - t.Cleanup(func() { - repoServer.Close() - refServer.Close() - must(t, service.Close()) - }) - - remote := repoServer.URL + "/" + tenant + "/" + project + ".git" - git := func(dir, who, executionID string, args ...string) (string, error) { - base := []string{"-c", "http.sslCert=" + filepath.Join(pki.dir, who+".pem"), "-c", "http.sslKey=" + filepath.Join(pki.dir, who+"-key.pem"), "-c", "http.sslCAInfo=" + deployment.SandboxCAFile, "-c", "http.extraHeader=" + gitserver.ExecutionHeader + ": " + executionID} - cmd := exec.Command("git", append(base, args...)...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.invalid", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.invalid") - out, err := cmd.CombinedOutput() - return string(out), err - } + server := startGitServer(t, f, t.TempDir()) + server.sandbox(t, "sandbox", tenant, workspace, generation) + server.sandbox(t, "stale", tenant, workspace, generation-1) + remote := server.remote(tenant, project) + git := server.git checkout := t.TempDir() for _, args := range [][]string{{"init", "--quiet"}, {"commit", "--quiet", "--allow-empty", "-m", "delivered"}} { if out, err := git(checkout, "sandbox", "", args...); err != nil { @@ -152,13 +118,51 @@ func TestGitServerAuthorizesDeliveryPushFromCloudState(t *testing.T) { if out, err := git(checkout, "sandbox", execution, "push", remote, "HEAD:"+ref); err != nil { t.Fatalf("authorized delivery push failed: %s", out) } - if got := queryDeliveryRef(t, refServer.URL, pki.pool, cloud, tenant, project, ref); got != head { + if got := queryDeliveryRef(t, server.refs.URL, server.pki.pool, server.cloud, tenant, project, ref); got != head { t.Fatalf("ref query = %q, want the pushed commit %q", got, head) } + if out, err := git(checkout, "sandbox", execution, "push", remote, "HEAD:"+ref); err != nil || !strings.Contains(out, "up-to-date") { + t.Fatalf("re-pushing the same commit to the delivery ref must be a no-op: %v %s", err, out) + } + + // Contract D6: the delivery may also fast-forward its own Workspace branch, the one its input + // names, and nothing else under any Workspace prefix. + in := f.deliveryInputOf(execution) + workspaceRef := in.S("workspaceRef") + if workspaceRef != core.GitWorkspaceRefPrefix(workspace)+"main" { + t.Fatalf("the delivery input names %q, want the run Workspace's branch", workspaceRef) + } + if out, err := git(checkout, "sandbox", execution, "push", remote, "HEAD:"+workspaceRef); err != nil { + t.Fatalf("the delivery must be able to create its Workspace branch: %s", out) + } + for _, other := range []string{core.GitWorkspaceRefPrefix(workspace) + "other", core.GitWorkspaceRefPrefix(uuid.NewString()) + "main", "refs/heads/main"} { + if out, err := git(checkout, "sandbox", execution, "push", remote, "HEAD:"+other); err == nil { + t.Fatalf("the delivery wrote %s outside its grant: %s", other, out) + } + } + if out, err := git(checkout, "sandbox", "", "commit", "--quiet", "--allow-empty", "-m", "descendant"); err != nil { + t.Fatal(out) + } + if out, err := git(checkout, "sandbox", execution, "push", remote, "HEAD:"+workspaceRef); err != nil { + t.Fatalf("a fast-forward of the Workspace branch must be accepted: %s", out) + } + if out, err := git(checkout, "sandbox", execution, "push", remote, "+"+head+":"+workspaceRef); err == nil { + t.Fatalf("a non-fast-forward of the Workspace branch was accepted: %s", out) + } - // The delivery execution ending in Cloud ends its repository access on the next request. - _, err = f.store.Pool.Exec(`UPDATE node_executions SET result='{"outcome":"revision_delivered"}' WHERE execution_id=$1`, execution) + // Revision D3 against the real ref query: Cloud verifies the pushed delivery ref through the Git + // Server's ref listener with its pinned identity, then registers the Revision; the execution's + // result ends its repository access on the next request. + refClient, err := gitrefquery.New(server.refs.URL, &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: server.pki.pool, Certificates: []tls.Certificate{server.cloud}}}}, 5*time.Second) must(t, err) + f.store.GitServer = &core.GitServer{RepositoryBaseURL: f.store.GitServer.RepositoryBaseURL, Refs: refClient} + f.useObjectStore() + if _, err := f.deliveryTerminal(scene, execution, "", 1, deliveredResult(scene, in, head), "revision_delivered"); err != nil { + t.Fatalf("a delivery whose ref the Git Server confirms must register: %v", err) + } + if row := f.revisionRow(scene.runID); row == nil || row.DeliveryRef != ref || row.FinalCommit != head { + t.Fatalf("the registered Revision must name the verified delivery ref and commit, got %+v", row) + } if out, err := git(t.TempDir(), "sandbox", execution, "ls-remote", remote); err == nil || !strings.Contains(out, "403") { t.Fatalf("an ended execution still reached the repository: %v %s", err, out) } @@ -202,3 +206,78 @@ func queryDeliveryRef(t *testing.T, base string, roots *x509.CertPool, cloud tls } return body["commit"] } + +// gitServerHarness is a real Git Server on loopback TLS: the shipped pre-receive hook, policy from +// the fixture's Cloud Store on PostgreSQL, repositories under root, and certificates from one PKI. +type gitServerHarness struct { + pki *gitPKI + repository *httptest.Server + refs *httptest.Server + cloud tls.Certificate + caFile string +} + +func startGitServer(t *testing.T, f *fixture, root string) *gitServerHarness { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Fatal("git is required for the Git Server boundary") + } + pki := newGitPKI(t) + pki.issue(t, "server", "", x509.ExtKeyUsageServerAuth) + cloud := pki.issue(t, "cloud", "spiffe://ora.local/cloud/git-ref-query", x509.ExtKeyUsageClientAuth) + deployment := gitserver.Config{CertificateFile: filepath.Join(pki.dir, "server.pem"), PrivateKeyFile: filepath.Join(pki.dir, "server-key.pem"), SandboxCAFile: filepath.Join(pki.dir, "ca.pem"), CloudClientCertFile: filepath.Join(pki.dir, "cloud.pem")} + // The hook is the shipped command, so the pre-receive path under test is the deployed one. + hook := filepath.Join(t.TempDir(), "git-server") + build := exec.Command("go", "build", "-o", hook, "../cmd/git-server") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build git-server: %v: %s", err, out) + } + cloudLeaf, err := deployment.CloudClient() + must(t, err) + service, err := gitserver.New(&gitserver.Options{Store: f.store, RepositoryRoot: root, GitProgram: "git", CloudClient: cloudLeaf, Hook: []string{hook, "-pre-receive"}, Health: f.store.Pool.PingContext}) + must(t, err) + repoTLS, err := deployment.RepositoryTLS() + must(t, err) + refTLS, err := deployment.RefTLS() + must(t, err) + repoServer := httptest.NewUnstartedServer(http.HandlerFunc(service.RepositoryHandler)) + repoServer.TLS = repoTLS + repoServer.StartTLS() + refServer := httptest.NewUnstartedServer(http.HandlerFunc(service.RefHandler)) + refServer.TLS = refTLS + refServer.StartTLS() + t.Cleanup(func() { + repoServer.Close() + refServer.Close() + must(t, service.Close()) + }) + return &gitServerHarness{pki: pki, repository: repoServer, refs: refServer, cloud: cloud, caFile: deployment.SandboxCAFile} +} + +// sandbox issues the git-access certificate a sandbox of workspace's generation holds, as who. +func (h *gitServerHarness) sandbox(t *testing.T, who, tenant, workspace string, generation int64) { + t.Helper() + h.pki.issue(t, who, "spiffe://ora.local/git-access/tenant/"+tenant+"/runtime/"+workspace+"/generation/"+strconv.FormatInt(generation, 10), x509.ExtKeyUsageClientAuth) +} + +func (h *gitServerHarness) remote(tenant, project string) string { + return h.repository.URL + "/" + tenant + "/" + project + ".git" +} + +// git runs a real git client in dir as the sandbox certificate who, declaring executionID. +func (h *gitServerHarness) git(dir, who, executionID string, args ...string) (string, error) { + base := []string{"-c", "http.sslCert=" + filepath.Join(h.pki.dir, who+".pem"), "-c", "http.sslKey=" + filepath.Join(h.pki.dir, who+"-key.pem"), "-c", "http.sslCAInfo=" + h.caFile, "-c", "http.extraHeader=" + gitserver.ExecutionHeader + ": " + executionID} + cmd := exec.Command("git", append(base, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.invalid", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.invalid") + out, err := cmd.CombinedOutput() + return string(out), err +} + +// serverRef reads one ref straight from the Git Server's repository on disk. +func serverRef(t *testing.T, root, tenant, project, ref string) string { + t.Helper() + out, err := exec.Command("git", "--git-dir", gitserver.RepositoryPath(root, tenant, project), "for-each-ref", "--format=%(objectname)", ref).Output() + must(t, err) + return strings.TrimSpace(string(out)) +} diff --git a/integration/migration_upgrade_path_test.go b/integration/migration_upgrade_path_test.go index c25cbb54e..2470fd908 100644 --- a/integration/migration_upgrade_path_test.go +++ b/integration/migration_upgrade_path_test.go @@ -1019,7 +1019,7 @@ func TestMigration0033RevisionResumeAppliesFreshAndUpgrades(t *testing.T) { t.Helper() var names string must(t, pool.QueryRow(`SELECT coalesce(string_agg(conname, ',' ORDER BY conname),'') FROM pg_constraint - WHERE conrelid='revisions'::regclass AND contype='c' AND conname IN ('revisions_check','revisions_bundle_shape','revisions_resume_refused')`).Scan(&names)) + WHERE conrelid='revisions'::regclass AND contype='c' AND conname IN ('revisions_check','revisions_bundle_shape','revisions_resume_refused','revisions_shape')`).Scan(&names)) return names } columns := func(pool *sql.DB) int { @@ -1043,7 +1043,8 @@ func TestMigration0033RevisionResumeAppliesFreshAndUpgrades(t *testing.T) { t.Fatalf("before 0033: columns %d constraints %q", got, names) } migratedTwice(t, newStoreOnSchema(t, pool)) - if got, names := columns(pool), constraints(pool); got != 5 || names != "revisions_bundle_shape,revisions_resume_refused" { + // 0054 later replaces the bundle shape 0033 introduced with revisions_shape. + if got, names := columns(pool), constraints(pool); got != 5 || names != "revisions_resume_refused,revisions_shape" { t.Fatalf("after 0033: columns %d constraints %q", got, names) } } @@ -1309,6 +1310,46 @@ func TestMigration0044ModelAttemptWorkflowRunAppliesFreshAndUpgrades(t *testing. assertShape(t, pool, "upgrade") } +// Migration 0053 upgrades a database that still holds tenant-level clones: their requests, +// executions and receipts are removed, while a Workspace operation's clone execution and its receipt +// stay, and every remaining clone execution must name its Workspace. +func TestMigration0053RetiresTenantLevelClonesKeepingWorkspaceClones(t *testing.T) { + pool, _ := testSchema(t, "test_gn53_") + applyMigrationsUpTo(t, pool, migrationsBefore(t, "0053_retire_tenant_level_clones.sql")) + s := seedOwnershipChain(t, pool) + exec := func(q string, args ...any) { + t.Helper() + _, e := pool.Exec(q, args...) + must(t, e) + } + request, operation := uuid.NewString(), uuid.NewString() + exec(`INSERT INTO clone_requests(id,tenant_id,actor_user_id,request_id,repository_url,branch,state) VALUES($1,$2,$3,'tenant-request','https://example.invalid/repo.git','main','succeeded')`, request, s.tenantID, s.userID) + exec(`INSERT INTO clone_executions(execution_id,operation_id,node_id,node_operation_id,input,result,dispatched_epoch) VALUES('tenant-clone',$1,'node','tenant-clone','{}','{"outcome":"clone_ready"}',1)`, request) + exec(`INSERT INTO clone_event_receipts(execution_id,sequence,event) VALUES('tenant-clone',1,'ready')`) + exec(`INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,'create_project','succeeded','done','{}','gn39','gn39')`, operation, s.tenantID, s.userID, s.projectID, s.mainWorkspaceID) + exec(`INSERT INTO clone_executions(execution_id,operation_id,workspace_id,node_id,node_operation_id,input,result,dispatched_epoch) VALUES('workspace-clone',$1,$2,'node','workspace-clone','{}','{"outcome":"clone_ready"}',1)`, operation, s.mainWorkspaceID) + exec(`INSERT INTO clone_event_receipts(execution_id,sequence,event) VALUES('workspace-clone',1,'ready')`) + + migratedTwice(t, newStoreOnSchema(t, pool)) + if tableExists(t, pool, "clone_requests") { + t.Fatal("0053 must drop clone_requests") + } + var executions, receipts string + must(t, pool.QueryRow(`SELECT string_agg(execution_id, ',' ORDER BY execution_id) FROM clone_executions`).Scan(&executions)) + must(t, pool.QueryRow(`SELECT string_agg(execution_id, ',' ORDER BY execution_id) FROM clone_event_receipts`).Scan(&receipts)) + if executions != "workspace-clone" || receipts != "workspace-clone" { + t.Fatalf("only the Workspace clone and its receipt may remain, got executions %q receipts %q", executions, receipts) + } + if columnNullable(t, pool, "clone_executions", "workspace_id") { + t.Fatal("every clone execution must name its Workspace after 0053") + } + var requestColumns int + must(t, pool.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='clone_executions' AND column_name='clone_request_id'`).Scan(&requestColumns)) + if requestColumns != 0 { + t.Fatal("0053 must drop clone_executions.clone_request_id") + } +} + // 0051 adds the cancellation-request instant to workflow_runs. The column is nullable and has no // default so a run that was never asked to stop stays distinguishable from one requested at any // instant; nothing is backfilled because the request is an intent recorded from now on. A fresh diff --git a/integration/project_space_test.go b/integration/project_space_test.go index 0b62461b3..592221c33 100644 --- a/integration/project_space_test.go +++ b/integration/project_space_test.go @@ -32,6 +32,8 @@ func TestProjectSpaceScopingAndRoleGates(t *testing.T) { t.Fatalf("space project list: want 1 got %d", len(items)) } pid := core.Object(items[0].(map[string]any)).S("id") + // The project was created outside f.call, so stand in for its first sync here. + f.syncProject(pid) if core.Object(items[0].(map[string]any)).S("spaceId") != sid { t.Fatal("project missing spaceId") } diff --git a/integration/repository_binding_test.go b/integration/repository_binding_test.go index 68de3ecaa..2ab853c57 100644 --- a/integration/repository_binding_test.go +++ b/integration/repository_binding_test.go @@ -67,6 +67,7 @@ func (f *fixture) repositoryOperationState(id string) (state, code string) { // exists, in the same transaction; the published Project keeps its repository fields. func TestCreateProjectRecordsBindingAndInitialSync(t *testing.T) { f := setup(t) + f.withoutSnapshot = true created := f.create("binding-create") project := created.O("resource") if project.S("repositoryUrl") != "https://example.invalid/repo.git" || project.S("defaultBranch") != "main" { @@ -96,6 +97,7 @@ func TestCreateProjectRecordsBindingAndInitialSync(t *testing.T) { // one unfinished sync per Binding, and the snapshot ref's name. func TestRepositoryOperationStorageConstraints(t *testing.T) { f := setup(t) + f.withoutSnapshot = true pid := f.create("binding-constraints").O("resource").S("id") bid := f.bindingOf(pid).S("id") @@ -144,9 +146,10 @@ func TestDeletingProjectCancelsQueuedRepositoryOperationsAndWaitsForRunning(t *t pid := f.create("binding-delete").O("resource").S("id") f.drain() bid := f.bindingOf(pid).S("id") - var initial string - must(t, f.store.Pool.QueryRow("SELECT id FROM repository_operations WHERE binding_id=$1", bid).Scan(&initial)) - queued := []string{initial} + // The Project's first sync produced its snapshot; a later one is still pending. + pending, err := f.repositoryOperation(pid, bid, core.RepositorySync, "pending") + must(t, err) + queued := []string{pending} for _, state := range []string{"awaiting_approval", "pending", "leased"} { id, err := f.repositoryOperation(pid, bid, core.RepositoryPublish, state) must(t, err) @@ -176,15 +179,16 @@ func TestDeletingProjectCancelsQueuedRepositoryOperationsAndWaitsForRunning(t *t t.Fatal("entering deleting kept the resync request") } + // Operation D6: the claim does not hand delete_project to the Controller while Operations are + // unfinished; it waits without failing. f.acknowledgeSimulatorBindings() - if err := f.controller.Drain(context.Background()); err == nil || !strings.Contains(err.Error(), "repository_operation_unfinished") { - t.Fatalf("delete_project started with unfinished repository Operations: %v", err) - } - if lifecycle := f.call("GET", f.path("/operations/"+deleting.O("operation").S("id")), nil, "", 200).S("step"); lifecycle != "quiesce" { - t.Fatalf("delete_project left quiesce at step %s", lifecycle) + f.drain() + if op := f.call("GET", f.path("/operations/"+deleting.O("operation").S("id")), nil, "", 200); op.S("step") != "quiesce" || op.S("state") != "retry_wait" || op.S("errorCode") != "repository_operation_running" { + t.Fatalf("delete_project must wait at quiesce, got %v", op) } must(t, firstErr(f.store.Pool.Exec("UPDATE repository_operations SET state='succeeded',finished_at=clock_timestamp() WHERE id=$1", running))) must(t, firstErr(f.store.Pool.Exec("UPDATE repository_operations SET state='failed',error_code='test',finished_at=clock_timestamp() WHERE id=$1", uncertain))) + f.due(deleting.O("operation").S("id")) f.drain() var lifecycle string must(t, f.store.Pool.QueryRow("SELECT lifecycle FROM projects WHERE id=$1", pid).Scan(&lifecycle)) @@ -207,6 +211,10 @@ func TestAbortedProjectDeletionRestoresTheOwedSync(t *testing.T) { { name: "queued sync cancelled", prepare: func(f *fixture, pid, bid, _ string) string { + // The fixture's Git Server double already ended the project_created sync with a + // snapshot, so the queued sync the deletion cancels is a later one. + _, err := f.repositoryOperation(pid, bid, core.RepositorySync, "pending") + must(f.t, err) publish, err := f.repositoryOperation(pid, bid, core.RepositoryPublish, "awaiting_approval") must(f.t, err) return publish @@ -215,11 +223,9 @@ func TestAbortedProjectDeletionRestoresTheOwedSync(t *testing.T) { }, { name: "resync requested while a sync runs", - prepare: func(f *fixture, _, bid, initial string) string { - // The external line's lease constraint requires the lease and authorization columns - // the foundation's fixture predates; a running sync without them is not a state this - // schema admits. - must(f.t, firstErr(f.store.Pool.Exec("UPDATE repository_operations SET state='running',lease_owner='test',lease_expires_at=clock_timestamp()+interval '1 hour',started_at=clock_timestamp(),authorization_id=gen_random_uuid() WHERE id=$1", initial))) + prepare: func(f *fixture, pid, bid, _ string) string { + _, err := f.repositoryOperation(pid, bid, core.RepositorySync, "running") + must(f.t, err) must(f.t, firstErr(f.store.Pool.Exec("UPDATE repository_bindings SET resync_requested=true WHERE id=$1", bid))) return "" }, diff --git a/integration/repository_credentials_test.go b/integration/repository_credentials_test.go index 340694a0e..cb6404c48 100644 --- a/integration/repository_credentials_test.go +++ b/integration/repository_credentials_test.go @@ -1,7 +1,7 @@ package integration import ( - "context" + "strings" "testing" "github.com/wanglongan587/cloud/internal/core" @@ -45,30 +45,26 @@ func TestCredentialDepartureFreezesPersonalAndUnknownButRetainsTeamHistory(t *te } } -func TestCredentialedCloneRefusesMissingExecutorAndAnonymousFallback(t *testing.T) { +// A credentialed Project's Workspace clones its Git Server snapshot like any other: the external +// repository's credential reference is the Repo Executor's business (operation D1), so it never +// gates the clone and never reaches the clone input, the Controller snapshot or the Node. +func TestCredentialedProjectClonesItsSnapshotWithoutTheCredential(t *testing.T) { f := setup(t) - ref := core.Object{"id": f.storedGitCredential(f.uid, "personal", "https://example.invalid/repo")} - created := f.call("POST", f.path("/projects"), core.Object{"name": "Private", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main", "credentialRefId": ref.S("id")}, "private", 202) - if err := f.controller.Drain(context.Background()); err == nil { - t.Fatal("credentialed Git must reject an unavailable scoped executor") - } + ref := core.Object{"id": f.storedGitCredential(f.uid, "personal", "https://example.invalid/private")} + created := f.call("POST", f.path("/projects"), core.Object{"name": "Private", "repositoryUrl": "https://example.invalid/private.git", "defaultBranch": "main", "credentialRefId": ref.S("id")}, "private", 202) + f.drain() wid := created.O("workspace").S("id") - if f.scalar("SELECT count(*) FROM clone_executions WHERE workspace_id=$1", wid) != 0 { - t.Fatal("credentialed project fell back to anonymous/static Git") - } - if f.scalar("SELECT count(*) FROM projects p JOIN repository_bindings b ON b.project_id=p.id WHERE p.id=$1 AND p.owner_user_id=$2 AND b.credential_ref_id=$3", created.O("resource").S("id"), f.uid, ref.S("id")) != 1 { - t.Fatal("refusal rewrote durable ownership or reference") + if w := f.ws(wid); w.S("observedState") != "ready" || w.S("baseCommitId") != f.commit { + t.Fatalf("a credentialed Project must clone its snapshot: %v", w) } -} - -func TestProductionStoreRefusesUnscopedCloneAdmission(t *testing.T) { - f := setup(t) - production := newStoreOnSchema(t, f.store.Pool) - _, err := production.EnqueueClone(context.Background(), f.tid, f.uid, "unscoped", "https://example.invalid/repo.git", "main") - if err == nil || core.ErrorCode(err).Code != "runtime_scope_required" { - t.Fatalf("production legacy admission: %v", err) + var input string + must(t, f.store.Pool.QueryRow("SELECT input::text FROM clone_executions WHERE workspace_id=$1", wid).Scan(&input)) + for _, private := range []string{"example.invalid/private", ref.S("id")} { + if strings.Contains(input, private) { + t.Fatalf("the clone input carries %q: %s", private, input) + } } - if f.scalar("SELECT count(*) FROM clone_requests") != 0 { - t.Fatal("refused legacy request was persisted") + if f.scalar("SELECT count(*) FROM projects p JOIN repository_bindings b ON b.project_id=p.id WHERE p.id=$1 AND p.owner_user_id=$2 AND b.credential_ref_id=$3", created.O("resource").S("id"), f.uid, ref.S("id")) != 1 { + t.Fatal("cloning rewrote durable ownership or reference") } } diff --git a/integration/repository_publish_external_test.go b/integration/repository_publish_external_test.go index a5a621af4..4d266ed0a 100644 --- a/integration/repository_publish_external_test.go +++ b/integration/repository_publish_external_test.go @@ -93,8 +93,11 @@ func TestRepositoryPublishPersonalApprovalMatrixAndIdempotency(t *testing.T) { func TestRepositoryPublishLeaseMutualExclusionAndRecovery(t *testing.T) { f := setup(t) + // The fixture settles the Project's first sync; the sync the publish must reuse is a later, + // not-yet-running one. pid := f.create("publish-recovery").O("resource").S("id") f.drain() + pendingSync := f.manualSync(pid) credential := f.publishCredential(f.uid, "team") path := f.path("/projects/" + pid + "/repository/publishes") first := f.gitCall("POST", path, f.publishBody(credential), "", 201).O("resource") @@ -128,23 +131,25 @@ func TestRepositoryPublishLeaseMutualExclusionAndRecovery(t *testing.T) { if state, _ := f.repositoryOperationState(task.OperationID); state != "succeeded" { t.Fatalf("verified publish = %s", state) } - if f.scalar("SELECT count(*) FROM repository_operations WHERE id=$1 AND state='leased'", f.initialSync(pid)) != 1 { + if f.scalar("SELECT count(*) FROM repository_operations WHERE id=$1 AND state='leased'", pendingSync) != 1 { t.Fatal("publish did not reuse not-yet-running sync") } } -func (f *fixture) initialSync(pid string) string { - f.t.Helper() - var id string - must(f.t, f.store.Pool.QueryRow("SELECT id FROM repository_operations WHERE project_id=$1 AND type='repository_sync' ORDER BY created_at LIMIT 1", pid).Scan(&id)) - return id -} - func TestRepositoryPublishSuccessSchedulesExactlyOneSyncAfterRunningSync(t *testing.T) { f := setup(t) - pid, st := f.leasedSync() + // The fixture settles the Project's first sync, so the Project is active and the running sync + // is one a member asked for. + pid := f.create(uuid.NewString()).O("resource").S("id") f.drain() - _, err := f.store.StartRepositoryOperation(context.Background(), st.OperationID, st.LeaseGeneration, strings.Repeat("0", 40), time.Minute, time.Minute) + sync := f.manualSync(pid) + tasks, err := f.store.LeaseRepositoryOperations(context.Background(), "test", 1, time.Minute) + must(t, err) + if len(tasks) != 1 || tasks[0].OperationID != sync { + t.Fatalf("leased = %v, want the manual sync %s", tasks, sync) + } + st := tasks[0] + _, err = f.store.StartRepositoryOperation(context.Background(), st.OperationID, st.LeaseGeneration, strings.Repeat("0", 40), time.Minute, time.Minute) must(t, err) credential := f.publishCredential(f.uid, "team") path := f.path("/projects/" + pid + "/repository/publishes") diff --git a/integration/repository_publish_revision_test.go b/integration/repository_publish_revision_test.go new file mode 100644 index 000000000..d878b5f5e --- /dev/null +++ b/integration/repository_publish_revision_test.go @@ -0,0 +1,53 @@ +package integration + +import ( + "testing" + + "github.com/wanglongan587/cloud/internal/cloneauth" + "github.com/wanglongan587/cloud/internal/core" +) + +// Publishing a run's Revision publishes what Cloud verified when it registered the Revision: its +// delivery ref on the Git Server and its final commit (publish ADR, revisionId; revision ADR D4). +// The Revision's revisionRef is only the Node-local refs/ora/revisions/ and never exists on +// the Git Server, so it must never become a publish source. +func TestPublishingARevisionUsesItsVerifiedDeliveryRef(t *testing.T) { + f := setup(t) + f.useObjectStore() + scene := deliveringScene(t, f) + execution := f.deliverRevision(t, scene) + in := f.deliveryInputOf(execution) + f.push(in, revisionFinalCommit) + if _, e := f.deliveryTerminal(scene, execution, "publish-revision-saved", 1, deliveredResult(scene, in, revisionFinalCommit), "revision_delivered"); e != nil { + t.Fatalf("a verified delivery must be taken over: %v", e) + } + row := f.revisionRow(scene.runID) + if row == nil || row.DeliveryRef == "" || row.DeliveryRef == row.RevisionRef { + t.Fatalf("the run must have registered a Git Revision with its own delivery ref: %+v", row) + } + + // The scene lives in its own tenant, where the fixture's user is an administrator. + f.tid = scene.tenantID + projectID, repositoryURL := f.runPlacement(scene.runID) + scope, err := cloneauth.NormalizeRepository(repositoryURL) + must(t, err) + credential := f.storedGitCredential(f.uid, "team", scope) + must(t, firstErr(f.store.Pool.Exec(`UPDATE credential_refs SET capabilities='["publish"]' WHERE id=$1`, credential))) + f.store.GitRefs = publishRefs{} + + path := f.path("/projects/" + projectID + "/repository/publishes") + body := core.Object{"revisionId": row.ID, "targetBranch": "main", "expectedOldOid": revisionBaseCommitOf(t, f, row.ID), "credentialRefId": credential} + op := f.gitCall("POST", path, body, "", 201).O("resource") + if op.S("sourceRef") != row.DeliveryRef || op.S("sourceCommit") != revisionFinalCommit || op.S("revisionId") != row.ID { + t.Fatalf("a Revision publish must use the verified delivery ref %s and final commit %s, got %v", row.DeliveryRef, revisionFinalCommit, op) + } +} + +// revisionBaseCommitOf is the commit the Revision started from, a target branch value that differs +// from its final commit so the publish has something to fast-forward. +func revisionBaseCommitOf(t *testing.T, f *fixture, revisionID string) string { + t.Helper() + var base string + must(t, f.store.Pool.QueryRow(`SELECT base_commit FROM revisions WHERE id=$1`, revisionID).Scan(&base)) + return base +} diff --git a/integration/revision_simulator_test.go b/integration/revision_simulator_test.go index 27b863b59..ee67f985c 100644 --- a/integration/revision_simulator_test.go +++ b/integration/revision_simulator_test.go @@ -163,7 +163,7 @@ func TestSimulatorDeliversRevisionToS3AcrossRestart(t *testing.T) { preparedRef := runGit(t, "-C", checkout, "rev-parse", work.O("input").S("revisionRef")) rebootedNode, err := simulator.NewAgentNode(filepath.Join(f.root, "agent-node")) must(t, err) - f.controller = &simulator.Controller{Client: f.client, SubstrateURL: f.external.URL, Executions: f.executions, AgentRuns: originalClient, AgentNode: rebootedNode} + f.controller = &simulator.Controller{Client: f.client, SubstrateURL: f.external.URL, Executions: f.executions, AgentRuns: originalClient, AgentNode: rebootedNode, Git: f.git} must(t, f.controller.Acquire(t.Context())) refresh := &expiryFirstGrantClient{AgentRunServiceClient: originalClient, cfg: cfg} f.controller.AgentRuns = refresh @@ -177,7 +177,7 @@ func TestSimulatorDeliversRevisionToS3AcrossRestart(t *testing.T) { // Replace both doubles after upload, then retry the same durable Node evidence. rebootedNode, err = simulator.NewAgentNode(filepath.Join(f.root, "agent-node")) must(t, err) - f.controller = &simulator.Controller{Client: f.client, SubstrateURL: f.external.URL, Executions: f.executions, AgentRuns: controlpb.NewAgentRunServiceClient(f.controlConn), AgentNode: rebootedNode} + f.controller = &simulator.Controller{Client: f.client, SubstrateURL: f.external.URL, Executions: f.executions, AgentRuns: controlpb.NewAgentRunServiceClient(f.controlConn), AgentNode: rebootedNode, Git: f.git} must(t, f.controller.Acquire(t.Context())) fail = false _, err = f.controller.Step(t.Context()) diff --git a/integration/revision_test.go b/integration/revision_test.go index 989c5c364..9892c5e51 100644 --- a/integration/revision_test.go +++ b/integration/revision_test.go @@ -52,7 +52,7 @@ func (f *fixture) registeredDelivery(t *testing.T, cfg *objectstore.Config) (str var wid string must(t, f.store.Pool.QueryRow("SELECT id::text FROM workspaces WHERE issue_run_id=$1", run).Scan(&wid)) sandbox, _, _ := f.liveNode(t, wid) - input := core.Object{"kind": "deliver_revision", "sessionExecutionId": session, "checkoutExecutionId": "checkout-1", "baseCommit": f.commit} + input := core.Object{"kind": "deliver_revision", "sessionExecutionId": session, "checkoutExecutionId": f.checkoutOf(wid), "baseCommit": f.commit} target := core.Object{"workspaceId": wid, "sandboxInstanceId": sandbox, "nodeId": node} work, err := f.store.EnqueueExecutionWork(t.Context(), run, "deliver_revision", input, target, time.Time{}) must(t, err) @@ -82,6 +82,8 @@ func (f *fixture) registeredDelivery(t *testing.T, cfg *objectstore.Config) (str if !proto.Equal(recovered.GetRecord().GetInput(), claim.GetItem().GetInput()) { t.Fatal("GetDispatch changed the frozen delivery input") } + // The Node pushed its final commit, here its unchanged baseline, to the attempt's delivery ref. + f.pushedDelivery("revision-delivery", f.commit) history := []byte("{\"kind\":\"assistant\",\"text\":\"echo\"}\n") sum := sha256.Sum256(history) result := &controlpb.ExecutionResult{Node: ended.GetNode(), Outcome: &controlpb.ExecutionResult_RevisionUnchanged{RevisionUnchanged: &controlpb.RevisionUnchanged{FinalCommit: f.commit, BaseCommit: f.commit, RevisionRef: spec.GetRevisionRef(), History: &controlpb.StoredObject{Key: spec.GetHistoryKey(), Size: uint64(len(history)), Sha256: hex.EncodeToString(sum[:])}}}} @@ -143,7 +145,7 @@ func TestFailedDeliveryRetryUsesNewWorkKeysAndAtomicHook(t *testing.T) { var session string must(t, f.store.Pool.QueryRow("SELECT input->>'sessionExecutionId' FROM node_executions WHERE execution_id=$1", req.ExecutionId).Scan(&session)) sandbox, node, _ := f.liveNode(t, wid) - work, err := f.store.EnqueueExecutionWork(t.Context(), req.OperationId, "deliver_revision", core.Object{"kind": "deliver_revision", "sessionExecutionId": session, "checkoutExecutionId": "checkout-1", "baseCommit": f.commit}, core.Object{"workspaceId": wid, "sandboxInstanceId": sandbox, "nodeId": node}, time.Time{}) + work, err := f.store.EnqueueExecutionWork(t.Context(), req.OperationId, "deliver_revision", core.Object{"kind": "deliver_revision", "sessionExecutionId": session, "checkoutExecutionId": f.checkoutOf(wid), "baseCommit": f.commit}, core.Object{"workspaceId": wid, "sandboxInstanceId": sandbox, "nodeId": node}, time.Time{}) must(t, err) if work.O("input").S("historyKey") == oldHistory || !strings.Contains(work.O("input").S("historyKey"), "/"+work.S("id")+"/") || f.scalar("SELECT count(*) FROM revisions") != 0 { t.Fatal("retry reused a previous work item's object key or fabricated a Revision") @@ -198,15 +200,10 @@ func TestRevisionS3VerificationAndAtomicTakeover(t *testing.T) { history.Size++ } if mode == "delivered" { - // Git artifact generation is exercised separately by the simulator acceptance. - bundle := []byte("opaque bundle object verified by S3 checksum") - sum := sha256.Sum256(bundle) - var key string - must(t, f.store.Pool.QueryRow("SELECT input->>'bundleKey' FROM node_executions WHERE execution_id=$1", req.ExecutionId).Scan(&key)) - if status := putRevisionObject(t, cfg, key, bundle, ""); status != 200 { - t.Fatalf("bundle PUT status=%d", status) - } - req.Result.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{BaseCommit: f.commit, FinalCommit: strings.Repeat("b", 40), RevisionRef: req.Result.GetRevisionUnchanged().GetRevisionRef(), History: history, Bundle: &controlpb.StoredObject{Key: key, Size: uint64(len(bundle)), Sha256: hex.EncodeToString(sum[:])}}} + // The Git push itself is exercised against a real Git Server in git_server_test.go. + final := strings.Repeat("b", 40) + f.pushedDelivery(req.ExecutionId, final) + req.Result.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{BaseCommit: f.commit, FinalCommit: final, RevisionRef: req.Result.GetRevisionUnchanged().GetRevisionRef(), History: history}} } _, err := f.store.Pool.Exec("CREATE TABLE delivery_hook_evidence(outcome text, revision_id uuid)") must(t, err) diff --git a/integration/workspace_lifecycle_test.go b/integration/workspace_lifecycle_test.go index cbdf326eb..19b75f33b 100644 --- a/integration/workspace_lifecycle_test.go +++ b/integration/workspace_lifecycle_test.go @@ -142,7 +142,7 @@ func TestCloneFailureRetriesAsNewExecutionAndUnknownBlocks(t *testing.T) { must(t, f.store.Pool.QueryRow("SELECT n.node_id FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id WHERE s.workspace_id=$1 AND n.ended_at IS NULL", uwid).Scan(&node)) ctx := asController(f.client.Subject) dispatch := func(execution string) error { - _, e := f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "dispatch-" + execution, Epoch: at.N("epoch"), OperationId: uoid, ExecutionId: execution, NodeId: node, Input: cloneSpec("https://example.invalid/repo.git", "main")}) + _, e := f.executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "dispatch-" + execution, Epoch: at.N("epoch"), OperationId: uoid, ExecutionId: execution, NodeId: node, Input: cloneInputSpec(f.frozenClone(uoid))}) return e } must(t, dispatch("unknown-1")) diff --git a/integration/workspace_operations_grpc_test.go b/integration/workspace_operations_grpc_test.go index f39b368c3..f00afa2d1 100644 --- a/integration/workspace_operations_grpc_test.go +++ b/integration/workspace_operations_grpc_test.go @@ -6,13 +6,17 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "time" + "github.com/google/uuid" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" "github.com/wanglongan587/cloud/internal/controlpb" "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" ) // grpcController is a test Controller that drives runtime Workspace operations only through the @@ -116,13 +120,10 @@ func (c *grpcController) status(n *controlpb.NodeRecord, connection controlpb.No return out.GetNode(), e } -func cloneSpec(repository, branch string) *controlpb.ExecutionInput { - return &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{Repository: repository, Branch: branch}}} -} - // A Rust-shaped Controller creates a Project purely over gRPC: sandbox, then the Controller reports -// the sandbox's Node, then the Workspace's clone is registered against Cloud-decided input and -// Node; admission opens only once the clone succeeded. Stop then quiesces with Controller-reported +// the sandbox's Node, then the Workspace's clone is registered with the snapshot input Cloud froze +// into the clone step and against the Workspace's Node; admission opens only once the clone of +// exactly that snapshot commit succeeded. Stop then quiesces with Controller-reported // idle evidence and terminates, after which every report about the old sandbox is fenced. // // Evidence for specs test-cases/cloud/operation/workspace-runtime-lifecycle.md @@ -135,7 +136,7 @@ func TestGRPCControllerDrivesWorkspaceThroughNodeAndClone(t *testing.T) { wid := created.O("workspace").S("id") snap := c.claim() op := snap.GetOperation() - if op.GetKind() != controlpb.OperationKind_OPERATION_KIND_CREATE_PROJECT || op.GetStep() != controlpb.OperationStep_OPERATION_STEP_SANDBOX || snap.GetWorkspaces()[0].GetRequestedRef() != "main" || snap.GetProject().GetRepositoryUrl() != "https://example.invalid/repo.git" { + if op.GetKind() != controlpb.OperationKind_OPERATION_KIND_CREATE_PROJECT || op.GetStep() != controlpb.OperationStep_OPERATION_STEP_SANDBOX || snap.GetWorkspaces()[0].GetRequestedRef() != "main" || snap.GetProject().GetId() != created.O("resource").S("id") || snap.GetCloneInput() != nil { t.Fatalf("unexpected snapshot: %v", snap) } _, e := c.operations.PlanEffect(c.ctx, &controlpb.PlanEffectRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), Version: op.GetVersion(), Kind: controlpb.EffectKind_EFFECT_KIND_WORKSPACE_DATA_DELETE, WorkspaceId: wid}) @@ -189,39 +190,65 @@ func TestGRPCControllerDrivesWorkspaceThroughNodeAndClone(t *testing.T) { t.Fatalf("execution before the clone must be refused as execution_closed: %v", closed) } - // Cloud, not the Controller, decides the clone's input and target Node. + // Cloud, not the Controller, decides the clone's input and target Node: the step carries the + // snapshot input Cloud froze when it entered the step, and nothing of the external repository. + snap = c.claim() + op = snap.GetOperation() + frozen := snap.GetCloneInput() + var binding string + must(t, f.store.Pool.QueryRow("SELECT id FROM repository_bindings WHERE project_id=$1", created.O("resource").S("id")).Scan(&binding)) + want := &controlpb.CloneSpec{ + RepositoryUrl: simulator.GitServerBaseURL + "/" + f.tid + "/" + created.O("resource").S("id") + ".git", + SnapshotRef: core.GitSnapshotRef(binding, f.commit), BaseCommit: f.commit, WorkspaceRef: core.GitWorkspaceRefPrefix(wid) + "main", + } + if !proto.Equal(frozen, want) { + t.Fatalf("clone_input = %v, want %v", frozen, want) + } executions := f.executions - dispatch := func(repository, branch, node, execution string) error { - _, err := executions.RecordDispatch(c.ctx, &controlpb.RecordDispatchRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), ExecutionId: execution, NodeId: node, Input: cloneSpec(repository, branch)}) + dispatch := func(spec *controlpb.CloneSpec, node, execution string) error { + _, err := executions.RecordDispatch(c.ctx, &controlpb.RecordDispatchRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), ExecutionId: execution, NodeId: node, Input: &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: spec}}}) return err } - expectStatus(t, dispatch("https://example.invalid/other.git", "main", nodeID, "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - expectStatus(t, dispatch("https://example.invalid/repo.git", "other", nodeID, "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - expectStatus(t, dispatch("https://example.invalid/repo.git", "main", "not-this-node", "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - must(t, dispatch("https://example.invalid/repo.git", "main", nodeID, "exec-a")) - expectStatus(t, dispatch("https://example.invalid/repo.git", "main", nodeID, "exec-b"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - // A tenant clone request and the Workspace clone coexist: recovery sees both, ClaimWork only the - // tenant request. - tenantRequest, e := f.store.EnqueueClone(context.Background(), f.tid, f.uid, "tenant-request", "https://example.invalid/repo.git", "main") - must(t, e) + altered := func(edit func(*controlpb.CloneSpec)) *controlpb.CloneSpec { + spec := proto.Clone(frozen).(*controlpb.CloneSpec) + edit(spec) + return spec + } + other := strings.Repeat("f", 40) + expectStatus(t, dispatch(altered(func(s *controlpb.CloneSpec) { s.BaseCommit, s.SnapshotRef = other, core.GitSnapshotRef(binding, other) }), nodeID, "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) + expectStatus(t, dispatch(altered(func(s *controlpb.CloneSpec) { s.WorkspaceRef = core.GitWorkspaceRefPrefix(wid) + "other" }), nodeID, "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) + // A malformed input never reaches the registry: the snapshot ref must name the base commit. + expectStatus(t, dispatch(altered(func(s *controlpb.CloneSpec) { s.BaseCommit = other }), nodeID, "exec-a"), codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) + expectStatus(t, dispatch(frozen, "not-this-node", "exec-a"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) + must(t, dispatch(frozen, nodeID, "exec-a")) + expectStatus(t, dispatch(frozen, nodeID, "exec-b"), codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) + // There is no tenant-level clone: ClaimWork offers no clone work, and a dispatch naming an ID that + // is not a Workspace operation is refused. work, e := executions.ClaimWork(c.ctx, &controlpb.ClaimWorkRequest{SubmissionId: c.sub(), Epoch: c.epoch}) must(t, e) - if work.GetItem().GetOperationId() != tenantRequest.S("id") { - t.Fatalf("ClaimWork must offer only tenant clone requests: %v", work.GetItem()) + if work.GetItem() != nil { + t.Fatalf("ClaimWork must not offer clone work: %v", work.GetItem()) } - must(t, func() error { - _, err := executions.RecordDispatch(c.ctx, &controlpb.RecordDispatchRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: tenantRequest.S("id"), ExecutionId: "exec-tenant", NodeId: "fixed-node", Input: work.GetItem().GetInput()}) - return err - }()) + _, e = executions.RecordDispatch(c.ctx, &controlpb.RecordDispatchRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: uuid.NewString(), ExecutionId: "exec-tenant", NodeId: "fixed-node", Input: &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: frozen}}}) + expectStatus(t, e, codes.NotFound, controlpb.ErrorCode_ERROR_CODE_NOT_FOUND) pending, e := executions.ListPendingDispatches(c.ctx, &controlpb.ListPendingDispatchesRequest{}) must(t, e) - if len(pending.GetRecords()) != 2 { - t.Fatalf("recovery must list the Workspace and the tenant clone: %v", pending.GetRecords()) + if len(pending.GetRecords()) != 1 || !proto.Equal(pending.GetRecords()[0].GetInput().GetClone(), frozen) { + t.Fatalf("recovery must list exactly the Workspace clone with its frozen input: %v", pending.GetRecords()) } _, e = c.advance(op) expectStatus(t, e, codes.Aborted, controlpb.ErrorCode_ERROR_CODE_CONFLICT) - outcome := c.put("/clones/"+"00000000-0000-4000-8000-000000000001", core.Object{"workspaceId": wid, "repositoryUrl": "https://example.invalid/repo.git", "branch": "main"}) + outcome := c.put("/clones/"+"00000000-0000-4000-8000-000000000001", core.Object{"workspaceId": wid, "repositoryUrl": frozen.GetRepositoryUrl(), "snapshotRef": frozen.GetSnapshotRef(), "baseCommit": frozen.GetBaseCommit(), "workspaceRef": frozen.GetWorkspaceRef()}) + // A checkout of any other commit is a contradiction Cloud refuses, through both result paths. + wrong := &controlpb.ExecutionResult{Node: &controlpb.NodeIdentity{NodeId: nodeID, NodeIncarnationId: "inc-1"}, Outcome: &controlpb.ExecutionResult_CloneReady{CloneReady: &controlpb.CloneReady{Path: outcome.S("path"), Commit: other}}} + _, e = executions.RecordQueriedResult(c.ctx, &controlpb.RecordQueriedResultRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), ExecutionId: "exec-a", Result: wrong}) + expectStatus(t, e, codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) + _, e = executions.TakeOverNodeEvent(c.ctx, &controlpb.TakeOverNodeEventRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), ExecutionId: "exec-a", Sequence: 1, Result: wrong, Event: []byte("wrong")}) + expectStatus(t, e, codes.InvalidArgument, controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT) + if f.scalar("SELECT count(*) FROM clone_executions WHERE execution_id='exec-a' AND result IS NULL") != 1 { + t.Fatal("a contradictory clone result was recorded") + } ready := &controlpb.ExecutionResult{Node: &controlpb.NodeIdentity{NodeId: nodeID, NodeIncarnationId: "inc-1"}, Outcome: &controlpb.ExecutionResult_CloneReady{CloneReady: &controlpb.CloneReady{Path: outcome.S("path"), Commit: outcome.S("commit")}}} _, e = executions.RecordQueriedResult(c.ctx, &controlpb.RecordQueriedResultRequest{SubmissionId: c.sub(), Epoch: c.epoch, OperationId: op.GetId(), ExecutionId: "exec-a", Result: ready}) must(t, e) @@ -303,8 +330,7 @@ func TestGRPCNodeIncarnationIsReplacedOnlyAfterEnd(t *testing.T) { } } -// OperationAvailable follows the commit that queued a Workspace operation, just like WorkAvailable -// follows a tenant clone request. +// OperationAvailable follows the commit that queued a Workspace operation. func TestGRPCWatchDeliversOperationAvailable(t *testing.T) { f := setup(t) c := newGRPCController(t, f) diff --git a/internal/README.en.md b/internal/README.en.md index b128c0a36..af4758bc1 100644 --- a/internal/README.en.md +++ b/internal/README.en.md @@ -19,6 +19,7 @@ - [objectstore](objectstore/README.en.md) is cloud's own S3-compatible object-store client (SigV4-presigned upload grants, HEAD verification of an object's size and SHA-256), serving Revision delivery, archive reads and workflow asset reads. - [gitpreset](gitpreset/README.en.md) prepares and checks Git Server contract fixtures for acceptance only, writing the repository volume and database directly; no service process imports it. - [gitserver](gitserver/README.en.md) serves Git access to Project repositories: the sandbox and Repo Executor repository listeners, per-request ref rule sets from Core, `git http-backend`, per-namespace pre-receive rules and Cloud's ref query. +- [gitrefquery](gitrefquery/README.en.md) is Cloud's client for the Git Server's read-only ref query (`GET /internal/v1/refs`), verifying outside any transaction that a ref Cloud is about to register exists and names the declared commit. - [repoexec](repoexec/README.en.md) is the only Ora program that contacts external Git repositories: a fresh bare repository per task, sync/publish/probe execution, reporting observed facts while Cloud decides the Operation's state. - [gitrefs](gitrefs/README.en.md) is the client of the Git Server's read-only ref query (contract D6): TLS 1.3 mutual authentication, bounded answers, and an unavailable server kept distinct from a missing ref. - [repoworker](repoworker/README.en.md) drives repository Operations: reading the tracking ref, signing the one-time authorization, renewing and losing leases, and settling syncs and publishes against the lease generation. diff --git a/internal/README.md b/internal/README.md index 295ce276e..f20b53f03 100644 --- a/internal/README.md +++ b/internal/README.md @@ -19,6 +19,7 @@ - [objectstore](objectstore/README.md):Cloud 自己的 S3 兼容对象存储客户端(SigV4 预签上传授权、HEAD 校验对象的大小与 SHA-256),并支持归档与工作流资产读取。 - [gitpreset](gitpreset/README.md):仅用于验收的 Git Server 契约预置与断言,直接写仓库卷与数据库,不被服务进程引用。 - [gitserver](gitserver/README.md):Project 仓库的 Git 访问:沙盒与 Repo Executor 两个仓库监听、按 Core 逐请求授权的 ref 规则集、`git http-backend`、pre-receive 按 namespace 执行规则与 Cloud 的 ref 查询。 +- [gitrefquery](gitrefquery/README.md):Cloud 访问 Git Server 只读 ref 查询(`GET /internal/v1/refs`)的客户端,在事务外核对要登记的 ref 存在且指向声明的提交。 - [repoexec](repoexec/README.md):Ora 中唯一访问外部 Git 仓库的程序:按一次任务新建裸仓库、执行同步/发布/探测,只报告观察到的事实,Operation 状态由 Cloud 决定。 - [gitrefs](gitrefs/README.md):Git Server 只读 ref 查询的客户端(契约 D6):TLS 1.3 双向认证、有界应答,把「服务器不可用」与「ref 不存在」分开。 - [repoworker](repoworker/README.md):驱动仓库 Operation 的工作进程:读跟踪 ref、签发一次性授权、续租与取消、按租约世代收敛同步与发布结果。 diff --git a/internal/api/router/README.en.md b/internal/api/router/README.en.md index 5e6d6f1e5..4347605ab 100644 --- a/internal/api/router/README.en.md +++ b/internal/api/router/README.en.md @@ -6,7 +6,7 @@ ## Package processing -- Establish `X-Request-Id` and logging context. Health checks read database readiness and object-store configuration, without probing S3. +- Establish `X-Request-Id` and logging context. Health checks read database readiness and the object-store and Git Server configuration, without probing S3 or the Git Server. - Verify service/user credentials and caller binding; validate public fields using `Route.Fields` and `validField`. - The default body cap is 64 KiB. `ThreadMessagePath` and `TaskConversationMessagesPath` have a separate 256 KiB cap to contain a legal message's JSON envelope; core still limits decoded text to 64 KiB. - The JSON decoder uses `UseNumber` and rejects unknown fields, extra JSON values and invalid types; query parsing follows each read entrypoint's constraints. diff --git a/internal/api/router/README.md b/internal/api/router/README.md index 462562479..c93877a3f 100644 --- a/internal/api/router/README.md +++ b/internal/api/router/README.md @@ -6,7 +6,7 @@ ## 包内处理顺序 -- 为请求建立 `X-Request-Id` 与日志上下文;健康检查读取数据库就绪与对象存储配置状态,不探测 S3。 +- 为请求建立 `X-Request-Id` 与日志上下文;健康检查读取数据库就绪与对象存储、Git Server 的配置状态,不探测 S3 或 Git Server。 - 验证服务/用户凭据及 caller 绑定;公开字段由 `Route.Fields` 白名单与 `validField` 校验。 - 默认请求体上限为 64 KiB;`ThreadMessagePath` 与 `TaskConversationMessagesPath` 单独使用 256 KiB,以容纳合法消息的 JSON 封装;正文仍由领域核心限制为 64 KiB。 - JSON decoder 使用 `UseNumber`,拒绝未知字段、额外 JSON 值和非法类型;查询解析按对应读取入口约束。 diff --git a/internal/api/router/router.go b/internal/api/router/router.go index aafa17ca5..434e2a65b 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -234,9 +234,6 @@ func Routes() []Route { {"POST", "/api/v1/tenants/:tid/workspaces/:wid/start", "", []string{"version", "sessionId"}}, {"POST", "/api/v1/tenants/:tid/workspaces/:wid/stop", "", []string{"version", "sessionId"}}, {"DELETE", "/api/v1/tenants/:tid/workspaces/:wid", "", []string{"version", "sessionId"}}, - {"GET", "/api/v1/tenants/:tid/clones", "", nil}, - {"POST", "/api/v1/tenants/:tid/clones", "", []string{"requestId", "repository", "branch"}}, - {"GET", "/api/v1/tenants/:tid/clones/:cloneId", "", nil}, {"GET", "/api/v1/tenants/:tid/operations/:oid", "", nil}, {"POST", "/api/v1/tenants/:tid/operations/:oid/retry", "", []string{"version"}}, {"GET", "/api/v1/tenants/:tid/resource-status", "", nil}, @@ -304,11 +301,13 @@ func newRouter(store *core.Store, auth *core.Authenticator, log *zap.Logger, leg failure(c, &core.Fault{Code: "database_unavailable", Status: 503, Params: core.Object{}}) return } - storage := "unconfigured" - if store.ObjectStore != nil { - storage = "configured" + configured := func(ok bool) string { + if ok { + return "configured" + } + return "unconfigured" } - c.JSON(200, gin.H{"status": "ok", "dependencies": gin.H{"objectStore": storage}}) + c.JSON(200, gin.H{"status": "ok", "dependencies": gin.H{"objectStore": configured(store.ObjectStore != nil), "gitServer": configured(store.GitServer != nil)}}) }) for _, route := range Routes() { r.Handle(route.Method, route.Path, func(c *gin.Context) { @@ -459,7 +458,7 @@ func newRouter(store *core.Store, auth *core.Authenticator, log *zap.Logger, leg return } } - req := &core.PublicRequest{Method: c.Request.Method, Path: c.Request.URL.Path, TenantID: c.Param("tid"), ProjectID: c.Param("pid"), WorkspaceID: c.Param("wid"), SpaceID: c.Param("spaceId"), OperationID: c.Param("oid"), CloneID: c.Param("cloneId"), UserID: c.Param("uid"), IssueID: c.Param("iid"), CommentID: c.Param("cid"), ConversationMessageID: c.Param("mid"), LabelID: c.Param("lid"), StatusID: c.Param("sid"), ViewID: c.Param("vid"), RunID: c.Param("rid"), ContextRefID: c.Param("crid"), InteractionID: c.Param("ixid"), WorkflowID: c.Param("wfid"), SnapshotID: c.Param("snapshotId"), FormRef: c.Param("formRef"), FormIssueID: c.Query("issueId"), ModelConnectionID: c.Param("mcid"), SlotName: c.Param("slot"), GitCredentialID: c.Param("gcid"), RepositoryOperationID: c.Param("roid"), InvitationID: c.Param("iid"), JoinLinkID: c.Param("lid"), JoinRequestID: c.Param("rid"), Key: c.GetHeader("Idempotency-Key"), SessionProjectID: c.Query("projectId"), SessionStatus: c.Query("status"), SessionAgent: c.Query("agent"), SessionFrom: c.Query("from"), SessionTo: c.Query("to"), Limit: limit, After: c.Query("after"), Before: c.Query("before"), Query: c.Query("q"), GroupBy: c.Query("by"), Body: body, Identity: user, Person: person} + req := &core.PublicRequest{Method: c.Request.Method, Path: c.Request.URL.Path, TenantID: c.Param("tid"), ProjectID: c.Param("pid"), WorkspaceID: c.Param("wid"), SpaceID: c.Param("spaceId"), OperationID: c.Param("oid"), UserID: c.Param("uid"), IssueID: c.Param("iid"), CommentID: c.Param("cid"), ConversationMessageID: c.Param("mid"), LabelID: c.Param("lid"), StatusID: c.Param("sid"), ViewID: c.Param("vid"), RunID: c.Param("rid"), ContextRefID: c.Param("crid"), InteractionID: c.Param("ixid"), WorkflowID: c.Param("wfid"), SnapshotID: c.Param("snapshotId"), FormRef: c.Param("formRef"), FormIssueID: c.Query("issueId"), ModelConnectionID: c.Param("mcid"), SlotName: c.Param("slot"), GitCredentialID: c.Param("gcid"), RepositoryOperationID: c.Param("roid"), InvitationID: c.Param("iid"), JoinLinkID: c.Param("lid"), JoinRequestID: c.Param("rid"), Key: c.GetHeader("Idempotency-Key"), SessionProjectID: c.Query("projectId"), SessionStatus: c.Query("status"), SessionAgent: c.Query("agent"), SessionFrom: c.Query("from"), SessionTo: c.Query("to"), Limit: limit, After: c.Query("after"), Before: c.Query("before"), Query: c.Query("q"), GroupBy: c.Query("by"), Body: body, Identity: user, Person: person} if c.Param("pluginNamespace") != "" { req.AgentPluginID = c.Param("pluginNamespace") + "/" + c.Param("pluginIdentifier") } diff --git a/internal/config/config.go b/internal/config/config.go index 7b8f0083f..50ea02a47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { IssueRuns IssueRunsConfig `mapstructure:"issue_runs"` Workflow WorkflowConfig `mapstructure:"workflow"` ObjectStore ObjectStoreConfig `mapstructure:"object_store"` + GitServer GitServerConfig `mapstructure:"git_server"` Repository RepositoryConfig `mapstructure:"repository"` } diff --git a/internal/config/git_server.go b/internal/config/git_server.go new file mode 100644 index 000000000..4809aaf8d --- /dev/null +++ b/internal/config/git_server.go @@ -0,0 +1,69 @@ +package config + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + "time" + + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/gitrefquery" +) + +// DefaultGitRefQueryTimeout bounds one ref query when git_server.ref_query_timeout is unset. +const DefaultGitRefQueryTimeout = 10 * time.Second + +// GitServerConfig is how Cloud uses the deployment's Git Server (Revision D2): the repository base +// URL sandboxes reach, written into clone, session and delivery inputs, and the ref query listener +// Cloud verifies refs through with its own pinned client identity. The private key stays a file +// reference; it never enters a configuration value, a log or the database. Leaving +// repository_base_url empty runs Cloud without a Git Server: clone steps block as +// git_server_unconfigured and deliveries are skipped. CLOUD_GIT_SERVER_* overrides each key. +type GitServerConfig struct { + RepositoryBaseURL string `mapstructure:"repository_base_url"` + RefQueryURL string `mapstructure:"ref_query_url"` + CAFile string `mapstructure:"ca_file"` + ClientCertFile string `mapstructure:"client_cert_file"` + ClientKeyFile string `mapstructure:"client_key_file"` + RefQueryTimeout time.Duration `mapstructure:"ref_query_timeout"` +} + +// Open validates the section and loads its files. It performs no network call: a Git Server that is +// unreachable or presents another certificate makes verification unavailable, never startup fail. +func (c *GitServerConfig) Open() (*core.GitServer, error) { + if c.RepositoryBaseURL == "" { + return nil, nil + } + if !core.ValidGitRepositoryBaseURL(c.RepositoryBaseURL) { + return nil, fmt.Errorf("git_server.repository_base_url must be https without userinfo, query, fragment or trailing slash") + } + if c.RefQueryURL == "" || c.CAFile == "" || c.ClientCertFile == "" || c.ClientKeyFile == "" { + return nil, fmt.Errorf("git_server.ref_query_url, ca_file, client_cert_file and client_key_file are required with repository_base_url") + } + timeout := c.RefQueryTimeout + if timeout <= 0 { + timeout = DefaultGitRefQueryTimeout + } + pair, err := tls.LoadX509KeyPair(c.ClientCertFile, c.ClientKeyFile) + if err != nil { + return nil, fmt.Errorf("load git_server client certificate: %w", err) + } + pem, err := os.ReadFile(c.CAFile) + if err != nil { + return nil, fmt.Errorf("load git_server CA: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("invalid git_server CA") + } + transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: roots, Certificates: []tls.Certificate{pair}}} + // Redirects are never followed: the pinned listener answers itself or not at all. + client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + refs, err := gitrefquery.New(c.RefQueryURL, client, timeout) + if err != nil { + return nil, fmt.Errorf("git_server.ref_query_url: %w", err) + } + return &core.GitServer{RepositoryBaseURL: c.RepositoryBaseURL, Refs: refs}, nil +} diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index dd21dcf91..7e19e7f49 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -189,7 +189,8 @@ func Document() map[string]any { runProps["preparation"] = optional(object(preparationProps, preparationFields...)) // revision is the run's registered Revision as metadata only (Cloud Revision D5): no object key, // ref, digest or URL is public. Null until a Revision is registered, and for every non-agent run. - // `changed` says whether the run stored a bundle of its own; `priorRevisionId` is the Revision it + // `changed` is Cloud's verification verdict that the run delivered new commits, and `bundleSize` + // is null for a Revision delivered over Git (Revision D8); `priorRevisionId` is the Revision it // resumed (issue-run resume decision D4, D5). runProps["revision"] = optional(object(obj{"id": uuid(), "baseCommit": str(), "finalCommit": str(), "changed": boolean(), "bundleSize": optional(number()), "historySize": number(), "priorRevisionId": optional(uuid()), "createdAt": timestamp()}, "id", "baseCommit", "finalCommit", "changed", "bundleSize", "historySize", "priorRevisionId", "createdAt")) // resumeRevisionId is the Revision an agent run resumes, fixed at session start; null otherwise. @@ -325,7 +326,7 @@ func Document() map[string]any { s["PluginUniversalRelease"] = object(obj{"url": str(), "sha256": str()}, "url", "sha256") s["PluginReleaseTarget"] = object(obj{"target": str(), "url": str(), "sha256": str()}, "target", "url", "sha256") s["PluginLogoCandidate"] = object(obj{"role": enumeration("universal", "light", "dark"), "extension": enumeration("svg", "png", "webp", "jpg", "jpeg")}, "role", "extension") - s["OperationRequest"] = object(obj{"previous": obj{"type": "object", "additionalProperties": ref("Workspace")}, "syncOwed": boolean(), "pluginId": str(), "version": str(), "desiredRevision": number(), "release": ref("PluginCatalogEntry"), "plugins": array(obj{"type": "object", "additionalProperties": true}), "pluginMeta": array(object(obj{"pluginId": str(), "desiredRevision": number()}, "pluginId", "desiredRevision"))}) + s["OperationRequest"] = object(obj{"previous": obj{"type": "object", "additionalProperties": ref("Workspace")}, "syncOwed": boolean(), "pluginId": str(), "version": str(), "desiredRevision": number(), "release": ref("PluginCatalogEntry"), "plugins": array(obj{"type": "object", "additionalProperties": true}), "pluginMeta": array(object(obj{"pluginId": str(), "desiredRevision": number()}, "pluginId", "desiredRevision")), "clone": ref("CloneInput")}) s["OperationResult"] = object(obj{"resourceId": uuid(), "forceStopId": uuid()}) s["Operation"] = resource("id tenantId actorUserId projectId workspaceId kind state step request result errorCode idempotencyKey requestHash controllerEpoch retryAt version createdAt updatedAt", "workspaceId errorCode controllerEpoch retryAt") opProps := properties(s, "Operation") @@ -361,14 +362,16 @@ func Document() map[string]any { ep["externalId"] = optional(str()) ep["request"] = ref("EffectRequest") ep["result"] = ref("EffectResult") - s["ControllerProject"] = resource("id tenantId ownerUserId spaceId name repositoryUrl defaultBranch credentialRefId lifecycle version createdAt deletedAt secretRef", "credentialRefId deletedAt secretRef") + // The Controller sees the Project's identity and lifecycle only: its external repository and + // credential reference stay in Cloud, and a clone step carries its frozen cloneInput instead. + s["ControllerProject"] = resource("id tenantId ownerUserId spaceId name lifecycle version createdAt deletedAt", "deletedAt") s["ControllerWorkspace"] = resource("id tenantId ownerUserId projectId kind desiredState observedState runtimeGeneration version admissionOpen admissionEpoch createdAt deletedAt requestedRef baseCommitId creatorUserId creatorOperationId creatorEvidence", "deletedAt baseCommitId creatorUserId creatorOperationId") properties(s, "ControllerWorkspace")["issueRunId"] = optional(uuid()) for _, name := range []string{"Workspace", "WorkspaceListItem", "ControllerWorkspace"} { properties(s, name)["baseCommitId"] = optional(obj{"type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$"}) } // A Workspace operation's clone executions, registered through the gRPC ExecutionService. - s["CloneExecution"] = resource("executionId operationId workspaceId cloneRequestId nodeId input result dispatchedEpoch createdAt updatedAt nodeOperationId terminatedByForceStopId", "workspaceId cloneRequestId result terminatedByForceStopId") + s["CloneExecution"] = resource("executionId operationId workspaceId nodeId input result dispatchedEpoch createdAt updatedAt nodeOperationId terminatedByForceStopId", "result terminatedByForceStopId") ce := properties(s, "CloneExecution") ce["credentialRefId"] = optional(uuid()) ce["credentialRefVersion"] = optional(number()) @@ -390,19 +393,14 @@ func Document() map[string]any { ne["result"] = optional(obj{"type": "object", "additionalProperties": true}) ne["dispatchedEpoch"] = number() ne["lastEventSequence"] = number() - s["Snapshot"] = object(obj{"operation": ref("Operation"), "project": ref("ControllerProject"), "workspaces": array(ref("ControllerWorkspace")), "sandboxes": array(ref("Sandbox")), "nodes": array(ref("Node")), "effects": array(ref("Effect")), "clones": array(ref("CloneExecution")), "pluginExecutions": array(ref("NodeExecution")), "pluginInput": obj{"type": "object", "additionalProperties": true}}, "operation", "project", "workspaces", "sandboxes", "nodes", "effects", "clones", "pluginExecutions") + s["Snapshot"] = object(obj{"operation": ref("Operation"), "project": ref("ControllerProject"), "workspaces": array(ref("ControllerWorkspace")), "sandboxes": array(ref("Sandbox")), "nodes": array(ref("Node")), "effects": array(ref("Effect")), "clones": array(ref("CloneExecution")), "pluginExecutions": array(ref("NodeExecution")), "pluginInput": obj{"type": "object", "additionalProperties": true}, "cloneInput": ref("CloneInput")}, "operation", "project", "workspaces", "sandboxes", "nodes", "effects", "clones", "pluginExecutions") s["EmptyClaim"] = object(obj{"operation": obj{"type": "object", "nullable": true, "enum": []any{nil}}}, "operation") s["Access"] = object(obj{"userId": uuid(), "tenantId": uuid(), "workspaceId": uuid(), "allowedAction": enumeration("read", "execute"), "executable": boolean(), "runtimeGeneration": number()}, "userId", "tenantId", "workspaceId", "allowedAction", "executable", "runtimeGeneration") s["IdleRefusal"] = object(obj{"accepted": boolean(), "errorCode": enumeration("resource_in_use")}, "accepted", "errorCode") - // Clone requests mirror the transitional Controller DTO: the tagged state carries the terminal - // fact, and identities assigned at dispatch are null until a Controller records it. - s["CloneState"] = object(obj{"kind": enumeration("pending", "succeeded", "failed"), "path": str(), "commit": str(), "reason": enumeration("sourceUnavailable", "branchNotFound", "destinationConflict", "operationFailed", "interrupted", "unspecified"), "retainedPath": str()}, "kind") - cloneProps := fields("operationId createdAt updatedAt") - for _, name := range []string{"requestId", "repository", "branch"} { - cloneProps[name] = str() - } - cloneProps["executionId"], cloneProps["nodeId"], cloneProps["state"] = optional(str()), optional(str()), ref("CloneState") - s["CloneOperation"] = object(cloneProps, "operationId", "requestId", "repository", "branch", "executionId", "nodeId", "state", "createdAt", "updatedAt") + // The clone step's input, frozen from the Binding's current snapshot before a Controller can see + // the step and forwarded verbatim: a Git Server repository, a snapshot ref, its commit and the + // Workspace's branch there. It names no external repository and no credential. + s["CloneInput"] = object(obj{"kind": enumeration("clone"), "repositoryUrl": str(), "snapshotRef": str(), "baseCommit": obj{"type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$"}, "workspaceRef": str()}, "kind", "repositoryUrl", "snapshotRef", "baseCommit", "workspaceRef") repositorySchemas(s) paths := obj{} for _, r := range router.Routes() { @@ -520,7 +518,7 @@ func Document() map[string]any { "404": obj{"description": errorDescription("404"), "content": obj{"application/json": obj{"schema": ref("Error")}}}, }, }} - paths["/healthz"] = obj{"get": obj{"operationId": "health", "tags": []string{"health"}, "summary": "PostgreSQL readiness and optional dependency configuration", "responses": obj{"200": obj{"description": "Database reachable", "content": obj{"application/json": obj{"schema": object(obj{"status": enumeration("ok"), "dependencies": object(obj{"objectStore": enumeration("configured", "unconfigured")}, "objectStore")}, "status")}}}, "503": obj{"description": "Database unavailable", "content": obj{"application/json": obj{"schema": ref("Error")}}}}}} + paths["/healthz"] = obj{"get": obj{"operationId": "health", "tags": []string{"health"}, "summary": "PostgreSQL readiness and optional dependency configuration", "responses": obj{"200": obj{"description": "Database reachable", "content": obj{"application/json": obj{"schema": object(obj{"status": enumeration("ok"), "dependencies": object(obj{"objectStore": enumeration("configured", "unconfigured"), "gitServer": enumeration("configured", "unconfigured")}, "objectStore", "gitServer")}, "status")}}}, "503": obj{"description": "Database unavailable", "content": obj{"application/json": obj{"schema": ref("Error")}}}}}} paths["/api/v1/tenants/{tid}/issues/{iid}/runs/{rid}/thread/preview/events"] = obj{"get": obj{ "operationId": "getThreadPreviewEvents", "tags": []string{"tenants"}, "summary": "Stream ephemeral Thread replacement snapshots", "description": "Bounded optional previews, never durable history. Credentials and active membership are checked before each snapshot; terminal records supersede previews. Disconnect does not stop the Agent.", "parameters": []any{obj{"name": "tid", "in": "path", "required": true, "schema": uuid()}, obj{"name": "iid", "in": "path", "required": true, "schema": uuid()}, obj{"name": "rid", "in": "path", "required": true, "schema": uuid()}}, @@ -560,8 +558,6 @@ func tag(r router.Route) string { return "repository" case strings.HasPrefix(r.Path, "/api/v1/me"): return "me" - case strings.Contains(r.Path, "/clones"): - return "clones" case strings.Contains(r.Path, "/spaces"): return "spaces" case strings.Contains(r.Path, "/workflows"): @@ -593,7 +589,7 @@ func isList(r router.Route) bool { if r.Path == "/api/v1/me/model-connections" || r.Path == router.AgentSessionsPath || r.Path == router.SessionModelCallsPath || r.Path == router.WorkflowModelCallsPath || r.Path == "/api/v1/me/executor-slot-mappings" { return r.Method == "GET" } - return r.Method == "GET" && (strings.HasSuffix(r.Path, "/tenants") || strings.HasSuffix(r.Path, "/members") || strings.HasSuffix(r.Path, "/projects") || strings.HasSuffix(r.Path, "/workspaces") || strings.HasSuffix(r.Path, "/spaces") || strings.HasSuffix(r.Path, "/resource-status") || strings.HasSuffix(r.Path, "/issue-statuses") || strings.HasSuffix(r.Path, "/labels") || strings.HasSuffix(r.Path, "/issue-views") || strings.HasSuffix(r.Path, "/workflows") || strings.HasSuffix(r.Path, "/snapshots") || strings.HasSuffix(r.Path, "/runs") || strings.HasSuffix(r.Path, "/comments") || strings.HasSuffix(r.Path, "/subscribers") || strings.HasSuffix(r.Path, "/invitations") || strings.HasSuffix(r.Path, "/join-links") || strings.HasSuffix(r.Path, "/join-requests") || strings.HasSuffix(r.Path, "/clones")) + return r.Method == "GET" && (strings.HasSuffix(r.Path, "/tenants") || strings.HasSuffix(r.Path, "/members") || strings.HasSuffix(r.Path, "/projects") || strings.HasSuffix(r.Path, "/workspaces") || strings.HasSuffix(r.Path, "/spaces") || strings.HasSuffix(r.Path, "/resource-status") || strings.HasSuffix(r.Path, "/issue-statuses") || strings.HasSuffix(r.Path, "/labels") || strings.HasSuffix(r.Path, "/issue-views") || strings.HasSuffix(r.Path, "/workflows") || strings.HasSuffix(r.Path, "/snapshots") || strings.HasSuffix(r.Path, "/runs") || strings.HasSuffix(r.Path, "/comments") || strings.HasSuffix(r.Path, "/subscribers") || strings.HasSuffix(r.Path, "/invitations") || strings.HasSuffix(r.Path, "/join-links") || strings.HasSuffix(r.Path, "/join-requests")) } func responseSchema(r router.Route) (schema obj, status string) { @@ -896,15 +892,6 @@ func responseSchema(r router.Route) (schema obj, status string) { return object(obj{"items": array(ref("Issue")), "nextCursor": str()}, "items", "nextCursor"), "200" } return ref("Issue"), "200" - case strings.Contains(r.Path, "/clones"): - switch { - case isList(r): - return object(obj{"items": array(ref("CloneOperation")), "nextCursor": str()}, "items", "nextCursor"), "200" - case r.Method == "GET": - return ref("CloneOperation"), "200" - default: - return ref("CloneOperation"), "202" - } case r.Path == "/api/v1/tenants" && r.Method == "POST": return ref("TenantCreated"), "201" } @@ -1175,13 +1162,13 @@ func description(r router.Route) string { case "lease_acquire", "lease_renew", "lease_release": return "Controller subject is holderId. Global lease lasts 30 seconds using PostgreSQL clock_timestamp(); renew every 10 seconds. Expired acquisition increments epoch, active same-holder acquisition returns current lease. Release and renew require exact live holder+epoch." case "claim": - return base + "Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay." + return base + "Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay. A clone step is handed out only with its cloneInput, frozen once per operation from the Project's current Binding snapshot; without a snapshot the operation is parked as retry_wait snapshot_not_ready while the Binding's latest sync is unfinished, fails as repository_sync_failed once it ended unsuccessfully, and is blocked as git_server_unconfigured without a Git Server. delete_project is not handed out to quiesce while a sync or publish of the Project is unfinished (retry_wait repository_operation_running). A parked operation never empties the claim: the next claimable one is returned." case "plan": return base + "Only the effect kind appropriate to the current step is allowed. Scope is restricted to operation workspaces. Plan persists BEFORE dispatch; sandbox plan atomically increments generation and allocates a unique live instance. Old instance must be confirmed terminated. Same plan returns the same effect ID." case "effect_result": return base + "Reports/reconciles one scoped external effect. External ID cannot change; succeeded evidence is immutable. absent is allowed only for a planned effect. Sandbox success requires its preallocated instance ID and the nodeId its Node will present; termination requires terminated; Workspace data deletion requires removed. This endpoint trusts the authenticated controller's Substrate observation, not client-supplied status." case "advance": - return base + "Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. The clone step requires the operation's latest clone execution (registered over gRPC) to have succeeded on the current Node; it records the baseline commit and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation." + return base + "Derives the next step server-side. Requires current-epoch successful effects. Create goes sandbox, node, clone; start goes sandbox, node. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. Entering the clone step freezes its cloneInput, or parks or fails the operation as claim does. The clone step requires the operation's latest clone execution (registered over gRPC with exactly the frozen input) to have succeeded on the current Node with exactly the input's baseCommit; it records that commit as the baseline and commits Workspace Ready/admission with operation success, re-checking the fresh initialized current Node. start commits the same readiness at its node step. Workspace data deletion is planned and completed only after termination confirmation." case "defer": return base + "Preserves operation/effect/resource references and current step; sets blocked or retry_wait with bounded retry delay. Never reports cleanup success on timeout." case "node_register", "node_status", "node_idle", "node_finish": @@ -1208,9 +1195,6 @@ func description(r router.Route) string { if r.Path == "/api/v1/tenants" && r.Method == "POST" { base = "Public requests require a gateway service credential plus a caller-bound user credential. The verified identity authorizes self-service provisioning without prior membership. Atomically creates a tenant and its sole visible collaboration space with the same name and the given globally unique, immutable slug; the caller becomes its first administrator. The idempotency key is matched per user across tenants and recorded under the new tenant. " } - if strings.Contains(r.Path, "/clones") { - base += "Unscoped clone submission is retired in production and returns 410 runtime_scope_required; existing requests remain readable by their original submitter. An explicit development store can exercise the legacy coordination fixture without enabling a production bypass. requestId is the caller's durable request identity: repeating it with the same repository and branch returns the original request, a different input is 409 idempotency_conflict. repository must be an https or ssh URL the Controller can clone; branch is a short branch name, never HEAD. executionId and nodeId are null until a dispatch is recorded; a pending state means awaiting reconciliation, never failure. " - } if strings.HasSuffix(r.Path, "/thread/messages") { // The Thread POST's own fault vocabulary. It is stated here rather than added to the shared // per-status descriptions, which every unrelated route also carries. @@ -1260,10 +1244,10 @@ func description(r router.Route) string { base += "Requires matching resource version and no active project operation. Atomically closes new execution admission. Active tickets return 409 resource_in_use without changing admission. Unknown Node activity requires later proof and remains pending/blocked. main Workspace cannot be independently deleted. " } if strings.HasSuffix(r.Path, "/projects") && r.Method == "POST" { - base += "Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous. " + base += "Creates Project/main Workspace/operation atomically in the tenant's sole collaboration space. repositoryUrl must be HTTPS with no user information, query or fragment; it becomes the Project's Binding together with defaultBranch, the one branch phase one synchronizes (required, a branch name, not HEAD). credentialRefId is optional (public repositories need none); a personal credential may be bound only by its owner and a team one only by an admin, and it must be stored, available and scoped to the repository. The same transaction records the first sync, which must succeed before any Workspace can clone. Sandbox, Node and clone initialization is asynchronous; the main Workspace clones the Binding's first snapshot and waits for it. " } if strings.HasSuffix(r.Path, "/workspaces") && r.Method == "POST" { - base += "Creates one isolated Workspace and Task display identity. title/baseRef required; baseRef becomes the Workspace's requestedRef, which its Node clones; HEAD means the Project's defaultBranch. " + base += "Creates one isolated Workspace and Task display identity. title is required. The Workspace clones the Project's current snapshot of its Binding branch: baseRef may be omitted, empty, HEAD or that branch, and any other value is 400 base_ref_not_synced with nothing recorded. The branch becomes the Workspace's requestedRef, the short name of its Git Server branch refs/heads/workspaces//. " } pagination := "Lists use ascending UUID pagination." if r.Path == "/api/v1/me/tenants" { diff --git a/internal/contract/repository.go b/internal/contract/repository.go index 89fb59ef5..36954824a 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -133,7 +133,7 @@ func repositoryDescription(r router.Route) string { case strings.Contains(r.Path, "/git-credentials"): return "Git credential reference metadata, visible to tenant members. Never contains the token." case r.Path == router.PublishPath: - return "Creates a PublishOperation: fast-forward targetBranch of the Binding's repository from expectedOldOid (40/64 zeros creates the branch) to sourceCommit, taken from sourceRef (any Workspace branch or delivery ref of this Project, confirmed through the Git Server ref query) or from revisionId. Fields are immutable. A personal credential is approved by its owner only, a team credential by a tenant admin; when the requester is the approver it starts pending. The credential must have proven publish (409 credential_publish_unverified). 400 nothing_to_publish when expectedOldOid equals sourceCommit; 409 source_ref_not_found." + return "Creates a PublishOperation: fast-forward targetBranch of the Binding's repository from expectedOldOid (40/64 zeros creates the branch) to sourceCommit, taken from sourceRef (any Workspace branch or delivery ref of this Project, confirmed through the Git Server ref query) or from revisionId (the Revision's delivery ref and final commit verified when it was registered; 409 revision_not_publishable for a Revision without a delivery ref). Fields are immutable. A personal credential is approved by its owner only, a team credential by a tenant admin; when the requester is the approver it starts pending. The credential must have proven publish (409 credential_publish_unverified). 400 nothing_to_publish when expectedOldOid equals sourceCommit; 409 source_ref_not_found." case r.Path == router.WorkspaceBranchesPath: return "Lists one Workspace's branches (refs/heads/workspaces//*) of the Project repository through the Git Server ref query, for choosing a publish source. The answer is the query's moment only." case strings.HasSuffix(r.Path, "/repository/syncs"): diff --git a/internal/controlgrpc/agent_runs.go b/internal/controlgrpc/agent_runs.go index eb22d3872..5c44a7bf9 100644 --- a/internal/controlgrpc/agent_runs.go +++ b/internal/controlgrpc/agent_runs.go @@ -100,20 +100,6 @@ func (s *agentRunService) GrantRevisionUpload(ctx context.Context, req *controlp return &controlpb.GrantRevisionUploadResponse{Grants: grants}, nil } -// GrantRevisionDownload signs the read of a session execution's prior Revision bundle. -func (s *agentRunService) GrantRevisionDownload(ctx context.Context, req *controlpb.GrantRevisionDownloadRequest) (*controlpb.GrantRevisionDownloadResponse, error) { - out, e := control(ctx, s.store, "grant_revision_download", "", "", core.Object{"epoch": req.GetEpoch(), "executionId": req.GetExecutionId()}) - if e != nil { - return nil, e - } - grants := make([]*controlpb.DownloadGrant, 0) - for _, row := range rows(out["grants"]) { - upload := grantMessage(row) - grants = append(grants, &controlpb.DownloadGrant{ObjectKey: upload.GetObjectKey(), Url: upload.GetUrl(), Method: upload.GetMethod(), Headers: upload.GetHeaders(), ExpiresAt: upload.GetExpiresAt()}) - } - return &controlpb.GrantRevisionDownloadResponse{Grants: grants}, nil -} - func threadCommand(row core.Object) *controlpb.ThreadCommand { cmd := &controlpb.ThreadCommand{ CommandId: row.S("commandId"), RunId: row.S("runId"), ExecutionId: row.S("executionId"), diff --git a/internal/controlgrpc/execution_codec.go b/internal/controlgrpc/execution_codec.go index 0f9309bc2..c8eb3d58a 100644 --- a/internal/controlgrpc/execution_codec.go +++ b/internal/controlgrpc/execution_codec.go @@ -2,10 +2,10 @@ package controlgrpc import ( "math" + "net/url" + "strings" "time" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" "github.com/wanglongan587/cloud/internal/controlpb" @@ -17,14 +17,11 @@ import ( func inputObject(in *controlpb.ExecutionInput) (core.Object, error) { if in == nil { - return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") + return nil, invalidInput("invalid_dispatch") } switch spec := in.GetSpec().(type) { case *controlpb.ExecutionInput_Clone: - if spec.Clone.GetRepository() == "" || spec.Clone.GetBranch() == "" { - return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") - } - return core.Object{"kind": "clone", "repositoryUrl": spec.Clone.GetRepository(), "branch": spec.Clone.GetBranch()}, nil + return cloneObject(spec.Clone) case *controlpb.ExecutionInput_InstallPlugins: return core.Object{"kind": "install_plugins", "plugins": installObjects(spec.InstallPlugins.GetPlugins())}, nil case *controlpb.ExecutionInput_RemovePlugins: @@ -34,7 +31,7 @@ func inputObject(in *controlpb.ExecutionInput) (core.Object, error) { case *controlpb.ExecutionInput_DeliverRevision: return deliveryObject(spec.DeliverRevision) default: - return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") + return nil, invalidInput("invalid_dispatch") } } @@ -49,10 +46,35 @@ func input(o core.Object) *controlpb.ExecutionInput { case "deliver_revision": return &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_DeliverRevision{DeliverRevision: deliveryMessage(o)}} default: - return &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{Repository: o.S("repositoryUrl"), Branch: o.S("branch")}}} + return &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: cloneMessage(o)}} } } +// cloneObject is the durable form of a Workspace clone input: a Git Server snapshot. Its shape is +// checked here so a malformed input never reaches the registry, where it could only conflict with +// the operation's frozen input (contract D1). +func cloneObject(spec *controlpb.CloneSpec) (core.Object, error) { + snapshot, snapshotOK := core.ParseGitRef(spec.GetSnapshotRef()) + workspace, workspaceOK := core.ParseGitRef(spec.GetWorkspaceRef()) + if !gitRepositoryURL(spec.GetRepositoryUrl()) || !core.GitObjectID(spec.GetBaseCommit()) || + !snapshotOK || snapshot.Namespace != core.GitUpstreamSnapshot || snapshot.Name != spec.GetBaseCommit() || + !workspaceOK || workspace.Namespace != core.GitWorkspaceBranch || workspace.Name == "HEAD" { + return nil, invalidInput("invalid_dispatch") + } + return core.Object{"kind": "clone", "repositoryUrl": spec.GetRepositoryUrl(), "snapshotRef": spec.GetSnapshotRef(), "baseCommit": spec.GetBaseCommit(), "workspaceRef": spec.GetWorkspaceRef()}, nil +} + +func cloneMessage(o core.Object) *controlpb.CloneSpec { + return &controlpb.CloneSpec{RepositoryUrl: o.S("repositoryUrl"), SnapshotRef: o.S("snapshotRef"), BaseCommit: o.S("baseCommit"), WorkspaceRef: o.S("workspaceRef")} +} + +// gitRepositoryURL accepts the Git Server repository addresses Cloud writes into inputs: https, +// with a host, and no userinfo, query or fragment for a credential or option to hide in. +func gitRepositoryURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && strings.HasSuffix(u.Path, ".git") +} + func installObjects(plugins []*controlpb.PluginInstall) []core.Object { out := make([]core.Object, 0, len(plugins)) for _, plugin := range plugins { @@ -111,7 +133,7 @@ func removalMessages(plugins []core.Object) []*controlpb.PluginRemoval { func sessionObject(spec *controlpb.AgentSessionSpec) (core.Object, error) { if spec.GetAgentPluginId() == "" || spec.GetCheckoutExecutionId() == "" || spec.GetGitIdentity().GetName() == "" { - return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") + return nil, invalidInput("invalid_dispatch") } turn := spec.GetInitialTurn() out := core.Object{ @@ -126,14 +148,26 @@ func sessionObject(spec *controlpb.AgentSessionSpec) (core.Object, error) { if prior := spec.GetPriorRevision(); prior != nil { out["priorRevision"] = priorObject(prior) } + if listings := spec.GetWorkspaceDirectory(); len(listings) > 0 { + directory := make([]core.Object, 0, len(listings)) + for _, l := range listings { + listing := core.Object{"workspaceId": l.GetWorkspaceId(), "kind": l.GetKind()} + if l.GetIssueRunId() != "" { + listing["issueId"], listing["issueTitle"], listing["issueRunId"], listing["runStatus"] = l.GetIssueId(), l.GetIssueTitle(), l.GetIssueRunId(), l.GetRunStatus() + } + directory = append(directory, listing) + } + out["workspaceDirectory"] = directory + } return out, nil } -// priorObject is the durable form of a PriorRevision; a delivery spec's has no bundle. +// priorObject is the durable form of a PriorRevision. A session spec's names where the Node fetches +// it and what it was based on; a delivery spec's carries only the identity and final commit. func priorObject(prior *controlpb.PriorRevision) core.Object { out := core.Object{"revisionId": prior.GetRevisionId(), "finalCommit": prior.GetFinalCommit()} - if prior.GetBundle() != nil { - out["bundle"] = storedObject(prior.GetBundle()) + if prior.GetGitRepositoryUrl() != "" || prior.GetDeliveryRef() != "" || prior.GetBaseCommit() != "" { + out["gitRepositoryUrl"], out["deliveryRef"], out["baseCommit"] = prior.GetGitRepositoryUrl(), prior.GetDeliveryRef(), prior.GetBaseCommit() } return out } @@ -142,11 +176,15 @@ func priorMessage(o core.Object) *controlpb.PriorRevision { if o.S("revisionId") == "" { return nil } - prior := &controlpb.PriorRevision{RevisionId: o.S("revisionId"), FinalCommit: o.S("finalCommit")} - if bundle := o.O("bundle"); len(bundle) > 0 { - prior.Bundle = storedMessage(bundle) + return &controlpb.PriorRevision{RevisionId: o.S("revisionId"), FinalCommit: o.S("finalCommit"), GitRepositoryUrl: o.S("gitRepositoryUrl"), DeliveryRef: o.S("deliveryRef"), BaseCommit: o.S("baseCommit")} +} + +func directoryMessages(rows []core.Object) []*controlpb.WorkspaceListing { + out := make([]*controlpb.WorkspaceListing, 0, len(rows)) + for _, row := range rows { + out = append(out, &controlpb.WorkspaceListing{WorkspaceId: row.S("workspaceId"), Kind: row.S("kind"), IssueId: row.S("issueId"), IssueTitle: row.S("issueTitle"), IssueRunId: row.S("issueRunId"), RunStatus: row.S("runStatus")}) } - return prior + return out } func sessionMessage(o core.Object) *controlpb.AgentSessionSpec { @@ -154,10 +192,11 @@ func sessionMessage(o core.Object) *controlpb.AgentSessionSpec { turn := o.O("initialTurn") return &controlpb.AgentSessionSpec{ AgentPluginId: o.S("agentPluginId"), AgentPluginVersion: o.S("agentPluginVersion"), CheckoutExecutionId: o.S("checkoutExecutionId"), - ModelBindingId: o.S("modelBindingId"), - GitIdentity: &controlpb.GitIdentity{Name: git.S("name"), Email: git.S("email")}, - InitialTurn: &controlpb.UserTurn{TurnId: turn.S("turnId"), Content: contentMessages(rows(turn["content"]))}, - PriorRevision: priorMessage(o.O("priorRevision")), + ModelBindingId: o.S("modelBindingId"), + GitIdentity: &controlpb.GitIdentity{Name: git.S("name"), Email: git.S("email")}, + InitialTurn: &controlpb.UserTurn{TurnId: turn.S("turnId"), Content: contentMessages(rows(turn["content"]))}, + PriorRevision: priorMessage(o.O("priorRevision")), + WorkspaceDirectory: directoryMessages(rows(o["workspaceDirectory"])), } } @@ -178,12 +217,16 @@ func contentMessages(blocks []core.Object) []*controlpb.ContentBlock { } func deliveryObject(spec *controlpb.DeliverRevisionSpec) (core.Object, error) { - if spec.GetSessionExecutionId() == "" || spec.GetBundleKey() == "" || spec.GetHistoryKey() == "" { - return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") + delivery, deliveryOK := core.ParseGitRef(spec.GetDeliveryRef()) + workspace, workspaceOK := core.ParseGitRef(spec.GetWorkspaceRef()) + if spec.GetSessionExecutionId() == "" || spec.GetHistoryKey() == "" || !gitRepositoryURL(spec.GetGitRepositoryUrl()) || + !deliveryOK || delivery.Namespace != core.GitDelivery || !workspaceOK || workspace.Namespace != core.GitWorkspaceBranch { + return nil, invalidInput("invalid_dispatch") } out := core.Object{ "kind": "deliver_revision", "sessionExecutionId": spec.GetSessionExecutionId(), "checkoutExecutionId": spec.GetCheckoutExecutionId(), - "baseCommit": spec.GetBaseCommit(), "revisionRef": spec.GetRevisionRef(), "bundleKey": spec.GetBundleKey(), "historyKey": spec.GetHistoryKey(), + "baseCommit": spec.GetBaseCommit(), "revisionRef": spec.GetRevisionRef(), "historyKey": spec.GetHistoryKey(), + "gitRepositoryUrl": spec.GetGitRepositoryUrl(), "deliveryRef": spec.GetDeliveryRef(), "workspaceRef": spec.GetWorkspaceRef(), } if prior := spec.GetPriorRevision(); prior != nil { out["priorRevision"] = priorObject(prior) @@ -194,7 +237,8 @@ func deliveryObject(spec *controlpb.DeliverRevisionSpec) (core.Object, error) { func deliveryMessage(o core.Object) *controlpb.DeliverRevisionSpec { return &controlpb.DeliverRevisionSpec{ SessionExecutionId: o.S("sessionExecutionId"), CheckoutExecutionId: o.S("checkoutExecutionId"), BaseCommit: o.S("baseCommit"), - RevisionRef: o.S("revisionRef"), BundleKey: o.S("bundleKey"), HistoryKey: o.S("historyKey"), + RevisionRef: o.S("revisionRef"), HistoryKey: o.S("historyKey"), + GitRepositoryUrl: o.S("gitRepositoryUrl"), DeliveryRef: o.S("deliveryRef"), WorkspaceRef: o.S("workspaceRef"), PriorRevision: priorMessage(o.O("priorRevision")), } } @@ -202,7 +246,7 @@ func deliveryMessage(o core.Object) *controlpb.DeliverRevisionSpec { func resultObject(res *controlpb.ExecutionResult) (core.Object, error) { node := res.GetNode() if node == nil || node.GetNodeId() == "" || node.GetNodeIncarnationId() == "" { - return nil, status.Error(codes.InvalidArgument, "invalid_result") + return nil, invalidInput("invalid_result") } out := core.Object{"node": core.Object{"nodeId": node.GetNodeId(), "nodeIncarnationId": node.GetNodeIncarnationId()}} switch outcome := res.GetOutcome().(type) { @@ -227,7 +271,7 @@ func resultObject(res *controlpb.ExecutionResult) (core.Object, error) { out["outcome"] = "revision_delivered" delivered := outcome.RevisionDelivered out["finalCommit"], out["baseCommit"], out["revisionRef"] = delivered.GetFinalCommit(), delivered.GetBaseCommit(), delivered.GetRevisionRef() - out["bundle"], out["history"] = storedObject(delivered.GetBundle()), storedObject(delivered.GetHistory()) + out["history"] = storedObject(delivered.GetHistory()) case *controlpb.ExecutionResult_RevisionUnchanged: out["outcome"] = "revision_unchanged" unchanged := outcome.RevisionUnchanged @@ -236,7 +280,7 @@ func resultObject(res *controlpb.ExecutionResult) (core.Object, error) { case *controlpb.ExecutionResult_RevisionFailed: out["outcome"], out["reason"] = "revision_failed", shortEnum(outcome.RevisionFailed.GetReason().String(), "REVISION_FAILURE_REASON_") default: - return nil, status.Error(codes.InvalidArgument, "invalid_result") + return nil, invalidInput("invalid_result") } return out, nil } @@ -287,7 +331,7 @@ func result(o core.Object) *controlpb.ExecutionResult { } out.Outcome = &controlpb.ExecutionResult_AgentSessionEnded{AgentSessionEnded: ended} case "revision_delivered": - out.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{FinalCommit: o.S("finalCommit"), BaseCommit: o.S("baseCommit"), RevisionRef: o.S("revisionRef"), Bundle: storedMessage(o.O("bundle")), History: storedMessage(o.O("history"))}} + out.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{FinalCommit: o.S("finalCommit"), BaseCommit: o.S("baseCommit"), RevisionRef: o.S("revisionRef"), History: storedMessage(o.O("history"))}} case "revision_unchanged": out.Outcome = &controlpb.ExecutionResult_RevisionUnchanged{RevisionUnchanged: &controlpb.RevisionUnchanged{FinalCommit: o.S("finalCommit"), BaseCommit: o.S("baseCommit"), RevisionRef: o.S("revisionRef"), History: storedMessage(o.O("history"))}} case "revision_failed": @@ -357,6 +401,13 @@ func grantMessage(o core.Object) *controlpb.UploadGrant { return grant } +// invalidInput rejects a malformed execution input or result before it reaches the registry. It is +// a 400 Fault mapped in fault.go, so the status carries ErrorDetail INVALID_INPUT like every other +// invalid argument the contract reports. +func invalidInput(code string) error { + return toStatus(&core.Fault{Code: code, Status: 400, Params: core.Object{}}) +} + // boundedSchemaVersion converts the durable plan's schema version without overflowing: a value // outside u32 cannot have passed the sync's validation, so mapping it to zero keeps the wire // input unequal to the frozen plan and the dispatch is refused rather than truncated. diff --git a/internal/controlgrpc/executions.go b/internal/controlgrpc/executions.go index b32710345..9d7c1f4d7 100644 --- a/internal/controlgrpc/executions.go +++ b/internal/controlgrpc/executions.go @@ -12,7 +12,8 @@ import ( "github.com/wanglongan587/cloud/internal/core" ) -// executionService is the execution registry of the clone loop. Each RPC is one clone_* control +// executionService is the execution registry of Workspace clones, plugin installs, Agent sessions +// and Revision deliveries. Each RPC is one clone_* control // action; state-changing calls carry their submission identity so Cloud replays a recorded // response instead of reapplying when the Controller retries after a lost reply. type executionService struct { @@ -36,11 +37,7 @@ func (s *executionService) ClaimWork(ctx context.Context, req *controlpb.ClaimWo Target: &controlpb.WorkTarget{WorkspaceId: target.S("workspaceId"), SandboxInstanceId: target.S("sandboxInstanceId"), NodeId: target.S("nodeId")}, }}, nil } - request := out.O("request") - if request.S("id") == "" { - return &controlpb.ClaimWorkResponse{}, nil - } - return &controlpb.ClaimWorkResponse{Item: &controlpb.WorkItem{OperationId: request.S("id"), Input: input(core.Object{"kind": "clone", "repositoryUrl": request.S("repositoryUrl"), "branch": request.S("branch")})}}, nil + return &controlpb.ClaimWorkResponse{}, nil } func (s *executionService) RecordDispatch(ctx context.Context, req *controlpb.RecordDispatchRequest) (*controlpb.RecordDispatchResponse, error) { diff --git a/internal/controlgrpc/git_codec_test.go b/internal/controlgrpc/git_codec_test.go new file mode 100644 index 000000000..48c46900a --- /dev/null +++ b/internal/controlgrpc/git_codec_test.go @@ -0,0 +1,149 @@ +package controlgrpc + +import ( + "reflect" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + "github.com/wanglongan587/cloud/internal/controlpb" + "github.com/wanglongan587/cloud/internal/core" +) + +const ( + codecTenant = "6f9dd12a-3dd0-42c7-88cd-41c21bfa748e" + codecProject = "e93c0c7d-b434-4cdb-ba1a-d1f519fe28e8" + codecBinding = "46722143-1acd-4fa2-a3cf-b510457795f5" + codecWorkspace = "bf8abde8-edd6-4505-be20-05728658c7de" + codecWork = "0b2911dd-6688-4051-b0f0-5e35f14594e1" +) + +func validClone() *controlpb.CloneSpec { + commit := strings.Repeat("c", 40) + return &controlpb.CloneSpec{ + RepositoryUrl: "https://git-server:8445/" + codecTenant + "/" + codecProject + ".git", + SnapshotRef: core.GitSnapshotRef(codecBinding, commit), + BaseCommit: commit, + WorkspaceRef: core.GitWorkspaceRefPrefix(codecWorkspace) + "main", + } +} + +// Contract D1: a Workspace clone input is the four snapshot fields, round-tripped verbatim into the +// durable shape {kind, repositoryUrl, snapshotRef, baseCommit, workspaceRef}. +func TestCloneSpecRoundTripsTheSnapshotInput(t *testing.T) { + spec := validClone() + got, err := inputObject(&controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: spec}}) + if err != nil { + t.Fatal(err) + } + want := core.Object{"kind": "clone", "repositoryUrl": spec.GetRepositoryUrl(), "snapshotRef": spec.GetSnapshotRef(), "baseCommit": spec.GetBaseCommit(), "workspaceRef": spec.GetWorkspaceRef()} + if !reflect.DeepEqual(got, want) { + t.Fatalf("durable clone input = %v, want %v", got, want) + } + if back := input(got).GetClone(); !proto.Equal(back, spec) { + t.Fatalf("clone input changed across the codec: %v", back) + } +} + +// Contract D1: every field is required and shaped; in particular the snapshot ref must name the base +// commit, so a Controller cannot pair a snapshot with another commit. +func TestCloneSpecRejectsMalformedInput(t *testing.T) { + other := strings.Repeat("d", 40) + for name, edit := range map[string]func(*controlpb.CloneSpec){ + "empty repository": func(s *controlpb.CloneSpec) { s.RepositoryUrl = "" }, + "http repository": func(s *controlpb.CloneSpec) { s.RepositoryUrl = strings.Replace(s.RepositoryUrl, "https", "http", 1) }, + "repository with userinfo": func(s *controlpb.CloneSpec) { + s.RepositoryUrl = strings.Replace(s.RepositoryUrl, "https://", "https://user@", 1) + }, + "repository with query": func(s *controlpb.CloneSpec) { s.RepositoryUrl += "?x=1" }, + "repository with fragment": func(s *controlpb.CloneSpec) { s.RepositoryUrl += "#x" }, + "snapshot of another commit": func(s *controlpb.CloneSpec) { s.BaseCommit = other }, + "snapshot outside upstreams": func(s *controlpb.CloneSpec) { s.SnapshotRef = "refs/heads/main" }, + "tracking ref as snapshot": func(s *controlpb.CloneSpec) { s.SnapshotRef = core.GitUpstreamHeadPrefix(codecBinding) + "main" }, + "uppercase commit": func(s *controlpb.CloneSpec) { s.BaseCommit = strings.ToUpper(s.BaseCommit) }, + "workspace ref outside": func(s *controlpb.CloneSpec) { s.WorkspaceRef = "refs/heads/main" }, + "workspace branch HEAD": func(s *controlpb.CloneSpec) { s.WorkspaceRef = core.GitWorkspaceRefPrefix(codecWorkspace) + "HEAD" }, + "workspace ref without owner": func(s *controlpb.CloneSpec) { s.WorkspaceRef = "refs/heads/workspaces/main" }, + "empty workspace ref": func(s *controlpb.CloneSpec) { s.WorkspaceRef = "" }, + } { + spec := validClone() + edit(spec) + _, err := inputObject(&controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: spec}}) + if st, _ := status.FromError(err); st.Code() != codes.InvalidArgument || detailOf(st) != controlpb.ErrorCode_ERROR_CODE_INVALID_INPUT { + t.Errorf("%s: err = %v, want InvalidArgument with INVALID_INPUT", name, err) + } + } +} + +// Revision D1 and contract D6: a delivery input carries the Git Server repository, its own delivery +// ref and the Workspace branch, and no bundle key; all three are required and shaped. +func TestDeliverySpecCarriesGitLocations(t *testing.T) { + valid := func() *controlpb.DeliverRevisionSpec { + return &controlpb.DeliverRevisionSpec{ + SessionExecutionId: "session-1", CheckoutExecutionId: "checkout-1", BaseCommit: strings.Repeat("c", 40), + RevisionRef: "refs/ora/revisions/run-1", HistoryKey: "revisions/t/r/w/session.jsonl", + GitRepositoryUrl: "https://git-server:8445/" + codecTenant + "/" + codecProject + ".git", + DeliveryRef: core.GitDeliveryRefPrefix + codecWork, WorkspaceRef: core.GitWorkspaceRefPrefix(codecWorkspace) + "main", + PriorRevision: &controlpb.PriorRevision{RevisionId: "r", FinalCommit: strings.Repeat("e", 40)}, + } + } + spec := valid() + got, err := deliveryObject(spec) + if err != nil { + t.Fatal(err) + } + if got.S("deliveryRef") != spec.GetDeliveryRef() || got.S("workspaceRef") != spec.GetWorkspaceRef() || got.S("gitRepositoryUrl") != spec.GetGitRepositoryUrl() || got["bundleKey"] != nil { + t.Fatalf("durable delivery input = %v", got) + } + if prior := got.O("priorRevision"); len(prior) != 2 { + t.Fatalf("a delivery's prior carries identity and final commit only: %v", prior) + } + if back := deliveryMessage(got); !proto.Equal(back, spec) { + t.Fatalf("delivery input changed across the codec: %v", back) + } + for name, edit := range map[string]func(*controlpb.DeliverRevisionSpec){ + "no repository": func(s *controlpb.DeliverRevisionSpec) { s.GitRepositoryUrl = "" }, + "no delivery ref": func(s *controlpb.DeliverRevisionSpec) { s.DeliveryRef = "" }, + "delivery ref elsewhere": func(s *controlpb.DeliverRevisionSpec) { s.DeliveryRef = "refs/heads/main" }, + "no workspace ref": func(s *controlpb.DeliverRevisionSpec) { s.WorkspaceRef = "" }, + "workspace ref elsewhere": func(s *controlpb.DeliverRevisionSpec) { s.WorkspaceRef = core.GitDeliveryRefPrefix + codecWork }, + "no history key": func(s *controlpb.DeliverRevisionSpec) { s.HistoryKey = "" }, + "no session": func(s *controlpb.DeliverRevisionSpec) { s.SessionExecutionId = "" }, + "repository without .git": func(s *controlpb.DeliverRevisionSpec) { + s.GitRepositoryUrl = "https://git-server:8445/" + codecTenant + "/" + codecProject + }, + "repository with userinfo": func(s *controlpb.DeliverRevisionSpec) { + s.GitRepositoryUrl = "https://u:p@git-server/" + codecTenant + "/" + codecProject + ".git" + }, + } { + spec := valid() + edit(spec) + if _, err := deliveryObject(spec); status.Code(err) != codes.InvalidArgument { + t.Errorf("%s: err = %v, want InvalidArgument", name, err) + } + } +} + +// Revision D6: a delivered result has no bundle on the wire any more. +func TestRevisionDeliveredCarriesOnlyTheHistory(t *testing.T) { + res := &controlpb.ExecutionResult{ + Node: &controlpb.NodeIdentity{NodeId: "n", NodeIncarnationId: "i"}, + Outcome: &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{ + FinalCommit: strings.Repeat("f", 40), BaseCommit: strings.Repeat("c", 40), RevisionRef: "refs/ora/revisions/r", + History: &controlpb.StoredObject{Key: "k", Size: 1, Sha256: strings.Repeat("a", 64)}, + }}, + } + got, err := resultObject(res) + if err != nil { + t.Fatal(err) + } + if _, ok := got["bundle"]; ok { + t.Fatalf("a delivered result decoded a bundle: %v", got) + } + if back := result(got); !proto.Equal(back, res) { + t.Fatalf("result changed across the codec: %v", back) + } +} diff --git a/internal/controlgrpc/operations.go b/internal/controlgrpc/operations.go index fe7a4bf81..2645ae862 100644 --- a/internal/controlgrpc/operations.go +++ b/internal/controlgrpc/operations.go @@ -227,7 +227,7 @@ func snapshot(out core.Object) *controlpb.OperationSnapshot { p := out.O("project") snap := &controlpb.OperationSnapshot{ Operation: operation(out.O("operation")), - Project: &controlpb.OperationProject{Id: p.S("id"), RepositoryUrl: p.S("repositoryUrl"), DefaultBranch: p.S("defaultBranch"), CredentialRef: optionalString(p, "secretRef")}, + Project: &controlpb.OperationProject{Id: p.S("id")}, } for _, w := range rows(out["workspaces"]) { snap.Workspaces = append(snap.Workspaces, &controlpb.OperationWorkspace{ @@ -255,6 +255,9 @@ func snapshot(out core.Object) *controlpb.OperationSnapshot { if pluginInput := out.O("pluginInput"); pluginInput.S("kind") != "" { snap.PluginInput = input(pluginInput) } + if cloneInput := out.O("cloneInput"); cloneInput.S("kind") == "clone" { + snap.CloneInput = cloneMessage(cloneInput) + } for _, execution := range rows(out["pluginExecutions"]) { snap.PluginExecutions = append(snap.PluginExecutions, record(execution)) } diff --git a/internal/controlgrpc/session_models_restore_test.go b/internal/controlgrpc/session_models_restore_test.go index 8773eb333..410d9bdcd 100644 --- a/internal/controlgrpc/session_models_restore_test.go +++ b/internal/controlgrpc/session_models_restore_test.go @@ -11,7 +11,8 @@ import ( "github.com/wanglongan587/cloud/internal/core" ) -// A resumed model-backed session must retain both independent snapshots across the wire. +// A resumed model-backed session must retain its model binding, its prior Revision's Git location and +// its Workspace directory across the wire. func TestSessionWirePreservesModelBindingAndPriorRevision(t *testing.T) { want := core.Object{ "kind": "agent_session", "agentPluginId": "official/ora-space.opencode", "agentPluginVersion": "0.6.4", @@ -19,8 +20,12 @@ func TestSessionWirePreservesModelBindingAndPriorRevision(t *testing.T) { "gitIdentity": core.Object{"name": "User", "email": "user@example.invalid"}, "initialTurn": core.Object{"turnId": "turn-1", "content": []core.Object{{"text": "Continue this task"}}}, "priorRevision": core.Object{ - "revisionId": "revision-1", "finalCommit": strings.Repeat("a", 40), - "bundle": core.Object{"key": "revision-1/bundle", "size": int64(42), "sha256": strings.Repeat("b", 64)}, + "revisionId": "revision-1", "finalCommit": strings.Repeat("a", 40), "baseCommit": strings.Repeat("b", 40), + "gitRepositoryUrl": "https://git.invalid/tenant/project.git", "deliveryRef": "refs/ora/deliveries/work-1", + }, + "workspaceDirectory": []core.Object{ + {"workspaceId": "ws-run", "kind": "run", "issueId": "issue-1", "issueTitle": "Fix it", "issueRunId": "run-1", "runStatus": "completed"}, + {"workspaceId": "ws-main", "kind": "main"}, }, } message := sessionMessage(want) diff --git a/internal/controlpb/agent_executions.pb.go b/internal/controlpb/agent_executions.pb.go index bf739c4d8..be5270e62 100644 --- a/internal/controlpb/agent_executions.pb.go +++ b/internal/controlpb/agent_executions.pb.go @@ -89,11 +89,14 @@ const ( RevisionFailureReason_REVISION_FAILURE_REASON_SESSION_NOT_SETTLED RevisionFailureReason = 1 RevisionFailureReason_REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE RevisionFailureReason = 2 RevisionFailureReason_REVISION_FAILURE_REASON_SNAPSHOT_FAILED RevisionFailureReason = 3 - RevisionFailureReason_REVISION_FAILURE_REASON_BUNDLE_FAILED RevisionFailureReason = 4 RevisionFailureReason_REVISION_FAILURE_REASON_HISTORY_UNAVAILABLE RevisionFailureReason = 5 RevisionFailureReason_REVISION_FAILURE_REASON_UPLOAD_FAILED RevisionFailureReason = 6 // Recorded by Cloud when the declared objects are missing or differ; a Node never reports it. RevisionFailureReason_REVISION_FAILURE_REASON_VERIFICATION_FAILED RevisionFailureReason = 7 + // The Git Server refused the delivery ref push (401, 403 or a declined ref update). + RevisionFailureReason_REVISION_FAILURE_REASON_PUSH_REJECTED RevisionFailureReason = 8 + // The delivery ref push failed on 5xx, network, TLS or deadline after the retries ran out. + RevisionFailureReason_REVISION_FAILURE_REASON_PUSH_FAILED RevisionFailureReason = 9 ) // Enum value maps for RevisionFailureReason. @@ -103,20 +106,22 @@ var ( 1: "REVISION_FAILURE_REASON_SESSION_NOT_SETTLED", 2: "REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE", 3: "REVISION_FAILURE_REASON_SNAPSHOT_FAILED", - 4: "REVISION_FAILURE_REASON_BUNDLE_FAILED", 5: "REVISION_FAILURE_REASON_HISTORY_UNAVAILABLE", 6: "REVISION_FAILURE_REASON_UPLOAD_FAILED", 7: "REVISION_FAILURE_REASON_VERIFICATION_FAILED", + 8: "REVISION_FAILURE_REASON_PUSH_REJECTED", + 9: "REVISION_FAILURE_REASON_PUSH_FAILED", } RevisionFailureReason_value = map[string]int32{ "REVISION_FAILURE_REASON_UNSPECIFIED": 0, "REVISION_FAILURE_REASON_SESSION_NOT_SETTLED": 1, "REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE": 2, "REVISION_FAILURE_REASON_SNAPSHOT_FAILED": 3, - "REVISION_FAILURE_REASON_BUNDLE_FAILED": 4, "REVISION_FAILURE_REASON_HISTORY_UNAVAILABLE": 5, "REVISION_FAILURE_REASON_UPLOAD_FAILED": 6, "REVISION_FAILURE_REASON_VERIFICATION_FAILED": 7, + "REVISION_FAILURE_REASON_PUSH_REJECTED": 8, + "REVISION_FAILURE_REASON_PUSH_FAILED": 9, } ) @@ -367,14 +372,18 @@ func (x *UserTurn) GetContent() []*ContentBlock { } // The Revision a new run of the same Issue resumes from (IssueRun resume decision D1). On a session -// spec it names the verified object holding the bundle, which may belong to an earlier Revision the -// resumed one reused; on a delivery spec it carries only the identity and final commit. +// spec it also says where the Node fetches the prior final commit and what it was based on; on a +// delivery spec it carries only the identity and final commit. type PriorRevision struct { state protoimpl.MessageState `protogen:"open.v1"` RevisionId string `protobuf:"bytes,1,opt,name=revision_id,json=revisionId,proto3" json:"revision_id,omitempty"` FinalCommit string `protobuf:"bytes,2,opt,name=final_commit,json=finalCommit,proto3" json:"final_commit,omitempty"` - // Set on AgentSessionSpec only. The download grant covers exactly this object key. - Bundle *StoredObject `protobuf:"bytes,3,opt,name=bundle,proto3" json:"bundle,omitempty"` + // Session spec only: the Git Server repository and verified delivery ref holding final_commit. + GitRepositoryUrl string `protobuf:"bytes,4,opt,name=git_repository_url,json=gitRepositoryUrl,proto3" json:"git_repository_url,omitempty"` + DeliveryRef string `protobuf:"bytes,5,opt,name=delivery_ref,json=deliveryRef,proto3" json:"delivery_ref,omitempty"` + // Session spec only: the base commit of the prior Revision, used to tell whether the Workspace + // snapshot still contains it. + BaseCommit string `protobuf:"bytes,6,opt,name=base_commit,json=baseCommit,proto3" json:"base_commit,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -423,11 +432,114 @@ func (x *PriorRevision) GetFinalCommit() string { return "" } -func (x *PriorRevision) GetBundle() *StoredObject { +func (x *PriorRevision) GetGitRepositoryUrl() string { if x != nil { - return x.Bundle + return x.GitRepositoryUrl } - return nil + return "" +} + +func (x *PriorRevision) GetDeliveryRef() string { + if x != nil { + return x.DeliveryRef + } + return "" +} + +func (x *PriorRevision) GetBaseCommit() string { + if x != nil { + return x.BaseCommit + } + return "" +} + +// One Workspace of the session's Project, so `ora-git list` can name branches by Issue and run. It +// is a snapshot taken when the work item was released and never takes part in authorization. +type WorkspaceListing struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + // main | isolated | run + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + // Run Workspaces only. + IssueId string `protobuf:"bytes,3,opt,name=issue_id,json=issueId,proto3" json:"issue_id,omitempty"` + IssueTitle string `protobuf:"bytes,4,opt,name=issue_title,json=issueTitle,proto3" json:"issue_title,omitempty"` + IssueRunId string `protobuf:"bytes,5,opt,name=issue_run_id,json=issueRunId,proto3" json:"issue_run_id,omitempty"` + // As of when the session work item was released. + RunStatus string `protobuf:"bytes,6,opt,name=run_status,json=runStatus,proto3" json:"run_status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceListing) Reset() { + *x = WorkspaceListing{} + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceListing) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceListing) ProtoMessage() {} + +func (x *WorkspaceListing) ProtoReflect() protoreflect.Message { + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceListing.ProtoReflect.Descriptor instead. +func (*WorkspaceListing) Descriptor() ([]byte, []int) { + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{5} +} + +func (x *WorkspaceListing) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *WorkspaceListing) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *WorkspaceListing) GetIssueId() string { + if x != nil { + return x.IssueId + } + return "" +} + +func (x *WorkspaceListing) GetIssueTitle() string { + if x != nil { + return x.IssueTitle + } + return "" +} + +func (x *WorkspaceListing) GetIssueRunId() string { + if x != nil { + return x.IssueRunId + } + return "" +} + +func (x *WorkspaceListing) GetRunStatus() string { + if x != nil { + return x.RunStatus + } + return "" } type AgentSessionSpec struct { @@ -447,13 +559,15 @@ type AgentSessionSpec struct { // Opaque personal-model snapshot reference. Empty for agents that do not use model-gateway. // No provider credential or temporary access token crosses this contract. ModelBindingId string `protobuf:"bytes,7,opt,name=model_binding_id,json=modelBindingId,proto3" json:"model_binding_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Workspaces of this Project, newest first, at most 100, fixed when the work item was released. + WorkspaceDirectory []*WorkspaceListing `protobuf:"bytes,8,rep,name=workspace_directory,json=workspaceDirectory,proto3" json:"workspace_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AgentSessionSpec) Reset() { *x = AgentSessionSpec{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[5] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -465,7 +579,7 @@ func (x *AgentSessionSpec) String() string { func (*AgentSessionSpec) ProtoMessage() {} func (x *AgentSessionSpec) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[5] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -478,7 +592,7 @@ func (x *AgentSessionSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentSessionSpec.ProtoReflect.Descriptor instead. func (*AgentSessionSpec) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{5} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{6} } func (x *AgentSessionSpec) GetAgentPluginId() string { @@ -530,6 +644,13 @@ func (x *AgentSessionSpec) GetModelBindingId() string { return "" } +func (x *AgentSessionSpec) GetWorkspaceDirectory() []*WorkspaceListing { + if x != nil { + return x.WorkspaceDirectory + } + return nil +} + // The only terminal result of a session execution. It is taken over only after every Thread event // before it, so the Thread's last entry lands before the session is known to have ended. type AgentSessionEnded struct { @@ -543,7 +664,7 @@ type AgentSessionEnded struct { func (x *AgentSessionEnded) Reset() { *x = AgentSessionEnded{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[6] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -555,7 +676,7 @@ func (x *AgentSessionEnded) String() string { func (*AgentSessionEnded) ProtoMessage() {} func (x *AgentSessionEnded) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[6] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -568,7 +689,7 @@ func (x *AgentSessionEnded) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentSessionEnded.ProtoReflect.Descriptor instead. func (*AgentSessionEnded) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{6} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{7} } func (x *AgentSessionEnded) GetReason() AgentSessionEndReason { @@ -590,23 +711,30 @@ type DeliverRevisionSpec struct { // The ended session whose Workspace and history are saved. SessionExecutionId string `protobuf:"bytes,1,opt,name=session_execution_id,json=sessionExecutionId,proto3" json:"session_execution_id,omitempty"` CheckoutExecutionId string `protobuf:"bytes,2,opt,name=checkout_execution_id,json=checkoutExecutionId,proto3" json:"checkout_execution_id,omitempty"` - // Commit of the run Workspace's clone; the bundle is relative to it. + // Commit of the run Workspace's clone. BaseCommit string `protobuf:"bytes,3,opt,name=base_commit,json=baseCommit,proto3" json:"base_commit,omitempty"` // Must be under `refs/ora/revisions/`. RevisionRef string `protobuf:"bytes,4,opt,name=revision_ref,json=revisionRef,proto3" json:"revision_ref,omitempty"` - // Object keys Cloud chose for this delivery; upload grants are issued only for these keys. - BundleKey string `protobuf:"bytes,5,opt,name=bundle_key,json=bundleKey,proto3" json:"bundle_key,omitempty"` + // Object key Cloud chose for the session history; upload grants are issued only for this key. HistoryKey string `protobuf:"bytes,6,opt,name=history_key,json=historyKey,proto3" json:"history_key,omitempty"` - // The session's resumed Revision, without bundle. A final commit equal to its final commit is - // reported as RevisionUnchanged. + // The session's resumed Revision, identity and final commit only. A final commit equal to its + // final commit is reported as RevisionUnchanged. PriorRevision *PriorRevision `protobuf:"bytes,7,opt,name=prior_revision,json=priorRevision,proto3" json:"prior_revision,omitempty"` + // Fixed by Cloud when the work item was released; the Node pushes the final commit to + // delivery_ref (create only) in this repository. + GitRepositoryUrl string `protobuf:"bytes,8,opt,name=git_repository_url,json=gitRepositoryUrl,proto3" json:"git_repository_url,omitempty"` + // Under `refs/ora/deliveries/`. + DeliveryRef string `protobuf:"bytes,9,opt,name=delivery_ref,json=deliveryRef,proto3" json:"delivery_ref,omitempty"` + // The run Workspace's branch, from its clone input. The Node fast-forwards it to the final commit + // after the delivery ref is pushed; failing to do so never fails the delivery. + WorkspaceRef string `protobuf:"bytes,10,opt,name=workspace_ref,json=workspaceRef,proto3" json:"workspace_ref,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeliverRevisionSpec) Reset() { *x = DeliverRevisionSpec{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -618,7 +746,7 @@ func (x *DeliverRevisionSpec) String() string { func (*DeliverRevisionSpec) ProtoMessage() {} func (x *DeliverRevisionSpec) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -631,7 +759,7 @@ func (x *DeliverRevisionSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use DeliverRevisionSpec.ProtoReflect.Descriptor instead. func (*DeliverRevisionSpec) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{7} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{8} } func (x *DeliverRevisionSpec) GetSessionExecutionId() string { @@ -662,13 +790,6 @@ func (x *DeliverRevisionSpec) GetRevisionRef() string { return "" } -func (x *DeliverRevisionSpec) GetBundleKey() string { - if x != nil { - return x.BundleKey - } - return "" -} - func (x *DeliverRevisionSpec) GetHistoryKey() string { if x != nil { return x.HistoryKey @@ -683,6 +804,27 @@ func (x *DeliverRevisionSpec) GetPriorRevision() *PriorRevision { return nil } +func (x *DeliverRevisionSpec) GetGitRepositoryUrl() string { + if x != nil { + return x.GitRepositoryUrl + } + return "" +} + +func (x *DeliverRevisionSpec) GetDeliveryRef() string { + if x != nil { + return x.DeliveryRef + } + return "" +} + +func (x *DeliverRevisionSpec) GetWorkspaceRef() string { + if x != nil { + return x.WorkspaceRef + } + return "" +} + // An object the Node uploaded, as the Node measured it; Cloud verifies it before registering. type StoredObject struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -696,7 +838,7 @@ type StoredObject struct { func (x *StoredObject) Reset() { *x = StoredObject{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -708,7 +850,7 @@ func (x *StoredObject) String() string { func (*StoredObject) ProtoMessage() {} func (x *StoredObject) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -721,7 +863,7 @@ func (x *StoredObject) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredObject.ProtoReflect.Descriptor instead. func (*StoredObject) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{8} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{9} } func (x *StoredObject) GetKey() string { @@ -745,12 +887,12 @@ func (x *StoredObject) GetSha256() string { return "" } +// The final commit was pushed to the input's delivery_ref; Cloud verifies the ref before registering. type RevisionDelivered struct { state protoimpl.MessageState `protogen:"open.v1"` FinalCommit string `protobuf:"bytes,1,opt,name=final_commit,json=finalCommit,proto3" json:"final_commit,omitempty"` BaseCommit string `protobuf:"bytes,2,opt,name=base_commit,json=baseCommit,proto3" json:"base_commit,omitempty"` RevisionRef string `protobuf:"bytes,3,opt,name=revision_ref,json=revisionRef,proto3" json:"revision_ref,omitempty"` - Bundle *StoredObject `protobuf:"bytes,4,opt,name=bundle,proto3" json:"bundle,omitempty"` History *StoredObject `protobuf:"bytes,5,opt,name=history,proto3" json:"history,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -758,7 +900,7 @@ type RevisionDelivered struct { func (x *RevisionDelivered) Reset() { *x = RevisionDelivered{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -770,7 +912,7 @@ func (x *RevisionDelivered) String() string { func (*RevisionDelivered) ProtoMessage() {} func (x *RevisionDelivered) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -783,7 +925,7 @@ func (x *RevisionDelivered) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionDelivered.ProtoReflect.Descriptor instead. func (*RevisionDelivered) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{9} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{10} } func (x *RevisionDelivered) GetFinalCommit() string { @@ -807,13 +949,6 @@ func (x *RevisionDelivered) GetRevisionRef() string { return "" } -func (x *RevisionDelivered) GetBundle() *StoredObject { - if x != nil { - return x.Bundle - } - return nil -} - func (x *RevisionDelivered) GetHistory() *StoredObject { if x != nil { return x.History @@ -822,7 +957,8 @@ func (x *RevisionDelivered) GetHistory() *StoredObject { } // The final commit equals the base commit, or the delivery spec's prior_revision final commit (a -// resumed run that added nothing): no bundle, only the session history. +// resumed run that added nothing). It still pushes its own delivery_ref and attempts to fast-forward +// workspace_ref before uploading only the session history. type RevisionUnchanged struct { state protoimpl.MessageState `protogen:"open.v1"` FinalCommit string `protobuf:"bytes,1,opt,name=final_commit,json=finalCommit,proto3" json:"final_commit,omitempty"` @@ -835,7 +971,7 @@ type RevisionUnchanged struct { func (x *RevisionUnchanged) Reset() { *x = RevisionUnchanged{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -847,7 +983,7 @@ func (x *RevisionUnchanged) String() string { func (*RevisionUnchanged) ProtoMessage() {} func (x *RevisionUnchanged) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -860,7 +996,7 @@ func (x *RevisionUnchanged) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionUnchanged.ProtoReflect.Descriptor instead. func (*RevisionUnchanged) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{10} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{11} } func (x *RevisionUnchanged) GetFinalCommit() string { @@ -900,7 +1036,7 @@ type RevisionFailed struct { func (x *RevisionFailed) Reset() { *x = RevisionFailed{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -912,7 +1048,7 @@ func (x *RevisionFailed) String() string { func (*RevisionFailed) ProtoMessage() {} func (x *RevisionFailed) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -925,7 +1061,7 @@ func (x *RevisionFailed) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionFailed.ProtoReflect.Descriptor instead. func (*RevisionFailed) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{11} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{12} } func (x *RevisionFailed) GetReason() RevisionFailureReason { @@ -955,7 +1091,7 @@ type UploadGrant struct { func (x *UploadGrant) Reset() { *x = UploadGrant{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -967,7 +1103,7 @@ func (x *UploadGrant) String() string { func (*UploadGrant) ProtoMessage() {} func (x *UploadGrant) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -980,7 +1116,7 @@ func (x *UploadGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadGrant.ProtoReflect.Descriptor instead. func (*UploadGrant) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{12} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{13} } func (x *UploadGrant) GetObjectKey() string { @@ -1018,86 +1154,6 @@ func (x *UploadGrant) GetExpiresAt() *timestamppb.Timestamp { return nil } -// A presigned single-object read of a prior Revision's bundle. Like UploadGrant it is a short-lived -// bearer credential kept in memory only and never logged. -type DownloadGrant struct { - state protoimpl.MessageState `protogen:"open.v1"` - ObjectKey string `protobuf:"bytes,1,opt,name=object_key,json=objectKey,proto3" json:"object_key,omitempty"` - Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` - // Always GET. - Method string `protobuf:"bytes,3,opt,name=method,proto3" json:"method,omitempty"` - // Headers the download must carry unchanged. - Headers map[string]string `protobuf:"bytes,4,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DownloadGrant) Reset() { - *x = DownloadGrant{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DownloadGrant) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DownloadGrant) ProtoMessage() {} - -func (x *DownloadGrant) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DownloadGrant.ProtoReflect.Descriptor instead. -func (*DownloadGrant) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{13} -} - -func (x *DownloadGrant) GetObjectKey() string { - if x != nil { - return x.ObjectKey - } - return "" -} - -func (x *DownloadGrant) GetUrl() string { - if x != nil { - return x.Url - } - return "" -} - -func (x *DownloadGrant) GetMethod() string { - if x != nil { - return x.Method - } - return "" -} - -func (x *DownloadGrant) GetHeaders() map[string]string { - if x != nil { - return x.Headers - } - return nil -} - -func (x *DownloadGrant) GetExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.ExpiresAt - } - return nil -} - var File_ora_cloud_internal_v1_agent_executions_proto protoreflect.FileDescriptor const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + @@ -1113,12 +1169,25 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "\x05block\"b\n" + "\bUserTurn\x12\x17\n" + "\aturn_id\x18\x01 \x01(\tR\x06turnId\x12=\n" + - "\acontent\x18\x02 \x03(\v2#.ora.cloud.internal.v1.ContentBlockR\acontent\"\x90\x01\n" + + "\acontent\x18\x02 \x03(\v2#.ora.cloud.internal.v1.ContentBlockR\acontent\"\xd3\x01\n" + "\rPriorRevision\x12\x1f\n" + "\vrevision_id\x18\x01 \x01(\tR\n" + "revisionId\x12!\n" + - "\ffinal_commit\x18\x02 \x01(\tR\vfinalCommit\x12;\n" + - "\x06bundle\x18\x03 \x01(\v2#.ora.cloud.internal.v1.StoredObjectR\x06bundle\"\xa2\x03\n" + + "\ffinal_commit\x18\x02 \x01(\tR\vfinalCommit\x12,\n" + + "\x12git_repository_url\x18\x04 \x01(\tR\x10gitRepositoryUrl\x12!\n" + + "\fdelivery_ref\x18\x05 \x01(\tR\vdeliveryRef\x12\x1f\n" + + "\vbase_commit\x18\x06 \x01(\tR\n" + + "baseCommitJ\x04\b\x03\x10\x04R\x06bundle\"\xc6\x01\n" + + "\x10WorkspaceListing\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x19\n" + + "\bissue_id\x18\x03 \x01(\tR\aissueId\x12\x1f\n" + + "\vissue_title\x18\x04 \x01(\tR\n" + + "issueTitle\x12 \n" + + "\fissue_run_id\x18\x05 \x01(\tR\n" + + "issueRunId\x12\x1d\n" + + "\n" + + "run_status\x18\x06 \x01(\tR\trunStatus\"\xfc\x03\n" + "\x10AgentSessionSpec\x12&\n" + "\x0fagent_plugin_id\x18\x01 \x01(\tR\ragentPluginId\x120\n" + "\x14agent_plugin_version\x18\x02 \x01(\tR\x12agentPluginVersion\x122\n" + @@ -1126,33 +1195,36 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "\fgit_identity\x18\x04 \x01(\v2\".ora.cloud.internal.v1.GitIdentityR\vgitIdentity\x12B\n" + "\finitial_turn\x18\x05 \x01(\v2\x1f.ora.cloud.internal.v1.UserTurnR\vinitialTurn\x12K\n" + "\x0eprior_revision\x18\x06 \x01(\v2$.ora.cloud.internal.v1.PriorRevisionR\rpriorRevision\x12(\n" + - "\x10model_binding_id\x18\a \x01(\tR\x0emodelBindingId\"\x81\x01\n" + + "\x10model_binding_id\x18\a \x01(\tR\x0emodelBindingId\x12X\n" + + "\x13workspace_directory\x18\b \x03(\v2'.ora.cloud.internal.v1.WorkspaceListingR\x12workspaceDirectory\"\x81\x01\n" + "\x11AgentSessionEnded\x12D\n" + "\x06reason\x18\x01 \x01(\x0e2,.ora.cloud.internal.v1.AgentSessionEndReasonR\x06reason\x12\x1b\n" + "\x06detail\x18\x02 \x01(\tH\x00R\x06detail\x88\x01\x01B\t\n" + - "\a_detail\"\xcc\x02\n" + + "\a_detail\"\xb5\x03\n" + "\x13DeliverRevisionSpec\x120\n" + "\x14session_execution_id\x18\x01 \x01(\tR\x12sessionExecutionId\x122\n" + "\x15checkout_execution_id\x18\x02 \x01(\tR\x13checkoutExecutionId\x12\x1f\n" + "\vbase_commit\x18\x03 \x01(\tR\n" + "baseCommit\x12!\n" + - "\frevision_ref\x18\x04 \x01(\tR\vrevisionRef\x12\x1d\n" + - "\n" + - "bundle_key\x18\x05 \x01(\tR\tbundleKey\x12\x1f\n" + + "\frevision_ref\x18\x04 \x01(\tR\vrevisionRef\x12\x1f\n" + "\vhistory_key\x18\x06 \x01(\tR\n" + "historyKey\x12K\n" + - "\x0eprior_revision\x18\a \x01(\v2$.ora.cloud.internal.v1.PriorRevisionR\rpriorRevision\"L\n" + + "\x0eprior_revision\x18\a \x01(\v2$.ora.cloud.internal.v1.PriorRevisionR\rpriorRevision\x12,\n" + + "\x12git_repository_url\x18\b \x01(\tR\x10gitRepositoryUrl\x12!\n" + + "\fdelivery_ref\x18\t \x01(\tR\vdeliveryRef\x12#\n" + + "\rworkspace_ref\x18\n" + + " \x01(\tR\fworkspaceRefJ\x04\b\x05\x10\x06R\n" + + "bundle_key\"L\n" + "\fStoredObject\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + "\x04size\x18\x02 \x01(\x04R\x04size\x12\x16\n" + - "\x06sha256\x18\x03 \x01(\tR\x06sha256\"\xf6\x01\n" + + "\x06sha256\x18\x03 \x01(\tR\x06sha256\"\xc7\x01\n" + "\x11RevisionDelivered\x12!\n" + "\ffinal_commit\x18\x01 \x01(\tR\vfinalCommit\x12\x1f\n" + "\vbase_commit\x18\x02 \x01(\tR\n" + "baseCommit\x12!\n" + - "\frevision_ref\x18\x03 \x01(\tR\vrevisionRef\x12;\n" + - "\x06bundle\x18\x04 \x01(\v2#.ora.cloud.internal.v1.StoredObjectR\x06bundle\x12=\n" + - "\ahistory\x18\x05 \x01(\v2#.ora.cloud.internal.v1.StoredObjectR\ahistory\"\xb9\x01\n" + + "\frevision_ref\x18\x03 \x01(\tR\vrevisionRef\x12=\n" + + "\ahistory\x18\x05 \x01(\v2#.ora.cloud.internal.v1.StoredObjectR\ahistoryJ\x04\b\x04\x10\x05R\x06bundle\"\xb9\x01\n" + "\x11RevisionUnchanged\x12!\n" + "\ffinal_commit\x18\x01 \x01(\tR\vfinalCommit\x12\x1f\n" + "\vbase_commit\x18\x02 \x01(\tR\n" + @@ -1171,17 +1243,6 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x1a:\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9c\x02\n" + - "\rDownloadGrant\x12\x1d\n" + - "\n" + - "object_key\x18\x01 \x01(\tR\tobjectKey\x12\x10\n" + - "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" + - "\x06method\x18\x03 \x01(\tR\x06method\x12K\n" + - "\aheaders\x18\x04 \x03(\v21.ora.cloud.internal.v1.DownloadGrant.HeadersEntryR\aheaders\x129\n" + - "\n" + - "expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x1a:\n" + - "\fHeadersEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*\x92\x02\n" + "\x15AgentSessionEndReason\x12(\n" + "$AGENT_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12'\n" + @@ -1189,16 +1250,17 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "%AGENT_SESSION_END_REASON_IDLE_TIMEOUT\x10\x02\x12&\n" + "\"AGENT_SESSION_END_REASON_CANCELLED\x10\x03\x12)\n" + "%AGENT_SESSION_END_REASON_AGENT_FAILED\x10\x04\x12(\n" + - "$AGENT_SESSION_END_REASON_INTERRUPTED\x10\x05*\x88\x03\n" + + "$AGENT_SESSION_END_REASON_INTERRUPTED\x10\x05*\xde\x03\n" + "\x15RevisionFailureReason\x12'\n" + "#REVISION_FAILURE_REASON_UNSPECIFIED\x10\x00\x12/\n" + "+REVISION_FAILURE_REASON_SESSION_NOT_SETTLED\x10\x01\x120\n" + ",REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE\x10\x02\x12+\n" + - "'REVISION_FAILURE_REASON_SNAPSHOT_FAILED\x10\x03\x12)\n" + - "%REVISION_FAILURE_REASON_BUNDLE_FAILED\x10\x04\x12/\n" + + "'REVISION_FAILURE_REASON_SNAPSHOT_FAILED\x10\x03\x12/\n" + "+REVISION_FAILURE_REASON_HISTORY_UNAVAILABLE\x10\x05\x12)\n" + "%REVISION_FAILURE_REASON_UPLOAD_FAILED\x10\x06\x12/\n" + - "+REVISION_FAILURE_REASON_VERIFICATION_FAILED\x10\aB\xe5\x01\n" + + "+REVISION_FAILURE_REASON_VERIFICATION_FAILED\x10\a\x12)\n" + + "%REVISION_FAILURE_REASON_PUSH_REJECTED\x10\b\x12'\n" + + "#REVISION_FAILURE_REASON_PUSH_FAILED\x10\t\"\x04\b\x04\x10\x04*%REVISION_FAILURE_REASON_BUNDLE_FAILEDB\xe5\x01\n" + "\x19com.ora.cloud.internal.v1B\x14AgentExecutionsProtoP\x01Z;github.com/wanglongan587/cloud/internal/controlpb;controlpb\xa2\x02\x03OCI\xaa\x02\x15Ora.Cloud.Internal.V1\xca\x02\x15Ora\\Cloud\\Internal\\V1\xe2\x02!Ora\\Cloud\\Internal\\V1\\GPBMetadata\xea\x02\x18Ora::Cloud::Internal::V1b\x06proto3" var ( @@ -1214,7 +1276,7 @@ func file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP() []byte { } var file_ora_cloud_internal_v1_agent_executions_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ora_cloud_internal_v1_agent_executions_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_ora_cloud_internal_v1_agent_executions_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_ora_cloud_internal_v1_agent_executions_proto_goTypes = []any{ (AgentSessionEndReason)(0), // 0: ora.cloud.internal.v1.AgentSessionEndReason (RevisionFailureReason)(0), // 1: ora.cloud.internal.v1.RevisionFailureReason @@ -1223,41 +1285,37 @@ var file_ora_cloud_internal_v1_agent_executions_proto_goTypes = []any{ (*ContentBlock)(nil), // 4: ora.cloud.internal.v1.ContentBlock (*UserTurn)(nil), // 5: ora.cloud.internal.v1.UserTurn (*PriorRevision)(nil), // 6: ora.cloud.internal.v1.PriorRevision - (*AgentSessionSpec)(nil), // 7: ora.cloud.internal.v1.AgentSessionSpec - (*AgentSessionEnded)(nil), // 8: ora.cloud.internal.v1.AgentSessionEnded - (*DeliverRevisionSpec)(nil), // 9: ora.cloud.internal.v1.DeliverRevisionSpec - (*StoredObject)(nil), // 10: ora.cloud.internal.v1.StoredObject - (*RevisionDelivered)(nil), // 11: ora.cloud.internal.v1.RevisionDelivered - (*RevisionUnchanged)(nil), // 12: ora.cloud.internal.v1.RevisionUnchanged - (*RevisionFailed)(nil), // 13: ora.cloud.internal.v1.RevisionFailed - (*UploadGrant)(nil), // 14: ora.cloud.internal.v1.UploadGrant - (*DownloadGrant)(nil), // 15: ora.cloud.internal.v1.DownloadGrant + (*WorkspaceListing)(nil), // 7: ora.cloud.internal.v1.WorkspaceListing + (*AgentSessionSpec)(nil), // 8: ora.cloud.internal.v1.AgentSessionSpec + (*AgentSessionEnded)(nil), // 9: ora.cloud.internal.v1.AgentSessionEnded + (*DeliverRevisionSpec)(nil), // 10: ora.cloud.internal.v1.DeliverRevisionSpec + (*StoredObject)(nil), // 11: ora.cloud.internal.v1.StoredObject + (*RevisionDelivered)(nil), // 12: ora.cloud.internal.v1.RevisionDelivered + (*RevisionUnchanged)(nil), // 13: ora.cloud.internal.v1.RevisionUnchanged + (*RevisionFailed)(nil), // 14: ora.cloud.internal.v1.RevisionFailed + (*UploadGrant)(nil), // 15: ora.cloud.internal.v1.UploadGrant nil, // 16: ora.cloud.internal.v1.UploadGrant.HeadersEntry - nil, // 17: ora.cloud.internal.v1.DownloadGrant.HeadersEntry - (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp } var file_ora_cloud_internal_v1_agent_executions_proto_depIdxs = []int32{ 3, // 0: ora.cloud.internal.v1.ContentBlock.text:type_name -> ora.cloud.internal.v1.TextContent 4, // 1: ora.cloud.internal.v1.UserTurn.content:type_name -> ora.cloud.internal.v1.ContentBlock - 10, // 2: ora.cloud.internal.v1.PriorRevision.bundle:type_name -> ora.cloud.internal.v1.StoredObject - 2, // 3: ora.cloud.internal.v1.AgentSessionSpec.git_identity:type_name -> ora.cloud.internal.v1.GitIdentity - 5, // 4: ora.cloud.internal.v1.AgentSessionSpec.initial_turn:type_name -> ora.cloud.internal.v1.UserTurn - 6, // 5: ora.cloud.internal.v1.AgentSessionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision + 2, // 2: ora.cloud.internal.v1.AgentSessionSpec.git_identity:type_name -> ora.cloud.internal.v1.GitIdentity + 5, // 3: ora.cloud.internal.v1.AgentSessionSpec.initial_turn:type_name -> ora.cloud.internal.v1.UserTurn + 6, // 4: ora.cloud.internal.v1.AgentSessionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision + 7, // 5: ora.cloud.internal.v1.AgentSessionSpec.workspace_directory:type_name -> ora.cloud.internal.v1.WorkspaceListing 0, // 6: ora.cloud.internal.v1.AgentSessionEnded.reason:type_name -> ora.cloud.internal.v1.AgentSessionEndReason 6, // 7: ora.cloud.internal.v1.DeliverRevisionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision - 10, // 8: ora.cloud.internal.v1.RevisionDelivered.bundle:type_name -> ora.cloud.internal.v1.StoredObject - 10, // 9: ora.cloud.internal.v1.RevisionDelivered.history:type_name -> ora.cloud.internal.v1.StoredObject - 10, // 10: ora.cloud.internal.v1.RevisionUnchanged.history:type_name -> ora.cloud.internal.v1.StoredObject - 1, // 11: ora.cloud.internal.v1.RevisionFailed.reason:type_name -> ora.cloud.internal.v1.RevisionFailureReason - 16, // 12: ora.cloud.internal.v1.UploadGrant.headers:type_name -> ora.cloud.internal.v1.UploadGrant.HeadersEntry - 18, // 13: ora.cloud.internal.v1.UploadGrant.expires_at:type_name -> google.protobuf.Timestamp - 17, // 14: ora.cloud.internal.v1.DownloadGrant.headers:type_name -> ora.cloud.internal.v1.DownloadGrant.HeadersEntry - 18, // 15: ora.cloud.internal.v1.DownloadGrant.expires_at:type_name -> google.protobuf.Timestamp - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 11, // 8: ora.cloud.internal.v1.RevisionDelivered.history:type_name -> ora.cloud.internal.v1.StoredObject + 11, // 9: ora.cloud.internal.v1.RevisionUnchanged.history:type_name -> ora.cloud.internal.v1.StoredObject + 1, // 10: ora.cloud.internal.v1.RevisionFailed.reason:type_name -> ora.cloud.internal.v1.RevisionFailureReason + 16, // 11: ora.cloud.internal.v1.UploadGrant.headers:type_name -> ora.cloud.internal.v1.UploadGrant.HeadersEntry + 17, // 12: ora.cloud.internal.v1.UploadGrant.expires_at:type_name -> google.protobuf.Timestamp + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_ora_cloud_internal_v1_agent_executions_proto_init() } @@ -1268,14 +1326,14 @@ func file_ora_cloud_internal_v1_agent_executions_proto_init() { file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[2].OneofWrappers = []any{ (*ContentBlock_Text)(nil), } - file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[6].OneofWrappers = []any{} + file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ora_cloud_internal_v1_agent_executions_proto_rawDesc), len(file_ora_cloud_internal_v1_agent_executions_proto_rawDesc)), NumEnums: 2, - NumMessages: 16, + NumMessages: 15, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/controlpb/agent_runs.pb.go b/internal/controlpb/agent_runs.pb.go index c715967a4..46b153def 100644 --- a/internal/controlpb/agent_runs.pb.go +++ b/internal/controlpb/agent_runs.pb.go @@ -897,9 +897,9 @@ type GrantRevisionUploadRequest struct { Epoch int64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // A delivery execution registered through RecordDispatch. ExecutionId string `protobuf:"bytes,2,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` - // Optional object key -> lowercase hexadecimal SHA-256. Keys must belong to the frozen - // delivery input. Cloud binds each checksum into its ephemeral PUT signature; refreshing - // this capability never changes the execution input. Omit for legacy v1 grants. + // Optional object key -> lowercase hexadecimal SHA-256. The only accepted key is the frozen + // delivery input's history_key. Cloud binds the checksum into its ephemeral PUT signature; + // refreshing this capability never changes the execution input. Omit for unbound grants. Checksums map[string]string `protobuf:"bytes,3,rep,name=checksums,proto3" json:"checksums,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -958,7 +958,7 @@ func (x *GrantRevisionUploadRequest) GetChecksums() map[string]string { type GrantRevisionUploadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // One grant per requested checksum key, or both input keys for legacy requests. + // Exactly one grant, for the input's history_key. Grants []*UploadGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1001,104 +1001,6 @@ func (x *GrantRevisionUploadResponse) GetGrants() []*UploadGrant { return nil } -type GrantRevisionDownloadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Epoch int64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` - // A session execution registered through RecordDispatch whose input carries prior_revision. - ExecutionId string `protobuf:"bytes,2,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GrantRevisionDownloadRequest) Reset() { - *x = GrantRevisionDownloadRequest{} - mi := &file_ora_cloud_internal_v1_agent_runs_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GrantRevisionDownloadRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GrantRevisionDownloadRequest) ProtoMessage() {} - -func (x *GrantRevisionDownloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_runs_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GrantRevisionDownloadRequest.ProtoReflect.Descriptor instead. -func (*GrantRevisionDownloadRequest) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_runs_proto_rawDescGZIP(), []int{15} -} - -func (x *GrantRevisionDownloadRequest) GetEpoch() int64 { - if x != nil { - return x.Epoch - } - return 0 -} - -func (x *GrantRevisionDownloadRequest) GetExecutionId() string { - if x != nil { - return x.ExecutionId - } - return "" -} - -type GrantRevisionDownloadResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Exactly one grant, for the input's prior_revision bundle key. - Grants []*DownloadGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GrantRevisionDownloadResponse) Reset() { - *x = GrantRevisionDownloadResponse{} - mi := &file_ora_cloud_internal_v1_agent_runs_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GrantRevisionDownloadResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GrantRevisionDownloadResponse) ProtoMessage() {} - -func (x *GrantRevisionDownloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_runs_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GrantRevisionDownloadResponse.ProtoReflect.Descriptor instead. -func (*GrantRevisionDownloadResponse) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_runs_proto_rawDescGZIP(), []int{16} -} - -func (x *GrantRevisionDownloadResponse) GetGrants() []*DownloadGrant { - if x != nil { - return x.Grants - } - return nil -} - var File_ora_cloud_internal_v1_agent_runs_proto protoreflect.FileDescriptor const file_ora_cloud_internal_v1_agent_runs_proto_rawDesc = "" + @@ -1173,24 +1075,18 @@ const file_ora_cloud_internal_v1_agent_runs_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Y\n" + "\x1bGrantRevisionUploadResponse\x12:\n" + - "\x06grants\x18\x01 \x03(\v2\".ora.cloud.internal.v1.UploadGrantR\x06grants\"W\n" + - "\x1cGrantRevisionDownloadRequest\x12\x14\n" + - "\x05epoch\x18\x01 \x01(\x03R\x05epoch\x12!\n" + - "\fexecution_id\x18\x02 \x01(\tR\vexecutionId\"]\n" + - "\x1dGrantRevisionDownloadResponse\x12<\n" + - "\x06grants\x18\x01 \x03(\v2$.ora.cloud.internal.v1.DownloadGrantR\x06grants*\xa0\x01\n" + + "\x06grants\x18\x01 \x03(\v2\".ora.cloud.internal.v1.UploadGrantR\x06grants*\xa0\x01\n" + "\x10EndSessionReason\x12\"\n" + "\x1eEND_SESSION_REASON_UNSPECIFIED\x10\x00\x12!\n" + "\x1dEND_SESSION_REASON_USER_ENDED\x10\x01\x12#\n" + "\x1fEND_SESSION_REASON_IDLE_TIMEOUT\x10\x02\x12 \n" + - "\x1cEND_SESSION_REASON_CANCELLED\x10\x032\xae\x06\n" + + "\x1cEND_SESSION_REASON_CANCELLED\x10\x032\xa9\x05\n" + "\x0fAgentRunService\x12\x7f\n" + "\x14PublishThreadPreview\x122.ora.cloud.internal.v1.PublishThreadPreviewRequest\x1a3.ora.cloud.internal.v1.PublishThreadPreviewResponse\x12\x7f\n" + "\x14TakeOverThreadEvents\x122.ora.cloud.internal.v1.TakeOverThreadEventsRequest\x1a3.ora.cloud.internal.v1.TakeOverThreadEventsResponse\x12|\n" + "\x13ClaimThreadCommands\x121.ora.cloud.internal.v1.ClaimThreadCommandsRequest\x1a2.ora.cloud.internal.v1.ClaimThreadCommandsResponse\x12\x97\x01\n" + "\x1cRecordThreadCommandDelivered\x12:.ora.cloud.internal.v1.RecordThreadCommandDeliveredRequest\x1a;.ora.cloud.internal.v1.RecordThreadCommandDeliveredResponse\x12|\n" + - "\x13GrantRevisionUpload\x121.ora.cloud.internal.v1.GrantRevisionUploadRequest\x1a2.ora.cloud.internal.v1.GrantRevisionUploadResponse\x12\x82\x01\n" + - "\x15GrantRevisionDownload\x123.ora.cloud.internal.v1.GrantRevisionDownloadRequest\x1a4.ora.cloud.internal.v1.GrantRevisionDownloadResponseB\xdf\x01\n" + + "\x13GrantRevisionUpload\x121.ora.cloud.internal.v1.GrantRevisionUploadRequest\x1a2.ora.cloud.internal.v1.GrantRevisionUploadResponseB\xdf\x01\n" + "\x19com.ora.cloud.internal.v1B\x0eAgentRunsProtoP\x01Z;github.com/wanglongan587/cloud/internal/controlpb;controlpb\xa2\x02\x03OCI\xaa\x02\x15Ora.Cloud.Internal.V1\xca\x02\x15Ora\\Cloud\\Internal\\V1\xe2\x02!Ora\\Cloud\\Internal\\V1\\GPBMetadata\xea\x02\x18Ora::Cloud::Internal::V1b\x06proto3" var ( @@ -1206,7 +1102,7 @@ func file_ora_cloud_internal_v1_agent_runs_proto_rawDescGZIP() []byte { } var file_ora_cloud_internal_v1_agent_runs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_ora_cloud_internal_v1_agent_runs_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_ora_cloud_internal_v1_agent_runs_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_ora_cloud_internal_v1_agent_runs_proto_goTypes = []any{ (EndSessionReason)(0), // 0: ora.cloud.internal.v1.EndSessionReason (*ThreadEvent)(nil), // 1: ora.cloud.internal.v1.ThreadEvent @@ -1224,43 +1120,37 @@ var file_ora_cloud_internal_v1_agent_runs_proto_goTypes = []any{ (*RecordThreadCommandDeliveredResponse)(nil), // 13: ora.cloud.internal.v1.RecordThreadCommandDeliveredResponse (*GrantRevisionUploadRequest)(nil), // 14: ora.cloud.internal.v1.GrantRevisionUploadRequest (*GrantRevisionUploadResponse)(nil), // 15: ora.cloud.internal.v1.GrantRevisionUploadResponse - (*GrantRevisionDownloadRequest)(nil), // 16: ora.cloud.internal.v1.GrantRevisionDownloadRequest - (*GrantRevisionDownloadResponse)(nil), // 17: ora.cloud.internal.v1.GrantRevisionDownloadResponse - nil, // 18: ora.cloud.internal.v1.GrantRevisionUploadRequest.ChecksumsEntry - (*UserTurn)(nil), // 19: ora.cloud.internal.v1.UserTurn - (*WorkTarget)(nil), // 20: ora.cloud.internal.v1.WorkTarget - (*UploadGrant)(nil), // 21: ora.cloud.internal.v1.UploadGrant - (*DownloadGrant)(nil), // 22: ora.cloud.internal.v1.DownloadGrant + nil, // 16: ora.cloud.internal.v1.GrantRevisionUploadRequest.ChecksumsEntry + (*UserTurn)(nil), // 17: ora.cloud.internal.v1.UserTurn + (*WorkTarget)(nil), // 18: ora.cloud.internal.v1.WorkTarget + (*UploadGrant)(nil), // 19: ora.cloud.internal.v1.UploadGrant } var file_ora_cloud_internal_v1_agent_runs_proto_depIdxs = []int32{ 2, // 0: ora.cloud.internal.v1.PublishThreadPreviewRequest.blocks:type_name -> ora.cloud.internal.v1.ThreadPreviewBlock 1, // 1: ora.cloud.internal.v1.TakeOverThreadEventsRequest.events:type_name -> ora.cloud.internal.v1.ThreadEvent - 19, // 2: ora.cloud.internal.v1.SubmitUserTurn.turn:type_name -> ora.cloud.internal.v1.UserTurn + 17, // 2: ora.cloud.internal.v1.SubmitUserTurn.turn:type_name -> ora.cloud.internal.v1.UserTurn 0, // 3: ora.cloud.internal.v1.EndSession.reason:type_name -> ora.cloud.internal.v1.EndSessionReason - 20, // 4: ora.cloud.internal.v1.ThreadCommand.target:type_name -> ora.cloud.internal.v1.WorkTarget + 18, // 4: ora.cloud.internal.v1.ThreadCommand.target:type_name -> ora.cloud.internal.v1.WorkTarget 7, // 5: ora.cloud.internal.v1.ThreadCommand.submit_user_turn:type_name -> ora.cloud.internal.v1.SubmitUserTurn 8, // 6: ora.cloud.internal.v1.ThreadCommand.end_session:type_name -> ora.cloud.internal.v1.EndSession 9, // 7: ora.cloud.internal.v1.ClaimThreadCommandsResponse.commands:type_name -> ora.cloud.internal.v1.ThreadCommand - 18, // 8: ora.cloud.internal.v1.GrantRevisionUploadRequest.checksums:type_name -> ora.cloud.internal.v1.GrantRevisionUploadRequest.ChecksumsEntry - 21, // 9: ora.cloud.internal.v1.GrantRevisionUploadResponse.grants:type_name -> ora.cloud.internal.v1.UploadGrant - 22, // 10: ora.cloud.internal.v1.GrantRevisionDownloadResponse.grants:type_name -> ora.cloud.internal.v1.DownloadGrant - 3, // 11: ora.cloud.internal.v1.AgentRunService.PublishThreadPreview:input_type -> ora.cloud.internal.v1.PublishThreadPreviewRequest - 5, // 12: ora.cloud.internal.v1.AgentRunService.TakeOverThreadEvents:input_type -> ora.cloud.internal.v1.TakeOverThreadEventsRequest - 10, // 13: ora.cloud.internal.v1.AgentRunService.ClaimThreadCommands:input_type -> ora.cloud.internal.v1.ClaimThreadCommandsRequest - 12, // 14: ora.cloud.internal.v1.AgentRunService.RecordThreadCommandDelivered:input_type -> ora.cloud.internal.v1.RecordThreadCommandDeliveredRequest - 14, // 15: ora.cloud.internal.v1.AgentRunService.GrantRevisionUpload:input_type -> ora.cloud.internal.v1.GrantRevisionUploadRequest - 16, // 16: ora.cloud.internal.v1.AgentRunService.GrantRevisionDownload:input_type -> ora.cloud.internal.v1.GrantRevisionDownloadRequest - 4, // 17: ora.cloud.internal.v1.AgentRunService.PublishThreadPreview:output_type -> ora.cloud.internal.v1.PublishThreadPreviewResponse - 6, // 18: ora.cloud.internal.v1.AgentRunService.TakeOverThreadEvents:output_type -> ora.cloud.internal.v1.TakeOverThreadEventsResponse - 11, // 19: ora.cloud.internal.v1.AgentRunService.ClaimThreadCommands:output_type -> ora.cloud.internal.v1.ClaimThreadCommandsResponse - 13, // 20: ora.cloud.internal.v1.AgentRunService.RecordThreadCommandDelivered:output_type -> ora.cloud.internal.v1.RecordThreadCommandDeliveredResponse - 15, // 21: ora.cloud.internal.v1.AgentRunService.GrantRevisionUpload:output_type -> ora.cloud.internal.v1.GrantRevisionUploadResponse - 17, // 22: ora.cloud.internal.v1.AgentRunService.GrantRevisionDownload:output_type -> ora.cloud.internal.v1.GrantRevisionDownloadResponse - 17, // [17:23] is the sub-list for method output_type - 11, // [11:17] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 16, // 8: ora.cloud.internal.v1.GrantRevisionUploadRequest.checksums:type_name -> ora.cloud.internal.v1.GrantRevisionUploadRequest.ChecksumsEntry + 19, // 9: ora.cloud.internal.v1.GrantRevisionUploadResponse.grants:type_name -> ora.cloud.internal.v1.UploadGrant + 3, // 10: ora.cloud.internal.v1.AgentRunService.PublishThreadPreview:input_type -> ora.cloud.internal.v1.PublishThreadPreviewRequest + 5, // 11: ora.cloud.internal.v1.AgentRunService.TakeOverThreadEvents:input_type -> ora.cloud.internal.v1.TakeOverThreadEventsRequest + 10, // 12: ora.cloud.internal.v1.AgentRunService.ClaimThreadCommands:input_type -> ora.cloud.internal.v1.ClaimThreadCommandsRequest + 12, // 13: ora.cloud.internal.v1.AgentRunService.RecordThreadCommandDelivered:input_type -> ora.cloud.internal.v1.RecordThreadCommandDeliveredRequest + 14, // 14: ora.cloud.internal.v1.AgentRunService.GrantRevisionUpload:input_type -> ora.cloud.internal.v1.GrantRevisionUploadRequest + 4, // 15: ora.cloud.internal.v1.AgentRunService.PublishThreadPreview:output_type -> ora.cloud.internal.v1.PublishThreadPreviewResponse + 6, // 16: ora.cloud.internal.v1.AgentRunService.TakeOverThreadEvents:output_type -> ora.cloud.internal.v1.TakeOverThreadEventsResponse + 11, // 17: ora.cloud.internal.v1.AgentRunService.ClaimThreadCommands:output_type -> ora.cloud.internal.v1.ClaimThreadCommandsResponse + 13, // 18: ora.cloud.internal.v1.AgentRunService.RecordThreadCommandDelivered:output_type -> ora.cloud.internal.v1.RecordThreadCommandDeliveredResponse + 15, // 19: ora.cloud.internal.v1.AgentRunService.GrantRevisionUpload:output_type -> ora.cloud.internal.v1.GrantRevisionUploadResponse + 15, // [15:20] is the sub-list for method output_type + 10, // [10:15] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_ora_cloud_internal_v1_agent_runs_proto_init() } @@ -1281,7 +1171,7 @@ func file_ora_cloud_internal_v1_agent_runs_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ora_cloud_internal_v1_agent_runs_proto_rawDesc), len(file_ora_cloud_internal_v1_agent_runs_proto_rawDesc)), NumEnums: 1, - NumMessages: 18, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/controlpb/agent_runs_grpc.pb.go b/internal/controlpb/agent_runs_grpc.pb.go index fdfe08164..c3b4b4df4 100644 --- a/internal/controlpb/agent_runs_grpc.pb.go +++ b/internal/controlpb/agent_runs_grpc.pb.go @@ -24,7 +24,6 @@ const ( AgentRunService_ClaimThreadCommands_FullMethodName = "/ora.cloud.internal.v1.AgentRunService/ClaimThreadCommands" AgentRunService_RecordThreadCommandDelivered_FullMethodName = "/ora.cloud.internal.v1.AgentRunService/RecordThreadCommandDelivered" AgentRunService_GrantRevisionUpload_FullMethodName = "/ora.cloud.internal.v1.AgentRunService/GrantRevisionUpload" - AgentRunService_GrantRevisionDownload_FullMethodName = "/ora.cloud.internal.v1.AgentRunService/GrantRevisionDownload" ) // AgentRunServiceClient is the client API for AgentRunService service. @@ -48,9 +47,6 @@ type AgentRunServiceClient interface { // Issues fresh upload grants for a registered delivery execution that has no result yet. Not a // write: nothing is recorded, and every call issues new grants. GrantRevisionUpload(ctx context.Context, in *GrantRevisionUploadRequest, opts ...grpc.CallOption) (*GrantRevisionUploadResponse, error) - // Issues a fresh read grant for the prior Revision bundle of a registered session execution that - // has no result yet. Not a write: nothing is recorded, and every call issues a new grant. - GrantRevisionDownload(ctx context.Context, in *GrantRevisionDownloadRequest, opts ...grpc.CallOption) (*GrantRevisionDownloadResponse, error) } type agentRunServiceClient struct { @@ -111,16 +107,6 @@ func (c *agentRunServiceClient) GrantRevisionUpload(ctx context.Context, in *Gra return out, nil } -func (c *agentRunServiceClient) GrantRevisionDownload(ctx context.Context, in *GrantRevisionDownloadRequest, opts ...grpc.CallOption) (*GrantRevisionDownloadResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GrantRevisionDownloadResponse) - err := c.cc.Invoke(ctx, AgentRunService_GrantRevisionDownload_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - // AgentRunServiceServer is the server API for AgentRunService service. // All implementations must embed UnimplementedAgentRunServiceServer // for forward compatibility. @@ -142,9 +128,6 @@ type AgentRunServiceServer interface { // Issues fresh upload grants for a registered delivery execution that has no result yet. Not a // write: nothing is recorded, and every call issues new grants. GrantRevisionUpload(context.Context, *GrantRevisionUploadRequest) (*GrantRevisionUploadResponse, error) - // Issues a fresh read grant for the prior Revision bundle of a registered session execution that - // has no result yet. Not a write: nothing is recorded, and every call issues a new grant. - GrantRevisionDownload(context.Context, *GrantRevisionDownloadRequest) (*GrantRevisionDownloadResponse, error) mustEmbedUnimplementedAgentRunServiceServer() } @@ -170,9 +153,6 @@ func (UnimplementedAgentRunServiceServer) RecordThreadCommandDelivered(context.C func (UnimplementedAgentRunServiceServer) GrantRevisionUpload(context.Context, *GrantRevisionUploadRequest) (*GrantRevisionUploadResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GrantRevisionUpload not implemented") } -func (UnimplementedAgentRunServiceServer) GrantRevisionDownload(context.Context, *GrantRevisionDownloadRequest) (*GrantRevisionDownloadResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GrantRevisionDownload not implemented") -} func (UnimplementedAgentRunServiceServer) mustEmbedUnimplementedAgentRunServiceServer() {} func (UnimplementedAgentRunServiceServer) testEmbeddedByValue() {} @@ -284,24 +264,6 @@ func _AgentRunService_GrantRevisionUpload_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } -func _AgentRunService_GrantRevisionDownload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GrantRevisionDownloadRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(AgentRunServiceServer).GrantRevisionDownload(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: AgentRunService_GrantRevisionDownload_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(AgentRunServiceServer).GrantRevisionDownload(ctx, req.(*GrantRevisionDownloadRequest)) - } - return interceptor(ctx, in, info, handler) -} - // AgentRunService_ServiceDesc is the grpc.ServiceDesc for AgentRunService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -329,10 +291,6 @@ var AgentRunService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GrantRevisionUpload", Handler: _AgentRunService_GrantRevisionUpload_Handler, }, - { - MethodName: "GrantRevisionDownload", - Handler: _AgentRunService_GrantRevisionDownload_Handler, - }, }, Streams: []grpc.StreamDesc{}, Metadata: "ora/cloud/internal/v1/agent_runs.proto", diff --git a/internal/controlpb/executions.pb.go b/internal/controlpb/executions.pb.go index 1ba39bf22..2adbe01c8 100644 --- a/internal/controlpb/executions.pb.go +++ b/internal/controlpb/executions.pb.go @@ -24,14 +24,19 @@ const ( type CloneFailureReason int32 const ( - CloneFailureReason_CLONE_FAILURE_REASON_UNSPECIFIED CloneFailureReason = 0 + CloneFailureReason_CLONE_FAILURE_REASON_UNSPECIFIED CloneFailureReason = 0 + // The Git Server was unreachable, or answered 5xx until the retries ran out. CloneFailureReason_CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE CloneFailureReason = 1 - CloneFailureReason_CLONE_FAILURE_REASON_BRANCH_NOT_FOUND CloneFailureReason = 2 CloneFailureReason_CLONE_FAILURE_REASON_DESTINATION_CONFLICT CloneFailureReason = 3 CloneFailureReason_CLONE_FAILURE_REASON_OPERATION_FAILED CloneFailureReason = 4 // The attempt was terminated before Git reached its own verdict (Node stop, command deadline, // or an external signal) and its cleanup was confirmed; a new execution may simply retry. CloneFailureReason_CLONE_FAILURE_REASON_INTERRUPTED CloneFailureReason = 5 + // The Git Server answered that the snapshot ref is missing or not readable (it gives both the + // same answer). + CloneFailureReason_CLONE_FAILURE_REASON_SNAPSHOT_UNAVAILABLE CloneFailureReason = 6 + // The fetched snapshot ref did not point at base_commit. + CloneFailureReason_CLONE_FAILURE_REASON_BASE_COMMIT_MISMATCH CloneFailureReason = 7 ) // Enum value maps for CloneFailureReason. @@ -39,18 +44,20 @@ var ( CloneFailureReason_name = map[int32]string{ 0: "CLONE_FAILURE_REASON_UNSPECIFIED", 1: "CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE", - 2: "CLONE_FAILURE_REASON_BRANCH_NOT_FOUND", 3: "CLONE_FAILURE_REASON_DESTINATION_CONFLICT", 4: "CLONE_FAILURE_REASON_OPERATION_FAILED", 5: "CLONE_FAILURE_REASON_INTERRUPTED", + 6: "CLONE_FAILURE_REASON_SNAPSHOT_UNAVAILABLE", + 7: "CLONE_FAILURE_REASON_BASE_COMMIT_MISMATCH", } CloneFailureReason_value = map[string]int32{ "CLONE_FAILURE_REASON_UNSPECIFIED": 0, "CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE": 1, - "CLONE_FAILURE_REASON_BRANCH_NOT_FOUND": 2, "CLONE_FAILURE_REASON_DESTINATION_CONFLICT": 3, "CLONE_FAILURE_REASON_OPERATION_FAILED": 4, "CLONE_FAILURE_REASON_INTERRUPTED": 5, + "CLONE_FAILURE_REASON_SNAPSHOT_UNAVAILABLE": 6, + "CLONE_FAILURE_REASON_BASE_COMMIT_MISMATCH": 7, } ) @@ -135,11 +142,19 @@ func (x *NodeIdentity) GetNodeIncarnationId() string { return "" } +// A Workspace clone from a Binding snapshot on the Git Server. Cloud freezes all four fields before +// the clone step is handed to a Controller; the Controller forwards them verbatim. Checkout paths +// belong to the Node, never to the caller. type CloneSpec struct { state protoimpl.MessageState `protogen:"open.v1"` - // HTTPS or SSH repository URL. Checkout paths belong to the Node, never to the caller. - Repository string `protobuf:"bytes,1,opt,name=repository,proto3" json:"repository,omitempty"` - Branch string `protobuf:"bytes,2,opt,name=branch,proto3" json:"branch,omitempty"` + // https:////.git + RepositoryUrl string `protobuf:"bytes,3,opt,name=repository_url,json=repositoryUrl,proto3" json:"repository_url,omitempty"` + // refs/ora/upstreams//snapshots/ + SnapshotRef string `protobuf:"bytes,4,opt,name=snapshot_ref,json=snapshotRef,proto3" json:"snapshot_ref,omitempty"` + // The commit snapshot_ref points to; the checkout is verified against it. + BaseCommit string `protobuf:"bytes,5,opt,name=base_commit,json=baseCommit,proto3" json:"base_commit,omitempty"` + // refs/heads/workspaces//; is the local branch of the checkout. + WorkspaceRef string `protobuf:"bytes,6,opt,name=workspace_ref,json=workspaceRef,proto3" json:"workspace_ref,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -174,16 +189,30 @@ func (*CloneSpec) Descriptor() ([]byte, []int) { return file_ora_cloud_internal_v1_executions_proto_rawDescGZIP(), []int{1} } -func (x *CloneSpec) GetRepository() string { +func (x *CloneSpec) GetRepositoryUrl() string { if x != nil { - return x.Repository + return x.RepositoryUrl } return "" } -func (x *CloneSpec) GetBranch() string { +func (x *CloneSpec) GetSnapshotRef() string { if x != nil { - return x.Branch + return x.SnapshotRef + } + return "" +} + +func (x *CloneSpec) GetBaseCommit() string { + if x != nil { + return x.BaseCommit + } + return "" +} + +func (x *CloneSpec) GetWorkspaceRef() string { + if x != nil { + return x.WorkspaceRef } return "" } @@ -321,9 +350,10 @@ func (*ExecutionInput_AgentSession) isExecutionInput_Spec() {} func (*ExecutionInput_DeliverRevision) isExecutionInput_Spec() {} type CloneReady struct { - state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Commit string `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Must equal the input's base_commit. + Commit string `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -680,8 +710,7 @@ type WorkItem struct { // The IssueRun ID for Agent session and Revision delivery work. OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` Input *ExecutionInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` - // Set for Agent session and Revision delivery work; unset for a tenant-level clone, whose Node - // the Controller chooses. + // Always set: Agent session and Revision delivery work name their run Workspace sandbox. Target *WorkTarget `protobuf:"bytes,3,opt,name=target,proto3,oneof" json:"target,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1493,12 +1522,14 @@ const file_ora_cloud_internal_v1_executions_proto_rawDesc = "" + "&ora/cloud/internal/v1/executions.proto\x12\x15ora.cloud.internal.v1\x1a,ora/cloud/internal/v1/agent_executions.proto\x1a-ora/cloud/internal/v1/plugin_executions.proto\"W\n" + "\fNodeIdentity\x12\x17\n" + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12.\n" + - "\x13node_incarnation_id\x18\x02 \x01(\tR\x11nodeIncarnationId\"C\n" + - "\tCloneSpec\x12\x1e\n" + - "\n" + - "repository\x18\x01 \x01(\tR\n" + - "repository\x12\x16\n" + - "\x06branch\x18\x02 \x01(\tR\x06branch\"\xa4\x03\n" + + "\x13node_incarnation_id\x18\x02 \x01(\tR\x11nodeIncarnationId\"\xbb\x01\n" + + "\tCloneSpec\x12%\n" + + "\x0erepository_url\x18\x03 \x01(\tR\rrepositoryUrl\x12!\n" + + "\fsnapshot_ref\x18\x04 \x01(\tR\vsnapshotRef\x12\x1f\n" + + "\vbase_commit\x18\x05 \x01(\tR\n" + + "baseCommit\x12#\n" + + "\rworkspace_ref\x18\x06 \x01(\tR\fworkspaceRefJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\n" + + "repositoryR\x06branch\"\xa4\x03\n" + "\x0eExecutionInput\x128\n" + "\x05clone\x18\x01 \x01(\v2 .ora.cloud.internal.v1.CloneSpecH\x00R\x05clone\x12T\n" + "\x0finstall_plugins\x18\x02 \x01(\v2).ora.cloud.internal.v1.InstallPluginsSpecH\x00R\x0einstallPlugins\x12Q\n" + @@ -1584,14 +1615,15 @@ const file_ora_cloud_internal_v1_executions_proto_rawDesc = "" + "\x1cListPendingDispatchesRequest\x12\x17\n" + "\anode_id\x18\x01 \x01(\tR\x06nodeId\"a\n" + "\x1dListPendingDispatchesResponse\x12@\n" + - "\arecords\x18\x01 \x03(\v2&.ora.cloud.internal.v1.ExecutionRecordR\arecords*\x92\x02\n" + + "\arecords\x18\x01 \x03(\v2&.ora.cloud.internal.v1.ExecutionRecordR\arecords*\xf2\x02\n" + "\x12CloneFailureReason\x12$\n" + " CLONE_FAILURE_REASON_UNSPECIFIED\x10\x00\x12+\n" + - "'CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE\x10\x01\x12)\n" + - "%CLONE_FAILURE_REASON_BRANCH_NOT_FOUND\x10\x02\x12-\n" + + "'CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE\x10\x01\x12-\n" + ")CLONE_FAILURE_REASON_DESTINATION_CONFLICT\x10\x03\x12)\n" + "%CLONE_FAILURE_REASON_OPERATION_FAILED\x10\x04\x12$\n" + - " CLONE_FAILURE_REASON_INTERRUPTED\x10\x052\xc2\x05\n" + + " CLONE_FAILURE_REASON_INTERRUPTED\x10\x05\x12-\n" + + ")CLONE_FAILURE_REASON_SNAPSHOT_UNAVAILABLE\x10\x06\x12-\n" + + ")CLONE_FAILURE_REASON_BASE_COMMIT_MISMATCH\x10\a\"\x04\b\x02\x10\x02*%CLONE_FAILURE_REASON_BRANCH_NOT_FOUND2\xc2\x05\n" + "\x10ExecutionService\x12^\n" + "\tClaimWork\x12'.ora.cloud.internal.v1.ClaimWorkRequest\x1a(.ora.cloud.internal.v1.ClaimWorkResponse\x12m\n" + "\x0eRecordDispatch\x12,.ora.cloud.internal.v1.RecordDispatchRequest\x1a-.ora.cloud.internal.v1.RecordDispatchResponse\x12v\n" + diff --git a/internal/controlpb/operations.pb.go b/internal/controlpb/operations.pb.go index 646dcbaf3..adf21e9ab 100644 --- a/internal/controlpb/operations.pb.go +++ b/internal/controlpb/operations.pb.go @@ -733,13 +733,12 @@ func (x *Operation) GetErrorCode() string { return "" } +// The Project an operation belongs to. Its external repository, branch and credential reference +// never reach a Controller: a Workspace clones only the Git Server snapshot its clone step was +// handed (clone_input). type OperationProject struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - RepositoryUrl string `protobuf:"bytes,2,opt,name=repository_url,json=repositoryUrl,proto3" json:"repository_url,omitempty"` - DefaultBranch string `protobuf:"bytes,3,opt,name=default_branch,json=defaultBranch,proto3" json:"default_branch,omitempty"` - // Infrastructure reference to the repository credential; never the credential itself. - CredentialRef *string `protobuf:"bytes,4,opt,name=credential_ref,json=credentialRef,proto3,oneof" json:"credential_ref,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -781,27 +780,6 @@ func (x *OperationProject) GetId() string { return "" } -func (x *OperationProject) GetRepositoryUrl() string { - if x != nil { - return x.RepositoryUrl - } - return "" -} - -func (x *OperationProject) GetDefaultBranch() string { - if x != nil { - return x.DefaultBranch - } - return "" -} - -func (x *OperationProject) GetCredentialRef() string { - if x != nil && x.CredentialRef != nil { - return *x.CredentialRef - } - return "" -} - type OperationWorkspace struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -813,7 +791,8 @@ type OperationWorkspace struct { RuntimeGeneration int64 `protobuf:"varint,5,opt,name=runtime_generation,json=runtimeGeneration,proto3" json:"runtime_generation,omitempty"` AdmissionOpen bool `protobuf:"varint,6,opt,name=admission_open,json=admissionOpen,proto3" json:"admission_open,omitempty"` AdmissionEpoch int64 `protobuf:"varint,7,opt,name=admission_epoch,json=admissionEpoch,proto3" json:"admission_epoch,omitempty"` - // The ref the Workspace's Node clones. + // The short name of the Workspace's branch on the Git Server + // (refs/heads/workspaces//) and of the checkout's local branch. RequestedRef string `protobuf:"bytes,8,opt,name=requested_ref,json=requestedRef,proto3" json:"requested_ref,omitempty"` // Commit of the Workspace's successful clone. BaseCommitId *string `protobuf:"bytes,9,opt,name=base_commit_id,json=baseCommitId,proto3,oneof" json:"base_commit_id,omitempty"` @@ -1981,7 +1960,12 @@ type OperationSnapshot struct { // The input the plugin step's execution must carry (install_plugins or remove_plugins), fixed when // the operation entered the step. Unset outside the plugin step or when the set is empty, in which // case the step advances without an execution. - PluginInput *ExecutionInput `protobuf:"bytes,9,opt,name=plugin_input,json=pluginInput,proto3,oneof" json:"plugin_input,omitempty"` + PluginInput *ExecutionInput `protobuf:"bytes,9,opt,name=plugin_input,json=pluginInput,proto3,oneof" json:"plugin_input,omitempty"` + // The input the clone step's execution must carry, frozen by Cloud from the Binding's current + // snapshot before the step was handed out and identical for every attempt of the operation. Set + // whenever the operation is in the clone step; the Controller forwards it verbatim to + // RecordDispatch and the Node, and does not dispatch a clone step without it. + CloneInput *CloneSpec `protobuf:"bytes,10,opt,name=clone_input,json=cloneInput,proto3,oneof" json:"clone_input,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2079,6 +2063,13 @@ func (x *OperationSnapshot) GetPluginInput() *ExecutionInput { return nil } +func (x *OperationSnapshot) GetCloneInput() *CloneSpec { + if x != nil { + return x.CloneInput + } + return nil +} + type ClaimOperationRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Epoch int64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` @@ -3096,13 +3087,9 @@ const file_ora_cloud_internal_v1_operations_proto_rawDesc = "" + "error_code\x18\t \x01(\tH\x02R\terrorCode\x88\x01\x01B\x0f\n" + "\r_workspace_idB\x13\n" + "\x11_controller_epochB\r\n" + - "\v_error_code\"\xaf\x01\n" + + "\v_error_code\"d\n" + "\x10OperationProject\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + - "\x0erepository_url\x18\x02 \x01(\tR\rrepositoryUrl\x12%\n" + - "\x0edefault_branch\x18\x03 \x01(\tR\rdefaultBranch\x12*\n" + - "\x0ecredential_ref\x18\x04 \x01(\tH\x00R\rcredentialRef\x88\x01\x01B\x11\n" + - "\x0f_credential_ref\"\xa6\x03\n" + + "\x02id\x18\x01 \x01(\tR\x02idJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\x0erepository_urlR\x0edefault_branchR\x0ecredential_ref\"\xa6\x03\n" + "\x12OperationWorkspace\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x128\n" + "\x04kind\x18\x02 \x01(\x0e2$.ora.cloud.internal.v1.WorkspaceKindR\x04kind\x12#\n" + @@ -3197,7 +3184,7 @@ const file_ora_cloud_internal_v1_operations_proto_rawDesc = "" + "\f_external_idB\v\n" + "\t_evidenceB\n" + "\n" + - "\b_failure\"\x8c\x05\n" + + "\b_failure\"\xe4\x05\n" + "\x11OperationSnapshot\x12>\n" + "\toperation\x18\x01 \x01(\v2 .ora.cloud.internal.v1.OperationR\toperation\x12A\n" + "\aproject\x18\x02 \x01(\v2'.ora.cloud.internal.v1.OperationProjectR\aproject\x12I\n" + @@ -3209,8 +3196,12 @@ const file_ora_cloud_internal_v1_operations_proto_rawDesc = "" + "\aeffects\x18\x06 \x03(\v2\x1d.ora.cloud.internal.v1.EffectR\aeffects\x12>\n" + "\x06clones\x18\a \x03(\v2&.ora.cloud.internal.v1.ExecutionRecordR\x06clones\x12S\n" + "\x11plugin_executions\x18\b \x03(\v2&.ora.cloud.internal.v1.ExecutionRecordR\x10pluginExecutions\x12M\n" + - "\fplugin_input\x18\t \x01(\v2%.ora.cloud.internal.v1.ExecutionInputH\x00R\vpluginInput\x88\x01\x01B\x0f\n" + - "\r_plugin_input\"-\n" + + "\fplugin_input\x18\t \x01(\v2%.ora.cloud.internal.v1.ExecutionInputH\x00R\vpluginInput\x88\x01\x01\x12F\n" + + "\vclone_input\x18\n" + + " \x01(\v2 .ora.cloud.internal.v1.CloneSpecH\x01R\n" + + "cloneInput\x88\x01\x01B\x0f\n" + + "\r_plugin_inputB\x0e\n" + + "\f_clone_input\"-\n" + "\x15ClaimOperationRequest\x12\x14\n" + "\x05epoch\x18\x01 \x01(\x03R\x05epoch\"p\n" + "\x16ClaimOperationResponse\x12I\n" + @@ -3443,7 +3434,8 @@ var file_ora_cloud_internal_v1_operations_proto_goTypes = []any{ (*NodeRecord)(nil), // 44: ora.cloud.internal.v1.NodeRecord (*ExecutionRecord)(nil), // 45: ora.cloud.internal.v1.ExecutionRecord (*ExecutionInput)(nil), // 46: ora.cloud.internal.v1.ExecutionInput - (*ExecutionResult)(nil), // 47: ora.cloud.internal.v1.ExecutionResult + (*CloneSpec)(nil), // 47: ora.cloud.internal.v1.CloneSpec + (*ExecutionResult)(nil), // 48: ora.cloud.internal.v1.ExecutionResult } var file_ora_cloud_internal_v1_operations_proto_depIdxs = []int32{ 0, // 0: ora.cloud.internal.v1.Operation.kind:type_name -> ora.cloud.internal.v1.OperationKind @@ -3475,48 +3467,49 @@ var file_ora_cloud_internal_v1_operations_proto_depIdxs = []int32{ 45, // 26: ora.cloud.internal.v1.OperationSnapshot.clones:type_name -> ora.cloud.internal.v1.ExecutionRecord 45, // 27: ora.cloud.internal.v1.OperationSnapshot.plugin_executions:type_name -> ora.cloud.internal.v1.ExecutionRecord 46, // 28: ora.cloud.internal.v1.OperationSnapshot.plugin_input:type_name -> ora.cloud.internal.v1.ExecutionInput - 28, // 29: ora.cloud.internal.v1.ClaimOperationResponse.snapshot:type_name -> ora.cloud.internal.v1.OperationSnapshot - 4, // 30: ora.cloud.internal.v1.PlanEffectRequest.kind:type_name -> ora.cloud.internal.v1.EffectKind - 27, // 31: ora.cloud.internal.v1.PlanEffectResponse.effect:type_name -> ora.cloud.internal.v1.Effect - 10, // 32: ora.cloud.internal.v1.PlanEffectResponse.operation:type_name -> ora.cloud.internal.v1.Operation - 5, // 33: ora.cloud.internal.v1.RecordEffectResultRequest.state:type_name -> ora.cloud.internal.v1.EffectState - 26, // 34: ora.cloud.internal.v1.RecordEffectResultRequest.evidence:type_name -> ora.cloud.internal.v1.EffectEvidence - 27, // 35: ora.cloud.internal.v1.RecordEffectResultResponse.effect:type_name -> ora.cloud.internal.v1.Effect - 10, // 36: ora.cloud.internal.v1.RecordEffectResultResponse.operation:type_name -> ora.cloud.internal.v1.Operation - 10, // 37: ora.cloud.internal.v1.AdvanceOperationResponse.operation:type_name -> ora.cloud.internal.v1.Operation - 6, // 38: ora.cloud.internal.v1.DeferOperationRequest.state:type_name -> ora.cloud.internal.v1.DeferState - 7, // 39: ora.cloud.internal.v1.DeferOperationRequest.reason:type_name -> ora.cloud.internal.v1.DeferReason - 10, // 40: ora.cloud.internal.v1.DeferOperationResponse.operation:type_name -> ora.cloud.internal.v1.Operation - 8, // 41: ora.cloud.internal.v1.RecordProvisioningFactRequest.kind:type_name -> ora.cloud.internal.v1.ProvisioningFactKind - 5, // 42: ora.cloud.internal.v1.RecordProvisioningFactRequest.state:type_name -> ora.cloud.internal.v1.EffectState - 26, // 43: ora.cloud.internal.v1.RecordProvisioningFactRequest.evidence:type_name -> ora.cloud.internal.v1.EffectEvidence - 47, // 44: ora.cloud.internal.v1.RecordProvisioningFactRequest.execution_result:type_name -> ora.cloud.internal.v1.ExecutionResult - 9, // 45: ora.cloud.internal.v1.RecordProvisioningFactRequest.outcome:type_name -> ora.cloud.internal.v1.ProvisioningOutcome - 10, // 46: ora.cloud.internal.v1.RecordProvisioningFactResponse.operation:type_name -> ora.cloud.internal.v1.Operation - 27, // 47: ora.cloud.internal.v1.RecordProvisioningFactResponse.effect:type_name -> ora.cloud.internal.v1.Effect - 45, // 48: ora.cloud.internal.v1.RecordProvisioningFactResponse.execution:type_name -> ora.cloud.internal.v1.ExecutionRecord - 13, // 49: ora.cloud.internal.v1.LiveSandbox.sandbox:type_name -> ora.cloud.internal.v1.SandboxRecord - 44, // 50: ora.cloud.internal.v1.LiveSandbox.nodes:type_name -> ora.cloud.internal.v1.NodeRecord - 42, // 51: ora.cloud.internal.v1.ListLiveSandboxesResponse.sandboxes:type_name -> ora.cloud.internal.v1.LiveSandbox - 29, // 52: ora.cloud.internal.v1.WorkspaceOperationService.ClaimOperation:input_type -> ora.cloud.internal.v1.ClaimOperationRequest - 31, // 53: ora.cloud.internal.v1.WorkspaceOperationService.PlanEffect:input_type -> ora.cloud.internal.v1.PlanEffectRequest - 33, // 54: ora.cloud.internal.v1.WorkspaceOperationService.RecordEffectResult:input_type -> ora.cloud.internal.v1.RecordEffectResultRequest - 35, // 55: ora.cloud.internal.v1.WorkspaceOperationService.AdvanceOperation:input_type -> ora.cloud.internal.v1.AdvanceOperationRequest - 37, // 56: ora.cloud.internal.v1.WorkspaceOperationService.DeferOperation:input_type -> ora.cloud.internal.v1.DeferOperationRequest - 39, // 57: ora.cloud.internal.v1.WorkspaceOperationService.RecordProvisioningFact:input_type -> ora.cloud.internal.v1.RecordProvisioningFactRequest - 41, // 58: ora.cloud.internal.v1.WorkspaceOperationService.ListLiveSandboxes:input_type -> ora.cloud.internal.v1.ListLiveSandboxesRequest - 30, // 59: ora.cloud.internal.v1.WorkspaceOperationService.ClaimOperation:output_type -> ora.cloud.internal.v1.ClaimOperationResponse - 32, // 60: ora.cloud.internal.v1.WorkspaceOperationService.PlanEffect:output_type -> ora.cloud.internal.v1.PlanEffectResponse - 34, // 61: ora.cloud.internal.v1.WorkspaceOperationService.RecordEffectResult:output_type -> ora.cloud.internal.v1.RecordEffectResultResponse - 36, // 62: ora.cloud.internal.v1.WorkspaceOperationService.AdvanceOperation:output_type -> ora.cloud.internal.v1.AdvanceOperationResponse - 38, // 63: ora.cloud.internal.v1.WorkspaceOperationService.DeferOperation:output_type -> ora.cloud.internal.v1.DeferOperationResponse - 40, // 64: ora.cloud.internal.v1.WorkspaceOperationService.RecordProvisioningFact:output_type -> ora.cloud.internal.v1.RecordProvisioningFactResponse - 43, // 65: ora.cloud.internal.v1.WorkspaceOperationService.ListLiveSandboxes:output_type -> ora.cloud.internal.v1.ListLiveSandboxesResponse - 59, // [59:66] is the sub-list for method output_type - 52, // [52:59] is the sub-list for method input_type - 52, // [52:52] is the sub-list for extension type_name - 52, // [52:52] is the sub-list for extension extendee - 0, // [0:52] is the sub-list for field type_name + 47, // 29: ora.cloud.internal.v1.OperationSnapshot.clone_input:type_name -> ora.cloud.internal.v1.CloneSpec + 28, // 30: ora.cloud.internal.v1.ClaimOperationResponse.snapshot:type_name -> ora.cloud.internal.v1.OperationSnapshot + 4, // 31: ora.cloud.internal.v1.PlanEffectRequest.kind:type_name -> ora.cloud.internal.v1.EffectKind + 27, // 32: ora.cloud.internal.v1.PlanEffectResponse.effect:type_name -> ora.cloud.internal.v1.Effect + 10, // 33: ora.cloud.internal.v1.PlanEffectResponse.operation:type_name -> ora.cloud.internal.v1.Operation + 5, // 34: ora.cloud.internal.v1.RecordEffectResultRequest.state:type_name -> ora.cloud.internal.v1.EffectState + 26, // 35: ora.cloud.internal.v1.RecordEffectResultRequest.evidence:type_name -> ora.cloud.internal.v1.EffectEvidence + 27, // 36: ora.cloud.internal.v1.RecordEffectResultResponse.effect:type_name -> ora.cloud.internal.v1.Effect + 10, // 37: ora.cloud.internal.v1.RecordEffectResultResponse.operation:type_name -> ora.cloud.internal.v1.Operation + 10, // 38: ora.cloud.internal.v1.AdvanceOperationResponse.operation:type_name -> ora.cloud.internal.v1.Operation + 6, // 39: ora.cloud.internal.v1.DeferOperationRequest.state:type_name -> ora.cloud.internal.v1.DeferState + 7, // 40: ora.cloud.internal.v1.DeferOperationRequest.reason:type_name -> ora.cloud.internal.v1.DeferReason + 10, // 41: ora.cloud.internal.v1.DeferOperationResponse.operation:type_name -> ora.cloud.internal.v1.Operation + 8, // 42: ora.cloud.internal.v1.RecordProvisioningFactRequest.kind:type_name -> ora.cloud.internal.v1.ProvisioningFactKind + 5, // 43: ora.cloud.internal.v1.RecordProvisioningFactRequest.state:type_name -> ora.cloud.internal.v1.EffectState + 26, // 44: ora.cloud.internal.v1.RecordProvisioningFactRequest.evidence:type_name -> ora.cloud.internal.v1.EffectEvidence + 48, // 45: ora.cloud.internal.v1.RecordProvisioningFactRequest.execution_result:type_name -> ora.cloud.internal.v1.ExecutionResult + 9, // 46: ora.cloud.internal.v1.RecordProvisioningFactRequest.outcome:type_name -> ora.cloud.internal.v1.ProvisioningOutcome + 10, // 47: ora.cloud.internal.v1.RecordProvisioningFactResponse.operation:type_name -> ora.cloud.internal.v1.Operation + 27, // 48: ora.cloud.internal.v1.RecordProvisioningFactResponse.effect:type_name -> ora.cloud.internal.v1.Effect + 45, // 49: ora.cloud.internal.v1.RecordProvisioningFactResponse.execution:type_name -> ora.cloud.internal.v1.ExecutionRecord + 13, // 50: ora.cloud.internal.v1.LiveSandbox.sandbox:type_name -> ora.cloud.internal.v1.SandboxRecord + 44, // 51: ora.cloud.internal.v1.LiveSandbox.nodes:type_name -> ora.cloud.internal.v1.NodeRecord + 42, // 52: ora.cloud.internal.v1.ListLiveSandboxesResponse.sandboxes:type_name -> ora.cloud.internal.v1.LiveSandbox + 29, // 53: ora.cloud.internal.v1.WorkspaceOperationService.ClaimOperation:input_type -> ora.cloud.internal.v1.ClaimOperationRequest + 31, // 54: ora.cloud.internal.v1.WorkspaceOperationService.PlanEffect:input_type -> ora.cloud.internal.v1.PlanEffectRequest + 33, // 55: ora.cloud.internal.v1.WorkspaceOperationService.RecordEffectResult:input_type -> ora.cloud.internal.v1.RecordEffectResultRequest + 35, // 56: ora.cloud.internal.v1.WorkspaceOperationService.AdvanceOperation:input_type -> ora.cloud.internal.v1.AdvanceOperationRequest + 37, // 57: ora.cloud.internal.v1.WorkspaceOperationService.DeferOperation:input_type -> ora.cloud.internal.v1.DeferOperationRequest + 39, // 58: ora.cloud.internal.v1.WorkspaceOperationService.RecordProvisioningFact:input_type -> ora.cloud.internal.v1.RecordProvisioningFactRequest + 41, // 59: ora.cloud.internal.v1.WorkspaceOperationService.ListLiveSandboxes:input_type -> ora.cloud.internal.v1.ListLiveSandboxesRequest + 30, // 60: ora.cloud.internal.v1.WorkspaceOperationService.ClaimOperation:output_type -> ora.cloud.internal.v1.ClaimOperationResponse + 32, // 61: ora.cloud.internal.v1.WorkspaceOperationService.PlanEffect:output_type -> ora.cloud.internal.v1.PlanEffectResponse + 34, // 62: ora.cloud.internal.v1.WorkspaceOperationService.RecordEffectResult:output_type -> ora.cloud.internal.v1.RecordEffectResultResponse + 36, // 63: ora.cloud.internal.v1.WorkspaceOperationService.AdvanceOperation:output_type -> ora.cloud.internal.v1.AdvanceOperationResponse + 38, // 64: ora.cloud.internal.v1.WorkspaceOperationService.DeferOperation:output_type -> ora.cloud.internal.v1.DeferOperationResponse + 40, // 65: ora.cloud.internal.v1.WorkspaceOperationService.RecordProvisioningFact:output_type -> ora.cloud.internal.v1.RecordProvisioningFactResponse + 43, // 66: ora.cloud.internal.v1.WorkspaceOperationService.ListLiveSandboxes:output_type -> ora.cloud.internal.v1.ListLiveSandboxesResponse + 60, // [60:67] is the sub-list for method output_type + 53, // [53:60] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name } func init() { file_ora_cloud_internal_v1_operations_proto_init() } @@ -3527,7 +3520,6 @@ func file_ora_cloud_internal_v1_operations_proto_init() { file_ora_cloud_internal_v1_executions_proto_init() file_ora_cloud_internal_v1_nodes_proto_init() file_ora_cloud_internal_v1_operations_proto_msgTypes[0].OneofWrappers = []any{} - file_ora_cloud_internal_v1_operations_proto_msgTypes[1].OneofWrappers = []any{} file_ora_cloud_internal_v1_operations_proto_msgTypes[2].OneofWrappers = []any{} file_ora_cloud_internal_v1_operations_proto_msgTypes[3].OneofWrappers = []any{} file_ora_cloud_internal_v1_operations_proto_msgTypes[7].OneofWrappers = []any{} diff --git a/internal/core/README.en.md b/internal/core/README.en.md index 87887e72b..7e262f4dc 100644 --- a/internal/core/README.en.md +++ b/internal/core/README.en.md @@ -23,7 +23,7 @@ This package owns business state, authorization, transactions and recovery decis ## Store and transaction boundaries -- `NewStore` obtains `*sql.DB` from injected GORM and wires the real Workflow Directory/Forms; it neither connects nor migrates nor creates a global database. `NewDevelopmentStore` additionally permits the retired clone fixture. +- `NewStore` obtains `*sql.DB` from injected GORM and wires the real Workflow Directory/Forms; it neither connects nor migrates nor creates a global database. - `transact` takes `pg_advisory_xact_lock(67420911)` inside PostgreSQL, handles domain Faults and post-commit notifications. Hooks must use the supplied transaction, never start a nested Store transaction. - Network, Git, filesystem and object-store calls happen outside transactions; callers recheck identity, version and authorization afterwards. Archive reads, publish-source checks and credential confirmation have dedicated entrypoints. - `Object` is the boundary JSON value. `Fault` and `ErrorCode` convert expected failures and database errors into safe public errors. Domain panics are converted only at the transaction control boundary; unexpected panics must not be swallowed. diff --git a/internal/core/README.md b/internal/core/README.md index 0a3ebee94..300add565 100644 --- a/internal/core/README.md +++ b/internal/core/README.md @@ -23,7 +23,7 @@ ## Store 与事务边界 -- `NewStore` 从注入的 GORM 获取 `*sql.DB`,装配真实 Workflow Directory/Forms;不连接、迁移或建立全局数据库。`NewDevelopmentStore` 额外允许旧 clone 夹具。 +- `NewStore` 从注入的 GORM 获取 `*sql.DB`,装配真实 Workflow Directory/Forms;不连接、迁移或建立全局数据库。 - `transact` 在 PostgreSQL 事务内取得 `pg_advisory_xact_lock(67420911)`,处理领域 Fault 和提交后的通知。调用方必须使用传入的事务执行钩子,不能嵌套开启 Store 事务。 - 外部网络、Git、文件和对象存储调用必须在事务外;返回后按原身份、版本和授权重新核对。归档读取、发布源核对与凭据确认有专用入口。 - `Object` 是边界 JSON 值;`Fault` 与 `ErrorCode` 将可预期错误和数据库失败转换为安全公开错误。领域内部的 panic 只在事务控制边界转换,不能吞掉未知 panic。 diff --git a/internal/core/agent_run_delivery.go b/internal/core/agent_run_delivery.go index 56d931a1a..6e36ef9d5 100644 --- a/internal/core/agent_run_delivery.go +++ b/internal/core/agent_run_delivery.go @@ -81,8 +81,8 @@ func deliveryRetryBackoff(attempted int) time.Duration { // with D5's backoff (a retry never creates a new logical delivery — // the run, its Revision target and its Workspace are unchanged). // skipped → releasing with deliveryState=skipped: the deployment has no object -// store, so no attempt could ever succeed and retrying would hold the -// Workspace against a limit that can never be reached. +// store or no Git Server, so no attempt could ever succeed and retrying +// would hold the Workspace against a limit that can never be reached. // // Identity is re-read, never taken from the caller: the run must be the one the registered delivery // execution was dispatched for, through execution → execution_work → run, and the work item must be @@ -149,7 +149,7 @@ func (s *Store) settleDelivery(t *transaction, runID, executionID string, settle } return s.releaseAfterDelivery(t, o, outcome, revisionID) case deliveryStateSkipped: - // No object store is configured, so no delivery attempt was ever declared and none ever will + // No object store or Git Server is configured, so no delivery attempt was ever declared and none ever will // be: the control plane's enqueue recorded the skip instead of a work item. Release with // deliveryState=skipped, which is D4's own spelling for "no delivery happened", rather than // waiting on a give-up window that no attempt is running against. diff --git a/internal/core/agent_run_preparation.go b/internal/core/agent_run_preparation.go index 28c40fd2c..bef40e6a2 100644 --- a/internal/core/agent_run_preparation.go +++ b/internal/core/agent_run_preparation.go @@ -17,6 +17,10 @@ func withAgentRunDetails(t *transaction, run Object) Object { return withRevision(t, stripAgentRunSkeleton(run)) } +// preparationWaitCodes are the clone-step codes a run's preparation shows as they are: safe, bounded +// reasons the step is not making progress that name no Binding, snapshot or deployment detail. +var preparationWaitCodes = map[string]bool{snapshotNotReady: true, gitServerUnconfigured: true} + // repositorySyncFailed is the preparation error code of a clone step that ended because its // Binding's latest sync failed; syncErrorCode then names why. const repositorySyncFailed = "repository_sync_failed" @@ -29,8 +33,22 @@ func preparationSyncErrorCode(t *transaction, run, progress Object) any { if progress.S("errorCode") != repositorySyncFailed || run.S("workspaceId") == "" { return nil } - o := t.one(`SELECT o.error_code FROM repository_operations o JOIN workspaces w ON w.project_id=o.project_id - WHERE w.id=$1 AND o.type='repository_sync' AND o.state='failed' ORDER BY o.finished_at DESC,o.id DESC LIMIT 1`, run.S("workspaceId")) + // The clone step recorded the very sync it failed on, and only its ID (operation D2): the code is + // that sync's own error_code, read in the Workspace's Project so a recorded ID never reaches + // another tenant's sync. A recorded sync without a code (superseded or cancelled) has none to + // show. The latest failed sync of the Binding answers only for operations that recorded no ID. + var o Object + op := t.one("SELECT result FROM operations WHERE workspace_id=$1 AND kind='create_workspace' AND error_code=$2 ORDER BY created_at DESC,id DESC LIMIT 1", run.S("workspaceId"), repositorySyncFailed) + if id := op.O("result").S("syncOperationId"); id != "" { + if !validID(id) { + return nil + } + o = t.one(`SELECT o.error_code FROM repository_operations o JOIN workspaces w ON w.project_id=o.project_id AND w.tenant_id=o.tenant_id + WHERE w.id=$1 AND o.id=$2 AND o.type='repository_sync'`, run.S("workspaceId"), id) + } else { + o = t.one(`SELECT o.error_code FROM repository_operations o JOIN workspaces w ON w.project_id=o.project_id AND w.tenant_id=o.tenant_id + WHERE w.id=$1 AND o.type='repository_sync' AND o.state='failed' ORDER BY o.finished_at DESC,o.id DESC LIMIT 1`, run.S("workspaceId")) + } if o == nil || o.S("errorCode") == "" { return nil } @@ -71,7 +89,14 @@ func agentRunPreparation(t *transaction, run Object) Object { progress["errorCode"] = "clone_attempts_exhausted" return progress } - if code := op.S("errorCode"); deferCodes[code] { + // The Binding's sync ended without a snapshot to clone: the run fails for that reason, + // whatever the session layer later records (operation D2). + if op.S("state") == "failed" && op.S("errorCode") == repositorySyncFailed { + progress["stage"] = "failed" + progress["errorCode"] = repositorySyncFailed + return progress + } + if code := op.S("errorCode"); deferCodes[code] || preparationWaitCodes[code] { progress["errorCode"] = code } } diff --git a/internal/core/agent_run_preparation_db_test.go b/internal/core/agent_run_preparation_db_test.go index 6e810458a..e53eb0a12 100644 --- a/internal/core/agent_run_preparation_db_test.go +++ b/internal/core/agent_run_preparation_db_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -// syncErrorCode is filled only when the clone step ended with repository_sync_failed, and then -// from the Binding's latest failed sync. +// syncErrorCode is filled only when the clone step ended with repository_sync_failed. With no +// recorded clone operation to name the sync, it falls back to the Binding's latest failed sync. func TestPreparationSyncErrorCodeNamesTheFailedSync(t *testing.T) { s := commandStore(t) seed := seedDispatchScene(t, s.Pool, false) @@ -42,3 +42,81 @@ func TestPreparationSyncErrorCodeNamesTheFailedSync(t *testing.T) { } } } + +// Operation D2: a run whose Workspace waits for a snapshot shows the wait, and one whose clone step +// failed on the Binding's sync shows that failure with the code of the exact sync whose ID it +// recorded, read from the sync itself, even when a later failed sync carries another code. +func TestPreparationShowsSnapshotWaitsAndSyncFailures(t *testing.T) { + s := commandStore(t) + seed := seedDispatchScene(t, s.Pool, false) + var workspace string + if err := s.Pool.QueryRow("SELECT id FROM workspaces WHERE project_id=$1 ORDER BY created_at LIMIT 1", seed.project).Scan(&workspace); err != nil { + t.Fatal(err) + } + var actor string + if err := s.Pool.QueryRow("SELECT user_id FROM tenant_memberships WHERE tenant_id=$1 LIMIT 1", seed.tenant).Scan(&actor); err != nil { + t.Fatal(err) + } + operation := "11111111-1111-4111-8111-111111111111" + if _, err := s.Pool.Exec(`INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash,error_code,retry_at) + VALUES($1,$2,$3,$4,$5,'create_workspace','retry_wait','clone','{}','prep','prep','snapshot_not_ready',now())`, operation, seed.tenant, actor, seed.project, workspace); err != nil { + t.Fatal(err) + } + run := Object{"workspaceId": workspace, "phase": "provisioning", "status": "dispatched", "executorType": "agent"} + read := func() Object { + t.Helper() + var out Object + if err := s.view(context.Background(), func(tx *transaction) { + out = agentRunPreparation(tx, run) + out["syncErrorCode"] = preparationSyncErrorCode(tx, run, out) + }); err != nil { + t.Fatal(err) + } + return out + } + if got := read(); got.S("stage") != "clone" || got.S("errorCode") != snapshotNotReady || got["syncErrorCode"] != nil { + t.Fatalf("a waiting clone step = %v, want stage clone with snapshot_not_ready", got) + } + var binding string + if err := s.Pool.QueryRow("SELECT id FROM repository_bindings WHERE project_id=$1", seed.project).Scan(&binding); err != nil { + t.Fatal(err) + } + recorded, superseded := "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333" + for _, q := range []struct { + id, state string + code any + age string + }{ + {recorded, "failed", "branch_not_found", "3 minutes"}, + {superseded, "superseded", nil, "2 minutes"}, + // A later failed sync: the fallback would pick it, so it must not answer for a recorded ID. + {"44444444-4444-4444-8444-444444444444", "failed", "credential_rejected", "1 minute"}, + } { + if _, err := s.Pool.Exec(`INSERT INTO repository_operations(id,type,tenant_id,project_id,binding_id,state,trigger,error_code,finished_at) + VALUES($1,'repository_sync',$2,$3,$4,$5,'test',$6,clock_timestamp()-$7::interval)`, q.id, seed.tenant, seed.project, binding, q.state, q.code, q.age); err != nil { + t.Fatal(err) + } + } + fail := func(result string) { + t.Helper() + if _, err := s.Pool.Exec(`UPDATE operations SET state='failed',error_code='repository_sync_failed',retry_at=NULL,result=$2::jsonb WHERE id=$1`, operation, result); err != nil { + t.Fatal(err) + } + } + run["status"] = "failed" + for _, c := range []struct { + name, result string + want any + }{ + {"the recorded sync's own code", `{"syncOperationId":"` + recorded + `"}`, "branch_not_found"}, + {"a recorded sync without a code shows none", `{"syncOperationId":"` + superseded + `"}`, nil}, + {"a recorded ID naming no sync shows none", `{"syncOperationId":"55555555-5555-4555-8555-555555555555"}`, nil}, + {"a malformed recorded ID shows none", `{"syncOperationId":"x"}`, nil}, + {"no recorded ID falls back to the latest failed sync", `{}`, "credential_rejected"}, + } { + fail(c.result) + if got := read(); got.S("stage") != "failed" || got.S("errorCode") != repositorySyncFailed || got["syncErrorCode"] != c.want { + t.Errorf("%s: preparation = %v, want failed repository_sync_failed with syncErrorCode %v", c.name, got, c.want) + } + } +} diff --git a/internal/core/agent_run_resume.go b/internal/core/agent_run_resume.go index 2d66a2201..d8e9316b4 100644 --- a/internal/core/agent_run_resume.go +++ b/internal/core/agent_run_resume.go @@ -7,8 +7,9 @@ package core // Runs on one Issue are assumed serial: two overlapping runs may select the same prior Revision. // Session-end details a Node reports with AGENT_FAILED when it could not restore the prior Revision -// (contract decision D3). Only the base-unavailable one is permanent, so only it refuses the Revision -// for later selection (resume decision D3). +// (contract decision D3). Only the base-unavailable one, which now means the prior's delivery ref +// could not be fetched from the Git Server, is permanent, so only it refuses the Revision for later +// selection (resume decision D3). const ( priorRevisionUnavailable = "prior_revision_unavailable" priorRevisionBaseUnavailable = "prior_revision_base_unavailable" @@ -16,31 +17,31 @@ const ( // resumeChoice is the outcome of selecting a prior Revision for one session start. type resumeChoice struct { - // prior is the spec's `priorRevision{revisionId, finalCommit, bundle}`; nil starts fresh. + // prior is the session spec's `priorRevision{revisionId, finalCommit, baseCommit, + // gitRepositoryUrl, deliveryRef}`; nil starts fresh. prior Object // context is the initial turn's `resume` value; nil when the Issue has nothing to resume, which // keeps the first prompt of every run without history byte-identical to before. context Object } -// selectPriorRevision picks the Revision the run resumes (resume decision D1): the latest Revision -// of another run of the same Issue that carries a bundle, its own or reused. It is skipped, and the -// run starts fresh, when it belongs to a different project or repository than this run's Workspace, -// or when an earlier restore refused it. There is deliberately no fallback to an older Revision: -// older ones usually depend on the same rewritten history, and falling back would silently drop the -// latest work. +// selectPriorRevision picks the Revision the run resumes (resume decision D1 as changed by Revision +// D7): the latest Revision of another run of the same Issue that has its own verified delivery ref +// and moved off its base commit. A Revision that only holds its base commit would turn the new +// checkout back to older code, and one registered before Git delivery has no ref to fetch; neither +// is a candidate. The candidate is skipped, and the run starts fresh, when it belongs to a different +// project or repository than this run's Workspace, when the Git Server repository it would be +// fetched from is not the one this Workspace cloned from (contract D8), or when an earlier restore +// refused it. There is deliberately no fallback to an older Revision: older ones usually depend on +// the same rewritten history, and falling back would silently drop the latest work. func selectPriorRevision(t *transaction, o Object) resumeChoice { candidate := t.one(` - SELECT r.id, r.run_id, r.project_id, r.repository_url, r.base_commit, r.final_commit, - r.resume_refused_at IS NOT NULL AS refused, - COALESCE(h.bundle_key, r.bundle_key) AS bundle_key, - COALESCE(h.bundle_size, r.bundle_size) AS bundle_size, - COALESCE(h.bundle_sha256, r.bundle_sha256) AS bundle_sha256 + SELECT r.id, r.run_id, r.tenant_id, r.project_id, r.repository_url, r.base_commit, r.final_commit, r.delivery_ref, + r.resume_refused_at IS NOT NULL AS refused FROM revisions r JOIN issue_runs ir ON ir.id = r.run_id AND ir.tenant_id = r.tenant_id - LEFT JOIN revisions h ON h.id = r.bundle_revision_id WHERE ir.issue_id = $1 AND ir.tenant_id = $2 AND r.run_id <> $3 - AND (r.bundle_key IS NOT NULL OR r.bundle_revision_id IS NOT NULL) + AND r.delivery_ref IS NOT NULL AND r.final_commit <> r.base_commit ORDER BY r.created_at DESC, r.id DESC LIMIT 1`, o.S("issueId"), o.S("tenantId"), o.S("id")) if candidate == nil { @@ -50,7 +51,7 @@ func selectPriorRevision(t *transaction, o Object) resumeChoice { return resumeChoice{context: Object{"skippedRevisionId": candidate.S("id"), "reason": reason}} } w := t.one(` - SELECT w.project_id, b.repository_url FROM workspaces w JOIN repository_bindings b ON b.project_id = w.project_id + SELECT w.project_id, b.repository_url, w.base_commit_id FROM workspaces w JOIN repository_bindings b ON b.project_id = w.project_id WHERE w.id = $1 AND w.issue_run_id = $2 AND w.deleted_at IS NULL`, o.S("workspaceId"), o.S("id")) if w == nil || w.S("projectId") != candidate.S("projectId") || w.S("repositoryUrl") != candidate.S("repositoryUrl") { return skipped("project_changed") @@ -58,11 +59,25 @@ func selectPriorRevision(t *transaction, o Object) resumeChoice { if candidate.B("refused") { return skipped("resume_refused") } + // The session restores the prior from the same Git Server repository the checkout was cloned + // from; both addresses come from one configuration and one Project, so a difference means the + // deployment moved its Git Server and the prior cannot be fetched where the checkout lives. + clone := t.one(`SELECT input FROM clone_executions WHERE workspace_id=$1 AND result->>'outcome'='clone_ready' AND result->>'commit'=$2 + ORDER BY created_at DESC, execution_id DESC LIMIT 1`, o.S("workspaceId"), w.S("baseCommitId")) + var repository string + if g := t.store.GitServer; g != nil { + repository = g.gitRepositoryURL(candidate.S("tenantId"), candidate.S("projectId")) + } + if clone == nil || repository == "" || clone.O("input").S("repositoryUrl") != repository { + return skipped("repository_changed") + } return resumeChoice{ prior: Object{ - "revisionId": candidate.S("id"), - "finalCommit": candidate.S("finalCommit"), - "bundle": Object{"key": candidate.S("bundleKey"), "size": candidate.N("bundleSize"), "sha256": candidate.S("bundleSha256")}, + "revisionId": candidate.S("id"), + "finalCommit": candidate.S("finalCommit"), + "baseCommit": candidate.S("baseCommit"), + "gitRepositoryUrl": repository, + "deliveryRef": candidate.S("deliveryRef"), }, context: Object{ "revisionId": candidate.S("id"), @@ -74,8 +89,8 @@ func selectPriorRevision(t *transaction, o Object) resumeChoice { } // deliveryPriorRevision is the delivery spec's `priorRevision{revisionId, finalCommit}`: the same -// Revision the session spec carried, without the bundle, so the Node can tell a resumed run that -// added nothing (contract decision D4). Nil when the session started fresh. +// Revision the session spec carried, without where to fetch it, so the Node can tell a resumed run +// that added nothing (contract decision D4). Nil when the session started fresh. func deliveryPriorRevision(t *transaction, sessionExecutionID string) Object { e := t.one("SELECT input FROM node_executions WHERE execution_id=$1 AND kind='agent_session'", sessionExecutionID) if e == nil { @@ -89,12 +104,19 @@ func deliveryPriorRevision(t *transaction, sessionExecutionID string) Object { } // refuseResumedRevision marks the session's prior Revision as never to be selected again, in the -// session-end settlement transaction, when the Node found its bundle's base commit gone from the -// remote (resume decision D3). Other restore failures may be transient and leave it selectable. +// session-end settlement transaction, when the Node could not fetch its delivery ref from the Git +// Server (resume decision D3 as changed by Revision D7). A run that already asked to cancel is +// exempt: the Git Server refuses a cancelled run's reads with the same answer it gives for a +// missing ref, so the failure proves nothing about the Revision. Other restore failures may be +// transient and leave it selectable. func refuseResumedRevision(t *transaction, sessionExecutionID string, ended Object) { if ended.S("reason") != "agent_failed" || ended.S("detail") != priorRevisionBaseUnavailable { return } + e := t.one("SELECT r.cancel_requested_at IS NOT NULL AS cancelled FROM node_executions e JOIN issue_runs r ON r.id=e.operation_id WHERE e.execution_id=$1", sessionExecutionID) + if e == nil || e.B("cancelled") { + return + } prior := deliveryPriorRevision(t, sessionExecutionID) if prior == nil { return @@ -102,15 +124,3 @@ func refuseResumedRevision(t *transaction, sessionExecutionID string, ended Obje t.exec(`UPDATE revisions SET resume_refused_at=now(), resume_refused_reason=$2 WHERE id=$1 AND resume_refused_at IS NULL`, prior.S("revisionId"), priorRevisionBaseUnavailable) } - -// resumedBundleHolder returns the Revision that holds the bundle a resumed run reuses when it added -// nothing (resume decision D5): the prior itself when it stored one, else the holder it reused. -func resumedBundleHolder(t *transaction, priorRevisionID string) string { - prior := t.one("SELECT id, bundle_key, bundle_revision_id FROM revisions WHERE id=$1", priorRevisionID) - require(prior != nil, 409, "result_conflict") - if prior.S("bundleKey") != "" { - return prior.S("id") - } - require(prior.S("bundleRevisionId") != "", 409, "result_conflict") - return prior.S("bundleRevisionId") -} diff --git a/internal/core/agent_run_session_directory_db_test.go b/internal/core/agent_run_session_directory_db_test.go new file mode 100644 index 000000000..a2252e16a --- /dev/null +++ b/internal/core/agent_run_session_directory_db_test.go @@ -0,0 +1,107 @@ +package core + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" +) + +// Controller-integration D7, invariant 11: the session input carries the Project's Workspaces newest +// first, at most 100. Run Workspaces stay listed after deletion, with their Issue and run, while a +// deleted isolated Workspace, a WorkflowRun's Workspace (D7: its sessions have no Git access) and +// another Project's Workspaces are not listed. +func TestSessionWorkspaceDirectoryListsTheProjectNewestFirst(t *testing.T) { + s := commandStore(t) + scene := seedStartingRun(t, s, sessionStartInput()) + seed := scene.seed + // Another tenant's Project in the same database, whose Workspaces must never be listed. + seedDispatchScene(t, s.Pool, false) + var user, main string + if err := s.Pool.QueryRow(`SELECT owner_user_id,id FROM workspaces WHERE project_id=$1 AND kind='main'`, seed.project).Scan(&user, &main); err != nil { + t.Fatal(err) + } + isolated, deletedIsolated, deletedRun := uuid.NewString(), uuid.NewString(), uuid.NewString() + workflow, snapshot, workflowRun, workflowWorkspace := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + earlierIssue, earlierRun := uuid.NewString(), uuid.NewString() + tx, err := s.Pool.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + exec := func(q string, args ...any) { + t.Helper() + if _, err := tx.Exec(q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + // Workspace columns shared by every row this scene adds. + const insert = `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref,issue_run_id,deleted_at,created_at) + VALUES($1,$2,$3,$3,'verified_request',$4,'isolated',$5,$6,1,'main',$7,$8,now()-$9::interval)` + exec(`UPDATE workspaces SET created_at=now()-interval '10 hours' WHERE id=$1`, main) + exec(`UPDATE workspaces SET created_at=now()-interval '1 hour' WHERE id=$1`, scene.ws) + exec(`INSERT INTO issues(id,tenant_id,creator_user_id,project_ref,title,number) VALUES($1,$2,$3,$4,'Earlier issue',2)`, earlierIssue, seed.tenant, user, seed.project) + exec(`INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,status) VALUES($1,$2,$3,'agent',$4,'failed')`, earlierRun, seed.tenant, earlierIssue, seed.agent) + exec(insert, deletedRun, seed.tenant, user, seed.project, "deleted", "deleted", earlierRun, "now", "5 hours") + exec(insert, deletedIsolated, seed.tenant, user, seed.project, "deleted", "deleted", nil, "now", "4 hours") + exec(insert, isolated, seed.tenant, user, seed.project, "running", "ready", nil, nil, "3 hours") + exec(`INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'Isolated')`, uuid.NewString(), isolated) + // The newest Workspace of the Project belongs to a WorkflowRun and must still be left out. + exec(`INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'directory','{}'::jsonb)`, workflow, seed.tenant) + exec(`INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1','{}'::jsonb)`, snapshot, seed.tenant, workflow) + exec(`INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,'directory','running','{}'::jsonb,$5,$6)`, workflowRun, seed.tenant, workflow, snapshot, user, seed.project) + exec(insert, workflowWorkspace, seed.tenant, user, seed.project, "running", "ready", nil, nil, "1 minute") + exec(`INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'Workflow')`, uuid.NewString(), workflowWorkspace) + exec(`UPDATE workspaces SET workflow_run_id=$2 WHERE id=$1`, workflowWorkspace, workflowRun) + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + directory := func() []Object { + t.Helper() + var out []Object + if err := s.view(context.Background(), func(tx *transaction) { out = workspaceDirectory(tx, scene.ws) }); err != nil { + t.Fatal(err) + } + return out + } + + want := []Object{ + {"workspaceId": scene.ws, "kind": "run", "issueId": seed.issue, "issueTitle": "disp issue", "issueRunId": seed.run, "runStatus": "dispatched"}, + {"workspaceId": isolated, "kind": "isolated"}, + {"workspaceId": deletedRun, "kind": "run", "issueId": earlierIssue, "issueTitle": "Earlier issue", "issueRunId": earlierRun, "runStatus": "failed"}, + {"workspaceId": main, "kind": "main"}, + } + // The complete value: the deleted isolated Workspace, the WorkflowRun's and the other Project's are + // absent. + if got := directory(); jsonText(got) != jsonText(want) { + t.Fatalf("directory = %s\nwant %s", jsonText(got), jsonText(want)) + } + + // More than the limit: the newest 100 are listed and the oldest drop out. + tx, err = s.Pool.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + for i := range workspaceDirectoryLimit { + id := uuid.NewString() + exec(insert, id, seed.tenant, user, seed.project, "running", "ready", nil, nil, fmt.Sprintf("%d minutes", 61+i)) + exec(`INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'Bulk')`, uuid.NewString(), id) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + got := directory() + if len(got) != workspaceDirectoryLimit || got[0].S("workspaceId") != scene.ws { + t.Fatalf("directory holds %d listings starting with %v, want %d starting with the session's own", len(got), got[0], workspaceDirectoryLimit) + } + for _, listing := range got { + if id := listing.S("workspaceId"); id == workflowWorkspace { + t.Fatalf("the WorkflowRun Workspace %s entered the directory", id) + } + if id := listing.S("workspaceId"); id == isolated || id == deletedRun || id == main { + t.Fatalf("an older Workspace %s outlived the limit", id) + } + } +} diff --git a/internal/core/agent_run_session_settle.go b/internal/core/agent_run_session_settle.go index c78759ed3..fa12e4ca4 100644 --- a/internal/core/agent_run_session_settle.go +++ b/internal/core/agent_run_session_settle.go @@ -167,19 +167,20 @@ func (s *Store) settleSessionEnded(t *transaction, runID, executionID string, en } // sessionDeliverySpec builds the fixed DeliverRevision snapshot the delivery work item carries -// (proto DeliverRevisionSpec; Revision D2). Everything but the object keys is read from +// (proto DeliverRevisionSpec; Revision D1). Everything but the server-owned locations is read from // authoritative rows in the caller's transaction: // // sessionExecutionId: the ended session execution. // checkoutExecutionId: the clone execution that produced the run Workspace's recorded baseline. -// baseCommit: workspaces.base_commit_id, the commit the bundle is relative to. -// priorRevision: the Revision the session resumed, without bundle (resume decision D5). +// baseCommit: workspaces.base_commit_id, the commit the delivery is measured against. +// priorRevision: the Revision the session resumed (resume decision D5). // kind: the work item kind, which the control plane's enqueue re-checks. // -// The three server-owned locations — the per-attempt `bundleKey`/`historyKey` and the -// `revisionRef` — are deliberately absent: the control plane fixes them while it inserts the work -// item, so a retried delivery is a new attempt with new keys by construction and no caller can name -// an object another attempt already wrote. +// The server-owned locations — the per-attempt `historyKey` and `deliveryRef`, the Git Server +// repository, the Workspace branch and the Node-local `revisionRef` — are deliberately absent: the +// control plane fixes them while it inserts the work item, so a retried delivery is a new attempt +// with a new key and ref by construction and no caller can name an object or ref another attempt +// already wrote. // // A run Workspace with no recorded baseline, or one whose baseline names no successful clone // execution, is an invariant violation: a session cannot have run in a Workspace that was never diff --git a/internal/core/agent_run_session_start.go b/internal/core/agent_run_session_start.go index 5a0ad8689..9599e7d41 100644 --- a/internal/core/agent_run_session_start.go +++ b/internal/core/agent_run_session_start.go @@ -107,7 +107,9 @@ func (s *Store) startAgentSession(t *transaction, runID string) error { // actor's default (identity-access D1; agent_run_identity.go names the // divergence from D2's creation-time freeze). // initialTurn: the deterministic first prompt this transaction just wrote as seq=1. -// priorRevision: the Revision the run resumes, when selectPriorRevision chose one. +// priorRevision: the Revision the run resumes, when selectPriorRevision chose one and it +// lives in the repository the checkout was cloned from. +// workspaceDirectory: the Project's Workspaces as of now, for `ora-git list`. // // A run Workspace with no recorded baseline, or one whose baseline names no successful clone // execution, is an invariant violation: a session cannot run in a Workspace that was never cloned, so @@ -126,12 +128,18 @@ func (s *Store) sessionStartSpec(t *transaction, o, snap Object, turnID, content return nil, fmt.Errorf("session start: run %s workspace %s has no usable base commit (%q)", runID, wid, base) } checkout := t.one(` - SELECT execution_id FROM clone_executions + SELECT execution_id, input FROM clone_executions WHERE workspace_id=$1 AND result->>'outcome'='clone_ready' AND result->>'commit'=$2 ORDER BY created_at DESC, execution_id DESC LIMIT 1`, wid, base) if checkout == nil { return nil, fmt.Errorf("session start: run %s workspace %s has no successful clone execution for base commit %s", runID, wid, base) } + // The prior Revision is fetched from the repository the checkout was cloned from, or not at + // all (contract D8). Selection already required this; the frozen prior is checked again here, + // where the two inputs are finally put side by side. + if prior != nil && prior.S("gitRepositoryUrl") != checkout.O("input").S("repositoryUrl") { + prior = nil + } identity, err := runGitIdentity(t, o) if err != nil { return nil, err @@ -153,9 +161,43 @@ func (s *Store) sessionStartSpec(t *transaction, o, snap Object, turnID, content if prior != nil { spec["priorRevision"] = prior } + spec["workspaceDirectory"] = workspaceDirectory(t, wid) return spec, nil } +// workspaceDirectoryLimit bounds the Workspace directory a session input carries (contract D7). +const workspaceDirectoryLimit = 100 + +// workspaceDirectory lists the Workspaces of the session's Project, newest first, so `ora-git list` +// can name their branches by Issue and run (contract D7). Run Workspaces stay listed after deletion +// because their branches remain on the Git Server. A WorkflowRun's Workspace is never listed: its +// sessions get no Git Server access in the first phase and the directory has no fields for it +// (contract D5, D7). It is a snapshot taken when the session work is +// released, never refreshed and never used for authorization: which branches a session can read and +// write is decided by the Git Server alone. +func workspaceDirectory(t *transaction, wid string) []Object { + rows := t.list(` + SELECT w.id AS workspace_id, + CASE WHEN w.issue_run_id IS NOT NULL THEN 'run' ELSE w.kind END AS kind, + r.issue_id, i.title AS issue_title, r.id AS issue_run_id, r.status AS run_status + FROM workspaces w + JOIN workspaces self ON self.id=$1 AND self.project_id=w.project_id AND self.tenant_id=w.tenant_id + LEFT JOIN issue_runs r ON r.id=w.issue_run_id AND r.tenant_id=w.tenant_id + LEFT JOIN issues i ON i.id=r.issue_id AND i.tenant_id=r.tenant_id + WHERE (w.deleted_at IS NULL OR w.issue_run_id IS NOT NULL) AND w.workflow_run_id IS NULL + ORDER BY w.created_at DESC, w.id DESC + LIMIT $2`, wid, workspaceDirectoryLimit) + out := make([]Object, 0, len(rows)) + for _, row := range rows { + listing := Object{"workspaceId": row.S("workspaceId"), "kind": row.S("kind")} + if row.S("issueRunId") != "" { + listing["issueId"], listing["issueTitle"], listing["issueRunId"], listing["runStatus"] = row.S("issueId"), row.S("issueTitle"), row.S("issueRunId"), row.S("runStatus") + } + out = append(out, listing) + } + return out +} + // renderAgentInitialTurn produces the deterministic first-prompt content from the frozen run-create // snapshot. It reads only the immutable issue_runs.input the business layer froze at run // creation (issue_run_lifecycle.buildRunContext + agent_target.snapshotAgentRunInput), never the diff --git a/internal/core/agent_run_workspace_creation_db_test.go b/internal/core/agent_run_workspace_creation_db_test.go index a8739f750..31d9c1973 100644 --- a/internal/core/agent_run_workspace_creation_db_test.go +++ b/internal/core/agent_run_workspace_creation_db_test.go @@ -10,8 +10,9 @@ import ( // Scenes for the D5 bounded abandonment of a blocked run Workspace creation. Everything before the // `blocked` write uses shipped code paths: the AgentRunDispatcher claim (createRunWorkspace plus the -// provisioning transition), the control plane's own claim action and its defer action, which is the -// only writer of `blocked` and therefore the transaction the deadline freezes in. +// provisioning transition), the control plane's own claim action and its defer action. The defer, and +// Cloud's own claim parking a clone step it cannot hand out, are the writers of `blocked` and +// therefore the transactions the deadline freezes in. // provisioningSeed is one queued run whose claim created the run Workspace and moved the run to // provisioning, plus the ids the abandonment asserts on. @@ -144,6 +145,36 @@ func TestBlockedCreationFreezesDeadlineInDeferTransaction(t *testing.T) { } } +// Cloud parking a run Workspace creation's clone step as blocked when it claims it (no Git Server +// configured, so no snapshot input can be frozen) is a persistent blocked write too: the deadline +// freezes in the claim's transaction as blocked_write, not later as first_observed. +func TestBlockedCloneParkFreezesDeadlineInClaimTransaction(t *testing.T) { + store := commandStore(t) + parked := seedProvisioningCreation(t, store) + seedControllerLease(t, store) + if _, err := store.Pool.Exec(`UPDATE operations SET step='clone',state='queued' WHERE id=$1`, parked.operation); err != nil { + t.Fatal(err) + } + out, err := store.Control(context.Background(), &ControlRequest{Action: "claim", Body: Object{"epoch": 1}, Service: controllerClaims()}) + if err != nil { + t.Fatalf("claim: %v", err) + } + if got := out.O("operation").S("id"); got == parked.operation { + t.Fatal("a clone step without a Git Server was handed to the Controller") + } + var state, code string + if err := store.Pool.QueryRow(`SELECT state,error_code FROM operations WHERE id=$1`, parked.operation).Scan(&state, &code); err != nil { + t.Fatal(err) + } + if state != "blocked" || code != gitServerUnconfigured { + t.Fatalf("claim left the clone step %s/%s, want blocked/%s", state, code, gitServerUnconfigured) + } + blockedAt, deadlineAt, source := creationDeadline(t, store, parked.run) + if !blockedAt.Valid || !deadlineAt.Valid || source.String != "blocked_write" { + t.Fatalf("parking as blocked did not freeze the deadline: %v/%v/%v", blockedAt, deadlineAt, source) + } +} + // Defer, manual retry, re-claiming, restarts and configuration changes never renew a frozen window; // a temporary return to running or retry_wait does not erase it either. func TestBlockedCreationDeadlineIsNotRenewedByRetryRestartOrConfiguration(t *testing.T) { diff --git a/internal/core/clone.go b/internal/core/clone.go index c0eb547e6..79eaa9d76 100644 --- a/internal/core/clone.go +++ b/internal/core/clone.go @@ -1,37 +1,9 @@ package core -import ( - "context" - "net/url" - "strings" - "unicode" -) +import "strings" -// EnqueueClone accepts a clone request in Cloud's own transaction and hands it to Controllers as -// queued work; it is the programmatic twin of the public clones API used by tests and tooling. -func (s *Store) EnqueueClone(ctx context.Context, tenantID, userID, requestID, repositoryURL, branch string) (Object, error) { - created := false - out, err := s.transact(ctx, func(t *transaction) Object { - var row Object - row, created = enqueueClone(t, tenantID, userID, requestID, repositoryURL, branch) - return row - }) - if err == nil && created { - s.signalWork(out.S("id")) - } - return out, err -} - -// signalWork tells the lease holder that a claim is worth trying. It runs only after the accepting -// transaction committed so a Controller that claims immediately finds the row. -func (s *Store) signalWork(operationID string) { - if s.Signals != nil { - s.Signals.Publish(ControlSignal{Kind: SignalWorkAvailable, OperationID: operationID}) - } -} - -// signalOperations tells the lease holder which operations became claimable. Like signalWork it -// runs only after the queuing transaction committed. +// signalOperations tells the lease holder which operations became claimable. It runs only after +// the queuing transaction committed so a Controller that claims immediately finds the row. func (s *Store) signalOperations(ids []string) { if s.Signals == nil { return @@ -55,112 +27,6 @@ func (s *Store) signalRuns(kind ControlSignalKind, ids []string) { } } -// enqueueClone records one clone request inside the caller's transaction. Repeating -// (tenant, user, requestId) with the same input returns the original request and reports nothing -// new; different input is a conflict. Nothing is dispatched here: a Controller claims the row. -func enqueueClone(t *transaction, tenantID, userID, requestID, repositoryURL, branch string) (row Object, created bool) { - require(t.legacyCloneFixture, 410, "runtime_scope_required") - require(requestID != "" && len(requestID) <= 200, 400, "invalid_clone_request") - validCloneSource(repositoryURL, branch) - membership(t, tenantID, userID, false) - if existing := t.one("SELECT * FROM clone_requests WHERE tenant_id=$1 AND actor_user_id=$2 AND request_id=$3", tenantID, userID, requestID); existing != nil { - require(existing.S("repositoryUrl") == repositoryURL && existing.S("branch") == branch, 409, "idempotency_conflict") - return existing, false - } - id := newID() - t.exec("INSERT INTO clone_requests(id,tenant_id,actor_user_id,request_id,repository_url,branch,state) VALUES($1,$2,$3,$4,$5,$6,'queued')", id, tenantID, userID, requestID, repositoryURL, branch) - return t.one("SELECT * FROM clone_requests WHERE id=$1", id), true -} - -// validCloneSource applies the Controller's own clone input policy at acceptance time. Cloud must -// not accept what no Controller can dispatch: such a request would sit queued forever with no -// terminal fact, and the caller would read that as "awaiting reconciliation" rather than failure. -func validCloneSource(repository, branch string) { - require(repository != "" && len(repository) <= 2000 && !strings.ContainsAny(repository, "\\") && !hasSpaceOrControl(repository), 400, "invalid_repository_url") - scheme, rest, ok := strings.Cut(repository, "://") - require(ok && (scheme == "https" || scheme == "ssh") && !strings.HasPrefix(rest, "/"), 400, "invalid_repository_url") - parsed, e := url.Parse(repository) - require(e == nil && parsed.Host != "" && parsed.Path != "" && parsed.Path != "/" && parsed.RawQuery == "" && !parsed.ForceQuery && parsed.Fragment == "", 400, "invalid_repository_url") - authority, _, _ := strings.Cut(rest, "/") - // Git receives the text verbatim: https never carries userinfo, ssh carries a user but no password. - if i := strings.LastIndex(authority, "@"); i >= 0 { - require(scheme == "ssh" && i > 0 && !strings.Contains(authority[:i], ":"), 400, "invalid_repository_url") - } - require(branch != "HEAD", 400, "invalid_ref") - // validRef judges the trimmed text, but the branch is stored and handed to Git verbatim. A - // surrounding space or an inner tab or control character would pass here and be refused by the - // Controller; ClaimWork keeps returning the oldest queued request, so that one refusal would - // stall every clone queued behind it. - require(!hasSpaceOrControl(branch), 400, "invalid_ref") - validRef(branch) - require(!strings.HasPrefix(branch, "refs/") && !strings.HasPrefix(branch, "/") && !strings.HasSuffix(branch, "/") && !strings.Contains(branch, "//") && !strings.HasSuffix(branch, ".") && branch != "@", 400, "invalid_ref") - for component := range strings.SplitSeq(branch, "/") { - require(!strings.HasPrefix(component, ".") && !strings.HasSuffix(component, ".lock"), 400, "invalid_ref") - } -} - -// hasSpaceOrControl reports any Unicode space or control character, which Git never receives from -// a clone source: the text is passed verbatim, so nothing is trimmed on its behalf. -func hasSpaceOrControl(s string) bool { - return strings.IndexFunc(s, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 -} - -// cloneQuery joins each request with its at-most-one execution; the LEFT JOIN keeps queued -// requests visible before a Controller registers a dispatch. -const cloneQuery = "SELECT r.*, e.execution_id, e.node_id, e.result FROM clone_requests r LEFT JOIN clone_executions e ON e.operation_id=r.id WHERE r.tenant_id=$1 AND r.actor_user_id=$2" - -// clonesPublic serves the public clones routes for the verified actor: only the user who submitted -// a request can read it, mirroring operation visibility. accepted names a newly queued request the -// caller must signal once the transaction committed. -func clonesPublic(t *transaction, r *PublicRequest, uid string) (out Object, accepted string) { - switch { - case r.CloneID != "": - require(validID(r.CloneID), 404, "not_found") - row := t.one(cloneQuery+" AND r.id=$3", r.TenantID, uid, r.CloneID) - require(row != nil, 404, "not_found") - return cloneView(row), "" - case r.Method == "GET": - listing := page(t, cloneQuery, []any{r.TenantID, uid}, "r.id", r) - rows, _ := listing["items"].([]Object) - items := make([]Object, 0, len(rows)) - for _, row := range rows { - items = append(items, cloneView(row)) - } - listing["items"] = items - return listing, "" - default: - row, created := enqueueClone(t, r.TenantID, uid, r.Body.S("requestId"), r.Body.S("repository"), r.Body.S("branch")) - if created { - accepted = row.S("id") - } - return cloneView(t.one(cloneQuery+" AND r.id=$3", r.TenantID, uid, row.S("id"))), accepted - } -} - -// cloneView is the public shape of one clone request: the transitional Controller DTO field for -// field, so a browser observes one clone semantics whichever authority accepted the request. -// executionId and nodeId are null until a Controller records the dispatch. -func cloneView(row Object) Object { - state := Object{"kind": "pending"} - result := row.O("result") - switch row.S("state") { - case "succeeded": - state = Object{"kind": "succeeded", "path": result.S("path"), "commit": result.S("commit")} - case "failed": - state = Object{"kind": "failed", "reason": cloneFailureReason(result.S("reason"))} - if retained, ok := result["retainedPath"].(string); ok { - state["retainedPath"] = retained - } - } - return Object{"operationId": row.S("id"), "requestId": row.S("requestId"), "repository": row.S("repositoryUrl"), "branch": row.S("branch"), "executionId": row["executionId"], "nodeId": row["nodeId"], "state": state, "createdAt": row["createdAt"], "updatedAt": row["updatedAt"]} -} - -// cloneFailureReason turns the stored contract enum name (CLONE_FAILURE_REASON_BRANCH_NOT_FOUND) -// into the public camelCase value (branchNotFound) the browser DTO uses. -func cloneFailureReason(stored string) string { - return camel(strings.ToLower(strings.TrimPrefix(stored, "CLONE_FAILURE_REASON_"))) -} - // submitted wraps one state-changing control action in its submission identity. The identity is // checked and recorded inside the caller's transaction, so a recorded response and its effects // commit together: a retry after a lost reply replays the response, never the effect. @@ -222,8 +88,6 @@ func cloneCommand(t *transaction, r *ControlRequest) Object { return submitted(t, r, func() Object { return recordThreadCommandDelivered(t, r) }) case "grant_revision_upload": return revisionGrants(t, r.Body.S("executionId"), r.Body.O("checksums")) - case "grant_revision_download": - return revisionDownloadGrants(t, r.Body.S("executionId")) default: reject(404, "not_found") } @@ -231,28 +95,13 @@ func cloneCommand(t *transaction, r *ControlRequest) Object { } // cloneDispatch registers execution identity, target Node and full input before any Node sees the -// command. The request must still be queued (or already dispatched as exactly this execution). An -// operation ID that names no tenant clone request is a Workspace operation's clone step. +// command. Every clone is the clone step of a Workspace operation: Cloud has no tenant-level clone. func cloneDispatch(t *transaction, r *ControlRequest) Object { operation, execution, node := r.Body.S("operationId"), r.Body.S("executionId"), r.Body.S("nodeId") input := r.Body.O("input") require(validID(operation) && execution != "" && node != "" && len(input) > 0, 400, "invalid_dispatch") require(t.one("SELECT execution_id FROM node_executions WHERE execution_id=$1", execution) == nil, 409, "dispatch_conflict") - request := t.one("SELECT * FROM clone_requests WHERE id=$1", operation) - if request == nil { - return workspaceCloneDispatch(t, r, operation, execution, node, input) - } - require(t.legacyCloneFixture, 410, "runtime_scope_required") - require(input.S("repositoryUrl") == request.S("repositoryUrl") && input.S("branch") == request.S("branch"), 409, "dispatch_conflict") - if existing := t.one("SELECT * FROM clone_executions WHERE operation_id=$1", operation); existing != nil { - require(existing.S("executionId") == execution && existing.S("nodeId") == node && jsonText(existing.O("input")) == jsonText(input), 409, "dispatch_conflict") - return existing - } - require(request.S("state") == "queued", 409, "dispatch_conflict") - require(t.one("SELECT execution_id FROM clone_executions WHERE execution_id=$1", execution) == nil, 409, "dispatch_conflict") - t.exec("INSERT INTO clone_executions(execution_id,operation_id,node_id,input,dispatched_epoch,node_operation_id) VALUES($1,$2,$3,$4,$5,$2::uuid::text)", execution, operation, node, jsonText(input), r.Body.N("epoch")) - t.exec("UPDATE clone_requests SET state='dispatched',updated_at=now() WHERE id=$1", operation) - return t.one("SELECT * FROM clone_executions WHERE execution_id=$1", execution) + return workspaceCloneDispatch(t, r, operation, execution, node, input) } // cloneResult commits an execution fact. With a receipt it is the takeover of a Node event and the @@ -265,15 +114,13 @@ func cloneResult(t *transaction, r *ControlRequest, withReceipt bool) Object { require(e != nil, 404, "not_found") outcome := result.S("outcome") require((outcome == "clone_ready" || outcome == "clone_failed") && result.O("node").S("nodeId") == e.S("nodeId"), 409, "result_conflict") + // A checkout of any other commit than the snapshot it was given is a contradiction, not a + // fact to record: the baseline would be wrong for every later delivery and resume (contract D3). + require(outcome != "clone_ready" || result.S("commit") == e.O("input").S("baseCommit"), 400, "base_commit_mismatch") if previous := e.O("result"); len(previous) > 0 { require(jsonText(previous) == jsonText(result), 409, "result_conflict") } else { - state := "succeeded" - if outcome == "clone_failed" { - state = "failed" - } t.exec("UPDATE clone_executions SET result=$2,updated_at=now() WHERE execution_id=$1", execution, jsonText(result)) - t.exec("UPDATE clone_requests SET state=$2,updated_at=now() WHERE id=$1", operation, state) } if withReceipt { sequence, event := r.Body.N("sequence"), r.Body.S("event") @@ -290,5 +137,5 @@ func cloneResult(t *transaction, r *ControlRequest, withReceipt bool) Object { // isCloneAction reports whether a control action belongs to the execution registry, including the // Agent session methods that share its lease and submission rules. func isCloneAction(action string) bool { - return strings.HasPrefix(action, "clone_") || strings.HasPrefix(action, "thread_") || action == "grant_revision_upload" || action == "grant_revision_download" + return strings.HasPrefix(action, "clone_") || strings.HasPrefix(action, "thread_") || action == "grant_revision_upload" } diff --git a/internal/core/control.go b/internal/core/control.go index c0dd49a66..9e69d2ab7 100644 --- a/internal/core/control.go +++ b/internal/core/control.go @@ -159,19 +159,43 @@ func leaseValid(t *transaction, r *ControlRequest) { require(l != nil, 409, "stale_controller") } +// claim hands the oldest claimable operation to the Controller. An operation that must not be handed +// out yet is parked in this same transaction and the claim moves on to the next one, so a waiter at +// the head of the queue never makes a claim come back empty while other work is claimable +// (operation D2). Each parked operation leaves the claimable set, so the loop ends. func claim(t *transaction, r *ControlRequest) Object { schedulePluginMaintenance(t) - o := t.one("SELECT * FROM operations WHERE state='queued' OR (state='retry_wait' AND retry_at<=clock_timestamp()) OR state='running' ORDER BY created_at,id LIMIT 1") - if o == nil { - return Object{"operation": nil} - } - t.exec("UPDATE operations SET state='running',controller_epoch=$2,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), r.Body.N("epoch")) - o = t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) - if o.S("step") == "plugin" { - enterPluginStep(t, o) + for { + o := t.one("SELECT * FROM operations WHERE state='queued' OR (state='retry_wait' AND retry_at<=clock_timestamp()) OR state='running' ORDER BY created_at,id LIMIT 1") + if o == nil { + return Object{"operation": nil} + } + if !readyToHandOut(t, o) { + continue + } + t.exec("UPDATE operations SET state='running',controller_epoch=$2,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), r.Body.N("epoch")) o = t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) + if o.S("step") == "plugin" { + enterPluginStep(t, o) + o = t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) + } + return snapshot(t, o) + } +} + +// readyToHandOut decides, before a Controller sees an operation, the conditions only Cloud can +// judge. A clone step needs its frozen snapshot input (operation D1, D2). delete_project does not +// start quiescing while a sync or publish of the Project may still write to its repository +// (operation D6); like a snapshot wait this consumes no attempt and fails nothing. +func readyToHandOut(t *transaction, o Object) bool { + switch { + case o.S("step") == "clone": + return freezeCloneInput(t, o) + case o.S("kind") == "delete_project" && o.S("step") == "quiesce" && repositoryOperationsUnfinished(t, o.S("projectId")): + parkOperation(t, o, "retry_wait", repositoryOperationRunning, repositoryWaitSeconds) + return false } - return snapshot(t, o) + return true } func operation(t *transaction, r *ControlRequest) Object { @@ -184,11 +208,13 @@ func operation(t *transaction, r *ControlRequest) Object { } // snapshot is everything a Controller needs to drive one operation. Workspaces carry their own -// requested ref and baseline commit; the retired Project storage and worktree rows are not part of -// it. clones lists this operation's Workspace clone executions so a recovering Controller finds the +// branch name and baseline commit, and a clone step carries its frozen input; the retired Project +// storage and worktree rows are not part of it. clones lists this operation's Workspace clone executions so a recovering Controller finds the // original execution instead of registering a second one. func snapshot(t *transaction, o Object) Object { - p := t.one("SELECT p.*,c.secret_ref FROM "+projectView+" p LEFT JOIN credential_refs c ON c.id=p.credential_ref_id WHERE p.id=$1", o.S("projectId")) + // The Project's external repository and credential reference stay in Cloud: a Workspace clones + // only the snapshot input frozen into its clone step. + p := t.one("SELECT p.* FROM projects p WHERE p.id=$1", o.S("projectId")) out := Object{ "operation": o, "project": p, @@ -205,6 +231,9 @@ func snapshot(t *transaction, o Object) Object { if input := pluginInputOf(o); len(input) > 0 { out["pluginInput"] = input } + if input := cloneInputOf(o); input != nil && o.S("step") == "clone" { + out["cloneInput"] = input + } return out } @@ -449,6 +478,11 @@ func advance(t *transaction, r *ControlRequest, o Object) Object { } } else { t.exec("UPDATE operations SET step=$2,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), next) + if next == "clone" { + // The step is frozen in the transaction that enters it; without a snapshot the operation + // leaves the Controller here, parked or failed, and returns through a later claim. + freezeCloneInput(t, t.one("SELECT * FROM operations WHERE id=$1", o.S("id"))) + } } return t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) } diff --git a/internal/core/execution_work.go b/internal/core/execution_work.go index f3d4d0883..d1b218c35 100644 --- a/internal/core/execution_work.go +++ b/internal/core/execution_work.go @@ -47,10 +47,19 @@ func enqueueExecutionWork(t *transaction, runID, kind string, input, target Obje } id := newID() if kind == "deliver_revision" { - // B owns the skipped/releasing transition; A supplies its same-transaction settlement. - if t.objectStore == nil { + // B owns the skipped/releasing transition; A supplies its same-transaction settlement. A + // delivery needs both the object store (session history) and the Git Server (code); the + // object store is named first when both are missing (Revision D2). + reason := "" + switch { + case t.objectStore == nil: + reason = "object_store_unconfigured" + case t.store.GitServer == nil: + reason = gitServerUnconfigured + } + if reason != "" { if t.execRows("INSERT INTO revision_delivery_skips(run_id) VALUES($1) ON CONFLICT DO NOTHING", runID) == 1 { - deliverySettled(t, runID, "", Object{"outcome": "skipped", "reason": "object_store_unconfigured"}) + deliverySettled(t, runID, "", Object{"outcome": "skipped", "reason": reason}) } return Object{"skipped": true} } @@ -93,32 +102,36 @@ func enqueueWorkflowExecutionWork(t *transaction, runID, kind string, input, tar return t.one("SELECT * FROM execution_work WHERE id=$1", id) } -// deliveryWorkInput fixes server-owned locations before an execution identity exists. -// Copying the caller's map keeps retries from accidentally retaining an earlier attempt's keys. +// deliveryWorkInput fixes server-owned locations before an execution identity exists: the session +// history key, the Node-local revision ref, the Git Server repository and this attempt's own +// delivery ref, and the run Workspace's branch from the clone that made its checkout (Revision D1, +// contract D6). Copying the caller's map keeps retries from accidentally retaining an earlier +// attempt's locations. func deliveryWorkInput(t *transaction, run Object, workID string, input Object) Object { w := t.one("SELECT * FROM workspaces WHERE issue_run_id=$1 AND deleted_at IS NULL", run.S("id")) require(w != nil, 409, "dispatch_conflict") require(commitID(input.S("baseCommit")) && input.S("baseCommit") == w.S("baseCommitId"), 409, "dispatch_conflict") require(input.S("sessionExecutionId") != "" && input.S("checkoutExecutionId") != "", 400, "invalid_dispatch") + require(t.store.GitServer != nil, 409, gitServerUnconfigured) + checkout := t.one("SELECT input FROM clone_executions WHERE execution_id=$1 AND workspace_id=$2 AND result->>'outcome'='clone_ready'", input.S("checkoutExecutionId"), w.S("id")) + require(checkout != nil && checkout.O("input").S("workspaceRef") != "", 409, "dispatch_conflict") out := Object{} for key, value := range input { out[key] = value } prefix := "revisions/" + run.S("tenantId") + "/" + run.S("id") + "/" + workID + "/" - out["bundleKey"], out["historyKey"] = prefix+"revision.bundle", prefix+"session.jsonl" + out["historyKey"] = prefix + "session.jsonl" out["revisionRef"] = "refs/ora/revisions/" + run.S("id") + out["gitRepositoryUrl"] = t.store.GitServer.gitRepositoryURL(w.S("tenantId"), w.S("projectId")) + out["deliveryRef"] = GitDeliveryRefPrefix + workID + out["workspaceRef"] = checkout.O("input").S("workspaceRef") return out } -// claimWorkItem is the pure read behind ClaimWork: the older of a queued tenant clone and an -// unregistered session or delivery. Ownership moves only when the dispatch is recorded. +// claimWorkItem is the pure read behind ClaimWork: the oldest unregistered session or delivery. +// Ownership moves only when the dispatch is recorded. Clones are never work items: every clone is +// a Workspace operation's clone step, handed out by the operation claim. func claimWorkItem(t *transaction) Object { - var request Object - if t.legacyCloneFixture { - request = t.one("SELECT * FROM clone_requests WHERE state='queued' ORDER BY created_at,id LIMIT 1") - } else if t.one("SELECT id FROM clone_requests WHERE state='queued'") != nil { - reject(410, "runtime_scope_required") - } // A work item names the run it belongs to in one of two columns: agent work carries run_id (an // issue_runs id), workflow work carries workflow_run_id (a workflow_runs id) and leaves run_id // NULL by the mutual-exclusion CHECK 0039 added. The Controller is handed the run id as the @@ -139,8 +152,8 @@ func claimWorkItem(t *transaction) Object { AND NOT EXISTS(SELECT 1 FROM agent_run_residuals ar WHERE ar.run_id=ew.run_id) AND (ew.run_id IS NULL OR ew.run_id IN (` + executionWorkLiveRuns + `)) ORDER BY created_at,id LIMIT 1`) - if work != nil && (request == nil || work.S("createdAt") < request.S("createdAt")) { - return Object{"work": work} + if work == nil { + return Object{"work": nil} } - return Object{"request": request} + return Object{"work": work} } diff --git a/internal/core/git_access.go b/internal/core/git_access.go index 2ae0d6c05..bd42d7d32 100644 --- a/internal/core/git_access.go +++ b/internal/core/git_access.go @@ -36,11 +36,12 @@ type GitGrant struct { func (g *GitGrant) Writes() bool { return len(g.Writable) > 0 } // AuthorizeGitAccess decides from current PostgreSQL state whether the execution a sandbox -// declares may read or write the Project repository. The certificate only names the runtime -// target; every permission comes from this check, so ending the execution, replacing the -// sandbox, canceling the run or disabling the trigger's account takes effect on the next request. -// It runs in a read-only snapshot without the control-plane advisory lock: Git traffic must not -// serialize with control-plane writes, and the decision mutates nothing. +// declares may read the Project repository and which refs it may write (contract D5). The +// certificate only names the runtime target; every permission comes from this check, so ending the +// execution, advancing its operation, replacing the sandbox, canceling the run or disabling the +// trigger's account takes effect on the next request. It runs in a read-only snapshot without the +// control-plane advisory lock: Git traffic must not serialize with control-plane writes, and the +// decision mutates nothing. func (s *Store) AuthorizeGitAccess(ctx context.Context, scope GitRuntimeScope, executionID, projectID string) (GitGrant, error) { var access GitGrant err := s.view(ctx, func(t *transaction) { @@ -56,8 +57,14 @@ func (s *Store) AuthorizeGitAccess(ctx context.Context, scope GitRuntimeScope, e return access, err } +// gitAccess maps the declared execution to its grant. Every granted execution reads the whole +// Project repository; what it may write depends on its kind, and never exceeds the sandbox column of +// the ref contract: no upstream ref, no other Workspace's branch, no ref outside the namespaces. func gitAccess(t *transaction, scope GitRuntimeScope, executionID, projectID string) GitGrant { require(validID(scope.TenantID) && validID(scope.WorkspaceID) && scope.Generation > 0 && validID(projectID) && executionID != "" && len(executionID) <= 200, 403, "git_access_denied") + if t.one("SELECT execution_id FROM clone_executions WHERE execution_id=$1", executionID) != nil { + return gitCloneAccess(t, scope, executionID, projectID) + } e := t.one(`SELECT e.kind,e.node_id,e.work_id,e.input,e.operation_id AS run_id,w.creator_user_id,w.tenant_id,w.id AS workspace_id, r.phase,r.thread_state,r.cancel_requested_at FROM node_executions e @@ -69,17 +76,27 @@ func gitAccess(t *transaction, scope GitRuntimeScope, executionID, projectID str AND e.kind IN ('agent_session','deliver_revision') AND e.result IS NULL AND e.terminated_by_force_stop_id IS NULL AND r.deleted_at IS NULL AND w.deleted_at IS NULL AND w.desired_state='running' AND w.observed_state='ready' AND w.admission_open AND p.deleted_at IS NULL AND p.lifecycle='active'`, executionID, scope.TenantID, scope.WorkspaceID, scope.Generation, projectID) + // Plugin executions never reach the repository: the kind filter above leaves them unmatched. require(e != nil, 403, "git_access_denied") access := GitGrant{TenantID: scope.TenantID, ProjectID: projectID} switch e.S("kind") { case "deliver_revision": require(e.S("phase") == "delivering", 403, "git_access_denied") - access.Writable = []GitWritableRef{{Ref: GitDeliveryRefPrefix + e.S("workId"), Rule: GitCreateOnly}} + // The delivery creates its own ref, named in its fixed input, and may fast-forward the run + // Workspace's branch to the same final commit (contract D6). Both come from the input Cloud + // fixed; the branch must belong to the certificate's Workspace whatever the input says. + input := e.O("input") + require(input.S("deliveryRef") == GitDeliveryRefPrefix+e.S("workId"), 403, "git_access_denied") + access.Writable = []GitWritableRef{{Ref: input.S("deliveryRef"), Rule: GitCreateOnly}} + if ref, ok := ParseGitRef(input.S("workspaceRef")); ok && ref.Namespace == GitWorkspaceBranch && ref.Owner == scope.WorkspaceID { + access.Writable = append(access.Writable, GitWritableRef{Ref: input.S("workspaceRef"), Rule: GitFastForward}) + } case "agent_session": - // Only a session restoring a prior Revision reads the repository; other sessions start - // from the upstream clone and have no reason to reach the Git Server. + // Every live session reads the repository to collaborate and may create or fast-forward + // branches of its own Workspace. The prefix comes from the certificate, never the request. live := e.S("phase") == "starting" || e.S("phase") == "running" - require(live && e.S("cancelRequestedAt") == "" && e.S("threadState") != "ending" && e.S("threadState") != "ended" && len(e.O("input").O("priorRevision")) > 0, 403, "git_access_denied") + require(live && e.S("cancelRequestedAt") == "" && e.S("threadState") != "ending" && e.S("threadState") != "ended", 403, "git_access_denied") + access.Writable = []GitWritableRef{{Prefix: GitWorkspaceRefPrefix(scope.WorkspaceID), Rule: GitFastForward}} default: reject(403, "git_access_denied") } @@ -96,6 +113,29 @@ func gitAccess(t *transaction, scope GitRuntimeScope, executionID, projectID str return access } +// gitCloneAccess grants a Workspace clone read-only access for exactly as long as its operation is +// in the clone step (contract D5). Unlike sessions it needs no ready Workspace or open admission: +// cloning is what happens before admission opens, and main Workspaces clone while their Project is +// still provisioning. The operation leaving the step, or the execution getting a result, ends it. +func gitCloneAccess(t *transaction, scope GitRuntimeScope, executionID, projectID string) GitGrant { + e := t.one(`SELECT e.node_id,e.input,o.actor_user_id,w.id,w.tenant_id,w.creator_user_id + FROM clone_executions e + JOIN operations o ON o.id=e.operation_id AND o.workspace_id=e.workspace_id + JOIN workspaces w ON w.id=e.workspace_id + JOIN projects p ON p.id=w.project_id AND p.tenant_id=w.tenant_id + WHERE e.execution_id=$1 AND w.tenant_id=$2 AND w.id=$3 AND w.runtime_generation=$4 AND w.project_id=$5 + AND e.result IS NULL AND e.terminated_by_force_stop_id IS NULL + AND o.state='running' AND o.step='clone' AND w.deleted_at IS NULL + AND p.deleted_at IS NULL AND p.lifecycle IN ('provisioning','active')`, executionID, scope.TenantID, scope.WorkspaceID, scope.Generation, projectID) + require(e != nil, 403, "git_access_denied") + require(gitRepositoryURLNames(e.O("input").S("repositoryUrl"), scope.TenantID, projectID), 403, "git_access_denied") + // The operation's actor must still be able to use this runtime, as when the clone was dispatched. + require(runtimeUsable(t, e, e.S("actorUserId")), 403, "git_access_denied") + requireNoForceStop(t, scope.WorkspaceID) + require(currentNode(t, scope.WorkspaceID).S("nodeId") == e.S("nodeId"), 403, "git_access_denied") + return GitGrant{TenantID: scope.TenantID, ProjectID: projectID} +} + // view runs a read-only policy decision in one consistent snapshot. Unlike transact it takes no // advisory lock and has no side effects, hooks or events: the decision may only read. A Fault // panic becomes the returned error; any database failure is returned unchanged so callers can diff --git a/internal/core/git_access_db_test.go b/internal/core/git_access_db_test.go index f1c7b40d4..1451bebec 100644 --- a/internal/core/git_access_db_test.go +++ b/internal/core/git_access_db_test.go @@ -36,28 +36,22 @@ func seedGitScene(t *testing.T, s *Store) gitScene { return gitScene{takeoverScene: scene, scope: GitRuntimeScope{TenantID: scene.seed.seed.tenant, WorkspaceID: scene.ws, Generation: generation}, project: scene.seed.seed.project} } -// restoring marks the session execution as one that restores a prior Revision. -func (g gitScene) restoring(t *testing.T, s *Store) { - t.Helper() - if _, err := s.Pool.Exec(`UPDATE node_executions SET input=input||'{"priorRevision":{"revisionId":"r","finalCommit":"c"}}' WHERE execution_id=$1`, g.execution); err != nil { - t.Fatal(err) - } -} - // delivering stages the rows a delivery leaves after the session ended: the session execution has // a result, the run is `delivering`, and a deliver_revision execution is registered for a work item -// targeting the same sandbox. It returns the delivery execution and its work item id. +// targeting the same sandbox, with the delivery ref and Workspace branch its fixed input names. It +// returns the delivery execution and its work item id. func (g gitScene) delivering(t *testing.T, s *Store) (execution, work string) { t.Helper() execution, work = "exec-deliver-"+g.execution, uuid.NewString() + input := jsonText(Object{"kind": "deliver_revision", "deliveryRef": GitDeliveryRefPrefix + work, "workspaceRef": GitWorkspaceRefPrefix(g.ws) + "main"}) for _, q := range []struct { query string args []any }{ {`UPDATE node_executions SET result='{"outcome":"agent_session_ended"}' WHERE execution_id=$1`, []any{g.execution}}, {`UPDATE issue_runs SET phase='delivering' WHERE id=$1`, []any{g.run}}, - {`INSERT INTO execution_work(id,run_id,kind,input,target,execution_id) SELECT $1,run_id,'deliver_revision','{}',target,$2 FROM execution_work WHERE id=$3`, []any{work, execution, g.work.S("id")}}, - {`INSERT INTO node_executions(execution_id,kind,operation_id,work_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch) VALUES($1,'deliver_revision',$2,$3,$4,$5,'op-deliver','{}',1)`, []any{execution, g.run, work, g.ws, g.node}}, + {`INSERT INTO execution_work(id,run_id,kind,input,target,execution_id) SELECT $1,run_id,'deliver_revision',$4,target,$2 FROM execution_work WHERE id=$3`, []any{work, execution, g.work.S("id"), input}}, + {`INSERT INTO node_executions(execution_id,kind,operation_id,work_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch) VALUES($1,'deliver_revision',$2,$3,$4,$5,'op-deliver',$6,1)`, []any{execution, g.run, work, g.ws, g.node, input}}, } { if _, err := s.Pool.Exec(q.query, q.args...); err != nil { t.Fatalf("stage delivery: %v", err) @@ -74,21 +68,21 @@ func expectGitDenied(t *testing.T, err error, why string) { } } -func TestGitAccessGrantsRestoreReadAndDeliveryWriteFromCurrentState(t *testing.T) { +// Contract D5: every live session reads the repository and may create or fast-forward branches of +// its own Workspace, restoring a prior or not; a delivery creates its own ref and fast-forwards its +// Workspace's branch; an ended session keeps nothing. +func TestGitAccessGrantsSessionBranchesAndDeliveryRefsFromCurrentState(t *testing.T) { s := commandStore(t) g := seedGitScene(t, s) ctx := context.Background() - _, err := s.AuthorizeGitAccess(ctx, g.scope, g.execution, g.project) - expectGitDenied(t, err, "a session that restores nothing has no repository access") - - g.restoring(t, s) got, err := s.AuthorizeGitAccess(ctx, g.scope, g.execution, g.project) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(got, GitGrant{TenantID: g.scope.TenantID, ProjectID: g.project}) { - t.Fatalf("restoring session access = %+v, want read-only", got) + session := GitGrant{TenantID: g.scope.TenantID, ProjectID: g.project, Writable: []GitWritableRef{{Prefix: GitWorkspaceRefPrefix(g.ws), Rule: GitFastForward}}} + if !reflect.DeepEqual(got, session) { + t.Fatalf("session access = %+v, want its own Workspace prefix fast-forward", got) } delivery, work := g.delivering(t, s) @@ -98,11 +92,38 @@ func TestGitAccessGrantsRestoreReadAndDeliveryWriteFromCurrentState(t *testing.T if err != nil { t.Fatal(err) } - if want := (GitGrant{TenantID: g.scope.TenantID, ProjectID: g.project, Writable: []GitWritableRef{{Ref: GitDeliveryRefPrefix + work, Rule: GitCreateOnly}}}); !reflect.DeepEqual(got, want) { - t.Fatalf("delivery access = %+v, want write to its own delivery ref", got) + want := GitGrant{TenantID: g.scope.TenantID, ProjectID: g.project, Writable: []GitWritableRef{ + {Ref: GitDeliveryRefPrefix + work, Rule: GitCreateOnly}, + {Ref: GitWorkspaceRefPrefix(g.ws) + "main", Rule: GitFastForward}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("delivery access = %+v, want its own delivery ref and Workspace branch", got) } } +// A delivery input naming another Workspace's branch, or a delivery ref that is not its own, grants +// no write beyond the contract's sandbox column. +func TestGitAccessNeverWidensBeyondTheCertificateWorkspace(t *testing.T) { + s := commandStore(t) + g := seedGitScene(t, s) + delivery, work := g.delivering(t, s) + if _, err := s.Pool.Exec(`UPDATE node_executions SET input=jsonb_set(input,'{workspaceRef}',to_jsonb($2::text)) WHERE execution_id=$1`, delivery, GitWorkspaceRefPrefix(uuid.NewString())+"main"); err != nil { + t.Fatal(err) + } + got, err := s.AuthorizeGitAccess(context.Background(), g.scope, delivery, g.project) + if err != nil { + t.Fatal(err) + } + if want := []GitWritableRef{{Ref: GitDeliveryRefPrefix + work, Rule: GitCreateOnly}}; !reflect.DeepEqual(got.Writable, want) { + t.Fatalf("another Workspace's branch was granted: %+v", got.Writable) + } + if _, err := s.Pool.Exec(`UPDATE node_executions SET input=jsonb_set(input,'{deliveryRef}',to_jsonb($2::text)) WHERE execution_id=$1`, delivery, GitDeliveryRefPrefix+uuid.NewString()); err != nil { + t.Fatal(err) + } + _, err = s.AuthorizeGitAccess(context.Background(), g.scope, delivery, g.project) + expectGitDenied(t, err, "a delivery ref that is not the work item's own") +} + // Each case withdraws exactly one fact the decision depends on; the next request must be denied, // with the same fault whichever gate refused. func TestGitAccessDeniesWhenAnyCurrentFactIsWithdrawn(t *testing.T) { @@ -160,15 +181,14 @@ func TestGitAccessDeniesWhenAnyCurrentFactIsWithdrawn(t *testing.T) { } } -// The restoring session loses read access as soon as the run starts ending. -func TestGitAccessRestoreReadEndsWithTheSession(t *testing.T) { +// A session loses repository access as soon as the run starts ending. +func TestGitAccessSessionAccessEndsWithTheSession(t *testing.T) { for _, query := range []string{ "UPDATE issue_runs SET cancel_requested_at=now() WHERE id=$1", "UPDATE issue_runs SET thread_state='ending' WHERE id=$1", } { s := commandStore(t) g := seedGitScene(t, s) - g.restoring(t, s) if _, err := s.Pool.Exec(query, g.run); err != nil { t.Fatal(err) } @@ -177,11 +197,27 @@ func TestGitAccessRestoreReadEndsWithTheSession(t *testing.T) { } } +// Plugin executions of the same Workspace never reach the repository. +func TestGitAccessDeniesPluginExecutions(t *testing.T) { + s := commandStore(t) + g := seedGitScene(t, s) + res, err := s.Pool.Exec(`INSERT INTO node_executions(execution_id,kind,operation_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch) + SELECT 'exec-plugin','install_plugins',o.id,$1,$2,'op-plugin','{"kind":"install_plugins","plugins":[]}',1 + FROM operations o WHERE o.workspace_id=$1 LIMIT 1`, g.ws, g.node) + if err != nil { + t.Fatal(err) + } + if n, _ := res.RowsAffected(); n != 1 { + t.Fatal("the scene has no Workspace operation to register a plugin execution under") + } + _, err = s.AuthorizeGitAccess(context.Background(), g.scope, "exec-plugin", g.project) + expectGitDenied(t, err, "a plugin execution") +} + // An unreachable database is not a denial: the caller must report unavailable and never allow. func TestGitAccessDatabaseFailureIsNotADenial(t *testing.T) { s := commandStore(t) g := seedGitScene(t, s) - g.restoring(t, s) if err := s.Pool.Close(); err != nil { t.Fatal(err) } diff --git a/internal/core/git_server.go b/internal/core/git_server.go new file mode 100644 index 000000000..0f2a56e79 --- /dev/null +++ b/internal/core/git_server.go @@ -0,0 +1,51 @@ +package core + +import ( + "context" + "errors" + "net/url" + "strings" +) + +// ErrGitRefNotFound is a definitive answer from the Git Server that a ref does not exist. It is +// negative evidence, unlike a transport failure, which proves nothing. +var ErrGitRefNotFound = errors.New("git ref not found") + +// GitRefResolver is Cloud's read-only view of Project repositories on the Git Server: the commit an +// exact ref names. It returns ErrGitRefNotFound for an absent ref and any other error when the +// Git Server could not answer. +type GitRefResolver interface { + ResolveGitRef(ctx context.Context, tenantID, projectID, ref string) (string, error) +} + +// GitServer is the deployment's Git Server as Cloud uses it: the repository base URL sandboxes +// reach, which Cloud writes into fixed execution inputs, and the ref query Cloud verifies refs +// through. A Store without one cannot freeze clone inputs or deliver Revisions. +type GitServer struct { + // RepositoryBaseURL is https://host[:port][/path], without userinfo, query, fragment or a + // trailing slash. A Project's repository is //.git. + RepositoryBaseURL string + Refs GitRefResolver +} + +// ValidGitRepositoryBaseURL accepts the sandbox-facing repository base: https only, with a host, +// and nothing a fixed input must not carry (userinfo, query, fragment, trailing slash). +func ValidGitRepositoryBaseURL(base string) bool { + u, err := url.Parse(base) + return err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && u.RawQuery == "" && !u.ForceQuery && + u.Fragment == "" && !strings.HasSuffix(base, "/") && !strings.ContainsAny(base, " \t\r\n\\") +} + +// gitRepositoryURL is the Git Server repository of one Project. Every input that names it is built +// here, so the clone, session and delivery inputs of one Project always spell it identically. +func (g *GitServer) gitRepositoryURL(tenantID, projectID string) string { + return g.RepositoryBaseURL + "/" + tenantID + "/" + projectID + ".git" +} + +// gitRepositoryURLNames reports whether a fixed input's repository URL addresses exactly the given +// Project repository. The Git Server process checks it without knowing the configured base: the +// path's last two segments are the repository identity, and nothing else may follow them. +func gitRepositoryURLNames(repositoryURL, tenantID, projectID string) bool { + u, err := url.Parse(repositoryURL) + return err == nil && u.Scheme == "https" && u.RawQuery == "" && u.Fragment == "" && strings.HasSuffix(u.Path, "/"+tenantID+"/"+projectID+".git") +} diff --git a/internal/core/issue_runs.go b/internal/core/issue_runs.go index c47f79298..5855d6fd0 100644 --- a/internal/core/issue_runs.go +++ b/internal/core/issue_runs.go @@ -47,17 +47,21 @@ func runList(t *transaction, r *PublicRequest) Object { // a private storage location. `revision` is null for a run that registered none (not an agent run, // delivery still pending, skipped or failed); the run's `result.deliveryState` says which. // -// `changed` says whether this run stored a bundle of its own: a resumed run that added nothing reuses -// its prior Revision's bundle and is unchanged (resume decision D5). `priorRevisionId` names the -// Revision the run resumed; the run's own `resumeRevisionId` shows it from session start on. +// `changed` is Cloud's own verification verdict: true when the run delivered new commits, false when +// it ended on its base commit or on its resumed prior's final commit (Revision D8). For a Revision +// registered before Git delivery that is exactly "it stored a bundle of its own". `bundleSize` is +// null for a Revision delivered over Git and keeps its size on an older one. `priorRevisionId` names +// the Revision the run resumed; the run's own `resumeRevisionId` shows it from session start on. func withRevision(t *transaction, o Object) Object { o["revision"] = nil if o.S("executorType") != "agent" { return o } - rev := t.one(`SELECT id, base_commit, final_commit, bundle_key IS NOT NULL AS stored_bundle, bundle_size, - history_size, prior_revision_id, created_at - FROM revisions WHERE run_id=$1 AND tenant_id=$2`, o.S("id"), o.S("tenantId")) + rev := t.one(`SELECT r.id, r.base_commit, r.final_commit, + COALESCE(v.outcome = 'delivered', r.bundle_key IS NOT NULL) AS stored_bundle, r.bundle_size, + r.history_size, r.prior_revision_id, r.created_at + FROM revisions r LEFT JOIN revision_verifications v ON v.execution_id = r.execution_id + WHERE r.run_id=$1 AND r.tenant_id=$2`, o.S("id"), o.S("tenantId")) if rev == nil { return o } diff --git a/internal/core/migrations/0053_retire_tenant_level_clones.sql b/internal/core/migrations/0053_retire_tenant_level_clones.sql new file mode 100644 index 000000000..019ea75a2 --- /dev/null +++ b/internal/core/migrations/0053_retire_tenant_level_clones.sql @@ -0,0 +1,12 @@ +-- Workspaces clone from Git Server snapshots (ADRs cloud/operation "clone step consumes the Binding +-- snapshot" D5 and cloud/controller-integration "snapshot clone input and execution ref grants"). +-- This is a breaking switch: no data is migrated, development data is rebuilt. +-- +-- Tenant-level clone requests are retired. Their executions and receipts go with them, so +-- clone_executions only ever belongs to a Workspace operation's clone step. +DELETE FROM clone_event_receipts r USING clone_executions e WHERE e.execution_id = r.execution_id AND e.workspace_id IS NULL; +DELETE FROM clone_executions WHERE workspace_id IS NULL; +DROP INDEX IF EXISTS one_request_execution; +ALTER TABLE clone_executions DROP COLUMN IF EXISTS clone_request_id; +ALTER TABLE clone_executions ALTER COLUMN workspace_id SET NOT NULL; +DROP TABLE IF EXISTS clone_requests; diff --git a/internal/core/migrations/0054_git_delivery_refs.sql b/internal/core/migrations/0054_git_delivery_refs.sql new file mode 100644 index 000000000..9dc6bcf41 --- /dev/null +++ b/internal/core/migrations/0054_git_delivery_refs.sql @@ -0,0 +1,23 @@ +-- Revisions are delivered over Git (ADR cloud/revision "Git Server delivery and verified delivery +-- refs" D4). This is a breaking switch: no data is migrated, development data is rebuilt. +-- +-- A Revision delivered over Git records the delivery ref Cloud verified through the ref query. Rows +-- registered before the switch keep their bundle shape and meaning and are never rewritten; the +-- two shapes are mutually exclusive. +ALTER TABLE revisions ADD COLUMN IF NOT EXISTS delivery_ref text; +ALTER TABLE revisions DROP CONSTRAINT IF EXISTS revisions_bundle_shape; +ALTER TABLE revisions DROP CONSTRAINT IF EXISTS revisions_shape; +ALTER TABLE revisions ADD CONSTRAINT revisions_shape CHECK ( + (delivery_ref IS NULL AND ( + (base_commit = final_commit AND num_nonnulls(bundle_key, bundle_size, bundle_sha256, bundle_revision_id) = 0) + OR (base_commit <> final_commit AND bundle_revision_id IS NULL + AND num_nonnulls(bundle_key, bundle_size, bundle_sha256) = 3 + AND bundle_size > 0 AND bundle_sha256 ~ '^[0-9a-f]{64}$') + OR (base_commit <> final_commit AND bundle_revision_id IS NOT NULL AND prior_revision_id IS NOT NULL + AND num_nonnulls(bundle_key, bundle_size, bundle_sha256) = 0))) + OR (delivery_ref IS NOT NULL + AND delivery_ref ~ '^refs/ora/deliveries/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + AND num_nonnulls(bundle_key, bundle_size, bundle_sha256, bundle_revision_id) = 0) +); +-- One delivery attempt, one ref: a second Revision can never claim a ref another one verified. +CREATE UNIQUE INDEX IF NOT EXISTS revisions_delivery_ref ON revisions(delivery_ref) WHERE delivery_ref IS NOT NULL; diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index dcf3bfb84..51d4f3fd7 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -64,6 +64,8 @@ There are currently 54 SQL files. | [0050_personal_agent_configurations.sql](0050_personal_agent_configurations.sql) | Persistent personal Agent configuration revisions and private run snapshots | | [0051_workflow_run_cancel.sql](0051_workflow_run_cancel.sql) | WorkflowRun cancellation requests | | [0052_legacy_workflow_issue_run_settlement.sql](0052_legacy_workflow_issue_run_settlement.sql) | One-time settlement of legacy queued workflow IssueRuns | +| [0053_retire_tenant_level_clones.sql](0053_retire_tenant_level_clones.sql) | Retire tenant-level clones | +| [0054_git_delivery_refs.sql](0054_git_delivery_refs.sql) | Revisions delivered over Git | 0050 copies no personal connection or key and backfills no historical run. Clearing configuration retains its revision; run options are stored separately from shared run input. @@ -79,6 +81,8 @@ There are currently 54 SQL files. - 0049 lets `thread_commands` accept a WorkflowRun owner and requires a graph node on workflow commands; each node holds at most one `end_session`, so the scheduler can replay it safely. Every old row carries `run_id` and is not backfilled. Semantics are in [workflows](../../../docs/workflows/workflows.en.md); coverage is in the `TestWorkflowSessionEndsAfterSuccessfulTurn` DB tests. - 0051 adds the cancellation-request instant to `workflow_runs` and backfills no old row; the request records intent only and is settled by the scheduler on later passes. Semantics are in [workflows](../../../docs/workflows/workflows.en.md#scheduling); coverage is in the `TestWorkflowRunCancelBeforeStartNeverStarts` DB tests. - 0052 settles the live `executor_type='workflow'` IssueRuns still `queued`/`dispatched` at upgrade time: they have no executor and no frozen workflow identity, so the ADR's D3 marks them failed with `failure_reason='workflow_execution_unavailable'` instead of migrating them. Other runs and soft-deleted rows are untouched. Rows of that shape stop being produced only once the workflow launch gate binds a manual run to a real WorkflowRun; until that gate (circus#71) lands, `createRun` keeps producing new ones, and this migration runs once at upgrade time and does not settle them. Coverage is `TestMigration0052LegacyWorkflowIssueRunSettlementAppliesFreshAndUpgrades`. +- 0053 is the switch to Workspaces cloned from Git Server snapshots and migrates no data: clone executions that belong to no Workspace and their receipts are deleted, `clone_executions.clone_request_id` and the `one_request_execution` index are dropped, `clone_executions.workspace_id` becomes NOT NULL, and `clone_requests` is dropped; Workspace clone executions and their receipts stay, and it is retry-safe. See [Workspaces](../../../docs/runtime/workspaces.en.md); upgrading a database that still holds tenant-level clones is covered by `TestMigration0053RetiresTenantLevelClonesKeepingWorkspaceClones`. +- 0054 is the switch to Revisions delivered over Git and migrates no data: `revisions` gains `delivery_ref`, and `revisions_bundle_shape` is replaced by `revisions_shape` (with a NULL `delivery_ref` a row keeps a legacy bundle shape; with one it must be a well-formed `refs/ora/deliveries/` and carry no bundle column); the partial unique index `revisions_delivery_ref` lets one delivery ref belong to one Revision only. Existing bundle rows are never rewritten, and it is retry-safe. See [Revisions](../../../docs/agent-runs/revisions.en.md); the constraints are covered by `TestRevisionShapeKeepsGitAndBundleRowsApart`. - New migrations must verify fresh databases, upgrades from the preceding schema, repeated application and key constraints. See [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) and domain upgrade tests in integration. See [Persistence](../../../docs/core/persistence.en.md) for ownership/transactions, and [Workflows](../../../docs/workflows/workflows.en.md) / [External Git line](../../../docs/repositories/external-git.en.md) for runtime semantics. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 065d56cb2..bc1efb627 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -64,6 +64,8 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 | [0050_personal_agent_configurations.sql](0050_personal_agent_configurations.sql) | 个人 Agent 配置的持续 revision 与私有运行快照 | | [0051_workflow_run_cancel.sql](0051_workflow_run_cancel.sql) | WorkflowRun 的取消请求 | | [0052_legacy_workflow_issue_run_settlement.sql](0052_legacy_workflow_issue_run_settlement.sql) | 存量 queued 工作流 IssueRun 的一次性结算 | +| [0053_retire_tenant_level_clones.sql](0053_retire_tenant_level_clones.sql) | 退役租户级 clone | +| [0054_git_delivery_refs.sql](0054_git_delivery_refs.sql) | 经 Git 交付的 Revision | 0050 不复制个人连接或 Key,不回填历史运行;清除配置保留 revision,运行选项独立于共享运行输入保存。 @@ -79,6 +81,8 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 - 0049 让 `thread_commands` 接受 WorkflowRun 归属,并要求工作流命令带图节点;每个节点至多一条 `end_session`,调度器可安全重放。旧行全部带 `run_id`,不回填。语义见 [工作流](../../../docs/workflows/workflows.md),覆盖见 `TestWorkflowSessionEndsAfterSuccessfulTurn` 等 DB 测试。 - 0051 给 `workflow_runs` 增加取消请求时刻,不回填旧行;请求只记录意图,由调度器在后续轮次结算。语义见[工作流](../../../docs/workflows/workflows.md#调度),覆盖见 `TestWorkflowRunCancelBeforeStartNeverStarts` 等 DB 测试。 - 0052 结算升级时仍为 `queued`/`dispatched` 的、存活的 `executor_type='workflow'` IssueRun:它们没有执行器、也没有冻结的工作流身份,按 ADR D3 标记失败并写 `failure_reason='workflow_execution_unavailable'`,不做迁移;其他状态的行与软删除行保持原样。工作流启动门禁把 manual 运行绑定到真实 WorkflowRun 之后才会停止产生这种行;门禁(circus#71)落地前 `createRun` 仍会造出新行,而本迁移只在升级时执行一次,不会回头结算它们。覆盖见 `TestMigration0052LegacyWorkflowIssueRunSettlementAppliesFreshAndUpgrades`。 +- 0053 是 Workspace 改为从 Git Server 快照 clone 的切换,不迁移数据:删除不属于 Workspace 的 clone execution 及其收据,去掉 `clone_executions.clone_request_id` 与索引 `one_request_execution`,`clone_executions.workspace_id` 改为 NOT NULL,删除 `clone_requests`;Workspace 的 clone execution 与收据保留,可重复执行。语义见 [Workspace](../../../docs/runtime/workspaces.md),仍有租户级 clone 的库的升级覆盖见 `TestMigration0053RetiresTenantLevelClonesKeepingWorkspaceClones`。 +- 0054 是 Revision 改为经 Git 交付的切换,不迁移数据:`revisions` 增加 `delivery_ref`,`revisions_bundle_shape` 换成 `revisions_shape`(`delivery_ref` 为 NULL 时仍是原有的 bundle 形状,非 NULL 时必须是格式正确的 `refs/ora/deliveries/` 且不带任何 bundle 列),部分唯一索引 `revisions_delivery_ref` 让一个交付 ref 只属于一个 Revision;既有 bundle 行不改写,可重复执行。语义见 [Revision](../../../docs/agent-runs/revisions.md),约束覆盖见 `TestRevisionShapeKeepsGitAndBundleRowsApart`。 - 新增迁移须验证空库、前一 schema 升级、重复应用与关键约束。覆盖见 [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) 和 integration 的领域升级测试。 数据归属与事务规则见 [持久化](../../../docs/core/persistence.md),运行语义见 [工作流](../../../docs/workflows/workflows.md) 和 [外部 Git 线](../../../docs/repositories/external-git.md)。 diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index ccfbd7816..0db824a67 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -177,9 +177,11 @@ var sessionEndReasons = map[string]bool{ "user_ended": true, "idle_timeout": true, "cancelled": true, "agent_failed": true, "interrupted": true, } +// revisionFailureReasons is the closed set a Node may report. The retired bundle_failed value is +// reserved on the wire, so a result carrying it decodes to a reason outside this set and is refused. var revisionFailureReasons = map[string]bool{ "session_not_settled": true, "checkout_unavailable": true, "snapshot_failed": true, - "bundle_failed": true, "history_unavailable": true, "upload_failed": true, + "history_unavailable": true, "upload_failed": true, "push_rejected": true, "push_failed": true, } func recordNodeReceipt(t *transaction, execution string, sequence int64, event string) { diff --git a/internal/core/public.go b/internal/core/public.go index 223b4c4e5..e54330274 100644 --- a/internal/core/public.go +++ b/internal/core/public.go @@ -13,7 +13,7 @@ func itoa(n int) string { return strconv.Itoa(n) } // PublicRequest is populated only after service and final-user credentials are verified. type PublicRequest struct { - Method, Path, TenantID, ProjectID, WorkspaceID, SpaceID, OperationID, CloneID, UserID, IssueID, CommentID, ConversationMessageID, LabelID, StatusID, ViewID, RunID, ContextRefID, InteractionID, WorkflowID, SnapshotID, FormRef, InvitationID, JoinLinkID, JoinRequestID, Key, After, Before, Query, GroupBy string + Method, Path, TenantID, ProjectID, WorkspaceID, SpaceID, OperationID, UserID, IssueID, CommentID, ConversationMessageID, LabelID, StatusID, ViewID, RunID, ContextRefID, InteractionID, WorkflowID, SnapshotID, FormRef, InvitationID, JoinLinkID, JoinRequestID, Key, After, Before, Query, GroupBy string // FormIssueID is the optional `issueId` query parameter of the form-descriptor route. It is // deliberately its own field rather than a fallback into IssueID: IssueID drives dispatch in both // readPublic and Public, so a query parameter folded into it would let `GET /issues?issueId=…` @@ -61,7 +61,6 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro status := 200 var dispatches []dispatchTarget var events []SpaceEvent - var accepted []string queueConversation := false result, e := s.transact(ctx, func(t *transaction) Object { u := identityWithAlias(t, r.Identity) @@ -175,15 +174,6 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro out = createProject(t, r, uid, hash) status = 202 events = append(events, SpaceEvent{Type: "project.created", SpaceID: out.O("resource").S("spaceId"), ProjectID: out.O("resource").S("id")}) - case strings.HasSuffix(r.Path, "/clones"): - // Clone requests are accepted work items outside the operation model; they share the - // public idempotency scope but never create an operation or effect. - var queued string - out, queued = clonesPublic(t, r, uid) - status = 202 - if queued != "" { - accepted = append(accepted, queued) - } case r.OperationID != "": out = retryOperation(t, r, uid) status = 202 @@ -438,9 +428,6 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro s.Events.Publish(ev) } } - for _, id := range accepted { - s.signalWork(id) - } } return result, status, e } @@ -524,9 +511,6 @@ func readPublic(t *transaction, r *PublicRequest, uid string) Object { switch { case strings.HasSuffix(r.Path, "/invitations") || strings.HasSuffix(r.Path, "/join-links") || strings.HasSuffix(r.Path, "/join-requests"): return adminJoinRead(t, r) - case r.CloneID != "" || strings.HasSuffix(r.Path, "/clones"): - out, _ := clonesPublic(t, r, uid) - return out case strings.HasSuffix(r.Path, "/spaces"): return listSpaces(t, r, uid) case strings.HasSuffix(r.Path, "/members"): @@ -662,7 +646,9 @@ func createProject(t *transaction, r *PublicRequest, uid, hash string) Object { _, e = cloneauth.NormalizeRepository(repo) require(e == nil, 400, "invalid_repository_url") // Cloud reads no remote repository, so the caller names the branch the Binding follows; it - // is the Project's default branch and the only one phase one synchronizes. + // is the Project's default branch and the only one phase one synchronizes. Workspaces take it + // as the short name of their Git Server branch refs/heads/workspaces//, so it must + // be a name Git accepts in a ref. branch := r.Body.S("defaultBranch") require(strings.TrimSpace(branch) != "", 400, "default_branch_required") require(validBranchName(branch) && branch != "HEAD", 400, "default_branch_required") @@ -687,8 +673,8 @@ func createProject(t *transaction, r *PublicRequest, uid, hash string) Object { } func insertWorkspace(t *transaction, tid, owner, creator, pid, wid, kind, ref, title string) { - // The Workspace's Node clones ref into the Workspace's own data; there is no shared Project - // repository or linked worktree any more. + // ref is the short name of the Workspace's branch on the Git Server; its Node checks the + // snapshot out on a local branch of that name in the Workspace's own data. t.exec("INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,requested_ref) VALUES($1,$2,$3,$4,'verified_request',$5,$6,'running','provisioning',$7)", wid, tid, owner, creator, pid, kind, ref) if kind == "isolated" { t.exec("INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,$3)", newID(), wid, title) @@ -699,13 +685,14 @@ func createWorkspace(t *transaction, r *PublicRequest, p Object, uid, hash strin require(p.S("lifecycle") == "active", 409, "resource_unavailable") idleProject(t, p.S("id")) title := validText(r.Body.S("title"), 200) - ref := validRef(r.Body.S("baseRef")) - if ref == "HEAD" { - // HEAD means the Project's default branch, which is concrete for every Project created - // since defaultBranch became required; an older Project that still stores HEAD cannot say. - ref = p.S("defaultBranch") - require(ref != "HEAD", 400, "default_branch_required") + // A Workspace starts from its Binding's current snapshot, which holds only the Binding's branch + // (operation D3). An absent, empty or HEAD baseRef means that branch; naming any other branch + // would give a Workspace called "feature" the content of "main", so it is refused. + ref := p.S("defaultBranch") + if requested := strings.TrimSpace(r.Body.S("baseRef")); requested != "" && requested != "HEAD" { + require(validRef(requested) == ref, 400, "base_ref_not_synced") } + require(ref != "" && ref != "HEAD", 400, "default_branch_required") wid := newID() // The project's durable owner is part of the workspace FK. A different // tenant member may initiate this action, recorded separately as actor. @@ -812,6 +799,15 @@ func ownedOperation(t *transaction, r *PublicRequest, uid string) Object { membership(t, r.TenantID, uid, true) return adminOperation(o) } + // The frozen clone input names the Git Server's repository and refs; like a Revision's delivery + // ref it is Cloud's and the Controller's, never part of the public API. + if req := o.O("request"); req["clone"] != nil { + delete(req, "clone") + } + // A clone step that ended with repository_sync_failed keeps the sync Operation's ID for Cloud and + // operators, never for the public view; a run shows the sync's safe error code through its + // preparation instead (operation D2). + delete(o.O("result"), "syncOperationId") return o } diff --git a/internal/core/repository_credentials.go b/internal/core/repository_credentials.go index 5c6f57749..5835ab17b 100644 --- a/internal/core/repository_credentials.go +++ b/internal/core/repository_credentials.go @@ -1,27 +1,5 @@ package core -// requireRepositoryAccess authorizes only new remote Git work. Finishing accepted executions, -// querying results, local code access, ordinary restart and cleanup do not call this guard. -// An available reference alone cannot enable a missing scoped credential provider. -func requireRepositoryAccess(t *transaction, pid string) { - p := t.one("SELECT * FROM "+projectView+" p WHERE id=$1", pid) - require(p != nil, 404, "not_found") - id := p.S("credentialRefId") - if id == "" { - return - } // Anonymous public Git is the existing real execution path. - c := t.one("SELECT * FROM credential_refs WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", id, p.S("tenantId")) - require(c != nil, 409, "repository_credential_unavailable") - require(c.S("availability") == "available", 409, "repository_credential_frozen") - if c.S("attribution") != "team" { - require(t.one("SELECT m.user_id FROM tenant_memberships m JOIN users u ON u.id=m.user_id WHERE m.tenant_id=$1 AND m.user_id=$2 AND m.status='active' AND u.status='active' AND u.deleted_at IS NULL", c.S("tenantId"), c.S("ownerUserId")) != nil, 409, "repository_credential_frozen") - } - require(c.S("repositoryScope") == "" || c.S("repositoryScope") == p.S("repositoryUrl"), 409, "repository_credential_scope_mismatch") - // No production Controller/Node resolves project references yet. Refuse explicitly; never - // substitute a global Git/SSH credential or issue an anonymous clone for a credentialed project. - reject(409, "repository_credential_executor_unavailable") -} - // freezeInactiveRepositoryCredentials also catches deployment-side state changes before admission. // The database triggers make membership deactivation and freezing atomic; this is a recovery scan. func freezeInactiveRepositoryCredentials(t *transaction) { diff --git a/internal/core/repository_publish.go b/internal/core/repository_publish.go index f8361d857..56fa9148a 100644 --- a/internal/core/repository_publish.go +++ b/internal/core/repository_publish.go @@ -47,10 +47,12 @@ func preparePublish(t *transaction, r *PublicRequest, uid string) publishRequest require(validID(id), 400, "invalid_revision") rev := t.one("SELECT * FROM revisions WHERE id=$1 AND project_id=$2 AND tenant_id=$3", id, p.S("id"), r.TenantID) require(rev != nil, 404, "revision_not_found") - // The Revision's delivery ref and final commit are values Cloud already verified. - name, ok := ParseGitRef(rev.S("revisionRef")) + // The Revision's delivery ref and final commit are values Cloud already verified (revision + // ADR D4). revision_ref is only the Node-local refs/ora/revisions/ and never exists on + // the Git Server; a Revision registered in the bundle shape has no delivery ref to publish. + name, ok := ParseGitRef(rev.S("deliveryRef")) require(ok && name.Namespace == GitDelivery, 409, "revision_not_publishable") - q.revisionID, q.sourceRef, q.sourceCommit = id, rev.S("revisionRef"), rev.S("finalCommit") + q.revisionID, q.sourceRef, q.sourceCommit = id, rev.S("deliveryRef"), rev.S("finalCommit") } else { q.sourceRef, q.sourceCommit = r.Body.S("sourceRef"), r.Body.S("sourceCommit") name, ok := ParseGitRef(q.sourceRef) diff --git a/internal/core/revision.go b/internal/core/revision.go index d02d10597..d95a016b4 100644 --- a/internal/core/revision.go +++ b/internal/core/revision.go @@ -3,14 +3,16 @@ package core import ( "context" "encoding/hex" + "errors" "github.com/wanglongan587/cloud/internal/objectstore" ) // revisionVerificationFailure is the reason Cloud records on its own verdict when the declared -// objects could not be confirmed. It is Cloud's word, never a Node's: the wire's closed set -// deliberately excludes it so a Node cannot assert Cloud's verification outcome about objects Cloud -// has not looked at. The business settlement recognizes it as the one reason outside that set. +// delivery ref or session history could not be confirmed. It is Cloud's word, never a Node's: the +// wire's closed set deliberately excludes it so a Node cannot assert Cloud's verification outcome +// about evidence Cloud has not looked at. The business settlement recognizes it as the one reason +// outside that set. const revisionVerificationFailure = "verification_failed" type revisionVerification struct { @@ -22,8 +24,11 @@ func revisionSuccess(result Object) bool { return result.S("outcome") == "revision_delivered" || result.S("outcome") == "revision_unchanged" } -// prepareRevision authenticates and validates the frozen declaration before contacting storage. -// Definitive negative evidence is settled, while transport failures remain retryable with no ACK. +// prepareRevision authenticates and validates the frozen declaration, then checks it against the +// Git Server and the object store outside any transaction (Revision D3): the delivery ref must name +// the declared final commit and the session history must match its declared size and digest. +// Definitive negative evidence is settled as a verification failure, while an unreachable Git +// Server or object store stays retryable with no takeover and no ACK. func (s *Store) prepareRevision(ctx context.Context, r *ControlRequest) (*revisionVerification, error) { e, err := s.transact(ctx, func(t *transaction) Object { require(r.Service != nil && r.Service.Role == "controller", 403, "service_forbidden") @@ -42,6 +47,8 @@ func (s *Store) prepareRevision(ctx context.Context, r *ControlRequest) (*revisi validateDeliveryDeclaration(e, r.Body.O("result")) liveDelivery(t, e) require(r.Body.N("sequence") == e.N("lastEventSequence")+1 && r.Body.S("event") != "", 409, "receipt_conflict") + w := t.one("SELECT tenant_id,project_id FROM workspaces WHERE id=$1", e.S("workspaceId")) + e["tenantId"], e["projectId"] = w.S("tenantId"), w.S("projectId") return e }) if err != nil || e == nil { @@ -49,36 +56,44 @@ func (s *Store) prepareRevision(ctx context.Context, r *ControlRequest) (*revisi } result := r.Body.O("result") verified := &revisionVerification{input: jsonText(e.O("input")), valid: true} - objects := []Object{result.O("history")} - if result.S("outcome") == "revision_delivered" { - objects = append(objects, result.O("bundle")) + if s.GitServer == nil || s.GitServer.Refs == nil { + return nil, &Fault{Code: "git_server_unavailable", Status: 503, Params: Object{}} } - for _, object := range objects { - valid, err := objectstore.Verify(ctx, s.ObjectStore, object.S("key"), object.N("size"), object.S("sha256")) - if err != nil { - return nil, &Fault{Code: "object_store_unavailable", Status: 503, Params: Object{}} - } - verified.valid = verified.valid && valid + // A delivery ref can only be created, never moved or deleted, so what the query sees now is + // what it names when the Revision is registered and ever after. + commit, err := s.GitServer.Refs.ResolveGitRef(ctx, e.S("tenantId"), e.S("projectId"), e.O("input").S("deliveryRef")) + switch { + case errors.Is(err, ErrGitRefNotFound): + verified.valid = false + case err != nil: + return nil, &Fault{Code: "git_server_unavailable", Status: 503, Params: Object{}} + default: + verified.valid = commit == result.S("finalCommit") + } + history := result.O("history") + valid, err := objectstore.Verify(ctx, s.ObjectStore, history.S("key"), history.N("size"), history.S("sha256")) + if err != nil { + return nil, &Fault{Code: "object_store_unavailable", Status: 503, Params: Object{}} } + verified.valid = verified.valid && valid return verified, nil } +// validateDeliveryDeclaration compares a delivery result with its frozen input before any external +// check, so a contradictory declaration never reaches the Git Server or the object store. func validateDeliveryDeclaration(e, result Object) { input := e.O("input") require(result.O("node").S("nodeId") == e.S("nodeId"), 409, "result_conflict") require(result.S("baseCommit") == input.S("baseCommit") && commitID(result.S("finalCommit")) && result.S("revisionRef") == input.S("revisionRef"), 409, "result_conflict") + // A bundle has no place in a Git delivery; a result carrying one is not this contract's. + require(len(result.O("bundle")) == 0, 409, "result_conflict") history := result.O("history") require(history.S("key") == input.S("historyKey") && validStoredObject(history), 400, "invalid_result") - if result.S("outcome") == "revision_unchanged" { - // A resumed run that added nothing ends on its prior Revision's final commit (contract D4). - prior := input.O("priorRevision") - unchanged := result.S("finalCommit") == result.S("baseCommit") || - (prior.S("finalCommit") != "" && result.S("finalCommit") == prior.S("finalCommit")) - require(unchanged && len(result.O("bundle")) == 0, 409, "result_conflict") - } else { - bundle := result.O("bundle") - require(result.S("finalCommit") != result.S("baseCommit") && bundle.S("key") == input.S("bundleKey") && validStoredObject(bundle) && bundle.N("size") > 0, 409, "result_conflict") - } + // A resumed run that added nothing ends on its prior Revision's final commit (contract D4). + prior := input.O("priorRevision") + unchanged := result.S("finalCommit") == result.S("baseCommit") || + (prior.S("finalCommit") != "" && result.S("finalCommit") == prior.S("finalCommit")) + require(unchanged == (result.S("outcome") == "revision_unchanged"), 409, "result_conflict") } func validStoredObject(object Object) bool { @@ -120,23 +135,17 @@ func settleVerifiedRevision(t *transaction, r *ControlRequest, e Object) { settled["reason"] = reason } else { w := t.one("SELECT w.*,b.repository_url FROM workspaces w JOIN repository_bindings b ON b.project_id=w.project_id WHERE w.id=$1", e.S("workspaceId")) - bundle, history := result.O("bundle"), result.O("history") - var bundleKey, bundleSize, bundleDigest any - if len(bundle) > 0 { - bundleKey, bundleSize, bundleDigest = bundle.S("key"), bundle.N("size"), bundle.S("sha256") - } - // A run that resumed and moved off its base commit records its prior Revision; one that ended - // on the prior's final commit reuses the prior's bundle instead of storing one (resume D5). - var priorID, holderID any + history := result.O("history") + // A run that resumed and moved off its base commit records its prior Revision. Every Git + // Revision, an unchanged one included, has its own verified delivery ref, so nothing is + // borrowed from the prior (Revision D7). + var priorID any if prior := e.O("input").O("priorRevision"); prior.S("revisionId") != "" && result.S("finalCommit") != result.S("baseCommit") { priorID = prior.S("revisionId") - if len(bundle) == 0 { - holderID = resumedBundleHolder(t, prior.S("revisionId")) - } } id := newID() - t.exec(`INSERT INTO revisions(id,execution_id,tenant_id,run_id,workspace_id,project_id,repository_url,base_commit,final_commit,revision_ref,bundle_key,bundle_size,bundle_sha256,history_key,history_size,history_sha256,prior_revision_id,bundle_revision_id) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, id, e.S("executionId"), w.S("tenantId"), e.S("operationId"), w.S("id"), w.S("projectId"), w.S("repositoryUrl"), result.S("baseCommit"), result.S("finalCommit"), result.S("revisionRef"), bundleKey, bundleSize, bundleDigest, history.S("key"), history.N("size"), history.S("sha256"), priorID, holderID) + t.exec(`INSERT INTO revisions(id,execution_id,tenant_id,run_id,workspace_id,project_id,repository_url,base_commit,final_commit,revision_ref,delivery_ref,history_key,history_size,history_sha256,prior_revision_id) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, id, e.S("executionId"), w.S("tenantId"), e.S("operationId"), w.S("id"), w.S("projectId"), w.S("repositoryUrl"), result.S("baseCommit"), result.S("finalCommit"), result.S("revisionRef"), e.O("input").S("deliveryRef"), history.S("key"), history.N("size"), history.S("sha256"), priorID) settled["revision"] = t.one("SELECT * FROM revisions WHERE id=$1", id) settled["revisionId"] = id if outcome == "delivered" { diff --git a/internal/core/revision_grants.go b/internal/core/revision_grants.go index 0b2be32b1..0c036d7d6 100644 --- a/internal/core/revision_grants.go +++ b/internal/core/revision_grants.go @@ -6,52 +6,30 @@ import ( "github.com/wanglongan587/cloud/internal/objectstore" ) -// revisionDownloadGrants signs the read of the prior Revision bundle for a session execution that has -// no result yet (contract decision D2). The key is the one the session input fixed, so a grant can -// never reach another object. Like upload grants, nothing about the URL is written down. -func revisionDownloadGrants(t *transaction, executionID string) Object { - e := t.one("SELECT * FROM node_executions WHERE execution_id=$1 AND kind='agent_session'", executionID) - require(e != nil, 404, "not_found") - require(len(e.O("result")) == 0 && e["terminatedByForceStopId"] == nil, 409, "dispatch_conflict") - liveDelivery(t, e) - require(t.objectStore != nil, 409, "object_store_unconfigured") - key := e.O("input").O("priorRevision").O("bundle").S("key") - require(key != "", 409, "dispatch_conflict") - signed, err := objectstore.PresignGET(t.objectStore, key, time.Now()) - require(err == nil, 409, "object_store_unconfigured") - return Object{"grants": []Object{{"objectKey": key, "url": signed.URL, "method": signed.Method, "headers": signed.Headers, "expiresAt": signed.Expires}}} -} - -// revisionGrants signs upload URLs for a delivery execution that has no result yet. -// Nothing about the URL is written down: the signature is returned to the caller and forgotten. +// revisionGrants signs the upload URL of the session history for a delivery execution that has no +// result yet. The code itself is pushed to the Git Server, which authorizes every push from current +// state, so the history key is the only object a grant can ever reach; a checksum for any other key +// is refused. Nothing about the URL is written down: the signature is returned to the caller and +// forgotten. func revisionGrants(t *transaction, executionID string, checksums Object) Object { e := t.one("SELECT * FROM node_executions WHERE execution_id=$1 AND kind='deliver_revision'", executionID) require(e != nil, 404, "not_found") require(len(e.O("result")) == 0 && e["terminatedByForceStopId"] == nil, 409, "dispatch_conflict") liveDelivery(t, e) require(t.objectStore != nil, 409, "object_store_unconfigured") - input := e.O("input") - keys := []string{input.S("bundleKey"), input.S("historyKey")} - require(len(checksums) <= len(keys), 400, "invalid_upload_checksum") - for key := range checksums { - require(key == input.S("bundleKey") || key == input.S("historyKey"), 400, "invalid_upload_checksum") + key := e.O("input").S("historyKey") + require(key != "", 409, "dispatch_conflict") + for name := range checksums { + require(name == key, 400, "invalid_upload_checksum") } - grants := make([]Object, 0, len(keys)) - for _, key := range keys { - require(key != "", 409, "dispatch_conflict") - if len(checksums) > 0 && checksums[key] == nil { - continue - } - var signed objectstore.Grant - var err error - if len(checksums) > 0 { - signed, err = objectstore.PresignPUTChecksum(t.objectStore, key, checksums.S(key), time.Now()) - require(err == nil, 400, "invalid_upload_checksum") - } else { - signed, err = objectstore.PresignPUT(t.objectStore, key, time.Now()) - } + var signed objectstore.Grant + var err error + if len(checksums) > 0 { + signed, err = objectstore.PresignPUTChecksum(t.objectStore, key, checksums.S(key), time.Now()) + require(err == nil, 400, "invalid_upload_checksum") + } else { + signed, err = objectstore.PresignPUT(t.objectStore, key, time.Now()) require(err == nil, 409, "object_store_unconfigured") - grants = append(grants, Object{"objectKey": key, "url": signed.URL, "method": signed.Method, "headers": signed.Headers, "expiresAt": signed.Expires}) } - return Object{"grants": grants} + return Object{"grants": []Object{{"objectKey": key, "url": signed.URL, "method": signed.Method, "headers": signed.Headers, "expiresAt": signed.Expires}}} } diff --git a/internal/core/runtime_control_delivery.go b/internal/core/runtime_control_delivery.go index d9e0ff6c9..1753cb4be 100644 --- a/internal/core/runtime_control_delivery.go +++ b/internal/core/runtime_control_delivery.go @@ -55,7 +55,6 @@ func runtimeControlCommand(t *transaction, r *ControlRequest) Object { } w := t.one("SELECT * FROM workspaces WHERE id=$1", c.S("workspaceId")) require(run != nil && runtimeUsable(t, w, w.S("creatorUserId")), 403, "runtime_use_forbidden") - requireRepositoryAccess(t, w.S("projectId")) binding := runtimeBinding(t, c, n) binding["executionId"], binding["nodeOperationId"] = e.S("executionId"), e.S("nodeOperationId") return binding @@ -64,7 +63,6 @@ func runtimeControlCommand(t *transaction, r *ControlRequest) Object { o := t.one("SELECT * FROM operations WHERE id=$1", c.S("maintenanceOperationId")) require(o.S("state") == "running" && o.N("controllerEpoch") == r.Body.N("epoch") && o.S("step") == step, 409, "execution_closed") require(runtimeUsable(t, t.one("SELECT * FROM workspaces WHERE id=$1", c.S("workspaceId")), o.S("actorUserId")), 403, "runtime_use_forbidden") - requireRepositoryAccess(t, o.S("projectId")) binding := runtimeBinding(t, c, n) binding["executionId"] = e.S("executionId") binding["nodeOperationId"] = e.S("nodeOperationId") diff --git a/internal/core/signals.go b/internal/core/signals.go index ca66b1ea5..1e7633e03 100644 --- a/internal/core/signals.go +++ b/internal/core/signals.go @@ -16,7 +16,8 @@ type ControlSignal struct { type ControlSignalKind string const ( - // SignalWorkAvailable follows a committed clone request; OperationID names it. + // SignalWorkAvailable follows a committed execution work item (an Agent session or a Revision + // delivery); OperationID carries the ID of its IssueRun. SignalWorkAvailable ControlSignalKind = "work_available" // SignalOperationAvailable follows a committed transaction that queued a runtime Workspace // operation (created or retried); OperationID names it. A retry_wait operation becoming due has no diff --git a/internal/core/store.go b/internal/core/store.go index c5d8c0ee8..cc3b3a333 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -87,14 +87,16 @@ type Store struct { // PluginExecution remains for fixtures that still name the old simulation mode. Plugin installs // are Node executions and are no longer gated on it. - PluginExecution PluginExecutionCapability - legacyCloneFixture bool + PluginExecution PluginExecutionCapability // ObjectStore signs upload grants and verifies stored checksums outside SQL transactions. // Nil means delivery is explicitly skipped through the transaction-scoped business hook. ObjectStore *objectstore.Config // WorkflowAssets downloads the pinned package of a workflow plugin for asset import. Nil // keeps the import loop idle, so a control-plane-only deployment never leaves the cluster. WorkflowAssets WorkflowAssetSource + // GitServer is where Workspaces clone from and Revisions are delivered to. Nil means clone + // steps block as git_server_unconfigured and deliveries are skipped. + GitServer *GitServer // ModelEgress is the immutable deployment admission policy for personal connection writes. ModelEgress ModelEgress // PersonalAgentRuntime is nil until a deployment supports the versioned adapter path. @@ -225,17 +227,6 @@ func NewStore(db *gorm.DB) (*Store, error) { }, nil } -// NewDevelopmentStore explicitly enables the retired unscoped clone test contract. Production -// NewStore remains runtime-scoped; this constructor must never be used by the production server. -func NewDevelopmentStore(db *gorm.DB) (*Store, error) { - s, err := NewStore(db) - if err != nil { - return nil, err - } - s.legacyCloneFixture = true - return s, nil -} - type transaction struct { tx *sql.Tx ctx context.Context @@ -250,7 +241,6 @@ type transaction struct { assist InputAssistProvider simulator WorkflowRunSimulator // queued names operations this transaction made claimable; they are published only after commit. - legacyCloneFixture bool pluginExecution PluginExecutionCapability objectStore *objectstore.Config onThreadEvents func(context.Context, *sql.Tx, string, string, []Object) error @@ -370,7 +360,7 @@ func (s *Store) transact(ctx context.Context, fn func(*transaction) Object) (out } } }() - t := &transaction{tx: tx, ctx: ctx, store: s, pluginExecution: s.PluginExecution, legacyCloneFixture: s.legacyCloneFixture, directory: s.Directory, contextBuilder: s.Context, forms: s.Forms, assist: s.Assist, simulator: s.Simulator, objectStore: s.ObjectStore, onThreadEvents: s.OnThreadEvents, onSessionEnded: s.OnSessionEnded} + t := &transaction{tx: tx, ctx: ctx, store: s, pluginExecution: s.PluginExecution, directory: s.Directory, contextBuilder: s.Context, forms: s.Forms, assist: s.Assist, simulator: s.Simulator, objectStore: s.ObjectStore, onThreadEvents: s.OnThreadEvents, onSessionEnded: s.OnSessionEnded} t.onRunWorkspaceSettled, t.onRunWorkspaceDeleted, t.onDeliverySettled = s.OnRunWorkspaceSettled, s.OnRunWorkspaceDeleted, s.OnDeliverySettled // Publish this transaction to the B-side hooks, which the seams below invoke with the *sql.Tx // alone. Without it a hook could only wrap the handle in a second transaction view, and every diff --git a/internal/core/workspace_clone.go b/internal/core/workspace_clone.go index 992e8121d..8bf8e7567 100644 --- a/internal/core/workspace_clone.go +++ b/internal/core/workspace_clone.go @@ -1,9 +1,11 @@ package core // The clone step of a runtime Workspace (specs decisions/cloud/operation/0-workspace-runtime-follows- -// desktop-node.md D2). The Controller dispatches one clone to the Workspace's current Node through the -// same execution registry as tenant clone requests; the execution is additionally bound to the -// operation and Workspace, and Cloud, not the Controller, decides its input and target Node. +// desktop-node.md D2, as changed by 20261016-clone-step-consumes-binding-snapshot.md). The Workspace's +// Node clones a Git Server snapshot of the Project's Binding, never the external repository. Cloud +// freezes that input into the operation before a Controller can see the step, and the Controller +// forwards it verbatim; the execution is bound to the operation and Workspace, and Cloud, not the +// Controller, decides its input and target Node. // deferCodes are the finite reasons a Controller may give when it parks an operation. clone_failed // covers every failure a Node reported (a retry is a new execution); clone_result_unknown parks an @@ -17,11 +19,129 @@ var deferCodes = map[string]bool{ // workspaceCloneAttemptLimit bounds one operation across Controller restarts and manual retries. const workspaceCloneAttemptLimit = 3 -// workspaceCloneAttempts reads durable dispatches, rather than an in-memory retry counter. +// Error codes of a clone step that cannot be handed to a Controller (operation D1, D2). +const ( + // snapshotNotReady parks the step while the Binding's latest sync may still produce a snapshot. + snapshotNotReady = "snapshot_not_ready" + // gitServerUnconfigured blocks the step: the deployment has no Git Server to clone from, and + // waiting cannot change that. + gitServerUnconfigured = "git_server_unconfigured" + // invalidWorkspaceRef blocks a step whose Workspace branch cannot name a Git ref. Project + // creation refuses such branches, so it can only follow from data written before that rule. + invalidWorkspaceRef = "invalid_workspace_ref" + // repositoryOperationRunning parks delete_project while a sync or publish may still write. + repositoryOperationRunning = "repository_operation_running" +) + +// repositoryWaitSeconds is how long a step parked on repository state waits before the next claim +// rechecks it. The recheck, not a notification from the external line, ends the wait. +const repositoryWaitSeconds = 10 + +// workspaceCloneAttempts reads durable dispatches, rather than an in-memory retry counter. Waiting +// for a snapshot registers nothing and so never consumes an attempt. func workspaceCloneAttempts(t *transaction, operationID string) int64 { return t.one("SELECT count(*) AS attempts FROM clone_executions WHERE operation_id=$1", operationID).N("attempts") } +// cloneInputOf is the operation's frozen clone input, or nil before it is frozen. +func cloneInputOf(o Object) Object { + input := o.O("request").O("clone") + if len(input) == 0 { + return nil + } + return input +} + +// freezeCloneInput fixes the clone step's input from the Binding's current snapshot, once per +// operation (operation D1). It runs in the transaction that moves the operation into the clone step +// and in every claim of a clone step not yet frozen. It reports whether the operation may be handed +// to a Controller; when it may not, the operation has been parked or ended here (operation D2), so a +// Controller never sees a clone step without its input. A frozen input is never recomputed: every +// execution of the operation, including retries after a failure, clones the same snapshot. +func freezeCloneInput(t *transaction, o Object) bool { + if cloneInputOf(o) != nil { + return true + } + g := t.store.GitServer + if g == nil { + parkOperation(t, o, "blocked", gitServerUnconfigured, 0) + return false + } + // Phase one has exactly one Binding per Project. The snapshot row exists only after Cloud + // verified its ref on the Git Server, so it never names a ref that is not there. The snapshot and + // the Binding's latest sync are read in one statement, so one consistent view decides (operation + // D2): the external line records a successful sync and its snapshot in one transaction without + // taking any lock the claim holds, and two statements at READ COMMITTED could see no snapshot and + // then that sync's success, failing a step that has a snapshot to clone. + b := t.one(`SELECT b.id AS binding_id,s.commit_oid,s.snapshot_ref,ls.id AS sync_id,ls.state AS sync_state + FROM repository_bindings b LEFT JOIN repository_snapshots s ON s.binding_id=b.id + LEFT JOIN LATERAL (SELECT id,state FROM repository_operations WHERE binding_id=b.id AND type=$2 ORDER BY created_at DESC,id DESC LIMIT 1) ls ON true + WHERE b.project_id=$1`, o.S("projectId"), RepositorySync) + if b == nil { + // Every Project is created with its Binding; parking keeps a defect from failing every claim. + parkOperation(t, o, "blocked", "binding_missing", 0) + return false + } + if b.S("commitOid") == "" { + awaitSnapshot(t, o, b) + return false + } + w := t.one("SELECT id,tenant_id,project_id,requested_ref FROM workspaces WHERE id=$1", o.S("workspaceId")) + workspaceRef := GitWorkspaceRefPrefix(w.S("id")) + w.S("requestedRef") + if name, ok := ParseGitRef(workspaceRef); !ok || name.Namespace != GitWorkspaceBranch { + parkOperation(t, o, "blocked", invalidWorkspaceRef, 0) + return false + } + input := Object{ + "kind": "clone", + "repositoryUrl": g.gitRepositoryURL(w.S("tenantId"), w.S("projectId")), + "snapshotRef": b.S("snapshotRef"), + "baseCommit": b.S("commitOid"), + "workspaceRef": workspaceRef, + } + t.exec("UPDATE operations SET request=jsonb_set(request,'{clone}',$2::jsonb),version=version+1,updated_at=now() WHERE id=$1", o.S("id"), jsonText(input)) + return true +} + +// awaitSnapshot decides a clone step whose Binding has no current snapshot by the Binding's latest +// sync (operation D2), read together with the snapshot: while it may still produce one the step waits +// without consuming an attempt; once it ended without success the step fails with that sync +// recorded. Only the sync's ID is recorded: its error code stays on the sync itself, where a run's +// preparation reads it (operation D2). It never falls back to the external repository and never +// starts a sync itself. +func awaitSnapshot(t *transaction, o, b Object) { + switch b.S("syncState") { + case "pending", "leased", "running": + parkOperation(t, o, "retry_wait", snapshotNotReady, repositoryWaitSeconds) + return + } + // Every Binding is created with its first sync and a successful sync records its snapshot in the + // same transaction, so no sync at all, or a successful latest sync without a snapshot, is a defect + // and fails the same way rather than waiting forever. + result := Object{} + if id := b.S("syncId"); id != "" { + result["syncOperationId"] = id + } + t.exec("UPDATE operations SET result=$2 WHERE id=$1", o.S("id"), jsonText(result)) + failWorkspaceClone(t, o, repositorySyncFailed) +} + +// parkOperation takes an operation out of the claimable set without handing it to a Controller. +// retry_wait becomes claimable again after delaySeconds; blocked needs an explicit retry. +func parkOperation(t *transaction, o Object, state, code string, delaySeconds int64) { + if state == "blocked" { + t.exec("UPDATE operations SET state='blocked',error_code=$2,retry_at=NULL,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), code) + if o.S("kind") == "create_workspace" && o.S("workspaceId") != "" { + // Parking a run Workspace creation as blocked is a persistent blocked write like the + // Controller's defer: it freezes the creation's abandonment deadline in this same + // transaction, so the bounded window starts at the write rather than at a later scan. + t.store.freezeWorkspaceCreationDeadline(t, o.S("workspaceId")) + } + return + } + t.exec("UPDATE operations SET state='retry_wait',error_code=$2,retry_at=clock_timestamp()+($3 * interval '1 second'),version=version+1,updated_at=now() WHERE id=$1", o.S("id"), code, delaySeconds) +} + // deferWorkspaceClone retries only settled failures, with a server-owned bounded backoff. // An unknown outcome retains its existing blocked policy and can never consume a new dispatch. func deferWorkspaceClone(t *transaction, o Object) { @@ -29,11 +149,17 @@ func deferWorkspaceClone(t *transaction, o Object) { require(latest.O("result").S("outcome") == "clone_failed", 409, "clone_incomplete") attempts := workspaceCloneAttempts(t, o.S("id")) if attempts < workspaceCloneAttemptLimit { - delay := int64(5) * max(attempts, 1) - t.exec("UPDATE operations SET state='retry_wait',error_code='clone_failed',retry_at=clock_timestamp()+($2 * interval '1 second'),version=version+1,updated_at=now() WHERE id=$1", o.S("id"), delay) + parkOperation(t, o, "retry_wait", "clone_failed", int64(5)*max(attempts, 1)) return } - t.exec("UPDATE operations SET state='failed',error_code='clone_failed',retry_at=NULL,version=version+1,updated_at=now() WHERE id=$1", o.S("id")) + failWorkspaceClone(t, o, "clone_failed") +} + +// failWorkspaceClone ends a clone step for good: admission stays closed, runtime maintenance is +// handed back, a run Workspace takes its failure release and a failed create_project still leaves +// the Project usable, so it can be repaired or deleted. +func failWorkspaceClone(t *transaction, o Object, code string) { + t.exec("UPDATE operations SET state='failed',error_code=$2,retry_at=NULL,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), code) if o.S("kind") == "create_project" { settleFailedProjectCreation(t, o.S("projectId")) } @@ -45,10 +171,12 @@ func deferWorkspaceClone(t *transaction, o Object) { } // workspaceCloneDispatch registers the clone of a Workspace operation in its clone step. Replaying -// the same execution is idempotent; anything else must match the Workspace's Project repository, -// requested ref and current Node exactly, and waits until no earlier execution is still unresolved. +// the same execution is idempotent; anything else must carry the operation's frozen input verbatim +// and name the Workspace's current Node, and waits until no earlier execution is still unresolved. func workspaceCloneDispatch(t *transaction, r *ControlRequest, operation, execution, node string, input Object) Object { o := t.one("SELECT * FROM operations WHERE id=$1 AND workspace_id IS NOT NULL", operation) + // Tenant-level clone requests no longer exist; an ID that names no Workspace operation is not a + // clone this registry can accept. require(o != nil, 404, "not_found") if existing := t.one("SELECT * FROM clone_executions WHERE execution_id=$1", execution); existing != nil { require(existing.S("operationId") == operation && existing.S("nodeId") == node && jsonText(existing.O("input")) == jsonText(input), 409, "dispatch_conflict") @@ -56,13 +184,12 @@ func workspaceCloneDispatch(t *transaction, r *ControlRequest, operation, execut } require(o.S("step") == "clone" && o.S("state") == "running", 409, "dispatch_conflict") require(o.N("controllerEpoch") == r.Body.N("epoch"), 409, "stale_operation") + frozen := cloneInputOf(o) + require(frozen != nil && jsonText(frozen) == jsonText(input), 409, "dispatch_conflict") require(workspaceCloneAttempts(t, operation) < workspaceCloneAttemptLimit, 409, "dispatch_conflict") wid := o.S("workspaceId") require(runtimeUsable(t, t.one("SELECT * FROM workspaces WHERE id=$1", wid), o.S("actorUserId")), 403, "runtime_use_forbidden") - requireRepositoryAccess(t, o.S("projectId")) requireNoForceStop(t, wid) - w := t.one("SELECT w.requested_ref,b.repository_url FROM workspaces w JOIN repository_bindings b ON b.project_id=w.project_id WHERE w.id=$1", wid) - require(input.S("repositoryUrl") == w.S("repositoryUrl") && input.S("branch") == w.S("requestedRef"), 409, "dispatch_conflict") require(currentNode(t, wid).S("nodeId") == node, 409, "dispatch_conflict") require(t.one("SELECT execution_id FROM clone_executions WHERE operation_id=$1 AND (result IS NULL OR result->>'outcome'='clone_ready')", operation) == nil, 409, "dispatch_conflict") t.exec("INSERT INTO clone_executions(execution_id,operation_id,workspace_id,node_id,input,dispatched_epoch,node_operation_id) VALUES($1,$2,$3,$4,$5,$6,$1::text)", execution, operation, wid, node, jsonText(input), r.Body.N("epoch")) @@ -70,10 +197,13 @@ func workspaceCloneDispatch(t *transaction, r *ControlRequest, operation, execut } // advanceClone requires the operation's latest clone execution to have succeeded on this -// Workspace's Node and records its commit as the Workspace's baseline. +// Workspace's Node with exactly the frozen snapshot commit, and records it as the Workspace's +// baseline (operation D3). func advanceClone(t *transaction, o Object, wid string) { e := t.one("SELECT * FROM clone_executions WHERE operation_id=$1 ORDER BY created_at DESC,execution_id DESC LIMIT 1", o.S("id")) require(e != nil && e.O("result").S("outcome") == "clone_ready" && commitID(e.O("result").S("commit")), 409, "clone_incomplete") + frozen := cloneInputOf(o) + require(frozen != nil && e.O("result").S("commit") == frozen.S("baseCommit") && jsonText(e.O("input")) == jsonText(frozen), 409, "clone_incomplete") require(currentNode(t, wid).S("nodeId") == e.S("nodeId"), 409, "clone_incomplete") t.exec("UPDATE workspaces SET base_commit_id=$2,version=version+1 WHERE id=$1", wid, e.O("result").S("commit")) } diff --git a/internal/gitpreset/README.en.md b/internal/gitpreset/README.en.md index 08ebbc7e7..2ad6a730c 100644 --- a/internal/gitpreset/README.en.md +++ b/internal/gitpreset/README.en.md @@ -9,7 +9,7 @@ authorization, so it is never in the service image and no service process import | Files | Responsibility | |---|---| -| `preset.go` | `Snapshot` (a deterministic synthetic history or an existing commit → snapshot ref, tracking ref, a `succeeded` sync and the current snapshot, recorded in one transaction), `Ref`, `Operation` | +| `preset.go` | `Snapshot` (a deterministic synthetic history or an existing commit → snapshot ref, tracking ref, the Binding's unfinished sync advanced to `succeeded` — or a new `succeeded` sync — and the current snapshot, recorded in one transaction), `Ref`, `Operation` (a terminal sync state advances the unfinished sync when there is one) | | `check.go` | `Check`: every ref is in a contract namespace and names a commit; upstream refs belong to the Project's Binding; snapshot refs name their target; tracking refs point at a snapshot; at most one unfinished sync; the current snapshot comes from a succeeded sync of the Binding and its ref on the Git Server names the recorded commit | Usage errors are `UsageError`, contract problems are returned as `violations`, and both stay distinct diff --git a/internal/gitpreset/README.md b/internal/gitpreset/README.md index 69fac6c73..39bf47705 100644 --- a/internal/gitpreset/README.md +++ b/internal/gitpreset/README.md @@ -7,7 +7,7 @@ | 文件 | 职责 | |---|---| -| `preset.go` | `Snapshot`(确定性的合成历史或已有提交 → 快照 ref、跟踪 ref、`succeeded` 同步与当前快照,一个事务登记)、`Ref`、`Operation` | +| `preset.go` | `Snapshot`(确定性的合成历史或已有提交 → 快照 ref、跟踪 ref、把 Binding 未结束的同步推进为 `succeeded`(没有时新登记一行)与当前快照,一个事务登记)、`Ref`、`Operation`(同步的终态先推进未结束的同步) | | `check.go` | `Check`:ref 都在契约 namespace 内并指向提交;上游 ref 属于本 Project 的 Binding;快照 ref 名称等于目标;跟踪 ref 指向已有快照;至多一个未结束同步;当前快照来自本 Binding 的成功同步,且 Git Server 上的快照 ref 指向记录的提交 | 用法错误返回 `UsageError`,契约问题以 `violations` 返回,二者都与命令无法执行(数据库、git 故障)区分开。 diff --git a/internal/gitpreset/preset.go b/internal/gitpreset/preset.go index e3253fca3..b4a642d6b 100644 --- a/internal/gitpreset/preset.go +++ b/internal/gitpreset/preset.go @@ -110,10 +110,17 @@ func (p *Preset) recordSnapshot(ctx context.Context, b binding, commit, snapshot return "", err } defer func() { _ = tx.Rollback() }() - var operation string - if err := tx.QueryRowContext(ctx, `INSERT INTO repository_operations(id,type,tenant_id,project_id,binding_id,state,trigger,finished_at) - VALUES(gen_random_uuid(),'repository_sync',$1,$2,$3,'succeeded','preset',clock_timestamp()) RETURNING id`, b.tenant, b.project, b.id).Scan(&operation); err != nil { - return "", fmt.Errorf("record sync: %w", err) + // The Binding's unfinished sync, such as the one every new Project starts with, is the sync this + // snapshot completes; only without one is a new succeeded sync recorded. + operation, err := advanceSync(ctx, tx, b, "succeeded", nil, operationStates[core.RepositorySync]["succeeded"]) + if err != nil { + return "", err + } + if operation == "" { + if err := tx.QueryRowContext(ctx, `INSERT INTO repository_operations(id,type,tenant_id,project_id,binding_id,state,trigger,finished_at) + VALUES(gen_random_uuid(),'repository_sync',$1,$2,$3,'succeeded','preset',clock_timestamp()) RETURNING id`, b.tenant, b.project, b.id).Scan(&operation); err != nil { + return "", fmt.Errorf("record sync: %w", err) + } } if _, err := tx.ExecContext(ctx, `INSERT INTO repository_snapshots(binding_id,tenant_id,project_id,commit_oid,snapshot_ref,sync_operation_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(binding_id) DO UPDATE SET commit_oid=EXCLUDED.commit_oid,snapshot_ref=EXCLUDED.snapshot_ref,sync_operation_id=EXCLUDED.sync_operation_id,recorded_at=clock_timestamp()`, diff --git a/internal/gitpreset/preset_test.go b/internal/gitpreset/preset_test.go index 70f1bd3a4..6d290ac6f 100644 --- a/internal/gitpreset/preset_test.go +++ b/internal/gitpreset/preset_test.go @@ -196,6 +196,50 @@ func TestRefAndOperationPresets(t *testing.T) { } } +// A Project's unfinished sync, such as the one every new Project starts with, is the sync a preset +// ends: snapshot completes it and a terminal sync state ends it, instead of adding a second row +// beside a sync that would then never finish. +func TestPresetsAdvanceTheUnfinishedSync(t *testing.T) { + p, tenant, project, binding := newPreset(t) + ctx := context.Background() + pending := func() string { + t.Helper() + var id string + if err := p.DB.QueryRow(`INSERT INTO repository_operations(id,type,tenant_id,project_id,binding_id,state,trigger) VALUES(gen_random_uuid(),'repository_sync',$1,$2,$3,'pending','project_created') RETURNING id`, tenant, project, binding).Scan(&id); err != nil { + t.Fatal(err) + } + return id + } + state := func(id string) (string, string) { + t.Helper() + var s, code string + if err := p.DB.QueryRow(`SELECT state,COALESCE(error_code,'') FROM repository_operations WHERE id=$1`, id).Scan(&s, &code); err != nil { + t.Fatal(err) + } + return s, code + } + first := pending() + snapshot := (expect{t}).ok(p.Snapshot(ctx, project, "", 2)) + if snapshot["operationId"] != first { + t.Fatalf("snapshot recorded sync %v, want the pending one %s", snapshot["operationId"], first) + } + if s, _ := state(first); s != "succeeded" { + t.Fatalf("the pending sync is %s, want succeeded", s) + } + second := pending() + failed := (expect{t}).ok(p.Operation(ctx, project, "sync", "failed", "credential_rejected")) + if failed["operationId"] != second { + t.Fatalf("failing a sync wrote %v, want the pending one %s", failed["operationId"], second) + } + if s, code := state(second); s != "failed" || code != "credential_rejected" { + t.Fatalf("the pending sync is %s/%s, want failed/credential_rejected", s, code) + } + var syncs int + if err := p.DB.QueryRow(`SELECT count(*) FROM repository_operations WHERE project_id=$1`, project).Scan(&syncs); err != nil || syncs != 2 { + t.Fatalf("presets added rows beside the syncs they ended: %d %v", syncs, err) + } +} + // check reports each kind of contract violation without failing to run. func TestCheckReportsViolations(t *testing.T) { p, tenant, project, binding := newPreset(t) diff --git a/internal/gitrefquery/README.en.md b/internal/gitrefquery/README.en.md new file mode 100644 index 000000000..286e24bff --- /dev/null +++ b/internal/gitrefquery/README.en.md @@ -0,0 +1,18 @@ +# gitrefquery: Cloud's Git Server ref query client + +[中文](README.md) | [English](README.en.md) + +Cloud's client for the Git Server's read-only ref query. Cloud uses it, outside any database +transaction, to verify that a ref it is about to register (such as a Revision's delivery ref) exists +and names the commit it was told about. The query listener accepts only Cloud's pinned client +certificate; the TLS configuration (pinned Git Server CA, Cloud's identity, no redirects) is built from +the `git_server` section in `internal/config` and injected. + +| Files | Responsibility | +|---|---| +| `client.go` | `New` (base must be `https://host[:port]`; needs an HTTP client and a positive timeout); `ResolveGitRef` queries `GET /internal/v1/refs?tenantId=&projectId=&ref=` and returns the commit of a `200` answer carrying a valid one | + +Only `404 ref_not_found` is the definitive `core.ErrGitRefNotFound`; any other answer, a transport +failure or a timeout is an error that proves nothing, so callers retry rather than conclude. Every +query is bounded by the timeout and the response body by a size limit. The package implements +`core.GitRefResolver`; `internal/core` does not import it. diff --git a/internal/gitrefquery/README.md b/internal/gitrefquery/README.md new file mode 100644 index 000000000..38b6f2815 --- /dev/null +++ b/internal/gitrefquery/README.md @@ -0,0 +1,15 @@ +# gitrefquery:Cloud 的 Git Server ref 查询客户端 + +[中文](README.md) | [English](README.en.md) + +Cloud 访问 Git Server 只读 ref 查询的客户端。Cloud 在任何数据库事务之外用它核对一个即将登记的 ref +(例如 Revision 的交付 ref)存在且指向被告知的提交。查询监听只接受 Cloud 固定的客户端证书;TLS 配置 +(固定 Git Server CA、出示 Cloud 身份、不跟随重定向)由 `internal/config` 的 `git_server` 段构造后注入。 + +| 文件 | 职责 | +|---|---| +| `client.go` | `New`(base 必须是 `https://host[:port]`,需要 HTTP client 与正的超时);`ResolveGitRef` 以 `GET /internal/v1/refs?tenantId=&projectId=&ref=` 查询,`200` 带合法提交即返回该提交 | + +只有 `404 ref_not_found` 是确定的 `core.ErrGitRefNotFound`;其他响应、传输失败或超时都是不能证明任何事的 +错误,调用方应重试而不是下结论。每次查询受超时约束,响应体大小有上限。本包实现 `core.GitRefResolver`, +`internal/core` 不导入它。 diff --git a/internal/gitrefquery/client.go b/internal/gitrefquery/client.go new file mode 100644 index 000000000..4d413c1fd --- /dev/null +++ b/internal/gitrefquery/client.go @@ -0,0 +1,78 @@ +// Package gitrefquery is Cloud's client for the Git Server's read-only ref query. Cloud uses it to +// verify, outside any database transaction, that a ref it is about to register exists and names the +// commit it was told about. The query listener accepts only Cloud's pinned client certificate. +package gitrefquery + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/wanglongan587/cloud/internal/core" +) + +// maxResponse bounds what one answer may be; a commit answer is well under 1 KiB. +const maxResponse = 64 << 10 + +// Client queries one Git Server ref listener. +type Client struct { + base *url.URL + http *http.Client + timeout time.Duration +} + +// New returns a client for the ref listener at base (https, no path, query or fragment). The HTTP +// client carries the TLS configuration that pins the Git Server and presents Cloud's identity; +// timeout bounds every query. +func New(base string, client *http.Client, timeout time.Duration) (*Client, error) { + u, err := url.Parse(base) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("git ref query URL must be https://host[:port]") + } + if client == nil || timeout <= 0 { + return nil, errors.New("git ref query needs an HTTP client and a positive timeout") + } + u.Path = "" + return &Client{base: u, http: client, timeout: timeout}, nil +} + +// ResolveGitRef returns the commit ref names in the Project repository. A 404 ref_not_found is the +// definitive core.ErrGitRefNotFound; every other answer, a transport failure or a timeout is an +// error that proves nothing, so callers retry rather than conclude. +func (c *Client) ResolveGitRef(ctx context.Context, tenantID, projectID, ref string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + target := *c.base + target.Path = "/internal/v1/refs" + target.RawQuery = url.Values{"tenantId": {tenantID}, "projectId": {projectID}, "ref": {ref}}.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), http.NoBody) + if err != nil { + return "", fmt.Errorf("build git ref query: %w", err) + } + response, err := c.http.Do(request) + if err != nil { + return "", fmt.Errorf("git ref query: %w", err) + } + defer func() { _ = response.Body.Close() }() + var body struct { + Commit string `json:"commit"` + Code string `json:"code"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, maxResponse)).Decode(&body); err != nil { + return "", fmt.Errorf("git ref query answered %d without a readable body", response.StatusCode) + } + switch { + case response.StatusCode == http.StatusOK && core.GitObjectID(strings.TrimSpace(body.Commit)): + return body.Commit, nil + case response.StatusCode == http.StatusNotFound && body.Code == "ref_not_found": + return "", core.ErrGitRefNotFound + default: + return "", fmt.Errorf("git ref query answered %d %q", response.StatusCode, body.Code) + } +} diff --git a/internal/gitrefquery/client_test.go b/internal/gitrefquery/client_test.go new file mode 100644 index 000000000..86f3d4e84 --- /dev/null +++ b/internal/gitrefquery/client_test.go @@ -0,0 +1,60 @@ +package gitrefquery + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/wanglongan587/cloud/internal/core" +) + +// A ref query's negative evidence is only a named 404; transport and malformed responses prove +// nothing. The production client must preserve that distinction before Revision registration. +func TestResolveGitRefDistinguishesMissingFromUnavailable(t *testing.T) { + commit := strings.Repeat("a", 40) + for _, tc := range []struct { + name, body string + status int + missing bool + valid bool + }{ + {"present", `{"commit":"` + commit + `"}`, 200, false, true}, + {"missing", `{"code":"ref_not_found"}`, 404, true, false}, + {"unavailable", `{"code":"unavailable"}`, 503, false, false}, + {"unknown_not_found", `{"code":"not_found"}`, 404, false, false}, + {"malformed_commit", `{"commit":"not-a-commit"}`, 200, false, false}, + {"malformed_body", `not-json`, 200, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/internal/v1/refs" || r.URL.Query().Get("tenantId") != "tenant" || r.URL.Query().Get("projectId") != "project" || r.URL.Query().Get("ref") != "refs/ora/deliveries/work" { + t.Error("query changed the exact Project/ref identity") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + client, err := New(server.URL, server.Client(), time.Second) + if err != nil { + t.Fatal(err) + } + got, err := client.ResolveGitRef(t.Context(), "tenant", "project", "refs/ora/deliveries/work") + if tc.valid { + if got != commit || err != nil { + t.Fatalf("resolve = %q, %v", got, err) + } + } else if got != "" || err == nil || errors.Is(err, core.ErrGitRefNotFound) != tc.missing { + t.Fatalf("resolve = %q, %v; missing=%v", got, err, tc.missing) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := client.ResolveGitRef(ctx, "tenant", "project", "refs/ora/deliveries/work"); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled query = %v", err) + } + }) + } +} diff --git a/internal/objectstore/presign.go b/internal/objectstore/presign.go index f228c7c47..7b51a43ea 100644 --- a/internal/objectstore/presign.go +++ b/internal/objectstore/presign.go @@ -53,12 +53,6 @@ func PresignPUTChecksum(cfg *Config, key, digest string, now time.Time) (Grant, return presign(cfg, key, "PUT", map[string]string{"x-amz-sdk-checksum-algorithm": "SHA256", "x-amz-checksum-sha256": base64.StdEncoding.EncodeToString(checksum)}, now) } -// PresignGET signs a single-object read for the Node that restores a prior Revision. It carries no -// condition: the Node verifies size and SHA-256 itself before Git sees the bytes. -func PresignGET(cfg *Config, key string, now time.Time) (Grant, error) { - return presign(cfg, key, "GET", map[string]string{}, now) -} - // presign shares canonicalization between the Node-facing PUT and Cloud's private HEAD. func presign(cfg *Config, key, method string, headers map[string]string, now time.Time) (Grant, error) { if cfg == nil || cfg.Endpoint == "" || cfg.Region == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { diff --git a/internal/objectstore/presign_test.go b/internal/objectstore/presign_test.go index e1864d5f2..0d59d700c 100644 --- a/internal/objectstore/presign_test.go +++ b/internal/objectstore/presign_test.go @@ -102,29 +102,3 @@ func TestPresignPUTUsesTheCanonicalObjectPath(t *testing.T) { } } } - -// A restore read is signed against the sandbox-reachable public endpoint like an upload, but as a -// plain GET: the create-only condition and checksum headers belong to writes only. -func TestPresignGETReadsThroughThePublicEndpointWithoutWriteConditions(t *testing.T) { - cfg := Config{ - Endpoint: "http://objectstore:9000", PublicEndpoint: "http://ora-revisions:9000", Region: "us-east-1", Bucket: "revisions", - PathStyle: true, AccessKeyID: "test-access", SecretAccessKey: "test-secret", UploadGrantTTL: 15 * time.Minute, - } - now := time.Date(2026, 10, 10, 1, 2, 3, 0, time.UTC) - grant, err := PresignGET(&cfg, "revisions/tenant/run/work/revision.bundle", now) - if err != nil { - t.Fatal(err) - } - if grant.Method != "GET" || !grant.Expires.Equal(now.Add(15*time.Minute)) { - t.Fatalf("method %q expiry %v", grant.Method, grant.Expires) - } - if !strings.HasPrefix(grant.URL, "http://ora-revisions:9000/revisions/revisions/tenant/run/work/revision.bundle?") { - t.Fatalf("a read grant must name the public endpoint and the object, got %s", grant.URL) - } - if len(grant.Headers) != 1 || grant.Headers["host"] != "ora-revisions:9000" { - t.Fatalf("a read grant signs only the host, got %v", grant.Headers) - } - if _, err = PresignGET(&cfg, "../escape", now); err == nil { - t.Fatal("escaped key was signed") - } -} diff --git a/internal/simulator/README.en.md b/internal/simulator/README.en.md index 3d5b717a2..4fffed6db 100644 --- a/internal/simulator/README.en.md +++ b/internal/simulator/README.en.md @@ -12,7 +12,7 @@ - Executes mock infrastructure operations: - **Sandbox**: Simulates sandbox instance allocation and termination; sandbox_ensure mounts the Workspace's own data (`/workspaces//home`) and returns the Node's `nodeId`. - **Workspace data**: `workspace_data_delete` removes one Workspace's data directory. - - **Node clone** (`node.go`): `PUT/GET /clones/` stands in for the desktop Node running a clone with the local Git CLI into `home/checkout`, journaled per execution. + - **Node clone** (`node.go`): `PUT/GET /clones/` stands in for the desktop Node fetching the snapshot ref from the Git Server double into a `home/checkout` without any remote, verifying the fetched commit equals `baseCommit` and checking out the Workspace branch's short name with `-B `, journaled per execution; the external repository is never contacted. `NewSubstrate(root, git *GitServer)` injects the Git Server double. - Supports deterministic fault injection (`SetFault`) for testing error recovery and retry policies. #### HTTP interface @@ -23,7 +23,7 @@ The simulator mounts the Substrate double on a loopback HTTP server for developm |---|---|---| | `GET /effects/{effectId}` | The effect UUID Cloud allocated in advance | `404` means the intent is known not to exist; otherwise the original request, `externalId`, `state` and `result`; a query never creates an effect | | `PUT /effects/{effectId}` | `kind`, `projectId`, `workspaceId`, plus `sandboxInstanceId` for terminate | Journaled before it runs; the same ID with the same request is idempotent and with a different request is `409`; a succeeded effect returns its original result | -| `PUT /clones/{executionId}`, `GET /clones/{executionId}` | `workspaceId`, `repositoryUrl`, `branch` | Stands in for a Node running a clone: one execution runs once and is journaled per execution; returns `clone_ready` with `commitId`, or `clone_failed` with a reason | +| `PUT /clones/{executionId}`, `GET /clones/{executionId}` | `workspaceId`, `repositoryUrl` (the Git Server repository), `snapshotRef`, `baseCommit`, `workspaceRef` | Stands in for a Node running a clone: one execution runs once and is journaled per execution; returns `clone_ready` with `path` and `commit`, or `clone_failed` with a `reason` | | `kind` | Result | |---|---| @@ -32,8 +32,13 @@ The simulator mounts the Substrate double on a loopback HTTP server for developm | `workspace_data_delete` | `removed` | `SetFault` modes: `fail` and `unconfirmed` journal a `running` effect and answer `503`; `lose_response` performs the effect but answers `504`, to exercise -"query by stable identity when the outcome is unknown"; `fail` for clones returns `clone_failed`. Repositories come only from an explicit repository URL → -local fixture mapping, never real Git credentials. Plugin installs are Node executions now and Cloud refuses to plan plugin effects, so the double's leftover `plugin_ensure` branch is never reached from Cloud. +"query by stable identity when the outcome is unknown"; `fail` for clones returns `clone_failed`. Clones read only the Git Server double's on-disk +repositories, never an external repository or real Git credentials. Plugin installs are Node executions now and Cloud refuses to plan plugin effects, so the double's leftover `plugin_ensure` branch is never reached from Cloud. + +### Git Server double (`gitserver.go`) +- `GitServer` keeps Project repositories on disk laid out exactly like the Git Server volume, with repository base URL `https://git-server.simulator.invalid:8445` (nothing listens there; the double maps it onto disk); it enforces no ref rules. +- `Config()` hands it to a Store as `core.GitServer`, so Cloud's ref query is answered from disk. +- `SeedSnapshot` stands in for the external line through the git-server-preset fixtures to record a Project's snapshot. ### Controller double (`controller.go`) - Simulates the external control plane worker. The lease, operations and Node registration use the retired `/internal/v1/...` JSON endpoints, which only @@ -43,15 +48,15 @@ local fixture mapping, never real Git credentials. Plugin installs are Node exec - Plans and executes required effects against Substrate. - Reports effect execution outcomes via `/internal/v1/operations/{oid}/effects/{eid}/result`. - Advances or defers operations with proper monotonic epoch fencing. - - Drives the clone step (`clone.go`) through the gRPC `ExecutionService`: registers the execution, lets the simulated Node run it, records the queried result, and defers `clone_failed` to retry_wait. + - Drives the clone step (`clone.go`) through the gRPC `ExecutionService`: registers the execution with Cloud's frozen `cloneInput` forwarded verbatim, lets the simulated Node run it, records the queried result, and defers `clone_failed` to retry_wait; it drops an operation that an advance left non-running (parked or failed). ### Agent session double (`agent.go`) -Every bounded Controller step services run work, Thread commands and Node evidence so quiesce cannot starve its end command. Disk-backed `AgentNode` freezes input, deduplicates commands and echoes/ends a session. Delivery creates cumulative bundles and sealed JSONL from real Git and session data, uploads with checksum-signed PUT headers and refreshes expired grants for the same keys. Terminal protojson evidence survives Controller/Node restart; grants never enter the journal. PostgreSQL and S3 tests exercise these doubles' recovery paths; they do not establish production Rust relay or real Agent execution. +Every bounded Controller step services run work, Thread commands and Node evidence so quiesce cannot starve its end command. Disk-backed `AgentNode` freezes input, deduplicates commands and echoes/ends a session. Delivery first pushes the final commit to the delivery ref on the Git Server double (changed and unchanged alike), then tries to fast-forward `workspaceRef`, and uploads only the sealed JSONL with checksum-signed PUT headers, refreshing expired grants for the same key. Terminal protojson evidence survives Controller/Node restart; grants never enter the journal. Real PostgreSQL and S3 tests prove A's loop, not production Rust relay or a real D Agent. Uploads carry signed `If-None-Match: *` and cannot replace the first object. A 412 after a lost response preserves that object and submits the declaration for Cloud verification; it does not prove matching bytes. Controller cancellation returns its cause without journaling a delivery terminal fact, so restart can continue the original execution. Actual Node terminal failures remain durable. -Before the first PUT, the Node durably freezes bundle/JSONL bytes and metadata under its journal root. Restart after a partial upload reuses that payload rather than creating a new timestamped snapshot. Once terminal evidence is atomically saved, artifact copies are removed and the original terminal remains replayable. Historical pending journals with `Result: null` remain pending; new journals omit absent results. Grants never enter the preparation plan or terminal journal. +Before the first PUT, the Node durably freezes JSONL bytes and metadata under its journal root. Restart after a partial upload reuses that payload rather than creating a new timestamped snapshot. Once terminal evidence is atomically saved, artifact copies are removed and the original terminal remains replayable. Historical pending journals with `Result: null` remain pending; new journals omit absent results. Grants never enter the preparation plan or terminal journal. ### Ephemeral credential issuer - `NewCredentials()` generates in-memory Ed25519 cryptographic keypairs for the four distinct actor roles: `gateway`, `controller`, `node`, and `user`. diff --git a/internal/simulator/README.md b/internal/simulator/README.md index 634f1fabc..778003793 100644 --- a/internal/simulator/README.md +++ b/internal/simulator/README.md @@ -12,7 +12,7 @@ - 执行模拟基础设施操作: - **沙箱模拟(Sandbox)**:模拟 Sandbox 实例的分配与终止;sandbox_ensure 挂载该 Workspace 自己的数据(`/workspaces//home`)并返回 Node 的 `nodeId`。 - **Workspace 数据**:`workspace_data_delete` 删除单个 Workspace 的数据目录。 - - **Node clone**(`node.go`):`PUT/GET /clones/` 模拟 desktop Node 用本地 Git CLI clone 到 `home/checkout`,按 execution 落日志。 + - **Node clone**(`node.go`):`PUT/GET /clones/` 模拟 desktop Node 从 Git Server 替身把快照 ref 取进不带任何 remote 的 `home/checkout`,核对取到的提交等于 `baseCommit`,再以 `-B ` 检出 Workspace 分支的短名,按 execution 落日志;从不访问外部仓库。`NewSubstrate(root, git *GitServer)` 注入 Git Server 替身。 - 支持确定性故障注入(`SetFault`),用于验证错误恢复与重试策略。 #### HTTP 接口 @@ -23,7 +23,7 @@ |---|---|---| | `GET /effects/{effectId}` | Cloud 事先分配的 effect UUID | `404` 表示确知没有这个 intent;否则返回原请求、`externalId`、`state` 与 `result`;查询从不创建 effect | | `PUT /effects/{effectId}` | `kind`、`projectId`、`workspaceId`,terminate 还带 `sandboxInstanceId` | 先写日志再执行;同 ID 同请求幂等,同 ID 不同请求 `409`;已成功的 effect 直接返回原结果 | -| `PUT /clones/{executionId}`、`GET /clones/{executionId}` | `workspaceId`、`repositoryUrl`、`branch` | 模拟 Node 执行 clone:同一 execution 只执行一次并按 execution 写日志;返回 `clone_ready` 与 `commitId`,或 `clone_failed` 与原因 | +| `PUT /clones/{executionId}`、`GET /clones/{executionId}` | `workspaceId`、`repositoryUrl`(Git Server 仓库)、`snapshotRef`、`baseCommit`、`workspaceRef` | 模拟 Node 执行 clone:同一 execution 只执行一次并按 execution 写日志;返回 `clone_ready` 与 `path`、`commit`,或 `clone_failed` 与 `reason` | | `kind` | 结果 | |---|---| @@ -32,9 +32,14 @@ | `workspace_data_delete` | `removed` | `SetFault` 的模式:`fail` 与 `unconfirmed` 写下 `running` 日志后返回 `503`,`lose_response` 执行成功但返回 `504`,用于验证"结果不明时按稳定身份查询"; -clone 的 `fail` 返回 `clone_failed`。仓库只能来自显式的 repository URL → 本地 fixture 映射,不接入真实的 Git 凭据。插件安装已改为 Node execution, +clone 的 `fail` 返回 `clone_failed`。clone 只读取 Git Server 替身在磁盘上的仓库,不访问外部仓库,也不接入真实的 Git 凭据。插件安装已改为 Node execution, Cloud 拒绝计划插件 effect,替身里残留的 `plugin_ensure` 分支不会被 Cloud 触发。 +### Git Server 替身 (`gitserver.go`) +- `GitServer` 在磁盘上按 Git Server 卷的布局保存 Project 仓库,仓库 base URL 为 `https://git-server.simulator.invalid:8445`(无人监听,替身把它映射到磁盘);不执行 ref 规则。 +- `Config()` 交给 Store 作为 `core.GitServer`,Cloud 的 ref 查询直接从磁盘回答。 +- `SeedSnapshot` 经 git-server-preset 夹具代替外部线登记 Project 的快照。 + ### Controller 执行替身 (`controller.go`) - 模拟外部控制平面工作进程。租约、操作与 Node 注册走已退役的 `/internal/v1/...` JSON 接口,只有 `router.NewDevelopment` 打开它们,生产 server 一律返回 `410`;execution 与 Agent 运行走 gRPC: @@ -43,15 +48,15 @@ Cloud 拒绝计划插件 effect,替身里残留的 `plugin_ensure` 分支不 - 针对 Substrate 规划并执行所需的 Effect。 - 通过 `/internal/v1/operations/{oid}/effects/{eid}/result` 上报 Effect 执行结果。 - 在严格的单调递增 Epoch 栅栏保护下推进或延期操作。 - - 通过 gRPC `ExecutionService` 驱动 clone 步骤(`clone.go`):登记 execution、交给模拟 Node 执行、登记查询结果,失败时以 `clone_failed` 延期到 retry_wait。 + - 通过 gRPC `ExecutionService` 驱动 clone 步骤(`clone.go`):原样转发 Cloud 冻结的 `cloneInput` 登记 execution、交给模拟 Node 执行、登记查询结果,失败时以 `clone_failed` 延期到 retry_wait;推进后 operation 不再 running(已停放或失败)时放下它。 ### Agent 会话替身 (`agent.go`) -Controller 每次有界 Step 都领取运行工作、投递 Thread 命令并接管 Node 证据,避免 quiesce 饿死结束命令。`AgentNode` 用磁盘日志固定输入、去重 command_id,回显并结束会话;交付从封存会话和真实 Git checkout 创建累计 bundle/JSONL,取得 checksum 签名头后真实 PUT,授权过期则刷新同键。终态按 protojson 写日志,上传授权不落盘;替换 Controller/Node 后重放原证据。PostgreSQL 与 S3 测试覆盖替身恢复路径,不能代替生产 Rust relay 或真实 Agent 执行证据。 +Controller 每次有界 Step 都领取运行工作、投递 Thread 命令并接管 Node 证据,避免 quiesce 饿死结束命令。`AgentNode` 用磁盘日志固定输入、去重 command_id,回显并结束会话;交付先把最终提交推送到 Git Server 替身上的交付 ref(changed 与 unchanged 都推送),再尝试快进 `workspaceRef`,然后只上传封存的 JSONL:取得 checksum 签名头后真实 PUT,授权过期则刷新同键。终态按 protojson 写日志,上传授权不落盘;替换 Controller/Node 后重放原证据。真实 PostgreSQL + S3 测试证明 A 闭环,不能替代生产 Rust relay 或 D 真实 Agent 验收。 上传携带签名的 `If-None-Match: *`,不能覆盖首次对象。回复丢失后的 412 保留对象并把声明交给 Cloud 核对,不能仅凭 412 推断摘要相同。Controller 取消会返回取消原因且不写交付终态,重启后仍可继续原执行;实际 Node 的终态失败继续持久化。 -首个 PUT 前,Node 在日志根目录内持久固定 bundle/JSONL 字节和元数据;部分上传后重启复用原内容,不重新生成带新时间戳的快照。终态证据原子写入后清理对象副本,此后重放原终态。旧 pending 日志中的 `Result: null` 按未终结处理;新日志省略空结果。授权从不写入准备计划或终态日志。 +首个 PUT 前,Node 在日志根目录内持久固定 JSONL 字节和元数据;部分上传后重启复用原内容,不重新生成带新时间戳的快照。终态证据原子写入后清理对象副本,此后重放原终态。旧 pending 日志中的 `Result: null` 按未终结处理;新日志省略空结果。授权从不写入准备计划或终态日志。 ### 临时凭据签发器 - `NewCredentials()` 在内存中为四种不同的角色(`gateway`、`controller`、`node`、`user`)生成 Ed25519 密码学密钥对。 diff --git a/internal/simulator/clone.go b/internal/simulator/clone.go index 87663d2eb..2d9cc14a9 100644 --- a/internal/simulator/clone.go +++ b/internal/simulator/clone.go @@ -40,7 +40,12 @@ func (c *Controller) clone(ctx context.Context, snap core.Object, workspaces, no if e != nil { return e } - input := core.Object{"workspaceId": wid, "repositoryUrl": snap.O("project").S("repositoryUrl"), "branch": workspace.S("requestedRef")} + // The clone input is the one Cloud froze into the step; the Controller never builds it. + frozen := snap.O("cloneInput") + if frozen.S("kind") != "clone" { + return fmt.Errorf("clone step without a frozen clone input") + } + input := core.Object{"workspaceId": wid, "repositoryUrl": frozen.S("repositoryUrl"), "snapshotRef": frozen.S("snapshotRef"), "baseCommit": frozen.S("baseCommit"), "workspaceRef": frozen.S("workspaceRef")} ctx = metadata.AppendToOutgoingContext(ctx, controlgrpc.HolderMetadata, c.Client.Subject) execution := "" for _, clone := range clones { @@ -54,7 +59,7 @@ func (c *Controller) clone(ctx context.Context, snap core.Object, workspaces, no } if execution == "" { execution = uuid.NewString() - spec := &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{Repository: input.S("repositoryUrl"), Branch: input.S("branch")}}} + spec := &controlpb.ExecutionInput{Spec: &controlpb.ExecutionInput_Clone{Clone: &controlpb.CloneSpec{RepositoryUrl: frozen.S("repositoryUrl"), SnapshotRef: frozen.S("snapshotRef"), BaseCommit: frozen.S("baseCommit"), WorkspaceRef: frozen.S("workspaceRef")}}} if _, e = c.Executions.RecordDispatch(ctx, &controlpb.RecordDispatchRequest{SubmissionId: "dispatch-" + execution, Epoch: c.Epoch, OperationId: c.Operation.S("id"), ExecutionId: execution, NodeId: node.S("nodeId"), Input: spec}); e != nil { return fmt.Errorf("record clone dispatch: %w", e) } diff --git a/internal/simulator/controller.go b/internal/simulator/controller.go index 3ebdbe6c8..3883c680c 100644 --- a/internal/simulator/controller.go +++ b/internal/simulator/controller.go @@ -132,8 +132,10 @@ type Controller struct { Executions controlpb.ExecutionServiceClient AgentRuns controlpb.AgentRunServiceClient AgentNode *AgentNode - Epoch int64 - Operation core.Object + // Git is the Git Server double deliveries push to; the simulated Node reaches nothing else. + Git *GitServer + Epoch int64 + Operation core.Object } func (c *Controller) Acquire(ctx context.Context) error { @@ -395,7 +397,10 @@ func (c *Controller) Step(ctx context.Context) (bool, error) { return false, e } c.Operation = out - if out.S("state") == "succeeded" { + // An advance can also leave the operation with Cloud: succeeded, or parked (retry_wait, blocked) + // or failed when the clone step it entered has no snapshot to clone. Either way this worker no + // longer drives it; a later claim hands it out again if it is still claimable. + if out.S("state") != "running" { c.Operation = nil } return false, nil diff --git a/internal/simulator/gitserver.go b/internal/simulator/gitserver.go new file mode 100644 index 000000000..48ba0f8e8 --- /dev/null +++ b/internal/simulator/gitserver.go @@ -0,0 +1,115 @@ +package simulator + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/gitpreset" + "github.com/wanglongan587/cloud/internal/gitserver" +) + +// GitServerBaseURL is the repository base the simulated deployment writes into inputs. Nothing +// listens there: the simulated Node and Cloud's ref resolver map it onto Root on disk. +const GitServerBaseURL = "https://git-server.simulator.invalid:8445" + +// GitServer stands in for the Git Server: the Project repositories it would serve, laid out on disk +// exactly as the Git Server lays out its volume, so the git-server-preset fixtures write snapshots +// into it unchanged. It enforces no ref rules; the real Git Server's authorization has its own tests. +type GitServer struct { + Root string +} + +// NewGitServer creates the repository root. +func NewGitServer(root string) (*GitServer, error) { + absolute, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if err := os.MkdirAll(absolute, 0o700); err != nil { + return nil, err + } + return &GitServer{Root: absolute}, nil +} + +// Config is the Git Server as a Store uses it: inputs name GitServerBaseURL and ref queries read +// the repositories on disk. +func (g *GitServer) Config() *core.GitServer { + return &core.GitServer{RepositoryBaseURL: GitServerBaseURL, Refs: g} +} + +// Repository maps a repository URL from a fixed input to its bare repository on disk. +func (g *GitServer) Repository(repositoryURL string) (string, bool) { + rest, ok := strings.CutPrefix(repositoryURL, GitServerBaseURL+"/") + if !ok { + return "", false + } + tenant, project, ok := strings.Cut(strings.TrimSuffix(rest, ".git"), "/") + if !ok || !strings.HasSuffix(rest, ".git") || uuid.Validate(tenant) != nil || uuid.Validate(project) != nil { + return "", false + } + return gitserver.RepositoryPath(g.Root, tenant, project), true +} + +// URL is the repository URL of one Project. +func (g *GitServer) URL(tenantID, projectID string) string { + return GitServerBaseURL + "/" + url.PathEscape(tenantID) + "/" + url.PathEscape(projectID) + ".git" +} + +// ResolveGitRef answers Cloud's ref query from the repository on disk. An absent repository or ref +// is the definitive not-found answer, as the Git Server gives it. +func (g *GitServer) ResolveGitRef(ctx context.Context, tenantID, projectID, ref string) (string, error) { + if _, ok := core.ParseGitRef(ref); !ok { + return "", fmt.Errorf("ref outside the contract namespaces") + } + repo := gitserver.RepositoryPath(g.Root, tenantID, projectID) + if _, err := os.Stat(repo); os.IsNotExist(err) { + return "", core.ErrGitRefNotFound + } + out, err := git(ctx, "--git-dir", repo, "for-each-ref", "--format=%(refname) %(objectname)", ref) + if err != nil { + return "", err + } + for _, line := range strings.Split(out, "\n") { + if fields := strings.Fields(line); len(fields) == 2 && fields[0] == ref { + return fields[1], nil + } + } + return "", core.ErrGitRefNotFound +} + +// SeedSnapshot stands in for the external line's sync of a Project: it copies the HEAD commit of a +// local source repository into the Project repository and records it as the Binding's current +// snapshot through the git-server-preset fixtures, advancing the Project's pending sync. It returns +// the snapshot commit. +func (g *GitServer) SeedSnapshot(ctx context.Context, db *sql.DB, projectID, source string) (string, error) { + var tenant string + if err := db.QueryRowContext(ctx, "SELECT tenant_id FROM projects WHERE id=$1", projectID).Scan(&tenant); err != nil { + return "", fmt.Errorf("seed snapshot: %w", err) + } + repo, err := gitserver.EnsureRepository(ctx, g.Root, "git", tenant, projectID) + if err != nil { + return "", err + } + commit, err := git(ctx, "-C", source, "rev-parse", "HEAD") + if err != nil { + return "", err + } + // FETCH_HEAD keeps the objects without writing a ref outside the contract's namespaces; the + // preset then names them with the snapshot and tracking refs. + if _, err := git(ctx, "--git-dir", repo, "fetch", "--quiet", "--no-tags", "--", source, "HEAD"); err != nil { + return "", err + } + preset := &gitpreset.Preset{DB: db, Root: g.Root, Git: "git"} + if _, err := preset.Snapshot(ctx, projectID, commit, 0); err != nil { + return "", err + } + return commit, nil +} diff --git a/internal/simulator/node.go b/internal/simulator/node.go index 6eb8c16e7..a2e63669d 100644 --- a/internal/simulator/node.go +++ b/internal/simulator/node.go @@ -54,8 +54,10 @@ func (s *Substrate) serveClone(w http.ResponseWriter, r *http.Request) { writeJSON(w, outcome) } -// clone checks out the requested ref of a mapped repository into the Workspace's Node home and -// reports the commit, or a failure reason in the contract's enum names. +// clone stands in for the Node's clone from a Git Server snapshot: it fetches the snapshot ref from +// the Git Server double into a new checkout without any remote, verifies the fetched commit is the +// input's base commit and checks it out on the Workspace branch's short name. It reports the commit, +// or a failure reason in the contract's enum names. The external repository is never involved. func (s *Substrate) clone(ctx context.Context, b core.Object) core.Object { failed := func(reason string) core.Object { return core.Object{"outcome": "clone_failed", "reason": "CLONE_FAILURE_REASON_" + reason} @@ -63,23 +65,31 @@ func (s *Substrate) clone(ctx context.Context, b core.Object) core.Object { if s.faults["clone"] == "fail" { return failed("OPERATION_FAILED") } - wid, ref := b.S("workspaceId"), b.S("branch") - source, ok := s.Repositories[b.S("repositoryUrl")] - if _, e := uuid.Parse(wid); e != nil || !ok { + wid := b.S("workspaceId") + if s.Git == nil { return failed("SOURCE_UNAVAILABLE") } - if ref == "" || strings.HasPrefix(ref, "-") || strings.ContainsAny(ref, "\r\n\x00") { - return failed("BRANCH_NOT_FOUND") + source, ok := s.Git.Repository(b.S("repositoryUrl")) + branch, branchOK := core.ParseGitRef(b.S("workspaceRef")) + if _, e := uuid.Parse(wid); e != nil || !ok || !branchOK || branch.Namespace != core.GitWorkspaceBranch || branch.Owner != wid { + return failed("OPERATION_FAILED") } checkout := filepath.Join(s.WorkspaceData(wid), "home", "checkout") if !exists(checkout) { - args := []string{"clone", "--quiet"} - if ref != "HEAD" { - args = append(args, "--branch", ref) + if _, e := git(ctx, "init", "--quiet", checkout); e != nil { + return failed("OPERATION_FAILED") + } + if _, e := git(ctx, "-C", checkout, "fetch", "--quiet", "--no-tags", "--", source, b.S("snapshotRef")); e != nil { + _ = os.RemoveAll(checkout) + return failed("SNAPSHOT_UNAVAILABLE") + } + if fetched, e := git(ctx, "-C", checkout, "rev-parse", "FETCH_HEAD"); e != nil || fetched != b.S("baseCommit") { + _ = os.RemoveAll(checkout) + return failed("BASE_COMMIT_MISMATCH") } - if _, e := git(ctx, append(args, "--", source, checkout)...); e != nil { + if _, e := git(ctx, "-C", checkout, "checkout", "--quiet", "-B", branch.Name, b.S("baseCommit")); e != nil { _ = os.RemoveAll(checkout) - return failed("BRANCH_NOT_FOUND") + return failed("OPERATION_FAILED") } } commit, e := git(ctx, "-C", checkout, "rev-parse", "HEAD") diff --git a/internal/simulator/revision.go b/internal/simulator/revision.go index 6b273ab41..264ce3529 100644 --- a/internal/simulator/revision.go +++ b/internal/simulator/revision.go @@ -39,7 +39,6 @@ func (c *Controller) deliverRevision(ctx context.Context, record *controlpb.Exec } return result, nil } - spec := record.GetInput().GetDeliverRevision() result := &controlpb.ExecutionResult{Node: &controlpb.NodeIdentity{NodeId: record.GetNodeId(), NodeIncarnationId: "echo-fixture"}} failure := func(reason controlpb.RevisionFailureReason) (*controlpb.ExecutionResult, error) { // Controller shutdown does not terminate the Node execution. Leave its journal replayable. @@ -72,29 +71,31 @@ func (c *Controller) deliverRevision(ctx context.Context, record *controlpb.Exec if err := protojson.Unmarshal(journal.Delivery.Declaration, result); err != nil { return nil, fmt.Errorf("decode prepared delivery evidence: %w", err) } - var objects []*controlpb.StoredObject + var history *controlpb.StoredObject + var final string if delivered := result.GetRevisionDelivered(); delivered != nil { - objects = append(objects, delivered.GetBundle(), delivered.GetHistory()) + history, final = delivered.GetHistory(), delivered.GetFinalCommit() } else if unchanged := result.GetRevisionUnchanged(); unchanged != nil { - objects = append(objects, unchanged.GetHistory()) + history, final = unchanged.GetHistory(), unchanged.GetFinalCommit() } else { return nil, fmt.Errorf("invalid prepared delivery outcome") } - for _, object := range objects { - name := "history.jsonl" - if object.GetKey() == spec.GetBundleKey() { - name = "revision.bundle" - } - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - return nil, fmt.Errorf("read prepared delivery object: %w", err) - } - if !proto.Equal(storedBytes(object.GetKey(), data), object) { - return nil, fmt.Errorf("prepared delivery object changed") - } - if err := c.uploadRevisionObject(ctx, record.GetExecutionId(), object.GetKey(), data); err != nil { - return failure(controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_UPLOAD_FAILED) - } + data, err := os.ReadFile(filepath.Join(dir, "history.jsonl")) + if err != nil { + return nil, fmt.Errorf("read prepared delivery object: %w", err) + } + if !proto.Equal(storedBytes(history.GetKey(), data), history) { + return nil, fmt.Errorf("prepared delivery object changed") + } + // The code goes to the Git Server first, unchanged runs included: the delivery ref is what Cloud + // verifies and what a later run resumes from (Revision D1, D3). + if reason, err := c.pushDelivery(ctx, record, final); err != nil { + return nil, err + } else if reason != controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_UNSPECIFIED { + return failure(reason) + } + if err := c.uploadRevisionObject(ctx, record.GetExecutionId(), history.GetKey(), data); err != nil { + return failure(controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_UPLOAD_FAILED) } if err := ctx.Err(); err != nil { return nil, err @@ -148,28 +149,12 @@ func (c *Controller) prepareRevision(ctx context.Context, record *controlpb.Exec return nil, err } historyObject := storedBytes(spec.GetHistoryKey(), history.Bytes()) - if final == spec.GetBaseCommit() { + if final == spec.GetBaseCommit() || (spec.GetPriorRevision() != nil && final == spec.GetPriorRevision().GetFinalCommit()) { result.Outcome = &controlpb.ExecutionResult_RevisionUnchanged{RevisionUnchanged: &controlpb.RevisionUnchanged{BaseCommit: spec.GetBaseCommit(), FinalCommit: final, RevisionRef: spec.GetRevisionRef(), History: historyObject}} } else { - bundlePath := filepath.Join(dir, "revision.bundle") - if _, err = git(ctx, "-C", checkout.GetPath(), "bundle", "create", bundlePath, spec.GetRevisionRef(), "^"+spec.GetBaseCommit()); err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - return nil, revisionPreparationError(controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_BUNDLE_FAILED) - } - if _, err = git(ctx, "-C", checkout.GetPath(), "bundle", "verify", bundlePath); err != nil { - return nil, revisionPreparationError(controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_BUNDLE_FAILED) - } - bundle, readErr := os.ReadFile(bundlePath) - if readErr != nil { - return nil, fmt.Errorf("read delivery bundle: %w", readErr) - } - if err := writePreparedObject(bundlePath, bundle); err != nil { - return nil, err - } - result.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{BaseCommit: spec.GetBaseCommit(), FinalCommit: final, RevisionRef: spec.GetRevisionRef(), Bundle: storedBytes(spec.GetBundleKey(), bundle), History: historyObject}} + result.Outcome = &controlpb.ExecutionResult_RevisionDelivered{RevisionDelivered: &controlpb.RevisionDelivered{BaseCommit: spec.GetBaseCommit(), FinalCommit: final, RevisionRef: spec.GetRevisionRef(), History: historyObject}} } + declaration, err := protojson.Marshal(result) if err != nil { return nil, fmt.Errorf("encode prepared delivery evidence: %w", err) @@ -178,6 +163,35 @@ func (c *Controller) prepareRevision(ctx context.Context, record *controlpb.Exec return &revisionPlan{Directory: filepath.Base(dir), Declaration: declaration}, nil } +// pushDelivery pushes the final commit to the delivery's own ref on the Git Server double, then +// tries to fast-forward the run Workspace's branch to it (contract D6). Only the delivery ref decides +// the delivery: a refused or failed branch update is ignored. A push that already landed, for example +// before a restart, finds the ref at the same commit and succeeds again. +func (c *Controller) pushDelivery(ctx context.Context, record *controlpb.ExecutionRecord, final string) (controlpb.RevisionFailureReason, error) { + spec := record.GetInput().GetDeliverRevision() + clone, err := c.Executions.GetDispatch(ctx, &controlpb.GetDispatchRequest{ExecutionId: spec.GetCheckoutExecutionId()}) + if err != nil { + return 0, err + } + checkout := clone.GetRecord().GetResult().GetCloneReady().GetPath() + var remote string + ok := false + if c.Git != nil { + remote, ok = c.Git.Repository(spec.GetGitRepositoryUrl()) + } + if !ok || checkout == "" { + return controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_PUSH_REJECTED, nil + } + if _, err := git(ctx, "-C", checkout, "push", "--quiet", "--", remote, final+":"+spec.GetDeliveryRef()); err != nil { + if ctx.Err() != nil { + return 0, ctx.Err() + } + return controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_PUSH_FAILED, nil + } + _, _ = git(ctx, "-C", checkout, "push", "--quiet", "--", remote, final+":"+spec.GetWorkspaceRef()) + return controlpb.RevisionFailureReason_REVISION_FAILURE_REASON_UNSPECIFIED, nil +} + func (c *Controller) saveRevisionResult(execution string, journal *agentJournal, result *controlpb.ExecutionResult) error { data, err := protojson.Marshal(result) if err != nil { diff --git a/internal/simulator/revision_test.go b/internal/simulator/revision_test.go index 7fa23d93b..5e7033ae4 100644 --- a/internal/simulator/revision_test.go +++ b/internal/simulator/revision_test.go @@ -8,8 +8,9 @@ import ( "github.com/wanglongan587/cloud/internal/controlpb" ) -// Snapshot retains user commits and tracked deletion/new files without hooks, branch or index writes. -func TestRevisionSnapshotAndCumulativeBundleAreRecoverable(t *testing.T) { +// Snapshot retains user commits and tracked deletion/new files without hooks, branch or index writes, +// and the final commit pushed to a delivery ref restores the cumulative tree elsewhere. +func TestRevisionSnapshotIsRecoverableFromItsDeliveryRef(t *testing.T) { ctx := t.Context() repo := filepath.Join(t.TempDir(), "repo") run := func(args ...string) string { @@ -34,9 +35,6 @@ func TestRevisionSnapshotAndCumulativeBundleAreRecoverable(t *testing.T) { write(".gitignore", "ignored\n") run("-C", repo, "add", ".") run("-C", repo, "commit", "-m", "base") - base := run("-C", repo, "rev-parse", "HEAD") - baseBundle := filepath.Join(t.TempDir(), "base.bundle") - run("-C", repo, "bundle", "create", baseBundle, "main") write("tracked", "agent commit\n") run("-C", repo, "add", "tracked") run("-C", repo, "commit", "-m", "agent commit") @@ -71,19 +69,16 @@ func TestRevisionSnapshotAndCumulativeBundleAreRecoverable(t *testing.T) { if err != nil || string(indexAfter) != string(indexBefore) { t.Fatal("snapshot changed the real index", err) } - bundle := filepath.Join(t.TempDir(), "revision.bundle") - run("-C", repo, "bundle", "create", bundle, ref, "^"+base) - run("-C", repo, "bundle", "verify", bundle) + server := filepath.Join(t.TempDir(), "server.git") + run("init", "--bare", "--quiet", server) + delivery := "refs/ora/deliveries/00000000-0000-4000-8000-000000000001" + run("-C", repo, "push", "--quiet", "--", server, final+":"+delivery) restored := filepath.Join(t.TempDir(), "restored") - // Restore into a repository containing only the base, so missing Agent commits in the - // incremental bundle cannot be hidden by objects copied from the original checkout. run("init", "--initial-branch=main", restored) - run("-C", restored, "fetch", "--", baseBundle, "main") - run("-C", restored, "checkout", "--detach", base) - run("-C", restored, "fetch", "--", bundle, ref) + run("-C", restored, "fetch", "--", server, delivery) run("-C", restored, "checkout", "--detach", "FETCH_HEAD") if run("-C", restored, "rev-parse", "HEAD") != final || run("-C", restored, "show", "HEAD:tracked") != "final working tree" || run("-C", restored, "show", "HEAD:new") != "new result" { - t.Fatal("bundle did not restore the cumulative final tree") + t.Fatal("the delivery ref did not restore the cumulative final tree") } for _, name := range []string{"ignored", "deleted"} { if _, err := os.Stat(filepath.Join(restored, name)); !os.IsNotExist(err) { diff --git a/internal/simulator/substrate.go b/internal/simulator/substrate.go index 34633956d..13f382e10 100644 --- a/internal/simulator/substrate.go +++ b/internal/simulator/substrate.go @@ -23,8 +23,9 @@ import ( // Substrate persists stable effect journals and project data outside cloud's database. type Substrate struct { - Root string - Repositories map[string]string + Root string + // Git is the Git Server double the simulated Node clones snapshots from. + Git *GitServer // Artifacts maps plugin release URLs to local fixture files so the // simulated Node "downloads" real bytes and verifies real digests without // network access. An unmapped URL fails the install, mirroring production. @@ -33,7 +34,7 @@ type Substrate struct { faults map[string]string } -func NewSubstrate(root string, repos map[string]string) (*Substrate, error) { +func NewSubstrate(root string, git *GitServer) (*Substrate, error) { absolute, e := filepath.Abs(root) if e != nil { return nil, e @@ -43,7 +44,7 @@ func NewSubstrate(root string, repos map[string]string) (*Substrate, error) { return nil, e } } - return &Substrate{Root: absolute, Repositories: repos, Artifacts: map[string]string{}, faults: map[string]string{}}, nil + return &Substrate{Root: absolute, Git: git, Artifacts: map[string]string{}, faults: map[string]string{}}, nil } // MapArtifact binds a plugin release URL to a local fixture file, mirroring diff --git a/proto/buf.yaml b/proto/buf.yaml index bed2de3ba..a15afb2d7 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -1,7 +1,6 @@ # Cloud owns the Controller-facing internal control contract: this directory is the single source, # Cloud generates its gRPC server from it, and consumers (desktop's ora-controller) pin a commit of -# this repository and generate their clients. `v1` accepts additive changes only; a breaking change -# is expressed as a sibling `v2` package while `v1` stays. +# this repository and generate their clients. Cloud, Controller and Node are released together. version: v2 modules: - path: . @@ -11,3 +10,18 @@ lint: breaking: use: - FILE + # One-time exemption for the switch to Git-native Workspace code (clone from a Git Server snapshot, + # Revision delivery over Git without bundles). It is removed in the next change to this file. + ignore_only: + FIELD_NO_DELETE: + - ora/cloud/internal/v1/executions.proto + - ora/cloud/internal/v1/operations.proto + - ora/cloud/internal/v1/agent_executions.proto + MESSAGE_NO_DELETE: + - ora/cloud/internal/v1/agent_executions.proto + - ora/cloud/internal/v1/agent_runs.proto + RPC_NO_DELETE: + - ora/cloud/internal/v1/agent_runs.proto + ENUM_VALUE_NO_DELETE: + - ora/cloud/internal/v1/executions.proto + - ora/cloud/internal/v1/agent_executions.proto diff --git a/proto/ora/cloud/internal/v1/agent_executions.proto b/proto/ora/cloud/internal/v1/agent_executions.proto index 305602246..5871ccf74 100644 --- a/proto/ora/cloud/internal/v1/agent_executions.proto +++ b/proto/ora/cloud/internal/v1/agent_executions.proto @@ -35,13 +35,33 @@ message UserTurn { } // The Revision a new run of the same Issue resumes from (IssueRun resume decision D1). On a session -// spec it names the verified object holding the bundle, which may belong to an earlier Revision the -// resumed one reused; on a delivery spec it carries only the identity and final commit. +// spec it also says where the Node fetches the prior final commit and what it was based on; on a +// delivery spec it carries only the identity and final commit. message PriorRevision { + reserved 3; + reserved "bundle"; string revision_id = 1; string final_commit = 2; - // Set on AgentSessionSpec only. The download grant covers exactly this object key. - StoredObject bundle = 3; + // Session spec only: the Git Server repository and verified delivery ref holding final_commit. + string git_repository_url = 4; + string delivery_ref = 5; + // Session spec only: the base commit of the prior Revision, used to tell whether the Workspace + // snapshot still contains it. + string base_commit = 6; +} + +// One Workspace of the session's Project, so `ora-git list` can name branches by Issue and run. It +// is a snapshot taken when the work item was released and never takes part in authorization. +message WorkspaceListing { + string workspace_id = 1; + // main | isolated | run + string kind = 2; + // Run Workspaces only. + string issue_id = 3; + string issue_title = 4; + string issue_run_id = 5; + // As of when the session work item was released. + string run_status = 6; } message AgentSessionSpec { @@ -60,6 +80,8 @@ message AgentSessionSpec { // Opaque personal-model snapshot reference. Empty for agents that do not use model-gateway. // No provider credential or temporary access token crosses this contract. string model_binding_id = 7; + // Workspaces of this Project, newest first, at most 100, fixed when the work item was released. + repeated WorkspaceListing workspace_directory = 8; } enum AgentSessionEndReason { @@ -82,19 +104,28 @@ message AgentSessionEnded { } message DeliverRevisionSpec { + reserved 5; + reserved "bundle_key"; // The ended session whose Workspace and history are saved. string session_execution_id = 1; string checkout_execution_id = 2; - // Commit of the run Workspace's clone; the bundle is relative to it. + // Commit of the run Workspace's clone. string base_commit = 3; // Must be under `refs/ora/revisions/`. string revision_ref = 4; - // Object keys Cloud chose for this delivery; upload grants are issued only for these keys. - string bundle_key = 5; + // Object key Cloud chose for the session history; upload grants are issued only for this key. string history_key = 6; - // The session's resumed Revision, without bundle. A final commit equal to its final commit is - // reported as RevisionUnchanged. + // The session's resumed Revision, identity and final commit only. A final commit equal to its + // final commit is reported as RevisionUnchanged. PriorRevision prior_revision = 7; + // Fixed by Cloud when the work item was released; the Node pushes the final commit to + // delivery_ref (create only) in this repository. + string git_repository_url = 8; + // Under `refs/ora/deliveries/`. + string delivery_ref = 9; + // The run Workspace's branch, from its clone input. The Node fast-forwards it to the final commit + // after the delivery ref is pushed; failing to do so never fails the delivery. + string workspace_ref = 10; } // An object the Node uploaded, as the Node measured it; Cloud verifies it before registering. @@ -105,16 +136,19 @@ message StoredObject { string sha256 = 3; } +// The final commit was pushed to the input's delivery_ref; Cloud verifies the ref before registering. message RevisionDelivered { + reserved 4; + reserved "bundle"; string final_commit = 1; string base_commit = 2; string revision_ref = 3; - StoredObject bundle = 4; StoredObject history = 5; } // The final commit equals the base commit, or the delivery spec's prior_revision final commit (a -// resumed run that added nothing): no bundle, only the session history. +// resumed run that added nothing). It still pushes its own delivery_ref and attempts to fast-forward +// workspace_ref before uploading only the session history. message RevisionUnchanged { string final_commit = 1; string base_commit = 2; @@ -123,15 +157,20 @@ message RevisionUnchanged { } enum RevisionFailureReason { + reserved 4; + reserved "REVISION_FAILURE_REASON_BUNDLE_FAILED"; REVISION_FAILURE_REASON_UNSPECIFIED = 0; REVISION_FAILURE_REASON_SESSION_NOT_SETTLED = 1; REVISION_FAILURE_REASON_CHECKOUT_UNAVAILABLE = 2; REVISION_FAILURE_REASON_SNAPSHOT_FAILED = 3; - REVISION_FAILURE_REASON_BUNDLE_FAILED = 4; REVISION_FAILURE_REASON_HISTORY_UNAVAILABLE = 5; REVISION_FAILURE_REASON_UPLOAD_FAILED = 6; // Recorded by Cloud when the declared objects are missing or differ; a Node never reports it. REVISION_FAILURE_REASON_VERIFICATION_FAILED = 7; + // The Git Server refused the delivery ref push (401, 403 or a declined ref update). + REVISION_FAILURE_REASON_PUSH_REJECTED = 8; + // The delivery ref push failed on 5xx, network, TLS or deadline after the retries ran out. + REVISION_FAILURE_REASON_PUSH_FAILED = 9; } message RevisionFailed { @@ -152,15 +191,3 @@ message UploadGrant { map headers = 4; google.protobuf.Timestamp expires_at = 5; } - -// A presigned single-object read of a prior Revision's bundle. Like UploadGrant it is a short-lived -// bearer credential kept in memory only and never logged. -message DownloadGrant { - string object_key = 1; - string url = 2; - // Always GET. - string method = 3; - // Headers the download must carry unchanged. - map headers = 4; - google.protobuf.Timestamp expires_at = 5; -} diff --git a/proto/ora/cloud/internal/v1/agent_runs.proto b/proto/ora/cloud/internal/v1/agent_runs.proto index 0513a336b..88f12424e 100644 --- a/proto/ora/cloud/internal/v1/agent_runs.proto +++ b/proto/ora/cloud/internal/v1/agent_runs.proto @@ -22,9 +22,6 @@ service AgentRunService { // Issues fresh upload grants for a registered delivery execution that has no result yet. Not a // write: nothing is recorded, and every call issues new grants. rpc GrantRevisionUpload(GrantRevisionUploadRequest) returns (GrantRevisionUploadResponse); - // Issues a fresh read grant for the prior Revision bundle of a registered session execution that - // has no result yet. Not a write: nothing is recorded, and every call issues a new grant. - rpc GrantRevisionDownload(GrantRevisionDownloadRequest) returns (GrantRevisionDownloadResponse); } // One non-terminal event of a session execution, as the Node sent it. Thread events share the @@ -139,24 +136,13 @@ message GrantRevisionUploadRequest { int64 epoch = 1; // A delivery execution registered through RecordDispatch. string execution_id = 2; - // Optional object key -> lowercase hexadecimal SHA-256. Keys must belong to the frozen - // delivery input. Cloud binds each checksum into its ephemeral PUT signature; refreshing - // this capability never changes the execution input. Omit for legacy v1 grants. + // Optional object key -> lowercase hexadecimal SHA-256. The only accepted key is the frozen + // delivery input's history_key. Cloud binds the checksum into its ephemeral PUT signature; + // refreshing this capability never changes the execution input. Omit for unbound grants. map checksums = 3; } message GrantRevisionUploadResponse { - // One grant per requested checksum key, or both input keys for legacy requests. + // Exactly one grant, for the input's history_key. repeated UploadGrant grants = 1; } - -message GrantRevisionDownloadRequest { - int64 epoch = 1; - // A session execution registered through RecordDispatch whose input carries prior_revision. - string execution_id = 2; -} - -message GrantRevisionDownloadResponse { - // Exactly one grant, for the input's prior_revision bundle key. - repeated DownloadGrant grants = 1; -} diff --git a/proto/ora/cloud/internal/v1/executions.proto b/proto/ora/cloud/internal/v1/executions.proto index 67fe0c663..71c4f764d 100644 --- a/proto/ora/cloud/internal/v1/executions.proto +++ b/proto/ora/cloud/internal/v1/executions.proto @@ -40,10 +40,20 @@ message NodeIdentity { string node_incarnation_id = 2; } +// A Workspace clone from a Binding snapshot on the Git Server. Cloud freezes all four fields before +// the clone step is handed to a Controller; the Controller forwards them verbatim. Checkout paths +// belong to the Node, never to the caller. message CloneSpec { - // HTTPS or SSH repository URL. Checkout paths belong to the Node, never to the caller. - string repository = 1; - string branch = 2; + reserved 1, 2; + reserved "repository", "branch"; + // https:////.git + string repository_url = 3; + // refs/ora/upstreams//snapshots/ + string snapshot_ref = 4; + // The commit snapshot_ref points to; the checkout is verified against it. + string base_commit = 5; + // refs/heads/workspaces//; is the local branch of the checkout. + string workspace_ref = 6; } // Full execution input as dispatched; extended with new oneof members as further execution kinds @@ -60,6 +70,7 @@ message ExecutionInput { message CloneReady { string path = 1; + // Must equal the input's base_commit. string commit = 2; } @@ -70,14 +81,21 @@ message CloneFailed { } enum CloneFailureReason { + reserved 2; + reserved "CLONE_FAILURE_REASON_BRANCH_NOT_FOUND"; CLONE_FAILURE_REASON_UNSPECIFIED = 0; + // The Git Server was unreachable, or answered 5xx until the retries ran out. CLONE_FAILURE_REASON_SOURCE_UNAVAILABLE = 1; - CLONE_FAILURE_REASON_BRANCH_NOT_FOUND = 2; CLONE_FAILURE_REASON_DESTINATION_CONFLICT = 3; CLONE_FAILURE_REASON_OPERATION_FAILED = 4; // The attempt was terminated before Git reached its own verdict (Node stop, command deadline, // or an external signal) and its cleanup was confirmed; a new execution may simply retry. CLONE_FAILURE_REASON_INTERRUPTED = 5; + // The Git Server answered that the snapshot ref is missing or not readable (it gives both the + // same answer). + CLONE_FAILURE_REASON_SNAPSHOT_UNAVAILABLE = 6; + // The fetched snapshot ref did not point at base_commit. + CLONE_FAILURE_REASON_BASE_COMMIT_MISMATCH = 7; } // Terminal fact reported by a Node for one execution. @@ -108,8 +126,7 @@ message WorkItem { // The IssueRun ID for Agent session and Revision delivery work. string operation_id = 1; ExecutionInput input = 2; - // Set for Agent session and Revision delivery work; unset for a tenant-level clone, whose Node - // the Controller chooses. + // Always set: Agent session and Revision delivery work name their run Workspace sandbox. optional WorkTarget target = 3; } diff --git a/proto/ora/cloud/internal/v1/operations.proto b/proto/ora/cloud/internal/v1/operations.proto index 76dae45cb..c3e93dbcf 100644 --- a/proto/ora/cloud/internal/v1/operations.proto +++ b/proto/ora/cloud/internal/v1/operations.proto @@ -93,12 +93,13 @@ message Operation { optional string error_code = 9; } +// The Project an operation belongs to. Its external repository, branch and credential reference +// never reach a Controller: a Workspace clones only the Git Server snapshot its clone step was +// handed (clone_input). message OperationProject { + reserved 2, 3, 4; + reserved "repository_url", "default_branch", "credential_ref"; string id = 1; - string repository_url = 2; - string default_branch = 3; - // Infrastructure reference to the repository credential; never the credential itself. - optional string credential_ref = 4; } enum WorkspaceKind { @@ -117,7 +118,8 @@ message OperationWorkspace { int64 runtime_generation = 5; bool admission_open = 6; int64 admission_epoch = 7; - // The ref the Workspace's Node clones. + // The short name of the Workspace's branch on the Git Server + // (refs/heads/workspaces//) and of the checkout's local branch. string requested_ref = 8; // Commit of the Workspace's successful clone. optional string base_commit_id = 9; @@ -267,6 +269,11 @@ message OperationSnapshot { // the operation entered the step. Unset outside the plugin step or when the set is empty, in which // case the step advances without an execution. optional ExecutionInput plugin_input = 9; + // The input the clone step's execution must carry, frozen by Cloud from the Binding's current + // snapshot before the step was handed out and identical for every attempt of the operation. Set + // whenever the operation is in the clone step; the Controller forwards it verbatim to + // RecordDispatch and the Node, and does not dispatch a clone step without it. + optional CloneSpec clone_input = 10; } message ClaimOperationRequest {