diff --git a/.github/workflows/dev-version-bump.yml b/.github/workflows/dev-version-bump.yml new file mode 100644 index 0000000000..45ecd4ae3f --- /dev/null +++ b/.github/workflows/dev-version-bump.yml @@ -0,0 +1,170 @@ +name: Dev version bump + +# When a release publishes, open a pull request that moves `dev` past the published +# version. Without this, `dev` keeps carrying a version that is at or behind a released +# one, and `tests/release-version-line.test.ts` fails on `dev` and on every pull request +# opened against it - inherited red a contributor cannot fix from their own diff. +# +# That has been repaired by hand four times: 32529c2b2, e4a85d134, 076ad3036, befcac3e1. +# The second of those ADDED the detector and two more repairs followed it, so more +# visibility was never the missing piece; a prepared change was. +# +# WHAT THIS DOES NOT DO. It does not push to `dev`. It opens a pull request and a human +# merges it, because ruleset `Protect dev` requires an approving review and code-owner +# sign-off that a bot cannot supply. Until that merge the red persists. This converts a +# forgotten chore into a queued, reviewable change - not into an automatic repair. +# +# A `release` event resolves this workflow file from the repository DEFAULT branch +# (`main`), not from `dev` - the same trap documented in cleanup-closed-pr-branches.yml. +# So merging this file to `dev` installs it but arms nothing; it first fires after an +# ordinary dev -> main promotion carries it there. +# +# There is deliberately no `workflow_dispatch`: a branch-selected manual run executes +# THAT branch body with `contents: write`. Re-drive a missed run by running +# `bun scripts/bump-dev-version.ts package.json` locally and opening the pull +# request normally. +on: + release: + types: [published] + +permissions: {} + +concurrency: + group: dev-version-bump + cancel-in-progress: false + +jobs: + open-bump-pr: + runs-on: ubuntu-latest + permissions: + # Push the new codex/dev-version-* branch. Ruleset `Protect dev` covers only + # refs/heads/dev, so the bump branch is unprotected and this token cannot + # bypass dev review. It is the ruleset that keeps this job off dev, not the + # permission name. + contents: write + # Open the pull request. + pull-requests: write + steps: + - name: Checkout dev + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + # Tags are load-bearing, not decoration: the freeness gate below is a bun + # test that reads the local tag set, and release-version-line.test.ts + # returns EARLY on an empty set. A shallow checkout would make that gate + # silently vacuous instead of failing loudly. + fetch-depth: 0 + # Do NOT set persist-credentials: false here as the read-only workflows do. + # This job has to push its bump branch. + + # The repository-owned composite action, not a hand-pinned setup-bun SHA: it + # resolves the Bun version from package.json so the runtime SOT stays in one + # place. An independently pinned action here would drift from every other job. + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Decide the version dev should carry + id: decide + env: + RELEASED_VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + bun scripts/bump-dev-version.ts "${RELEASED_VERSION}" package.json + + - name: Prove the chosen version is unused + if: ${{ steps.decide.outputs.changed == 'true' }} + # The script decides the candidate from the released version SHAPE, which is all + # a pure function can see. Whether that candidate is actually FREE is a property + # of the tag set, so it is settled here by the detector that already owns the + # question. If this fails, no pull request is opened and the job goes red asking + # for a human decision - which is the correct outcome, not a fallback. + run: bun test tests/release-version-line.test.ts + + - name: Open the bump pull request + if: ${{ steps.decide.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ steps.decide.outputs.version }} + RELEASED_VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + + branch="codex/dev-version-${NEXT_VERSION}" + + # Idempotent: a second publish, a re-run, or a manual repair must not turn a + # successful release into a red job. + # + # Check the PULL REQUEST as well as the branch, not just the branch. A security + # review caught that: an open bump pull request whose head branch was deleted + # leaves the branch check passing, so the job would recreate the branch and then + # fail on `gh pr create` with "already exists" — turning a successful release red + # for a repair that was already queued. + open_prs="$(gh pr list --base dev --head "${branch}" --state open --json number --jq 'length')" + if [ "${open_prs}" != "0" ]; then + echo "::notice::a bump pull request for ${branch} is already open; nothing to do" + exit 0 + fi + + # An existing branch is NOT terminal. If a previous run pushed the branch and then + # failed at `gh pr create`, exiting here would leave the repair permanently unqueued + # while every rerun reports success - the exact failure mode a reviewer caught. So + # reuse the branch and fall through to pull-request creation instead. + if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then + echo "::notice::${branch} exists without an open pull request; validating it" + git fetch origin "${branch}" + + # Fail closed on unexpected content. The branch carries the bot's own one-line + # bump, so anything else on it means a human or another job is using that name and + # this job must not push to it or open a pull request from it. + changed_files="$(git diff --name-only "origin/dev...origin/${branch}")" + if [ "${changed_files}" != "package.json" ]; then + echo "::error::${branch} touches unexpected files: ${changed_files:-}" + exit 1 + fi + branch_version="$(git show "origin/${branch}:package.json" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" + if [ "${branch_version}" != "${NEXT_VERSION}" ]; then + echo "::error::${branch} carries ${branch_version}, expected ${NEXT_VERSION}" + exit 1 + fi + git checkout -B "${branch}" "origin/${branch}" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${branch}" + git add package.json + git commit -m "fix(release): move dev to ${NEXT_VERSION} after ${RELEASED_VERSION}" + git push origin "${branch}" + fi + + gh pr create \ + --base dev \ + --head "${branch}" \ + --title "fix(release): move dev to ${NEXT_VERSION} after ${RELEASED_VERSION}" \ + --body "$(cat < package.json`, + then open the pull request normally. ## The retired `dev2-go` line diff --git a/Start-OpenCodex.cmd b/Start-OpenCodex.cmd new file mode 100644 index 0000000000..ea8f81fa73 --- /dev/null +++ b/Start-OpenCodex.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Start-OpenCodex.ps1" %* +set "launcher_exit=%ERRORLEVEL%" + +if not "%launcher_exit%"=="0" ( + echo. + echo OpenCodex could not be started. Review the error above. + pause +) + +exit /b %launcher_exit% diff --git a/Start-OpenCodex.ps1 b/Start-OpenCodex.ps1 new file mode 100644 index 0000000000..b28e836573 --- /dev/null +++ b/Start-OpenCodex.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param( + [ValidateRange(1, 65535)] + [int]$Port = 10100, + + [ValidateRange(1, 120)] + [int]$StartupTimeoutSeconds = 30, + + [switch]$NoBrowser +) + +$ErrorActionPreference = "Stop" +$repoRoot = $PSScriptRoot +$dashboardUrl = "http://127.0.0.1:$Port/" +$healthUrl = "${dashboardUrl}healthz" +$logDirectory = Join-Path $repoRoot ".tmp" +$stdoutLog = Join-Path $logDirectory "launcher.out.log" +$stderrLog = Join-Path $logDirectory "launcher.err.log" + +function Get-OpenCodexHealth { + try { + $response = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 2 + if ($response.service -eq "opencodex" -and $response.status -eq "ok") { + return $response + } + } + catch { + return $null + } + + return $null +} + +function Open-Dashboard { + if (-not $NoBrowser) { + Start-Process $dashboardUrl + } +} + +function Test-IsLocalCheckoutProcess { + param([Parameter(Mandatory = $true)][int]$ProcessId) + + try { + $runningProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" + if ($null -eq $runningProcess) { + return $false + } + + if (-not [string]::IsNullOrWhiteSpace($runningProcess.ExecutablePath) -and + $runningProcess.ExecutablePath.StartsWith($repoRoot, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + if ([string]::IsNullOrWhiteSpace($runningProcess.CommandLine)) { + return $false + } + + $expectedEntryPoint = Join-Path $repoRoot "src\cli\index.ts" + return $runningProcess.CommandLine.IndexOf($expectedEntryPoint, [StringComparison]::OrdinalIgnoreCase) -ge 0 + } + catch { + return $false + } +} + +$localBunExecutable = Join-Path $repoRoot "node_modules\bun\bin\bun.exe" +$bunApplication = Get-Command bun.exe -CommandType Application -ErrorAction SilentlyContinue +if (Test-Path -LiteralPath $localBunExecutable) { + $bunExecutable = $localBunExecutable +} +elseif ($null -ne $bunApplication) { + $bunExecutable = $bunApplication.Source +} +else { + throw "Bun was not found. Install Bun from https://bun.sh, then run this launcher again." +} + +if (-not (Test-Path -LiteralPath (Join-Path $repoRoot "node_modules"))) { + throw "Dependencies are missing. Open PowerShell in '$repoRoot', run 'bun install', then try again." +} + +$existingHealth = Get-OpenCodexHealth +if ($null -ne $existingHealth) { + if (Test-IsLocalCheckoutProcess -ProcessId $existingHealth.pid) { + Write-Host "This OpenCodex checkout is already running on port $Port (PID $($existingHealth.pid))." + Open-Dashboard + exit 0 + } + + Write-Host "A different OpenCodex installation is using port $Port (PID $($existingHealth.pid))." + Write-Host "Stopping it before starting this checkout..." + & $bunExecutable run src/cli/index.ts stop + if ($LASTEXITCODE -ne 0) { + throw "The existing OpenCodex instance could not be stopped safely." + } + + $stopDeadline = (Get-Date).AddSeconds(15) + do { + Start-Sleep -Milliseconds 250 + $existingHealth = Get-OpenCodexHealth + } while ($null -ne $existingHealth -and (Get-Date) -lt $stopDeadline) + + if ($null -ne $existingHealth) { + throw "The previous OpenCodex instance is still using port $Port." + } +} + +New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + +Write-Host "Starting OpenCodex on port $Port..." +$process = Start-Process ` + -FilePath $bunExecutable ` + -ArgumentList @("run", "src/cli/index.ts", "start", "--port", "$Port") ` + -WorkingDirectory $repoRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -PassThru + +$deadline = (Get-Date).AddSeconds($StartupTimeoutSeconds) +do { + Start-Sleep -Milliseconds 250 + $process.Refresh() + + $health = Get-OpenCodexHealth + if ($null -ne $health) { + if (-not (Test-IsLocalCheckoutProcess -ProcessId $health.pid)) { + throw "Port $Port became healthy, but it belongs to a different OpenCodex installation." + } + Write-Host "OpenCodex is ready at $dashboardUrl (PID $($health.pid))." + Open-Dashboard + exit 0 + } + + if ($process.HasExited) { + throw "OpenCodex stopped during startup (exit code $($process.ExitCode)). See '$stderrLog'." + } +} while ((Get-Date) -lt $deadline) + +throw "OpenCodex did not become ready within $StartupTimeoutSeconds seconds. See '$stderrLog'." diff --git a/devlog/_plan/260830_dev_version_line_bump_pr/000_cause_and_roadmap.md b/devlog/_plan/260830_dev_version_line_bump_pr/000_cause_and_roadmap.md new file mode 100644 index 0000000000..32b1a7efd6 --- /dev/null +++ b/devlog/_plan/260830_dev_version_line_bump_pr/000_cause_and_roadmap.md @@ -0,0 +1,134 @@ +# dev version line: stop repairing it by hand + +Unit: `devlog/_plan/260830_dev_version_line_bump_pr/` + +Named for what it ships: a version-bump PULL REQUEST opened when a release publishes. +The unit was briefly called `..._autobump`, which an audit correctly rejected — the +workflow prepares the change and a human merges it, so nothing is automatic end to +end. +Goalplan: `repair-the-dev-version-line-and-add-a-post-relea` + +## The symptom, today + +`dev` head `df8b3882f` carries `package.json` version `2.36.0`. Tag `v2.36.0` +names `c7d8407d2`, which is `origin/main`. So the tree claims a version that is +already published from a different commit, and +`tests/release-version-line.test.ts` reports exactly that: + +``` +(fail) release version line > the in-tree version is never behind a released one +error: package.json version 2.36.0 equals release tag v2.36.0, but this commit is +not the one that tag names. The tree claims an already-published version: +publishing is refused as a duplicate. Bump package.json. +``` + +This fails CI jobs `test 2/4` and `macos` on `dev` itself (run 33312566315, cut at +`c2778ca3a` — `dev` has since advanced to `df8b3882f` and the failure still +reproduces there) and therefore on every PR opened against it. PR #3007 inherited +the same two red jobs for a two-file GUI change, and branch protection refused the +merge until it was overridden. + +## Why a one-line bump is not the fix + +The same defect has been repaired by hand FOUR times: + +| commit | what it did | +|---|---| +| `32529c2b2` | `2.24.2` -> `2.27.0`, after dev trailed the published channel by two releases | +| `e4a85d134` | `2.32.1-preview.20260825` -> `2.34.0`; also ADDED `release-version-line.test.ts` | +| `076ad3036` | `2.34.0` -> `2.35.0`, right after v2.34.0 shipped | +| `befcac3e1` | `2.35.0` -> `2.36.0`, after v2.36.0-preview.20260829 shipped | + +Note the second row: the detector was added DURING this sequence, and two more +hand-repairs followed it. Visibility was never the missing piece — that is the +finding that decided the design in `020`. + +Four repairs of one cause is a missing actor, not four accidents. The cause is +structural and visible in `scripts/release.ts`: the release runs only on `main` or +`preview` (`allowedBranches = ["main", "preview"]`, line 496), bumps +`package.json` there, commits `release: v`, pushes THAT branch, and +dispatches `release.yml`. The workflow ends at "Create GitHub release" — tag plus +GitHub release, nothing more. No step in either file ever advances `dev`. The +workflow declares `permissions: {}` at the top (line 32) and grants each job only +`contents: read` or `contents: write`, which is what makes an added `dev` write +there a security-review problem rather than a convenience. + +So the version line on `dev` goes stale the moment a release publishes, and stays +stale until a human notices red CI on an unrelated PR. The cost lands on +contributors: inherited red they did not cause and cannot fix from their own diff. + +What this unit can and cannot promise: it moves the repair from "someone eventually +remembers" to "a reviewable PR is waiting." It does not make the red impossible, +because the bump still needs a human merge — `Protect dev` requires an approving +review and code-owner sign-off, which a bot cannot supply. Claiming more than that +was the defect an audit caught in the first two drafts of `020`. + +## Constraint that shapes the design + +`dev` carries the NEXT STABLE version; the preview train adds its own suffix at +release time. That is the precedent `befcac3e1` states explicitly and the three +earlier repairs followed. A mechanism must preserve it — bumping dev to a preview +string would contradict every prior repair. + +The existing test is already the right detector. It reads the local tag set, needs +no network, and distinguishes "equal on the release commit" (legal) from "equal +anywhere else" (duplicate). Nothing about the detector needs changing. What is +missing is anything that PREPARES the repair: today the detector reports the problem +to whoever happens to open the next PR, and the fix is left to memory. + +## Phase map + +Each decade doc below is one full PABCD cycle. Dependency-ordered: the version +repair lands first because it unblocks CI for everything else, then the actor that +prepares the next repair as a reviewable PR, then the ship. + +- `010_version_repair.md` — move `dev` off the consumed `2.36.0` (wp2). +- `020_post_release_bump.md` — open the dev bump as a PR when a release publishes (wp3). + Note: that workflow only runs once it reaches `main`, the default branch. Merging it + to `dev` does not activate it. +- `030_ship.md` — PR against `dev`, CI evidence, merge (wp4). + +## Audit record + +TWO drafts of this roadmap were FAILED by an independent reviewer, and both verdicts +changed the design rather than the wording. + +Round 1: `020` chose a printed notice inside the release script and called it an +autobump. The reviewer showed the existing test is already louder than any printout, +and that two hand-repairs happened AFTER it landed. It also caught a wrong +"highest tag" claim in `010` and a test plan citing a `--dry-run` flag and reusable +shim helpers that do not exist. + +Round 2: the replacement PR-workflow design could not have worked. A `release` event +runs the workflow from the DEFAULT branch (`main`), which the scope forbade touching; +the named comparator `compareReleaseVersions` sits behind a module-scope +`process.exit` in `scripts/release.ts` and cannot be imported; and the "+minor" bump +rule contradicted `befcac3e1`, which moved `dev` to `2.36.0` on a +`v2.36.0-preview.*` publish. All three are fixed in the third draft, which imports +`compareReleaseTags` from `scripts/release-notes.ts` instead, records the `main` +promotion as a named follow-up in `030`, and replaces "+minor" with the two-branch +rule in `020`. The unit was also renamed. + +Round 3 caught the sequel to that last fix: "lowest unused stable" is not a pure +function of the script's two inputs, because "unused" is a property of the tag set and +the registry. The rule is now split — shape arithmetic in the script, freeness in the +tag-aware detector that already exists. It also caught that the out-of-scope list +below forbade the very promotion `030` depends on. + +Every rejected option and its reason stay in `020` so the decision is auditable. + +## Out of scope + +No publish, tag, or Release dispatch. No `main`/`preview` change IN THIS UNIT. No +merge of `main` back into `dev` to "sync" the version: `010_wp2_version_line.md` +names that as the trap that lands the consumed string on top of newer commits. + +That `main` exclusion is a scope boundary, not a claim that `main` is irrelevant. The +workflow in `020` cannot run until an ordinary maintainer-controlled promotion carries +it to the default branch; `030` records that as the named follow-up. Two consequences +worth stating plainly: + +- Merging this unit into `dev` fixes the red CI immediately (that is `010`) but arms + nothing (that is `020`, dormant until promotion). +- The next release cut from the CURRENT `main` will still strand `dev` one last time. + The loop closes on the release AFTER the workflow reaches `main`. diff --git a/devlog/_plan/260830_dev_version_line_bump_pr/010_version_repair.md b/devlog/_plan/260830_dev_version_line_bump_pr/010_version_repair.md new file mode 100644 index 0000000000..f74141e12c --- /dev/null +++ b/devlog/_plan/260830_dev_version_line_bump_pr/010_version_repair.md @@ -0,0 +1,55 @@ +# 010 — move dev off the consumed 2.36.0 (wp2) + +One line. `package.json` `version`: `2.36.0` -> `2.37.0`. + +## Why 2.37.0 + +Verified against the real state, not read off a pattern: + +| candidate | verdict | +|---|---| +| `2.36.0` (current) | tag `v2.36.0` names `c7d8407d2`, not dev's head; npm `latest` = 2.36.0. Consumed. | +| `2.36.1` | mechanically legal but labels the range a patch, against the `befcac3e1` precedent | +| `2.36.1-preview.*` | contradicts "dev carries the next STABLE version" | +| `2.37.0` | `npm view @bitkyc08/opencodex@2.37.0` -> E404; no `v2.37.0` in the tag set; forward of every tag | + +Highest existing tag by the repository's own ordering is `v2.36.0` — NOT the +later-dated `v2.36.0-preview.20260830`. Sorting all 218 `v*` tags with +`compareReleaseTags` puts the stable release above its own prerelease, which is +correct SemVer precedence and the reason the failing message names `v2.36.0`: + +``` +top 5: v2.34.0 v2.35.0 v2.36.0-preview.20260829 v2.36.0-preview.20260830 v2.36.0 +HIGHEST = v2.36.0 +compareReleaseTags("v2.37.0", "v2.36.0") -> 1 +``` + +The first draft of this doc asserted the preview was highest while claiming to have +run the comparator. It had not. Run it. + +npm dist-tags at the time of writing: `latest` = 2.36.0, `preview` = +2.36.0-preview.20260830. + +## The diff + +```json +- "version": "2.36.0", ++ "version": "2.37.0", +``` + +No other file carries the product version. `gui/package.json` is `0.0.0`, +`docs-site/package.json` is `0.0.1`, and `src/generated/*` hold catalog hashes. +Re-verify with a repo-wide search excluding `node_modules`, `.tmp`, `devlog`, +`gui/dist` before claiming the line is unique. + +## Verification + +- `bun test tests/release-version-line.test.ts` — all three tests pass, including + "the in-tree version is never behind a released one" which currently fails. +- Re-run the freeness checks (`npm view`, `git tag --list`) immediately before + committing: another release landing mid-cycle would consume the candidate. + +## What this does not do + +It does not publish, tag, or promote, and it does not stop the next release from +stranding dev again. That is `020`. diff --git a/devlog/_plan/260830_dev_version_line_bump_pr/020_post_release_bump.md b/devlog/_plan/260830_dev_version_line_bump_pr/020_post_release_bump.md new file mode 100644 index 0000000000..b3b51de7d3 --- /dev/null +++ b/devlog/_plan/260830_dev_version_line_bump_pr/020_post_release_bump.md @@ -0,0 +1,202 @@ +# 020 — open the dev bump as a PR when a release publishes (wp3) + +Third draft. Two independent audit rounds failed the first two; both verdicts and the +reasons are recorded below, because each one changed the design rather than the prose. + +## Round 1 rejected a printed notice (option C) + +> The detector already exists and is louder than a notice. +> `tests/release-version-line.test.ts` fails CI on every unrelated PR, and TWO +> hand-repairs happened after it landed — `e4a85d134` added it. A printout is a +> reminder, not a mechanism. + +Also verified: `dryRun = !args.includes("--publish")` (`scripts/release.ts:492`) makes +the default invocation a rehearsal, so a notice fires on every dry run until it is +trained away; and `release.yml` is `workflow_dispatch`, so an Actions-tab release +never runs `scripts/release.ts` at all. + +## Round 2 rejected the first PR-workflow design + +Three blockers, each confirmed against the repository: + +1. **It would never fire.** A `release` event runs the workflow file from the + DEFAULT branch. `gh repo view` reports `main`. This repository already documents + the identical trap in `cleanup-closed-pr-branches.yml:8-10` for scheduled + workflows. Landing the file on `dev` alone starts nothing. +2. **It could not import its comparator.** `compareReleaseVersions` is exported from + `scripts/release.ts:303`, but that file parses `process.argv` and calls + `process.exit(1)` at module scope (lines 487-491) with no `import.meta.main` + guard. `tests/release-version-line.test.ts:27-29` already records that importing + it kills the runner. +3. **The bump rule was wrong for preview-first releases.** `befcac3e1` moved `dev` + from `2.35.0` to `2.36.0` when the published tag was + `v2.36.0-preview.20260829`. "Increment the released core's minor" would have said + `2.37.0` and skipped a stable version that had not shipped. + +## Chosen design + +A separate workflow that opens a PULL REQUEST against `dev`, plus a pure script that +decides the version. + +**Honest scope.** This does not silently repair `dev`; it converts a forgotten chore +into a review-queue item that a human merges. Until that merge, +`release-version-line` stays red on `dev`. That is a real improvement over today — +a PR is durable where a printout is not, and it lands in the same place +`MAINTAINERS.md` already requires every `dev` change to land — but it is not an +autobump, and this unit should not be described as one. + +## The version rule + +Not "+minor" — that contradicts `befcac3e1`. But not "lowest unused stable" either, +phrased as if the script could evaluate it: "unused" is a property of the TAG SET and +the npm registry, and a pure function cannot see either. Stating the rule that way +would have made the doc unimplementable in exactly the manner the previous two drafts +were. + +Split the rule by who can answer it: + +**The script decides the CANDIDATE from the published version's SHAPE alone.** + +| published | candidate | precedent | +|---|---|---| +| `X.Y.Z-preview.*` (a prerelease of an unreleased core) | `X.Y.Z` | `befcac3e1`: 2.35.0 -> 2.36.0 on v2.36.0-preview.20260829 | +| `X.Y.Z` (stable) | `X.(Y+1).0` | `e4a85d134` 2.33.0 -> 2.34.0; `076ad3036` 2.34.0 -> 2.35.0; `32529c2b2` tip 2.26.0 -> 2.27.0 | + +Both rows are pure string arithmetic on the published version, and both are pinned by +tests. The prerelease row is the one that matters: the stable core of a +preview-first release has NOT shipped, so `dev` should carry it rather than skip it. + +**Freeness is verified where the tag set is visible.** The candidate is passed to the +existing detector, not re-derived: after the bump the workflow runs +`bun test tests/release-version-line.test.ts` in the `dev` checkout, which sorts the +real local tags with `compareReleaseTags` and fails if the candidate is at or behind +any published version. If that test fails, the workflow opens NO PR and the job goes +red — a visible request for a human decision, not a wrong PR. + +This is the honest division: shape arithmetic in the pure function, set membership in +the tag-aware gate that already exists. The script additionally refuses to emit a +candidate that `compareReleaseTags` does not rank strictly ahead of both `dev`'s +current version and the published one, which is the part it CAN check without I/O. + +## Files + +**`scripts/bump-dev-version.ts`** — pure decision logic, no git and no network. + +- Imports `compareReleaseTags` from `scripts/release-notes.ts`, NOT + `compareReleaseVersions` from `scripts/release.ts`. `release-notes.ts` guards its + CLI behind `import.meta.main` (line 1231) and already exports the comparator at + line 66, which is exactly why `release-version-line.test.ts` imports from there. + This avoids editing `scripts/release.ts` at all, keeping the release authority and + its security review surface untouched. +- Takes the released version and an explicit `package.json` path, so a test can + operate on a temp copy and the script is genuinely pure with respect to the + checkout. +- Emits a MACHINE CONTRACT, not prose: writes `changed=true|false` and `version=` + to `$GITHUB_OUTPUT` when set, and prints the same as JSON otherwise. Round 2 was + right that "print the chosen version" mixed with "print that nothing is needed" is + not an interface. +- `dev` already ahead -> `changed=false`, file untouched, exit 0. +- Malformed released version -> non-zero exit, file untouched. + +**`.github/workflows/dev-version-bump.yml`** — the actor. + +- Trigger: `release: [published]` only. No `workflow_dispatch`: round 2 correctly + noted that a branch-selected manual run executes THAT branch's body with + `contents: write`, which is the pattern this repository's own workflow comments + refuse. A missed run is re-driven by running the script by hand and opening the PR + normally. +- `permissions: {}` at the top; the single job takes `contents: write` (to push a new + `codex/dev-version-` branch — ruleset `Protect dev` covers only + `refs/heads/dev`, so the new branch is unprotected) and `pull-requests: write` (to + open the PR). Not `issues: write`, not `id-token: write`. +- `actions/checkout` with `ref: dev` AND `fetch-depth: 0` (or `fetch-tags: true`). A + `release` checkout defaults to the tag on `main`/`preview`, which is the wrong tree + to bump — and the tags are not optional decoration: `release-version-line.test.ts` + returns early on an empty tag set (line 93), so a shallow checkout would make the + freeness gate below silently vacuous rather than failing loudly. +- Do NOT copy `persist-credentials: false` from the repository's read-only workflows. + This job has to push its bump branch. +- Set up Bun and run `bun install` before the freeness gate: that gate is a + `bun test` invocation, not a shell comparison. +- Idempotent: if `codex/dev-version-` or its PR already exists, log and exit 0 + rather than failing the push. A second publish must not error. +- The workflow file must reach `main` to ever run. That is a promotion, not a + `dev`-only change, and `030` records it as an explicit follow-up rather than + pretending the merge to `dev` activates it. + +**Known limitation, stated not hidden:** a PR opened with `GITHUB_TOKEN` does not +start `pull_request` workflows, so the bump PR arrives without CI. `Protect dev` +additionally requires an approving review and code-owner review, and +`.github/CODEOWNERS` assigns `/.github/` and `/package.json` to human owners. A bot +cannot satisfy those. The PR is therefore a prepared, reviewable change — which is +the honest ceiling for automation here, and the reason the "autobump" framing is +dropped. + +## Test + +`tests/bump-dev-version.test.ts`, against temp copies of `package.json`. No shim +harness: the script is pure and takes a path. + +- dev `2.36.0`, released stable `2.36.0` -> `2.37.0`, `changed=true`. +- dev `2.35.0`, released stable `2.36.0` -> `2.37.0` (behind, not merely equal). +- dev `2.35.0`, released `2.36.0-preview.20260829` -> `2.36.0`. Pins `befcac3e1`, and + fails under a naive "+minor" rule, which is what makes it the load-bearing case. +- dev `2.36.0`, released `2.36.0-preview.20260830` -> `changed=false`: dev already + carries the prerelease's stable core, so there is nothing to do. +- dev `2.37.0`, released `2.36.0` -> `changed=false`, file BYTE-IDENTICAL, exit 0. +- dev `2.37.0-preview.1`, released `2.36.0` -> `changed=false`; a preview of a future + core is ahead, which `release-version-line.test.ts` already pins. +- released version malformed -> non-zero exit, file untouched. + +Red-first for a CLI is a behavioral red, not an import error: assert the chosen +version and the untouched-file invariant, and confirm each assertion fails against a +deliberately wrong rule (e.g. always `+minor`, which breaks the preview case) before +committing. + +## Also + +`MAINTAINERS.md`: after a release publishes, a `dev` version-bump PR is opened +automatically; merging it is part of closing out the release. Note that the workflow +only runs once it is on `main`. + +## As implemented + +Shipped in `075a33be8`. Three deviations from the sketch above, recorded because each +was forced by the tree rather than chosen: + +1. **Bun setup uses the repository's composite action**, `./.github/actions/setup-project-bun`, + not a hand-pinned `oven-sh/setup-bun` SHA. That action resolves the version from + `package.json` so the runtime source of truth stays in one place; an independently + pinned SHA here would have drifted from every other job. The first draft of the + workflow pinned its own and disagreed with the one already in the tree. +2. **`parseReleaseTag` is not exported** from `release-notes.ts`, so the script does its + own shape parse rather than widening that module's surface for one caller. Only + `compareReleaseTags` is imported. +3. **A `v`-prefix normaliser was required.** The workflow passes + `github.event.release.tag_name` (`v2.36.0`) while `package.json` holds a bare version, + so prefixing blindly built `vv2.36.0` and every comparison against it misordered. It + surfaced as the script rejecting a correct candidate: "candidate 2.37.0 does not rank + ahead of released v2.36.0". Now pinned by a test. + +The tests also caught a defect the plan did not anticipate. The ahead-check originally +compared `dev` against the CANDIDATE, which is the wrong question: a `dev` at +`2.37.0-preview.1` with `2.36.0` published is genuinely ahead of the release but behind +the candidate `2.37.0`, so the script would have "repaired" a healthy tree and +downgraded a legitimate prerelease line. It now compares against the released version, +which is the same question `release-version-line.test.ts` asks. + +A security review of the shipped workflow also found one gap worth recording. The +idempotency guard originally checked only whether the bump BRANCH existed. An open bump +pull request whose head branch had been deleted leaves that check passing, so the job +would recreate the branch and then fail on `gh pr create` with "already exists" - turning +a successful release red for a repair that was already queued. It now checks for an open +pull request first, then the branch. + +Two residual gaps are accepted rather than fixed, and named so a later reader does not +mistake them for oversights: + +- `Bun.write` to `$GITHUB_OUTPUT` truncates rather than appends. That is equivalent to a + first write today because the step emits nothing else, but it is not append-safe if a + later edit adds a second output in the same step. +- There is no test that exercises the `$GITHUB_OUTPUT` path itself; the tests cover the + decision and the file rewrite. diff --git a/devlog/_plan/260830_dev_version_line_bump_pr/030_ship.md b/devlog/_plan/260830_dev_version_line_bump_pr/030_ship.md new file mode 100644 index 0000000000..5d1bb35545 --- /dev/null +++ b/devlog/_plan/260830_dev_version_line_bump_pr/030_ship.md @@ -0,0 +1,73 @@ +# 030 — ship it (wp4) + +## Branch and commits + +Branch `codex/dev-version-line-bump-pr` off `origin/dev`. Two commits, matching +the two implementation phases: + +1. `fix(release): move dev's version line past the published 2.36.0` +2. `feat(release): open the dev version bump as a PR when a release publishes` + +Plus the devlog unit. Push with `--no-verify` as the user directed. + +## PR + +Against `dev`, filling every `.github/PULL_REQUEST_TEMPLATE.md` section: Summary, +Verification, Checklist. No screenshot section is required — this touches no GUI. + +The description must state the four prior hand-repairs, because that history is the +argument for the mechanism. Reviewers who see only the version bump will read it as +routine maintenance. + +Release-tooling changes require explicit security review per `scripts/AGENTS.md` and +`MAINTAINERS.md`. Call that out in the description rather than leaving a reviewer to +discover it, and be precise about what the new workflow can do: it takes +`contents: write` to push a NEW unprotected bump branch and `pull-requests: write` to +open the PR. It does not use the release deploy key, does not write to protected +`dev` directly, and is a separate file from `release.yml` so the publish job's +permissions are unchanged. + +## Evidence required before the merge claim + +Local: +- `bun test tests/release-version-line.test.ts` — pass. +- `bun test tests/bump-dev-version.test.ts` — pass, including the NOOP case that must + leave `package.json` byte-identical. +- `bun test tests/release-helper.test.ts` — pass, proving the existing release + contract is unbroken. `scripts/release.ts` is deliberately NOT modified by this + unit, so this suite is a regression check rather than coverage of new behavior. +- `actionlint` on the new workflow if available; otherwise state that the YAML was + not machine-validated. +- `bun x tsc --noEmit` — clean. +- `bun run privacy:scan` — clean. +- `bun run prepush` — required by `scripts/AGENTS.md` for release-tooling changes. +- Red-then-green transcript for each new assertion. + +Remote: +- `gh pr checks` for the PR head showing `test 2/4` and `macos` GREEN. This is the + specific flip that proves the fix: those two jobs are red on `dev` today for this + exact test. + +The full local root suite is prohibited by the user. State that boundary in the PR +and rely on CI for whole-suite coverage. + +## Merge + +Merge into `dev` once the two previously-red jobs are green. If some unrelated job +is red, check whether it is also red on `dev` at `c2778ca3a` before deciding — the +point of this unit is to stop inheriting someone else's red, not to add to it. + +## Required follow-up, not part of this PR + +`.github/workflows/dev-version-bump.yml` DOES NOT RUN until it reaches `main`. A +`release` event resolves the workflow file from the repository default branch, which +`gh repo view` reports as `main`; this repository documents the same trap for +scheduled workflows in `cleanup-closed-pr-branches.yml:8-10`. + +So merging this PR into `dev` installs the file but arms nothing. The workflow first +fires after the next ordinary `dev` -> `main` promotion carries it there. That +promotion is maintainer-controlled (`MAINTAINERS.md`) and explicitly out of scope +here: this unit must not touch `main`. + +State this in the PR description. A reviewer who assumes the merge activates the +automation will believe the loop is closed a release earlier than it is. diff --git a/devlog/_plan/260830_kiro_post_answer_tool_calls/000_research.md b/devlog/_plan/260830_kiro_post_answer_tool_calls/000_research.md new file mode 100644 index 0000000000..09d299304d --- /dev/null +++ b/devlog/_plan/260830_kiro_post_answer_tool_calls/000_research.md @@ -0,0 +1,139 @@ +# Kiro post-final-answer tool calls — measurement and root cause + +Reported symptom, twice: routed through Kiro, the agent keeps issuing tool calls +after its final response has already been delivered. + +## Hosts measured + +| Host | Proxy | Version | Checkout | Kiro attempt rows | +| --- | --- | --- | --- | --- | +| local (this machine) | PID 99470, port 10100 | 2.36.0 | primary source checkout | 4080 | +| `macmini-cf` | PID 96671, port 10100 | 2.35.0 | `~/opencodex` | 0 | + +`macmini-cf` carries no Kiro attempt diagnostics at all, so every behavioral +row below comes from the local 2.36.0 proxy. The remote host is one release +behind and is not the reporting surface. + +## What the attempt rows say + +`ocx:kiro:attempt_complete` over the local log, bucketed: + +| Count | mode | sawText | sawRealTool | completionCalls | stopReason | +| --- | --- | --- | --- | --- | --- | +| 2643 | required | true | true | 0 | TOOL_USE | +| 1400 | required | false | true | 0 | TOOL_USE | +| 23 | required | true | false | 1 | TOOL_USE | +| 10 | disabled | true | false | 0 | END_TURN | +| 2 | required | false | false | 1 | TOOL_USE | +| 1 | required | true | false | 0 | END_TURN | +| 1 | text_fallback | false | false | 1 | TOOL_USE | + +4069 of 4080 attempts ran in `required` mode and every one of them ended with +upstream `stopReason: TOOL_USE`. Only 25 attempts ever called the private +completion tool. The model overwhelmingly prefers another tool call to the +completion channel. + +## What is NOT the cause + +Two candidate mechanisms were ruled out with evidence rather than reading. + +Replayed history is not the cause. 532 client rollouts under +`~/.codex/sessions/2026/08/{29,30}` were scanned for a `final_answer` message +followed by a tool call with no intervening user turn. What actually follows a +recorded `final_answer`: END 478, user message 131, developer message 5, tool +call 0. The client never replays a post-answer tool call. + +The delivered-answer local terminal is not broken. Two live probes against the +running proxy replayed a closed turn — once with `phase: "final_answer"` +echoed, once without it, matching real Codex traffic — and both returned +`output: []` with `end_turn: true` and added zero upstream Kiro requests. +The guard added in `b557a8140`/`68eaf45d8` works. + +It has simply never been needed: `~/.opencodex/usage.jsonl` holds 25042 Kiro +rows with zero `localTerminalReason` and zero `locallyAnswered`. Real turns +never arrive already closed, because the client ends the turn itself. So the +defect lives inside a live turn, not across turns. + +## Rejected first hypothesis + +The first diagnosis was that the model calls the completion tool, waits for a +tool result that never arrives, and then calls another tool. An independent +read-only audit refuted it with the parser: `flushOpen` consumes a valid +completion call and records `completionAnswer` without emitting any tool-call +event, the stream end yields the answer as `final_answer` followed by +`done(endTurn: true)`, and `parseKiroStream` returns without another request. +A completion call therefore terminates locally inside one inference; there is +no later inference in which the model could "keep going". Mixed +completion-plus-real-tool output in one inference also fails closed before any +answer is delivered. + +That refutation is correct, and it narrows the defect rather than dissolving it: +the problem is not what happens AFTER a completion call, it is that the model +mostly never makes one. + +## Root cause + +The private completion tool is advertised to the model as an ordinary tool. + +A source probe (`buildKiroPayload` with an `exec`/`wait` catalog) renders the +wire tool names as `["exec","wait","codex_kiro_final_answer"]` and injects: + +> Valid tool names for this turn are exactly \`exec\`, \`wait\`, +> \`codex_kiro_final_answer\`. These listed names are the complete top-level +> tool-call surface for this turn. + +That sentence comes from the shared, provider-agnostic nudge in +`src/adapters/tool-catalog-nudge.ts`, which knows nothing about completion +semantics. It cannot distinguish the proxy's private terminal channel from +`exec`, and the same nudge closes with: + +> Count a tool call only after its tool result returns. + +`KIRO_COMPLETION_INSTRUCTIONS` is the only text that describes the completion +tool, and it never contradicts that: + +> When tools are available, ordinary assistant text is mid-task commentary and +> does not end the turn. Continue using tools after progress updates. When the +> task is fully complete and no more tool calls are needed, call +> `codex_kiro_final_answer` exactly once with the complete user-facing final +> answer in `answer`. Do not provide the final answer as ordinary assistant +> text. + +Every sentence there is about WHEN to call it. Nothing marks it as different in +kind from `exec`, and nothing states what happens after. So the model holds a +contract in which the terminal channel is one more ordinary tool it may defer +while it keeps working — and the generic nudge's "count a tool call only after +its tool result returns" applies to it as uniformly as to everything else. + +The failure that follows is one of SELECTION, not sequencing. Across 4069 +required-mode attempts the completion tool was chosen 25 times: 0.6%. The model +keeps emitting finished prose as commentary and calling ordinary tools instead +of completing through the channel built for it. + +That is what the user sees. Measured over 1116 Kiro turns in the same two days +of client rollouts: 626 turns ended through the completion channel, 462 ended +on a tool call, and 28 ended with answer-shaped commentary prose and no +completion call at all. Those 28 are answers the model had already finished +writing — they open with "Done.", "완료", "머지까지 끝났습니다", "All ten items are +done" — delivered as mid-task commentary, which by the proxy's own contract +"does not end the turn". Three of them are followed by 4, 10, and 12 further +tool calls after the closing summary was already on screen. + +The missing terminal distinction is the leading mechanism behind that measured +selection failure: the terminal channel is advertised as an ordinary, deferrable +tool, and nothing tells the model that this is the one call that ends the turn. +It is a defect in the proxy's own injected text, not a client bug and not a +stream-parsing bug. Causality is not claimed as proven — establishing it +requires a live post-change comparison of the same selection rate, which this +unit records as the follow-up measurement rather than asserting up front. + +## Fix direction + +State terminal semantics where the model reads them: calling the completion +tool ENDS the turn, returns no tool result, and nothing may follow it. The +completion tool's own schema description is the load-bearing site — it travels +with the tool the nudge enumerates — with the prose contract kept consistent. + +Removing the tool from the enumeration is not an option: the nudge states that +names mentioned only in instructions are not callable, so an unlisted +completion tool would be a tool the model is told not to call. diff --git a/devlog/_plan/260830_kiro_post_answer_tool_calls/010_wp2_terminal_completion_contract.md b/devlog/_plan/260830_kiro_post_answer_tool_calls/010_wp2_terminal_completion_contract.md new file mode 100644 index 0000000000..313d49c110 --- /dev/null +++ b/devlog/_plan/260830_kiro_post_answer_tool_calls/010_wp2_terminal_completion_contract.md @@ -0,0 +1,71 @@ +# wp2 — make the completion tool's terminal semantics explicit + +## Change + +Two injected surfaces describe the private completion tool. Both need the same +fact, and the schema description is the one that travels with the tool the +nudge enumerates. + +`src/adapters/kiro.ts`, `kiroCompletionTool()` description: mark the tool as a +terminal channel rather than an ordinary work tool, make completing an +obligation rather than a permitted option, and state that the call ends the +turn, returns no tool result, and admits nothing after it. This sits directly on +the tool object the model is choosing between, so it is read in the same place +the model decides whether to call `exec` again. + +Exact target string: + +> Terminal completion channel, not an ordinary work tool. When the task is fully +> complete and no more work or tool calls are needed, you must call this tool +> exactly once instead of providing the final answer as ordinary assistant text. +> Put the complete user-facing final answer in \`answer\`. The call is complete +> when issued: it ends the turn, returns no tool result, and no text or tool call +> may follow it. + +`src/adapters/kiro-constants.ts`, `KIRO_COMPLETION_INSTRUCTIONS`: keep the +existing commentary-vs-completion rules verbatim and append the terminal clause, +so the prose contract cannot contradict the schema. + +Exact appended string: + +> This completion tool is not an ordinary work tool. When the task is complete, +> call it instead of emitting answer-shaped ordinary assistant text. The call is +> terminal and is the exception to generic tool-result counting: it is complete +> when issued, ends the turn, returns no tool result, and no text or tool call +> may follow it. + +## What must not change + +The commentary rule stays. "Ordinary assistant text is mid-task commentary and +does not end the turn" and "continue using tools after progress updates" are +the behavior that keeps a mid-task turn alive; the new wording constrains only +what happens after the completion call itself. A model must still be free to +call ten more tools before it completes — the fix is that after completing, it +must stop. + +The tool stays in the wire catalog and in the nudge enumeration. The nudge +states that instruction-only names are not callable, so delisting the +completion tool would advertise a tool the model is told to refuse. + +No change to `src/router.ts`, `src/server/lifecycle.ts`, or +`src/server/responses/core.ts`: the Lab core boundary is unrelated to this +defect and `tests/core-lab-boundary.test.ts` guards it. + +## Regression test + +`tests/kiro-adapter.test.ts` gets a focused case asserting the rendered wire +payload carries terminal semantics on BOTH injected surfaces: the completion +tool's schema description and the injected prose contract. Driven red before +the fix. + +What this test proves and does not prove: it proves the contract reaches the +model on both surfaces, which is the deliverable. It does not prove the model's +selection rate improves — that is a live behavioral property measured from +attempt diagnostics (`completionCalls` per required-mode attempt), recorded in +`000_research.md` at 25/4069 before the change. The change is prompt hardening +against a measured selection failure, not a parser fix. + +## Verification + +`bun run typecheck` plus the focused Kiro suites. The full local suite is +excluded by explicit user instruction for this unit; CI covers it on the PR. diff --git a/devlog/_plan/260830_kiro_post_answer_tool_calls/020_close_out.md b/devlog/_plan/260830_kiro_post_answer_tool_calls/020_close_out.md new file mode 100644 index 0000000000..503f955cef --- /dev/null +++ b/devlog/_plan/260830_kiro_post_answer_tool_calls/020_close_out.md @@ -0,0 +1,81 @@ +# Close-out — terminal completion contract shipped + +Terminal outcome: **DONE**. + +## What shipped + +PR #3012, merged to `dev` at 2026-08-30T15:31:15Z as `f5a625cf3`. Two injected +surfaces now state that the private completion tool is terminal: + +- `kiroCompletionTool()` schema description in `src/adapters/kiro.ts`, which + travels with the tool object the model chooses between. +- `KIRO_COMPLETION_INSTRUCTIONS` in `src/adapters/kiro-constants.ts`, so the + prose contract cannot contradict the schema. + +The mid-task rules are untouched: commentary still does not end the turn, and +the model must still keep using tools before completing. Only what may follow +the completion call is constrained. `tests/kiro-adapter.test.ts` pins both +surfaces and asserts the two commentary sentences survive. + +## Verification + +`bun run typecheck` clean. 197 pass / 0 fail across `kiro-adapter`, +`kiro-stream`, and `tool-catalog-nudge`. `privacy:scan` passes. The regression +test was driven red against the old description first. + +CI on the merged head: 20 checks green. The single failure, on both +`test 2/4` and `macos`, was `release version line > the in-tree version is +never behind a released one` — reproduced identically on a pristine +`origin/dev` worktree at `df8b3882f` with none of this unit's commits, and +owned by PR #3006. A release-version bump does not belong in an unrelated bug +fix. + +## Review findings and what was done with them + +Three findings, all answered on the PR. + +A P1 privacy finding was correct: the measurement table carried a remote +absolute home path into a public devlog directory, which `privacy:scan` rejects. +Fixed in `da9b4989c`; the hostname, PID, and version carry the evidence without +an account identifier. + +A truncation-ordering finding was plausible and turned out to be unreachable. +The completion contract is charged last against +`MAX_KIRO_INJECTED_INSTRUCTION_CHARS` (16384), so in principle a large enough +injected context could slice it mid-clause. Measured: the omission notice tops +out at 922 characters under a hostile 60-tool probe, the nudge ceiling is about +5540 under the 48-tool and 64-character caps, and the contract is 696 — roughly +9900 characters of headroom. The caller's system prompt is not charged to this +budget at all, so no caller input can crowd the contract out. + +A reservation guard plus a budget-exhaustion test were implemented first, then +reverted: the test passed with the guard removed, because the only available +lever for inflating the budget was the uncharged system prompt. A guard for an +unreachable path whose test cannot detect its own removal is worse than the +documented measurement, so the measurement is what stayed. If a future addition +starts charging caller-sized text to this budget, the numbers above are the +starting point. + +A duplicate-`.find` finding was a false positive: one call rendered across two +lines. Applying the proposed fix would have deleted the only lookup. + +## Follow-up + +Causality is not claimed as proven. The pre-change selection rate is recorded at +25 completion calls across 4069 required-mode attempts; the follow-up is the +same measurement on traffic served by a proxy running `f5a625cf3` or later. +Both hosts were on older builds at measurement time (local 2.36.0, `macmini-cf` +2.35.0), so a restart onto current `dev` is the precondition for that comparison. + +## Landed-state verification + +Checked against `origin/dev` after both merges (`6f75616f0`), reading the files +out of the remote ref rather than the working tree: + +- `src/adapters/kiro.ts` contains the terminal schema description. +- `src/adapters/kiro-constants.ts` contains the appended terminal clause. +- `tests/kiro-adapter.test.ts` contains the both-surfaces regression test. +- This unit's close-out record is present. + +Merge trail: `f5a625cf3` (#3012, the contract change) and `6f75616f0` +(#3014, this record). diff --git a/devlog/_plan/260830_sidecar_control_band/010_shared_control_band.md b/devlog/_plan/260830_sidecar_control_band/010_shared_control_band.md new file mode 100644 index 0000000000..8d585899b8 --- /dev/null +++ b/devlog/_plan/260830_sidecar_control_band/010_shared_control_band.md @@ -0,0 +1,86 @@ +# Sidecar control band: one shared start position + +Status: shipped on `codex/sidecar-shared-control-band`. + +## Reported symptom + +The two dashboard sidecar cards looked centre-aligned rather than +left-copy / right-controls, and the two model selects did not start at the +same x. + +## Two independent causes + +Both were measured over CDP in a real browser across the eight shipped +locales, not inferred from the stylesheet. + +### 1. Every two-up card was born stacked + +`.dash-sidecar-grid` used a `21rem` track floor. The stacking container query +fires at `36rem` of card. A two-up card therefore had 309-517px of content -- +always under the threshold -- so it entered the stacked regime immediately: +copy on a full-width flex line, controls on a second full-width line +inheriting `justify-content: flex-end` from the shared +`.dash-delegation-controls` rule. The model select floated in the middle of +the card with the switch pinned to the right edge, which is what reads as +centred. + +The track floor is now `39rem`: 624px of track, 586px of content after the +panel's 2x19px padding, clear of the 576px stacking threshold. A card the grid +places two-up can now hold a real row, and below that the grid drops to one +column where a card is wide enough for the same row. + +The general lesson: the track floor is the width at which a card can render +its intended LAYOUT, not the width at which its widest control stops +overflowing. The old comment stated the second and the number satisfied only +that. + +### 2. The two control groups sized intrinsically + +The groups do not hold the same controls. Web search is one select plus a +label and a switch; vision is two selects. Measured natural widths at 1600px: + +| locale | web search | vision | select start delta | +|--------|-----------:|-------:|-------------------:| +| ja | 268px | 569px | 301px | +| ko | 275px | 569px | 294px | +| zh | 272px | 569px | 297px | +| de | 309px | 569px | 260px | +| ru | 326px | 569px | 244px | +| fr | 344px | 569px | 225px | + +Both groups packed to the card's right edge. Equal right edges with unequal +widths gives unequal left edges, so the start position was a function of +translated label width. + +`flex: 0 0 min(100%, 26rem)` makes the band definite and identical in both +cards, with `justify-content: space-between` packing from the band's left +edge while keeping the trailing switch on the card's right. The vision card no +longer overrides the band width or the copy basis: copy absorbs leftover +width, so a per-card copy basis moved the band's left edge by the difference, +which is why equal band widths alone were not sufficient. + +## Rendered verification + +At 1920/1600/1440/1200/1024/900/760/600/430 across ko/en/ru/fr/ja/de/tr/zh: +band start delta 0px at every cell, no horizontal or vertical overflow, no +truncated select label, no clipped hint. The streaming label also stops +wrapping to three lines, because the band gives it room to stay on one. + +Evidence images in `evidence/`. "Before" is the same build with the shipped +declarations reverted by an injected in-browser override, so the pair differs +only by the fix: 571px of select divergence at ko/1440 and 256px at ko/1024, +both 0px after. + +## Regression coverage + +`gui/tests/sidecar-layout.test.ts` gains two source-oracle assertions -- the +definite unshrinkable band with no per-card override, and a track floor that +must exceed the stacking threshold plus padding. Both were driven red against +the pre-fix values before being committed. + +One trap worth recording: `allRuleBodies` matches a selector everywhere it +appears, including inside `@container` blocks, where the stacked regime +legitimately sets `flex: 0 1 auto`. A row-regime assertion read those +overrides and failed against a correct stylesheet. The new `baseCascade` +helper strips container queries so the base-rule invariants are tested against +the base rules only. diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1024.png b/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1024.png new file mode 100644 index 0000000000..341dfa6f35 Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1024.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1440.png b/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1440.png new file mode 100644 index 0000000000..4a30133aeb Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/010-after-ko-1440.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1024.png b/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1024.png new file mode 100644 index 0000000000..2e15689f3b Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1024.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1440.png b/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1440.png new file mode 100644 index 0000000000..51a58c2a71 Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/010-before-ko-1440.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/020-after-en-1600.png b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-en-1600.png new file mode 100644 index 0000000000..098f9ac140 Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-en-1600.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1024.png b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1024.png new file mode 100644 index 0000000000..d7301da900 Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1024.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1440.png b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1440.png new file mode 100644 index 0000000000..0b73847ecb Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ko-1440.png differ diff --git a/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ru-1440.png b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ru-1440.png new file mode 100644 index 0000000000..8a846b0513 Binary files /dev/null and b/devlog/_plan/260830_sidecar_control_band/evidence/020-after-ru-1440.png differ diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 5c5e7468cf..0411700220 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -3,7 +3,6 @@ * (STATS + Notes). Phase 030 of workspace design parity. */ import { useCallback, useEffect, useRef, useState } from "react"; -import { readJsonOrThrow } from "../../fetch-json"; import { useT, useI18n } from "../../i18n/shared"; import { IconAlert, IconCheck } from "../../icons"; import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog"; @@ -13,15 +12,7 @@ import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch, ProviderUpdateResult } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; - -type ConnectionTestResult = { - applicable?: boolean; - ok?: boolean; - latencyMs?: number; - reason?: string; - message?: string; - error?: string; -}; +import { testProviderConnection, type ConnectionTestResult } from "./provider-test"; type ConnectionTestState = { key: string; @@ -101,16 +92,11 @@ export default function ProviderOverview({ connectionAbortRef.current = { key: connectionProbeKey, controller }; setConnectionTest({ key: connectionProbeKey, testing: true, result: null }); try { - const response = await fetch(`${apiBase}/api/providers/test?name=${encodeURIComponent(item.name)}`, { - method: "POST", - signal: controller.signal, - }); - const result = await readJsonOrThrow(response, t("pws.connectionFailed")); - if (!result) throw new Error(t("pws.connectionFailed")); + const result = await testProviderConnection(apiBase, item.name, controller.signal); if (!controller.signal.aborted) { setConnectionTest({ key: connectionProbeKey, testing: false, result }); } - } catch (error) { + } catch { if (!controller.signal.aborted) { setConnectionTest({ key: connectionProbeKey, @@ -118,7 +104,7 @@ export default function ProviderOverview({ result: { applicable: true, ok: false, - error: error instanceof Error ? error.message : t("pws.connectionFailed"), + error: t("pws.connectionFailed"), }, }); } diff --git a/gui/src/components/provider-workspace/provider-test.ts b/gui/src/components/provider-workspace/provider-test.ts new file mode 100644 index 0000000000..26b0bfa56e --- /dev/null +++ b/gui/src/components/provider-workspace/provider-test.ts @@ -0,0 +1,37 @@ +import { readJsonOrThrow } from "../../fetch-json"; + +export type ConnectionTestResult = { + ok?: boolean; + latencyMs?: number; + error?: string; + message?: string; + applicable?: boolean; + reason?: string; +}; + +/** + * Probe a single provider through `POST /api/providers/test?name=...`. + * On abort or network failure returns `{ ok: false, error }` — never throws. + */ +export async function testProviderConnection( + apiBase: string, + name: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return { ok: false, error: "Aborted" }; + try { + const response = await fetch( + `${apiBase}/api/providers/test?${new URLSearchParams({ name })}`, + { method: "POST", signal }, + ); + if (signal?.aborted) return { ok: false, error: "Aborted" }; + const result = await readJsonOrThrow(response); + return result ?? { ok: false, error: "Empty response" }; + } catch (error) { + if (signal?.aborted) return { ok: false, error: "Aborted" }; + return { + ok: false, + error: error instanceof Error ? error.message : "Connection test failed", + }; + } +} diff --git a/gui/src/hooks/use-provider-batch-controller.ts b/gui/src/hooks/use-provider-batch-controller.ts new file mode 100644 index 0000000000..43faf57263 --- /dev/null +++ b/gui/src/hooks/use-provider-batch-controller.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ProviderBatchController { + /** Whether a batch is currently in progress. */ + batchTesting: boolean; + /** Start a new batch. Ownership transfers to the returned controller. */ + startBatch: () => AbortController; + /** Cancel an in-flight batch: abort signal + set batchTesting false. */ + cancelMountedBatch: () => void; + /** Clean up batch resources on unmount without touching state. */ + abortBatchOnUnmount: () => void; + /** Check whether a controller is still the active batch. */ + isActiveBatch: (controller: AbortController) => boolean; +} + +/** + * Manages a single in-flight provider connection-test batch. + * + * Production callers: + * - testAllProviders() calls startBatch() to acquire the controller, + * then iterates providers and passes signal to each probe. + * - apiBase change effect calls cancelMountedBatch(). + * - config-generation change effect calls cancelMountedBatch(). + * - unmount effect calls abortBatchOnUnmount(). + * + * Internal state (refs) keeps cancellation consistent across re-renders. + */ +export function useProviderBatchController(): ProviderBatchController { + /** Monotonically increasing batch counter. Never resets. */ + const nextBatchIdRef = useRef(0); + /** Identity of the currently-active batch; stale callbacks see a mismatch and bail out. */ + const activeBatchRef = useRef<{ id: number; controller: AbortController } | null>(null); + const [batchTestingState, setBatchTestingState] = useState(false); + + const cancelMountedBatch = useCallback(() => { + const active = activeBatchRef.current; + if (!active) return; + active.controller.abort(); + activeBatchRef.current = null; + setBatchTestingState(false); + }, []); + + const abortBatchOnUnmount = useCallback(() => { + activeBatchRef.current?.controller.abort(); + activeBatchRef.current = null; + }, []); + + const startBatch = useCallback((): AbortController => { + // Cancel any in-flight batch (ownership transfers to the new batch). + activeBatchRef.current?.controller.abort(); + const batchId = ++nextBatchIdRef.current; + const controller = new AbortController(); + activeBatchRef.current = { id: batchId, controller }; + setBatchTestingState(true); + return controller; + }, []); + + // Cancel batch on unmount — no state updates (component may be gone). + useEffect(() => { + return () => { abortBatchOnUnmount(); }; + }, [abortBatchOnUnmount]); + + const isActiveBatch = useCallback((controller: AbortController) => { + return activeBatchRef.current?.controller === controller; + }, []); + + return { batchTesting: batchTestingState, startBatch, cancelMountedBatch, abortBatchOnUnmount, isActiveBatch }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 36d058a311..befe972464 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -210,6 +210,7 @@ export const de: Record = { "dash.offline": "Offline", "dash.version": "Version", "dash.uptime": "Laufzeit", + "dash.port": "Port", "dash.providers": "Anbieter", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} Abdeckung", @@ -252,6 +253,11 @@ export const de: Record = { "dash.col.adapter": "Adapter", "dash.col.baseUrl": "Basis-URL", "dash.col.model": "Modell", + "dash.col.status": "Status", + "dash.providerStatus.ok": "Aktiv", + "dash.providerStatus.error": "Fehler", + "dash.providerStatus.disabled": "Deaktiviert", + "dash.providerStatus.unknown": "Unbekannt", "dash.modelsNoResults": "Keine Modelle entsprechen deiner Suche.", "dash.availableModels": "Verfügbare Modelle", "dash.noModels": "Keine Modelle gefunden. Prüfe die API-Schlüssel des Anbieters.", @@ -358,6 +364,12 @@ export const de: Record = { "dash.updateStatus.failed": "Update fehlgeschlagen.", "prov.subtitle": "Konfiguriere die Upstream-Anbieter, die opencodex in Codex routet. Melde dich mit einem Konto an, füge einen Anbieter hinzu oder bearbeite die Rohkonfiguration.", "prov.add": "Anbieter hinzufügen", + "prov.testAll": "Alle testen", + "prov.testing": "Teste…", + "prov.testAll.ok": "Alle {count} Anbieter sind aktiv.", + "prov.testAll.partial": "{passed} aktiv, {failed} mit Fehlern.", + "prov.testAll.result.ok": "Aktiv ({latency}ms)", + "prov.testAll.result.error": "Fehler: {error}", "prov.editJson": "JSON bearbeiten", "prov.accountLogin": "Konto-Login", "prov.noOauth": "Keine OAuth-Anbieter verfügbar.", @@ -644,6 +656,9 @@ export const de: Record = { "logs.tabDebug": "Diagnose", "logs.subtitle": "Letzte Anfragen über den lokalen opencodex-Proxy, neueste zuerst.", "logs.autoRefresh": "Auto-Aktualisierung", + "logs.autoScroll": "Auto-Scroll", + "logs.clearView": "Ansicht leeren", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "Noch keine Anfragen.", "logs.loadError": "Anfrageprotokolle konnten nicht geladen werden.", "logs.filter.surface.label": "Oberfläche", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 703fa71d09..5af9d15c59 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -221,6 +221,7 @@ export const en = { "dash.offline": "Offline", "dash.version": "Version", "dash.uptime": "Uptime", + "dash.port": "Port", "dash.providers": "Providers", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} coverage", @@ -264,6 +265,11 @@ export const en = { "dash.col.adapter": "Adapter", "dash.col.baseUrl": "Base URL", "dash.col.model": "Model", + "dash.col.status": "Status", + "dash.providerStatus.ok": "Healthy", + "dash.providerStatus.error": "Error", + "dash.providerStatus.disabled": "Disabled", + "dash.providerStatus.unknown": "Unknown", "dash.modelsNoResults": "No models match your search.", "dash.availableModels": "Available models", "dash.noModels": "No models found. Check provider API keys.", @@ -381,6 +387,12 @@ export const en = { // providers "prov.subtitle": "Configure the upstream providers opencodex routes into Codex. Log in with an account, add a provider, or edit the raw config.", "prov.add": "Add Provider", + "prov.testAll": "Test All", + "prov.testing": "Testing…", + "prov.testAll.ok": "All {count} providers healthy.", + "prov.testAll.partial": "{passed} healthy, {failed} with errors.", + "prov.testAll.result.ok": "Healthy ({latency}ms)", + "prov.testAll.result.error": "Error: {error}", "prov.editJson": "Edit JSON", "prov.accountLogin": "Account login", "prov.noOauth": "No OAuth providers available.", @@ -677,6 +689,9 @@ export const en = { "logs.tabDebug": "Debug", "logs.subtitle": "Recent requests routed through the local opencodex proxy, newest first.", "logs.autoRefresh": "Auto-refresh", + "logs.autoScroll": "Auto-scroll", + "logs.clearView": "Clear view", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "No requests yet.", "logs.loadError": "Could not load request logs.", "logs.filter.surface.label": "Surface", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5055c6b56b..4117f0b915 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -212,6 +212,7 @@ export const fr: Record = { "dash.offline": "Hors ligne", "dash.version": "Version", "dash.uptime": "Durée de fonctionnement", + "dash.port": "Port", "dash.providers": "Fournisseurs", "dash.tokens30d": "Jetons (30 j)", "dash.coverage": "Couverture : {pct}", @@ -254,6 +255,11 @@ export const fr: Record = { "dash.col.adapter": "Adaptateur", "dash.col.baseUrl": "URL de base", "dash.col.model": "Modèle", + "dash.col.status": "État", + "dash.providerStatus.ok": "Actif", + "dash.providerStatus.error": "Erreur", + "dash.providerStatus.disabled": "Désactivé", + "dash.providerStatus.unknown": "Inconnu", "dash.modelsNoResults": "Aucun modèle ne correspond à votre recherche.", "dash.availableModels": "Modèles disponibles", "dash.noModels": "Aucun modèle trouvé. Vérifiez les clés API des fournisseurs.", @@ -368,6 +374,12 @@ export const fr: Record = { "dash.updateStatus.failed": "Échec de la mise à jour.", "prov.subtitle": "Configurez les fournisseurs en amont vers lesquels opencodex route Codex. Connectez-vous avec un compte, ajoutez un fournisseur ou modifiez la configuration brute.", "prov.add": "Ajouter un fournisseur", + "prov.testAll": "Tout tester", + "prov.testing": "Test en cours…", + "prov.testAll.ok": "Les {count} fournisseurs sont actifs.", + "prov.testAll.partial": "{passed} actifs, {failed} en erreur.", + "prov.testAll.result.ok": "Actif ({latency}ms)", + "prov.testAll.result.error": "Erreur : {error}", "prov.editJson": "Modifier le JSON", "prov.accountLogin": "Connexion au compte", "prov.noOauth": "Aucun fournisseur OAuth disponible.", @@ -658,6 +670,9 @@ export const fr: Record = { "logs.tabDebug": "Débogage", "logs.subtitle": "Requêtes récentes routées par le proxy opencodex local, de la plus récente à la plus ancienne.", "logs.autoRefresh": "Actualisation automatique", + "logs.autoScroll": "Défilement auto", + "logs.clearView": "Effacer la vue", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "Aucune requête pour le moment.", "logs.loadError": "Impossible de charger les journaux des requêtes.", "logs.filter.surface.label": "Interface", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 69920a3510..ef29b71a1f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -219,6 +219,7 @@ export const ja: Record = { "dash.offline": "オフライン", "dash.version": "バージョン", "dash.uptime": "稼働時間", + "dash.port": "ポート", "dash.providers": "プロバイダー", "dash.tokens30d": "トークン (30日)", "dash.coverage": "{pct} カバレッジ", @@ -261,6 +262,11 @@ export const ja: Record = { "dash.col.adapter": "アダプター", "dash.col.baseUrl": "ベース URL", "dash.col.model": "モデル", + "dash.col.status": "ステータス", + "dash.providerStatus.ok": "正常", + "dash.providerStatus.error": "エラー", + "dash.providerStatus.disabled": "無効", + "dash.providerStatus.unknown": "不明", "dash.modelsNoResults": "検索に一致するモデルはありません。", "dash.availableModels": "利用可能なモデル", "dash.noModels": "モデルが見つかりません。プロバイダーの API キーを確認してください。", @@ -369,6 +375,12 @@ export const ja: Record = { // providers "prov.subtitle": "opencodex が Codex にルーティングする上流プロバイダーを設定します。アカウントでログインするか、プロバイダーを追加、または生の設定を編集します。", "prov.add": "プロバイダーを追加", + "prov.testAll": "すべてテスト", + "prov.testing": "テスト中…", + "prov.testAll.ok": "全 {count} プロバイダー正常。", + "prov.testAll.partial": "{passed} 正常、{failed} エラー。", + "prov.testAll.result.ok": "正常 ({latency}ms)", + "prov.testAll.result.error": "エラー:{error}", "prov.editJson": "JSON を編集", "prov.accountLogin": "アカウントログイン", "prov.noOauth": "利用可能な OAuth プロバイダーがありません。", @@ -620,6 +632,9 @@ export const ja: Record = { "logs.tabDebug": "デバッグ", "logs.subtitle": "ローカル opencodex プロキシを経由した最近のリクエスト(新しい順)。", "logs.autoRefresh": "自動更新", + "logs.autoScroll": "自動スクロール", + "logs.clearView": "ビューをクリア", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "まだリクエストがありません。", "logs.loadError": "リクエストログを読み込めませんでした。", "logs.filter.surface.label": "サーフェス", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b2d78471fd..d6d23f1f56 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -214,6 +214,7 @@ export const ko: Record = { "dash.offline": "오프라인", "dash.version": "버전", "dash.uptime": "가동 시간", + "dash.port": "포트", "dash.providers": "프로바이더", "dash.tokens30d": "토큰 (30일)", "dash.coverage": "커버리지 {pct}", @@ -256,6 +257,11 @@ export const ko: Record = { "dash.col.adapter": "어댑터", "dash.col.baseUrl": "Base URL", "dash.col.model": "모델", + "dash.col.status": "상태", + "dash.providerStatus.ok": "정상", + "dash.providerStatus.error": "오류", + "dash.providerStatus.disabled": "비활성", + "dash.providerStatus.unknown": "알 수 없음", "dash.modelsNoResults": "검색과 일치하는 모델이 없습니다.", "dash.availableModels": "사용 가능한 모델", "dash.noModels": "모델을 찾을 수 없습니다. 프로바이더 API 키를 확인하세요.", @@ -367,6 +373,12 @@ export const ko: Record = { // providers "prov.subtitle": "opencodex가 Codex로 라우팅하는 업스트림 프로바이더를 설정합니다. 계정으로 로그인하거나, 프로바이더를 추가하거나, 원본 설정을 편집하세요.", "prov.add": "프로바이더 추가", + "prov.testAll": "전체 테스트", + "prov.testing": "테스트 중…", + "prov.testAll.ok": "프로바이더 {count}개 모두 정상.", + "prov.testAll.partial": "{passed}개 정상, {failed}개 오류.", + "prov.testAll.result.ok": "정상 ({latency}ms)", + "prov.testAll.result.error": "오류: {error}", "prov.editJson": "JSON 편집", "prov.accountLogin": "계정 로그인", "prov.noOauth": "사용 가능한 OAuth 프로바이더가 없습니다.", @@ -663,6 +675,9 @@ export const ko: Record = { "logs.tabDebug": "디버그", "logs.subtitle": "로컬 opencodex 프록시를 거친 최근 요청입니다. 최신순.", "logs.autoRefresh": "자동 새로고침", + "logs.autoScroll": "자동 스크롤", + "logs.clearView": "뷰 지우기", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "아직 요청이 없습니다.", "logs.loadError": "요청 로그를 불러오지 못했습니다.", "logs.filter.surface.label": "표면", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 72e743a819..07eb8461ad 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -219,6 +219,7 @@ export const ru: Record = { "dash.offline": "Не в сети", "dash.version": "Версия", "dash.uptime": "Время работы", + "dash.port": "Порт", "dash.providers": "Провайдеры", "dash.tokens30d": "Токены (30 дн.)", "dash.coverage": "{pct} покрытия", @@ -261,6 +262,11 @@ export const ru: Record = { "dash.col.adapter": "Адаптер", "dash.col.baseUrl": "Базовый URL", "dash.col.model": "Модель", + "dash.col.status": "Статус", + "dash.providerStatus.ok": "Активен", + "dash.providerStatus.error": "Ошибка", + "dash.providerStatus.disabled": "Отключён", + "dash.providerStatus.unknown": "Неизвестно", "dash.modelsNoResults": "Нет моделей, соответствующих поиску.", "dash.availableModels": "Доступные модели", "dash.noModels": "Модели не найдены. Проверьте API-ключи провайдеров.", @@ -369,6 +375,12 @@ export const ru: Record = { // providers "prov.subtitle": "Настройте вышестоящих провайдеров, которых opencodex маршрутизирует в Codex. Войдите в аккаунт, добавьте провайдера или отредактируйте конфигурацию вручную.", "prov.add": "Добавить провайдера", + "prov.testAll": "Проверить все", + "prov.testing": "Проверка…", + "prov.testAll.ok": "Все {count} провайдеров активны.", + "prov.testAll.partial": "{passed} активны, {failed} с ошибками.", + "prov.testAll.result.ok": "Активен ({latency}ms)", + "prov.testAll.result.error": "Ошибка: {error}", "prov.editJson": "Редактировать JSON", "prov.accountLogin": "Вход в аккаунт", "prov.noOauth": "Нет доступных OAuth-провайдеров.", @@ -661,6 +673,9 @@ export const ru: Record = { "logs.tabDebug": "Отладка", "logs.subtitle": "Недавние запросы через локальный прокси opencodex, новые сверху.", "logs.autoRefresh": "Автообновление", + "logs.autoScroll": "Авто-прокрутка", + "logs.clearView": "Очистить вид", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "Запросов пока нет.", "logs.loadError": "Не удалось загрузить журнал запросов.", "logs.filter.surface.label": "Источник", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4239246487..be571de41c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -220,6 +220,7 @@ export const tr: Record = { "dash.offline": "Çevrimdışı", "dash.version": "Sürüm", "dash.uptime": "Çalışma Süresi", + "dash.port": "Port", "dash.providers": "Sağlayıcılar", "dash.tokens30d": "Jetonlar (30 gün)", "dash.coverage": "%{pct} kapsam", @@ -262,6 +263,11 @@ export const tr: Record = { "dash.col.adapter": "Adaptör", "dash.col.baseUrl": "Taban URL", "dash.col.model": "Model", + "dash.col.status": "Durum", + "dash.providerStatus.ok": "Sağlıklı", + "dash.providerStatus.error": "Hata", + "dash.providerStatus.disabled": "Devre dışı", + "dash.providerStatus.unknown": "Bilinmiyor", "dash.modelsNoResults": "Aramanızla eşleşen model bulunamadı.", "dash.availableModels": "Kullanılabilir modeller", "dash.noModels": "Model bulunamadı. Sağlayıcı API anahtarlarını kontrol edin.", @@ -372,6 +378,12 @@ export const tr: Record = { // providers "prov.subtitle": "opencodex'in Codex'e yönlendirdiği sağlayıcıları yapılandırın.", "prov.add": "Sağlayıcı Ekle", + "prov.testAll": "Tümünü Test Et", + "prov.testing": "Test ediliyor…", + "prov.testAll.ok": "Tüm {count} sağlayıcı sağlıklı.", + "prov.testAll.partial": "{passed} sağlıklı, {failed} hatalı.", + "prov.testAll.result.ok": "Sağlıklı ({latency}ms)", + "prov.testAll.result.error": "Hata: {error}", "prov.editJson": "JSON Düzenle", "prov.accountLogin": "Hesap girişi", "prov.noOauth": "Kullanılabilir OAuth sağlayıcısı yok.", @@ -668,6 +680,9 @@ export const tr: Record = { "logs.tabDebug": "Hata Ayıklama", "logs.subtitle": "Proxy üzerinden yönlendirilen son istekler.", "logs.autoRefresh": "Otomatik yenile", + "logs.autoScroll": "Otomatik kaydırma", + "logs.clearView": "Görünümü temizle", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "Henüz istek yok.", "logs.loadError": "İstek günlükleri yüklenemedi.", "logs.filter.surface.label": "Yüzey", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5c74b86447..7732de02dc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -117,6 +117,7 @@ export const zhTW: Record = { "dash.offline": "離線", "dash.version": "版本", "dash.uptime": "執行時間", + "dash.port": "連接埠", "dash.providers": "供應商", "dash.tokens30d": "Token (30 天)", "dash.coverage": "覆蓋率 {pct}", @@ -155,6 +156,11 @@ export const zhTW: Record = { "dash.col.adapter": "介面卡", "dash.col.baseUrl": "Base URL", "dash.col.model": "模型", + "dash.col.status": "狀態", + "dash.providerStatus.ok": "正常", + "dash.providerStatus.error": "錯誤", + "dash.providerStatus.disabled": "已停用", + "dash.providerStatus.unknown": "未知", "dash.modelsNoResults": "沒有符合搜尋的模型。", "dash.availableModels": "可用模型", "dash.noModels": "未找到模型。請檢查供應商 API 金鑰。", @@ -259,6 +265,12 @@ export const zhTW: Record = { "dash.updateStatus.failed": "更新失敗。", "prov.subtitle": "配置 opencodex 路由到 Codex 的上游供應商。使用帳號登入、新增供應商,或編輯原始配置。", "prov.add": "新增供應商", + "prov.testAll": "測試全部", + "prov.testing": "測試中…", + "prov.testAll.ok": "全部 {count} 個供應商正常。", + "prov.testAll.partial": "{passed} 個正常,{failed} 個出錯。", + "prov.testAll.result.ok": "正常 ({latency}ms)", + "prov.testAll.result.error": "錯誤:{error}", "prov.editJson": "編輯 JSON", "prov.accountLogin": "帳號登入", "prov.noOauth": "沒有可用的 OAuth 供應商。", @@ -515,6 +527,9 @@ export const zhTW: Record = { "logs.tabDebug": "除錯", "logs.subtitle": "經過本地 opencodex 代理的最近請求,最新在前。", "logs.autoRefresh": "自動重新整理", + "logs.autoScroll": "自動捲動", + "logs.clearView": "清空視圖", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "暫無請求。", "logs.loadError": "無法載入請求日誌。", "logs.filter.surface.label": "介面", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 87001cbb0f..de35bb5c8f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -214,6 +214,7 @@ export const zh: Record = { "dash.offline": "离线", "dash.version": "版本", "dash.uptime": "运行时间", + "dash.port": "端口", "dash.providers": "提供方", "dash.tokens30d": "Token (30 天)", "dash.coverage": "覆盖率 {pct}", @@ -256,6 +257,11 @@ export const zh: Record = { "dash.col.adapter": "适配器", "dash.col.baseUrl": "Base URL", "dash.col.model": "模型", + "dash.col.status": "状态", + "dash.providerStatus.ok": "正常", + "dash.providerStatus.error": "错误", + "dash.providerStatus.disabled": "已禁用", + "dash.providerStatus.unknown": "未知", "dash.modelsNoResults": "没有符合搜索的模型。", "dash.availableModels": "可用模型", "dash.noModels": "未找到模型。请检查提供方 API 密钥。", @@ -364,6 +370,12 @@ export const zh: Record = { // providers "prov.subtitle": "配置 opencodex 路由到 Codex 的上游提供方。使用账户登录、添加提供方,或编辑原始配置。", "prov.add": "添加提供方", + "prov.testAll": "测试全部", + "prov.testing": "测试中…", + "prov.testAll.ok": "全部 {count} 个提供方正常。", + "prov.testAll.partial": "{passed} 个正常,{failed} 个出错。", + "prov.testAll.result.ok": "正常 ({latency}ms)", + "prov.testAll.result.error": "错误:{error}", "prov.editJson": "编辑 JSON", "prov.accountLogin": "账户登录", "prov.noOauth": "没有可用的 OAuth 提供方。", @@ -656,6 +668,9 @@ export const zh: Record = { "logs.tabDebug": "调试", "logs.subtitle": "经过本地 opencodex 代理的最近请求,最新在前。", "logs.autoRefresh": "自动刷新", + "logs.autoScroll": "自动滚动", + "logs.clearView": "清空视图", + "logs.bufferCount": "{shown} / {total}", "logs.noRequests": "暂无请求。", "logs.loadError": "无法加载请求日志。", "logs.filter.surface.label": "界面", diff --git a/gui/src/log-key.ts b/gui/src/log-key.ts new file mode 100644 index 0000000000..a580a89444 --- /dev/null +++ b/gui/src/log-key.ts @@ -0,0 +1,8 @@ +/** + * Stable log identity. The server assigns every log entry a unique `requestId` + * (`ocx-${randomBytes(16).hex}`), guaranteed present on all entries returned + * by the management API. This is the sole clear-view identity key. + */ +export function logKey(requestId: string): string { + return requestId; +} diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index e7384a96b2..5fde9c7f38 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n, LOCALES, type TFn } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; +import { logKey } from "../log-key"; import { hashLogConversationQuery, matchesLogConversationId } from "../log-conversation-id"; import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; @@ -127,7 +128,7 @@ interface LogAttempt { } export interface LogEntry { - requestId?: string; + requestId: string; timestamp: number; model: string; provider: string; @@ -365,6 +366,10 @@ export default function Logs({ apiBase }: { apiBase: string }) { const resourceKey = logsCacheKey(apiBase); const cachedLogs = validCachedLogs(readSessionListCache(resourceKey)); const [autoRefresh, setAutoRefresh] = useState(true); + const [autoScroll, setAutoScroll] = useState(true); + const [clearedIds, setClearedIds] = useState | null>(null); + + useEffect(() => { setClearedIds(null); }, [resourceKey]); const [failureStreak, setFailureStreak] = useState<{ error: unknown; count: number }>( { error: null, count: 0 }, ); @@ -508,7 +513,19 @@ export default function Logs({ apiBase }: { apiBase: string }) { return () => { cancelled = true; }; }, [conversationQuery]); - const filteredLogs = logs.filter(log => ( + const logsRef = useRef(logs); + logsRef.current = logs; + const clearView = useCallback(() => { + const ids = new Set(); + for (const log of logsRef.current) { ids.add(logKey(log.requestId)); } + setClearedIds(ids); + }, []); + const visibleLogs = (() => { + if (!clearedIds) return logs; + return logs.filter(log => !clearedIds.has(logKey(log.requestId))); + })(); + + const filteredLogs = visibleLogs.filter(log => ( logMatchesSurface(log, surfaceFilter) && (!interceptedHelpersOnly || Boolean(log.shadowCallRewrittenFrom)) && (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash)) @@ -529,15 +546,33 @@ export default function Logs({ apiBase }: { apiBase: string }) { ? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end : 0; + const prevCountRef = useRef(filteredLogs.length); + useEffect(() => { + if (filteredLogs.length === 0) { prevCountRef.current = 0; return; } + if (autoScroll && filteredLogs.length > prevCountRef.current) { + rowVirtualizer.scrollToIndex(filteredLogs.length - 1, { align: "end" }); + } + prevCountRef.current = filteredLogs.length; + }, [filteredLogs.length, autoScroll, rowVirtualizer]); + return (

