feat(agentctl): push to a caller-named remote and branch, not just origin (#3558)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
---
|
||||
created: 2026-09-08
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/configured-push-target.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Configured Push Target
|
||||
|
||||
## Overview
|
||||
|
||||
The workspace push contract can publish only to `origin` or to a remote that a
|
||||
contribution binding selects, and push preflight reports success without
|
||||
contacting a remote at all on the ordinary path. This work package adds two
|
||||
optional inputs to the push and push-preflight contracts, an explicit push
|
||||
target and an expected branch, and makes preflight validate the destination it
|
||||
would actually use.
|
||||
|
||||
Implementation runs in four work orders. Task 01 lands the vocabulary the rest
|
||||
of the package needs: the request options carried into the operator, the eleven
|
||||
error codes, the five result fields, and the pure resolution and validation
|
||||
helpers, with no behavior change. Task 02 rewires the push path onto those
|
||||
helpers, which is where the refusal ordering and the two-point expected-branch
|
||||
verification take effect. Task 04 carries the two inputs across the HTTP
|
||||
surface, the runtime client, and the backend action handler; it touches files
|
||||
disjoint from Task 02 and depends only on Task 01's signatures, so the two run
|
||||
in the same wave. Task 03 lands last because preflight consumes the shared
|
||||
resolution and guard call sites that Task 02 establishes.
|
||||
|
||||
This order exists so that no work order changes a destination decision and its
|
||||
transport in the same pass: the operator owns every new decision, and the
|
||||
transport work carries values it does not interpret.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- An optional explicit push target on the push and push-preflight contracts,
|
||||
accepted as a configured remote name or as a remote URL resolved against
|
||||
already-configured remotes.
|
||||
- An optional expected branch on both contracts, verified twice on the push
|
||||
path and once on preflight.
|
||||
- The fixed refusal ordering and the eleven stable error codes.
|
||||
- Push preflight validating the destination-branch refspec against the resolved
|
||||
remote, including the `origin` path that reports blanket success today.
|
||||
- The five optional result fields, with the push/preflight asymmetry the design
|
||||
requires.
|
||||
- Transport plumbing for both inputs across agentctl HTTP, the runtime client,
|
||||
and the backend workspace Git action handler.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Exact-OID `--force-with-lease=<ref>:<oid>`. Force keeps emitting the bare
|
||||
lease and stays refused under contribution routing.
|
||||
- Auto-push policy. This package only makes such a caller expressible.
|
||||
- Creating, renaming, retargeting, or deleting a remote.
|
||||
- Any change to remote-contribution or contribution-destination routing, their
|
||||
force refusal, or their existing preflight behavior.
|
||||
- Any web, desktop, or mobile control. The existing push control keeps sending
|
||||
neither input, so this package ships no user-visible surface.
|
||||
- Preflighting the empty-remote baseline refspec.
|
||||
- Pushing tags, multiple refspecs, or deleting a remote branch.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Operator primitives (`internal/agentctl/server/process`)
|
||||
|
||||
A new `git_push_target.go` owns everything the resolution and guard need, so the
|
||||
decision logic sits in one file rather than growing `Push`:
|
||||
|
||||
- `PushOptions{Force, SetUpstream bool; Remote, ExpectedBranch string}` replaces
|
||||
the two positional booleans on `GitOperator.Push` and is added to
|
||||
`GitOperator.PushPreflight`, which takes no options today.
|
||||
- Error-code constants for the eleven codes the design tables, alongside the
|
||||
existing `emptyRemote*ErrorCode` constants.
|
||||
- `configuredRemotes(ctx)` enumerates names with `git remote`, then reads each
|
||||
name's `--get-all remote.<name>.pushurl` and `--get-all remote.<name>.url`.
|
||||
The effective push URL set is the push URL list when non-empty, otherwise the
|
||||
single-element fetch URL list, otherwise empty. A read failure yields
|
||||
`push_remote_config_unreadable` rather than an absent or unmatched target.
|
||||
- `resolvePushTarget` applies the trim, discriminates name from URL with
|
||||
`securityutil.IsValidBranchName`, and returns a remote **name**. A
|
||||
caller-supplied URL never reaches a Git command line, which is what keeps
|
||||
`runGitCommand`'s existing argument allowlist unchanged.
|
||||
- `currentBranch(ctx)` reads `git symbolic-ref HEAD` and trims `refs/heads/`.
|
||||
A non-zero exit is the detached state, reported as an empty branch. This
|
||||
replaces `getCurrentBranch`'s `rev-parse --abbrev-ref HEAD` on the new paths,
|
||||
which returns the literal `HEAD` when detached and cannot satisfy
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11. `symbolic-ref HEAD` needs no
|
||||
flag and `HEAD` is already a safe literal, so the allowlist is untouched.
|
||||
|
||||
### Expected-branch validation (`internal/common/securityutil`)
|
||||
|
||||
The strict allowlist the expected branch must pass is `IsValidBranchName` plus
|
||||
a reject on a trailing `/`, on `//`, and on the four symbolic pseudo-refs. The
|
||||
existing `IsValidDefaultBranchName` composes exactly those checks but reaches
|
||||
them through `IsValidBaseBranchRef`, which first strips an `origin/` prefix, so
|
||||
an expected branch of `origin/main` would validate as `main` and pass. A new
|
||||
`IsValidExpectedBranchName` applies the same checks without that strip.
|
||||
|
||||
### Push path (`Push`, `internal/agentctl/server/process/git.go`)
|
||||
|
||||
`Push` gains a `resolvePushPlan` step that evaluates the whole refusal list
|
||||
before any remote is contacted, then keeps the existing structure. Two ordering
|
||||
changes are load-bearing:
|
||||
|
||||
- Empty-remote first publication runs only when the resolved remote is `origin`
|
||||
and no contribution routing applies.
|
||||
- `getUpstreamRef` is read today after first publication and inside the argument
|
||||
build. It moves ahead of the second expected-branch verification, so that the
|
||||
branch read is the last Git command before the push
|
||||
(AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12).
|
||||
|
||||
Refspec selection is `HEAD:refs/heads/<expected>` with an explicit target and an
|
||||
expected branch, `HEAD:refs/heads/<current>` with an explicit target alone, and
|
||||
unchanged on every path that names no target. The `HEAD:refs/heads/` form is
|
||||
already admitted by `runGitCommand`.
|
||||
|
||||
### Preflight (`PushPreflight`)
|
||||
|
||||
The contribution-destination and remote-contribution branches keep their current
|
||||
bodies and result shape. The `g.remoteContribution == nil` early `result.Success
|
||||
= true` return is replaced by the resolved-remote dry-run, defaulting to
|
||||
`origin`, reporting `push_no_remote_configured` when no explicit target, no
|
||||
contribution routing, and no `origin` remote exist. A successful preflight to
|
||||
which contribution routing did not apply reports `pushed_remote` and
|
||||
`pushed_branch`.
|
||||
|
||||
### Transport
|
||||
|
||||
- `internal/agentctl/server/api/git.go`: `GitPushRequest` and
|
||||
`GitPushPreflightRequest` gain `remote` and `expected_branch`, bound and
|
||||
forwarded without interpretation.
|
||||
- `internal/agent/runtime/agentctl/git.go`: both result structs gain the five
|
||||
fields; `GitPush` and `GitPushPreflight` carry the two inputs.
|
||||
- `internal/agent/handlers/git_handlers.go`: the `worktree.push` payload gains
|
||||
both fields and forwards them.
|
||||
- `internal/agent/runtime/lifecycle/manager_startup.go` keeps passing neither,
|
||||
so the startup remote-contribution preflight is unchanged.
|
||||
|
||||
### Output redaction
|
||||
|
||||
`runGitCommand` already routes every `push` invocation, dry-run included,
|
||||
through `sanitizeGitPushOutput`, so the redaction
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18 requires is satisfied by
|
||||
construction on both new paths. No second sanitization is added; see
|
||||
[Open questions](#open-questions).
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| 001.1, 004.7 | `TestResolvePushTargetTrimsAndTreatsEmptyAsAbsent` in `internal/agentctl/server/process/git_push_target_test.go` |
|
||||
| 001.3, 001.15 | `TestResolvePushTargetDiscriminatesNameFromURL`, `TestResolvePushTargetRejectsCaseAndPrefixVariants` in `git_push_target_test.go` |
|
||||
| 001.4 | `TestResolvePushTargetReportsUnknownRemoteName` in `git_push_target_test.go` |
|
||||
| 001.5, 001.6 | `TestResolvePushTargetMatchesSingleEntryPushURL`, `TestResolvePushTargetPicksFirstByByteOrder` in `git_push_target_test.go` |
|
||||
| 001.7 | `TestResolvePushTargetReportsUnmatchedURL` in `git_push_target_test.go` |
|
||||
| 001.16 | `TestResolvePushTargetReportsUnreadableRemoteConfig` in `git_push_target_test.go` |
|
||||
| 001.17 | `TestResolvePushTargetReportsFanoutRemote`, `TestGitOperatorPushNamedFanoutRemotePublishesToEveryPushURL` in `git_push_target_test.go` |
|
||||
| 002.2 | `TestIsValidExpectedBranchName` in `internal/common/securityutil/git_test.go` |
|
||||
| 002.11 | `TestGitOperatorCurrentBranchReportsDetachedHeadAsEmpty` in `git_push_target_test.go` |
|
||||
| 001.2 | `TestGitOperatorPushWithoutOptionsIsUnchanged` in `internal/agentctl/server/process/git_push_expected_branch_test.go` |
|
||||
| 001.8 | `TestGitOperatorPushNeverCreatesRemote` in `git_push_expected_branch_test.go` |
|
||||
| 001.9, 001.10 | `TestGitOperatorPushRefusesTargetUnderContributionRouting`, `TestGitOperatorPushRefusesTargetWithSetUpstream` in `git_push_expected_branch_test.go` |
|
||||
| 001.11, 001.12 | `TestGitOperatorPushToNamedRemoteLeavesUpstreamUnset`, `TestGitOperatorPushToNamedRemoteSkipsBaselinePublication` in `git_push_expected_branch_test.go` |
|
||||
| 001.13 | `TestGitOperatorPushToNamedRemoteStaysNonForce` in `git_push_expected_branch_test.go` |
|
||||
| 001.14 | `TestGitOperatorPushResultAndLogsCarryNoRemoteURL` in `git_push_expected_branch_test.go` |
|
||||
| 001.18 | `TestGitOperatorPushRedactsCredentialsOnExplicitTargetPath` in `git_push_expected_branch_test.go`; `TestPushPreflightRedactsCredentialsInRemoteOutput` in `git_push_preflight_test.go` |
|
||||
| 002.1, 002.9 | `TestGitOperatorPushExpectedBranchAloneOnlyGates` in `git_push_expected_branch_test.go` |
|
||||
| 002.3, 002.4 | `TestGitOperatorPushRefusesExpectedBranchMismatch`, `TestGitOperatorPushRefusesDetachedHeadAsMismatch` in `git_push_expected_branch_test.go` |
|
||||
| 002.5, 002.12 | `TestGitOperatorPushVerifiesBranchAsLastReadBeforePush` in `git_push_expected_branch_test.go` |
|
||||
| 002.6 | `TestGitOperatorPushReportsMismatchAfterBaselinePublication` in `git_push_expected_branch_test.go` |
|
||||
| 002.7, 002.8, 002.10 | `TestGitOperatorPushPublishesHeadToExpectedBranch`, `TestGitOperatorPushRefusesDetachedHeadWithTargetOnly`, `TestGitOperatorPushCreatesMissingDestinationBranch` in `git_push_expected_branch_test.go` |
|
||||
| 003.1, 003.7 | `TestPushPreflightAppliesSameRefusalOrdering`, `TestPushPreflightReportsValidatedRemoteAndBranch` in `internal/agentctl/server/process/git_push_preflight_test.go` |
|
||||
| 003.2, 003.3 | `TestPushPreflightValidatesNamedRemote`, `TestPushPreflightValidatesOriginWithoutTarget` in `git_push_preflight_test.go` |
|
||||
| 003.4 | `TestPushPreflightReportsNoRemoteConfigured` in `git_push_preflight_test.go` |
|
||||
| 003.5 | `TestPushPreflightMutatesNothing` in `git_push_preflight_test.go` |
|
||||
| 003.6 | `TestPushPreflightReportsRemoteRefusalOutput` in `git_push_preflight_test.go` |
|
||||
| 004.1, 004.2 | `TestGitOperatorPushRefusalOrdering`, `TestGitOperatorPushRefusalsAreNoOps` in `git_push_expected_branch_test.go` |
|
||||
| 004.3 | `TestGitOperatorPushReportsOperationInProgress` in `git_push_expected_branch_test.go` |
|
||||
| 004.4, 004.5 | `TestGitOperatorPushRepeatedIdenticalRequestSucceeds`, `TestGitOperatorPushDoesNotEscalateRejectedNonForcePush` in `git_push_expected_branch_test.go` |
|
||||
| 004.6 | `TestGitOperatorPushOmitsTargetFieldsWithoutExplicitTarget` in `git_push_expected_branch_test.go` |
|
||||
| 004.8 | `TestGitPushRequestScopesTargetToSelectedRepo` in `internal/agentctl/server/api/git_handlers_test.go` |
|
||||
| 001.1, 002.1 transport | `TestHandleGitPushBindsRemoteAndExpectedBranch` in `internal/agentctl/server/api/git_handlers_test.go`; `TestGitPushSendsPushOptions` in `internal/agent/runtime/agentctl/git_test.go`; `TestWsPushForwardsPushOptions` in `internal/agent/handlers/git_handlers_test.go` |
|
||||
|
||||
## E2E tests
|
||||
|
||||
None. The design places every web, desktop, and mobile surface out of scope,
|
||||
and the existing push control keeps sending neither input, so this package adds
|
||||
no user-visible behavior for a browser test to exercise. Evidence is the Go
|
||||
unit and integration coverage above, which drives real Git repositories through
|
||||
the existing `setupEmptyRemoteTaskRepo` fixture pattern.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Push-target and expected-branch primitives](task-01-resolution-primitives.md)
|
||||
- [x] [Task 02: Explicit push target and expected-branch verification on the push path](task-02-push-path.md)
|
||||
- [x] [Task 03: Push preflight validates the resolved destination](task-03-preflight.md)
|
||||
- [x] [Task 04: Carry both inputs across the push transport](task-04-transport.md)
|
||||
|
||||
Dependency order: Task 01, then Tasks 02 and 04 together, then Task 03.
|
||||
|
||||
## Verification results
|
||||
|
||||
All four work orders are implemented and verified on the neo SSH runner.
|
||||
|
||||
- `make -C apps/backend lint` — 0 issues.
|
||||
- `go test -tags fts5 ./internal/agentctl/server/process/...` — all push, preflight,
|
||||
resolution and empty-remote tests pass.
|
||||
- `go test -tags fts5 ./internal/agentctl/server/api/... ./internal/agent/runtime/agentctl/
|
||||
./internal/agent/handlers/... ./internal/agent/runtime/lifecycle/...
|
||||
./internal/common/securityutil/...` — all new and existing push tests pass.
|
||||
|
||||
Four tests fail identically on this runner both with these changes and on a
|
||||
clean worktree at `HEAD`, so they are pre-existing and environment-specific, not
|
||||
regressions: `TestRepairManagedRuntimeCacheClearsPreviousStderr`,
|
||||
`TestManagedRuntimeCacheRepairUsesAgentEnvironmentAndExactTree`, the Kubernetes
|
||||
PVC and `WorktreePreparer` cases in `internal/agent/runtime/lifecycle`, and
|
||||
`TestBuildAuthMethodsIdentityAgentOverridesEnvironment`.
|
||||
`TestWorkspaceGitAdmissionWaitDoesNotConsumeTimeout` is load-sensitive and fails
|
||||
under parallel suite runs on clean `HEAD` too.
|
||||
|
||||
Two ordering guarantees are proved directly rather than by inspection. A PATH
|
||||
shim records every git invocation, so
|
||||
`TestGitOperatorPushVerifiesBranchAsLastReadBeforePush` asserts that
|
||||
`symbolic-ref HEAD` is the command immediately preceding `push` on both the
|
||||
explicit-target and default paths. A second shim moves `HEAD` during
|
||||
empty-remote first publication, so
|
||||
`TestGitOperatorPushReportsMismatchAfterBaselinePublication` exercises the
|
||||
second-verification window that the first verification cannot cover.
|
||||
|
||||
## Risks
|
||||
|
||||
- `Push` is already long and carries contribution routing, force refusal, and
|
||||
empty-remote publication. Adding resolution and two verification points
|
||||
in-line would breach the backend function-length limit; the plan therefore
|
||||
puts resolution and the guard in `git_push_target.go` and has `Push` call a
|
||||
single `resolvePushPlan`.
|
||||
- Moving `getUpstreamRef` ahead of the second verification changes the order of
|
||||
existing Git reads on the path that names no target. The existing
|
||||
`git_empty_remote_test.go` cases are the regression net for that path and must
|
||||
stay green unmodified.
|
||||
- Replacing preflight's blanket `result.Success = true` makes a previously
|
||||
inert call contact `origin`. The startup remote-contribution preflight is not
|
||||
affected because it runs only when a binding exists, but any future caller on
|
||||
the ordinary path now performs network I/O.
|
||||
- A configured remote whose name fails `IsValidBranchName` cannot be addressed
|
||||
by the name form and is skipped during URL matching. This is consistent in
|
||||
both directions, but it means such a remote is unreachable through this
|
||||
contract.
|
||||
- The five result fields are `omitempty`, so `current_branch` reported as empty
|
||||
for a detached `HEAD` is omitted from the JSON rather than present and empty.
|
||||
Consumers must read a missing key as the detached state.
|
||||
|
||||
## Resolved questions
|
||||
|
||||
- The system design's Security section claimed `Push` assigns Git's output to
|
||||
the result without sanitizing it. `runGitCommand` in fact sanitizes every
|
||||
invocation whose subcommand is `push`, dry-run included, so
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18 was already satisfied on both new
|
||||
paths and no new redaction call was needed. The design's Security section has
|
||||
been corrected to describe the existing behavior, and
|
||||
`TestGitOperatorPushRedactsCredentialsOnExplicitTargetPath` plus
|
||||
`TestPushPreflightRedactsCredentialsInRemoteOutput` guard it against
|
||||
regression.
|
||||
- The plan proposed `IsValidExpectedBranchName` because
|
||||
`IsValidDefaultBranchName` strips an `origin/` prefix. Implementation
|
||||
confirmed the two disagree on inputs such as `origin/-dash`, and the new
|
||||
validator checks the value that is actually used in the comparison and the
|
||||
refspec. `TestIsValidExpectedBranchName` pins the distinguishing case.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
id: "01-resolution-primitives"
|
||||
title: "Push-target and expected-branch primitives"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.3
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.4
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.6
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.7
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.15
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.16
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.17
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.7
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/configured-push-target.md
|
||||
---
|
||||
|
||||
# Task 01: Push-target and expected-branch primitives
|
||||
|
||||
## Summary
|
||||
|
||||
Add the vocabulary and pure helpers the rest of the package builds on: a
|
||||
`PushOptions` request struct, the eleven error-code constants, the five result
|
||||
fields, remote enumeration, push-target resolution, strict expected-branch
|
||||
validation, and a symbolic-ref current-branch read. Push and preflight take the
|
||||
new options and ignore them, so this work order changes no observable behavior.
|
||||
|
||||
## In scope
|
||||
|
||||
- `PushOptions{Force, SetUpstream bool; Remote, ExpectedBranch string}` in
|
||||
`internal/agentctl/server/process`, threaded through `GitOperator.Push` and
|
||||
`GitOperator.PushPreflight` as a pass-through with behavior unchanged.
|
||||
- The eleven error-code constants from the system design's table.
|
||||
- `PushedRemote`, `PushedBranch`, `ExpectedBranch`, `CurrentBranch`, and
|
||||
`BaselinePublished` on `process.GitOperationResult`, all `omitempty`.
|
||||
- `configuredRemotes`, which reads each remote's effective push URL set and
|
||||
distinguishes a genuine read failure from an absent remote.
|
||||
- `resolvePushTarget`, returning a remote name or one of the four resolution
|
||||
refusals, and never placing a caller-supplied URL on a Git command line.
|
||||
- `currentBranch`, reading `git symbolic-ref HEAD` so a detached `HEAD` is an
|
||||
empty branch rather than the literal `HEAD`.
|
||||
- `securityutil.IsValidExpectedBranchName`, the strict allowlist without
|
||||
`IsValidBaseBranchRef`'s `origin/` prefix strip.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to how `Push` or `PushPreflight` behave. The options are accepted
|
||||
and ignored here; Tasks 02 and 03 own the behavior.
|
||||
- The runtime client and backend handler result structs, which Task 04 owns.
|
||||
- Widening `runGitCommand`'s argument allowlist. `symbolic-ref HEAD` and
|
||||
`config --get-all` are already admitted; if an implementation needs a flag
|
||||
that is not, change the approach rather than the allowlist.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `resolvePushTarget` returns the resolved remote name for the name form and
|
||||
the single-entry URL form, `push_remote_fanout` for a multi-push-URL remote
|
||||
carrying the value, `push_remote_url_unmatched` when nothing carries it,
|
||||
`push_remote_not_found` for an unknown name, and
|
||||
`push_remote_config_unreadable` when the remote configuration cannot be read.
|
||||
- `currentBranch` returns the branch for an attached `HEAD` and an empty string
|
||||
with no error for a detached one, distinguished from a real read failure.
|
||||
- `go build ./...` and the existing process and securityutil suites pass with
|
||||
`Push` and `PushPreflight` behavior unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/... ./internal/common/securityutil/...
|
||||
cd apps/backend && make lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_target.go` (new)
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_target_test.go` (new)
|
||||
- `apps/backend/internal/agentctl/server/process/git.go`
|
||||
- `apps/backend/internal/common/securityutil/git.go`
|
||||
- `apps/backend/internal/common/securityutil/git_test.go`
|
||||
- `apps/backend/internal/agentctl/server/api/git.go` (call-site signature only)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- `IsValidDefaultBranchName` looks like the right validator but strips an
|
||||
`origin/` prefix, so reusing it would let `origin/main` validate as `main`.
|
||||
The new validator must not route through `IsValidBaseBranchRef`.
|
||||
- Changing `Push`'s signature touches its existing call site in
|
||||
`internal/agentctl/server/api/git.go`. Keep that edit mechanical; Task 04
|
||||
owns the request-field work there.
|
||||
- A remote name that fails `IsValidBranchName` cannot be safely interpolated
|
||||
into `remote.<name>.url`. Skip such remotes during URL matching rather than
|
||||
failing the whole enumeration.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001 and -002, and the
|
||||
system design's [Push-target resolution](../../specs/workspaces/system-design/configured-push-target.md)
|
||||
and [Data and contracts](../../specs/workspaces/system-design/configured-push-target.md) sections.
|
||||
- `internal/agentctl/server/process/git.go` `runGitCommand` argument allowlist
|
||||
and `getCurrentBranch`.
|
||||
- `internal/common/securityutil/git.go` existing validators.
|
||||
- `setupEmptyRemoteTaskRepo` in `git_empty_remote_test.go` as the fixture
|
||||
pattern for real-repository tests.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented. `PushOptions`, the eleven error-code constants, the five result
|
||||
fields, `configuredRemotes`, `resolvePushTarget`, and `currentBranch` live in
|
||||
`apps/backend/internal/agentctl/server/process/git_push_target.go`;
|
||||
`securityutil.IsValidExpectedBranchName` lives alongside the existing
|
||||
validators. `Push` and `PushPreflight` take `PushOptions` as a pass-through, so
|
||||
this work order changed no observable behavior.
|
||||
|
||||
`IsValidBaseBranchRef`'s `origin/` strip was confirmed to matter: it and the new
|
||||
validator disagree on inputs such as `origin/-dash`, so the new validator checks
|
||||
the value that is actually compared against `HEAD` and used in the refspec.
|
||||
|
||||
`currentBranch` distinguishes a detached `HEAD` from an unreadable checkout by
|
||||
falling back to `rev-parse --verify HEAD` only when `symbolic-ref` fails, so a
|
||||
detached checkout reports an empty branch and a broken one still errors.
|
||||
|
||||
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...
|
||||
./internal/common/securityutil/...` and `make -C apps/backend lint`.
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
id: "02-push-path"
|
||||
title: "Explicit push target and expected-branch verification on the push path"
|
||||
status: completed
|
||||
wave: 2
|
||||
depends_on: ["01-resolution-primitives"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.2
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.8
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.9
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.10
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.11
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.12
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.13
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.14
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.4
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.7
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.8
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.9
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.10
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.2
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.3
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.4
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.5
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.6
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/configured-push-target.md
|
||||
---
|
||||
|
||||
# Task 02: Explicit push target and expected-branch verification on the push path
|
||||
|
||||
## Summary
|
||||
|
||||
Wire `GitOperator.Push` onto Task 01's primitives: evaluate the fixed refusal
|
||||
list before contacting any remote, skip empty-remote first publication for a
|
||||
resolved remote other than `origin`, and verify the expected branch twice, the
|
||||
second time as the last Git read before the push command. A request that names
|
||||
neither input performs the same push it performs today.
|
||||
|
||||
## In scope
|
||||
|
||||
- A `resolvePushPlan` helper that evaluates the refusal list in the design's
|
||||
fixed order and yields the resolved remote, the refspec, and the
|
||||
set-upstream decision.
|
||||
- Refusing `push_remote_contribution_conflict` and
|
||||
`push_remote_upstream_unsupported` before any remote read.
|
||||
- Restricting empty-remote first publication to the `origin` path.
|
||||
- Moving `getUpstreamRef` ahead of the second verification so the branch read is
|
||||
the last Git command before the push.
|
||||
- The second verification, reporting `push_branch_mismatch_after_baseline` with
|
||||
`baseline_published` set when first publication published a baseline in this
|
||||
request, and `push_branch_mismatch` otherwise.
|
||||
- Refspec selection: `HEAD:refs/heads/<expected>` with a target and an expected
|
||||
branch, `HEAD:refs/heads/<current>` with a target alone, unchanged otherwise.
|
||||
- Populating `pushed_remote` and `pushed_branch` on a successful push only when
|
||||
the request carried an explicit target.
|
||||
- Extending the push completion log with the resolved remote, destination
|
||||
branch, and whether an expected branch was supplied and matched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `PushPreflight`, which Task 03 owns even though it reuses `resolvePushPlan`.
|
||||
- Transport plumbing, which Task 04 owns.
|
||||
- Contribution routing behavior, its force refusal, and the empty-remote
|
||||
publication mechanics themselves, all of which stay as they are.
|
||||
- Adding a second output sanitization. `runGitCommand` already redacts every
|
||||
`push` invocation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A push naming a configured remote other than `origin` publishes
|
||||
`HEAD:refs/heads/<branch>` to it, sets no upstream, and publishes no baseline.
|
||||
- An expected branch that does not match the current branch, including a
|
||||
detached `HEAD`, refuses the push with the expected and current branches
|
||||
reported and no remote contacted.
|
||||
- A request naming neither input produces the same Git commands and the same
|
||||
result shape as before this task, with `git_empty_remote_test.go` unmodified
|
||||
and green.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/...
|
||||
cd apps/backend && make lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/process/git.go`
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_target.go`
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_expected_branch_test.go` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Risks
|
||||
|
||||
- `Push` is near the backend function-length limit already. Keep
|
||||
`resolvePushPlan` and the second verification in `git_push_target.go` rather
|
||||
than growing `Push` in place.
|
||||
- The second verification can follow a completed baseline publication, so the
|
||||
refusal is deliberately not side-effect-free. Do not add a rollback; report
|
||||
`baseline_published` instead.
|
||||
- Reordering the upstream read is observable to any test that asserts Git
|
||||
command sequence on the `origin` path. Existing empty-remote tests must pass
|
||||
unmodified; if one fails, the reorder is wrong rather than the test.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe`
|
||||
|
||||
## Inputs
|
||||
|
||||
- The system design's [Control flow](../../specs/workspaces/system-design/configured-push-target.md)
|
||||
and [Refusal ordering](../../specs/workspaces/system-design/configured-push-target.md) sections.
|
||||
- `Push` at `internal/agentctl/server/process/git.go`, and
|
||||
`prepareEmptyRemotePublication` in `git_empty_remote.go`.
|
||||
- `git_empty_remote_test.go` as the regression net for the unchanged path.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented. `Push` now calls `validateContributionState`, then
|
||||
`resolvePushPlan`, which evaluates the whole refusal list before any remote is
|
||||
contacted. Empty-remote first publication is gated on `plan.baselineEligible`,
|
||||
so it runs only for `origin` with no contribution routing. `resolveSetUpstream`
|
||||
moves the upstream read ahead of the second verification and skips it entirely
|
||||
on the explicit-target path.
|
||||
|
||||
The ordering guarantee is proved rather than asserted: a PATH shim records every
|
||||
git invocation, and `TestGitOperatorPushVerifiesBranchAsLastReadBeforePush`
|
||||
checks that `symbolic-ref HEAD` immediately precedes `push` on both the
|
||||
explicit-target and default paths. A second shim moves `HEAD` during first
|
||||
publication so
|
||||
`TestGitOperatorPushReportsMismatchAfterBaselinePublication` exercises the
|
||||
post-baseline refusal, confirming `push_branch_mismatch_after_baseline`,
|
||||
`baseline_published`, and that the task branch stays unpublished while the
|
||||
baseline remains.
|
||||
|
||||
The existing `git_empty_remote_test.go` cases pass unmodified, which is the
|
||||
regression evidence that the path naming no target is unchanged.
|
||||
|
||||
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...` and
|
||||
`make -C apps/backend lint`.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
id: "03-preflight"
|
||||
title: "Push preflight validates the resolved destination"
|
||||
status: completed
|
||||
wave: 3
|
||||
depends_on: ["02-push-path"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.1
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.2
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.3
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.4
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.5
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.6
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/configured-push-target.md
|
||||
---
|
||||
|
||||
# Task 03: Push preflight validates the resolved destination
|
||||
|
||||
## Summary
|
||||
|
||||
Replace push preflight's blanket success on the ordinary path with a real
|
||||
dry-run against the destination the equivalent push would use. Preflight reuses
|
||||
Task 02's resolution, guard, and refusal ordering, verifies the expected branch
|
||||
once, and reports the remote and branch it validated whenever contribution
|
||||
routing did not apply.
|
||||
|
||||
## In scope
|
||||
|
||||
- Replacing the `g.remoteContribution == nil` early `result.Success = true`
|
||||
return with a resolved-remote dry-run, defaulting to `origin`.
|
||||
- Reporting `push_no_remote_configured` when there is no explicit target, no
|
||||
contribution routing, and no `origin` remote.
|
||||
- Applying the same refusal ordering and codes as the push path, with a single
|
||||
expected-branch verification and no reachable
|
||||
`push_branch_mismatch_after_baseline`.
|
||||
- Reporting `pushed_remote` and `pushed_branch` on every successful preflight to
|
||||
which contribution routing did not apply, with or without an explicit target.
|
||||
- Reporting a remote's write refusal as a failure carrying the remote's output
|
||||
rather than an unhandled error.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The contribution-destination and remote-contribution preflight branches,
|
||||
which keep their bodies, their result shape, and their output handling.
|
||||
- Preflighting the empty-remote baseline refspec. Preflight exercises the
|
||||
destination-branch refspec only.
|
||||
- Any change to the startup remote-contribution preflight caller in
|
||||
`internal/agent/runtime/lifecycle/manager_startup.go`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Preflight with no explicit target and no contribution routing dry-runs against
|
||||
`origin` for the current branch and reports the remote and branch it checked.
|
||||
- Preflight against a remote that refuses the write reports failure with the
|
||||
remote's output, and creates, moves, updates, or deletes no ref anywhere.
|
||||
- A checkout with no `origin` and no contribution routing reports
|
||||
`push_no_remote_configured` instead of success.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/... ./internal/agent/runtime/lifecycle/...
|
||||
cd apps/backend && make lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/process/git.go`
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_target.go`
|
||||
- `apps/backend/internal/agentctl/server/process/git_push_preflight_test.go` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02, whose `resolvePushPlan` this reuses.
|
||||
|
||||
## Risks
|
||||
|
||||
- This turns a previously inert call into one that contacts a remote. The
|
||||
startup caller is unaffected because it runs only when a contribution binding
|
||||
exists, but the behavior change is real for any other caller on that path.
|
||||
- A dry-run is still a `push` subcommand, so it must not be allowed to mutate.
|
||||
Assert absence of ref movement directly in the test rather than trusting
|
||||
`--dry-run`.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirement REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003 and the system design's
|
||||
[Preflight](../../specs/workspaces/system-design/configured-push-target.md) section.
|
||||
- `PushPreflight` at `internal/agentctl/server/process/git.go`.
|
||||
- The startup caller at `internal/agent/runtime/lifecycle/manager_startup.go`.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented. `PushPreflight` reuses `validateContributionState` and
|
||||
`resolvePushPlan`, passing `requireConfiguredRemote: true` so a checkout with no
|
||||
`origin` and no contribution routing reports `push_no_remote_configured` instead
|
||||
of success. The blanket `result.Success = true` return is gone; every
|
||||
non-routed preflight now dry-runs the destination-branch refspec and reports the
|
||||
remote and branch it validated. A preflight under contribution routing keeps its
|
||||
existing body and result shape.
|
||||
|
||||
Preflight verifies the expected branch once, through `resolvePushPlan`, and
|
||||
never calls `verifyExpectedBranch`, so `push_branch_mismatch_after_baseline` is
|
||||
unreachable from preflight. `TestPushPreflightAppliesSameRefusalOrdering`
|
||||
asserts that directly.
|
||||
|
||||
`TestPushPreflightMutatesNothing` checks `HEAD`, the full local ref list,
|
||||
upstream configuration, and both remotes across a preflight rather than trusting
|
||||
`--dry-run`.
|
||||
|
||||
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...
|
||||
./internal/agent/runtime/lifecycle/...` and `make -C apps/backend lint`.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
id: "04-transport"
|
||||
title: "Carry both inputs across the push transport"
|
||||
status: completed
|
||||
wave: 2
|
||||
depends_on: ["01-resolution-primitives"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.1
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.1
|
||||
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.8
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/configured-push-target.md
|
||||
---
|
||||
|
||||
# Task 04: Carry both inputs across the push transport
|
||||
|
||||
## Summary
|
||||
|
||||
Carry the explicit push target and the expected branch from the backend
|
||||
workspace Git action handler, through the runtime client, to the agentctl HTTP
|
||||
surface, and add the five result fields to the client-side result struct. Every
|
||||
layer passes both values through without interpreting them; all destination
|
||||
logic stays in the operator.
|
||||
|
||||
## In scope
|
||||
|
||||
- `remote` and `expected_branch` on `GitPushRequest` and
|
||||
`GitPushPreflightRequest` in `internal/agentctl/server/api/git.go`, bound and
|
||||
forwarded into `PushOptions`.
|
||||
- The five result fields on `client.GitOperationResult` in
|
||||
`internal/agent/runtime/agentctl/git.go`, matching the server struct's JSON
|
||||
keys.
|
||||
- Both inputs on `Client.GitPush` and `Client.GitPushPreflight`, carried in the
|
||||
request payloads.
|
||||
- Both fields on the `worktree.push` payload in
|
||||
`internal/agent/handlers/git_handlers.go`, forwarded to the client.
|
||||
- Keeping the startup preflight caller in
|
||||
`internal/agent/runtime/lifecycle/manager_startup.go` passing neither.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any validation, resolution, or refusal decision at any transport layer. The
|
||||
HTTP surface performs no destination logic of its own.
|
||||
- Any web, desktop, or mobile control. The existing push control keeps sending
|
||||
neither input.
|
||||
- Operator behavior, which Tasks 02 and 03 own.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A push request carrying `remote` and `expected_branch` reaches
|
||||
`GitOperator.Push` with both values intact, and a request carrying neither
|
||||
produces a payload and behavior identical to today's.
|
||||
- Both values are scoped to the repository the request already selects via
|
||||
`repo` and affect no other repository in a multi-repository task.
|
||||
- The client result struct deserializes all five new fields.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/api/... ./internal/agent/runtime/agentctl/... ./internal/agent/handlers/... ./internal/agent/runtime/lifecycle/...
|
||||
cd apps/backend && make lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/api/git.go`
|
||||
- `apps/backend/internal/agentctl/server/api/git_handlers_test.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/git.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/git_test.go`
|
||||
- `apps/backend/internal/agent/handlers/git_handlers.go`
|
||||
- `apps/backend/internal/agent/handlers/git_handlers_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01, for `PushOptions` and the server-side result fields.
|
||||
|
||||
## Risks
|
||||
|
||||
- `Client.GitPush` already takes four positional arguments. Adding two more
|
||||
makes the call site unreadable; carry a small options struct instead, and
|
||||
update the single existing caller in `internal/agent/handlers/git_handlers.go`.
|
||||
- The client and server result structs are duplicated by design and drift
|
||||
silently. The JSON keys of all five fields must match exactly.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe`
|
||||
|
||||
## Inputs
|
||||
|
||||
- The system design's [Components and responsibilities](../../specs/workspaces/system-design/configured-push-target.md)
|
||||
and [Data and contracts](../../specs/workspaces/system-design/configured-push-target.md) sections.
|
||||
- The existing `repo` field convention across the git request structs.
|
||||
- `GitContributionRequest` as the pattern for an optional string field carried
|
||||
end to end.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented. `GitPushRequest` and `GitPushPreflightRequest` carry `remote` and
|
||||
`expected_branch` through the agentctl HTTP surface; the runtime client gained a
|
||||
`PushOptions` struct plus the five result fields; and the backend `worktree.push`
|
||||
payload forwards both. The startup remote-contribution preflight passes an empty
|
||||
`PushOptions`, so it is unchanged.
|
||||
|
||||
Two call sites shadowed the client package identifier with a local variable named
|
||||
`client`, which the package's misleading `Package agentctl` doc comment hides. The
|
||||
handler's local was renamed to `agentClient` to match its siblings, and the
|
||||
lifecycle call site imports the package under an explicit `agentctlclient` alias.
|
||||
|
||||
The new API-surface tests were placed in
|
||||
`internal/agentctl/server/api/git_push_target_test.go` and
|
||||
`internal/agent/handlers/git_push_options_test.go` rather than appended to the
|
||||
existing `git_handlers_test.go` files, which were already close enough to
|
||||
revive's 800-line file-length limit that appending crossed it.
|
||||
|
||||
Verified with `go test -tags fts5 ./internal/agentctl/server/api/...
|
||||
./internal/agent/runtime/agentctl/ ./internal/agent/handlers/...
|
||||
./internal/agent/runtime/lifecycle/...` and `make -C apps/backend lint`.
|
||||
@@ -302,7 +302,7 @@ These are the registered Kandev WebSocket actions. Every payload requires `sessi
|
||||
| Action | Additional payload |
|
||||
|--------|--------------------|
|
||||
| `worktree.pull` | `rebase` boolean |
|
||||
| `worktree.push` | `force` and `set_upstream` booleans |
|
||||
| `worktree.push` | `force` and `set_upstream` booleans; optional `remote` (configured remote name or exact configured push URL) and `expected_branch` (current-branch precondition) |
|
||||
| `worktree.rebase` | required `base_branch` |
|
||||
| `worktree.merge` | required `base_branch` |
|
||||
| `worktree.abort` | `operation`: exactly `merge` or `rebase` |
|
||||
@@ -315,6 +315,12 @@ These are the registered Kandev WebSocket actions. Every payload requires `sessi
|
||||
| `worktree.rename_branch` | required `new_name` |
|
||||
| `worktree.reset` | required `commit_sha`; `mode` is `soft`, `mixed`, or `hard` |
|
||||
|
||||
The optional push target fields apply to the WebSocket action and the backend
|
||||
API. An explicit target uses the expected branch as the destination and does
|
||||
not set upstream tracking. A branch mismatch returns the expected and current
|
||||
branch values. Existing contribution routing takes precedence over an explicit
|
||||
target.
|
||||
|
||||
Example request and normal operation result:
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
---
|
||||
status: draft
|
||||
system: workspaces
|
||||
created: 2026-09-08
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Configured Push Target Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The workspace push API can today publish only to `origin` or to a remote a
|
||||
contribution binding selects. A caller cannot name a third destination, and
|
||||
cannot state which branch it believes it is publishing.
|
||||
|
||||
This capability adds two independent, optional inputs to the workspace push and
|
||||
push-preflight contracts: an explicit push target, and an expected branch. The
|
||||
expected branch exists so a caller that decided to publish `feature/x` cannot
|
||||
publish different content under that name when the agent moved `HEAD` between
|
||||
the decision and the push.
|
||||
|
||||
The workspace system owns this contract because it governs workspace Git state
|
||||
and its remotes, not the runtime that executes the push.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Push target:** The destination a push publishes to, expressed either as a
|
||||
configured Git remote name or as a remote URL.
|
||||
- **Explicit push target:** A push target the caller names in the request,
|
||||
rather than one Kandev derives from `origin` or a contribution binding.
|
||||
- **Configured remote:** A remote already present in the checkout's Git
|
||||
configuration.
|
||||
- **Effective push URL set:** A configured remote's push URLs when it has at
|
||||
least one, otherwise the single-element list holding its fetch URL. A remote
|
||||
with neither has an empty set.
|
||||
- **Contribution routing:** The existing remote-contribution and
|
||||
contribution-destination behavior that selects a remote and refspec for the
|
||||
caller.
|
||||
- **Expected branch:** The branch name a caller states it intends to publish.
|
||||
- **Current branch:** The branch the workspace checkout's `HEAD` points at.
|
||||
A detached `HEAD` has no current branch.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001: Explicit Push Target Selection
|
||||
|
||||
**Intent:** Let a caller publish a task branch to a remote it names, without
|
||||
changing what a caller that names nothing observes today.
|
||||
|
||||
**User story:** As a Kandev service that publishes task work, I want to name the
|
||||
remote a push goes to, so that a work branch can be preserved somewhere other
|
||||
than `origin`.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.1:** The workspace push and
|
||||
push-preflight contracts shall accept an optional explicit push target.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.2:** When a request omits the
|
||||
explicit push target and omits the expected branch, Kandev shall perform the
|
||||
same push it performs today, including `origin` selection, contribution
|
||||
routing, upstream selection, force behavior, and first publication.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.3:** When the explicit push target
|
||||
satisfies the permissive ref-syntax allowlist Kandev already applies to branch
|
||||
arguments, Kandev shall interpret it as a configured remote name; otherwise
|
||||
Kandev shall interpret it as a remote URL.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.4:** When the explicit push target
|
||||
is interpreted as a name and no configured remote carries that name, Kandev
|
||||
shall refuse the request with error code `push_remote_not_found` and shall not
|
||||
contact any remote.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5:** When the explicit push target
|
||||
is interpreted as a URL, Kandev shall resolve it to a configured remote whose
|
||||
effective push URL set holds exactly one entry equal to the supplied value.
|
||||
Each configured value shall be trimmed likewise, and the comparison shall
|
||||
otherwise be exact: no case folding, no `.git` suffix added or
|
||||
removed, no redirect resolved, no normalization. An empty set never matches.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.6:** When a URL push target matches
|
||||
more than one configured remote, Kandev shall select the matching remote whose
|
||||
name sorts first by byte order and shall publish to it.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.7:** When a URL push target matches
|
||||
no configured remote, Kandev shall refuse the request with error code
|
||||
`push_remote_url_unmatched` and shall not contact any remote.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.8:** Kandev shall never create,
|
||||
rename, retarget, or delete a remote while serving a push or push-preflight
|
||||
request. A push target that is not already configured shall be refused rather
|
||||
than materialized.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.9:** When a request carries an
|
||||
explicit push target and the workspace has contribution routing configured,
|
||||
Kandev shall refuse with error code `push_remote_contribution_conflict` and
|
||||
shall not push.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.10:** When a request carries an
|
||||
explicit push target together with the set-upstream flag, Kandev shall refuse
|
||||
the request with error code `push_remote_upstream_unsupported` and shall not
|
||||
push.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.11:** A push that carries an
|
||||
explicit push target shall not set, change, or remove the current branch's
|
||||
upstream tracking configuration.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.12:** When a request carries an
|
||||
explicit push target that resolves to a remote other than `origin`, Kandev
|
||||
shall not perform empty-remote first publication. When it resolves to
|
||||
`origin`, first publication shall behave exactly as it does for a request that
|
||||
names no push target.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.13:** A push that carries an
|
||||
explicit push target shall remain a non-force push unless the caller requests
|
||||
force, and force shall stay refused whenever contribution routing applies.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.14:** No result field Kandev
|
||||
populates, no error message Kandev composes, and no log record Kandev emits
|
||||
shall contain a remote URL; each shall identify a push target by its resolved
|
||||
remote name only. Git's passed-through output is excluded and keeps naming the
|
||||
destination it contacted, as it does today.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.15:** Remote-name matching shall be
|
||||
exact and case-sensitive. Kandev shall not accept a prefix, a suffix, or a
|
||||
case-insensitive variant of a configured remote name.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.16:** When Kandev cannot read the
|
||||
checkout's remote configuration while resolving an explicit push target, it
|
||||
shall refuse the request with error code `push_remote_config_unreadable` and
|
||||
shall not push. An unreadable configuration shall never be treated as an absent
|
||||
push target, as an unmatched push target, or as a successful resolution.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.17:** When no configured remote
|
||||
matches a URL push target under
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5 but at least one remote carries that
|
||||
value among several push URLs, Kandev shall refuse with error code
|
||||
`push_remote_fanout` and shall not push. A matching
|
||||
single-entry remote takes precedence and resolves normally; only when none
|
||||
exists does the fan-out refusal apply, and only when neither exists does
|
||||
`push_remote_url_unmatched` apply. Naming a multi-push-URL remote stays
|
||||
allowed and shall publish through it unchanged, reaching every configured push
|
||||
URL without reduction or reordering.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18:** On the explicit-push-target
|
||||
push path, and on the preflight paths this capability introduces or changes,
|
||||
Kandev shall apply to Git's passed-through output the same credential redaction
|
||||
it already applies to empty-remote first-publication output. The push path
|
||||
naming no explicit push target, and the existing contribution preflight paths,
|
||||
shall keep the output handling they have today.
|
||||
|
||||
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002: Expected-Branch Verification
|
||||
|
||||
**Intent:** Let a caller state the branch it decided to publish, so an agent that
|
||||
switches branches after that decision cannot cause different content to be
|
||||
published under that name.
|
||||
|
||||
**User story:** As a Kandev service that publishes task work, I want the push to
|
||||
be refused when the workspace is no longer on the branch I decided to publish,
|
||||
so that I never publish one branch's commits as another branch.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.1:** The workspace push and
|
||||
push-preflight contracts shall accept an optional expected branch, independent
|
||||
of whether the request carries an explicit push target.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2:** When the expected branch is
|
||||
supplied and is not a valid branch name, Kandev shall refuse the request with
|
||||
error code `push_branch_invalid` and shall not contact any remote. Valid means
|
||||
the strictest branch allowlist Kandev already applies to a persisted branch
|
||||
name: beyond the permissive rules on characters, length, `..` and `.lock`, an
|
||||
expected branch ending in `/`, containing `//`, or equal to a reserved Git
|
||||
symbolic pseudo-ref (`HEAD`, `ORIG_HEAD`, `FETCH_HEAD`, `MERGE_HEAD`) shall
|
||||
also be rejected.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3:** When the expected branch is
|
||||
supplied and does not equal the current branch at the first verification point,
|
||||
Kandev shall refuse with error code `push_branch_mismatch`, shall report the
|
||||
expected and current branches in the result fields the system design declares,
|
||||
and shall not contact any remote. A detached `HEAD` shall be reported as an
|
||||
empty current branch, not as the literal `HEAD`.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.4:** When the expected branch is
|
||||
supplied and `HEAD` is detached, Kandev shall treat the request as a mismatch
|
||||
under AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5:** On the push operation Kandev
|
||||
shall verify the expected branch at two points inside the workspace Git
|
||||
operation lock: once before it contacts any remote, so that a mismatch refuses
|
||||
the request with no side effect; and again as the last Git read before the push
|
||||
command, with no other Kandev-issued Git command between that verification and
|
||||
the push. A mismatch at either point shall refuse the request.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6:** When the second verification
|
||||
fails, Kandev shall refuse the push, shall keep the task branch unpublished,
|
||||
and shall report the expected and current branches as in
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3. When empty-remote first publication
|
||||
published the baseline earlier in the same request, the refusal shall carry
|
||||
error code `push_branch_mismatch_after_baseline` and shall set the result's
|
||||
baseline-published field; otherwise it shall carry `push_branch_mismatch` and
|
||||
shall omit that field. A second-point refusal is not required to be
|
||||
side-effect-free: empty-remote preparation may already have read `origin`'s
|
||||
advertised refs, and may already have retired the local first-publication
|
||||
marker without publishing anything.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.7:** When a request carries an
|
||||
explicit push target and an expected branch, Kandev shall publish the current
|
||||
`HEAD` to the branch named by the expected branch on the resolved remote.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.8:** When a request carries an
|
||||
explicit push target and no expected branch, Kandev shall publish the current
|
||||
`HEAD` to the branch named by the current branch on the resolved remote, and
|
||||
shall refuse a detached `HEAD` with error code `push_branch_detached` without
|
||||
contacting any remote.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.9:** When a request carries an
|
||||
expected branch and no explicit push target, the expected branch shall act only
|
||||
as a precondition. Kandev shall not change the remote, the refspec, the
|
||||
upstream behavior, or the first-publication behavior that the request would
|
||||
otherwise have.
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.10:** When the destination branch
|
||||
does not exist on the resolved remote, the push shall create it. Creating it
|
||||
on a remote other than `origin` shall not publish a baseline commit.
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11:** Kandev shall determine whether
|
||||
`HEAD` is detached by reading `HEAD` as a symbolic ref and observing whether it
|
||||
resolves under `refs/heads/`, and shall not infer the detached state by
|
||||
comparing an abbreviated ref name against the literal string `HEAD`.
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12:** Every Git read Kandev needs to
|
||||
build the push command shall complete before the second expected-branch
|
||||
verification required by AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5, including
|
||||
the upstream-tracking read on the path naming no explicit push target. Between that verification and the push, Kandev
|
||||
shall issue no Git command other than the push itself.
|
||||
|
||||
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003: Push Preflight Validation
|
||||
|
||||
**Intent:** Make push preflight answer whether the destination-branch push that
|
||||
would run is writable, instead of reporting success for a target it never
|
||||
checked.
|
||||
|
||||
**User story:** As a Kandev service preparing to publish, I want preflight to
|
||||
check the destination my push would actually use, so that a failure surfaces
|
||||
before the push runs.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.1:** Push preflight shall apply the
|
||||
same push-target resolution, expected-branch validation and comparison, refusal
|
||||
codes, and refusal ordering that the push contract applies, and shall verify the
|
||||
expected branch once, before its dry-run. Preflight publishes no baseline, so it
|
||||
has no second verification and shall never report
|
||||
`push_branch_mismatch_after_baseline`.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.2:** When a request carries an
|
||||
explicit push target, preflight shall verify write access to the resolved
|
||||
remote for the refspec the equivalent push would use.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.3:** When a request carries no
|
||||
explicit push target and no contribution routing applies, preflight shall
|
||||
verify write access to `origin` for the refspec the equivalent push would use,
|
||||
instead of reporting success without checking a remote.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.4:** When no explicit push target is
|
||||
supplied, no contribution routing applies, and the checkout has no `origin`
|
||||
remote, preflight shall report failure with error code
|
||||
`push_no_remote_configured`.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.5:** Push preflight shall not create,
|
||||
move, update, or delete any ref on any remote, and shall not change local refs,
|
||||
the working tree, or upstream tracking configuration.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.6:** Preflight against a remote that
|
||||
refuses the write shall report failure with the remote's output rather than an
|
||||
unhandled error, credential-redacted under
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7:** A successful preflight to which
|
||||
contribution routing did not apply shall report the resolved remote name and
|
||||
destination branch it validated, in the same result fields a successful push
|
||||
uses, whether or not the request carried an explicit push target. A successful
|
||||
preflight under contribution routing shall report neither field and shall keep
|
||||
the result shape it has today.
|
||||
|
||||
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004: Determinism, Concurrency, and Result Reporting
|
||||
|
||||
**Intent:** Make the outcome of a push request predictable when inputs conflict,
|
||||
when two callers arrive together, and when a caller retries.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1:** When a request would be refused
|
||||
for more than one reason, Kandev shall report the first applicable refusal in
|
||||
this fixed order: malformed request body; existing contribution binding
|
||||
validation failure; `push_remote_contribution_conflict`;
|
||||
`push_remote_upstream_unsupported`; `push_branch_invalid`; explicit push-target
|
||||
resolution refusals (`push_remote_config_unreadable`, `push_remote_not_found`,
|
||||
`push_remote_url_unmatched`, `push_remote_fanout`);
|
||||
`push_no_remote_configured`; `push_branch_detached`; `push_branch_mismatch`
|
||||
raised at the first verification point. Kandev shall evaluate every refusal in
|
||||
this ordered list before it contacts any remote, including before empty-remote
|
||||
first publication.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.2:** Every refusal listed in
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1 shall leave the workspace checkout,
|
||||
its refs, its upstream configuration, and every remote unchanged. The
|
||||
second-verification refusals governed by
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6 are the only refusals in this
|
||||
capability that are not listed there, and the only ones permitted to leave a
|
||||
published baseline or a retired first-publication marker behind.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.3:** A push or push-preflight
|
||||
request shall hold the existing single workspace Git operation lock for the
|
||||
whole request. When a second request for the same checkout arrives while one
|
||||
holds the lock, Kandev shall report the existing operation-in-progress result
|
||||
rather than queue, merge, or serialize it.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.4:** Repeating a successful push
|
||||
request with identical inputs while the workspace `HEAD` and the destination
|
||||
branch are unchanged shall report success and shall not change the destination
|
||||
branch.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.5:** When the destination branch on
|
||||
the resolved remote has commits the local `HEAD` does not contain, a non-force
|
||||
push shall be refused by the remote and Kandev shall report that failure
|
||||
without retrying and without escalating to a force push.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.6:** A successful push that carried
|
||||
an explicit push target shall report the resolved remote name and the
|
||||
destination branch name. A successful push that carried no explicit push target
|
||||
shall omit both, so an existing consumer observes an unchanged result shape.
|
||||
This omission rule governs the push operation only; preflight reports them in
|
||||
every case under AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.7:** Kandev shall trim leading and
|
||||
trailing whitespace from the explicit push target and from the expected branch
|
||||
before any other check, including before the validation
|
||||
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2 requires. Either input supplied
|
||||
empty, or empty after that trim, shall be treated as absent and shall produce
|
||||
the behavior of a request that omits it.
|
||||
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.8:** The explicit push target and
|
||||
expected branch shall be scoped to the repository the request already selects
|
||||
in a multi-repository task, and shall not affect any other repository in the
|
||||
same workspace.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Exact-OID lease.** No `--force-with-lease=<ref>:<oid>` form. A force push
|
||||
keeps emitting the bare lease it emits today, and force stays refused for
|
||||
contribution routing.
|
||||
- **Eliminating the residual race window.** An agent shell can move `HEAD`
|
||||
outside the operation lock, so the expected-branch check narrows the window but
|
||||
cannot close it. Closing it requires the exact-OID lease above.
|
||||
- **Preflighting the empty-remote baseline.** A push on the `origin` path may
|
||||
first publish the baseline, a different refspec. Preflight validates only the
|
||||
destination-branch refspec, so it can still report success where that baseline
|
||||
publication would fail.
|
||||
- **Auto-push policy.** When and under what condition a branch publishes
|
||||
automatically is a separate capability; this contract only makes such a caller
|
||||
expressible.
|
||||
- **Creating or configuring remotes.** Adding a backup remote belongs to
|
||||
workspace materialization, not the push contract.
|
||||
- **Changing contribution routing.** Remote-contribution and
|
||||
contribution-destination selection, their force-push refusal, and their
|
||||
preflight behavior are unchanged.
|
||||
- **A user-facing control.** No web, desktop, or mobile surface gains either
|
||||
control; the existing push control keeps sending neither input.
|
||||
- **Pushing tags, multiple refspecs, or deleting a remote branch.**
|
||||
- **Credential selection.** Unchanged: the task runtime Git credential route.
|
||||
|
||||
## Related requirements
|
||||
|
||||
- [Empty Remote Repository Requirements](empty-remote-repositories.md)
|
||||
- [Branch Policies](branch-policies.md)
|
||||
|
||||
## System design
|
||||
|
||||
- [Configured Push Target System Design](../system-design/configured-push-target.md)
|
||||
@@ -0,0 +1,368 @@
|
||||
---
|
||||
status: draft
|
||||
system: workspaces
|
||||
requirements:
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
|
||||
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
|
||||
---
|
||||
|
||||
# Configured Push Target System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The workspace system owns workspace Git state and the contract that publishes a
|
||||
task branch to a remote. This design extends the existing workspace push and
|
||||
push-preflight contracts with an explicit push target and an expected branch.
|
||||
|
||||
Adjacent contracts this design uses but does not own:
|
||||
|
||||
- Contribution routing (remote contribution, contribution destination) and its
|
||||
force-push refusal. This design refuses rather than overriding it.
|
||||
- Empty-remote first publication, which stays attached to `origin`.
|
||||
- The task runtime Git credential route.
|
||||
- The agent runtime transport that carries a push request from the orchestrator
|
||||
into the workspace.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001` | [Data and contracts](#data-and-contracts), [Push-target resolution](#push-target-resolution) |
|
||||
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002` | [Control flow](#control-flow) |
|
||||
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003` | [Preflight](#preflight) |
|
||||
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004` | [Refusal ordering](#refusal-ordering), [Failure and recovery](#failure-and-recovery) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- **Workspace Git operator (agentctl, per checkout).** Owns the operation lock,
|
||||
push-target resolution, the expected-branch guard, and Git command
|
||||
construction. All new decisions live here so that no caller can reach the
|
||||
destination selection by a different route.
|
||||
- **Workspace Git HTTP surface (agentctl).** Binds the two new optional request
|
||||
fields for both the push and push-preflight endpoints and passes them through
|
||||
unchanged. It performs no destination logic of its own.
|
||||
- **Agent runtime Git client (backend).** Carries the two new optional fields on
|
||||
the push and push-preflight calls. Existing callers that pass neither keep
|
||||
their current signatures' behavior.
|
||||
- **Workspace Git action handler (backend).** Accepts the two new optional
|
||||
fields on the existing push action and forwards them. The web client keeps
|
||||
sending neither.
|
||||
- **Git argument validator (agentctl).** The existing defense-in-depth allowlist
|
||||
that inspects every argument before a Git process starts. This design keeps a
|
||||
remote URL off the command line entirely so the allowlist does not have to be
|
||||
widened.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
Two optional inputs are added to the push and push-preflight request bodies, and
|
||||
to the equivalent backend action payload and client calls. Both default to
|
||||
absent, and absent reproduces today's behavior exactly.
|
||||
|
||||
- `remote`: the explicit push target, as a configured remote name or a remote
|
||||
URL.
|
||||
- `expected_branch`: the branch the caller believes it is publishing.
|
||||
|
||||
Both inputs are trimmed of leading and trailing whitespace before any other
|
||||
check, and a value that is empty after that trim is absent. For the expected
|
||||
branch this runs before the strict branch allowlist, so a whitespace-only value
|
||||
is an omitted input rather than a `push_branch_invalid` refusal. The trim is
|
||||
normalization, not a refusal: request-body parsing necessarily precedes it, and
|
||||
it precedes every check in the refusal ordering below that reads either value.
|
||||
|
||||
The result gains five optional output fields. Each is omitted when its rule
|
||||
below does not apply, so a consumer that reads none of them sees the shape it
|
||||
sees today.
|
||||
|
||||
- `pushed_remote`: the resolved remote name. Populated on a successful push that
|
||||
carried an explicit push target, and on a successful preflight to which
|
||||
contribution routing did not apply.
|
||||
- `pushed_branch`: the destination branch name. Same rule as `pushed_remote`.
|
||||
- `expected_branch`: the expected branch the request supplied. Populated on a
|
||||
`push_branch_mismatch` or `push_branch_mismatch_after_baseline` refusal.
|
||||
- `current_branch`: the branch the checkout was actually on. Populated on the
|
||||
same two refusals, and reported as empty when `HEAD` is detached rather than as
|
||||
the literal `HEAD`.
|
||||
- `baseline_published`: true when empty-remote first publication published the
|
||||
baseline during this request. Populated only on
|
||||
`push_branch_mismatch_after_baseline`, which is precisely the second-verification
|
||||
refusal that followed such a publication. A successful push never sets it, even when first
|
||||
publication ran, because doing so would change the result shape on the path
|
||||
that names no push target.
|
||||
|
||||
The asymmetry between push and preflight on the first two fields is deliberate.
|
||||
The push path omits them without an explicit target so that today's only
|
||||
frontend consumer observes an unchanged result; a preflight on that same path
|
||||
has no consumer that reads them at all, so it reports what it validated, which
|
||||
is the whole point of asking it. A preflight under contribution routing reports
|
||||
neither, because `## Out of scope` promises that path's behavior is unchanged
|
||||
and these fields are `omitempty`: populating them would add keys to a response
|
||||
shape this capability undertook not to touch.
|
||||
|
||||
Stable error codes, carried on the existing result error-code field:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `push_remote_not_found` | Name form; no configured remote carries that name. |
|
||||
| `push_remote_url_unmatched` | URL form; no configured remote has a single-entry effective push URL set equal to the supplied value. |
|
||||
| `push_remote_fanout` | URL form; a remote carries that URL but also others, so publishing through it would reach destinations the caller did not name. |
|
||||
| `push_remote_config_unreadable` | The checkout's remote configuration could not be read while resolving an explicit push target. |
|
||||
| `push_remote_contribution_conflict` | Explicit push target supplied while contribution routing is configured. |
|
||||
| `push_remote_upstream_unsupported` | Explicit push target supplied together with set-upstream. |
|
||||
| `push_branch_invalid` | Expected branch fails the ref allowlist. |
|
||||
| `push_branch_mismatch` | Expected branch is not the current branch, including detached `HEAD`. Side-effect-free at the first verification; at the second it may follow empty-remote preparation that read the remote and retired the local marker without publishing. |
|
||||
| `push_branch_mismatch_after_baseline` | Same mismatch at the second verification, after first publication published the baseline in this request. |
|
||||
| `push_branch_detached` | Explicit push target with no expected branch, and `HEAD` is detached. |
|
||||
| `push_no_remote_configured` | Preflight with no explicit target, no contribution routing, and no `origin`. |
|
||||
|
||||
### Push-target resolution
|
||||
|
||||
Resolution is a pure function of the request value and the checkout's remote
|
||||
configuration, and never mutates configuration.
|
||||
|
||||
1. Apply the shared trim above. An empty result means absent.
|
||||
2. If the value satisfies the existing branch-name allowlist, treat it as a
|
||||
remote name and require that the checkout's configured remote list contains
|
||||
it.
|
||||
3. Otherwise treat it as a URL. Read each configured remote's effective push URL
|
||||
set: its push URLs when it has one or more, and otherwise the single entry
|
||||
holding its fetch URL. Git allows a remote to carry several push URLs and
|
||||
publishes to every one of them, so a remote matches only when its set holds
|
||||
exactly one entry and that entry equals the supplied value under exact string
|
||||
comparison, both sides trimmed. A remote that carries the supplied URL
|
||||
alongside others is refused with `push_remote_fanout` rather than matched,
|
||||
because resolving to it would publish to destinations the caller never named;
|
||||
a caller that wants that fan-out names the remote instead, which is the
|
||||
difference between asking for a destination and asking for a remote. A remote
|
||||
with an empty set never matches. Several single-entry remotes can still match,
|
||||
because several can legitimately carry the same URL; select the matching name
|
||||
that sorts first by byte order, which is well defined because all such matches
|
||||
address the same destination.
|
||||
4. The name/URL discriminator is deliberately the permissive ref-syntax
|
||||
allowlist rather than the strict branch allowlist an expected branch must
|
||||
pass. A permissive reading cannot reach a destination on its own, because a
|
||||
value read as a name must still match an already-configured remote before
|
||||
anything is published; the expected branch has no such backstop, which is why
|
||||
it is held to the stricter rule. The strict rule is also what keeps `HEAD`
|
||||
out: the abbreviated-name read returns that literal for a detached checkout,
|
||||
so an expected branch of `HEAD` would otherwise compare equal to it and pass a
|
||||
guard whose whole purpose is to fail there.
|
||||
5. Resolution yields a remote **name**. Only that name reaches a Git command
|
||||
line. A caller-supplied URL is never passed to Git, which is what keeps the
|
||||
existing argument allowlist unchanged and keeps credentials out of process
|
||||
arguments.
|
||||
|
||||
## Control flow
|
||||
|
||||
A push request runs entirely inside the existing per-checkout operation lock.
|
||||
|
||||
1. Acquire the lock. A busy checkout returns the existing
|
||||
operation-in-progress signal.
|
||||
2. Evaluate every refusal in the fixed order below, including the first
|
||||
expected-branch verification. This whole step runs before any remote is
|
||||
contacted and before any local ref changes, so a refused request is a no-op.
|
||||
3. When the resolved remote is `origin` and no contribution routing applies, run
|
||||
empty-remote first publication as it runs today. Skip it for any other
|
||||
resolved remote.
|
||||
4. Complete every remaining Git read needed to build the push command. On the
|
||||
path that names no explicit push target this includes the upstream-tracking
|
||||
read that decides set-upstream behavior; the explicit-target path needs no
|
||||
such read, because that flag combination is already refused at step 2. Then
|
||||
re-read the current branch, reading `HEAD` as a symbolic ref so that a
|
||||
detached checkout is distinguishable from a branch literally named `HEAD`.
|
||||
That branch read is the last Git read before the push.
|
||||
5. When an expected branch was supplied, compare it again to the current branch
|
||||
and refuse on mismatch. A detached `HEAD` is a mismatch because it has no
|
||||
current branch. The refusal carries `push_branch_mismatch_after_baseline`
|
||||
when step 3 published a baseline in this request, and `push_branch_mismatch`
|
||||
otherwise.
|
||||
6. Issue the push. No Kandev-issued Git command runs between the branch read in
|
||||
step 4 and the push in step 6.
|
||||
|
||||
Verifying twice is what lets both guarantees hold at once. The step 2
|
||||
verification keeps every refusal side-effect-free even though first publication
|
||||
sits between the refusal set and the push. The step 5 verification gives the
|
||||
ordering guarantee the caller actually needs: nothing Kandev issues can move
|
||||
`HEAD` between the check and the push. The residual window is bounded by the
|
||||
push itself, because an agent shell can still move `HEAD` outside the lock;
|
||||
closing that needs an exact source-OID refspec or an equivalent lease, which
|
||||
the requirement places out of scope. A destination lease alone cannot freeze
|
||||
local `HEAD`. In the rare case where step 5 refuses after step 3 already published a
|
||||
baseline, the result reports the completed baseline publication and leaves the
|
||||
task branch unpublished, which is the existing partial first-publication
|
||||
contract rather than a new failure mode. A step 5 refusal after a step 3 that
|
||||
published nothing is a plain `push_branch_mismatch`. It is still not a strict
|
||||
no-op: when a first-publication marker is present but the baseline is already on
|
||||
the remote, step 3 reads `origin`'s advertised refs and retires that local
|
||||
marker. Both are idempotent and neither publishes the task branch, which is why
|
||||
this case needs no distinct code — but it is why the no-op guarantee is stated
|
||||
for the step 2 refusal list only.
|
||||
|
||||
Refspec selection:
|
||||
|
||||
- No explicit push target: unchanged on every existing path. An expected branch
|
||||
acts only as a precondition and does not rewrite the refspec.
|
||||
- Explicit push target with an expected branch: `HEAD:refs/heads/<expected>`.
|
||||
- Explicit push target without an expected branch: `HEAD:refs/heads/<current>`,
|
||||
with a detached `HEAD` refused first.
|
||||
|
||||
The explicit-target path never appends the set-upstream flag and never reads the
|
||||
upstream ref, because the flag combination is refused before the push.
|
||||
|
||||
### Refusal ordering
|
||||
|
||||
Fixed, so that a request with several problems always reports the same code:
|
||||
|
||||
1. Malformed request body.
|
||||
2. Existing contribution binding validation failure.
|
||||
3. `push_remote_contribution_conflict`.
|
||||
4. `push_remote_upstream_unsupported`.
|
||||
5. `push_branch_invalid`.
|
||||
6. `push_remote_config_unreadable` / `push_remote_not_found` /
|
||||
`push_remote_url_unmatched` / `push_remote_fanout`. These four are mutually
|
||||
exclusive by construction, so their relative order is never observable.
|
||||
7. `push_no_remote_configured`. Preflight only: a push with no explicit target
|
||||
and no `origin` fails at the push itself rather than at resolution. Ordered
|
||||
ahead of the branch guards so a checkout with nowhere to publish reports that,
|
||||
rather than reporting a stale branch the caller cannot act on yet.
|
||||
8. `push_branch_detached`.
|
||||
9. `push_branch_mismatch` raised at the first verification.
|
||||
|
||||
The second-verification refusal is deliberately absent from this list. It is
|
||||
produced at step 5, which runs after first publication, and it is the only
|
||||
refusal in this capability that can follow a completed baseline publication or a
|
||||
retired first-publication marker. It carries
|
||||
`push_branch_mismatch_after_baseline` when a baseline was published in this
|
||||
request and `push_branch_mismatch` otherwise. Everything in the list above is
|
||||
evaluated at step 2 and is therefore a no-op. Once the second-verification
|
||||
refusal is set aside, the list is exhaustive for this capability: every other
|
||||
refusal it introduces appears in it.
|
||||
|
||||
Cheap request-shape checks precede configuration reads, and configuration reads
|
||||
precede the checkout read that resolves `HEAD`, so a rejected request performs
|
||||
the least work. The whole list is evaluated at control-flow step 2, before
|
||||
empty-remote first publication, which is why a refusal drawn from it never leaves
|
||||
a partially-published remote behind.
|
||||
|
||||
### Preflight
|
||||
|
||||
Preflight reuses the same resolution, the same guard, and the same refusal
|
||||
ordering, then issues a dry-run push for the refspec the equivalent push would
|
||||
use. Two behaviors change relative to today:
|
||||
|
||||
- The path that previously reported success without contacting a remote now
|
||||
dry-runs against `origin` for the current branch.
|
||||
- Every successful preflight to which contribution routing did not apply, with
|
||||
or without an explicit push target, reports the remote name and branch it
|
||||
validated. The push path's rule of omitting those fields without an explicit
|
||||
target exists to protect an existing consumer; a preflight on that path has
|
||||
none, so withholding what it checked would only make the answer less useful. A
|
||||
preflight under contribution routing reports neither and keeps the result shape
|
||||
it has today.
|
||||
- A checkout with no `origin` and no contribution routing now reports
|
||||
`push_no_remote_configured` instead of success.
|
||||
|
||||
Preflight dry-runs the destination-branch refspec only. On the `origin` path an
|
||||
actual push may first publish the empty-remote baseline, a different refspec that
|
||||
preflight does not exercise, so preflight can still report success where that
|
||||
baseline publication would fail. The requirement's `## Out of scope` names this
|
||||
limit; REQ-003 removes preflight's blanket success for the destination branch and
|
||||
does not extend it to the baseline.
|
||||
|
||||
Preflight verifies the expected branch once rather than twice, and the
|
||||
requirement scopes the two-point rule to the push operation for that reason. The
|
||||
second verification exists on the push path only because first publication sits
|
||||
between the refusal set and the push; preflight runs no first publication and
|
||||
mutates nothing, so there is no window for the second read to close and
|
||||
`push_branch_mismatch_after_baseline` is unreachable from preflight. A dry-run is
|
||||
a push command, so this is a deliberate exception stated in both documents rather
|
||||
than an oversight.
|
||||
|
||||
The preflight paths this capability introduces or changes apply the same
|
||||
credential redaction to Git's output that empty-remote first publication already
|
||||
applies. The existing contribution preflight paths are left exactly as they are,
|
||||
which is what `## Out of scope` promises.
|
||||
|
||||
These changes are reachable only through the preflight endpoint and the startup
|
||||
remote-contribution preflight, and the startup caller runs only when a
|
||||
contribution binding exists, so none of them alters an existing live path.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- Every refusal in the ordered list is a no-op: no remote contacted, no local
|
||||
ref, upstream setting, or working tree changed. A caller can retry after
|
||||
correcting its inputs. The single exception is
|
||||
`push_branch_mismatch_after_baseline`, which by construction follows a
|
||||
completed baseline publication on `origin`; its result sets
|
||||
`baseline_published`, so a caller can see that a retry does not begin from the
|
||||
state the first attempt began from.
|
||||
- A remote that rejects a non-force push is reported as a failure with the
|
||||
remote's output. Kandev does not retry it and does not escalate to a force
|
||||
push.
|
||||
- Repeating an identical successful push while `HEAD` and the destination branch
|
||||
are unchanged succeeds and changes nothing, which is what makes the operation
|
||||
safe for a retrying caller.
|
||||
- Concurrency is handled by the existing single per-checkout lock. Requests are
|
||||
rejected rather than queued, so a caller never waits on a lock it cannot see.
|
||||
- Empty-remote failure codes keep their current meaning and keep applying only
|
||||
to the `origin` path.
|
||||
|
||||
## Persistence
|
||||
|
||||
None. Both inputs are per-request and are not stored. No schema, migration, or
|
||||
retention change. Restart behavior is unchanged because nothing new survives a
|
||||
request.
|
||||
|
||||
## Security
|
||||
|
||||
- The remote URL form is resolved against already-configured remotes and is
|
||||
never placed on a Git command line, so the existing command-injection
|
||||
allowlist stays as narrow as it is today.
|
||||
- The remote name form is validated against the existing ref allowlist and must
|
||||
name an already-configured remote, so a request cannot introduce a new
|
||||
destination or a flag-shaped argument.
|
||||
- Kandev-composed output never carries a remote URL: the result fields above,
|
||||
the error messages Kandev writes, and its log records identify a push target by
|
||||
its resolved name only.
|
||||
- Git's own output is a separate thing, and the honest description of today is
|
||||
not that it is already URL-free. The workspace Git operator redacts the output
|
||||
of every command whose subcommand is `push`, the preflight dry-run included,
|
||||
through the same credential redaction empty-remote first publication uses. So
|
||||
the credential redaction this capability requires is already in force on both
|
||||
paths it adds, by construction rather than by a new call, and no path's output
|
||||
handling changes. That redaction removes embedded credentials and known token
|
||||
values, not URLs, so Git's `To <url>` line ships today and continues to. This
|
||||
design does not change that, which is what keeps the path naming no explicit
|
||||
push target byte-identical.
|
||||
- Credentials continue to come from the task runtime Git credential route. Read
|
||||
access alone does not authorize publication.
|
||||
|
||||
## Observability
|
||||
|
||||
The existing push completion log gains the resolved remote name, the destination
|
||||
branch, and whether an expected branch was supplied and matched. Each refusal is
|
||||
logged once at its error code. No new metric is introduced: the failure modes are
|
||||
per-request and are already visible to the caller in the result.
|
||||
|
||||
## Prior art
|
||||
|
||||
- **Kandev knowledge vault (`wiki-query`).** Searched for: nothing; the leg did
|
||||
not run. The skill is not installed on this runner (`~/.claude/skills` has no
|
||||
`wiki-*` entry), there is no `~/.obsidian-wiki` directory or config symlink,
|
||||
and `OBSIDIAN_VAULT_PATH` is unset, so neither a QMD collection nor a `grep`
|
||||
fallback resolved. Treat this as missing input, not an empty result.
|
||||
- **`saas-kb` MCP (`search_fsm_docs`, `category: "ai_sdlc"`).** Searched for:
|
||||
nothing; the leg did not run. The server is not configured for this session,
|
||||
in which `kandev` is the only MCP server.
|
||||
- **In-repository prior reasoning.** Read directly:
|
||||
[Empty Remote Repositories](empty-remote-repositories.md), which owns first
|
||||
publication on the `origin` path, and
|
||||
[ADR 2026-08-12 task-bound fork destinations](../../../decisions/2026-08-12-task-bound-fork-destinations.md),
|
||||
which owns contribution-destination routing. This capability departs from
|
||||
neither: it refuses rather than overrides when contribution routing applies,
|
||||
and leaves first publication attached to `origin`.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Task-bound fork destinations](../../../decisions/2026-08-12-task-bound-fork-destinations.md)
|
||||
Reference in New Issue
Block a user