Skip to content

Commit 817f505

Browse files
authored
Merge pull request #32 from AI45Lab/feat/workspace-s3-hardening
Workspace v3.0: S3 hardening, remote git, typed errors, SDK alignment
2 parents 3c1b171 + 2dc8436 commit 817f505

40 files changed

Lines changed: 5353 additions & 207 deletions

CHANGELOG.md

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,60 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [3.0.0] - 2026-05-20
9+
10+
### Added (Phase 8 — typed-error SDK alignment)
11+
12+
- New public `ToolErrorKind` enum (`#[non_exhaustive]`, JSON-tagged on
13+
the `type` discriminator) carries structured tool failure reasons
14+
from the Rust core all the way to SDK callers without losing the
15+
type. Six variants: `version_conflict`, `remote_git_conflict`,
16+
`not_found`, `invalid_argument`, `unsupported`, `timeout`.
17+
- New optional `error_kind` field on `ToolOutput`, `ToolResult`, and
18+
`ToolCallResult`, plus a matching field on `AgentEvent::ToolEnd` for
19+
streaming consumers.
20+
- Built-in `edit` and `patch` tools populate `error_kind` via
21+
`ToolErrorKind::from_workspace_error` whenever a `WorkspaceError`
22+
variant maps to a typed kind. The human-readable `output` /
23+
`content` message is unchanged so the model still gets the retry
24+
hint; SDK callers now have a programmatic discriminator next to it.
25+
- Node SDK: new `errorKindJson` field on `ToolResult` and `AgentEvent`
26+
(JSON-encoded `ToolErrorKind`) plus a new `ToolErrorKind` TypeScript
27+
discriminated-union type in `index.d.ts`.
28+
- Python SDK: new `error_kind_json` (raw) and `error_kind` (parsed
29+
dict) properties on `ToolResult` and `AgentEvent`.
30+
31+
This closes the v3.0 typed-error gap: until this commit the typed
32+
`WorkspaceError` enum on the Rust trait surface was effectively
33+
re-stringified at the SDK boundary, forcing JS/Python callers to
34+
regex-match the output to detect e.g. concurrent-modification
35+
conflicts. They now `switch` / `match` on `error_kind.type` instead.
36+
37+
### ⚠️ Breaking changes (3.0.0)
38+
39+
- **`WorkspaceFileSystem` and `WorkspaceFileSystemExt` trait methods now
40+
return `WorkspaceResult<T>` instead of `anyhow::Result<T>`.** The new
41+
result type wraps the typed `WorkspaceError` enum
42+
(`#[non_exhaustive]`) with structured variants for `NotFound`,
43+
`VersionConflict`, `RemoteGitConflict`, `InvalidArgument`, `Timeout`,
44+
`Unsupported`, and a `Backend(anyhow::Error)` catch-all. Callers that
45+
used `?` to lift errors into `anyhow::Result` keep working unchanged
46+
thanks to the blanket `From<WorkspaceError> for anyhow::Error` impl;
47+
callers that previously did `err.downcast_ref::<WorkspaceVersionConflict>()`
48+
now `match` on the typed variant directly:
49+
```rust
50+
// before:
51+
if e.downcast_ref::<WorkspaceVersionConflict>().is_some() { ... }
52+
// after:
53+
if matches!(e, WorkspaceError::VersionConflict(_)) { ... }
54+
```
55+
`WorkspaceServices::read_for_edit`, `write_for_edit`, and the generic
56+
`run_with_timeout` (now polymorphic in the error type) follow the
57+
same shape. The other 5 traits (`WorkspaceCommandRunner`,
58+
`WorkspaceSearch`, `WorkspaceGit`, `WorkspaceGitStashProvider`,
59+
`WorkspaceGitWorktreeProvider`) **still return `anyhow::Result`**
60+
their migration to `WorkspaceResult` will be additive (non-breaking)
61+
in a future v3.x release.
962

1063
### Added
1164

