Skip to content

Commit 5eea9e1

Browse files
committed
fix(branches): preserve API compatibility at scale
The branch picker needs bounded name search, but changing the stable v1 metadata endpoint would break clients that depend on project-qualified tokens. Keep that contract intact and expose picker-oriented search through an additive route.\n\nLarge archives also need a bounded Activity branch panel. Preserve the report total by aggregating overflow into an Other row instead of rendering an unbounded list or silently dropping data.
1 parent a6ddc27 commit 5eea9e1

19 files changed

Lines changed: 361 additions & 42 deletions

docs/session-api.md

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,36 +117,58 @@ for filter options:
117117
GET /api/v1/projects
118118
GET /api/v1/machines
119119
GET /api/v1/branches
120+
GET /api/v1/branch-names
120121
GET /api/v1/agents
121122
```
122123

123-
`GET /api/v1/branches` returns a bounded list of distinct branch names for
124-
filter pickers. Results are sorted by session count and then branch name. The
125-
search is case-insensitive and matches branch-name substrings.
124+
`GET /api/v1/branches` preserves the stable project-qualified metadata
125+
contract. It returns distinct `(project, branch)` pairs, ordered by project and
126+
branch, plus an opaque token for exact filtering:
127+
128+
```json
129+
{
130+
"branches": [
131+
{
132+
"project": "myapp",
133+
"branch": "main",
134+
"token": "..."
135+
}
136+
]
137+
}
138+
```
139+
140+
Pass the token back as `git_branch` when project identity must remain exact;
141+
treat it as opaque and URL-encode it in manual HTTP calls.
142+
143+
`GET /api/v1/branch-names` is the bounded name-search contract used by filter
144+
pickers. It deduplicates same-named branches after applying project scope and
145+
orders names by the most recent matching session activity, then branch name.
146+
Search is case-insensitive and matches branch-name substrings.
126147

127148
| Query parameter | Meaning |
128149
|-----------------|---------|
129150
| `search` | Optional case-insensitive branch-name substring |
130151
| `projects` | Optional repeated project filter applied before branch-name deduplication |
131152
| `scope` | `roots` by default; `all` also includes subagent and fork sessions |
132153
| `limit` | Maximum branch names, from 1 through 100; default 100 |
154+
| `include_one_shot` | Include one-shot sessions; default false |
155+
| `include_automated` | Include automated sessions; default false |
133156

134157
```json
135158
{
136159
"branches": [
137160
{
138-
"branch": "main",
139-
"session_count": 42
161+
"branch": "main"
140162
}
141163
],
142164
"has_more": false
143165
}
144166
```
145167

146-
`has_more` is true when additional matching branch names exist beyond the
147-
requested limit. Branch-aware endpoints accept branch names directly in
148-
`git_branch`; clients do not obtain opaque filter tokens from this metadata
149-
endpoint.
168+
`has_more` is true when additional matching names exist beyond the requested
169+
limit. Branch-aware endpoints accept these names directly in `git_branch` for
170+
name-based filtering, or the token from `/api/v1/branches` for an exact
171+
project/branch identity.
150172

151173
## Commands
152174

@@ -236,7 +258,7 @@ therefore appear on both dates.
236258
| `--project` | `project` | string |
237259
| `--exclude-project` | `exclude_project` | string |
238260
| `--machine` | `machine` | string |
239-
| `--branch` | `git_branch` | Branch name; the CLI requires `--project`. Direct HTTP callers may supply project scope separately |
261+
| `--branch` | `git_branch` | Branch name; the CLI requires `--project` and sends an exact opaque token. Direct HTTP callers may use a name or token |
240262
| `--agent` | `agent` | string |
241263
| `--date` | `date` | `YYYY-MM-DD` |
242264
| `--date-from` | `date_from` | `YYYY-MM-DD` |

frontend/src/lib/api/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function searchBranches(
3939
params: BranchSearchParams,
4040
): Promise<BranchSearchResponse> {
4141
configureGeneratedClient();
42-
const response = await MetadataService.getApiV1Branches({
42+
const response = await MetadataService.getApiV1BranchNames({
4343
projects: params.projects,
4444
search: params.search || undefined,
4545
limit: params.limit ?? 100,

frontend/src/lib/api/generated/index.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/api/generated/models/BranchesResponse.ts

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/api/generated/models/DbBranchInfo.ts

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/api/generated/models/ServiceSessionDetail.ts

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

frontend/src/lib/api/generated/services/MetadataService.ts

Lines changed: 44 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/components/activity/Breakdowns.svelte

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
const byProject = $derived(rankedRows(report.by_project));
4646
const byModel = $derived(rankedRows(report.by_model));
4747
const byAgent = $derived(rankedRows(report.by_agent));
48+
const BRANCH_ROW_LIMIT = 40;
49+
const OTHER_BRANCHES_KEY = "__activity_other_branches__";
4850
// Tokenizing branch rows depends only on the report, not the metric, so
4951
// keep it out of the rankedRows derived: flipping minutes/cost re-ranks
5052
// without re-allocating every row of an uncapped (project, branch) list.
@@ -55,7 +57,34 @@
5557
displayLabel: branchLabel(b.project, b.branch, m.shared_no_branch()),
5658
})),
5759
);
58-
const byBranch = $derived(rankedRows(branchRows));
60+
const byBranch = $derived.by(() => {
61+
const ranked = rankedRows(branchRows);
62+
if (ranked.length <= BRANCH_ROW_LIMIT) return ranked;
63+
64+
const visible = ranked.slice(0, BRANCH_ROW_LIMIT - 1);
65+
const omitted = ranked.slice(BRANCH_ROW_LIMIT - 1);
66+
const sum = (value: (row: BreakdownRow) => number) =>
67+
omitted.reduce((total, row) => total + value(row), 0);
68+
return [
69+
...visible,
70+
{
71+
key: OTHER_BRANCHES_KEY,
72+
displayLabel: m.shared_other(),
73+
agent_minutes: sum((row) => row.agent_minutes),
74+
cost: moneyFromMicrodollars(sum((row) => row.cost.microdollars)),
75+
interactive_agent_minutes: sum(
76+
(row) => row.interactive_agent_minutes,
77+
),
78+
automated_agent_minutes: sum((row) => row.automated_agent_minutes),
79+
interactive_cost: moneyFromMicrodollars(
80+
sum((row) => row.interactive_cost.microdollars),
81+
),
82+
automated_cost: moneyFromMicrodollars(
83+
sum((row) => row.automated_cost.microdollars),
84+
),
85+
},
86+
];
87+
});
5988
6089
interface Panel {
6190
title: string;

frontend/src/lib/components/activity/Breakdowns.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,4 +394,51 @@ describe("Breakdowns", () => {
394394
unmount(component);
395395
target.remove();
396396
});
397+
398+
it("caps large branch panels with an aggregated Other row", async () => {
399+
const report = makeReport();
400+
report.by_branch = Array.from({ length: 41 }, (_, index) => {
401+
const value = index < 39 ? 2 : 1;
402+
return {
403+
project_key: `pl1:sha256:${index}`,
404+
project: `project-${index}`,
405+
branch: `branch-${index}`,
406+
agent_minutes: value,
407+
cost: testMoney(value),
408+
interactive_agent_minutes: value,
409+
automated_agent_minutes: 0,
410+
interactive_cost: testMoney(value),
411+
automated_cost: testMoney(0),
412+
};
413+
});
414+
const target = document.createElement("div");
415+
document.body.appendChild(target);
416+
const component = mount(Breakdowns, { target, props: { report } });
417+
await tick();
418+
419+
const branchPanel = Array.from(
420+
target.querySelectorAll<HTMLElement>(".breakdown-panel"),
421+
).find(
422+
(panel) => panel.querySelector(".panel-title")?.textContent === "Branch",
423+
);
424+
expect(branchPanel?.querySelectorAll(".bar-row")).toHaveLength(40);
425+
const otherRow = Array.from(
426+
branchPanel?.querySelectorAll<HTMLElement>(".bar-row") ?? [],
427+
).find((row) => row.querySelector(".bar-label")?.textContent === "Other");
428+
expect(otherRow?.querySelector(".bar-value")?.textContent?.trim()).toBe("2");
429+
expect(branchPanel?.textContent).not.toContain("project-39/branch-39");
430+
expect(branchPanel?.textContent).not.toContain("project-40/branch-40");
431+
432+
const costButton = Array.from(
433+
target.querySelectorAll<HTMLButtonElement>(".metric-btn"),
434+
).find((button) => button.textContent?.trim() === "Cost");
435+
costButton?.click();
436+
await tick();
437+
expect(otherRow?.querySelector(".bar-value")?.textContent?.trim()).toBe(
438+
"$2.00",
439+
);
440+
441+
await unmount(component);
442+
target.remove();
443+
});
397444
});

frontend/src/lib/components/usage/UsagePage.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ describe("UsagePage refresh behavior", () => {
503503
);
504504
vi.spyOn(usage, "fetchAll").mockResolvedValue();
505505
vi.spyOn(sessions, "loadAgents").mockResolvedValue();
506-
const searchBranches = vi.spyOn(MetadataService, "getApiV1Branches")
506+
const searchBranches = vi.spyOn(MetadataService, "getApiV1BranchNames")
507507
.mockResolvedValue({ branches: [], has_more: false });
508508
router.route = "usage";
509509
router.params = {

0 commit comments

Comments
 (0)