Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 107 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,115 @@ If so, use the `but` CLI instead of raw `git branch`/`git commit`:
- `but pr new` needs interactive forge auth; use `but push <branch>` then
`gh pr create --head <branch> --base <parent-or-main>` instead. For stacked PRs,
set `--base` to the parent branch so each PR shows only its own diff.
- **`but push` prints NOTHING on success.** It is not a confirmation — always verify
the push landed by comparing SHAs:
`git ls-remote --heads origin <branch>` vs `git rev-parse <branch>`. They must match.
- To update an already-committed file, `but absorb <path>` amends it into the right
commit; force-push with `but push <branch> -f`.
- To commit to a specific branch in a stack, stage the files to it first
(`but rub <path> <branch>`), then `but commit <branch> --only`. `but commit`
alone sweeps ALL uncommitted changes into that branch.

### Committing to specific lanes in a stack (the part that bites)

Changes are assigned to the **stack**, not to an individual branch. `but rub <file>
<branch>` and `but commit <branch> --only` both operate on the stack's *assigned-changes*
set — `--only` commits **whatever is currently assigned** to the named branch, regardless
of which branch name you used when staging. So:

- **Never pre-stage multiple lanes' files and then commit them one lane at a time.** The
first `but commit --only` sweeps the entire assigned set into that one branch (the others
end up empty or scrambled). Instead, work **one lane at a time**: assign exactly that
lane's files → `but commit <branch> --only` → **verify** → then assign the next lane's
files. Keep the assigned set equal to exactly one lane's files at each commit.
- **Verify every commit immediately:** `git show --stat --name-only <branch>`. If a file
from another lane leaked in, stop and fix before continuing.
- **`but rub` by path goes stale after any mutation.** Every `but` mutation kicks a
background sync that invalidates the path index, so the *next* path-based
`but rub <path> ...` often fails with "Source '<path>' not found". Use the stable
**cliId** instead (the 2-4 char code in `but status` / `but status --json`):
`but rub <cliId> <target>`. cliIds survive across the sync; paths don't.
- **Splitting one file across two stacked lanes** (e.g. `routers.py` where the lower lane
owns half the edit and the upper lane the other half): you cannot split mixed hunks
reliably. Instead use sequential working-tree states — make the file the lower lane's
version, commit it to the lower lane; then edit the file to add the upper lane's delta
and `but rub <fileCliId> <upperCommitCliId>` to amend that delta into the upper commit.
- The **branch ref can diverge from the workspace-applied commit** mid-session (after
absorb/amend/rebase). The **working tree is the source of truth**; `but push` pushes the
applied state. Don't panic if `git diff <branch> -- <file>` shows a delta while
`git status` is clean — verify against `git show "<branch>:<file>"` and re-push.

### Spreading a pile of edits back across an existing stack (the reliable way)

When you have a working tree full of changes that belong to *many* lanes of an
already-pushed stack (e.g. a review-pass that fixes files across wp0…wp4), do NOT try to
assign-and-commit lane by lane against the live working tree — `but rub`/`but commit
--only`/`but absorb` all route by **hunk dependency across the whole stack**, and they
mis-route in three predictable ways that scramble the stack and waste hours:

- **New (untracked) files ignore the target branch.** `but rub <newFileCliId> <lowerLane>`
dumps every untracked file into the **topmost** lane's staging group, not the one you
named. New files cannot be assigned to a lower lane at all.
- **`but absorb` sends anything it can't attribute to the docs/top lane.** Renamed files,
new files, and hunks in line-regions the target lane's original commit never touched all
fall to the "last commit in the primary lane" fallback — silently the wrong lane.
- **A multi-hunk file whose hunks belong to different commits won't commit whole.** `but
commit <lane>` / `-p <file>` commits the attributable hunks and **drops the rest**
("Warning: Some selected changes could not be committed"), often leaving an empty
no-change commit. Splitting one file across lower+upper lanes is the §"Splitting one file
across two stacked lanes" case above.

The technique that actually works — **git-stash isolation, one lane at a time:**

1. `but oplog snapshot -m "pristine"` then `git stash push -u` everything. Working tree
clean, every lane back at its remote tip. This snapshot is your only safe recovery
point — `but oplog restore` it whenever a step scrambles the stack (it does, often).
2. For each lane, restore **only that lane's files** into the clean tree:
tracked-modified from `git checkout 'stash@{0}' -- <paths>`; **untracked/new** files
from the stash's untracked parent `git checkout 'stash@{0}^3' -- <paths>`; reproduce
deletes/renames with `git rm`. Verify with `git status` that ONLY that lane's files are
present — nothing else.
3. Land them: if every hunk dependency-attributes cleanly to existing commits in that lane
(and the lane below), a blanket `but absorb` (no source — the tree holds only this
lane's files, so there's nothing to mis-route) puts each hunk in the right commit. If
the lane needs **new** files, use `but commit <lane>` instead (the new files have only
this lane to land in because the tree is isolated).
4. **Verify the lane's tip TREE, not the diff** (commit history within a lane doesn't
matter; the resulting tree does): `git show <lane>:<file>` for each touched file, plus
`git ls-tree -r <lane> <dir>` for moves/deletes. Then check the lanes *above* it for
resurrected deletes / phantom files (the rebase re-materializes deleted dirs as
untracked — `rm -rf` that residue; it's noise, the tip tree is authoritative).
5. Next lane. Push at the very end with `but push <lane> -f` and confirm every lane's
`git rev-parse <lane>` == `git ls-remote origin <lane>`.

Unrelated fixes that depend on nothing in the stack (e.g. a stale test for code already on
main) go on their **own parallel lane**: isolate just that file, `but commit -c <newlane>`.

### Stacks are linear; a fan-out is expressed through PR bases, not graph shape

A GitButler **stack** is a linear series. `but branch new <name> --anchor <parent>` does NOT
create a sibling of `<parent>` — it **inserts the new branch into the line** on top of it. So
anchoring two branches on the same parent produces `parent → first → second`, not two children
of `parent`. `but branch new <name>` with **no** anchor makes a separate parallel stack, but a
parallel stack branches off the workspace base (main), so a branch that genuinely depends on an
ancestor's commits can't live there with a clean diff.

This matters when a design's dependency tree fans out (e.g. a web lane and an SDK lane that both
depend on an API lane but not on each other). You cannot draw that fan-out in the git graph here.
You don't need to. The clean per-PR diff is a **PR-base** property, not a graph-shape property:
a stacked branch contains every commit below it, and GitHub shows only the delta against the base
you set. So put everything in **one linear stack in dependency order** and set each PR's base to
the branch directly below it. Order independent lanes however you like (sort by fewest conflicts);
lanes that touch disjoint files (e.g. `web/**` vs `api/**`) can sit anywhere in the line.

- Build the line with `but move <branch> <target-branch>` (stacks `<branch>` on top of `<target>`)
and `but move <branch> zz` (tears `<branch>` off into its own parallel stack). Use these to
reorder after the fact; take a `but oplog snapshot` first.
- **Verify the line by diffing, not by eyeballing the tree.** For each branch, run
`git diff --name-only <base>..<branch>` where `<base>` is the branch below it. The file list
must be exactly that lane's files. If a lower lane's files appear, the order is wrong (a lane got
inserted into another's ancestry) — `but move` it out of the way and re-diff.
- A branch torn off to its own parallel stack (base = main) gives a **wrong** diff against an
ancestor branch: `git diff <ancestor>..<torn-off>` reverses the ancestor's own changes (their
merge base is main). That's the tell that the branch needs to be stacked, not parallel.
- Set PR bases to match: bottom lane `--base main`, every other lane `--base <branch-below-it>`.

### Hard-won gotchas (don't relearn these)

Expand Down
8 changes: 8 additions & 0 deletions api/ee/src/core/access/permissions/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ class Permission(str, Enum):
EDIT_TOOLS = "edit_tools"
RUN_TOOLS = "run_tools"

# Triggers
VIEW_TRIGGERS = "view_triggers"
EDIT_TRIGGERS = "edit_triggers"
RUN_TRIGGERS = "run_triggers"

@classmethod
def default_permissions(cls, role):
VIEWER_PERMISSIONS = [
Expand Down Expand Up @@ -217,6 +222,7 @@ def default_permissions(cls, role):
cls.VIEW_EVALUATION_METRICS,
cls.VIEW_EVALUATION_QUEUES,
cls.VIEW_TOOLS,
cls.VIEW_TRIGGERS,
]
ANNOTATOR_PERMISSIONS = VIEWER_PERMISSIONS + [
cls.CREATE_EVALUATION,
Expand All @@ -230,6 +236,7 @@ def default_permissions(cls, role):
cls.EDIT_EVALUATION_QUEUES,
cls.EDIT_SPANS,
cls.RUN_TOOLS,
cls.RUN_TRIGGERS,
]
EDITOR_PERMISSIONS = ANNOTATOR_PERMISSIONS + [
cls.EDIT_APPLICATIONS,
Expand All @@ -251,6 +258,7 @@ def default_permissions(cls, role):
cls.EDIT_TESTSETS,
cls.EDIT_INVOCATIONS,
cls.EDIT_TOOLS,
cls.EDIT_TRIGGERS,
]
DEVELOPER_PERMISSIONS = EDITOR_PERMISSIONS + [
cls.VIEW_API_KEYS,
Expand Down
155 changes: 155 additions & 0 deletions api/ee/tests/pytest/acceptance/tools/test_tools_connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""EE acceptance tests for the /tools/connections contract (WP0).

Mirrors the OSS suite (oss/tests/pytest/acceptance/tools/test_tools_connections.py)
but exercises /tools/connections as a business-plan, developer-role account.
Under EE the endpoints are gated on the tools permission surface (VIEW_TOOLS for
reads, EDIT_TOOLS for writes); a developer role carries both, so this verifies
the contract behaves once the gate is satisfied.

The query endpoint is DB-only and needs no Composio credentials — it also proves
the gateway_connections rename landed in EE. Create / revoke make real provider
calls, so those are gated on COMPOSIO_API_KEY.

Requires a running API.
"""

import os
from uuid import uuid4

import pytest
import requests

from utils.constants import BASE_TIMEOUT


_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY"))
_requires_composio = pytest.mark.skipif(
not _COMPOSIO_ENABLED,
reason="needs live Composio credentials (COMPOSIO_API_KEY)",
)


def _create_developer_business_account(admin_api):
uid = uuid4().hex[:12]
email = f"connections-dev-{uid}@test.agenta.ai"
resp = admin_api(
"POST",
"/admin/simple/accounts/",
json={
"accounts": {
"u": {
"user": {"email": email},
"options": {
"create_api_keys": True,
"return_api_keys": True,
"seed_defaults": False,
},
"subscription": {"plan": "cloud_v0_business"},
"organization_memberships": [
{
"organization_ref": {"ref": "org"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
"workspace_memberships": [
{
"workspace_ref": {"ref": "wrk"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
"project_memberships": [
{
"project_ref": {"ref": "prj"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
}
}
},
)
assert resp.status_code == 200, resp.text
account = resp.json()["accounts"]["u"]
return {
"email": email,
"credentials": f"ApiKey {account['api_keys']['key']}",
}


def _delete_account_by_email(admin_api, *, email):
resp = admin_api(
"DELETE",
"/admin/simple/accounts/",
json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"},
)
assert resp.status_code == 204, resp.text


@pytest.fixture(scope="class")
def connections_api(admin_api, ag_env):
account = _create_developer_business_account(admin_api)

def _request(method: str, endpoint: str, **kwargs):
headers = kwargs.pop("headers", {})
headers.setdefault("Authorization", account["credentials"])
return requests.request(
method=method,
url=f"{ag_env['api_url']}{endpoint}",
headers=headers,
timeout=BASE_TIMEOUT,
**kwargs,
)

yield _request

_delete_account_by_email(admin_api, email=account["email"])


class TestToolsConnectionsQuery:
def test_query_connections_returns_200(self, connections_api):
response = connections_api("POST", "/tools/connections/query")
assert response.status_code == 200

def test_query_connections_response_shape(self, connections_api):
body = connections_api("POST", "/tools/connections/query").json()
assert "count" in body
assert "connections" in body
assert isinstance(body["connections"], list)
assert body["count"] == len(body["connections"])


class TestToolsConnectionsGet:
def test_get_unknown_connection_returns_404(self, connections_api):
response = connections_api("GET", f"/tools/connections/{uuid4()}")
assert response.status_code == 404


@_requires_composio
class TestToolsConnectionsLifecycle:
def test_create_revoke_roundtrip(self, connections_api):
slug = f"acc-{uuid4().hex[:8]}"
create = connections_api(
"POST",
"/tools/connections/",
json={
"connection": {
"slug": slug,
"provider_key": "composio",
"integration_key": "github",
"data": {"auth_scheme": "oauth"},
}
},
)
assert create.status_code == 200, create.text
connection_id = create.json()["connection"]["id"]

# Local-only revoke (C7/B3): flips is_valid on the shared row, no
# provider call, no cascade.
revoke = connections_api("POST", f"/tools/connections/{connection_id}/revoke")
assert revoke.status_code == 200, revoke.text
assert revoke.json()["connection"]["flags"]["is_valid"] is False

delete = connections_api("DELETE", f"/tools/connections/{connection_id}")
assert delete.status_code == 204, delete.text
Empty file.
Loading
Loading