@@ -24,6 +77,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2477
- Exposed `S3WorkspaceBackend` in the Node and Python SDKs alongside
2578
`LocalWorkspaceBackend`. Configuration uses the same option surface
2679
(`workspaceBackend` / `workspace_backend`).
80+
- `S3WorkspaceBackend::read_text` now enforces a configurable size ceiling
81+
(`S3BackendConfig::max_read_bytes`, default 10 MiB) by inspecting
82+
`Content-Length` on the `GetObject` response before consuming the body.
83+
Oversized objects are rejected with a clear error and never buffered
84+
into memory. Responses without a `Content-Length` header are refused
85+
rather than risking OOM.
86+
- Added optional `WorkspaceFileSystemExt` trait for backends that expose
87+
compare-and-swap writes, plus a `WorkspaceVersionConflict` error type.
88+
`S3WorkspaceBackend` implements it via ETag + `If-Match` on `PutObject`.
89+
The `edit` and `patch` tools now capture the ETag during the read and
90+
reject the write on version mismatch (HTTP 412), surfacing a typed
91+
"Concurrent modification detected" error so the model can re-read and
92+
retry instead of silently clobbering a concurrent writer.
93+
`WorkspaceServices::read_for_edit` and `write_for_edit` are the new
94+
helpers tools should use for any read-modify-write cycle; backends
95+
without versioning (e.g. local) transparently fall through to plain
96+
`read_text` / `write_text`.
97+
- `S3WorkspaceBackend` now implements `WorkspaceSearch` (degraded `grep` /
98+
`glob` via `LIST` + `GET` + regex). Off by default; opt in via
99+
`S3BackendConfig::enable_search(true)`. Hard ceilings on objects scanned
100+
per call (`max_objects_scanned`, default 500) and per-object body size
101+
for `grep` (`max_grep_bytes_per_object`, default 1 MiB) bound the API
102+
cost. Hitting either ceiling sets `WorkspaceGrepResult::truncated = true`.
103+
Glob patterns follow the local backend's recursion convention: `*.rs`
104+
matches the immediate level, `**/*.rs` recurses.
105+
- `S3WorkspaceBackend::grep` now downloads candidate objects in parallel
106+
via `futures::stream::buffer_unordered`. Concurrency defaults to 8 and
107+
is configurable via `S3BackendConfig::search_concurrency` (also
108+
exposed on both SDKs). Output ordering remains deterministic — results
109+
are sorted by workspace path before assembly — so callers see the same
110+
layout regardless of S3 response timing.
111+
112+
### Added
113+
114+
- Internal `workspace::conformance` module (test-only) codifies the
115+
behavioural invariants every backend implementing
116+
`WorkspaceFileSystem` (and optionally `WorkspaceFileSystemExt`) must
117+
satisfy. Two public entry points, `assert_filesystem_conformance` and
118+
`assert_filesystem_ext_conformance`, are run against
119+
`LocalWorkspaceBackend` and a new `InMemoryFileSystem` reference
120+
backend so the contract is exercised both over real I/O and an ideal
121+
HashMap-backed implementation. Future backends (GCS, container,
122+
browser) gain a regression suite for free — when the conformance set
123+
grows after a production incident, every backend running it picks up
124+
the new test automatically.
125+
126+
### Fixed
127+
128+
- `WorkspaceServices::with_remote_git` previously rebuilt the services
129+
through `WorkspaceServicesBuilder`, which silently dropped `local_root`
130+
(and would silently drop any future field). The decorator now goes
131+
through a new internal `with_git_provider` helper that uses an explicit
132+
struct literal — adding a new field to `WorkspaceServices` now triggers
133+
a compile error in every decorator, forcing a deliberate decision.
134+
- `RemoteGitBackend::diff` previously deserialised the entire response
135+
body before applying `max_diff_bytes`, so a misbehaving gitserver
136+
returning a multi-gigabyte JSON could exhaust client memory. The diff
137+
path now streams the body with a hard cap (`max_diff_bytes * 4`, floor
138+
64 KiB), rejecting requests upfront when `Content-Length` advertises an
139+
oversized body and aborting the stream mid-flight when chunked encoding
140+
hides the size. The soft `max_diff_bytes` display truncation is
141+
unchanged.
142+
143+
### Changed
144+
145+
- `S3WorkspaceBackend::list_dir` now errors with "S3 path not found" when
146+
the LIST returns zero entries on a non-root path, matching the local
147+
backend's behaviour. Previously a missing prefix silently returned
148+
`Ok(vec![])`, masking typos. Paths that exist only as S3 zero-byte
149+
directory markers still return `Ok(vec![])`.
150+
- Every S3 API call (`GET`, `PUT`, `LIST`) on `S3WorkspaceBackend` now
151+
emits a structured `tracing::debug!` event with fields `op`, `bucket`,
152+
`target`, `bytes`, `outcome`, `duration_ms`. Hosts can meter S3 cost
153+
by subscribing to these events without the backend taking a dependency
154+
on any metrics framework.
155+
- Node and Python SDKs now expose the workspace hardening options added
156+
in this release. The Node `JsS3BackendConfig` and Python
157+
`S3WorkspaceBackend` constructor accept `maxReadBytes` /
158+
`max_read_bytes`, `searchEnabled` / `search_enabled`,
159+
`maxObjectsScanned` / `max_objects_scanned`, and
160+
`maxGrepBytesPerObject` / `max_grep_bytes_per_object`. A new
161+
`RemoteGitBackendConfig` class (Python) / `JsRemoteGitBackendConfig`
162+
shape (Node) and a top-level `remoteGit` / `remote_git` session
163+
option let SDK callers attach `RemoteGitBackend` on top of any
164+
workspace backend. Passing `remoteGit` without `workspaceBackend`
165+
raises a clear error.
166+
- Added `RemoteGitBackend` — an HTTP/JSON `WorkspaceGit` client that
167+
brings the `git` tool to non-local workspaces (S3 today; future
168+
container / DFS). Implements `WorkspaceGit` in full and
169+
`WorkspaceGitStashProvider`; deliberately omits `WorkspaceGitWorktreeProvider`
170+
because worktrees do not map to a remote service. The protocol is
171+
specified in `apps/docs/content/docs/en/code/rfcs/workspace-remote-git.mdx`.
172+
- New types: `RemoteGitBackend`, `RemoteGitBackendConfig`,
173+
`RemoteGitConflict` (anyhow-downcastable for recoverable 409 / 422
174+
responses such as `WORKING_TREE_DIRTY` and `BRANCH_EXISTS`).
175+
- New factory: `WorkspaceServices::with_remote_git(config)` on any
176+
existing `Arc<WorkspaceServices>` to attach remote git on top of an
177+
S3 (or local) filesystem backend.
178+
- Client-side ceilings: `request_timeout` (default 30 s),
179+
`max_log_entries` (default 200), `max_diff_bytes` (default 1 MiB).
180+
- Per-call `tracing::debug!` event with fields `op`, `repo_id`,
181+
`status`, `bytes`, `outcome`, `duration_ms`, mirroring the S3
182+
metering shape so a single subscriber meters both.
183+
- Authentication: bearer token (header `Authorization: Bearer <token>`)
184+
or mTLS via `client_cert_pem` + `client_key_pem` (PKCS#8 PEM key for
185+
the `rustls-tls` backend). Setting only one of the mTLS pair fails
186+
at construction.
27187

28188
### Changed
29189

Cargo.lock

Lines changed: 86 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)