{t("nav.logs")}

{tab === "logs" && ( + <> + + + )}
@@ -635,6 +670,9 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.filter.conversation.clear")} )} + + {t("logs.bufferCount", { shown: filteredLogs.length, total: logs.length })} +
{conversationTotals && ( @@ -725,7 +763,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const when = formatLogDateParts(log.timestamp, localeTag, serverTimeZone); return ( @@ -802,13 +840,13 @@ export default function Logs({ apiBase }: { apiBase: string }) { type="button" className="log-detail-btn" onClick={() => setDetail(log)} - aria-label={`${t("logs.details")}: ${log.requestId ?? log.status}`} + aria-label={`${t("logs.details")}: ${log.requestId}`} > {t("logs.details")} - {log.requestId ?? "-"} + {log.requestId} {log.durationMs}ms ); @@ -874,7 +912,6 @@ function LogDetailDialog({ const reasoningWire = reasoningWireLabel(detail); const copyRequestId = async () => { - if (!detail.requestId) return; try { await navigator.clipboard.writeText(detail.requestId); setCopied(true); @@ -908,12 +945,10 @@ function LogDetailDialog({ {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} {t("logs.col.request")} - {detail.requestId ?? "\u2014"} - {detail.requestId && ( - - )} + {detail.requestId} + {detail.conversationId && ( <> diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 158497a36c..a427bb18b4 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -6,7 +6,7 @@ import type { WorkspaceProvider } from "../provider-workspace/catalog"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; import { ToastNotice, type NoticeTone } from "../ui"; -import { IconPlus } from "../icons"; +import { IconPlus, IconRefresh } from "../icons"; import { useT } from "../i18n/shared"; import { useProviderAccountPools } from "../hooks/useProviderAccountPools"; import { useCodexAccountPool } from "../hooks/useCodexAccountPool"; @@ -20,6 +20,10 @@ import { useProvidersFetch } from "./use-providers-fetch"; import { ProvidersPageModals } from "./providers-page-modals"; import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import { useProviderBatchController } from "../hooks/use-provider-batch-controller"; +import { testProviderConnection, type ConnectionTestResult } from "../components/provider-workspace/provider-test"; + + export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); @@ -68,6 +72,51 @@ export default function Providers({ apiBase }: { apiBase: string }) { setStatusTone("err"); }, []); + const { batchTesting, startBatch, cancelMountedBatch, isActiveBatch } = useProviderBatchController(); + const [providerConfigGeneration, setProviderConfigGeneration] = useState(0); + + // Cancel batch when apiBase changes. + const prevApiBaseRef = useRef(apiBase); + useEffect(() => { + if (prevApiBaseRef.current !== apiBase) { + cancelMountedBatch(); + prevApiBaseRef.current = apiBase; + } + }, [apiBase, cancelMountedBatch]); + + const testAllProviders = useCallback(async () => { + if (!config || batchTesting) return; + const names = Object.keys(config.providers); + if (names.length === 0) return; + + const controller = startBatch(); + const signal = controller.signal; + const results: Record = {}; + const queue = [...names]; + const workerCount = Math.min(3, names.length); + const runWorker = async () => { + while (queue.length > 0 && !signal.aborted) { + const name = queue.shift()!; + results[name] = await testProviderConnection(apiBase, name, signal); + } + }; + try { + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); + } finally { + if (!signal.aborted && isActiveBatch(controller) && aliveRef.current) { + cancelMountedBatch(); + const passed = Object.values(results).filter(r => r.ok).length; + const failed = names.length - passed; + notify( + failed === 0 + ? t("prov.testAll.ok", { count: passed }) + : t("prov.testAll.partial", { passed, failed }), + failed === 0, + ); + } + } + }, [config, batchTesting, apiBase, notify, t, startBatch, cancelMountedBatch, isActiveBatch]); + const notifyCodexCompletion = useCallback((completion: CodexAccountMutationCompletion) => { if (completion.catalogRefreshPending) { setStatus(t("codexAuth.catalogRefreshPending")); @@ -138,12 +187,18 @@ export default function Providers({ apiBase }: { apiBase: string }) { setQuotaRefresh(previous => ({ epoch: previous.epoch + 1, force })); }, []); const { fetchConfig, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ - apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify, + apiBase, t, setConfig, setProviderConfigGeneration, setOauthProviders, setOauthStatus, notify, invalidateProviderQuotas, configCacheKey, }); - // WP3: one Codex account controller for the whole Providers page, shared by the + useEffect(() => { + cancelMountedBatch(); + }, [providerConfigGeneration, cancelMountedBatch]); + + + + // WP3, shared by the // Overview tab and the Accounts tab so a mutation on either is instantly visible on // both. Mounting CodexAccountPool twice used to fork this state. const codexPool = useCodexAccountPool(apiBase); @@ -307,6 +362,9 @@ export default function Providers({ apiBase }: { apiBase: string }) {

{t("nav.providers")}

+
diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 9cccc0e26c..6dff989a00 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -78,6 +78,7 @@ export function DashboardOverviewHead({
{t("dash.version")}
{health?.version ?? "—"}
{t("dash.uptime")}
{health ? formatUptime(health.uptime, locale) : "—"}
+
{t("dash.port")}
{health?.port ?? "—"}
{t("dash.providers")}
{providers.length}
{t("dash.tokens30d")}
diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 971cc3a64f..7bdf282931 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -511,19 +511,25 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
{t("dash.webSearchSidecar")}
{t("dash.webSearchSidecarHint")}
+ {/* Same two-row shape as the vision card: the model select owns the first row, + and the secondary control sits right-aligned on its own row below. Sharing the + structure is what keeps the two cards' first rows on one line — the streaming + label used to sit beside the select and wrap to three lines in ko/ja/tr. */}
- { + void saveSidecar({ webSearch: webSearchSidecarSelectionForModel(models, sidecarModels, model) }); + }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + align="right" + /> +
+
+ {t("dash.webSearchStream")}
-
+