diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f6d06b43..cd5862c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,6 +83,7 @@ jobs:
for test in test_ci_policy test_release_candidate test_request_release test_release_scripts test_check_workflows test_web_e2e_workflow test_check_dependabot test_py39_compat; do
python3 "scripts/$test.py"
done
+ python3 scripts/test_windows_release.py WindowsArchive ReleaseInputs
rust:
name: Linux unit and storage contracts
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 190c3115..c096589e 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -178,6 +178,51 @@ jobs:
if-no-files-found: error
retention-days: 14
+ windows-binaries:
+ name: native Windows archive
+ timeout-minutes: 45
+ if: needs.verify.outputs.needed == 'true'
+ needs: verify
+ runs-on: windows-latest
+ env:
+ VERSION: ${{ needs.verify.outputs.version }}
+ SOURCE_DATE_EPOCH: ${{ needs.verify.outputs.epoch }}
+ RUSTFLAGS: -C target-feature=+crt-static
+ AG_REQUIRE_COMPONENTS: shell:powershell
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
+ with:
+ toolchain: stable
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+ with:
+ key: release-windows
+ cache-workspace-crates: true
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: apps/web/package-lock.json
+ - name: Build the embedded Web UI
+ shell: bash
+ run: scripts/build_ui.sh
+ - name: Build supported Windows binaries with a static CRT
+ run: cargo build --release --locked -p agentgit-cli -p agentgit-server -p agentgit-mcp
+ - name: Test the installer, package and run the native archive
+ shell: pwsh
+ run: |
+ python scripts/test_windows_release.py
+ if ($LASTEXITCODE) { throw 'Windows packaging tests failed.' }
+ ./scripts/smoke_windows_release.ps1 -Version $env:VERSION -BinDir target/release
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: archive-x86_64-pc-windows-msvc
+ path: dist/*.zip
+ if-no-files-found: error
+ retention-days: 14
+
sbom:
timeout-minutes: 15
if: needs.verify.outputs.needed == 'true'
@@ -378,7 +423,7 @@ jobs:
timeout-minutes: 10
if: needs.verify.outputs.needed == 'true'
name: assemble and attest the candidate
- needs: [verify, checks, scripts, binaries, sbom, manifest]
+ needs: [verify, checks, scripts, binaries, windows-binaries, sbom, manifest]
runs-on: ubuntu-latest
env:
PUBLISH: ${{ needs.verify.outputs.publish }}
@@ -403,8 +448,9 @@ jobs:
set -euo pipefail
ls -la dist
set -- dist/*.tar.gz; test $# = 4
+ set -- dist/*.zip; test $# = 1; test -f "$1"
set -- dist/*.cdx.json; test $# = 4
- python3 scripts/package_release.py sums dist/*.tar.gz dist/*.cdx.json | tee dist/SHA256SUMS
+ python3 scripts/package_release.py sums dist/*.tar.gz dist/*.zip dist/*.cdx.json | tee dist/SHA256SUMS
(cd dist && sha256sum -c SHA256SUMS)
python3 scripts/render_packaging.py --version "$VERSION" --sums dist/SHA256SUMS --out dist --only homebrew
ruby -c dist/agentgit.rb
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index bedf9222..4d706f59 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -15,6 +15,7 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
+ AG_REQUIRE_COMPONENTS: shell:powershell
jobs:
windows:
@@ -30,13 +31,22 @@ jobs:
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
cache-workspace-crates: true
- - name: Show the toolchain and Git
+ - name: Show the operating system, toolchain and Git
run: |
+ Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, OSArchitecture | Format-List
rustc -Vv
git --version
git config --show-origin --get-all core.autocrlf
where.exe nasm
continue-on-error: true
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: apps/web/package-lock.json
+ - name: Build the embedded Web UI
+ shell: bash
+ run: scripts/build_ui.sh
- name: Build everything that is portable
run: cargo build --workspace --all-targets
# The execution plane (ag-sandbox, ag-worker) is Linux-only: ag-sandbox is empty here, ag-worker is a
@@ -60,3 +70,29 @@ jobs:
run: |
$env:AG = "$PWD\target\debug\ag.exe"
.\examples\quickstart.ps1
+
+ - name: Windows archive and installer regression tests
+ run: python scripts/test_windows_release.py
+ - name: Installer tests on Windows PowerShell 5.1
+ # Start Python from Windows PowerShell too: inheriting pwsh's PSModulePath
+ # prevents the nested 5.1 process from loading its own utility module.
+ shell: powershell
+ run: |
+ $env:AG_TEST_PWSH = (Get-Command powershell.exe).Source
+ & $env:AG_TEST_PWSH -NoProfile -Command '$PSVersionTable; Get-Command Get-FileHash -ErrorAction Stop | Format-List Name, Source'
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ python scripts/test_windows_release.py
+ - name: Package, install and run the native Windows client
+ shell: pwsh
+ run: |
+ $version = python -c "import pathlib,tomllib; print(tomllib.loads(pathlib.Path('Cargo.toml').read_text())['workspace']['package']['version'])"
+ ./scripts/smoke_windows_release.ps1 -Version $version -BinDir target/debug
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: windows-install-rehearsal
+ path: |
+ dist/*.zip
+ dist/SHA256SUMS
+ dist/install.ps1
+ if-no-files-found: error
+ retention-days: 14
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac240f42..596090bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,46 @@ on-disk formats are versioned and refuse to open when newer than the build under
- Use component-specific fast CI for ordinary commits and cancel superseded work. Run the full platform/backend/browser/security matrix during qualification, reuse one server build for SDK runtimes, and retain compiled workspace caches.
- Separate release preparation from publication. Promote the exact prepared and attested archives/images without rebuilding or repeating the Rust suite, with a three-minute publishing job and two-minute parallel public-install checks. Cold preparation and runner queues remain outside the five-minute ready-candidate publication target.
+### Added
+
+- `ag remote workspace list/show/create/sync/delete` exposes the virtual workspace workflow from the CLI. Explicit resolution files require a pinned stream head and capable server; JSON output retains complete/incomplete structural scopes without choosing paths automatically.
+
+- Browser file/directory conflicts support explicit whole-file or whole-folder choices with bounded complete previews, exact blob/type/mode preservation, visible removed paths and pinned revision checks. Independent clean changes survive; incomplete previews remain blocked.
+
+- Fresh structural sync previews include bounded candidate path scopes and an explicit completeness flag. Clients can account for synthetic rename destinations without guessing or treating a truncated preview as complete.
+
+- The release pipeline prepares a native Windows x64 ZIP with the CLI, server and MCP, plus a per-user PowerShell installer that verifies checksums. Native install-and-first-use smoke is required before publishing; the first Windows archive is not yet released.
+
+- Development builds can export GitHub/GitLab issues and comments to a portable manifest, preview it in Web or CLI, and resume an explicit import without duplicates. Source provenance is retained and unsupported metadata is reported. Schema13 requires a pre-upgrade backup.
+- Home receives live updates across up to thirty loaded repositories through one resumable event feed. CLI, SDKs and MCP expose bounded multi-repository replay with per-repository cursors.
+- Existing-file edits can be saved privately to an account and resumed through Web, CLI, SDK and MCP. Original revision and file metadata are retained; publication stays explicit. Development schema 12 requires a pre-upgrade backup.
+- General proposal comments gain separate private account drafts across Web, CLI, SDK and MCP, with explicit save and version-checked cleanup. Development schema 11 preserves existing review drafts; back up before upgrading.
+- Reviewers can explicitly save a private review draft to their account and resume on another device through Web UI, CLI, MCP or SDKs. Local drafts remain available, conflicting drafts require a choice, and versioned saves/clears never overwrite newer notes or submit a review. Development schema 10 requires a pre-upgrade backup.
+
+- Browser conflict resolution can keep either exact binary, oversized or file-type version while preserving blob bytes, executable mode and symlink type. Virtual sync pins the inspected branch through its final transaction and reports its exact target to SDK/MCP clients.
+
+- Authors can request specific reviewers with an inspected-list update across the API and clients. Requests survive new revisions, never grant access or count as approval, and can be filtered before proposal pagination. Development metadata schema 9 requires a pre-upgrade backup.
+
+- Review suggestions can replace up to 100 consecutive original lines. Range drafts survive reload, previews show the source file, and batches reject overlapping ranges before publication. Existing one-line suggestions keep their original format.
+
+- Authors can preview and apply selected review suggestions across files as one proposal revision. Exact source lines, executable mode, BOM and line endings are preserved; conflicting selections are refused and discussions remain open.
+
+### Fixed
+
+- Release completeness checks require the Windows ZIP for versions newer than beta.7, so an interrupted future upload is repaired instead of being mistaken for a complete release. Historical POSIX-only releases remain recognized.
+
+- Structural workspace sync preserves independent incoming additions, edits and deletions while applying explicit file/directory resolutions. Incomplete choices fail without moving the workspace; local structural sync and legacy pending snapshots refuse unsafe fallback commits.
+
+- Renamed conflict previews now read each version at its exact source path instead of mistaking a relocated file for deletion. CLI, SDK and MCP retain this metadata; the browser also supports explicit rename/delete choices and requires capable servers.
+
+- SDKs retry workspace sync automatically only when a nonempty inspected branch head guards the operation. Unguarded calls require explicit unsafe-retry opt-in.
+
+### Changed
+
+- Installation now prefers a stable release and explicitly falls back to a published prerelease when no stable exists. It prints a runnable local-demo command and safe PATH guidance; both READMEs and the getting-started guide lead with a verified install-to-first-change path.
+
+- The review workspace puts changed code first, keeps Review and Land in the header, and moves secondary actions and context into a quieter side panel. Phone layouts use a compact readiness strip and file navigation. All review, validation and permission states remain available.
+
## [0.3.0-beta.7] - 2026-10-29
**Upgrade:** metadata schema 8 and control schema are unchanged from beta.6. Back up before upgrading. Returning to beta.5 or older requires the pre-upgrade backup.
diff --git a/Cargo.lock b/Cargo.lock
index b4592097..40b3eee4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -85,8 +85,11 @@ dependencies = [
"serde_json",
"sha2",
"tempfile",
+ "time",
"tokio",
"tonic",
+ "ureq",
+ "url",
]
[[package]]
diff --git a/README.md b/README.md
index fc6c971c..ed7a5689 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,154 @@
-
+# AgentGit
-
+**Source control for people and coding agents.**
-
+Give agents bounded work, review the exact result, and land changes under rules your repository enforces.
+AgentGit brings a local CLI, a self-hosted Web UI, agent tools and CI together around one review workflow.
[](https://github.com/Paffin/AgentGit/actions/workflows/ci.yml)
-[](docs/README.md)
+[](https://github.com/Paffin/AgentGit/releases)
[](LICENSE)
-[](#status-and-limits)
-**English** · [Русский](README.ru.md) · [Docs](docs/README.md) · [Roadmap](ROADMAP.md) · [Comparison](docs/comparison.md)
+[Get started](#try-agentgit) · [Documentation](docs/README.md) · [Roadmap](ROADMAP.md) · [Русский](README.ru.md)
-
+## Try AgentGit
-
+Install the published **v0.3.0-beta.7** for Linux (glibc) or macOS, then open a ready-to-use demo.
+No Rust toolchain, Node.js or database setup is needed.
-AgentGit is a version control system for repositories that people, coding agents and CI all write to.
+```sh
+curl -fsSL https://raw.githubusercontent.com/Paffin/AgentGit/main/scripts/install.sh | sh -s -- --version 0.3.0-beta.7
+export PATH="$HOME/.local/bin:$PATH"
+ag-server dev --open
+```
+
+The installer verifies the archive checksum and puts the programs in `~/.local/bin`. The server creates a demo
+repository, opens the browser and signs you in. If a browser cannot open automatically, use the link it prints.
+Keep the terminal running; stop with **Ctrl-C**. Restarting preserves the demo data in `./ag-dev-data`.
+
+**In the browser:** open a file, choose **Edit**, propose your change, then review and land it.
+The demo's rules protect `SECURITY.md`, so changing that file shows how a required review blocks landing.
-A change lands only when the repository's policy says it may, and the policy is checked in the same transaction that
-moves the branch, so nothing can change between "checked" and "merged". Test results are signed and tied to the exact
-tree they ran on. The final transaction also rechecks the proposal head and open state. Every agent run has its own identity, and every decision ends up in an audit log you can verify.
-The core that stores, merges and recovers never calls a language model; anything smart sits on top and can only propose.
+This demo listens on your machine only and uses temporary development credentials. For a shared server, follow
+[Deployment](docs/operations/deployment.md). For Windows, containers, source builds, a custom install directory or
+manual download verification, see [Install options](docs/getting-started.md#install).
-This is an early public beta: [status and limits](#status-and-limits) are below, and nobody but its authors has run it in production.
+## Your first change from the terminal
-**Available in the current beta:** repository issues with comments, close/reopen, conflict-safe edits and an atomic handoff to agent tasks with an expected result and explicit file scope. See the [Web UI guide](docs/guides/web-ui.md#repository-issues). Labels, milestones, attachments and provider issue import remain unfinished. Operators can pause and resume new task-bound runs without interrupting existing sessions, and inspect current or historical runs through permanent links. Task creators can reserve a registered agent at creation or reassign queued work; the server and orchestrator enforce the choice. A registered agent still needs a configured executor. The registered-agent view shows expiring executor connection reports and last reported capacity. Its setup wizard prepares repository-scoped credentials and a task executor configuration; host installation remains a separate step. Optional bounded executor output can be viewed, downloaded and deleted from a run page. The current release uses metadata schema 8; take a database backup before upgrading. Older binaries require restoring the pre-upgrade backup.
+With the demo server running, open another terminal in the same starting directory:
-**Review in the current beta:** revision-bound line discussions, replies and conflict-safe resolution are shared by Web UI, CLI and MCP. Filter open or resolved threads without losing reply drafts; source anchors retain the exact reviewed revision. CLI JSON response shapes are documented for automation. [Upgrade and rollback guidance](docs/adr/0025-line-review-threads.md).
+```sh
+export PATH="$HOME/.local/bin:$PATH"
+ag login http://127.0.0.1:8080 --token-file ./ag-dev-data/dev/token
+ag clone http://127.0.0.1:8080/demo/welcome
+cd welcome
+printf '\nA change from AgentGit.\n' >> README.md
+ag snapshot -m "Update the readme"
+ag push -t "Update the readme"
+```
-**Available since beta.6:** operators can disable or re-enable new runs for an individual agent from the browser or `ag agent disable NAME --remote origin` (`enable` to restore admission). Existing sessions, keys and history are retained. This is included in beta.6 and newer; see [ADR-0026](docs/adr/0026-agent-run-admission.md).
+`ag push` prints the proposal ID and opens the change for review. To land it, use the returned ID:
-**Available since beta.6:** Tasks can carry a server-enforced attempt limit. `ag task attempts TASK --max 5 --expected 3` lets an operator extend an exhausted task without losing its history; restart recovery uses durable run records. Zero keeps the existing repository retry policy. Concurrent operator policy changes are protected from stale automatic cancellation, and late results cannot finish a newer task attempt. [Execution limits](docs/adr/0033-task-attempt-limits.md).
+```sh
+ag remote integrate PROPOSAL_ID
+ag pull
+```
-**Upgrade:** persistent follow-up task links and durable task execution limits require metadata schema 8. Back up before opening existing repositories with beta.6 or newer; rollback uses the pre-upgrade backup. [Source-link semantics](docs/adr/0027-task-source-links.md).
+If policy blocks a change, `ag remote explain PROPOSAL_ID` names what is missing and the commands that address it.
+The [guided walkthrough](docs/getting-started.md) covers review, agent access and policy rules.
-Stream rules can require every line discussion to be resolved before integration, including feedback on earlier revisions.
+## One workflow, four clear answers
-Review threads support proposed line replacements, with explicit author preview and head-checked application.
+A **snapshot** records your work. A **proposal** publishes it for review. **Integration** lands the checked result
+on a **stream** (the shared branch). Each proposal answers four independent questions:
-Rules also expose protected-path approvers and check timing without editing JSON; runner settings survive form edits.
+| | Question |
+|---|---|
+| **Mergeable** | Can this change combine with the current branch? |
+| **Validated** | Did trusted checks pass on the exact result that would land? |
+| **Approved** | Did eligible reviewers approve the revision being landed? |
+| **Integrated** | Has the shared branch been updated? |
-## Coming from GitHub or GitLab?
+Passing tests does not imply approval. Approval of an old revision does not approve a new one. The server checks
+policy in the transaction that updates the branch; moving code cannot slip between the check and the update.
-Open **Repositories → Import from GitHub / GitLab** to upload a full Git bundle, inspect the branch/tag mapping and publish the verified result. Or run `ag migrate repository.bundle ./project --namespace team/project`. The source stays unchanged, and its original bundle is retained in backups. [Migration guide and metadata limitations](docs/guides/migrate-from-git.md).
+
-The beta also supports keyboard-driven file finding, recoverable file drafts, and review drafts that stay while you inspect the diff. Web reviews are bound to the exact head you inspected; an author update rejects a stale submission and preserves your text. These changes are included in beta.2 and documented in the [changelog](CHANGELOG.md) and [Web UI guide](docs/guides/web-ui.md).
+*Development preview: the simplified review workspace above is not yet in beta.7. Published-release details and
+unreleased work are kept separate in the [changelog](CHANGELOG.md) and [roadmap](ROADMAP.md).*
-Beta.6 and newer also support editing task instructions before the first run through Web UI, CLI and API, with concurrent-edit protection. This is included in beta.6 and newer. [Task editing](docs/guides/web-ui.md#refine-queued-task-instructions-development).
+## Use the tools you already work with
+
+| Start here | What it gives you |
+|---|---|
+| [Web UI](docs/guides/web-ui.md) | Browse code, edit files, review diffs, discuss changes and manage work. |
+| [CLI](docs/guides/remote.md) | Local repositories, offline snapshots and the same server workflow from your terminal. |
+| [MCP](docs/guides/mcp.md) | Let an agent read, propose and review through explicitly enabled tools. |
+| [TypeScript and Python SDKs](docs/guides/sdks.md) | Build integrations with typed clients for the server API. |
+| [Agents and task execution](docs/guides/agents.md) | Assign scoped work, track runs and control who may land their results. |
+| [CI and workers](docs/guides/validation.md) | Produce signed validation results for the exact candidate tree. |
+
+Agent runs have distinct identities. Stream rules control required checks, reviewers, protected paths and how much
+an agent may change autonomously. An append-only audit trail records decisions. The core merge and policy engine
+does not call a language model. [Concepts](docs/concepts.md) explains the model; [Security](docs/architecture/security.md)
+explains its trust boundaries.
+
+## Bring an existing repository
+
+Create a complete Git bundle, then choose **Repositories → Import from GitHub / GitLab** in the Web UI.
+You can also import locally:
+
+```sh
+git bundle create repository.bundle --all
+ag migrate repository.bundle ./project --namespace team/project
+```
+
+The import previews branch and tag mappings and retains the original bundle. Git export gives you a way back.
+Code and history migrate; issues, comments, pull-request metadata, LFS payloads and CI setup need separate handling.
+Read the [migration guide](docs/guides/migrate-from-git.md) before moving a team.
+
+AgentGit is an early alternative for teams experimenting with policy-controlled agent work. GitHub and GitLab have
+much broader hosting, project management and production experience. [Compare the trade-offs](docs/comparison.md).
+
+## Status and limits
-Home keeps queue filters in bookmarkable links and can follow a selected repository live; event bursts share one refresh, while the aggregate view retains bounded polling.
+**Early public beta.** The current release is [v0.3.0-beta.7](https://github.com/Paffin/AgentGit/releases/tag/v0.3.0-beta.7).
+There is no stable release or published crates.io, npm or PyPI package yet. Native release archives cover Linux and
+macOS on x86_64 and ARM64; the worker sandbox is Linux-only. Windows builds are tested in CI, but a native Windows
+release archive is not yet shipped.
-Next priorities are complete review workflows and hosting-metadata migration.
-The [roadmap](ROADMAP.md#next) separates delivered features, published releases and remaining qualification.
+**Back up before upgrading.** Beta.7 uses metadata schema **8**. The unreleased development code uses schema **13**,
+adding requested reviewers and private account-backed review, comment and single-file drafts, plus resumable issue imports. Opening a repository with a development build
+can upgrade it; returning to an older binary requires restoring the pre-upgrade backup. See
+[backup and recovery](docs/operations/durability.md) and [release notes](CHANGELOG.md).
+
+**Use it with recoverable data first.** External production qualification and an independent security audit remain
+open. Local mode trusts whoever can write the repository directory. For untrusted clients use the server, and for
+untrusted checks use the [worker sandbox](docs/guides/workers.md), not `ag validate run` on your own shell.
+Keep backups and a Git mirror for data you cannot lose. [Known limits](ROADMAP.md) include hosting-metadata migration,
+LFS and submodules, and qualification on real installations and assistive technology.
+
+## Build and contribute
+
+To work on the CLI, install Rust 1.89 or newer and a C compiler, then:
+
+```sh
+git clone https://github.com/Paffin/AgentGit.git
+cd AgentGit
+cargo install --path crates/ag-cli --locked
+```
+
+See [Contributing](CONTRIBUTING.md) for the server, Web UI, checks and repository conventions.
+For architecture and correctness evidence, start with [the overview](docs/architecture/overview.md),
+[the test strategy](docs/architecture/testing.md) and [the decision records](docs/adr/README.md).
+
+
+A policy-controlled change, from proposal to integration
## See it work
-A person, two agents and a CI service share one stream. The output is trimmed from
-[`examples/quickstart.sh`](examples/quickstart.sh), which CI runs on every push and compares line by line with this block.
+This transcript comes from [`examples/quickstart.sh`](examples/quickstart.sh), which CI runs against the CLI.
+It shows an agent being denied, a person being blocked by missing evidence, and the checked change landing:
```text
$ ag integrate prop_6eed… # as agent:refactor-bot — an agent tries to land code on its own
@@ -82,269 +174,19 @@ strategy: Merge (algorithm ag-merge/3+ag-diff/1)
conflict: src/main.rs (content conflict (1 region(s))) # exit code 4 — the stream was not touched
```
-Every refusal says what is missing. Exit code `3` means blocked (supply what is missing and run it again), `4` means refused.
-
-## Install
-
-You need Rust 1.89 or newer and a C compiler (SQLite is built in). Linux, macOS and Windows are built and tested in CI;
-the sandbox and `ag-worker` are Linux-only ([Windows](docs/operations/windows.md)).
-
-```bash
-git clone https://github.com/Paffin/AgentGit && cd AgentGit
-cargo install --path crates/ag-cli # installs the `ag` binary
-```
-
-The current release target is [`v0.3.0-beta.7`](https://github.com/Paffin/AgentGit/releases/tag/v0.3.0-beta.7). Use the following archives and install commands after its Release workflow completes. Archives contain `ag`, `ag-server`, `ag-worker`, `ag-mcp` and `orchestrator` for Linux and macOS; release verification is tracked by the [Release workflow](https://github.com/Paffin/AgentGit/actions/workflows/release.yml).
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/Paffin/AgentGit/main/scripts/install.sh | sh -s -- --version 0.3.0-beta.7
-docker pull ghcr.io/paffin/agentgit-cli:0.3.0-beta.7
-```
-
-`--version` is needed while only pre-releases exist. For an existing rc.2 installation, see the [previous rc.2 procedure](docs/getting-started.md#on-020-rc2-until-the-next-release). The [rc.2 verification record](docs/operations/releasing.md) describes that release's checks and limitations; it is not evidence for rc.3. [Install options](docs/getting-started.md#install) has the details.
-
-## Quick start
-
-```bash
-mkdir demo && cd demo
-ag init --namespace me/demo # creates .ag/ and the stream "main"
-echo "# demo" > README.md
-ag snapshot -m "first" # record the directory (not yet visible to others)
-ag propose -t "Initial import" # verify, publish and open a proposal
-ag integrate # move "main" atomically; any unique id prefix works
-ag log
-```
-
-Next, add a [policy](docs/guides/policy.md), start an [agent](docs/guides/agents.md), or run the whole story with
-`./examples/quickstart.sh`. The full walkthrough is [Getting started](docs/getting-started.md).
+Exit code `3` means blocked: supply the missing evidence and retry. Exit code `4` means refused.
-### Five minutes with a server, nothing to build
-
-```bash
-ag-server dev # data directory, administrator, demo repository, a sign-in link
-ag login http://127.0.0.1:8080 --token-file ./ag-dev-data/dev/token
-ag clone http://127.0.0.1:8080/demo/welcome # edit, `ag snapshot`, `ag push -t "my change"`, `ag explain` if refused
-```
-
-`ag-server dev` prints a link that signs you in to the Web UI and the exact commands above. It is an evaluation mode
-(loopback only, plain HTTP, an 8-hour credential), not a deployment. It is included in `v0.3.0-beta.7`. For the previous `v0.2.0-rc.2` archive, [Getting started](docs/getting-started.md#on-020-rc2-until-the-next-release) has the same
-start on rc.2 in a few more lines.
-
-### As a server
-
-```bash
-cargo install --path crates/ag-server
-export AG_BOOTSTRAP_TOKEN=$(openssl rand -hex 32) # the first administrator's credential
-ag-server admin --data-dir ./ag-data create-repo acme/web
-ag-server admin --data-dir ./ag-data grant --subject user:alice --repo 'acme/*' --role admin
-export AG_TOKEN=$(ag-server admin --data-dir ./ag-data mint --subject user:alice --ttl 86400 | tail -n 1)
-ag-server serve --data-dir ./ag-data & # 127.0.0.1:8080, gRPC + HTTP/JSON
-ag clone http://127.0.0.1:8080/acme/web web && cd web # then edit, `ag snapshot`, `ag push -t "my change"`
-```
-
-The server speaks one protocol, [AGWP v1](docs/protocol/agwp-v1.md), over gRPC and HTTP/JSON on a single port, and serves
-the [Web UI](docs/guides/web-ui.md) on it. Compose, Kubernetes, TLS and PostgreSQL with S3 are covered in
-[Deployment](docs/operations/deployment.md).
-
-## What it does
-
-- Publishes a change in one verified transaction and moves the stream head by compare-and-swap. A crash at any step
- leaves exactly one outcome; failures are injected at every protocol step in the tests.
-- Merges with a pure, versioned function of base, ours and theirs. Conflicts are typed results reported before anything
- changes, and a file renamed on one side and edited on the other merges cleanly.
-- Binds validation to the candidate tree. An attestation names the tree, the policy version and the environment, and is
- signed by an issuer the policy trusts. When the stream moves, the old result stops counting.
-- Keeps a policy per stream: required checks, approvals bound to the reviewed head, protected paths, and an autonomy
- envelope that says which paths, and how many files, an agent may land without a person.
-- Coordinates agents: an identity per run, tasks, soft and exclusive leases with fencing tokens, conflict prediction and
- an ordered integration queue.
-- Runs untrusted checks in `ag-worker` inside a sandbox (rootless namespaces or a container) and signs the result.
-- Records an append-only reflog and a hash-chained audit log; `ag verify` checks the whole repository.
-- Imports from and exports to Git with identical tree SHAs, and works fully offline.
-- Ships a [Web UI](docs/guides/web-ui.md), [TypeScript and Python SDKs](docs/guides/sdks.md), an
- [MCP server](docs/guides/mcp.md) that exposes AgentGit to an agent as tools, and a Go
- [agent platform](docs/guides/orchestrator.md) that can propose changes but never integrate them.
-
-## How it compares
-
-GitHub, GitLab and Bitbucket are mature hosting platforms with broader project management,
-hosted CI, security scanning, permissions at scale and support. AgentGit has repository issues and
-agent-task handoff; labels, milestones, attachments and hosting-metadata import remain open. The table covers only the question AgentGit is built around:
-how a change lands when several untrusted actors write to one codebase. Cells about the platforms describe their
-public documentation; the page linked below has the sources, the access date and the much longer list of what they do better.
-
-| | GitHub | GitLab | Bitbucket | AgentGit |
-|---|---|---|---|---|
-| Unit of review | pull request | merge request | pull request | proposal |
-| Where merge rules are enforced | the platform: branch protection and rulesets | the platform: protected branches, approval rules, push rules (several need Premium) | the platform: branch permissions, merge checks (enforced on Premium in Cloud) | the stream's policy, evaluated in the transaction that moves the head |
-| A passing check is bound to the exact tree that lands | in the merge queue, which tests a temporary merge of the queue; otherwise to a commit | with merged results pipelines and merge trains (Premium); otherwise to a pipeline | required builds on the head commit; merge queues build a merge result (paid plans, Pipelines) | always: a signed attestation names the tree, policy version and environment |
-| Identity of automated actors | accounts, GitHub Apps, tokens | service accounts, access tokens | accounts, tokens | an identity per agent and per run; credentials can be bound to one run |
-| Policy-bound autonomy for agents | built from branch rules and bot permissions | built from the same pieces | built from the same pieces | built in: allowed paths and a file limit per stream |
-| Tamper-evident audit | an audit log with export; the docs make no integrity claim | audit events (Ultimate); the docs make no integrity claim | an audit log; not compared | hash-chained, checked by `ag audit` and `ag verify` |
-| Self-contained and offline | hosted; Enterprise Server for self-managed | SaaS or self-managed | Cloud or Data Center | local mode needs no server; the server is one binary |
-| Ecosystem | very large | very large, CI and security built in | large, Jira and Pipelines | small: CLI, SDKs (unpublished), MCP, Web UI |
-| Maturity | very high | very high | very high | beta |
-
-The full comparison, including Git, Gerrit and Jujutsu: [AgentGit compared](docs/comparison.md).
-
-## The Web UI
-
-A new user starts on a home page that says what needs attention, with plain labels (the AgentGit word is in a tooltip),
-blockers that come with their fix as a button, guided flows to start a repository or give an agent work, rules presets
-instead of raw JSON, and editing a file and proposing it from the browser. These are included in beta.2.
-What is still missing is on the [roadmap](ROADMAP.md).
-
-Beta.3 adds file-by-file review progress, reload recovery for review drafts, permanent task pages with prerequisite management and confirmed completion/cancellation, and server-side code search with links to matching lines. See the [changelog](CHANGELOG.md) for details and limitations.
-
-A live board of runs, leases, tasks and the merge queue, with four independent verdicts per proposal. These are real
-screenshots of the UI running against a real server, captured by the end-to-end suite.
-
-
-
-
-
-
-
-
-
-
-
-
- A proposal: four verdicts, what blocks it, the diff
-
-
-
- Policy: versioned, append-only, with diffs
-
-
-
- Blame: line-level, follows renames
-
-
-
-
-## How it works
-
-```mermaid
-flowchart LR
- W["workspace snapshots"] -->|propose| P["publish verify every object, register in ONE transaction"]
- P --> Q["proposal reviews bound to the head"]
- Q -->|integrate| C["candidate fast-forward or deterministic merge"]
- C --> G{"policy, in the committing transaction"}
- G -->|allowed| S[("stream head compare-and-swap + reflog + audit")]
- G -->|blocked / denied| Q
- C -.->|conflict| W
-
- classDef step fill:#e8eeff,stroke:#5b6cff,color:#1b2150
- classDef gate fill:#fff4d6,stroke:#c99a1e,color:#4a3508
- classDef done fill:#dcf5e8,stroke:#1f9d67,color:#0f3d2e
- class W,P,Q,C step
- class G gate
- class S done
-```
+
-A proposal has four verdicts. They are independent: none implies another, and each changes on its own.
+## Documentation and community
-| Verdict | Question | Decided by | Changes when |
-|---|---|---|---|
-| Mergeable | Is there one unambiguous result tree? | the merge engine (`ag preview`) | a change on either side leaves no single result |
-| Validated | Did trusted checks pass on that tree? | signed attestations | the candidate tree or the policy changes |
-| Approved | Did enough eligible people approve that head? | reviews and policy | the author pushes again, or the policy's bar moves |
-| Integrated | Was the stream updated atomically? | one compare-and-swap transaction | never; it is a fact in history |
-
-
-
-
-
-
-
-
-
-Three trust boundaries matter more than the boxes. The core decides without a model, a clock or randomness. The engine runs
-inside the server, so policy is enforced where untrusted clients connect. Untrusted code runs only in a separate
-execution plane, which can report a result but never decides whether it counts. Read more in
-[Concepts](docs/concepts.md), the [architecture overview](docs/architecture/overview.md) and
-[publication and integration](docs/architecture/publication-and-integration.md).
-
-## Guarantees and their tests
-
-Each row names a test in this repository. More than 1,000 Rust tests run in CI, together with the SDK, Web UI and
-orchestrator suites against a real `ag-server`, fuzzing, coverage and mutation gates ([Testing](docs/architecture/testing.md)).
-
-| Guarantee | Test |
-|---|---|
-| A crash at any step of publication or integration recovers to exactly one update | `crash_at_every_step_recovers_to_exactly_one_integration` |
-| No lost update under parallel integrators | `concurrent_integrations_never_lose_an_update` |
-| A lost response never causes a double apply | `idempotent_replay_and_unknown_outcome_recovery` |
-| A check on an old candidate is not reused after the stream moves | `attestation_cannot_be_reused_after_the_stream_moves` |
-| A stale lock holder cannot land into a scope someone else now holds | `fencing_tokens_are_strictly_increasing_and_reject_stale_holders` |
-| Byte formats match an independent Python implementation | `tests/conformance/` |
-| Git history round-trips with identical tree SHAs | `history_survives_a_round_trip_with_identical_git_trees` |
-| Tampering with the audit log or objects is detected | `audit_chain_detects_tampering` |
-
-The threat model and its known limits are in [Security](docs/architecture/security.md).
-
-## Documentation
-
-| | |
-|---|---|
-| Start | [Getting started](docs/getting-started.md), [tutorials](docs/tutorials/README.md), [concepts](docs/concepts.md), [FAQ](docs/faq.md), [comparison](docs/comparison.md) |
-| Guides | [agents](docs/guides/agents.md), [policy](docs/guides/policy.md), [validation](docs/guides/validation.md), [workers](docs/guides/workers.md), [remote](docs/guides/remote.md), [history](docs/guides/history.md), [Git bridge](docs/guides/git-bridge.md) |
-| Clients | [Web UI](docs/guides/web-ui.md), [SDKs](docs/guides/sdks.md), [MCP](docs/guides/mcp.md), [orchestrator](docs/guides/orchestrator.md) |
-| Reference | [CLI](docs/reference/cli.md), [configuration](docs/reference/configuration.md), [AGWP v1](docs/protocol/agwp-v1.md), [OpenAPI](docs/protocol/openapi.json), [object formats](docs/architecture/object-format.md) |
-| Operations | [deployment](docs/operations/deployment.md), [high availability](docs/operations/high-availability.md), [observability](docs/operations/observability.md), [backup](docs/operations/durability.md), [hardening](docs/operations/hardening.md), [troubleshooting](docs/operations/troubleshooting.md) |
-| Design | [architecture](docs/architecture/overview.md), [ADRs](docs/adr/), [ADR traceability](docs/architecture/adr-traceability.md), [security](docs/architecture/security.md), [sandbox](docs/architecture/sandbox.md) |
-
-The docs are checked in CI: the CLI reference is generated from the binary, tutorial commands are run against real
-binaries, and `scripts/check_docs.py` verifies links, test names, `ag` commands and `AG_*` variables. The site builds with
-`mkdocs build --strict` and deploys to GitHub Pages from `main`.
-
-## Status and limits
+- [Getting started](docs/getting-started.md) · [Tutorials](docs/tutorials/README.md) · [FAQ](docs/faq.md)
+- [Policy](docs/guides/policy.md) · [CLI reference](docs/reference/cli.md) · [Configuration](docs/reference/configuration.md)
+- [Deployment](docs/operations/deployment.md) · [Troubleshooting](docs/operations/troubleshooting.md) · [API](docs/protocol/agwp-v1.md)
+- [Join the beta](docs/community/start-here.md) · [Report an issue](https://github.com/Paffin/AgentGit/issues) · [Roadmap](ROADMAP.md)
-AgentGit is an early public beta for a single Linux server with SQLite and filesystem storage. The core and the guarantees above are implemented and tested, and so is the platform around it. It has
-not been run in production by anyone but its authors, and there has been no external security audit. The full list is in
-[ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md); the ones that decide whether you can use it today:
-
-- Local mode trusts the local user. Identities are labels, and anyone who can write `.ag/` can bypass policy; tampering
- is detectable with `ag verify`, not prevented. Against clients you do not control, run `ag-server`.
-- Several server replicas on one PostgreSQL and one S3 bucket are tested with real processes in CI. A PostgreSQL
- failover, mixed versions during a rolling upgrade, load, and the Kubernetes `ha` overlay on a real cluster are not
- proven ([high availability](docs/operations/high-availability.md)).
-- Credentials can be bound to one agent run ([ADR-0010](docs/adr/0010-run-bound-credentials.md)), but that is opt-in
- per client; acting on another agent's run needs `agent.operate`, which a plain writer does not have. Fencing tokens for
- job claims are optional per request ([ADR-0012](docs/adr/0012-job-claim-fencing.md)).
-- Only prereleases are available; the current beta is `v0.3.0-beta.7`. No stable release, and no
- crates.io, npm or PyPI package; the 21 `agentgit-` crates are only dry-run
- ([releasing](docs/operations/releasing.md)). Metadata schema 8 requires a pre-upgrade backup; rollback restores that backup with the older binary.
-- `ag validate run` is not a sandbox. Untrusted code goes through [`ag-worker`](docs/guides/workers.md), whose sandbox
- shares the host kernel. Verified against real Docker and Podman and cgroup v2; not verified for microVMs, Windows
- (the worker refuses to run) or macOS beyond a limits-only fallback ([sandbox](docs/architecture/sandbox.md)).
-- PostgreSQL and S3 are tested against a real PostgreSQL and SeaweedFS, not against Amazon S3, MinIO, Ceph or R2.
-- Trees are flat manifests, measured at 100,000 files ([ADR-0006](docs/adr/0006-flat-trees-measured.md)). Performance
- numbers come from one shared 4-vCPU VM and are not targets ([engine](docs/architecture/performance.md),
- [server](docs/operations/performance.md)); PostgreSQL, S3, TLS and multi-replica throughput are not measured. The decoded-tree
- cache and the 3 durable commits per integration were measured on the memory and disk backends only.
-- No history rewriting, tags, submodules or LFS; the Git bridge reports what it cannot carry.
-- The Web UI is tested in Chromium, Firefox and WebKit; OIDC only against Keycloak. There has been no screen-reader pass.
- `ag-mcp` was not run inside real hosts. The SDKs are unpublished.
-- Nightly fuzzing and weekly mutation checks run in GitHub Actions. Inspect the results on the release commit; a successful coverage run alone does not establish passing mutation checks.
-
-Reasonable today: a team and its agents on one machine, one server or a few replicas, with CI-gated workflows, or
-experimenting with policy-bound autonomy. Not yet: hosting mutually untrusting tenants, or being the only copy of data you
-cannot lose. Keep `ag backup` copies and a Git mirror.
-
-## Try the beta workflow
-
-The public beta is open to small teams: see the
-[scope and release gates](docs/community/beta.md), [start here](docs/community/start-here.md), and the
-[beta milestone](https://github.com/Paffin/AgentGit/milestone/1). Installation problems and confusing workflows are useful feedback.
-
-## Contributing
-
-See [CONTRIBUTING.md](CONTRIBUTING.md). Changes to formats, hashing, merge semantics or the publication protocol start
-with an ADR. Report security issues as described in [SECURITY.md](SECURITY.md). Everyone taking part follows the
-[Code of Conduct](CODE_OF_CONDUCT.md).
+Report vulnerabilities through [Security](SECURITY.md). Everyone taking part follows the [Code of Conduct](CODE_OF_CONDUCT.md).
## License
-Apache License 2.0, see [LICENSE](LICENSE). Copyright 2026 The AgentGit Authors.
+[Apache 2.0](LICENSE). Copyright 2026 The AgentGit Authors.
diff --git a/README.ru.md b/README.ru.md
index 485bf48f..8664b558 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -1,365 +1,161 @@
-
+# AgentGit
-
+**Контроль версий для людей и программирующих агентов.**
-
+Поручайте агентам работу с явными границами, проверяйте результат и принимайте изменения по правилам репозитория.
+AgentGit объединяет локальную CLI, собственный Web UI, инструменты агентов и CI в одном процессе ревью.
[](https://github.com/Paffin/AgentGit/actions/workflows/ci.yml)
-[](docs/README.md)
+[](https://github.com/Paffin/AgentGit/releases)
[](LICENSE)
-[](#статус-и-ограничения)
-[English](README.md) · **Русский** · [Документация](docs/README.md) · [Roadmap](ROADMAP.md) · [Сравнение](docs/comparison.md)
+[Начать](#попробовать-agentgit) · [Документация](docs/README.md) · [Roadmap](ROADMAP.md) · [English](README.md)
-
+## Попробовать AgentGit
-
+Установите опубликованную **v0.3.0-beta.7** для Linux (glibc) или macOS и откройте готовый деморепозиторий.
+Rust, Node.js и отдельная настройка базы данных не нужны.
-> Документация в `docs/` написана на английском; ADR-0001 (исходное архитектурное решение) на русском:
-> [`docs/adr/0001-final-architecture.md`](docs/adr/0001-final-architecture.md).
-
-AgentGit — система контроля версий для репозиториев, в которые пишут люди, coding-агенты и CI.
-
-Изменение попадает в ветку, только если это разрешает политика репозитория, и политика проверяется в той же транзакции,
-которая двигает ветку, поэтому между «проверили» и «влили» ничего не успевает измениться. Результаты тестов подписаны и
-привязаны к тому дереву, на котором они запускались. Финальная транзакция также проверяет head и открытое состояние предложения. У каждого запуска агента своя идентичность, а каждое решение
-попадает в журнал аудита, который можно проверить. Ядро, которое хранит, сливает и восстанавливает данные, никогда не
-обращается к языковой модели; всё «умное» живёт поверх него и может только предлагать.
-
-Открытая ранняя бета: [статус и ограничения](#статус-и-ограничения) ниже, и в продакшене его не запускал никто,
-кроме авторов.
-
-**В текущей beta:** задачи из issues, выбор исполнителя, подключение оркестратора через Web UI, управление запуском и постоянные страницы запусков. Сохранение ограниченного вывода stdout/stderr включается отдельно; известные секреты маскируются, запись можно удалить. Для обновления потребуется резервная копия: используется схема метаданных 8. Для отката к старому бинарнику восстановите резервную копию до обновления.
-
-Установка исполнителя и доступ к модели остаются задачами владельца его хоста. Labels, milestones, вложения и импорт обсуждений пока не готовы. [Руководство Web UI](docs/guides/web-ui.md#repository-issues).
-
-**Ревью в текущей beta:** обсуждения по строкам доступны в Web UI, CLI и MCP. Ответы и разрешение замечаний сохраняются на сервере; фильтры открытых и решённых обсуждений не теряют черновики. Привязки сохраняют точную просмотренную ревизию. JSON-контракты CLI документированы для автоматизации. [Обновление и откат](docs/adr/0025-line-review-threads.md).
-
-**Доступно с beta.6:** оператор может отключить или снова разрешить новые запуски отдельного агента из браузера или через `ag agent disable NAME --remote origin` (`enable` для включения). Ключ, история и работающие сессии сохраняются. Функция включена в beta.6 и новее; [ADR-0026](docs/adr/0026-agent-run-admission.md).
-
-**Доступно с beta.6:** Сервер ограничивает число запусков каждой задачи, сохраняя счётчик через историю запусков при перезапуске. Оператор может продолжить исчерпанную задачу командой `ag task attempts TASK --max 5 --expected 3`, не теряя её историю. Нулевой лимит сохраняет прежнюю политику повторов репозитория. Изменение лимита во время запуска защищено от отмены по устаревшей политике; запоздавший результат не завершит более новую попытку. [Лимиты исполнения](docs/adr/0033-task-attempt-limits.md).
-
-**Обновление:** связи задач-продолжений и постоянные лимиты исполнения используют схему метаданных 8. Перед запуском beta.6 или новее на существующих данных сделайте резервную копию; для отката восстановите её. [Семантика связей](docs/adr/0027-task-source-links.md).
-
-В beta.6 и новее доступно редактирование инструкций задачи до первого запуска через Web UI, CLI и API с защитой от одновременных правок. Функция включена в beta.6 и новее. [Редактирование задач](docs/guides/web-ui.md#refine-queued-task-instructions-development).
-
-Главная страница сохраняет фильтры очереди в ссылке и обновляет выбранный репозиторий по событиям; серия событий объединяется в одно обновление, общий обзор сохраняет периодический опрос.
-
-Ближайшие приоритеты — завершение сценариев ревью и перенос метаданных GitHub/GitLab. [Roadmap](ROADMAP.md#next) разделяет готовые возможности, опубликованные выпуски и оставшуюся квалификацию.
-
-В development-версии форма правил также позволяет менять одобряющих для защищённых путей и сроки проверок без редактирования JSON, сохраняя настройки исполнителя.
-
-В development-версии разрешение конфликтов проверяет исходный head change request: параллельные изменения отклоняются вместо перезаписи.
-
-В development-версии рецензент может предложить замену строки, а автор — просмотреть её и применить с проверкой исходной ревизии.
-В development-версии правила ветки могут требовать разрешения всех построчных обсуждений перед интеграцией, включая обсуждения прежних ревизий.
-
-## Переезд с GitHub или GitLab
-
-В **Repositories → Import from GitHub / GitLab** загрузите полный Git bundle, проверьте соответствие веток и тегов и подтвердите публикацию. Либо выполните `ag migrate repository.bundle ./project --namespace team/project`. Источник не изменяется, оригинальный bundle сохраняется в резервных копиях. [Руководство и ограничения переноса метаданных](docs/guides/migrate-from-git.md).
-
-В бета-версии доступны поиск файлов с клавиатуры, восстановление файловых черновиков и сохранение текста ревью при просмотре diff. Веб-ревью привязано к просмотренному head: если автор изменил код, сервер отклонит устаревшую отправку, сохранив ваш текст. Эти изменения включены в beta.2 и отражены в [журнале изменений](CHANGELOG.md) и [руководстве Web UI](docs/guides/web-ui.md).
-
-В beta.3 добавлены отметки просмотренных файлов, восстановление черновика ревью после перезагрузки, отдельные страницы задач с зависимостями и подтверждением завершения или отмены, а также поиск по содержимому с переходом к найденной строке. Возможности и ограничения описаны в [журнале изменений](CHANGELOG.md).
-
-## Как это выглядит
-
-Человек, два агента и CI-сервис работают с одним потоком. Вывод сокращён из
-[`examples/quickstart.sh`](examples/quickstart.sh): CI запускает этот сценарий при каждом push и построчно сверяет его
-с английским README.
-
-```text
-$ ag integrate prop_6eed… # as agent:refactor-bot — агент пытается сам влить код
-Denied by policy; the stream was not changed:
- - actor not allowed to integrate: agents may not integrate unless agent_autonomy.auto_integrate is on …
-
-$ ag integrate prop_6eed… # as user:alice
-Blocked (policy v1); the stream was not changed:
- - check 'tests' has no trusted passing attestation for this exact tree
- - approvals (default): 0 of 1 required
- - agent-authored change has no signed provenance
- - exclusive lease on 'src/**' held by agent:refactor-bot#run_8f78… (stale_or_missing_token)
-candidate tree: 315f74d45183c9e7…
+```sh
+curl -fsSL https://raw.githubusercontent.com/Paffin/AgentGit/main/scripts/install.sh | sh -s -- --version 0.3.0-beta.7
+export PATH="$HOME/.local/bin:$PATH"
+ag-server dev --open
+```
-$ ag validate run prop_6eed… --check tests --key ci -- cargo test # as service:ci
-check 'tests' PASSED on tree 315f74d45183 (policy v1); attestation att_0f1a… signed by service:ci
-$ ag proposal review prop_6eed… --approve # as user:bob
-$ ag integrate prop_6eed… --fencing-token 1 # as user:alice
-Integrated (merge): stream is now at 793a03b6da69 (version 3, was 70e31c72a4d0), 1 file(s) changed.
+Установщик проверит контрольную сумму архива и сохранит программы в `~/.local/bin`. Сервер создаст деморепозиторий,
+откроет браузер и выполнит вход. Если браузер не открылся, перейдите по ссылке из терминала.
+Оставьте терминал работающим; **Ctrl-C** остановит сервер. После повторного запуска данные останутся в `./ag-dev-data`.
-$ ag preview prop_2b19… # параллельная правка той же строки
-strategy: Merge (algorithm ag-merge/3+ag-diff/1)
-conflict: src/main.rs (content conflict (1 region(s))) # код выхода 4 — поток не тронут
-```
+**В браузере:** откройте файл, нажмите **Edit**, предложите изменение, затем проверьте и примите его.
+Правила деморепозитория защищают `SECURITY.md`: изменение этого файла покажет, как обязательное ревью блокирует принятие.
-Каждый отказ объясняет, чего не хватает. Код выхода `3` — заблокировано (добавьте недостающее и повторите), `4` — отказ.
+Демо доступно только с вашей машины и использует временные учётные данные. Для общего сервера есть
+[руководство по развёртыванию](docs/operations/deployment.md). Windows, контейнеры, сборка из исходников,
+другой каталог установки и ручная проверка архива описаны в [вариантах установки](docs/getting-started.md#install).
+Документация в `docs/` — на английском.
-## Установка
+## Первое изменение из терминала
-Нужен Rust 1.89 или новее и компилятор C (SQLite собирается вместе с проектом). Linux, macOS и Windows собираются и
-тестируются в CI; песочница и `ag-worker` работают только на Linux ([Windows](docs/operations/windows.md)).
+Пока демосервер работает, откройте второй терминал в том же исходном каталоге:
-```bash
-git clone https://github.com/Paffin/AgentGit && cd AgentGit
-cargo install --path crates/ag-cli # ставит бинарник `ag`
+```sh
+export PATH="$HOME/.local/bin:$PATH"
+ag login http://127.0.0.1:8080 --token-file ./ag-dev-data/dev/token
+ag clone http://127.0.0.1:8080/demo/welcome
+cd welcome
+printf '\nA change from AgentGit.\n' >> README.md
+ag snapshot -m "Update the readme"
+ag push -t "Update the readme"
```
-Текущий кандидат — [`v0.3.0-beta.7`](https://github.com/Paffin/AgentGit/releases/tag/v0.3.0-beta.7). Используйте архивы для установки после успешного завершения Release workflow. Они содержат `ag`, `ag-server`, `ag-worker`, `ag-mcp` и `orchestrator` для Linux и macOS; проверки выпуска отражены в [Release workflow](https://github.com/Paffin/AgentGit/actions/workflows/release.yml).
+`ag push` напечатает ID предложения и опубликует изменение для ревью. Чтобы принять его, подставьте полученный ID:
-```bash
-curl -fsSL https://raw.githubusercontent.com/Paffin/AgentGit/main/scripts/install.sh | sh -s -- --version 0.3.0-beta.7
-docker pull ghcr.io/paffin/agentgit-cli:0.3.0-beta.7
+```sh
+ag remote integrate PROPOSAL_ID
+ag pull
```
-Пока есть только пре-релизы, `--version` обязателен. Если у вас установлена rc.2, используйте [инструкцию для rc.2](docs/getting-started.md#on-020-rc2-until-the-next-release). [Отчёт о проверке rc.2](docs/operations/releasing.md) относится только к тому выпуску и не подтверждает готовность rc.3. Подробности — в [вариантах установки](docs/getting-started.md#install).
+Если правила блокируют изменение, `ag remote explain PROPOSAL_ID` покажет, чего не хватает и какие команды помогут.
+[Пошаговое руководство](docs/getting-started.md) объясняет ревью, доступ агентов и правила репозитория.
-## Быстрый старт
+## Один процесс, четыре понятных ответа
-```bash
-mkdir demo && cd demo
-ag init --namespace me/demo # создаёт .ag/ и поток "main"
-echo "# demo" > README.md
-ag snapshot -m "first" # записать каталог (другим пока не виден)
-ag propose -t "Initial import" # проверить, опубликовать и открыть proposal
-ag integrate # атомарно сдвинуть "main"; достаточно уникального префикса id
-ag log
-```
+**Snapshot** сохраняет вашу работу. **Proposal** публикует её для ревью. **Integration** принимает проверенный
+результат в **stream** — общую ветку. У каждого предложения четыре независимых состояния:
-Дальше можно добавить [политику](docs/guides/policy.md), запустить [агента](docs/guides/agents.md) или прогнать всю
-историю целиком: `./examples/quickstart.sh`. Подробный разбор: [Getting started](docs/getting-started.md).
-
-### Пять минут с сервером, ничего не собирая
+| | Вопрос |
+|---|---|
+| **Mergeable** | Можно ли объединить изменение с текущей веткой? |
+| **Validated** | Прошли ли доверенные проверки на том результате, который будет принят? |
+| **Approved** | Одобрили ли нужные ревьюеры именно эту ревизию? |
+| **Integrated** | Обновилась ли общая ветка? |
-```bash
-ag-server dev # каталог данных, администратор, демо-репозиторий, ссылка для входа
-ag login http://127.0.0.1:8080 --token-file ./ag-dev-data/dev/token
-ag clone http://127.0.0.1:8080/demo/welcome # правьте, `ag snapshot`, `ag push -t "my change"`, при отказе `ag remote explain `
-```
+Успешные тесты не заменяют ревью. Одобрение старой ревизии не одобряет новую. Сервер проверяет правила
+в той же транзакции, которая обновляет ветку: между проверкой и записью нельзя незаметно подменить результат.
-`ag-server dev` печатает ссылку, которая входит в Web UI, и те же команды. Это режим для знакомства (только loopback,
-обычный HTTP, учётные данные на 8 часов), а не развёртывание. Он включён в `v0.3.0-beta.7`. Для прежних архивов `v0.2.0-rc.2` [Getting started](docs/getting-started.md#on-020-rc2-until-the-next-release) описывает тот же старт на
-rc.2 несколькими строками длиннее.
+
-### Как сервер
+*Это разрабатываемый интерфейс: упрощённый экран ревью выше ещё не входит в beta.7.
+Опубликованные изменения и текущая разработка разделены в [changelog](CHANGELOG.md) и [roadmap](ROADMAP.md).*
-```bash
-cargo install --path crates/ag-server
-export AG_BOOTSTRAP_TOKEN=$(openssl rand -hex 32) # учётные данные первого администратора
-ag-server admin --data-dir ./ag-data create-repo acme/web
-ag-server admin --data-dir ./ag-data grant --subject user:alice --repo 'acme/*' --role admin
-export AG_TOKEN=$(ag-server admin --data-dir ./ag-data mint --subject user:alice --ttl 86400 | tail -n 1)
-ag-server serve --data-dir ./ag-data & # 127.0.0.1:8080, gRPC + HTTP/JSON
-ag clone http://127.0.0.1:8080/acme/web web && cd web # дальше правьте, `ag snapshot`, `ag push -t "my change"`
-```
+## Выберите удобный инструмент
-Сервер говорит на одном протоколе, [AGWP v1](docs/protocol/agwp-v1.md), по gRPC и HTTP/JSON на одном порту и отдаёт на
-нём же [Web UI](docs/guides/web-ui.md). Compose, Kubernetes, TLS и PostgreSQL с S3 описаны в
-[Развёртывании](docs/operations/deployment.md).
-
-## Что умеет
-
-- Публикует изменение одной проверенной транзакцией и двигает голову потока через compare-and-swap. Сбой на любом шаге
- оставляет ровно один исход; в тестах сбои внедряются на каждом шаге протокола.
-- Сливает чистой версионированной функцией от base, ours и theirs. Конфликты — типизированный результат, о котором
- сообщается до любых изменений; файл, переименованный на одной стороне и изменённый на другой, сливается без конфликта.
-- Привязывает валидацию к дереву-кандидату. Аттестация называет дерево, версию политики и окружение и подписана
- издателем, которому доверяет политика. Когда поток сдвинулся, старый результат перестаёт засчитываться.
-- Хранит политику на каждый поток: обязательные проверки, одобрения, привязанные к просмотренной голове, защищённые пути
- и конверт автономности: в какие пути и сколько файлов агент может влить без человека.
-- Координирует агентов: идентичность на каждый запуск, задачи, мягкие и эксклюзивные lease с fencing-токенами,
- предсказание конфликтов и упорядоченная очередь интеграции.
-- Запускает недоверенные проверки в `ag-worker` внутри песочницы (rootless namespaces или контейнер) и подписывает
- результат.
-- Ведёт append-only reflog и журнал аудита с хеш-цепочкой; `ag verify` проверяет весь репозиторий.
-- Импортирует из Git и экспортирует в него с теми же SHA деревьев и целиком работает офлайн.
-- Поставляется с [Web UI](docs/guides/web-ui.md), [SDK для TypeScript и Python](docs/guides/sdks.md),
- [MCP-сервером](docs/guides/mcp.md), который отдаёт агенту AgentGit как набор инструментов, и
- [платформой агентов](docs/guides/orchestrator.md) на Go, которая может предлагать изменения, но не интегрировать их.
-
-## Сравнение с GitHub, GitLab и Bitbucket
-
-GitHub, GitLab и Bitbucket — зрелые платформы с более широкими возможностями управления проектами,
-хостируемым CI, сканированием безопасности, правами доступа в большом масштабе и поддержкой. В AgentGit уже
-есть issues репозитория и передача задач агентам; labels, milestones, вложения и импорт метаданных хостинга ещё не готовы. В таблице только тот вопрос,
-вокруг которого построен AgentGit: как попадает в кодовую базу изменение, когда пишут несколько недоверенных
-участников. Ячейки про платформы описывают их публичную документацию; на странице по ссылке ниже есть источники, дата
-обращения и куда более длинный список того, в чём они сильнее.
-
-| | GitHub | GitLab | Bitbucket | AgentGit |
-|---|---|---|---|---|
-| Единица ревью | pull request | merge request | pull request | proposal |
-| Где применяются правила слияния | платформа: защита веток и rulesets | платформа: защищённые ветки, правила одобрения, push rules (многое требует Premium) | платформа: права на ветки, merge checks (в Cloud принудительно на Premium) | политика потока, вычисляется в транзакции, которая двигает голову |
-| Успешная проверка привязана к точному дереву, которое вливается | в merge queue, где тестируется временное слияние очереди; иначе к коммиту | через merged results pipelines и merge trains (Premium); иначе к пайплайну | required builds на головном коммите; merge queues собирают результат слияния (платные планы, Pipelines) | всегда: подписанная аттестация называет дерево, версию политики и окружение |
-| Идентичность автоматических участников | аккаунты, GitHub Apps, токены | сервисные аккаунты, access tokens | аккаунты, токены | идентичность на агента и на запуск; учётные данные можно привязать к одному запуску |
-| Автономность агентов в рамках политики | собирается из правил веток и прав ботов | собирается из тех же частей | собирается из тех же частей | встроена: разрешённые пути и лимит файлов на поток |
-| Аудит, в котором видна подмена | журнал аудита с экспортом; в документации нет утверждений о целостности | события аудита (Ultimate); утверждений о целостности нет | журнал аудита; не сравнивали | хеш-цепочка, проверяется `ag audit` и `ag verify` |
-| Самодостаточность и офлайн | облако; для своей установки Enterprise Server | SaaS или своя установка | Cloud или Data Center | локальному режиму сервер не нужен; сервер — один бинарник |
-| Экосистема | очень большая | очень большая, CI и безопасность встроены | большая, Jira и Pipelines | небольшая: CLI, SDK (не опубликованы), MCP, Web UI |
-| Зрелость | очень высокая | очень высокая | очень высокая | beta |
-
-Полное сравнение, включая Git, Gerrit и Jujutsu: [AgentGit compared](docs/comparison.md) (на английском).
-
-## Web UI
-
-Новый пользователь попадает на главную страницу, где сказано, что требует внимания; подписи простыми словами (термин
-AgentGit в подсказке), у блокировок есть кнопка с исправлением, есть пошаговые сценарии (создать репозиторий, дать
-агенту работу), пресеты правил вместо сырого JSON и правка файла с предложением изменения прямо в браузере. Всё это в
-`main`, а не в архивах rc.2. Чего ещё нет, написано в [дорожной карте](ROADMAP.md).
-
-Живая доска запусков, lease, задач и очереди слияния с четырьмя независимыми вердиктами на каждый proposal. Это
-настоящие скриншоты UI, работающего с настоящим сервером; их снимает сквозной набор тестов.
-
-
-
-
-
-
-
-
-
-
-
-
- Proposal: четыре вердикта, что блокирует, дифф
-
-
-
- Политика: версии только добавляются, есть диффы
-
-
-
- Blame: построчный, следует за переименованиями
-
-
-
-
-## Как это работает
-
-```mermaid
-flowchart LR
- W["workspace снимки"] -->|propose| P["публикация проверка каждого объекта, регистрация в ОДНОЙ транзакции"]
- P --> Q["proposal ревью привязаны к голове"]
- Q -->|integrate| C["кандидат fast-forward или детерминированный merge"]
- C --> G{"политика, в фиксирующей транзакции"}
- G -->|разрешено| S[("голова потока compare-and-swap + reflog + аудит")]
- G -->|заблокировано / отказ| Q
- C -.->|конфликт| W
-
- classDef step fill:#e8eeff,stroke:#5b6cff,color:#1b2150
- classDef gate fill:#fff4d6,stroke:#c99a1e,color:#4a3508
- classDef done fill:#dcf5e8,stroke:#1f9d67,color:#0f3d2e
- class W,P,Q,C step
- class G gate
- class S done
+| С чего начать | Для чего |
+|---|---|
+| [Web UI](docs/guides/web-ui.md) | Просмотр и редактирование кода, ревью, обсуждения и управление работой. |
+| [CLI](docs/guides/remote.md) | Локальные репозитории, снимки без сети и работа с сервером из терминала. |
+| [MCP](docs/guides/mcp.md) | Чтение, предложения и ревью через явно разрешённые инструменты агента. |
+| [TypeScript и Python SDK](docs/guides/sdks.md) | Интеграции через типизированные клиенты серверного API. |
+| [Агенты и выполнение задач](docs/guides/agents.md) | Назначение ограниченной работы, история запусков и управление правом принимать результат. |
+| [CI и workers](docs/guides/validation.md) | Подписанные результаты проверок для точного дерева изменений. |
+
+У запусков агентов отдельные идентичности. Правила ветки задают обязательные проверки, ревьюеров, защищённые пути
+и пределы самостоятельной работы агента. Решения записываются в журнал аудита. Ядро слияния и проверки правил
+не вызывает языковую модель. [Основные понятия](docs/concepts.md) объясняют устройство,
+[модель безопасности](docs/architecture/security.md) — границы доверия.
+
+## Перенести существующий репозиторий
+
+Создайте полный Git bundle и выберите **Repositories → Import from GitHub / GitLab** в Web UI.
+Либо импортируйте его локально:
+
+```sh
+git bundle create repository.bundle --all
+ag migrate repository.bundle ./project --namespace team/project
```
-У proposal четыре вердикта. Они независимы: ни один не следует из другого, и каждый меняется сам по себе.
-
-| Вердикт | Вопрос | Кто решает | Меняется, когда |
-|---|---|---|---|
-| Mergeable | Есть ли один однозначный итоговый результат? | движок слияния (`ag preview`) | изменение с любой стороны лишает результат однозначности |
-| Validated | Прошли ли доверенные проверки на этом дереве? | подписанные аттестации | меняется дерево-кандидат или политика |
-| Approved | Одобрило ли эту голову достаточно подходящих людей? | ревью и политика | автор запушил снова или изменилась планка политики |
-| Integrated | Обновился ли поток атомарно? | одна транзакция compare-and-swap | никогда: это факт истории |
+Импорт позволяет проверить соответствие веток и тегов и сохраняет исходный bundle. Git export оставляет путь назад.
+Код и история переносятся; issues, комментарии, метаданные pull requests, LFS и настройка CI требуют отдельной работы.
+Перед переносом команды прочитайте [руководство по миграции](docs/guides/migrate-from-git.md).
-
-
-
+AgentGit — ранняя альтернатива для команд, которые пробуют работу агентов под правилами репозитория.
+У GitHub и GitLab значительно шире возможности хостинга, управления проектами и опыт эксплуатации.
+[Сравнение и компромиссы](docs/comparison.md).
-
-
-
+## Статус и ограничения
-Важнее самих блоков три границы доверия. Ядро принимает решения без модели, часов и случайности. Движок работает внутри
-сервера, поэтому политика применяется там, где подключаются недоверенные клиенты. Недоверенный код выполняется только
-в отдельной плоскости исполнения: она может сообщить результат, но не решает, засчитывается ли он. Подробнее:
-[Concepts](docs/concepts.md), [обзор архитектуры](docs/architecture/overview.md) и
-[публикация и интеграция](docs/architecture/publication-and-integration.md).
+**Ранняя публичная beta.** Текущий выпуск — [v0.3.0-beta.7](https://github.com/Paffin/AgentGit/releases/tag/v0.3.0-beta.7).
+Стабильного выпуска и опубликованных пакетов в crates.io, npm и PyPI пока нет. Готовые архивы доступны для Linux
+и macOS на x86_64 и ARM64; песочница worker работает только на Linux. Windows проверяется в CI,
+но отдельный готовый архив для Windows ещё не выпускается.
-## Гарантии и тесты
+**Перед обновлением сделайте резервную копию.** В beta.7 используется схема метаданных **8**.
+Неопубликованная разработка использует схему **13**, которая добавляет назначенных ревьюеров и личные черновики ревью, комментариев и отдельных файлов, а также возобновляемый импорт issues.
+Запуск сборки из разработки может обновить репозиторий; для возврата к старому бинарнику потребуется восстановить
+копию до обновления. Подробнее: [резервное копирование](docs/operations/durability.md) и [история выпусков](CHANGELOG.md).
-В каждой строке назван тест из этого репозитория. В CI идёт более 1000 тестов на Rust, а также наборы SDK, Web UI и
-оркестратора против настоящего `ag-server`, фаззинг и гейты покрытия и мутационного тестирования
-([Testing](docs/architecture/testing.md)).
+**Начните с данных, которые можно восстановить.** Внешняя проверка в production и независимый аудит безопасности
+ещё впереди. Локальный режим доверяет тому, кто может записывать в каталог репозитория. Для недоверенных клиентов
+нужен сервер, а для недоверенных проверок — [песочница worker](docs/guides/workers.md), а не `ag validate run`
+в вашей оболочке. Сохраняйте резервные копии и Git-зеркало важных данных. В [roadmap](ROADMAP.md) перечислены
+оставшиеся ограничения: перенос метаданных хостинга, LFS, submodules и проверки на реальных установках
+и со вспомогательными технологиями.
-| Гарантия | Тест |
-|---|---|
-| Сбой на любом шаге публикации или интеграции восстанавливается ровно в одно обновление | `crash_at_every_step_recovers_to_exactly_one_integration` |
-| Параллельные интеграторы не теряют обновлений | `concurrent_integrations_never_lose_an_update` |
-| Потерянный ответ не приводит к повторному применению | `idempotent_replay_and_unknown_outcome_recovery` |
-| Проверка старого кандидата не используется повторно после сдвига потока | `attestation_cannot_be_reused_after_the_stream_moves` |
-| Держатель устаревшего lock не может влить код в область, которой теперь владеет другой | `fencing_tokens_are_strictly_increasing_and_reject_stale_holders` |
-| Бинарные форматы совпадают с независимой реализацией на Python | `tests/conformance/` |
-| История Git проходит туда и обратно с теми же SHA деревьев | `history_survives_a_round_trip_with_identical_git_trees` |
-| Подмена журнала аудита или объектов обнаруживается | `audit_chain_detects_tampering` |
+## Сборка и участие в разработке
-Модель угроз и её известные пределы: [Security](docs/architecture/security.md).
+Для работы над CLI установите Rust 1.89 или новее и компилятор C:
-## Документация
+```sh
+git clone https://github.com/Paffin/AgentGit.git
+cd AgentGit
+cargo install --path crates/ag-cli --locked
+```
-| | |
-|---|---|
-| Начало | [Getting started](docs/getting-started.md), [туториалы](docs/tutorials/README.md), [Concepts](docs/concepts.md), [FAQ](docs/faq.md), [сравнение](docs/comparison.md) |
-| Руководства | [агенты](docs/guides/agents.md), [политики](docs/guides/policy.md), [валидация](docs/guides/validation.md), [воркеры](docs/guides/workers.md), [работа с сервером](docs/guides/remote.md), [история](docs/guides/history.md), [мост с Git](docs/guides/git-bridge.md) |
-| Клиенты | [Web UI](docs/guides/web-ui.md), [SDK](docs/guides/sdks.md), [MCP](docs/guides/mcp.md), [оркестратор](docs/guides/orchestrator.md) |
-| Справочник | [CLI](docs/reference/cli.md), [конфигурация](docs/reference/configuration.md), [AGWP v1](docs/protocol/agwp-v1.md), [OpenAPI](docs/protocol/openapi.json), [форматы объектов](docs/architecture/object-format.md) |
-| Эксплуатация | [развёртывание](docs/operations/deployment.md), [высокая доступность](docs/operations/high-availability.md), [наблюдаемость](docs/operations/observability.md), [резервные копии](docs/operations/durability.md), [hardening](docs/operations/hardening.md), [диагностика](docs/operations/troubleshooting.md) |
-| Устройство | [архитектура](docs/architecture/overview.md), [ADR](docs/adr/), [соответствие ADR реализации](docs/architecture/adr-traceability.md), [безопасность](docs/architecture/security.md), [песочница](docs/architecture/sandbox.md) |
+[Contributing](CONTRIBUTING.md) описывает сборку сервера, Web UI, проверки и правила работы с репозиторием.
+Устройство проекта раскрывают [обзор архитектуры](docs/architecture/overview.md),
+[стратегия тестирования](docs/architecture/testing.md) и [архитектурные решения](docs/adr/README.md).
+[Пример в английском README](README.md#build-and-contribute) показывает полный путь от отказа по правилам до принятия изменения;
+его сценарий [`examples/quickstart.sh`](examples/quickstart.sh) выполняется в CI.
-Документацию проверяет CI: справочник CLI генерируется из бинарника, команды туториалов запускаются на настоящих
-бинарниках, а `scripts/check_docs.py` сверяет ссылки, имена тестов, команды `ag` и переменные `AG_*` с кодом. Сайт
-собирается через `mkdocs build --strict` и публикуется на GitHub Pages из `main`.
+## Документация и сообщество
-## Статус и ограничения
+- [Начало работы](docs/getting-started.md) · [Учебные сценарии](docs/tutorials/README.md) · [FAQ](docs/faq.md)
+- [Правила репозитория](docs/guides/policy.md) · [Справочник CLI](docs/reference/cli.md) · [Настройка](docs/reference/configuration.md)
+- [Развёртывание](docs/operations/deployment.md) · [Решение проблем](docs/operations/troubleshooting.md) · [API](docs/protocol/agwp-v1.md)
+- [Участие в beta](docs/community/start-here.md) · [Сообщить о проблеме](https://github.com/Paffin/AgentGit/issues) · [Roadmap](ROADMAP.md)
-AgentGit — ранняя публичная бета для одного Linux-сервера с SQLite и файловым хранилищем. Ядро и перечисленные гарантии реализованы и протестированы, как и платформа вокруг него. В продакшене
-проект не использовал никто, кроме авторов, внешнего аудита безопасности не было. Полный список — в
-[ROADMAP.md](ROADMAP.md) и [CHANGELOG.md](CHANGELOG.md); то, от чего зависит, можно ли пользоваться уже сейчас:
-
-- Локальный режим доверяет локальному пользователю. Идентичности — просто метки, и тот, кто может писать в `.ag/`,
- способен обойти политику; подмену можно обнаружить через `ag verify`, но не предотвратить. Для клиентов, которых вы не
- контролируете, запускайте `ag-server`.
-- Несколько реплик сервера на одном PostgreSQL и одном бакете S3 проверены в CI настоящими процессами. Не доказаны:
- отказоустойчивое переключение PostgreSQL, смешанные версии при плавном обновлении, нагрузка и оверлей `ha` для
- Kubernetes на настоящем кластере ([высокая доступность](docs/operations/high-availability.md)).
-- Учётные данные можно привязать к одному запуску агента ([ADR-0010](docs/adr/0010-run-bound-credentials.md)), но это
- включается на стороне клиента; действовать в чужом запуске можно только с `agent.operate`, которого у обычного
- writer нет. Токен ограждения (fencing) для захвата заданий передаётся по желанию клиента
- ([ADR-0012](docs/adr/0012-job-claim-fencing.md)).
-- Доступны только пре-релизы; текущий кандидат — `v0.3.0-beta.7` (GitHub и GHCR). Стабильного релиза нет, пакетов в
- crates.io, npm и PyPI нет; 21 крейт `agentgit-` проверен только пробной публикацией
- ([релизы](docs/operations/releasing.md)). Схема метаданных 8 требует резервной копии перед обновлением; для отката к beta.5 или старее восстановите копию до обновления.
-- `ag validate run` не песочница. Недоверенный код идёт через [`ag-worker`](docs/guides/workers.md), чья песочница
- делит с хостом ядро. Проверено на настоящих Docker и Podman и cgroup v2; не проверено для microVM, Windows (воркер
- отказывается запускаться) и macOS, кроме запасного режима с одними лимитами ([песочница](docs/architecture/sandbox.md)).
-- PostgreSQL и S3 проверены на настоящем PostgreSQL и SeaweedFS, но не на Amazon S3, MinIO, Ceph и R2.
-- Деревья хранятся плоскими манифестами, измерено до 100 000 файлов ([ADR-0006](docs/adr/0006-flat-trees-measured.md)).
- Цифры производительности сняты на одной общей виртуальной машине с 4 vCPU и целями не являются
- ([движок](docs/architecture/performance.md), [сервер](docs/operations/performance.md)); пропускная способность с
- PostgreSQL, S3, TLS и несколькими репликами не измерялась. Кэш декодированных деревьев и 3 долговечных коммита на
- интеграцию измерены только на дисковом и in-memory бэкендах.
-- Нет переписывания истории, тегов, submodules и LFS; мост с Git сообщает, что не может перенести.
-- Web UI проверен в Chromium, Firefox и WebKit; OIDC только с Keycloak. Проверки со скринридером не было. `ag-mcp` не
- запускали внутри настоящих хостов. SDK не опубликованы.
-- Ночной фаззинг и еженедельное мутационное тестирование запускаются на GitHub. Проверяйте результаты на коммите релиза: успешное покрытие само по себе не означает, что все мутации обнаруживаются тестами.
-
-Сейчас подойдёт: команда и её агенты на одной машине, одном сервере или нескольких репликах, процессы с проверками в
-CI, эксперименты с автономностью агентов в рамках политики. Пока не подойдёт: хостинг взаимно недоверяющих арендаторов и
-роль единственной копии данных, которые нельзя потерять. Делайте копии через `ag backup` и держите зеркало в Git.
-
-## Подготовка беты
-
-Открыт публичный бета-тест. Для беты зафиксированы [поддерживаемый сценарий и критерии готовности](docs/community/beta.md),
-создан [milestone](https://github.com/Paffin/AgentGit/milestone/1) и [вход для участников](docs/community/start-here.md).
-Нужны внешние проверки установки и реальных задач с агентами; полезны и отчёты о том, где процесс непонятен.
-Независимые пилоты ещё нужны; метка beta не означает подтверждённую готовность к критичной эксплуатации.
-
-## Участие
-
-См. [CONTRIBUTING.md](CONTRIBUTING.md). Изменения форматов, хеширования, семантики слияния и протокола публикации
-начинаются с ADR. О проблемах безопасности сообщайте, как описано в [SECURITY.md](SECURITY.md). Все участники следуют
-[Кодексу поведения](CODE_OF_CONDUCT.md).
+Об уязвимостях сообщайте по инструкции [Security](SECURITY.md). Участники следуют [кодексу поведения](CODE_OF_CONDUCT.md).
## Лицензия
-Apache License 2.0, см. [LICENSE](LICENSE). Copyright 2026 The AgentGit Authors.
+[Apache 2.0](LICENSE). Copyright 2026 The AgentGit Authors.
diff --git a/ROADMAP.md b/ROADMAP.md
index 90765423..f5d95172 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -95,28 +95,27 @@ result, understand policy decisions and land a verified change. Migration must l
team reach that workflow without losing its history or silently discarding hosting metadata.
Work in the following order; keep each deliverable usable across the relevant clients.
-1. **Deliver the next development slice.** Beta.5 is published; its Linux archive checksum,
- provenance and upgrade to development schema 7 have been verified. Admission controls and
- task sources are already merged; they need their own subsequent release and schema-8 upgrade
- notice, not a replacement beta.5 tag.
+1. **Deliver the next development slice.** Beta.7 is published with metadata schema 8.
+ Its Linux archive installation and first reviewed change have been exercised end to end.
+ The next release must include the development review workflow and its schema-13 upgrade
+ notice with backup/rollback guidance; existing beta tags remain immutable.
2. **Finish the daily task and review workflow.** Queued-task refinement now preserves
historical instructions and source links. Proposal updates now have an atomic inspected-head
precondition as the prerequisite for safe review edits. Workspace mutations now compare the
observed base/head in their final transaction, and proposal closing preserves terminal states. The final integration transaction also
- rechecks the proposal head and open state. Single-line suggestions support explicit author preview and application. Next add batched/range suggestions and requested reviewers. Optional policy-required line-discussion resolution is now implemented
+ rechecks the proposal head and open state. Single-line, range and batched suggestions support explicit author preview and application across files in one revision. Requested reviewers route work across Web UI, CLI, MCP and SDKs without changing approval policy. Optional policy-required line-discussion resolution is now implemented
across core, API, CLI explanations and Web UI. Preserve drafts, exact-head
checks and explicit confirmation when a user changes executable work or a review decision.
- Account-backed drafts and non-text conflict resolution remain open. The development Home page uses one live stream for a selected repository, retaining complete snapshots and
- polling for the aggregate view. A cross-repository event feed remains future work.
+ Binary, oversized, file-type and rename/delete conflicts now support exact version choices; renamed content previews retain their per-side source paths. Private account-backed review-decision drafts now restore across browsers, with explicit save and version checks; general proposal comments now have separate private drafts with the same explicit save and version checks; single-file edits now support explicit account save and restore, including discovery after a file disappears from the current branch. Account-backed batch and new-file drafts remain open. Bounded file/directory conflicts now support whole-source choices with complete candidate previews; multi-target rename conflicts and larger structural cases remain open. The development Home page retains complete snapshots and uses one resumable live feed for up to thirty loaded repositories. Larger aggregate views keep bounded polling for the remaining repositories; CLI and SDKs share the scoped cross-repository cursor contract.
3. **Make GitHub/GitLab cutover complete.** Code history, branch/tag mapping, the original bundle
- and reconciliation already move through Web UI and CLI. Next design and implement resumable
- hosting-metadata migration: issues and comments first, then pull/merge-request references,
- releases and identity/permission mapping. Preview unsupported data and require deliberate
+ and reconciliation already move through Web UI and CLI. Development builds now export GitHub/GitLab issues and comments into bounded manifests and import them with preview, durable source mappings and resume without duplicates. Next extend beyond bounded initial snapshots to larger cutovers, pull/merge-request references, releases and identity/permission mapping. Preview unsupported data and require deliberate
choices before publication. LFS payloads and CI configurations need separate transfer/setup;
source YAML alone is not a working validation pipeline. Keep Git export and rollback visible
in the [migration guide](docs/guides/migrate-from-git.md).
4. **Qualify the real first-use experience.** Native archives already include the orchestrator;
- browser setup prepares scoped executor access. Record an end-to-end run in a real MCP host
+ browser setup prepares scoped executor access. The Linux beta.7 archive now has an isolated
+ install-to-first-change smoke, and the installer/READMEs lead with that path. Record an
+ end-to-end run in a real MCP host
([#50](https://github.com/Paffin/AgentGit/issues/50)), clean macOS published-archive installation
([#52](https://github.com/Paffin/AgentGit/issues/52)), a screen-reader session
([#53](https://github.com/Paffin/AgentGit/issues/53)) and a physical-phone session
@@ -124,8 +123,9 @@ Work in the following order; keep each deliverable usable across the relevant cl
([#51](https://github.com/Paffin/AgentGit/issues/51)); automated browser runs do not substitute
for these reports. The development rules form now edits protected-path approvers and check timing while preserving
runner settings. Keep the dev sign-in exchange and remaining advanced controls on the onboarding backlog.
-5. **Finish distribution and widen support deliberately.** Verify Homebrew installation, add
- a Windows release archive, publish crates and SDKs when registry ownership/trusted publishing
+5. **Finish distribution and widen support deliberately.** Verify Homebrew installation and
+ run the prepared Windows ZIP/PowerShell installer pipeline on a native Windows runner before
+ publishing its first archive. Publish crates and SDKs when registry ownership/trusted publishing
is configured, and qualify installation outside CI. A stable tag follows the
[beta exit evidence](docs/community/beta.md#release-gates), not a count of shipped features.
6. **Qualify deployment and server follow-ups.** Compose and kind deployment already run in CI.
diff --git a/SECURITY.md b/SECURITY.md
index befc7960..6b625657 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,10 +11,12 @@ agents. We take integrity and isolation bugs seriously.
| `0.3.0-beta.7` | Current patch release; use its binaries after the Release workflow completes. |
| `0.3.0-beta.6` and earlier prereleases | Upgrade to the current patch release for the fixes to stream scopes, workspace concurrency and retention. |
-AgentGit is **beta** software. The current repository metadata schema is **8**;
+AgentGit is **beta** software. The current development repository metadata schema is **13**;
`ChangesetV1`, tree manifest v1 and repository configuration format 1 remain unchanged.
The server control database is migrated independently; use the same server version across replicas.
-Beta.7 introduces no storage migration relative to beta.6. Returning to beta.5 or older
+The published beta.6/beta.7 format is schema 8. Development schema 9 adds requested reviewers, schema 10 adds private review drafts, schema 11 adds separate private comment drafts, schema 12 adds private single-file drafts, and schema 13 adds durable hosting issue-import mappings;
+back up before opening a schema-8 through schema-12 repository with this build. Beta.7 introduces no
+storage migration relative to beta.6. Returning to beta.5 or older
requires the pre-upgrade backup, not opening the upgraded database with an older binary.
See [compatibility](docs/architecture/compatibility.md) and [releasing](docs/operations/releasing.md).
There has been no independent external security audit. Keep backups (`ag backup`) and a Git mirror (`ag git export`).
diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md
index 14876f88..03d60cc3 100644
--- a/apps/web/DESIGN.md
+++ b/apps/web/DESIGN.md
@@ -37,6 +37,14 @@ routes, permission checks, keyboard behavior and server-backed data.
- Dark mode uses the existing paired tokens, not inversion. Motion only explains
state changes and obeys `prefers-reduced-motion`.
+## Review workspace
+
+Lead with the change title and the two primary review/integration actions. Keep the
+four independent readiness states compact; do not put a large status card between
+the title and changed code. Details, requested reviewers and secondary actions live
+in a quiet side panel, after the code on phones. Preserve visible permission guidance
+for unavailable actions and keep file navigation compact on narrow screens.
+
## Verification
Run the existing type, lint, unit and real-server browser checks after changes.
diff --git a/apps/web/e2e/account-comment-draft.spec.ts b/apps/web/e2e/account-comment-draft.spec.ts
new file mode 100644
index 00000000..32b66445
--- /dev/null
+++ b/apps/web/e2e/account-comment-draft.spec.ts
@@ -0,0 +1,95 @@
+import { test, expect } from './fixtures.ts'
+import { amend, commitFiles, freshRepo, openProposal } from './support/world.ts'
+
+test('account comment drafts cross devices, preserve newer work and require head reinspection', async ({
+ cast,
+ openAs,
+ allowConsole,
+}, info) => {
+ const { repo } = await freshRepo(cast, 'comment-account')
+ const author = cast.as('alice')
+ const change = await commitFiles(author, repo, { 'code.txt': 'first\n' }, 'Discuss source')
+ const proposal = await openProposal(author, repo, {
+ head: change.head,
+ title: 'Account discussion drafts',
+ })
+ const url = `/repos/${repo}/proposals/${proposal.id}?tab=reviews`
+ const path = `/v1/repos/${repo}/proposals/${proposal.id}/comment-draft`
+ const first = await openAs('alice')
+ await first.goto(url)
+ const firstText = first.getByRole('textbox', { name: 'Add to the discussion', exact: true })
+ await firstText.fill('Private reasoning from the first device')
+ await first.getByRole('button', { name: 'Save draft', exact: true }).click()
+ await expect(first.getByText('Draft saved to your account', { exact: true })).toBeVisible()
+ const stored = await author.ok_(author.get(path), 'read private comment draft')
+ expect(stored.body).toBe('Private reasoning from the first device')
+ const publicState = await author.ok_(
+ author.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read shared proposal',
+ )
+ expect(publicState.reviews).toEqual([])
+ const other = await cast.as('bob').ok_(cast.as('bob').get(path), 'read another account draft')
+ expect(other.present).toBe(false)
+
+ const second = await openAs('alice')
+ await second.goto(url)
+ const secondText = second.getByRole('textbox', { name: 'Add to the discussion', exact: true })
+ await expect(secondText).toHaveValue(stored.body)
+ await firstText.fill('Newer work on first device')
+ await first.getByRole('button', { name: 'Save draft', exact: true }).click()
+ await expect
+ .poll(async () => (await author.ok_(author.get(path), 'read newer draft')).body)
+ .toBe('Newer work on first device')
+ await secondText.fill('Keep second device text')
+ allowConsole(/status of 412.*\/comment-draft(?:\?|\])/)
+ await second.getByRole('button', { name: 'Save draft', exact: true }).click()
+ await expect(
+ second.getByRole('button', { name: 'Reload account draft', exact: true }),
+ ).toBeVisible()
+ await expect(secondText).toHaveValue('Keep second device text')
+ await second.getByRole('button', { name: 'Reload account draft', exact: true }).click()
+ await expect(second.getByText('An account draft is available', { exact: true })).toBeVisible()
+ await second.getByRole('button', { name: 'Keep tab draft', exact: true }).click()
+ await second.getByRole('button', { name: 'Save draft', exact: true }).click()
+ await expect
+ .poll(async () => (await author.ok_(author.get(path), 'read replaced draft')).body)
+ .toBe('Keep second device text')
+
+ const next = await amend(author, repo, change.workspace, { 'code.txt': 'second\n' }, 'New head')
+ await author.ok_(
+ author.patch(`/v1/repos/${repo}/proposals/${proposal.id}`, { new_head: next.head }),
+ 'advance proposal',
+ )
+ const third = await openAs('alice')
+ await third.goto(url)
+ const thirdText = third.getByRole('textbox', { name: 'Add to the discussion', exact: true })
+ await expect(thirdText).toHaveValue('Keep second device text')
+ await expect(third.getByRole('button', { name: 'Add comment', exact: true })).toBeDisabled()
+ await third.screenshot({ path: info.outputPath('account-comment-draft.png'), fullPage: true })
+ await third
+ .getByRole('button', { name: 'I have inspected the current changes', exact: true })
+ .click()
+ await third.getByRole('button', { name: 'Add comment', exact: true }).click()
+ await expect(thirdText).toHaveValue('')
+ await expect
+ .poll(async () => (await author.ok_(author.get(path), 'read cleared draft')).present)
+ .toBe(false)
+ const posted = await author.ok_(
+ author.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read posted comment',
+ )
+ expect(posted.reviews).toHaveLength(1)
+ expect(posted.reviews[0].body).toBe('Keep second device text')
+ expect(posted.reviews[0].head).toBe(next.head)
+
+ await author.ok_(
+ author.post(`/v1/repos/${repo}/proposals/${proposal.id}/close`),
+ 'close proposal',
+ )
+ await third.reload()
+ await thirdText.fill('Follow-up on a closed proposal')
+ await third.getByRole('button', { name: 'Save draft', exact: true }).click()
+ await expect
+ .poll(async () => (await author.ok_(author.get(path), 'closed proposal draft')).body)
+ .toBe('Follow-up on a closed proposal')
+})
diff --git a/apps/web/e2e/account-review-draft.spec.ts b/apps/web/e2e/account-review-draft.spec.ts
new file mode 100644
index 00000000..50c38fc9
--- /dev/null
+++ b/apps/web/e2e/account-review-draft.spec.ts
@@ -0,0 +1,130 @@
+import { test, expect } from './fixtures.ts'
+import { amend, commitFiles, freshRepo, openProposal } from './support/world.ts'
+
+test('private account drafts cross devices without overwriting concurrent work or submitting stale reviews', async ({
+ cast,
+ openAs,
+ allowConsole,
+}, info) => {
+ const { repo } = await freshRepo(cast, 'account-draft')
+ const author = cast.as('alice')
+ const original = await commitFiles(
+ author,
+ repo,
+ { 'code.txt': 'first version\n' },
+ 'Review source',
+ )
+ const proposal = await openProposal(author, repo, {
+ head: original.head,
+ title: 'Review from another device',
+ })
+ const path = `/v1/repos/${repo}/proposals/${proposal.id}/review-draft`
+ const url = `/repos/${repo}/proposals/${proposal.id}`
+ await cast
+ .as('admin')
+ .ok_(
+ cast.as('admin').post('/v1/roles', { subject: 'user:carol', repo, role: 'reviewer' }),
+ 'grant isolated reviewer',
+ )
+
+ const first = await openAs('bob')
+ await first.goto(url)
+ await first.getByTestId('act-review').click()
+ const firstDialog = first.getByRole('dialog', { name: 'Write a review' })
+ await firstDialog
+ .getByRole('textbox', { name: 'Comment', exact: true })
+ .fill('Reasoning from my first device')
+ await firstDialog.getByRole('radio', { name: 'Request changes', exact: true }).check()
+ await firstDialog.getByRole('button', { name: 'Save draft and close' }).click()
+ await expect(firstDialog).not.toBeVisible()
+ const stored = await cast.as('bob').ok_(cast.as('bob').get(path), 'read own draft')
+ expect(stored.present).toBe(true)
+ expect(stored.body).toBe('Reasoning from my first device')
+ expect(
+ (
+ await author.ok_(
+ author.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'no published review',
+ )
+ ).reviews,
+ ).toEqual([])
+ const other = await cast
+ .as('carol')
+ .ok_(cast.as('carol').get(path), 'other reviewer has separate draft')
+ expect(other.present).toBe(false)
+ expect(other.body).toBe('')
+
+ const second = await openAs('bob')
+ await second.goto(url)
+ await second.getByTestId('act-review').click()
+ const secondDialog = second.getByRole('dialog', { name: 'Write a review' })
+ await expect(secondDialog.getByRole('textbox', { name: 'Comment', exact: true })).toHaveValue(
+ stored.body,
+ )
+ await expect(
+ secondDialog.getByRole('radio', { name: 'Request changes', exact: true }),
+ ).toBeChecked()
+ await first.getByTestId('act-review').click()
+ await firstDialog
+ .getByRole('textbox', { name: 'Comment', exact: true })
+ .fill('Newer reasoning from first device')
+ await firstDialog.getByRole('button', { name: 'Save draft and close' }).click()
+ await expect(firstDialog).not.toBeVisible()
+ await secondDialog
+ .getByRole('textbox', { name: 'Comment', exact: true })
+ .fill('Keep this second-device draft')
+ allowConsole(/status of 412.*\/review-draft(?:\?|\])/)
+ await secondDialog.getByRole('button', { name: 'Save draft and close' }).click()
+ await expect(secondDialog.getByText('Account draft storage', { exact: true })).toBeVisible()
+ await expect(secondDialog.getByRole('textbox', { name: 'Comment', exact: true })).toHaveValue(
+ 'Keep this second-device draft',
+ )
+ await secondDialog.getByRole('button', { name: 'Reload account draft' }).click()
+ await expect(secondDialog.getByText('Two drafts', { exact: true })).toBeVisible()
+ await secondDialog.getByRole('button', { name: 'Keep this tab draft' }).click()
+ await secondDialog.getByRole('button', { name: 'Save draft and close' }).click()
+ await expect(secondDialog).not.toBeVisible()
+
+ const third = await openAs('bob')
+ await third.goto(url)
+ await third.getByTestId('act-review').click()
+ const thirdDialog = third.getByRole('dialog', { name: 'Write a review' })
+ await expect(thirdDialog.getByRole('textbox', { name: 'Comment', exact: true })).toHaveValue(
+ 'Keep this second-device draft',
+ )
+ const changed = await amend(
+ author,
+ repo,
+ original.workspace,
+ { 'code.txt': 'new version\n' },
+ 'Changed after the draft',
+ )
+ await author.ok_(
+ author.call('PATCH', `/v1/repos/${repo}/proposals/${proposal.id}`, {
+ new_head: changed.head,
+ expected_head: original.head,
+ }),
+ 'advance proposal',
+ )
+ await expect(thirdDialog.getByText('The proposal changed', { exact: true })).toBeVisible()
+ await expect(
+ thirdDialog.getByRole('button', { name: 'Submit review', exact: true }),
+ ).toBeDisabled()
+ await third.screenshot({ path: info.outputPath('account-review-draft.png'), fullPage: true })
+ await thirdDialog.getByRole('button', { name: 'I reviewed the updated changes' }).click()
+ await thirdDialog.getByRole('button', { name: 'Submit review', exact: true }).click()
+ await expect(thirdDialog).not.toBeVisible()
+ const reviewed = await author.ok_(
+ author.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read published review',
+ )
+ expect(reviewed.reviews).toHaveLength(1)
+ expect(reviewed.reviews[0].head).toBe(changed.head)
+ expect(reviewed.reviews[0].body).toBe('Keep this second-device draft')
+ const cleared = await cast.as('bob').ok_(cast.as('bob').get(path), 'submitted draft cleared')
+ expect(cleared.present).toBe(false)
+ expect(cleared.version).toBeGreaterThan(stored.version)
+ await first.close()
+ await second.close()
+ await third.close()
+})
diff --git a/apps/web/e2e/nontext-conflicts.spec.ts b/apps/web/e2e/nontext-conflicts.spec.ts
new file mode 100644
index 00000000..a6e78454
--- /dev/null
+++ b/apps/web/e2e/nontext-conflicts.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from './fixtures.ts'
+import { freshRepo, commitFiles, openProposal, integrate } from './support/world.ts'
+import type { Cast } from './support/world.ts'
+
+async function conflict(cast: Cast) {
+ const { repo } = await freshRepo(cast, 'nontext-conflict', {
+ 'asset.bin': new Uint8Array([0, 1]),
+ launch: 'base\n',
+ })
+ const alice = cast.as('alice'),
+ dana = cast.as('dana')
+ const ours = await commitFiles(
+ alice,
+ repo,
+ { 'asset.bin': new Uint8Array([0, 2]), launch: 'local\n' },
+ 'Change asset and launcher',
+ )
+ const mine = await alice.ok_(
+ alice.post(`/v1/repos/${repo}/workspaces/${ours.workspace}/changes`, {
+ changes: [
+ {
+ path: 'asset.bin',
+ op: 'put',
+ content: Buffer.from([0, 2]).toString('base64'),
+ executable: true,
+ },
+ ],
+ expected_head: ours.head,
+ message: 'Keep asset executable',
+ }),
+ 'set exact local mode',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: mine.head,
+ title: 'Review non-text conflicts',
+ })
+ const other = await commitFiles(
+ dana,
+ repo,
+ { 'asset.bin': new Uint8Array([0, 3]) },
+ 'Upstream asset',
+ )
+ const branch = await dana.ok_(
+ dana.post(`/v1/repos/${repo}/workspaces/${other.workspace}/changes`, {
+ changes: [
+ {
+ path: 'launch',
+ op: 'put',
+ content: Buffer.from('branch-target').toString('base64'),
+ file_type: 'symlink',
+ },
+ ],
+ expected_head: other.head,
+ message: 'Launcher becomes a link',
+ }),
+ 'set upstream link',
+ )
+ const upstream = await openProposal(dana, repo, {
+ head: branch.head,
+ title: 'Land upstream versions',
+ })
+ await integrate(cast.as('carol'), repo, upstream.id)
+ return { repo, alice, dana, mine, branch, proposal }
+}
+
+test('an author keeps exact binary and symlink versions without a text round trip', async ({
+ cast,
+ openAs,
+}, info) => {
+ const { repo, alice, mine, branch, proposal } = await conflict(cast)
+ const sourceAsset = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${mine.head}&path=asset.bin`),
+ 'read local asset identity',
+ )
+ const sourceLink = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${branch.head}&path=launch`),
+ 'read branch link identity',
+ )
+ const page = await openAs('alice')
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await page.getByTestId('fix-conflict').click()
+ const resolver = page.getByTestId('resolver')
+ await expect(resolver.locator('[data-path="asset.bin"]')).toBeVisible()
+ await expect(page.getByTestId('resolve-save')).toBeDisabled()
+ await expect(resolver.locator('textarea')).toHaveCount(0)
+ await resolver
+ .locator('[data-path="asset.bin"]')
+ .getByRole('button', { name: 'Keep mine', exact: true })
+ .click()
+ await resolver
+ .locator('[data-path="launch"]')
+ .getByRole('button', { name: 'Keep the branch’s', exact: true })
+ .click()
+ await page.screenshot({ path: info.outputPath('nontext-resolution.png'), fullPage: true })
+ await page.getByTestId('resolve-save').click()
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ const detail = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read resolved revision',
+ )
+ expect(detail.proposal.head).not.toBe(mine.head)
+ for (const [path, source] of [
+ ['asset.bin', sourceAsset.entry],
+ ['launch', sourceLink.entry],
+ ]) {
+ const resolved = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${detail.proposal.head}&path=${path}`),
+ 'read preserved entry',
+ )
+ expect(resolved.entry).toMatchObject({
+ blob_id: source.blob_id,
+ file_type: source.file_type,
+ executable: source.executable,
+ })
+ }
+ await page.close()
+})
+
+test('a branch change keeps selected resolutions and cannot publish a stale merge', async ({
+ cast,
+ openAs,
+ allowConsole,
+}) => {
+ const { repo, alice, dana, mine, proposal } = await conflict(cast)
+ const page = await openAs('alice')
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await page.getByTestId('fix-conflict').click()
+ const resolver = page.getByTestId('resolver')
+ await resolver
+ .locator('[data-path="asset.bin"]')
+ .getByRole('button', { name: 'Keep mine', exact: true })
+ .click()
+ await resolver
+ .locator('[data-path="launch"]')
+ .getByRole('button', { name: 'Keep the branch’s', exact: true })
+ .click()
+ const next = await commitFiles(
+ dana,
+ repo,
+ { 'later.txt': 'new upstream work' },
+ 'Advance branch after inspection',
+ )
+ const upstream = await openProposal(dana, repo, { head: next.head, title: 'Later upstream work' })
+ await integrate(cast.as('carol'), repo, upstream.id)
+ allowConsole(/status of 412.*\/workspaces\/[^/]+\/sync\]$/)
+ await page.getByTestId('resolve-save').click()
+ await expect(page.getByRole('dialog')).toContainText(
+ 'Your resolution has not replaced the newer changes',
+ )
+ await expect(page.getByTestId('resolve-save')).toBeDisabled()
+ const current = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read retained proposal',
+ )
+ expect(current.proposal.head).toBe(mine.head)
+ await page.close()
+})
diff --git a/apps/web/e2e/proposal-layout.spec.ts b/apps/web/e2e/proposal-layout.spec.ts
new file mode 100644
index 00000000..c3ee8dfa
--- /dev/null
+++ b/apps/web/e2e/proposal-layout.spec.ts
@@ -0,0 +1,53 @@
+import { test, expect } from './fixtures.ts'
+import { freshRepo, commitFiles, openProposal } from './support/world.ts'
+import { expectAccessible } from './support/ui.ts'
+
+for (const viewport of [
+ { name: 'desktop', width: 1440, height: 900 },
+ { name: 'phone', width: 390, height: 844 },
+]) {
+ test(`the ${viewport.name} review workspace keeps changed code ahead of secondary actions`, async ({
+ cast,
+ openAs,
+ }, info) => {
+ const { repo } = await freshRepo(cast, 'review-layout')
+ const alice = cast.as('alice')
+ const revision = await commitFiles(
+ alice,
+ repo,
+ {
+ 'src/session.ts':
+ 'export function createSession(user: string) {\n return { user, expiresIn: 3600 }\n}\n',
+ 'test/session.test.ts': 'expect(createSession("alex").expiresIn).toBe(3600)\n',
+ },
+ 'Bound session lifetime',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: revision.head,
+ title: 'Expire inactive sessions after one hour',
+ })
+ const page = await openAs('bob')
+ await page.setViewportSize(viewport)
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await expect(page.getByTestId('changes-tab')).toContainText('src/session.ts')
+ await expect(
+ page.getByRole('list', { name: /four independent states/ }).getByText('checking'),
+ ).toHaveCount(0)
+ await page.screenshot({
+ path: info.outputPath(`proposal-${viewport.name}.png`),
+ fullPage: true,
+ })
+ const tabs = await page.getByRole('tablist', { name: 'Change request details' }).boundingBox()
+ expect(tabs!.y).toBeLessThan(viewport.width < 600 ? 650 : 500)
+ const review = await page.getByTestId('act-review').boundingBox()
+ expect(review!.y).toBeLessThan(tabs!.y)
+ await page.getByTestId('act-review').click()
+ await expect(page.getByRole('dialog', { name: 'Write a review' })).toBeVisible()
+ await page.getByRole('button', { name: 'Keep draft and close' }).click()
+ await expectAccessible(page, info, `${viewport.name} review workspace`)
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
+ viewport.width,
+ )
+ await page.close()
+ })
+}
diff --git a/apps/web/e2e/rename-conflicts.spec.ts b/apps/web/e2e/rename-conflicts.spec.ts
new file mode 100644
index 00000000..32ab18a8
--- /dev/null
+++ b/apps/web/e2e/rename-conflicts.spec.ts
@@ -0,0 +1,127 @@
+import { test, expect } from './fixtures.ts'
+import { freshRepo, commitFiles, openProposal, integrate } from './support/world.ts'
+
+const base =
+ 'heading\nshared line one\nshared line two\nshared line three\nvalue=base\nshared line four\nshared line five\nend\n'
+const mine = base.replace('value=base', 'value=proposal')
+const branch = base.replace('value=base', 'value=branch')
+
+for (const renamed of ['proposal', 'branch']) {
+ test(`renamed ${renamed} side preserves both overlapping edits and writes the selected source`, async ({
+ cast,
+ openAs,
+ }, info) => {
+ const { repo } = await freshRepo(cast, 'rename-conflict', { 'old.txt': base })
+ const alice = cast.as('alice')
+ const dana = cast.as('dana')
+ const ours = await commitFiles(
+ alice,
+ repo,
+ renamed === 'proposal' ? { 'old.txt': null, 'renamed.txt': mine } : { 'old.txt': mine },
+ 'Proposal edit',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: ours.head,
+ title: 'Resolve renamed overlapping edits',
+ })
+ const theirs = await commitFiles(
+ dana,
+ repo,
+ renamed === 'branch' ? { 'old.txt': null, 'renamed.txt': branch } : { 'old.txt': branch },
+ 'Branch edit',
+ )
+ const upstream = await openProposal(dana, repo, { head: theirs.head, title: 'Branch update' })
+ await integrate(cast.as('carol'), repo, upstream.id)
+ const page = await openAs('alice')
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await page.getByTestId('fix-conflict').click()
+ const row = page.getByTestId('resolver').locator('[data-path="renamed.txt"]')
+ await expect(row.getByRole('region', { name: 'Your version of renamed.txt' })).toContainText(
+ 'value=proposal',
+ )
+ await expect(
+ row.getByRole('region', { name: 'The branch’s version of renamed.txt' }),
+ ).toContainText('value=branch')
+ await expect(row).toContainText('Source: old.txt')
+ await expect(row).not.toContainText('deleted this file')
+ const chooseBranch = renamed === 'proposal'
+ await row
+ .getByRole('button', { name: chooseBranch ? 'Keep the branch’s' : 'Keep mine', exact: true })
+ .click()
+ await page.screenshot({ path: info.outputPath('rename-conflict.png'), fullPage: true })
+ await page.getByTestId('resolve-save').click()
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ const detail = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'resolved proposal',
+ )
+ const expectedHead = chooseBranch ? theirs.head : ours.head
+ const source = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${expectedHead}&path=old.txt`),
+ 'source identity',
+ )
+ const result = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${detail.proposal.head}&path=renamed.txt`),
+ 'resolved identity',
+ )
+ expect(result.entry.blob_id).toBe(source.entry.blob_id)
+ const old = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${detail.proposal.head}&path=old.txt`),
+ 'old path stays absent',
+ )
+ expect(old.kind).toBe('missing')
+ await page.close()
+ })
+
+ test(`rename/delete with ${renamed} rename preserves the existing side`, async ({
+ cast,
+ openAs,
+ }) => {
+ const { repo } = await freshRepo(cast, 'rename-delete', { 'old.txt': base })
+ const alice = cast.as('alice')
+ const dana = cast.as('dana')
+ const ours = await commitFiles(
+ alice,
+ repo,
+ renamed === 'proposal' ? { 'old.txt': null, 'renamed.txt': base } : { 'old.txt': null },
+ 'Proposal move or delete',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: ours.head,
+ title: 'Resolve rename and delete',
+ })
+ const theirs = await commitFiles(
+ dana,
+ repo,
+ renamed === 'branch' ? { 'old.txt': null, 'renamed.txt': base } : { 'old.txt': null },
+ 'Branch move or delete',
+ )
+ const upstream = await openProposal(dana, repo, { head: theirs.head, title: 'Branch update' })
+ await integrate(cast.as('carol'), repo, upstream.id)
+ const page = await openAs('alice')
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await page.getByTestId('fix-conflict').click()
+ const row = page.getByTestId('resolver').locator('[data-path="renamed.txt"]')
+ await expect(row).toContainText(
+ renamed === 'proposal' ? '(the branch deleted this file)' : '(you deleted this file)',
+ )
+ await row
+ .getByRole('button', {
+ name: renamed === 'proposal' ? 'Keep mine' : 'Keep the branch’s',
+ exact: true,
+ })
+ .click()
+ await page.getByTestId('resolve-save').click()
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ const detail = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'resolved proposal',
+ )
+ const result = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${detail.proposal.head}&path=renamed.txt`),
+ 'preserved renamed file',
+ )
+ expect(result.entry.file_type).toBe('regular')
+ await page.close()
+ })
+}
diff --git a/apps/web/e2e/review-requests.spec.ts b/apps/web/e2e/review-requests.spec.ts
new file mode 100644
index 00000000..36ccac11
--- /dev/null
+++ b/apps/web/e2e/review-requests.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from './fixtures.ts'
+import { freshRepo, commitFiles, openProposal } from './support/world.ts'
+
+test('requested reviewers persist, follow the current revision and preserve a conflicting draft', async ({
+ cast,
+ openAs,
+ allowConsole,
+}) => {
+ const { repo } = await freshRepo(cast, 'review-requests')
+ const alice = cast.as('alice')
+ const initial = await commitFiles(alice, repo, { 'code.txt': 'first\n' }, 'Review source')
+ const proposal = await openProposal(alice, repo, {
+ head: initial.head,
+ title: 'Ask for a review',
+ })
+ const url = `/repos/${repo}/proposals/${proposal.id}?tab=reviews`
+ const author = await openAs('alice')
+ await author.goto(url)
+ await author.getByRole('button', { name: 'Request review', exact: true }).click()
+ const dialog = author.getByRole('dialog', { name: 'Request reviewers' })
+ await dialog.getByRole('textbox', { name: 'Reviewer identities' }).fill('user:bob')
+ const before = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read unchanged request',
+ )
+ expect(before.requested_reviewers).toEqual([])
+ await dialog.getByRole('button', { name: 'Save reviewer requests' }).click()
+ await expect(dialog).toHaveCount(0)
+ await author.reload()
+ const requests = author.getByRole('region', { name: 'Requested reviewers' })
+ await expect(requests).toContainText('bob')
+ await expect(requests).toContainText('Review requested')
+ const saved = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read reviewer request',
+ )
+ expect(saved.proposal.head).toBe(initial.head)
+ expect(saved.requested_reviewers).toEqual(['user:bob'])
+ expect(saved.reviews).toEqual([])
+ await openProposal(alice, repo, { head: initial.head, title: 'No review requested here' })
+ const reviewer = await openAs('bob')
+ await reviewer.goto(`/repos/${repo}/proposals?reviewer=me`)
+ await expect(reviewer.getByRole('checkbox', { name: 'Review requested from me' })).toBeChecked()
+ await expect(reviewer.getByRole('link', { name: 'Ask for a review', exact: true })).toBeVisible()
+ await expect(
+ reviewer.getByRole('link', { name: 'No review requested here', exact: true }),
+ ).toHaveCount(0)
+ await reviewer.reload()
+ await expect(reviewer.getByRole('link', { name: 'Ask for a review', exact: true })).toBeVisible()
+ await reviewer.goto(url)
+ await expect(reviewer.getByRole('button', { name: 'Request review', exact: true })).toHaveCount(0)
+ await cast.as('bob').ok_(
+ cast.as('bob').post(`/v1/repos/${repo}/proposals/${proposal.id}/reviews`, {
+ decision: 'approve',
+ expected_head: initial.head,
+ }),
+ 'approve inspected revision',
+ )
+ await author.reload()
+ await expect(requests).toContainText('Reviewed this revision')
+ const next = await commitFiles(alice, repo, { 'code.txt': 'second\n' }, 'New revision')
+ await alice.ok_(
+ alice.call('PATCH', `/v1/repos/${repo}/proposals/${proposal.id}`, {
+ new_head: next.head,
+ expected_head: initial.head,
+ }),
+ 'move proposal head',
+ )
+ await author.reload()
+ await expect(requests).toContainText('Review requested')
+ await author.getByRole('button', { name: 'Request review', exact: true }).click()
+ await dialog.getByRole('textbox', { name: 'Reviewer identities' }).fill('user:carol')
+ await alice.ok_(
+ alice.call('PUT', `/v1/repos/${repo}/proposals/${proposal.id}/reviewers`, {
+ reviewers: ['user:dave'],
+ expected_reviewers: ['user:bob'],
+ }),
+ 'concurrent reviewer edit',
+ )
+ // This click deliberately provokes the server's inspected-set precondition.
+ allowConsole(/status of 412.*\/proposals\/[^/]+\/reviewers\]$/)
+ await dialog.getByRole('button', { name: 'Save reviewer requests' }).click()
+ await expect(dialog).toContainText('requested reviewers changed')
+ await expect(dialog.getByRole('textbox', { name: 'Reviewer identities' })).toHaveValue(
+ 'user:carol',
+ )
+ const retained = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read winning edit',
+ )
+ expect(retained.requested_reviewers).toEqual(['user:dave'])
+ await author.close()
+ await reviewer.close()
+})
diff --git a/apps/web/e2e/review-suggestions.spec.ts b/apps/web/e2e/review-suggestions.spec.ts
index 94441f9f..79044c85 100644
--- a/apps/web/e2e/review-suggestions.spec.ts
+++ b/apps/web/e2e/review-suggestions.spec.ts
@@ -102,3 +102,177 @@ test('a reviewer proposes code and the author applies the exact replacement with
await author.close()
await reviewer.close()
})
+
+test('the author previews and applies selected suggestions across files in one revision', async ({
+ cast,
+ openAs,
+}, info) => {
+ const { repo } = await freshRepo(cast, 'batch-suggestions')
+ const alice = cast.as('alice')
+ const initial = await commitFiles(
+ alice,
+ repo,
+ {
+ 'script.sh': '\uFEFFfirst\r\nsecond\r\nthird\r\n',
+ 'note.txt': 'old\n',
+ },
+ 'Files for review',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: initial.head,
+ title: 'Apply reviewed fixes together',
+ })
+ const entries = [
+ { path: 'script.sh', line: 1, replacement: 'one\nextra' },
+ { path: 'script.sh', line: 3, replacement: 'three' },
+ { path: 'note.txt', line: 1, replacement: 'new' },
+ ]
+ const bob = cast.as('bob')
+ for (const entry of entries) {
+ await bob.ok_(
+ bob.post(`/v1/repos/${repo}/proposals/${proposal.id}/threads`, {
+ expected_head: initial.head,
+ anchor: {
+ base: initial.base,
+ head: initial.head,
+ side: 'new',
+ path: entry.path,
+ line: entry.line,
+ },
+ body: `Use reviewed text.\n\n\`\`\`suggestion\n${entry.replacement}\n\`\`\``,
+ }),
+ 'create suggestion',
+ )
+ }
+ const url = `/repos/${repo}/proposals/${proposal.id}?tab=reviews`
+ const reviewer = await openAs('bob')
+ await reviewer.goto(url)
+ await expect(reviewer.getByText('Use reviewed text.', { exact: true })).toHaveCount(3)
+ await expect(reviewer.getByText('Apply several suggestions', { exact: true })).toHaveCount(0)
+ const author = await openAs('alice')
+ await author.goto(url)
+ await author.getByText('Apply several suggestions', { exact: true }).click()
+ for (const entry of entries)
+ await author
+ .getByRole('checkbox', { name: `${entry.path}, line ${entry.line}`, exact: true })
+ .check()
+ await author.getByRole('button', { name: 'Review selected suggestions', exact: true }).click()
+ const dialog = author.getByRole('dialog', { name: 'Apply selected suggestions?' })
+ await expect(
+ dialog.getByRole('button', { name: 'Apply selected suggestions', exact: true }),
+ ).toBeEnabled()
+ await expect(dialog).toContainText('3 suggestions in 2 files')
+ expect(
+ (
+ await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'inspect original revision',
+ )
+ ).proposal.head,
+ ).toBe(initial.head)
+ await author.screenshot({ path: info.outputPath('batch-suggestion-preview.png'), fullPage: true })
+ const mutations: string[] = []
+ author.on('request', (request) => {
+ if (request.method() === 'POST' && new URL(request.url()).pathname.endsWith('/changes'))
+ mutations.push('changes')
+ if (
+ request.method() === 'PATCH' &&
+ new URL(request.url()).pathname.endsWith(`/proposals/${proposal.id}`)
+ )
+ mutations.push('proposal')
+ })
+ await dialog.getByRole('button', { name: 'Apply selected suggestions', exact: true }).click()
+ await expect(dialog).toHaveCount(0)
+ expect(mutations).toEqual(['changes', 'proposal'])
+ const current = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read resulting revision',
+ )
+ expect(current.proposal.head).not.toBe(initial.head)
+ for (const [path, text] of [
+ ['script.sh', '\uFEFFone\r\nextra\r\nsecond\r\nthree\r\n'],
+ ['note.txt', 'new\n'],
+ ]) {
+ const response = await fetch(
+ `${cast.baseURL}/v1/repos/${repo}/files/${path}?rev=${current.proposal.head}`,
+ { headers: { authorization: `Bearer ${cast.token('alice')}` } },
+ )
+ expect(response.status).toBe(200)
+ expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual(
+ Array.from(new TextEncoder().encode(text)),
+ )
+ }
+ const threads = await bob.ok_(
+ bob.get(`/v1/repos/${repo}/proposals/${proposal.id}/threads`),
+ 'read retained discussions',
+ )
+ expect(threads.threads).toHaveLength(3)
+ expect(threads.threads.every((thread: { resolved: boolean }) => !thread.resolved)).toBe(true)
+ await reviewer.close()
+ await author.close()
+})
+
+test('a reviewer selects a range and the author confirms the exact prepared file', async ({
+ cast,
+ openAs,
+}, info) => {
+ const { repo } = await freshRepo(cast, 'range-suggestions')
+ const alice = cast.as('alice')
+ const original = '\uFEFFfirst\r\nold one\r\nold two\r\nlast\r\n'
+ const initial = await commitFiles(alice, repo, { 'code.txt': original }, 'Range source')
+ const proposal = await openProposal(alice, repo, { head: initial.head, title: 'Review a block' })
+ const url = `/repos/${repo}/proposals/${proposal.id}`
+ const reviewer = await openAs('bob')
+ await reviewer.goto(url)
+ await reviewer
+ .getByRole('button', { name: 'Discuss new line 2 of code.txt', exact: true })
+ .click()
+ await reviewer.getByRole('button', { name: 'Suggest a replacement', exact: true }).click()
+ await reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }).fill('101')
+ await expect(
+ reviewer.getByRole('button', { name: 'Start discussion', exact: true }),
+ ).toBeDisabled()
+ await reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }).fill('2')
+ await reviewer
+ .getByRole('textbox', { name: 'Replacement code', exact: true })
+ .fill('new one\nnew two\nextra')
+ await reviewer.reload()
+ await expect(
+ reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }),
+ ).toHaveValue('2')
+ await expect(
+ reviewer.getByRole('textbox', { name: 'Replacement code', exact: true }),
+ ).toHaveValue('new one\nnew two\nextra')
+ await reviewer.getByRole('button', { name: 'Start discussion', exact: true }).click()
+ await expect(reviewer.getByRole('region', { name: 'Suggested replacement' })).toBeVisible()
+ const author = await openAs('alice')
+ await author.goto(`${url}?tab=reviews`)
+ await author.getByRole('button', { name: 'Review and apply suggestion', exact: true }).click()
+ const dialog = author.getByRole('dialog', { name: 'Apply this suggestion?' })
+ await expect(dialog).toContainText('lines 2–3')
+ await expect(dialog).toContainText('old two')
+ await expect(dialog).toContainText('new two')
+ await author.screenshot({ path: info.outputPath('range-suggestion-preview.png'), fullPage: true })
+ const before = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'check before range confirmation',
+ )
+ expect(before.proposal.head).toBe(initial.head)
+ await dialog.getByRole('button', { name: 'Apply suggestion', exact: true }).click()
+ await expect(dialog).toHaveCount(0)
+ const after = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'read range revision',
+ )
+ expect(after.proposal.head).not.toBe(initial.head)
+ const response = await fetch(
+ `${alice.base}/v1/repos/${repo}/files/code.txt?rev=${after.proposal.head}`,
+ { headers: { Authorization: `Bearer ${alice.token}` } },
+ )
+ expect(response.status).toBe(200)
+ expect(Buffer.from(await response.arrayBuffer())).toEqual(
+ Buffer.from('\uFEFFfirst\r\nnew one\r\nnew two\r\nextra\r\nlast\r\n'),
+ )
+ await author.close()
+ await reviewer.close()
+})
diff --git a/apps/web/e2e/structural-conflicts.spec.ts b/apps/web/e2e/structural-conflicts.spec.ts
new file mode 100644
index 00000000..f6d947c1
--- /dev/null
+++ b/apps/web/e2e/structural-conflicts.spec.ts
@@ -0,0 +1,129 @@
+import { test, expect } from './fixtures.ts'
+import { freshRepo, commitFiles, openProposal, integrate } from './support/world.ts'
+
+for (const keepFolder of [true, false])
+ for (const directorySide of ['proposal', 'branch']) {
+ test(`chooses ${keepFolder ? 'folder' : 'file'} against ${directorySide} folder and preserves independent clean edits`, async ({
+ cast,
+ openAs,
+ }, info) => {
+ const { repo } = await freshRepo(cast, 'structural-conflict')
+ const alice = cast.as('alice'),
+ dana = cast.as('dana')
+ const folder = { 'clash/nested/run': 'run\n', 'clash/link': 'target\n' }
+ const ours = await commitFiles(
+ alice,
+ repo,
+ directorySide === 'proposal'
+ ? { ...folder, 'mine-clean.txt': 'mine clean\n' }
+ : { clash: 'my file\n', 'mine-clean.txt': 'mine clean\n' },
+ 'Proposal layout',
+ )
+ const theirs = await commitFiles(
+ dana,
+ repo,
+ directorySide === 'branch'
+ ? { ...folder, 'branch-clean.txt': 'branch clean\n' }
+ : { clash: 'branch file\n', 'branch-clean.txt': 'branch clean\n' },
+ 'Branch layout',
+ )
+ const owner = directorySide === 'proposal' ? alice : dana,
+ source = directorySide === 'proposal' ? ours : theirs
+ const exact = await owner.ok_(
+ owner.post(`/v1/repos/${repo}/workspaces/${source.workspace}/changes`, {
+ expected_head: source.head,
+ message: 'Preserve type and mode',
+ changes: [
+ {
+ path: 'clash/nested/run',
+ op: 'put',
+ content: Buffer.from('run\n').toString('base64'),
+ executable: true,
+ },
+ {
+ path: 'clash/link',
+ op: 'put',
+ content: Buffer.from('target').toString('base64'),
+ file_type: 'symlink',
+ },
+ ],
+ }),
+ 'set exact entries',
+ )
+ const proposal = await openProposal(alice, repo, {
+ head: directorySide === 'proposal' ? exact.head : ours.head,
+ title: 'Choose a whole folder',
+ })
+ const upstream = await openProposal(dana, repo, {
+ head: directorySide === 'branch' ? exact.head : theirs.head,
+ title: 'Branch structure',
+ })
+ await integrate(cast.as('carol'), repo, upstream.id)
+ const page = await openAs('alice')
+ await page.goto(`/repos/${repo}/proposals/${proposal.id}`)
+ await page.getByTestId('fix-conflict').click()
+ const row = page.getByTestId('resolver').locator('[data-path="clash"]')
+ await expect(row).toBeVisible()
+ await expect(page.getByTestId('resolve-save')).toBeDisabled()
+ await row
+ .getByRole('button', {
+ name: keepFolder
+ ? directorySide === 'proposal'
+ ? 'Keep mine (2 files)'
+ : 'Keep the branch’s (2 files)'
+ : directorySide === 'proposal'
+ ? 'Keep the branch’s (1 file)'
+ : 'Keep mine (1 file)',
+ exact: true,
+ })
+ .click()
+ await page.screenshot({ path: info.outputPath('structural-conflict.png') })
+ await page.getByTestId('resolve-save').click()
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ const detail = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`),
+ 'resolved proposal',
+ )
+ const head = detail.proposal.head
+ if (keepFolder)
+ for (const path of ['clash/nested/run', 'clash/link']) {
+ const result = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${head}&path=${path}`),
+ 'preserved entry',
+ )
+ const original = await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${exact.head}&path=${path}`),
+ 'source entry',
+ )
+ expect(result.entry).toEqual(original.entry)
+ }
+ for (const path of ['mine-clean.txt', 'branch-clean.txt'])
+ expect(
+ (
+ await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${head}&path=${path}`),
+ 'independent edit',
+ )
+ ).entry,
+ ).not.toBeNull()
+ expect(
+ (
+ await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${head}&path=clash`),
+ 'folder chosen',
+ )
+ ).kind,
+ ).toBe(keepFolder ? 'dir' : 'file')
+ if (!keepFolder)
+ for (const path of ['clash/link', 'clash/nested/run'])
+ expect(
+ (
+ await alice.ok_(
+ alice.get(`/v1/repos/${repo}/tree/resolve?rev=${head}&path=${path}`),
+ 'removed child',
+ )
+ ).kind,
+ ).toBe('missing')
+ await page.close()
+ })
+ }
diff --git a/apps/web/package.json b/apps/web/package.json
index c2c871ef..b96be13f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -18,7 +18,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
- "gen:api": "openapi-typescript ../../docs/protocol/openapi.json -o src/api/schema.gen.ts && prettier --write src/api/schema.gen.ts",
+ "gen:api": "node scripts/generate-api.mjs src/api/schema.gen.ts && prettier --write src/api/schema.gen.ts",
"check:api": "node scripts/check-api.mjs",
"pretest:e2e": "npm run build",
"demo:server": "npm run build && node e2e/demo.ts"
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index 654b52cf..72490f00 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -26,7 +26,7 @@ const screenshots = /screenshots\.spec\.ts/
// Layout assertions are made at the viewports of the tablet and phone projects, not at desktop size.
const layout = /responsive\.spec\.ts/
const small =
- /(responsive|accessibility|code-workflows|code-search|task-details|task-history|review-workbench|review-threads|review-suggestions|required-discussions|draft-recovery|review-drafts|multi-file-edit|execution-control|proposal-comments|run-details|run-recovery|task-assignment|task-refinement|task-attempt-limit|home-live|policy-controls|executor-presence|executor-setup|run-output)\.spec\.ts/
+ /(responsive|accessibility|code-workflows|code-search|task-details|task-history|review-workbench|review-threads|review-suggestions|nontext-conflicts|rename-conflicts|structural-conflicts|review-requests|required-discussions|draft-recovery|review-drafts|account-review-draft|account-comment-draft|multi-file-edit|execution-control|proposal-comments|run-details|run-recovery|task-assignment|task-refinement|task-attempt-limit|home-live|policy-controls|executor-presence|executor-setup|run-output)\.spec\.ts/
const desktop = { viewport: { width: 1440, height: 900 } }
export default defineConfig({
diff --git a/apps/web/scripts/check-api.mjs b/apps/web/scripts/check-api.mjs
index f84e56fa..332a0fcb 100644
--- a/apps/web/scripts/check-api.mjs
+++ b/apps/web/scripts/check-api.mjs
@@ -20,7 +20,7 @@ function run(cmd, args) {
}
try {
- run(bin('openapi-typescript'), ['../../docs/protocol/openapi.json', '-o', out])
+ run(process.execPath, ['scripts/generate-api.mjs', out])
run(bin('prettier'), ['--config', join(root, '.prettierrc.json'), '--write', out])
const fresh = readFileSync(out, 'utf8')
const committed = readFileSync(join(root, 'src/api/schema.gen.ts'), 'utf8')
diff --git a/apps/web/scripts/generate-api.mjs b/apps/web/scripts/generate-api.mjs
new file mode 100644
index 00000000..606bc036
--- /dev/null
+++ b/apps/web/scripts/generate-api.mjs
@@ -0,0 +1,13 @@
+// Keep additive fields optional: runtime responses from older servers are not default-filled.
+import { readFileSync, writeFileSync } from 'node:fs'
+import openapiTS, { astToString } from 'openapi-typescript'
+
+const schema = JSON.parse(readFileSync('../../docs/protocol/openapi.json', 'utf8'))
+const conflict = schema.components.schemas.Conflict
+for (const field of ['origin', 'resolution_scope']) {
+ if (conflict.properties[field] && !conflict.required?.includes(field))
+ delete conflict.properties[field].default
+}
+const header =
+ '/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\n'
+writeFileSync(process.argv[2], header + astToString(await openapiTS(schema)))
diff --git a/apps/web/src/api/endpoints.spec.ts b/apps/web/src/api/endpoints.spec.ts
index 7ee34c13..e95dc680 100644
--- a/apps/web/src/api/endpoints.spec.ts
+++ b/apps/web/src/api/endpoints.spec.ts
@@ -30,6 +30,9 @@ const invoke = (api: ReturnType, operation: string) => {
options,
)
+ if (operation === 'reviewers')
+ return api.proposals.setReviewers('acme/new', 'proposal-one', ['user:bob'], [], options)
+
if (operation === 'proposal-update')
return api.proposals.update('acme/new', 'proposal-one', 'new-head', 'seen-head', options)
@@ -61,6 +64,7 @@ describe('repository setup request ownership', () => {
'workspace-remove',
'proposal-create',
'proposal-update',
+ 'reviewers',
'review',
'task-dependency',
@@ -100,6 +104,7 @@ describe('repository setup request ownership', () => {
'workspace-remove',
'proposal-create',
'proposal-update',
+ 'reviewers',
'review',
'task-dependency',
@@ -146,3 +151,113 @@ it('patches an attempt limit with its inspected value and an encoded task id', a
expect(options?.method).toBe('PATCH')
expect(JSON.parse(String(options?.body))).toEqual({ max_attempts: 0, expected_max_attempts: 2 })
})
+
+it('sends requested reviewers and the inspected set to the encoded proposal path', async () => {
+ const request = vi.fn().mockResolvedValue(
+ new Response('{"reviewers":["user:bob"]}', {
+ headers: { 'content-type': 'application/json' },
+ }),
+ )
+ const client = new ApiClient({ getToken: () => 'session', fetch: request })
+ await createApi(client).proposals.setReviewers('acme/repo', 'p/one', ['user:bob'], ['user:carol'])
+ const [url, options] = request.mock.calls[0]!
+ expect(String(url)).toContain('/v1/repos/acme/repo/proposals/p%2Fone/reviewers')
+ expect(options?.method).toBe('PUT')
+ expect(JSON.parse(String(options?.body))).toEqual({
+ reviewers: ['user:bob'],
+ expected_reviewers: ['user:carol'],
+ })
+})
+
+it('pins virtual sync to the inspected branch while retaining the initiating session', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(new Response('{}', { headers: { 'content-type': 'application/json' } }))
+ const client = new ApiClient({ getToken: () => 'new-session', fetch: request })
+ await createApi(client).workspaces.sync('acme/repo', 'w/one', [], {
+ token: 'original-session',
+ expectedStreamHead: 'inspected',
+ })
+ const [url, options] = request.mock.calls[0]!
+ expect(String(url)).toContain('/workspaces/w%2Fone/sync')
+ expect(JSON.parse(String(options?.body))).toEqual({ expected_stream_head: 'inspected' })
+ expect(new Headers(options?.headers).get('authorization')).toBe('Bearer original-session')
+})
+
+it('keeps private draft content in the save body and captures the initiating session', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(new Response('{}', { headers: { 'content-type': 'application/json' } }))
+ const client = new ApiClient({ getToken: () => 'new-session', fetch: request })
+ await createApi(client).proposals.saveReviewDraft(
+ 'acme/repo',
+ 'p/one',
+ { head: 'seen', decision: 'comment', body: 'private', expected_version: 0 },
+ { token: 'original', quietUnauthenticated: true },
+ )
+ const [url, options] = request.mock.calls[0]!
+ expect(String(url)).toContain('/proposals/p%2Fone/review-draft')
+ expect(String(url)).not.toContain('private')
+ expect(options?.method).toBe('PUT')
+ expect(new Headers(options?.headers).get('authorization')).toBe('Bearer original')
+ expect(JSON.parse(String(options?.body))).toEqual({
+ head: 'seen',
+ decision: 'comment',
+ body: 'private',
+ expected_version: 0,
+ })
+})
+
+it('sends the clear-draft version in a DELETE query, including zero', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(new Response('{}', { headers: { 'content-type': 'application/json' } }))
+ const client = new ApiClient({ getToken: () => 'session', fetch: request })
+ await createApi(client).proposals.clearReviewDraft('acme/repo', 'p/one', 0)
+ const [url, options] = request.mock.calls[0]!
+ expect(String(url)).toContain('/proposals/p%2Fone/review-draft?expected_version=0')
+ expect(options?.method).toBe('DELETE')
+ expect(options?.body).toBeUndefined()
+})
+
+it('keeps account comment drafts distinct from reviews and sends private text only in the body', async () => {
+ const request = vi
+ .fn()
+ .mockImplementation(
+ async () => new Response('{}', { headers: { 'content-type': 'application/json' } }),
+ )
+ const client = new ApiClient({ getToken: () => 'current-session', fetch: request })
+ const proposals = createApi(client).proposals
+ await proposals.commentDraft('acme/repo', 'p/one', { token: 'captured-session' })
+ await proposals.saveCommentDraft(
+ 'acme/repo',
+ 'p/one',
+ { head: 'inspected', body: 'unsubmitted notes', expected_version: 7 },
+ { token: 'captured-session' },
+ )
+ expect(request.mock.calls).toHaveLength(2)
+ for (const [url, options] of request.mock.calls) {
+ expect(String(url)).toContain('/proposals/p%2Fone/comment-draft')
+ expect(String(url)).not.toContain('unsubmitted')
+ expect(new Headers(options?.headers).get('authorization')).toBe('Bearer captured-session')
+ }
+ const [, saved] = request.mock.calls[1]!
+ expect(saved?.method).toBe('PUT')
+ expect(JSON.parse(String(saved?.body))).toEqual({
+ head: 'inspected',
+ body: 'unsubmitted notes',
+ expected_version: 7,
+ })
+})
+
+it('clears a private comment draft using its inspected version query', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValue(new Response('{}', { headers: { 'content-type': 'application/json' } }))
+ const client = new ApiClient({ getToken: () => 'session', fetch: request })
+ await createApi(client).proposals.clearCommentDraft('acme/repo', 'p/one', 0)
+ const [url, options] = request.mock.calls[0]!
+ expect(String(url)).toContain('/proposals/p%2Fone/comment-draft?expected_version=0')
+ expect(options?.method).toBe('DELETE')
+ expect(options?.body).toBeUndefined()
+})
diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts
index a3610c05..ed02663f 100644
--- a/apps/web/src/api/endpoints.ts
+++ b/apps/web/src/api/endpoints.ts
@@ -7,6 +7,8 @@ import type {
Agent,
Issue,
IssueActivity,
+ IssueImportPreview,
+ IssueImportResult,
AuditEntry,
AuthConfig,
Capabilities,
@@ -33,6 +35,10 @@ import type {
Repo,
Req,
Review,
+ ReviewDraft,
+ CommentDraft,
+ FileDraft,
+ FileDraftSummary,
ReviewThread,
ThreadComment,
RoleBinding,
@@ -62,6 +68,19 @@ export function createApi(c: ApiClient) {
const r = repoPath
return {
client: c,
+ files: {
+ draft: (repo: string, stream: string, path: string, options?: SessionOpts) =>
+ c.get(r(repo, '/file-draft'), { stream, path }, options),
+ listDrafts: async (repo: string, stream: string, options?: SessionOpts) =>
+ (await c.get<{ drafts: FileDraftSummary[] }>(r(repo, '/file-drafts'), { stream }, options)).drafts ?? [],
+ saveDraft: (
+ repo: string,
+ body: { stream: string; path: string; base: string; content: string; expected_version: number },
+ options?: SessionOpts,
+ ) => c.json(r(repo, '/file-draft'), { ...options, method: 'PUT', body }),
+ clearDraft: (repo: string, stream: string, path: string, expectedVersion: number, options?: SessionOpts) =>
+ c.del(r(repo, '/file-draft'), { stream, path, expected_version: expectedVersion }, options),
+ },
// ---- session --------------------------------------------------------------------------------
capabilities: (o?: Opts) =>
@@ -205,6 +224,13 @@ export function createApi(c: ApiClient) {
},
issues: {
+ previewImport: (repo: string, body: { manifest_json: string }, options?: SessionOpts) =>
+ c.post(r(repo, '/issues/import/preview'), body, options),
+ applyImport: (
+ repo: string,
+ body: { manifest_json: string; expected_digest: string; allow_unsupported: boolean },
+ options?: SessionOpts,
+ ) => c.post(r(repo, '/issues/import/apply'), body, options),
list: (
repo: string,
q: { state?: string; query?: string; page_size?: number; page_token?: string } = {},
@@ -253,6 +279,7 @@ export function createApi(c: ApiClient) {
stream?: string
author?: string
task_id?: string
+ requested_reviewer?: string
page_size?: number
page_token?: string
} = {},
@@ -267,6 +294,74 @@ export function createApi(c: ApiClient) {
},
get: (repo: string, id: string, o?: Opts) =>
c.get(r(repo, `/proposals/${encodeURIComponent(id)}`), undefined, o),
+ reviewDraft: (repo: string, id: string, options?: SessionOpts) =>
+ c.get(
+ r(repo, `/proposals/${encodeURIComponent(id)}/review-draft`),
+ undefined,
+ options,
+ ),
+ saveReviewDraft: (
+ repo: string,
+ id: string,
+ body: { head: string; decision: string; body: string; expected_version: number },
+ options?: SessionOpts,
+ ) =>
+ c.json(r(repo, `/proposals/${encodeURIComponent(id)}/review-draft`), {
+ ...options,
+ method: 'PUT',
+ body,
+ }),
+ clearReviewDraft: (
+ repo: string,
+ id: string,
+ expectedVersion: number,
+ options?: SessionOpts,
+ ) =>
+ c.del(
+ r(repo, `/proposals/${encodeURIComponent(id)}/review-draft`),
+ { expected_version: expectedVersion },
+ options,
+ ),
+ commentDraft: (repo: string, id: string, options?: SessionOpts) =>
+ c.get(
+ r(repo, `/proposals/${encodeURIComponent(id)}/comment-draft`),
+ undefined,
+ options,
+ ),
+ saveCommentDraft: (
+ repo: string,
+ id: string,
+ body: { head: string; body: string; expected_version: number },
+ options?: SessionOpts,
+ ) =>
+ c.json(r(repo, `/proposals/${encodeURIComponent(id)}/comment-draft`), {
+ ...options,
+ method: 'PUT',
+ body,
+ }),
+ clearCommentDraft: (
+ repo: string,
+ id: string,
+ expectedVersion: number,
+ options?: SessionOpts,
+ ) =>
+ c.del(
+ r(repo, `/proposals/${encodeURIComponent(id)}/comment-draft`),
+ { expected_version: expectedVersion },
+ options,
+ ),
+ setReviewers: (
+ repo: string,
+ id: string,
+ reviewers: string[],
+ expectedReviewers: string[],
+ o?: SessionOpts,
+ ) =>
+ c.json<{ reviewers: string[] }>(r(repo, `/proposals/${encodeURIComponent(id)}/reviewers`), {
+ ...o,
+ method: 'PUT',
+ body: { reviewers, expected_reviewers: expectedReviewers },
+ }),
comment: (
repo: string,
id: string,
@@ -376,12 +471,24 @@ export function createApi(c: ApiClient) {
o?: SessionOpts,
) => c.post(r(repo, `/workspaces/${encodeURIComponent(name)}/changes`), body, o),
/** Brings the stream head in. Conflicts change nothing until every one has a resolution. */
- sync: (repo: string, name: string, resolutions: FileChange[] = [], o?: Opts) =>
- c.post(
+ sync: (
+ repo: string,
+ name: string,
+ resolutions: FileChange[] = [],
+ options?: SessionOpts & { expectedStreamHead?: string },
+ ) => {
+ const { expectedStreamHead, ...request } = options ?? {}
+ return c.post(
r(repo, `/workspaces/${encodeURIComponent(name)}/sync`),
- resolutions.length ? { resolutions } : {},
- o,
- ),
+ {
+ ...(resolutions.length ? { resolutions } : {}),
+ ...(expectedStreamHead !== undefined
+ ? { expected_stream_head: expectedStreamHead }
+ : {}),
+ },
+ request,
+ )
+ },
remove: (repo: string, name: string, o?: SessionOpts) =>
c.del>(
r(repo, `/workspaces/${encodeURIComponent(name)}`),
diff --git a/apps/web/src/api/schema.gen.ts b/apps/web/src/api/schema.gen.ts
index 1feed6b8..a40f339f 100644
--- a/apps/web/src/api/schema.gen.ts
+++ b/apps/web/src/api/schema.gen.ts
@@ -73,6 +73,40 @@ export interface paths {
patch?: never
trace?: never
}
+ '/v1/events': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Multi-repository SSE; no cursor checkpoints now; Last-Event-ID resumes an opaque vector cursor */
+ get: operations['events.subscribe_many']
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/v1/events/replay': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Replay explicit repositories using an opaque vector cursor; no cursor checkpoints now, from_start replays history */
+ get: operations['events.replay_many']
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/v1/jobs/claim': {
parameters: {
query?: never
@@ -366,6 +400,42 @@ export interface paths {
patch?: never
trace?: never
}
+ '/v1/repos/{owner}/{rname}/file-draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Get private file drafts */
+ get: operations['file_draft.get']
+ /** Save private file drafts */
+ put: operations['file_draft.save']
+ post?: never
+ /** Clear private file drafts */
+ delete: operations['file_draft.clear']
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/v1/repos/{owner}/{rname}/file-drafts': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** List private file drafts */
+ get: operations['file_draft.list']
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/v1/repos/{owner}/{rname}/files/{*path}': {
parameters: {
query?: never
@@ -486,6 +556,40 @@ export interface paths {
patch?: never
trace?: never
}
+ '/v1/repos/{owner}/{rname}/issues/import/apply': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /** Apply the inspected import manifest digest */
+ post: operations['issue.import_apply']
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/v1/repos/{owner}/{rname}/issues/import/preview': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /** Preview normalized hosting issues and comments */
+ post: operations['issue.import_preview']
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/v1/repos/{owner}/{rname}/issues/{number}': {
parameters: {
query?: never
@@ -885,6 +989,25 @@ export interface paths {
patch?: never
trace?: never
}
+ '/v1/repos/{owner}/{rname}/proposals/{id}/comment-draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Read your private comment draft */
+ get: operations['proposal.get_comment_draft']
+ /** Save your private comment draft */
+ put: operations['proposal.save_comment_draft']
+ post?: never
+ /** Clear the inspected private comment draft */
+ delete: operations['proposal.clear_comment_draft']
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/v1/repos/{owner}/{rname}/proposals/{id}/comments': {
parameters: {
query?: never
@@ -902,6 +1025,42 @@ export interface paths {
patch?: never
trace?: never
}
+ '/v1/repos/{owner}/{rname}/proposals/{id}/review-draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Read your private review draft */
+ get: operations['proposal.get_draft']
+ /** Save your private review draft */
+ put: operations['proposal.save_draft']
+ post?: never
+ /** Clear the inspected private review draft */
+ delete: operations['proposal.clear_draft']
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/v1/repos/{owner}/{rname}/proposals/{id}/reviewers': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ /** Replace the inspected set of requested reviewers */
+ put: operations['proposal.set_reviewers']
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/v1/repos/{owner}/{rname}/proposals/{id}/reviews': {
parameters: {
query?: never
@@ -1696,6 +1855,16 @@ export interface components {
/** @default */
next_page_token: string
}
+ ApplyIssueImportRequest: {
+ /** @default false */
+ allow_unsupported: boolean
+ /** @default */
+ expected_digest: string
+ /** @default */
+ manifest_json: string
+ /** @default */
+ repo: string
+ }
AssignTaskRequest: {
/**
* @description Registered agent name; empty restores automatic routing.
@@ -1959,6 +2128,41 @@ export interface components {
/** @default null */
job: components['schemas']['Job'] | null
}
+ ClearCommentDraftRequest: {
+ /**
+ * Format: int64
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
+ ClearFileDraftRequest: {
+ /**
+ * Format: int64
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ path: string
+ /** @default */
+ repo: string
+ /** @default */
+ stream: string
+ }
+ ClearReviewDraftRequest: {
+ /**
+ * Format: int64
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
CloseProposalRequest: {
/** @default */
id: string
@@ -1970,6 +2174,25 @@ export interface components {
/** @default */
repo: string
}
+ /** @description Private, unsubmitted general discussion notes for the current acting identity. */
+ CommentDraft: {
+ /** @default */
+ body: string
+ /** @default */
+ head: string
+ /** @default false */
+ present: boolean
+ /**
+ * Format: int64
+ * @default 0
+ */
+ updated_at_ms: number
+ /**
+ * Format: int64
+ * @default 0
+ */
+ version: number
+ }
/** @description A discussion entry, never an approval or a request-changes vote. */
CommentProposalRequest: {
/** @default */
@@ -2084,8 +2307,33 @@ export interface components {
* @default
*/
message: string
+ origin?: components['schemas']['ConflictOrigin'] | null
/** @default */
path: string
+ resolution_scope?: components['schemas']['ConflictResolutionScope'] | null
+ }
+ /** @description Exact paths at the three revisions when source locations differ after a rename. */
+ ConflictOrigin: {
+ /** @default */
+ base: string
+ /** @default */
+ ours: string
+ /** @default */
+ theirs: string
+ }
+ /** @description Exact candidate paths at or beneath one file/directory conflict root. */
+ ConflictResolutionScope: {
+ /**
+ * @description False if any candidate was omitted; clients must not offer automatic subtree choices then.
+ * @default false
+ */
+ complete: boolean
+ /**
+ * @description Ordered candidate entries; a complete scope includes the root.
+ * At most 100 paths per root and 1000 in one response. Incomplete scopes may be empty.
+ * @default []
+ */
+ paths: string[]
}
CreateIssueRequest: {
/** @default */
@@ -2404,6 +2652,13 @@ export interface components {
*/
topic: string
}
+ /** @description An opaque vector cursor; events from different repositories have no shared sequence. */
+ EventEnvelope: {
+ /** @default */
+ cursor: string
+ /** @default null */
+ event: components['schemas']['Event'] | null
+ }
ExecutorPresence: {
/**
* Format: int32
@@ -2527,6 +2782,57 @@ export interface components {
*/
truncated: boolean
}
+ /** @description Private existing-file edit owned by the current acting identity. */
+ FileDraft: {
+ /** @default */
+ base: string
+ /**
+ * Format: byte
+ * @default []
+ */
+ content: string
+ /** @default false */
+ executable: boolean
+ /** @default */
+ file_type: string
+ /** @default */
+ original_blob_id: string
+ /** @default */
+ path: string
+ /** @default false */
+ present: boolean
+ /** @default */
+ stream: string
+ /**
+ * Format: int64
+ * @default 0
+ */
+ updated_at_ms: number
+ /**
+ * Format: int64
+ * @default 0
+ */
+ version: number
+ }
+ /** @description Discovery carries no private replacement bytes. */
+ FileDraftSummary: {
+ /** @default */
+ base: string
+ /** @default */
+ path: string
+ /** @default */
+ stream: string
+ /**
+ * Format: int64
+ * @default 0
+ */
+ updated_at_ms: number
+ /**
+ * Format: int64
+ * @default 0
+ */
+ version: number
+ }
FileOp: {
/**
* @description Content blob (put only).
@@ -2693,6 +2999,20 @@ export interface components {
*/
rev: string
}
+ GetCommentDraftRequest: {
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
+ GetFileDraftRequest: {
+ /** @default */
+ path: string
+ /** @default */
+ repo: string
+ /** @default */
+ stream: string
+ }
GetIssueRequest: {
/**
* Format: int64
@@ -2741,6 +3061,12 @@ export interface components {
/** @default */
repo: string
}
+ GetReviewDraftRequest: {
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
GetRunOutputRequest: {
/** @default */
repo: string
@@ -2855,11 +3181,20 @@ export interface components {
*/
server_time_ms: number
}
- IntegrateRequest: {
+ ImportedIssueMapping: {
/**
- * @description Fencing tokens of exclusive leases the author holds.
- * @default []
- */
+ * Format: int64
+ * @default 0
+ */
+ number: number
+ /** @default */
+ source_id: string
+ }
+ IntegrateRequest: {
+ /**
+ * @description Fencing tokens of exclusive leases the author holds.
+ * @default []
+ */
fencing_tokens: number[]
/** @default */
idempotency_key: string
@@ -2985,6 +3320,48 @@ export interface components {
/** @default */
kind: string
}
+ IssueImportPreview: {
+ /**
+ * Format: int32
+ * @default 0
+ */
+ comments: number
+ /** @default [] */
+ conflicts: string[]
+ /**
+ * Format: int32
+ * @default 0
+ */
+ create_count: number
+ /** @default */
+ digest: string
+ /**
+ * Format: int32
+ * @default 0
+ */
+ issues: number
+ /**
+ * Format: int32
+ * @default 0
+ */
+ skip_count: number
+ /** @default [] */
+ warnings: string[]
+ }
+ IssueImportResult: {
+ /**
+ * Format: int32
+ * @default 0
+ */
+ created: number
+ /** @default [] */
+ mappings: components['schemas']['ImportedIssueMapping'][]
+ /**
+ * Format: int32
+ * @default 0
+ */
+ skipped: number
+ }
Job: {
/**
* Format: int32
@@ -3176,6 +3553,16 @@ export interface components {
/** @default */
tree_id: string
}
+ ListFileDraftsRequest: {
+ /** @default */
+ repo: string
+ /** @default */
+ stream: string
+ }
+ ListFileDraftsResponse: {
+ /** @default [] */
+ drafts: components['schemas']['FileDraftSummary'][]
+ }
ListIssueActivityRequest: {
/**
* Format: int64
@@ -3278,6 +3665,11 @@ export interface components {
page_token: string
/** @default */
repo: string
+ /**
+ * @description Match a requested reviewer before keyset paging.
+ * @default
+ */
+ requested_reviewer: string
/** @default */
state: string
/** @default */
@@ -3773,6 +4165,12 @@ export interface components {
/** @default */
repo: string
}
+ PreviewIssueImportRequest: {
+ /** @default */
+ manifest_json: string
+ /** @default */
+ repo: string
+ }
Principal: {
/** @default */
credential_id: string
@@ -3840,6 +4238,11 @@ export interface components {
ProposalDetail: {
/** @default null */
proposal: components['schemas']['Proposal'] | null
+ /**
+ * @description Routing only; requests do not grant access or count as approval.
+ * @default []
+ */
+ requested_reviewers: string[]
/** @default [] */
reviews: components['schemas']['Review'][]
}
@@ -4118,6 +4521,32 @@ export interface components {
/** @default */
role: string
}
+ ReplayManyRequest: {
+ /** @default */
+ cursor: string
+ /**
+ * @description With no cursor, deliberately replay all history instead of checkpointing now.
+ * @default false
+ */
+ from_start: boolean
+ /**
+ * Format: int32
+ * @default 0
+ */
+ limit: number
+ /** @default [] */
+ repos: string[]
+ /** @default [] */
+ topics: string[]
+ }
+ ReplayManyResponse: {
+ /** @default */
+ cursor: string
+ /** @default [] */
+ events: components['schemas']['EventEnvelope'][]
+ /** @default false */
+ has_more: boolean
+ }
ReplayRequest: {
/**
* Format: int64
@@ -4244,6 +4673,10 @@ export interface components {
/** @default [] */
jobs: components['schemas']['Job'][]
}
+ RequestedReviewers: {
+ /** @default [] */
+ reviewers: string[]
+ }
ResolvePathRequest: {
/** @default */
path: string
@@ -4316,6 +4749,27 @@ export interface components {
*/
timestamp_ms: number
}
+ /** @description Private, unsubmitted review notes for the current acting identity only. */
+ ReviewDraft: {
+ /** @default */
+ body: string
+ /** @default */
+ decision: string
+ /** @default */
+ head: string
+ /** @default false */
+ present: boolean
+ /**
+ * Format: int64
+ * @default 0
+ */
+ updated_at_ms: number
+ /**
+ * Format: int64
+ * @default 0
+ */
+ version: number
+ }
/** @description A line location is immutable; text is filled from the repository by the server. */
ReviewLineAnchor: {
/** @default */
@@ -4555,6 +5009,60 @@ export interface components {
*/
timeout_secs: number
}
+ SaveCommentDraftRequest: {
+ /** @default */
+ body: string
+ /**
+ * Format: int64
+ * @description Zero means no prior draft; all saves compare this version atomically.
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ head: string
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
+ SaveFileDraftRequest: {
+ /** @default */
+ base: string
+ /**
+ * Format: byte
+ * @default []
+ */
+ content: string
+ /**
+ * Format: int64
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ path: string
+ /** @default */
+ repo: string
+ /** @default */
+ stream: string
+ }
+ SaveReviewDraftRequest: {
+ /** @default */
+ body: string
+ /** @default */
+ decision: string
+ /**
+ * Format: int64
+ * @description Zero means no prior draft; all saves compare this version atomically.
+ * @default 0
+ */
+ expected_version: number
+ /** @default */
+ head: string
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ }
SaveRunOutputRequest: {
/** @default null */
output: components['schemas']['RunOutput'] | null
@@ -4666,6 +5174,17 @@ export interface components {
/** @default */
stream: string
}
+ /** @description Replaces review routing after comparing the complete inspected set. */
+ SetRequestedReviewersRequest: {
+ /** @default [] */
+ expected_reviewers: string[]
+ /** @default */
+ id: string
+ /** @default */
+ repo: string
+ /** @default [] */
+ reviewers: string[]
+ }
/** @description Execution policy is independent of immutable task instructions and run history. */
SetTaskAttemptLimitRequest: {
/**
@@ -4759,6 +5278,12 @@ export interface components {
signature: string
}
SyncWorkspaceRequest: {
+ /**
+ * @description Optional branch revision inspected while preparing these resolutions.
+ * A mismatch refuses the sync without moving the workspace.
+ * @default
+ */
+ expected_stream_head: string
/**
* @description Accepted and ignored: this operation does not deduplicate (see Conventions above).
* @default
@@ -4786,6 +5311,12 @@ export interface components {
* @default
*/
status: string
+ /**
+ * @description Exact branch revision used for this attempt; empty for an empty branch.
+ * Older servers omit this field; negotiate workspaces.sync_precondition.
+ * @default
+ */
+ stream_head: string
}
Task: {
/**
@@ -5215,6 +5746,79 @@ export interface operations {
}
}
}
+ 'events.subscribe_many': {
+ parameters: {
+ query?: {
+ /** @description Required explicit comma-separated repository names (max30) */
+ repos?: string
+ /** @description Comma-separated audit topic prefixes */
+ topics?: string
+ /** @description Opaque cursor returned by replay or an earlier SSE envelope */
+ cursor?: string
+ /** @description Checkpoint keep-alive interval (default15000,minimum1000) */
+ heartbeat_ms?: number
+ }
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'text/event-stream': string
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'events.replay_many': {
+ parameters: {
+ query?: {
+ cursor?: string
+ from_start?: boolean
+ limit?: number
+ repos?: string[]
+ topics?: string[]
+ }
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ReplayManyResponse']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
'jobs.claim': {
parameters: {
query?: never
@@ -5977,11 +6581,11 @@ export interface operations {
}
}
}
- 'tree.file': {
+ 'file_draft.get': {
parameters: {
query?: {
- /** @description Revision expression; empty means the repository's default stream */
- rev?: string
+ path?: string
+ stream?: string
}
header?: never
path: {
@@ -5989,7 +6593,6 @@ export interface operations {
owner: string
/** @description Repository name (second half of owner/name) */
rname: string
- path: string
}
cookie?: never
}
@@ -6001,7 +6604,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/octet-stream': string
+ 'application/json': components['schemas']['FileDraft']
}
}
/** @description Error */
@@ -6015,7 +6618,7 @@ export interface operations {
}
}
}
- 'admin.fsck': {
+ 'file_draft.save': {
parameters: {
query?: never
header?: never
@@ -6029,7 +6632,7 @@ export interface operations {
}
requestBody: {
content: {
- 'application/json': components['schemas']['FsckRequest']
+ 'application/json': components['schemas']['SaveFileDraftRequest']
}
}
responses: {
@@ -6039,7 +6642,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['FsckResponse']
+ 'application/json': components['schemas']['FileDraft']
}
}
/** @description Error */
@@ -6053,9 +6656,13 @@ export interface operations {
}
}
}
- 'admin.gc': {
+ 'file_draft.clear': {
parameters: {
- query?: never
+ query?: {
+ expected_version?: number
+ path?: string
+ stream?: string
+ }
header?: never
path: {
/** @description Repository owner (first half of owner/name) */
@@ -6065,11 +6672,7 @@ export interface operations {
}
cookie?: never
}
- requestBody: {
- content: {
- 'application/json': components['schemas']['GcRequest']
- }
- }
+ requestBody?: never
responses: {
/** @description Success */
200: {
@@ -6077,7 +6680,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['GcResponse']
+ 'application/json': components['schemas']['FileDraft']
}
}
/** @description Error */
@@ -6091,9 +6694,11 @@ export interface operations {
}
}
}
- 'history.ancestors': {
+ 'file_draft.list': {
parameters: {
- query?: never
+ query?: {
+ stream?: string
+ }
header?: never
path: {
/** @description Repository owner (first half of owner/name) */
@@ -6103,11 +6708,7 @@ export interface operations {
}
cookie?: never
}
- requestBody: {
- content: {
- 'application/json': components['schemas']['AncestorsRequest']
- }
- }
+ requestBody?: never
responses: {
/** @description Success */
200: {
@@ -6115,7 +6716,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['AncestorsResponse']
+ 'application/json': components['schemas']['ListFileDraftsResponse']
}
}
/** @description Error */
@@ -6129,7 +6730,159 @@ export interface operations {
}
}
}
- 'history.merge_base': {
+ 'tree.file': {
+ parameters: {
+ query?: {
+ /** @description Revision expression; empty means the repository's default stream */
+ rev?: string
+ }
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ path: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/octet-stream': string
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'admin.fsck': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['FsckRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['FsckResponse']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'admin.gc': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['GcRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['GcResponse']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'history.ancestors': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AncestorsRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['AncestorsResponse']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'history.merge_base': {
parameters: {
query?: {
a?: string
@@ -6279,6 +7032,82 @@ export interface operations {
}
}
}
+ 'issue.import_apply': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['ApplyIssueImportRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['IssueImportResult']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'issue.import_preview': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['PreviewIssueImportRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['IssueImportPreview']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
'issue.get': {
parameters: {
query?: never
@@ -7254,6 +8083,7 @@ export interface operations {
author?: string
page_size?: number
page_token?: string
+ requested_reviewer?: string
state?: string
stream?: string
task_id?: string
@@ -7440,6 +8270,117 @@ export interface operations {
}
}
}
+ 'proposal.get_comment_draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['CommentDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'proposal.save_comment_draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['SaveCommentDraftRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['CommentDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'proposal.clear_comment_draft': {
+ parameters: {
+ query?: {
+ expected_version?: number
+ }
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['CommentDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
'proposal.comment': {
parameters: {
query?: never
@@ -7479,6 +8420,156 @@ export interface operations {
}
}
}
+ 'proposal.get_draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ReviewDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'proposal.save_draft': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['SaveReviewDraftRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ReviewDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'proposal.clear_draft': {
+ parameters: {
+ query?: {
+ expected_version?: number
+ }
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ReviewDraft']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
+ 'proposal.set_reviewers': {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Repository owner (first half of owner/name) */
+ owner: string
+ /** @description Repository name (second half of owner/name) */
+ rname: string
+ id: string
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['SetRequestedReviewersRequest']
+ }
+ }
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['RequestedReviewers']
+ }
+ }
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ErrorEnvelope']
+ }
+ }
+ }
+ }
'proposal.review': {
parameters: {
query?: never
diff --git a/apps/web/src/api/sse.ts b/apps/web/src/api/sse.ts
index 395509c5..b2dd959c 100644
--- a/apps/web/src/api/sse.ts
+++ b/apps/web/src/api/sse.ts
@@ -111,7 +111,9 @@ export interface FeedStatusInfo {
export interface FeedOptions {
client: ApiClient
- repo: string
+ repo?: string
+ /** Explicit bounded scope for the aggregate endpoint; the cursor remains opaque. */
+ repos?: readonly string[]
/** Optional initiating session; retained across reconnects. */
session?: Pick
topics?: readonly string[]
@@ -145,18 +147,22 @@ function defaultSleep(ms: number, signal: AbortSignal): Promise {
})
}
+function eventValue(value: unknown): ServerEvent | null {
+ const v = value as Partial | null
+ if (!v || typeof v !== 'object') return null
+ if (typeof v.seq !== 'number' || typeof v.topic !== 'string') return null
+ return {
+ seq: v.seq,
+ topic: v.topic,
+ repo: typeof v.repo === 'string' ? v.repo : '',
+ timestamp_ms: typeof v.timestamp_ms === 'number' ? v.timestamp_ms : 0,
+ payload: v.payload && typeof v.payload === 'object' ? v.payload : {},
+ }
+}
+
function parseEvent(data: string): ServerEvent | null {
try {
- const v = JSON.parse(data) as Partial | null
- if (!v || typeof v !== 'object') return null
- if (typeof v.seq !== 'number' || typeof v.topic !== 'string') return null
- return {
- seq: v.seq,
- topic: v.topic,
- repo: typeof v.repo === 'string' ? v.repo : '',
- timestamp_ms: typeof v.timestamp_ms === 'number' ? v.timestamp_ms : 0,
- payload: v.payload && typeof v.payload === 'object' ? v.payload : {},
- }
+ return eventValue(JSON.parse(data))
} catch {
return null
}
@@ -177,6 +183,10 @@ export function openEventFeed(options: FeedOptions): EventFeed {
const now = options.now ?? Date.now
const lifetime = new AbortController()
let lastSeq: number | null = options.afterSeq ?? null
+ const aggregate = options.repos !== undefined
+ const repositories = new Set(options.repos)
+ let cursor: string | undefined
+ const delivered = new Map()
let closed = false
const setStatus = (s: FeedStatus, info: FeedStatusInfo) => onStatus?.(s, info)
@@ -194,13 +204,25 @@ export function openEventFeed(options: FeedOptions): EventFeed {
)
try {
setStatus(attempt === 0 ? 'connecting' : 'reconnecting', { attempt })
- const res = await client.open(repoPath(repo, '/events'), {
+ const res = await client.open(aggregate ? '/v1/events' : repoPath(repo ?? '', '/events'), {
...options.session,
- query: {
- after_seq: lastSeq === null ? undefined : lastSeq,
- topics: options.topics && options.topics.length > 0 ? [...options.topics] : undefined,
- },
- headers: lastSeq === null ? undefined : { 'last-event-id': String(lastSeq) },
+ query: aggregate
+ ? {
+ repos: [...repositories].join(','),
+ cursor,
+ topics: options.topics?.join(','),
+ }
+ : {
+ after_seq: lastSeq === null ? undefined : lastSeq,
+ topics: options.topics && options.topics.length > 0 ? [...options.topics] : undefined,
+ },
+ headers: aggregate
+ ? cursor
+ ? { 'last-event-id': cursor }
+ : undefined
+ : lastSeq === null
+ ? undefined
+ : { 'last-event-id': String(lastSeq) },
signal: connection.signal,
retries: 0,
timeoutMs: 20_000,
@@ -218,6 +240,26 @@ export function openEventFeed(options: FeedOptions): EventFeed {
lastActivity = now()
for (const msg of parser.push(decoder.decode(value, { stream: true }))) {
if (msg.event === 'error') throw parseStreamError(msg.data)
+ if (aggregate) {
+ let envelope: { cursor?: unknown; event?: unknown }
+ try {
+ envelope = JSON.parse(msg.data)
+ } catch {
+ continue
+ }
+ if (!envelope || typeof envelope.cursor !== 'string') continue
+ if (envelope.event == null) {
+ cursor = envelope.cursor
+ continue
+ }
+ const ev = eventValue(envelope.event)
+ if (!ev || !repositories.has(ev.repo)) continue
+ cursor = envelope.cursor
+ if (ev.seq <= (delivered.get(ev.repo) ?? -1)) continue
+ delivered.set(ev.repo, ev.seq)
+ onEvent(ev)
+ continue
+ }
const ev = parseEvent(msg.data)
if (!ev) continue
if (lastSeq !== null && ev.seq <= lastSeq) continue // replay overlap: already delivered
diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts
index 7d132dfa..0e49e336 100644
--- a/apps/web/src/api/types.ts
+++ b/apps/web/src/api/types.ts
@@ -27,10 +27,16 @@ export type SearchTreeResponse = S['SearchTreeResponse']
export type SearchMatch = S['SearchMatch']
export type Proposal = S['Proposal']
export type Review = S['Review']
+export type ReviewDraft = S['ReviewDraft']
+export type CommentDraft = S['CommentDraft']
+export type FileDraft = S['FileDraft']
+export type FileDraftSummary = S['FileDraftSummary']
export type ReviewThread = S['ReviewThread']
export type ThreadComment = S['ThreadComment']
export type ReviewLineAnchor = S['ReviewLineAnchor']
-export type ProposalDetail = S['ProposalDetail']
+// Older servers do not return the additive requested-reviewer routing field.
+export type ProposalDetail = Omit &
+ Partial>
/**
* The OpenAPI document describes free-form JSON objects (`type: object` with no properties), which the
* generator renders as `Record`. They are open-ended on the wire, so say so.
@@ -39,7 +45,9 @@ type OpenObject = Record
export type Blocker = Omit & { detail?: OpenObject }
export type Conflict = S['Conflict']
export type Workspace = S['Workspace']
-export type SyncWorkspaceResponse = S['SyncWorkspaceResponse']
+// Older servers omit the pinned target revision.
+export type SyncWorkspaceResponse = Omit &
+ Partial>
export type ValidationStatus = S['ValidationStatus']
export type Attestation = S['Attestation']
export type QueueEntry = S['QueueEntry']
@@ -58,6 +66,8 @@ export type Run = S['Run']
export type RunOutput = S['RunOutput']
export type Issue = S['Issue']
export type IssueActivity = S['IssueActivity']
+export type IssueImportPreview = S['IssueImportPreview']
+export type IssueImportResult = S['IssueImportResult']
// Lineage fields are absent on servers predating tasks.lineage.
export type Task = Omit &
Partial>
diff --git a/apps/web/src/components/files/FileEditor.vue b/apps/web/src/components/files/FileEditor.vue
index 1bb606dc..04667d9a 100644
--- a/apps/web/src/components/files/FileEditor.vue
+++ b/apps/web/src/components/files/FileEditor.vue
@@ -3,7 +3,8 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '@/api/instance'
import { ApiError, describeError } from '@/api/errors'
-import { pathProblem, proposeChange, putFile } from '@/lib/edit'
+import { pathProblem, proposeChange, putFile, toBase64 } from '@/lib/edit'
+import { decodeUtf8, looksBinary, preserveTextFormat } from '@/lib/text'
import { useBoard } from '@/stores/board'
import { useToasts } from '@/stores/toasts'
import { useUnsavedChanges } from '@/composables/useUnsavedChanges'
@@ -73,16 +74,18 @@ const draft = useFileDraft({
isNew: !!props.isNew,
})
const recovered = draft.initial
-const originalText = recovered?.original ?? props.original
-const executable = recovered?.executable ?? props.executable === true
-const fileType = recovered?.fileType ?? (props.fileType === 'symlink' ? 'symlink' : 'regular')
+const originalText = ref(recovered?.original ?? props.original)
+const executable = ref(recovered?.executable ?? props.executable === true)
+const fileType = ref(recovered?.fileType ?? (props.fileType === 'symlink' ? 'symlink' : 'regular'))
const initialPath = props.isNew ? (props.path ? `${props.path}/` : '') : props.path
const newPath = ref(recovered?.path ?? initialPath)
const text = ref(recovered?.text ?? props.original)
const filePath = computed(() => (props.isNew ? newPath.value.trim() : props.path))
const problem = computed(() => (props.isNew && newPath.value ? pathProblem(newPath.value) : null))
-const changed = computed(() => (props.isNew ? filePath.value !== '' : text.value !== originalText))
+const changed = computed(() =>
+ props.isNew ? filePath.value !== '' : text.value !== originalText.value,
+)
const base = useResource(async () =>
recovered
? recovered.base
@@ -90,9 +93,25 @@ const base = useResource(async () =>
? (await api.streams.head(props.repo, props.stream)).head
: props.rev,
)
+const restoredBase = ref(null)
+const startingBase = computed(() => restoredBase.value ?? base.data.value)
+const accountSupported = computed(
+ () => !props.isNew && !!auth.capabilities?.features.includes('files.drafts'),
+)
+type AccountFileDraft = Awaited>
+const accountAvailable = ref(null)
+const accountBusy = ref(false)
+const accountLoaded = ref(false)
+const accountError = ref('')
+const accountRestored = ref(false)
+let accountVersion = 0
+let adoptedVersion: number | null = null
+let restoringAccount = false
+
const canPropose = computed(
() =>
auth.status === 'ready' &&
+ !accountBusy.value &&
changed.value &&
base.settled.value &&
!base.error.value &&
@@ -106,10 +125,11 @@ const busy = ref(false)
const error = ref(null)
const submitted = ref(false)
const dirty = computed(
- () => !submitted.value && (text.value !== originalText || newPath.value !== initialPath),
+ () => !submitted.value && (text.value !== originalText.value || newPath.value !== initialPath),
)
const replaceDraft = ref(false)
function persistDraft() {
+ if (restoringAccount) return
if (submitted.value) {
draft.clear()
return
@@ -118,19 +138,19 @@ function persistDraft() {
if (!draft.blocked.value) draft.clear()
return
}
- if (!base.settled.value || base.error.value || base.data.value === undefined) return
+ if (!base.settled.value || base.error.value || startingBase.value === undefined) return
draft.save({
text: text.value,
- original: originalText,
+ original: originalText.value,
path: newPath.value,
- base: base.data.value,
- executable,
- fileType,
+ base: startingBase.value,
+ executable: executable.value,
+ fileType: fileType.value,
title: title.value,
description: description.value,
})
}
-watch([text, newPath, title, description, base.data, base.settled, submitted], persistDraft, {
+watch([text, newPath, title, description, startingBase, base.settled, submitted], persistDraft, {
flush: 'sync',
})
const { askingToLeave, requestLeave, decide } = useUnsavedChanges(dirty, busy)
@@ -153,7 +173,11 @@ async function cancel() {
let draftUrl = ''
function downloadDraft() {
if (draftUrl) URL.revokeObjectURL(draftUrl)
- draftUrl = URL.createObjectURL(new Blob([text.value], { type: 'text/plain;charset=utf-8' }))
+ draftUrl = URL.createObjectURL(
+ new Blob([preserveTextFormat(originalText.value, text.value)], {
+ type: 'text/plain;charset=utf-8',
+ }),
+ )
const link = document.createElement('a')
link.href = draftUrl
link.download = filePath.value.split('/').pop() || 'draft.txt'
@@ -165,6 +189,118 @@ onBeforeUnmount(() => {
if (draftUrl) URL.revokeObjectURL(draftUrl)
})
+async function restoreAccountDraft(saved: AccountFileDraft) {
+ const mine = submission
+ const token = auth.token
+ accountBusy.value = true
+ accountError.value = ''
+ try {
+ const original = await api.tree.file(props.repo, saved.base, props.path, 4 * 1024 * 1024)
+ const bytes = Uint8Array.from(atob(saved.content), (char) => char.charCodeAt(0))
+ if (original.truncated || looksBinary(original.bytes) || looksBinary(bytes))
+ throw new Error(
+ 'This account draft cannot be opened in the text editor. Use the CLI to retrieve it.',
+ )
+ if (mine !== submission || token !== auth.token) return
+ restoringAccount = true
+ originalText.value = decodeUtf8(original.bytes)
+ executable.value = saved.executable
+ fileType.value = saved.file_type === 'symlink' ? 'symlink' : 'regular'
+ restoredBase.value = saved.base
+ text.value = decodeUtf8(bytes)
+ adoptedVersion = saved.version
+ accountAvailable.value = null
+ accountRestored.value = true
+ restoringAccount = false
+ persistDraft()
+ } catch (cause) {
+ if (mine === submission && token === auth.token) accountError.value = describeError(cause)
+ } finally {
+ restoringAccount = false
+ if (mine === submission) accountBusy.value = false
+ }
+}
+async function loadAccountDraft() {
+ if (!accountSupported.value || auth.status !== 'ready') return
+ const mine = submission
+ const token = auth.token
+ accountBusy.value = true
+ accountLoaded.value = false
+ accountError.value = ''
+ adoptedVersion = null
+ try {
+ const saved = await api.files.draft(props.repo, props.stream, props.path, { token })
+ if (mine !== submission || token !== auth.token) return
+ accountVersion = saved.version
+ accountLoaded.value = true
+ accountAvailable.value = null
+ if (!saved.present) return
+ accountAvailable.value = saved
+ if (!dirty.value) await restoreAccountDraft(saved)
+ } catch (cause) {
+ if (mine === submission && token === auth.token) accountError.value = describeError(cause)
+ } finally {
+ if (mine === submission) accountBusy.value = false
+ }
+}
+function keepTabDraft() {
+ accountAvailable.value = null
+ adoptedVersion = null
+}
+async function saveAccountDraft() {
+ if (
+ !accountSupported.value ||
+ !accountLoaded.value ||
+ accountBusy.value ||
+ busy.value ||
+ submitted.value ||
+ accountAvailable.value ||
+ !startingBase.value
+ )
+ return
+ const content = preserveTextFormat(originalText.value, text.value)
+ if (new TextEncoder().encode(content).length > 4 * 1024 * 1024) {
+ accountError.value =
+ 'Account file drafts are limited to 4 MiB. Download this draft to keep a copy.'
+ return
+ }
+ const mine = submission
+ const token = auth.token
+ accountBusy.value = true
+ accountError.value = ''
+ try {
+ const saved = await api.files.saveDraft(
+ props.repo,
+ {
+ stream: props.stream,
+ path: props.path,
+ base: startingBase.value,
+ content: toBase64(content),
+ expected_version: accountVersion,
+ },
+ { token, quietUnauthenticated: true },
+ )
+ if (mine !== submission || token !== auth.token) return
+ accountVersion = saved.version
+ adoptedVersion = saved.version
+ toasts.ok('File draft saved to your account')
+ } catch (cause) {
+ if (mine !== submission || token !== auth.token) return
+ accountError.value = describeError(cause)
+ if (cause instanceof ApiError && cause.code === 'failed_precondition')
+ accountLoaded.value = false
+ } finally {
+ if (mine === submission) accountBusy.value = false
+ }
+}
+watch(
+ [accountSupported, () => auth.status],
+ () => {
+ void loadAccountDraft()
+ },
+ { immediate: true },
+)
+
function ask() {
if (!title.value) title.value = props.isNew ? `Add ${filePath.value}` : `Update ${filePath.value}`
error.value = null
@@ -172,9 +308,17 @@ function ask() {
}
async function propose() {
- if (busy.value || !canPropose.value || !title.value.trim() || error.value?.isUnauthenticated)
+ if (
+ busy.value ||
+ submitted.value ||
+ !canPropose.value ||
+ !title.value.trim() ||
+ error.value?.isUnauthenticated
+ )
return
const mine = ++submission
+ const cleanupVersion = adoptedVersion
+ const token = auth.token
const current = new AbortController()
controller = current
busy.value = true
@@ -183,26 +327,38 @@ async function propose() {
const p = await proposeChange(api, {
repo: props.repo,
stream: props.stream,
- fromRev: base.data.value || undefined,
- expectedBase: base.data.value,
+ fromRev: startingBase.value || undefined,
+ expectedBase: startingBase.value,
changes: [
putFile(
filePath.value,
- text.value,
- executable,
- fileType === 'symlink' ? 'symlink' : undefined,
+ preserveTextFormat(originalText.value, text.value),
+ executable.value,
+ fileType.value === 'symlink' ? 'symlink' : undefined,
),
],
title: title.value.trim(),
description: description.value.trim() || undefined,
- request: { token: auth.token, quietUnauthenticated: true, signal: current.signal },
+ request: { token, quietUnauthenticated: true, signal: current.signal },
})
if (mine !== submission || current.signal.aborted) return
submitted.value = true
- busy.value = false
asking.value = false
board.touched(['proposals', 'streams', 'predict'])
toasts.ok('Change request opened', p.title)
+ if (cleanupVersion !== null) {
+ try {
+ await api.files.clearDraft(props.repo, props.stream, props.path, cleanupVersion, {
+ token,
+ signal: current.signal,
+ quietUnauthenticated: true,
+ })
+ } catch (cause) {
+ if (mine === submission) toasts.warn('Account draft kept', describeError(cause))
+ }
+ if (mine !== submission || current.signal.aborted) return
+ }
+ busy.value = false
await router.push({
name: 'proposal',
params: { owner: props.repo.split('/')[0], name: props.repo.split('/')[1], id: p.id },
@@ -234,6 +390,24 @@ async function propose() {
>Replace the unreadable draft
+
+ Your tab draft is unchanged. Choose which copy to continue.
+
+
+
+
+
+
+ Account draft restored with its original starting revision.
+
+
+ {{ accountError }}
+
+
@@ -267,11 +441,21 @@ async function propose() {
: 'No changes yet.'
}}
- Draft saved in this tab. It survives reload and sign-in here; download it before closing
- the tab.