Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59c6148ff | ||
|
|
95a78fe9a3 | ||
|
|
307ea07409 | ||
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec |
@@ -28,6 +28,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# Put BuildKit in the host's network namespace so it can reach
|
||||||
|
# act_runner's cache service.
|
||||||
|
#
|
||||||
|
# The `docker-container` driver — which the multi-arch build below
|
||||||
|
# requires, since the plain `docker` driver cannot do
|
||||||
|
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
|
||||||
|
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
|
||||||
|
# an address the *job* container can reach, and nothing teaches the
|
||||||
|
# BuildKit container about it: the job could reach
|
||||||
|
# 192.168.1.126:40649 while the container actually making the request
|
||||||
|
# could not, and the build died with `no route to host`.
|
||||||
|
#
|
||||||
|
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
|
||||||
|
# missing route (a wrong address times out instead) — which is what a
|
||||||
|
# default firewalld zone does to traffic arriving from the docker
|
||||||
|
# bridge. Sharing the host's namespace sidesteps the question
|
||||||
|
# entirely: the cache address becomes local to BuildKit.
|
||||||
|
#
|
||||||
|
# No effect on runners where this already worked.
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -55,5 +76,21 @@ jobs:
|
|||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||||
|
# `ignore-error` is what stops a cache failure failing a build that
|
||||||
|
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||||
|
# service on the runner host's LAN address, and the `docker-container`
|
||||||
|
# builder `setup-buildx-action` creates could not route to it —
|
||||||
|
# every layer of both arches built, then the job died on
|
||||||
|
# `GetCacheEntryDownloadURL: no route to host` while exporting.
|
||||||
|
#
|
||||||
|
# On a pull_request `push:` above is false, so this job pushes
|
||||||
|
# nothing and the cache is its only output: failing it discarded a
|
||||||
|
# complete, successful validation of the Dockerfile for both
|
||||||
|
# architectures. A cache is an optimisation and must degrade to
|
||||||
|
# "slow", never to "red".
|
||||||
|
#
|
||||||
|
# The import is already non-fatal — the build ran all 37 layers after
|
||||||
|
# warning that it could not read the cache — so only the exporter
|
||||||
|
# needs the flag.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,ignore-error=true
|
||||||
|
|||||||
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
|
|||||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||||
so a project already at `true` gets no recreation at all on upgrade.
|
so a project already at `true` gets no recreation at all on upgrade.
|
||||||
|
|
||||||
|
### Keeping Claude Code current
|
||||||
|
|
||||||
|
`claude update` runs in **two** places, and both are needed:
|
||||||
|
|
||||||
|
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||||
|
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||||
|
command every Claude session launches with, because containers use a stop/start model and a
|
||||||
|
long-lived one would otherwise never re-check.
|
||||||
|
|
||||||
|
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||||
|
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||||
|
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||||
|
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||||
|
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||||
|
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||||
|
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||||
|
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||||
|
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||||
|
deliberately untouched.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
@@ -436,6 +456,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
|||||||
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||||
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||||
|
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
|
||||||
|
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
|
||||||
|
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
|
||||||
|
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
|
||||||
|
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
|
||||||
|
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
|
||||||
|
for the same reason a running container already outranked it. Assuming a snapshot existed is what
|
||||||
|
made a stopped, never-recreated project report "no container or snapshot image yet" with its
|
||||||
|
container sitting right there, and left Update disabled on the projects furthest behind.
|
||||||
|
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
|
||||||
|
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
|
||||||
|
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
|
||||||
|
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
|
||||||
|
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
|
||||||
|
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
|
||||||
|
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
|
||||||
|
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
|
||||||
|
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
|
||||||
|
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
|
||||||
|
layer acceptable. Two rules it earned the hard way:
|
||||||
|
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
|
||||||
|
daemon-wide, so a second copy of the app has live probe images matching the glob.
|
||||||
|
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
|
||||||
|
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
|
||||||
|
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
|
||||||
|
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
|
||||||
|
two concurrent probes fought over one tag — whichever finished first force-removed the image the
|
||||||
|
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
|
||||||
|
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
|
||||||
|
uniqueness is what makes overlapping probes safe.
|
||||||
|
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
|
||||||
|
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
|
||||||
|
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
|
||||||
|
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
|
||||||
|
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
|
||||||
|
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
|
||||||
|
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
|
||||||
|
failed to invalidate would plan a migration against a filesystem the project no longer has.
|
||||||
|
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
|
||||||
|
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
|
||||||
|
which leaves the migrate action in the project menu enabled — that action is not gated on the
|
||||||
|
banner — so the pre-flight would report nothing to copy while the backend was told to copy
|
||||||
|
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
|
||||||
|
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
|
||||||
|
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
|
||||||
|
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
|
||||||
|
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
|
||||||
|
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
|
||||||
|
- **`get_container_staleness` never commits while the project is claimed.** It takes no
|
||||||
|
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
|
||||||
|
reads `project_lock::held` instead and probes the snapshot rather than the container. The
|
||||||
|
collision is not symmetric: the probe losing is a retryable `probe_error`, but
|
||||||
|
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
|
||||||
|
commit would fail the user's Start with an opaque error.
|
||||||
|
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
|
||||||
|
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
|
||||||
|
stamps *now* — which is what real probe images do, and what any fixture for them must do.
|
||||||
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||||
crash before that point self-heal — `start_project_container` just recreates from the old
|
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||||
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||||
|
|||||||
+27
-5
@@ -243,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ │ └──────────────────────────────────────────────────┘ │
|
│ │ └──────────────────────────────────────────────────┘ │
|
||||||
├─────────────┴────────────────────────────────────────────────────────┤
|
├─────────────┴────────────────────────────────────────────────────────┤
|
||||||
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │
|
│ 2 project(s) · 1 running · 2 terminal(s) Notes │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -268,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
||||||
open you get a welcome screen with Docker/image/project readiness checks.
|
open you get a welcome screen with Docker/image/project readiness checks.
|
||||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
||||||
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
|
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||||
speech-to-text is enabled.
|
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1224,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
|
|||||||
|
|
||||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||||
|
|
||||||
### Jump to Current
|
### Scrolling
|
||||||
|
|
||||||
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
|
||||||
|
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
|
||||||
|
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
|
||||||
|
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
|
||||||
|
for them to act on.
|
||||||
|
|
||||||
|
### When the mouse stops working
|
||||||
|
|
||||||
|
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
|
||||||
|
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
|
||||||
|
you cannot select text, and stray characters can appear as you move the pointer.
|
||||||
|
|
||||||
|
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
|
||||||
|
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
|
||||||
|
container — only the terminal's own state is reset.
|
||||||
|
|
||||||
|
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
|
||||||
|
the button is showing most of the time you are in one. It is there for when a program exits
|
||||||
|
without handing the mouse back and the terminal is left stuck; releasing while a program is still
|
||||||
|
running just takes the mouse away from that program.
|
||||||
|
|
||||||
|
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
|
||||||
|
macOS.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
|
||||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ choice it never asked about.
|
|||||||
|
|
||||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||||
file drag-drop, STT), the web terminal, and workspace backup.
|
file drag-drop, STT), the web terminal, and workspace backup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+6
-3
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||||
|
|
||||||
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||||
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||||
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||||
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
|
||||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
|
||||||
|
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
|
||||||
|
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
|
||||||
|
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
|
||||||
|
|
||||||
### bollard (Docker API)
|
### bollard (Docker API)
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,111 @@ fn pick_recorded_lineage(
|
|||||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||||
/// be called on demand, not polled.
|
/// container, stopped or otherwise, and no snapshot image.
|
||||||
|
///
|
||||||
|
/// It used to be reported for a *stopped* container too, which was simply
|
||||||
|
/// untrue — the container was sitting right there — and it disabled Update on
|
||||||
|
/// exactly the long-lived projects that had never been recreated and so had no
|
||||||
|
/// snapshot to fall back on.
|
||||||
|
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
|
||||||
|
|
||||||
|
/// Where [`get_container_staleness`] reads the project's *current* filesystem
|
||||||
|
/// from, in descending order of how current the answer is.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ProbeSource {
|
||||||
|
/// `docker exec` into the live container. The only source that includes
|
||||||
|
/// everything installed since the last commit *in this session*.
|
||||||
|
RunningContainer,
|
||||||
|
/// Commit the stopped container's writable layer to a throwaway image and
|
||||||
|
/// probe that. Exactly as current as the container, which is what makes it
|
||||||
|
/// preferable to the snapshot — see below.
|
||||||
|
StoppedContainer,
|
||||||
|
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
|
||||||
|
Snapshot,
|
||||||
|
/// Nothing to read: no container, no snapshot.
|
||||||
|
Nothing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the probe source. `container_running` is `None` when the project has no
|
||||||
|
/// container at all, `Some(false)` when it has a stopped one.
|
||||||
|
///
|
||||||
|
/// **A stopped container outranks the snapshot.** The snapshot image is not a
|
||||||
|
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
|
||||||
|
/// config-change recreate) or inside a migration, so a project that has never
|
||||||
|
/// hit either has *no snapshot at all*, however long it has been in use, and
|
||||||
|
/// one that has is stale by everything installed since. The container's
|
||||||
|
/// writable layer is the truth in both cases. This is the same argument
|
||||||
|
/// [`mig::manifest_from_container`] already makes for the running case; it does
|
||||||
|
/// not stop applying when the container is stopped.
|
||||||
|
///
|
||||||
|
/// Getting this wrong is what made a stopped, never-recreated project report
|
||||||
|
/// "no container or snapshot image yet" — with its container sitting right
|
||||||
|
/// there — and left Update disabled on the projects that most needed it.
|
||||||
|
fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) -> ProbeSource {
|
||||||
|
match (container_running, snapshot_exists) {
|
||||||
|
(Some(true), _) => ProbeSource::RunningContainer,
|
||||||
|
(Some(false), _) => ProbeSource::StoppedContainer,
|
||||||
|
(None, true) => ProbeSource::Snapshot,
|
||||||
|
(None, false) => ProbeSource::Nothing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reported as `probe_error` when another operation owns the project and there
|
||||||
|
/// is no snapshot image to read instead. Deliberately not a claim about the
|
||||||
|
/// container: nothing is wrong with it, the answer is simply not safe to take
|
||||||
|
/// right now. See [`stopped_probe_policy`].
|
||||||
|
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
|
||||||
|
|
||||||
|
/// What to do about a stopped container, whose probe is the expensive one: it
|
||||||
|
/// commits the writable layer before it can read anything.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum StoppedProbe {
|
||||||
|
/// Commit and probe. The current answer, and the default.
|
||||||
|
Commit,
|
||||||
|
/// Probe the snapshot image instead. Less current — it lags the container by
|
||||||
|
/// everything installed since the last commit — but it allocates nothing and
|
||||||
|
/// touches nothing, which is what makes it the right answer while another
|
||||||
|
/// operation owns the container.
|
||||||
|
SnapshotInstead,
|
||||||
|
/// Report rather than guess.
|
||||||
|
Defer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick what to do about a stopped container.
|
||||||
|
///
|
||||||
|
/// **Never commits while the project is claimed.** `get_container_staleness`
|
||||||
|
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
|
||||||
|
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
|
||||||
|
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
|
||||||
|
/// *recreate* losing is not, because `start_project_container` removes the old
|
||||||
|
/// container with a hard `?`, so a non-404 from a remove that raced this commit
|
||||||
|
/// fails the whole Start with an opaque "Failed to remove container". Reading
|
||||||
|
/// the claim costs nothing and takes that failure off the table.
|
||||||
|
fn stopped_probe_policy(project_is_busy: bool, snapshot_exists: bool) -> StoppedProbe {
|
||||||
|
match (project_is_busy, snapshot_exists) {
|
||||||
|
(false, _) => StoppedProbe::Commit,
|
||||||
|
(true, true) => StoppedProbe::SnapshotInstead,
|
||||||
|
(true, false) => StoppedProbe::Defer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
|
||||||
|
/// on demand, not polled.
|
||||||
|
///
|
||||||
|
/// **Not read-only, despite only reporting.** The stopped-container path commits
|
||||||
|
/// a throwaway image and force-removes it, which makes this a writer of a
|
||||||
|
/// `triple-c-probe-*` image and puts it in the class of thing
|
||||||
|
/// [`crate::project_lock`] exists for — and it takes no claim. That is
|
||||||
|
/// deliberate: this is what the migration banner calls to decide whether to
|
||||||
|
/// offer an update, including while a migration is in flight, so refusing it
|
||||||
|
/// under a claim would blank the banner exactly when it has the most to say.
|
||||||
|
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
|
||||||
|
/// migration can remove the container out from under the commit, and the result
|
||||||
|
/// is a `probe_error` the user can retry, never a damaged container or a
|
||||||
|
/// mislabelled image. Two overlapping probes cannot collide either, because
|
||||||
|
/// probe image names are unique per call; see
|
||||||
|
/// [`crate::docker::container::get_probe_image_name`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_container_staleness(
|
pub async fn get_container_staleness(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -145,16 +248,62 @@ pub async fn get_container_staleness(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Probes ───────────────────────────────────────────────────────────
|
// ── Probes ───────────────────────────────────────────────────────────
|
||||||
let running = match &container_id {
|
let container_running = match &container_id {
|
||||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
|
||||||
None => false,
|
None => None,
|
||||||
};
|
};
|
||||||
let from_manifest = if running {
|
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
|
||||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
let from_manifest = match (
|
||||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
pick_probe_source(container_running, snapshot_exists),
|
||||||
|
&container_id,
|
||||||
|
) {
|
||||||
|
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
|
||||||
|
(ProbeSource::StoppedContainer, Some(id)) => {
|
||||||
|
let busy = crate::project_lock::held(&project_id).is_some();
|
||||||
|
match stopped_probe_policy(busy, snapshot_exists) {
|
||||||
|
StoppedProbe::Commit => {
|
||||||
|
match mig::manifest_from_stopped_container_cached(id).await {
|
||||||
|
Ok(m) => Ok(m),
|
||||||
|
// **Never let a failed commit cost an answer the
|
||||||
|
// snapshot could have given.** Before stopped
|
||||||
|
// containers were readable at all, a stopped project
|
||||||
|
// fell straight through to its snapshot, so surfacing
|
||||||
|
// this error where the snapshot exists would make the
|
||||||
|
// banner *worse* than it was — and the ways this fails
|
||||||
|
// are the ones where the fallback matters most: a full
|
||||||
|
// disk (the commit has to allocate the whole writable
|
||||||
|
// layer; the snapshot probe allocates nothing) and a
|
||||||
|
// 409 from an operation that claimed the project after
|
||||||
|
// the check above.
|
||||||
|
Err(e) if snapshot_exists => {
|
||||||
|
log::warn!(
|
||||||
|
"Probing the stopped container for project {} failed ({}) — \
|
||||||
|
falling back to its snapshot image, which may lag it",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
mig::manifest_from_image(&snapshot_image).await
|
mig::manifest_from_image(&snapshot_image).await
|
||||||
} else {
|
}
|
||||||
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoppedProbe::SnapshotInstead => {
|
||||||
|
log::info!(
|
||||||
|
"Project {} is claimed by another operation — probing its snapshot image \
|
||||||
|
rather than committing the container",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
mig::manifest_from_image(&snapshot_image).await
|
||||||
|
}
|
||||||
|
StoppedProbe::Defer => Err(PROJECT_BUSY.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
|
||||||
|
// `container_running` is `Some` exactly when `container_id` is, so the
|
||||||
|
// two arms above are the only ones those variants can reach. This arm
|
||||||
|
// is `ProbeSource::Nothing` — and now *only* that: it used to also
|
||||||
|
// swallow every stopped container, which is the bug.
|
||||||
|
(_, _) => Err(NOTHING_TO_PROBE.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (from_manifest, base_manifest) = match from_manifest {
|
let (from_manifest, base_manifest) = match from_manifest {
|
||||||
@@ -1964,6 +2113,59 @@ mod tests {
|
|||||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stopped_container_is_probed_rather_than_reported_missing() {
|
||||||
|
// The regression: a container that exists but is stopped, with no
|
||||||
|
// snapshot ever taken, read as "nothing to compare against".
|
||||||
|
assert_eq!(
|
||||||
|
pick_probe_source(Some(false), false),
|
||||||
|
ProbeSource::StoppedContainer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
|
||||||
|
// The snapshot lags the container by everything installed since the
|
||||||
|
// last commit, in both states.
|
||||||
|
assert_eq!(
|
||||||
|
pick_probe_source(Some(true), true),
|
||||||
|
ProbeSource::RunningContainer
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_probe_source(Some(false), true),
|
||||||
|
ProbeSource::StoppedContainer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
|
||||||
|
assert_eq!(pick_probe_source(None, true), ProbeSource::Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
|
||||||
|
// The one case the "no container or snapshot image yet" message may
|
||||||
|
// still describe.
|
||||||
|
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
|
||||||
|
assert_eq!(stopped_probe_policy(false, false), StoppedProbe::Commit);
|
||||||
|
assert_eq!(stopped_probe_policy(false, true), StoppedProbe::Commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
|
||||||
|
// The snapshot lags, but a stale answer beats failing someone's Start.
|
||||||
|
assert_eq!(
|
||||||
|
stopped_probe_policy(true, true),
|
||||||
|
StoppedProbe::SnapshotInstead
|
||||||
|
);
|
||||||
|
// Nothing to fall back to: say so instead of committing anyway.
|
||||||
|
assert_eq!(stopped_probe_policy(true, false), StoppedProbe::Defer);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||||
assert_eq!(human_bytes(512), "512 B");
|
assert_eq!(human_bytes(512), "512 B");
|
||||||
|
|||||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
|||||||
|
|
||||||
/// Build the command to run in the container terminal.
|
/// Build the command to run in the container terminal.
|
||||||
///
|
///
|
||||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||||
|
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||||
|
/// WebLinksAddon).
|
||||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
build_claude_terminal_cmd(
|
||||||
|
project,
|
||||||
|
settings.global_aws.aws_profile.as_deref(),
|
||||||
|
session_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell line run immediately before `exec claude` in every Claude terminal
|
||||||
|
/// session.
|
||||||
|
///
|
||||||
|
/// `container/entrypoint.sh` already runs `claude update` when the container
|
||||||
|
/// starts, but containers here use a stop/start (and often just keep running)
|
||||||
|
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
||||||
|
/// it per session is what keeps a week-old container current.
|
||||||
|
///
|
||||||
|
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
||||||
|
/// network, npm registry down) so a session always opens, and `timeout 60`
|
||||||
|
/// bounds how long a user waits for a terminal.
|
||||||
|
///
|
||||||
|
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
||||||
|
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
||||||
|
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
||||||
|
/// races two updaters against the same `~/.claude/bin` install, as does
|
||||||
|
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
||||||
|
/// behind a friendly message and the very next line (`exec claude`) would run
|
||||||
|
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
||||||
|
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
||||||
|
/// other holder just updated, so there is nothing left to do.
|
||||||
|
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
||||||
|
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
||||||
|
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Single-quote one argument for interpolation into a shell script string.
|
||||||
|
fn shell_quote_arg(arg: &str) -> String {
|
||||||
|
format!(" '{}'", arg.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||||
|
/// profile rather than the whole [`AppState`].
|
||||||
|
fn build_claude_terminal_cmd(
|
||||||
|
project: &Project,
|
||||||
|
global_aws_profile: Option<&str>,
|
||||||
|
session_name: Option<&str>,
|
||||||
|
) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.bedrock_config
|
||||||
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
|||||||
|
|
||||||
let permission_args = project.effective_permission_mode().cli_args();
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
|
// The args are interpolated into a shell script string, so single-quote
|
||||||
|
// each one.
|
||||||
|
let name_flag = session_name
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||||
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
return vec![
|
||||||
cmd.extend(permission_args);
|
"bash".to_string(),
|
||||||
if let Some(name) = session_name {
|
"-c".to_string(),
|
||||||
if !name.is_empty() {
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
cmd.push("-n".to_string());
|
];
|
||||||
cmd.push(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cmd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = aws_commands::resolve_profile_for_project(
|
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||||
project,
|
|
||||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
// then exec's into claude.
|
// then exec's into claude.
|
||||||
let name_flag = session_name
|
|
||||||
.filter(|n| !n.is_empty())
|
|
||||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
// The args are interpolated into a shell script string, so single-quote
|
|
||||||
// each one (same escaping style as name_flag above).
|
|
||||||
let permission_flags: String = permission_args
|
|
||||||
.iter()
|
|
||||||
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
|
||||||
.collect();
|
|
||||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -75,9 +114,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||||
|
use crate::models::Project;
|
||||||
|
|
||||||
/// A dropped file must be named the way the *user* named it.
|
/// A dropped file must be named the way the *user* named it.
|
||||||
///
|
///
|
||||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||||
@@ -338,6 +382,122 @@ mod tests {
|
|||||||
/// answer comes from the spelling, and a path that does not name a file is
|
/// answer comes from the spelling, and a path that does not name a file is
|
||||||
/// refused rather than silently substituted (it used to fall back to
|
/// refused rather than silently substituted (it used to fall back to
|
||||||
/// `"dropped-file"`).
|
/// `"dropped-file"`).
|
||||||
|
/// A `Project` with only the fields these tests care about set; the rest
|
||||||
|
/// come through serde so the test does not have to track every field.
|
||||||
|
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "p1",
|
||||||
|
"name": "Test",
|
||||||
|
"paths": [],
|
||||||
|
"container_id": null,
|
||||||
|
"status": "running",
|
||||||
|
"backend": backend,
|
||||||
|
"bedrock_config": bedrock_config,
|
||||||
|
"ollama_config": null,
|
||||||
|
"openai_compatible_config": null,
|
||||||
|
"allow_docker_access": false,
|
||||||
|
"full_permissions": false,
|
||||||
|
"ssh_key_path": null,
|
||||||
|
"git_user_name": null,
|
||||||
|
"git_user_email": null,
|
||||||
|
"created_at": "now",
|
||||||
|
"updated_at": "now"
|
||||||
|
}))
|
||||||
|
.expect("test project deserializes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Claude session updates the CLI before launching it.
|
||||||
|
///
|
||||||
|
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||||
|
/// containers are long-lived, so a stale CLI is the normal case without
|
||||||
|
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||||
|
/// wrapper rather than a bare `claude` argv.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||||
|
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||||
|
|
||||||
|
assert_eq!(cmd[0], "bash");
|
||||||
|
assert_eq!(cmd[1], "-c");
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(UPDATE_PRELUDE),
|
||||||
|
"plain path must run the update prelude: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||||
|
// The update has to happen *before* the exec, which never returns.
|
||||||
|
assert!(
|
||||||
|
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||||
|
"prelude must precede the exec: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||||
|
"the update must stay time-bounded and non-fatal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session name is interpolated into a shell script, so a quote in it
|
||||||
|
/// must not break out of its single-quoted argument.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||||
|
let cmd = build_claude_terminal_cmd(
|
||||||
|
&project("anthropic", serde_json::Value::Null),
|
||||||
|
None,
|
||||||
|
Some("Bob's tab; rm -rf /"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||||
|
"session name must be single-quote escaped: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission flags travel the same escaped path, and an empty name adds
|
||||||
|
/// no `-n` at all.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||||
|
let mut p = project("anthropic", serde_json::Value::Null);
|
||||||
|
p.full_permissions = true;
|
||||||
|
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||||
|
"got: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
||||||
|
/// prelude, immediately before the exec.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
||||||
|
let cmd = build_claude_terminal_cmd(
|
||||||
|
&project("bedrock", serde_json::json!({
|
||||||
|
"auth_method": "profile",
|
||||||
|
"aws_region": "us-east-1",
|
||||||
|
"aws_profile": "acme",
|
||||||
|
"model_id": null,
|
||||||
|
"disable_prompt_caching": false
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
Some("it's fine"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(cmd[0], "bash");
|
||||||
|
let script = &cmd[2];
|
||||||
|
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
||||||
|
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
||||||
|
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
||||||
|
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
||||||
|
assert!(
|
||||||
|
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
||||||
|
"prelude must precede the exec: {}",
|
||||||
|
script
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||||
use crate::commands::file_commands::host_upload_name;
|
use crate::commands::file_commands::host_upload_name;
|
||||||
|
|||||||
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
|
||||||
|
/// container makes. The reaper's only handle on a leftover — see
|
||||||
|
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
|
||||||
|
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
|
||||||
|
|
||||||
|
/// The throwaway image a staleness probe of a **stopped** container commits to.
|
||||||
|
///
|
||||||
|
/// **Unique per call**, and both halves of the name earn their place: the
|
||||||
|
/// container id prefix makes a leftover traceable in `docker images`, and the
|
||||||
|
/// counter makes two overlapping probes independent.
|
||||||
|
///
|
||||||
|
/// An earlier version of this was deliberately *stable* per container, on the
|
||||||
|
/// theory that the next probe would move the tag off an abandoned image and
|
||||||
|
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
|
||||||
|
/// over. A container id does not survive a recreate, so for most leftovers
|
||||||
|
/// there is no "next probe of the same container" and the image was stranded
|
||||||
|
/// permanently; and a stable name made two concurrent probes fight over one
|
||||||
|
/// tag, where whichever finished first force-removed the image the other was
|
||||||
|
/// still reading and turned a healthy project into a bogus `probe_error`.
|
||||||
|
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
|
||||||
|
/// is what collects the leftovers instead.
|
||||||
|
pub fn get_probe_image_name(container_id: &str) -> String {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
let short: String = container_id.chars().take(12).collect();
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!(
|
||||||
|
"{}{}-{}-{}:latest",
|
||||||
|
PROBE_IMAGE_PREFIX,
|
||||||
|
short,
|
||||||
|
nanos,
|
||||||
|
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit a **stopped** container's filesystem to a throwaway image, returning
|
||||||
|
/// its name. The caller owns the image and must remove it.
|
||||||
|
///
|
||||||
|
/// This exists so a stopped project can be read at all. `docker exec` needs a
|
||||||
|
/// running container and the snapshot image is not a checkpoint — see
|
||||||
|
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
|
||||||
|
/// this there is no way to see inside a project that is merely stopped.
|
||||||
|
///
|
||||||
|
/// ## Why it is tagged at all
|
||||||
|
///
|
||||||
|
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
|
||||||
|
/// that `docker commit` copies off the container is exactly the pair
|
||||||
|
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
|
||||||
|
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
|
||||||
|
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
|
||||||
|
/// `commit_container` hands back `id: None` every time and there is no
|
||||||
|
/// reference left to probe. Neither existing commit site notices, because both
|
||||||
|
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
|
||||||
|
///
|
||||||
|
/// So the image needs a name, a tagged image is not dangling, and the sweep
|
||||||
|
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
|
||||||
|
/// is, and [`get_probe_image_name`] carries the rest of that argument.
|
||||||
|
///
|
||||||
|
/// ## What is in the image, and what is not
|
||||||
|
///
|
||||||
|
/// `pause: false` because nothing is running — pausing a stopped container is
|
||||||
|
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
|
||||||
|
/// passes `false`.
|
||||||
|
///
|
||||||
|
/// Secrets are blanked from the env for the same reason
|
||||||
|
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
|
||||||
|
/// full ENV into the image, and "it only lives a few seconds" is not a property
|
||||||
|
/// this function can promise after a crash.
|
||||||
|
///
|
||||||
|
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
|
||||||
|
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
|
||||||
|
/// because a commit stacks a layer and never rewrites one — but that scrub is a
|
||||||
|
/// `docker exec`, which is exactly what a stopped container cannot serve, and
|
||||||
|
/// scrubbing is not wanted anyway: the probe's whole job is to report the
|
||||||
|
/// filesystem as it actually is. What makes it acceptable is that this copies
|
||||||
|
/// bytes that are *already on this disk* in the container's own writable layer,
|
||||||
|
/// into an image that is never pushed, never created from, and reaped — so it
|
||||||
|
/// duplicates data inside one trust domain rather than widening it. That
|
||||||
|
/// argument depends on the reaping actually happening; treat
|
||||||
|
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
|
||||||
|
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let image_name = get_probe_image_name(container_id);
|
||||||
|
let (repo, tag) = image_name
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(r, t)| (r.to_string(), t.to_string()))
|
||||||
|
.expect("get_probe_image_name always emits a tag");
|
||||||
|
|
||||||
|
docker
|
||||||
|
.commit_container(
|
||||||
|
CommitContainerOptions {
|
||||||
|
container: container_id.to_string(),
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
pause: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Config::<String> {
|
||||||
|
env: Some(blanked_secret_env()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
|
||||||
|
|
||||||
|
Ok(image_name)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
||||||
/// name in [`SECRET_ENV_KEYS`].
|
/// name in [`SECRET_ENV_KEYS`].
|
||||||
fn env_holds_a_secret(env: &[String]) -> bool {
|
fn env_holds_a_secret(env: &[String]) -> bool {
|
||||||
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
|||||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
|
||||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
|
||||||
/// only has the image name (the project record is already gone by then).
|
/// (which only has the image name — the project record is already gone by
|
||||||
|
/// then), and the staleness probe's throwaway commit, which has only an id.
|
||||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Removed snapshot image {}", image_name);
|
log::info!("Removed image {}", image_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
Err(bollard::errors::Error::DockerResponseServerError {
|
||||||
@@ -4464,6 +4577,29 @@ mod tests {
|
|||||||
assert!(env_holds_a_secret(&env));
|
assert!(env_holds_a_secret(&env));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The probe image's name must be **unique per call**. A stable name was
|
||||||
|
/// tried and is wrong twice over: a container id does not survive a
|
||||||
|
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
|
||||||
|
/// next probe of the same container"; and two concurrent probes sharing one
|
||||||
|
/// tag means whichever finishes first force-removes the image the other is
|
||||||
|
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
|
||||||
|
#[test]
|
||||||
|
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
|
||||||
|
let id = "75993e6d5e1ab473b029a408c5ff0339";
|
||||||
|
let a = get_probe_image_name(id);
|
||||||
|
let b = get_probe_image_name(id);
|
||||||
|
assert_ne!(a, b, "two probes of one container must not share a tag");
|
||||||
|
|
||||||
|
// The prefix is the reaper's only handle on a leftover, so every name
|
||||||
|
// has to carry it — and it must not be the snapshot namespace, which is
|
||||||
|
// what a project is rebuilt from.
|
||||||
|
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
|
||||||
|
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
|
||||||
|
// Traceable back to its container, which is the point of the prefix.
|
||||||
|
assert!(a.contains("75993e6d5e1a"), "{}", a);
|
||||||
|
assert!(a.ends_with(":latest"), "{}", a);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
||||||
let clean = SnapshotScrubReport {
|
let clean = SnapshotScrubReport {
|
||||||
|
|||||||
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove throwaway images left behind by a staleness probe of a stopped
|
||||||
|
/// container — [`super::container::commit_container_for_probe`]'s commits.
|
||||||
|
///
|
||||||
|
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
|
||||||
|
/// gives no image id back from a commit and there has to be something to probe.
|
||||||
|
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
|
||||||
|
/// — which collects every other kind of orphan this app can leave — will never
|
||||||
|
/// see one. Without this, a probe that dies between its commit and its own
|
||||||
|
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
|
||||||
|
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
|
||||||
|
/// UI to find it either. That is the one leak in this app with no floor on it,
|
||||||
|
/// so this runs at startup beside [`reap_probe_containers`].
|
||||||
|
///
|
||||||
|
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
|
||||||
|
/// filter, so a second copy of the app probing a project on the same daemon has
|
||||||
|
/// images matching this glob, and removing one mid-capture fails that probe with
|
||||||
|
/// "No such image" — the bogus `probe_error` the staleness work exists to get
|
||||||
|
/// rid of. In-process state cannot see the other instance, so age is the only
|
||||||
|
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
|
||||||
|
/// `find` over a root filesystem, not a multi-minute job.
|
||||||
|
///
|
||||||
|
/// Never fails the caller. Housekeeping, like every other sweep here.
|
||||||
|
pub async fn reap_probe_images() {
|
||||||
|
use bollard::image::{ListImagesOptions, RemoveImageOptions};
|
||||||
|
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Could not reap leftover probe images: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters = HashMap::from([(
|
||||||
|
"reference".to_string(),
|
||||||
|
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
|
||||||
|
)]);
|
||||||
|
let images = match docker
|
||||||
|
.list_images(Some(ListImagesOptions {
|
||||||
|
all: false,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(images) => images,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Could not list leftover probe images: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
for image in images {
|
||||||
|
// Unlike a container summary, an image summary always carries a
|
||||||
|
// `Created`, so there is no unknown-age case to defend against here.
|
||||||
|
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
|
||||||
|
log::info!(
|
||||||
|
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
|
||||||
|
to another Triple-C instance's live probe",
|
||||||
|
image.repo_tags,
|
||||||
|
PROBE_REAP_MIN_AGE_SECS / 60
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// By **tag**, never by image id. A `force` removal by id untags an
|
||||||
|
// image everywhere, so an id that happens to carry another name loses
|
||||||
|
// that name too — which is how a test fixture that tagged
|
||||||
|
// `alpine:latest` into this namespace deleted the user's alpine. A real
|
||||||
|
// leftover has exactly the one probe tag, so removing the tag removes
|
||||||
|
// the image; anything else keeps whatever other names it has.
|
||||||
|
for tag in image
|
||||||
|
.repo_tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
|
||||||
|
{
|
||||||
|
log::info!("Removing leftover probe image {}", tag);
|
||||||
|
if let Err(e) = docker
|
||||||
|
.remove_image(
|
||||||
|
tag,
|
||||||
|
Some(RemoveImageOptions {
|
||||||
|
force: true,
|
||||||
|
noprune: false,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How old a `triple-c.probe=migration` container must be before
|
/// How old a `triple-c.probe=migration` container must be before
|
||||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||||
///
|
///
|
||||||
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
|
|||||||
Ok(parse_manifest(&out))
|
Ok(parse_manifest(&out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||||
|
/// the container's `FinishedAt` at the time it was captured.
|
||||||
|
///
|
||||||
|
/// **Sound because a stopped container's writable layer cannot change.** Nothing
|
||||||
|
/// can write to it while it is not running, so a manifest captured after it
|
||||||
|
/// stopped stays true until it is started again — and `FinishedAt` moves on
|
||||||
|
/// every stop, which is what makes the key exact rather than merely plausible.
|
||||||
|
///
|
||||||
|
/// This exists because `get_container_staleness` is called from a `useEffect`
|
||||||
|
/// that fires whenever the container settles, so simply opening a stopped
|
||||||
|
/// project's Overview probes it. Uncached that meant a `docker commit` of the
|
||||||
|
/// whole writable layer per visit — measured at 44 s on a real project — where
|
||||||
|
/// before this feature the same visit cost one throwaway container or nothing at
|
||||||
|
/// all. A regression like that is not worth the answer it buys.
|
||||||
|
///
|
||||||
|
/// Capped, because a `Manifest` of a real container is a few MB: this only has
|
||||||
|
/// to serve "the project whose page is open", so a handful of entries is the
|
||||||
|
/// whole working set and the oldest is dropped past that.
|
||||||
|
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
|
||||||
|
Option<Vec<(String, String, Manifest)>>,
|
||||||
|
> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
|
||||||
|
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
|
||||||
|
|
||||||
|
/// `FinishedAt` for a container, the cache's validity token. `None` when it
|
||||||
|
/// cannot be read, which is never treated as a hit.
|
||||||
|
async fn container_finished_at(container_id: &str) -> Option<String> {
|
||||||
|
let docker = get_docker().ok()?;
|
||||||
|
docker
|
||||||
|
.inspect_container(container_id, None)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.state?
|
||||||
|
.finished_at
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
|
||||||
|
/// when the container has not been started since it was taken.
|
||||||
|
///
|
||||||
|
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
|
||||||
|
pub async fn manifest_from_stopped_container_cached(
|
||||||
|
container_id: &str,
|
||||||
|
) -> Result<Manifest, String> {
|
||||||
|
let finished_at = container_finished_at(container_id).await;
|
||||||
|
|
||||||
|
if let Some(token) = &finished_at {
|
||||||
|
let guard = STOPPED_MANIFEST_CACHE.lock();
|
||||||
|
if let Ok(cache) = guard {
|
||||||
|
if let Some(entries) = cache.as_ref() {
|
||||||
|
if let Some((_, _, manifest)) = entries
|
||||||
|
.iter()
|
||||||
|
.find(|(id, tok, _)| id == container_id && tok == token)
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"Reusing the cached manifest for stopped container {}",
|
||||||
|
container_id
|
||||||
|
);
|
||||||
|
return Ok(manifest.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = manifest_from_stopped_container(container_id).await?;
|
||||||
|
|
||||||
|
// Only cacheable if the container's state could be read at all; an unknown
|
||||||
|
// `FinishedAt` means there is no token that could later be compared.
|
||||||
|
if let Some(token) = finished_at {
|
||||||
|
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
|
||||||
|
let entries = cache.get_or_insert_with(Vec::new);
|
||||||
|
entries.retain(|(id, _, _)| id != container_id);
|
||||||
|
entries.push((container_id.to_string(), token, manifest.clone()));
|
||||||
|
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
|
||||||
|
entries.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a [`Manifest`] from a **stopped** container.
|
||||||
|
///
|
||||||
|
/// Commits the container's writable layer to a throwaway image, probes that,
|
||||||
|
/// and removes it. This is as current as [`manifest_from_container`] — it reads
|
||||||
|
/// the same filesystem — and it is why a stopped project no longer has to fall
|
||||||
|
/// back to its snapshot image, which may not exist at all and lags the
|
||||||
|
/// container by everything installed since the last commit when it does.
|
||||||
|
///
|
||||||
|
/// The image is removed on every path, including a failed probe. See
|
||||||
|
/// [`super::container::commit_container_for_probe`] for what a crash in the
|
||||||
|
/// window between the two costs, and why it is bounded.
|
||||||
|
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
|
||||||
|
let image = super::container::commit_container_for_probe(container_id).await?;
|
||||||
|
|
||||||
|
let manifest = manifest_from_image(&image)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
|
||||||
|
|
||||||
|
if let Err(e) = super::container::remove_image_by_name(&image).await {
|
||||||
|
log::warn!(
|
||||||
|
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
|
||||||
|
collects it at the next app start; the orphan sweep never will, because it is tagged",
|
||||||
|
image,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
|
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
|
||||||
///
|
///
|
||||||
/// Deliberately the **ID**, not a repo digest: locally built images and custom
|
/// Deliberately the **ID**, not a repo digest: locally built images and custom
|
||||||
@@ -2146,4 +2353,258 @@ mod tests {
|
|||||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Live Docker ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The cache serves a second read of an unchanged stopped container, and —
|
||||||
|
/// the half that matters — stops serving it the moment the container is
|
||||||
|
/// started and stopped again. If invalidation were wrong this would report a
|
||||||
|
/// filesystem the project no longer has, and a migration would be planned
|
||||||
|
/// against it.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -- --ignored --nocapture stopped_manifest_cache
|
||||||
|
/// ```
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||||
|
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
|
||||||
|
fn docker_cli(args: &[&str]) -> String {
|
||||||
|
let out = std::process::Command::new("docker")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("docker CLI");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"docker {:?} failed: {}",
|
||||||
|
args,
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||||
|
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||||
|
let first = format!("/opt/cache-marker-a-{}", std::process::id());
|
||||||
|
let second = format!("/opt/cache-marker-b-{}", std::process::id());
|
||||||
|
|
||||||
|
let id = docker_cli(&[
|
||||||
|
"run", "-d", "--label", "triple-c.managed=true",
|
||||||
|
"--entrypoint", "/bin/sh",
|
||||||
|
&image, "-c", "sleep 600",
|
||||||
|
]);
|
||||||
|
let cleanup = || {
|
||||||
|
let _ = std::process::Command::new("docker")
|
||||||
|
.args(["rm", "-f", &id])
|
||||||
|
.output();
|
||||||
|
};
|
||||||
|
|
||||||
|
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
|
||||||
|
docker_cli(&["stop", "-t", "1", &id]);
|
||||||
|
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let cold = manifest_from_stopped_container_cached(&id).await;
|
||||||
|
let cold_ms = t0.elapsed().as_millis();
|
||||||
|
|
||||||
|
let t1 = std::time::Instant::now();
|
||||||
|
let warm = manifest_from_stopped_container_cached(&id).await;
|
||||||
|
let warm_ms = t1.elapsed().as_millis();
|
||||||
|
|
||||||
|
// Restart, change the filesystem, stop again — `FinishedAt` moves.
|
||||||
|
docker_cli(&["start", &id]);
|
||||||
|
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
|
||||||
|
docker_cli(&["stop", "-t", "1", &id]);
|
||||||
|
let after_restart = manifest_from_stopped_container_cached(&id).await;
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
|
||||||
|
|
||||||
|
let cold = cold.expect("cold read");
|
||||||
|
let warm = warm.expect("warm read");
|
||||||
|
let after_restart = after_restart.expect("read after restart");
|
||||||
|
|
||||||
|
assert!(has(&cold, &first), "cold read missed {}", first);
|
||||||
|
assert!(has(&warm, &first), "warm read missed {}", first);
|
||||||
|
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
|
||||||
|
assert!(
|
||||||
|
warm_ms * 5 < cold_ms.max(5),
|
||||||
|
"the second read cost {} ms against a cold {} ms — it re-committed \
|
||||||
|
instead of using the cache",
|
||||||
|
warm_ms,
|
||||||
|
cold_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
// The restart must have invalidated it: the new directory has to show up.
|
||||||
|
assert!(
|
||||||
|
has(&after_restart, &second),
|
||||||
|
"a restart did not invalidate the cache — {} is missing, so this is \
|
||||||
|
a stale manifest of a filesystem the container no longer has",
|
||||||
|
second
|
||||||
|
);
|
||||||
|
assert!(has(&after_restart, &first), "the restart lost {}", first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reaper finds a leftover probe image by prefix and — crucially —
|
||||||
|
/// refuses to remove a young one, because that image may be another
|
||||||
|
/// Triple-C instance's live probe. Only a real daemon can say whether the
|
||||||
|
/// `reference=` glob matches the names `get_probe_image_name` produces.
|
||||||
|
///
|
||||||
|
/// The fixture is **committed**, not tagged and not built. An image's
|
||||||
|
/// `Created` is its own, not its tag's, so tagging something already on disk
|
||||||
|
/// into this namespace yields a fixture the reaper is right to call ancient
|
||||||
|
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
|
||||||
|
/// one looks ancient too. A commit stamps *now*, verified against Engine
|
||||||
|
/// 29.6, which is also how real probe images get their age.
|
||||||
|
///
|
||||||
|
/// Both of those mistakes were made here first, and one of them deleted an
|
||||||
|
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
|
||||||
|
/// tag rather than by image id.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -- --ignored --nocapture reaper_spares
|
||||||
|
/// ```
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
|
||||||
|
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn docker_out(args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new("docker").args(args).output().expect("docker CLI")
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||||
|
.unwrap_or_else(|_| "alpine:latest".to_string());
|
||||||
|
let name = crate::docker::container::get_probe_image_name("reapertest01234");
|
||||||
|
|
||||||
|
// A never-started container is enough to commit from, and leaves the
|
||||||
|
// daemon's run state alone entirely.
|
||||||
|
let created = docker_out(&["create", &base, "true"]);
|
||||||
|
assert!(
|
||||||
|
created.status.success(),
|
||||||
|
"could not create the fixture container from {}: {}",
|
||||||
|
base,
|
||||||
|
String::from_utf8_lossy(&created.stderr)
|
||||||
|
);
|
||||||
|
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
|
||||||
|
|
||||||
|
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
|
||||||
|
let _ = docker_out(&["rm", "-f", &cid]);
|
||||||
|
assert!(
|
||||||
|
committed.status.success(),
|
||||||
|
"could not commit the fixture image: {}",
|
||||||
|
String::from_utf8_lossy(&committed.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
reap_probe_images().await;
|
||||||
|
|
||||||
|
let still_there = Command::new("docker")
|
||||||
|
.args(["image", "inspect", &name])
|
||||||
|
.output()
|
||||||
|
.expect("docker image inspect")
|
||||||
|
.status
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let _ = Command::new("docker").args(["rmi", &name]).output();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
still_there,
|
||||||
|
"a probe image committed seconds ago was reaped — that is another \
|
||||||
|
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A *stopped* container is readable, and what comes back is its writable
|
||||||
|
/// layer rather than the image it was created from. This is the whole point
|
||||||
|
/// of the function: the base image cannot answer it, and the project may
|
||||||
|
/// well have no snapshot image at all.
|
||||||
|
///
|
||||||
|
/// Also asserts the throwaway commit leaves nothing behind, which no unit
|
||||||
|
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
|
||||||
|
/// the probe image is *tagged*, so a leak never shows up as a dangling
|
||||||
|
/// image and a dangling-set assertion here would pass either way.
|
||||||
|
///
|
||||||
|
/// Ignored because it needs Docker and commits a container; run it with
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -- --ignored --nocapture stopped_container
|
||||||
|
/// ```
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||||
|
async fn a_stopped_container_is_read_from_its_writable_layer() {
|
||||||
|
fn docker_cli(args: &[&str]) -> String {
|
||||||
|
let out = std::process::Command::new("docker")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("docker CLI");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"docker {:?} failed: {}",
|
||||||
|
args,
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
fn probe_images() -> Vec<String> {
|
||||||
|
let mut ids: Vec<String> = docker_cli(&[
|
||||||
|
"images", "-q",
|
||||||
|
"--filter",
|
||||||
|
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
|
||||||
|
])
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||||
|
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||||
|
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
|
||||||
|
let marker = format!("/opt/probe-marker-{}", std::process::id());
|
||||||
|
|
||||||
|
// Another instance's live probe images are allowed to exist; what must
|
||||||
|
// hold is that this probe adds none of its own.
|
||||||
|
let before = probe_images();
|
||||||
|
|
||||||
|
let id = docker_cli(&[
|
||||||
|
"run", "-d", "--label", "triple-c.managed=true",
|
||||||
|
"--entrypoint", "/bin/sh",
|
||||||
|
&image, "-c", "sleep 300",
|
||||||
|
]);
|
||||||
|
let cleanup = |id: &str| {
|
||||||
|
let _ = std::process::Command::new("docker")
|
||||||
|
.args(["rm", "-f", id])
|
||||||
|
.output();
|
||||||
|
};
|
||||||
|
|
||||||
|
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
|
||||||
|
docker_cli(&["stop", "-t", "1", &id]);
|
||||||
|
|
||||||
|
let result = manifest_from_stopped_container(&id).await;
|
||||||
|
|
||||||
|
cleanup(&id);
|
||||||
|
|
||||||
|
let manifest = result.expect("a stopped container must be probeable");
|
||||||
|
assert!(
|
||||||
|
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
|
||||||
|
"the probe read the image, not the container's writable layer: {} missing",
|
||||||
|
marker
|
||||||
|
);
|
||||||
|
// Non-empty package sets prove the probe script really ran, rather than
|
||||||
|
// parsing an empty transcript into an empty-but-Ok manifest.
|
||||||
|
assert!(
|
||||||
|
!manifest.apt_manual.is_empty(),
|
||||||
|
"apt-mark showmanual came back empty, so the probe did not run"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
probe_images(),
|
||||||
|
before,
|
||||||
|
"the throwaway probe image was not cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,12 +263,20 @@ pub fn run() {
|
|||||||
// logged warning rather than a failed start.
|
// logged warning rather than a failed start.
|
||||||
//
|
//
|
||||||
// Ordering matters. Probes are removed first because a probe holds
|
// Ordering matters. Probes are removed first because a probe holds
|
||||||
// an image open and the sweep will not force; pins are untagged
|
// an image open and the sweep will not force — both the probe
|
||||||
|
// containers and the probe images, the latter being the one orphan
|
||||||
|
// the sweep can never reach on its own; pins are untagged
|
||||||
// second so the images they were holding are dangling by the time
|
// second so the images they were holding are dangling by the time
|
||||||
// the sweep lists them; the sweep runs last and collects both.
|
// the sweep lists them; the sweep runs last and collects both.
|
||||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
crate::docker::reap_probe_containers().await;
|
crate::docker::reap_probe_containers().await;
|
||||||
|
// Probe *images* too, and for a sharper reason: a probe
|
||||||
|
// container merely pins an image the sweep then refuses to
|
||||||
|
// touch, whereas a leftover probe image is tagged and so
|
||||||
|
// nothing else in this app can ever collect it. See
|
||||||
|
// `reap_probe_images`.
|
||||||
|
crate::docker::reap_probe_images().await;
|
||||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||||
if reaped > 0 {
|
if reaped > 0 {
|
||||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
|
|||||||
writer_handle.abort();
|
writer_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
|
||||||
|
/// copied so the web terminal cannot drift from it — a duplicated `const` with
|
||||||
|
/// a "keep these identical" comment is only as good as the next reader.
|
||||||
|
use crate::commands::terminal_commands::UPDATE_PRELUDE;
|
||||||
|
|
||||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
|
|
||||||
let permission_args = project.effective_permission_mode().cli_args();
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
|
||||||
let mut cmd = vec!["claude".to_string()];
|
|
||||||
cmd.extend(permission_args);
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile = aws_commands::resolve_profile_for_project(
|
|
||||||
project,
|
|
||||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The args are interpolated into a shell script string below, so
|
// The args are interpolated into a shell script string below, so
|
||||||
// single-quote each one.
|
// single-quote each one.
|
||||||
let permission_flags: String = permission_args
|
let permission_flags: String = permission_args
|
||||||
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
.collect();
|
.collect();
|
||||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
return vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = aws_commands::resolve_profile_for_project(
|
||||||
|
project,
|
||||||
|
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
echo "Validating AWS session for profile '{profile}'..."
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
@@ -260,9 +267,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
export default function StatusBar({ stt }: Props) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
terminalHasSelection: s.terminalHasSelection,
|
terminalHasSelection: s.terminalHasSelection,
|
||||||
activeSessionId: s.activeSessionId,
|
activeSessionId: s.activeSessionId,
|
||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
|
||||||
notesDockOpen: s.notesDockOpen,
|
notesDockOpen: s.notesDockOpen,
|
||||||
toggleNotesDock: s.toggleNotesDock,
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
|
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||||
|
releaseActiveMouse: s.releaseActiveMouse,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
{/* Right-aligned controls: mouse release + Notes + STT mic */}
|
||||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
{activeSessionId && !terminalAtBottom && (
|
{activeSessionId && terminalMouseCaptured && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollActiveToBottom()}
|
data-mouse-release="true"
|
||||||
|
onClick={() => releaseActiveMouse()}
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||||
title="Scroll the terminal to the latest output"
|
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
|
||||||
>
|
>
|
||||||
Jump to Current ↓
|
🖱 Mouse captured — release
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
|||||||
import TerminalView, { supersedes } from "./TerminalView";
|
import TerminalView, { supersedes } from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||||
|
import { URL_TOAST_SELECTOR } from "./UrlToast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The window-wide native drag-drop listener, captured at registration.
|
* The window-wide native drag-drop listener, captured at registration.
|
||||||
@@ -370,15 +371,34 @@ describe("TerminalView — where a dropped file lands", () => {
|
|||||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
|
||||||
// The regression this file could not see. The toggle is `absolute top-2
|
// The regression this file could not see. Chrome like the URL toast is a
|
||||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
// *sibling* of the xterm host painted over the pane, so
|
||||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
// `elementFromPoint` returns it rather than the host — and a gate asking
|
||||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||||
// with no message and no log line. jsdom never ran that branch.
|
// with no message and no log line. jsdom never ran that branch.
|
||||||
const view = await mountWithLayout();
|
//
|
||||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
// The original fixture was the always-rendered "▼ Following" toggle. That
|
||||||
stubElementFromPoint(toggle);
|
// control is retired and the mouse-release button that could have replaced
|
||||||
|
// it lives in the status bar now, so the toast is what stands in — it is
|
||||||
|
// real chrome over the pane, which is the only property under test.
|
||||||
|
await mountWithLayout();
|
||||||
|
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||||
|
if (!emit) throw new Error("no terminal-output listener registered");
|
||||||
|
await act(async () => {
|
||||||
|
emit({
|
||||||
|
payload: Array.from(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
const toast = document.querySelector(URL_TOAST_SELECTOR);
|
||||||
|
if (!toast) throw new Error("URL toast not shown");
|
||||||
|
stubElementFromPoint(toast);
|
||||||
|
|
||||||
await drop(780, 10);
|
await drop(780, 10);
|
||||||
|
|
||||||
@@ -594,3 +614,88 @@ describe("TerminalView — focus on request", () => {
|
|||||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("TerminalView — releasing a captured mouse", () => {
|
||||||
|
/** Feed raw bytes to the terminal as if the container had printed them, and
|
||||||
|
* let xterm drain its write queue (it parses asynchronously). */
|
||||||
|
async function emitBytes(text: string) {
|
||||||
|
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||||
|
if (!emit) throw new Error("no terminal-output listener registered");
|
||||||
|
await act(async () => {
|
||||||
|
emit({ payload: Array.from(new TextEncoder().encode(text)) });
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the status bar would render from: the active terminal publishes the
|
||||||
|
* capture state, and the release action, into the store. The control itself
|
||||||
|
* lives in `StatusBar` — deliberately, so it never sits on top of the TUI
|
||||||
|
* that is asking for the mouse. */
|
||||||
|
function captured(): boolean {
|
||||||
|
return useAppState.getState().terminalMouseCaptured;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows nothing while the container has not grabbed the mouse", async () => {
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a release control once the container turns mouse tracking on", async () => {
|
||||||
|
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
|
||||||
|
// reported to the app. When the TUI that asked for it dies without
|
||||||
|
// resetting the mode, xterm keeps routing moves to the PTY and drops text
|
||||||
|
// selection — the freeze this control exists to break out of.
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
await emitBytes("\x1b[?1003h\x1b[?1006h");
|
||||||
|
|
||||||
|
expect(captured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the mode locally, without sending a byte to the container", async () => {
|
||||||
|
// The reset is written into xterm's own parser, not onto the wire. The
|
||||||
|
// program inside is usually gone; if it is not, it must not be told the
|
||||||
|
// user pulled the mouse back, or a live TUI would just re-grab it.
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
await emitBytes("\x1b[?1003h");
|
||||||
|
terminalInput.mockClear();
|
||||||
|
|
||||||
|
// Exactly what the status-bar button's onClick does.
|
||||||
|
const release = useAppState.getState().releaseActiveMouse;
|
||||||
|
await act(async () => {
|
||||||
|
release();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The published flag is bound to the live mode, so it going false *is* the
|
||||||
|
// assertion that xterm's mouse tracking is back to "none".
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
expect(terminalInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
|
||||||
|
const { container } = mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
await emitBytes("\x1b[?1002h");
|
||||||
|
terminalInput.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(helperTextarea(container), {
|
||||||
|
key: "X",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
// The chord must not also reach the container as input.
|
||||||
|
expect(terminalInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
||||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -219,14 +219,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
}, []);
|
}, []);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
||||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
// True while the program in the container holds mouse reporting open (any of
|
||||||
// Tracks user intent to follow output — only set to false by explicit user
|
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
||||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
const [mouseCaptured, setMouseCaptured] = useState(false);
|
||||||
const autoFollowRef = useRef(true);
|
const mouseCapturedRef = useRef(false);
|
||||||
const lastUserScrollTimeRef = useRef(0);
|
|
||||||
|
|
||||||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||||
// and the unmount-cleanup effect further down).
|
// and the unmount-cleanup effect further down).
|
||||||
@@ -251,10 +248,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
//
|
//
|
||||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
||||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
// sibling of the host rather than a child. Nothing painted over the pane
|
||||||
// drop on its own account — asking "is this element mine?" once turned every
|
// refuses a drop on its own account — asking "is this element mine?" once
|
||||||
// pixel under that chrome into a permanent dead zone.
|
// turned every pixel under that chrome into a permanent dead zone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -315,12 +312,60 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
};
|
};
|
||||||
}, [sessionId, sendInput]);
|
}, [sessionId, sendInput]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the badge with xterm's live mouse-tracking mode.
|
||||||
|
*
|
||||||
|
* There is no event for this, but there does not need to be a poll either:
|
||||||
|
* the mode only ever changes because the container printed a DECSET/DECRST
|
||||||
|
* sequence, so checking once per write covers every transition, exactly when
|
||||||
|
* it happens. The ref gate keeps the common case (mode unchanged, thousands
|
||||||
|
* of writes a second) down to one string comparison and no re-render.
|
||||||
|
*/
|
||||||
|
const syncMouseCapture = useCallback(() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
const captured = term.modes.mouseTrackingMode !== "none";
|
||||||
|
if (captured === mouseCapturedRef.current) return;
|
||||||
|
mouseCapturedRef.current = captured;
|
||||||
|
setMouseCaptured(captured);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the mouse back from a program that grabbed it and never let go.
|
||||||
|
*
|
||||||
|
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
|
||||||
|
* tracking modes set. xterm goes on routing clicks, drags and — under
|
||||||
|
* `?1003` — every pointer *move* to the PTY, which kills text selection and
|
||||||
|
* floods the prompt with escape bytes. The result reads as a frozen
|
||||||
|
* terminal, and until now the only exit was closing the tab.
|
||||||
|
*
|
||||||
|
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
|
||||||
|
* xterm's own parser and never onto the wire. The program that asked for
|
||||||
|
* tracking is usually already gone; if it is not, telling it the user pulled
|
||||||
|
* the mouse back would only invite it to grab again on its next repaint.
|
||||||
|
*/
|
||||||
|
const releaseMouse = useCallback(() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
// The three tracking modes, then the two encodings they report in. All
|
||||||
|
// five, because a program is free to have set any combination and a
|
||||||
|
// leftover encoding mode outlives the tracking mode that motivated it.
|
||||||
|
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
|
||||||
|
}, [syncMouseCapture]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
// Let the user select text even while a program holds the mouse.
|
||||||
|
// xterm's force-selection modifier is Shift everywhere *except* macOS,
|
||||||
|
// where it is Option and is gated behind this option, which defaults to
|
||||||
|
// false — so without this line Mac users have no force-select at all and
|
||||||
|
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
||||||
|
// first. `SelectionService.shouldForceSelection`.
|
||||||
|
macOptionClickForcesSelection: true,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0d1117",
|
background: "#0d1117",
|
||||||
@@ -391,6 +436,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
useAppState.getState().sttToggle();
|
useAppState.getState().sttToggle();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
|
||||||
|
// a key because the failure this recovers from is *the pointer not
|
||||||
|
// working* — a control you have to click can be unreachable in exactly
|
||||||
|
// the situation that calls for it.
|
||||||
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
|
||||||
|
releaseMouse();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||||
@@ -501,42 +554,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
|
||||||
// Captured during capture phase so it fires before xterm's own handler.
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
|
||||||
lastUserScrollTimeRef.current = Date.now();
|
|
||||||
if (e.deltaY < 0) {
|
|
||||||
autoFollowRef.current = false;
|
|
||||||
setIsAutoFollow(false);
|
|
||||||
isAtBottomRef.current = false;
|
|
||||||
setIsAtBottom(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
|
||||||
|
|
||||||
// Track scroll position to show "Jump to Current" button.
|
|
||||||
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
|
||||||
let scrollStateRafId: number | null = null;
|
|
||||||
const scrollDisposable = term.onScroll(() => {
|
|
||||||
const buf = term.buffer.active;
|
|
||||||
const atBottom = buf.viewportY >= buf.baseY;
|
|
||||||
isAtBottomRef.current = atBottom;
|
|
||||||
|
|
||||||
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
|
||||||
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
|
||||||
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
|
||||||
autoFollowRef.current = true;
|
|
||||||
setIsAutoFollow(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollStateRafId === null) {
|
|
||||||
scrollStateRafId = requestAnimationFrame(() => {
|
|
||||||
scrollStateRafId = null;
|
|
||||||
setIsAtBottom(isAtBottomRef.current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track text selection to show copy hint in status bar
|
// Track text selection to show copy hint in status bar
|
||||||
const selectionDisposable = term.onSelectionChange(() => {
|
const selectionDisposable = term.onSelectionChange(() => {
|
||||||
setTerminalHasSelection(term.hasSelection());
|
setTerminalHasSelection(term.hasSelection());
|
||||||
@@ -599,15 +616,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data, () => {
|
// Scrolling on new output is xterm's own job, and it already gets it
|
||||||
if (autoFollowRef.current) {
|
// right: it follows the tail while the viewport is at the bottom and
|
||||||
term.scrollToBottom();
|
// holds position while you are reading further up. The manual
|
||||||
if (!isAtBottomRef.current) {
|
// `scrollToBottom()` that used to live here fought that second half.
|
||||||
isAtBottomRef.current = true;
|
term.write(data, syncMouseCapture);
|
||||||
setIsAtBottom(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
detector.feed(data);
|
detector.feed(data);
|
||||||
|
|
||||||
// Scan for SSO refresh marker in terminal output
|
// Scan for SSO refresh marker in terminal output
|
||||||
@@ -649,11 +662,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeRafId = requestAnimationFrame(() => {
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
resizeRafId = null;
|
resizeRafId = null;
|
||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
|
// Whether the viewport was following the tail has to be sampled
|
||||||
|
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
|
||||||
|
// afterwards cannot tell "was at the bottom" from "was pushed off it".
|
||||||
|
const wasAtBottom =
|
||||||
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
if (autoFollowRef.current) {
|
// Only re-anchor a viewport that was already on the tail. This
|
||||||
term.scrollToBottom();
|
// observer fires for any pane size change — opening the Notes dock,
|
||||||
}
|
// dragging the sidebar, resizing the window — and none of those are a
|
||||||
|
// reason to yank someone away from the scrollback they are reading.
|
||||||
|
if (wasAtBottom) term.scrollToBottom();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -667,14 +687,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
osc52Disposable.dispose();
|
osc52Disposable.dispose();
|
||||||
relayDisposable.dispose();
|
relayDisposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
setTerminalHasSelection(false);
|
setTerminalHasSelection(false);
|
||||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
@@ -723,10 +740,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
|
// Same rule as the resize observer: re-anchor only what was already
|
||||||
|
// anchored, so a tab left scrolled up comes back where it was left.
|
||||||
|
const wasAtBottom =
|
||||||
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
if (autoFollowRef.current) {
|
if (wasAtBottom) term.scrollToBottom();
|
||||||
term.scrollToBottom();
|
|
||||||
}
|
|
||||||
term.focus();
|
term.focus();
|
||||||
}
|
}
|
||||||
}, [active, gpuRenderingSetting]);
|
}, [active, gpuRenderingSetting]);
|
||||||
@@ -826,39 +845,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
|
||||||
const term = termRef.current;
|
|
||||||
if (term) {
|
|
||||||
autoFollowRef.current = true;
|
|
||||||
setIsAutoFollow(true);
|
|
||||||
fitRef.current?.fit();
|
|
||||||
term.scrollToBottom();
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
setIsAtBottom(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
|
||||||
// control, but only while it's the active (visible) terminal.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
setTerminalAtBottom(isAtBottom);
|
|
||||||
setScrollActiveToBottom(handleScrollToBottom);
|
|
||||||
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
|
||||||
|
|
||||||
// On unmount, if this was the active terminal, clear the status-bar scroll
|
|
||||||
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
|
||||||
// unmount — the deactivating terminal stays mounted but hidden — so this
|
|
||||||
// only fires when the active session is actually closed.)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (activeRef.current) {
|
|
||||||
setTerminalAtBottom(true);
|
|
||||||
setScrollActiveToBottom(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
|
||||||
|
|
||||||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -876,20 +862,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleAutoFollow = useCallback(() => {
|
// Surface the capture state and its escape hatch to the status bar, but only
|
||||||
const next = !autoFollowRef.current;
|
// while this is the visible terminal.
|
||||||
autoFollowRef.current = next;
|
useEffect(() => {
|
||||||
setIsAutoFollow(next);
|
if (!active) return;
|
||||||
if (next) {
|
setTerminalMouseCaptured(mouseCaptured);
|
||||||
const term = termRef.current;
|
setReleaseActiveMouse(releaseMouse);
|
||||||
if (term) {
|
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||||
fitRef.current?.fit();
|
|
||||||
term.scrollToBottom();
|
// On unmount, if this was the active terminal, clear the status-bar state so
|
||||||
isAtBottomRef.current = true;
|
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
||||||
setIsAtBottom(true);
|
// the deactivating terminal stays mounted but hidden — so this only fires
|
||||||
|
// when the active session is actually closed.)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setTerminalMouseCaptured(false);
|
||||||
|
setReleaseActiveMouse(() => {});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, []);
|
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -915,18 +907,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Auto-follow toggle - top right */}
|
|
||||||
<button
|
|
||||||
onClick={handleToggleAutoFollow}
|
|
||||||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
|
||||||
isAutoFollow
|
|
||||||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
|
||||||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
|
||||||
}`}
|
|
||||||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
|
||||||
>
|
|
||||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
|
||||||
</button>
|
|
||||||
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||||
FitAddon measures the host element it's mounted into; padding there
|
FitAddon measures the host element it's mounted into; padding there
|
||||||
causes the grid to overhang and clip the rightmost column / bottom
|
causes the grid to overhang and clip the rightmost column / bottom
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
|
|||||||
describe("chrome over a pane, with no dialog open", () => {
|
describe("chrome over a pane, with no dialog open", () => {
|
||||||
/** Everything that is painted over a pane and is not a blocker. */
|
/** Everything that is painted over a pane and is not a blocker. */
|
||||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
|
||||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
// and a *sibling* of the xterm host — so "does the pane contain what is
|
||||||
// host — so "does the pane contain what is painted here?" made the
|
// painted here?" made the terminal's top-right corner a dead zone no
|
||||||
// terminal's top-right corner a dead zone no user action could clear.
|
// user action could clear. (The retired Following toggle held the same
|
||||||
["the Following/Paused toggle", () => document.createElement("button")],
|
// corner and produced the original bug.)
|
||||||
|
["the mouse-release badge", () => document.createElement("button")],
|
||||||
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||||
// pane, and its error cards stay until dismissed.
|
// pane, and its error cards stay until dismissed.
|
||||||
["a toast card", () => document.createElement("div")],
|
["a toast card", () => document.createElement("div")],
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
*
|
*
|
||||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
|
||||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
* of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||||
* Permanent dead zones no user action could clear.
|
* Permanent dead zones no user action could clear.
|
||||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||||
|
|||||||
@@ -350,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
|
|||||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||||
//
|
//
|
||||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
|
||||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
// rather than polling) → migrateProjectToBase → the project sits
|
||||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||||
// rollbackMigration.
|
// rollbackMigration.
|
||||||
//
|
//
|
||||||
@@ -361,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
|
|||||||
//
|
//
|
||||||
// Progress arrives on the existing `container-progress` event.
|
// Progress arrives on the existing `container-progress` event.
|
||||||
|
|
||||||
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
/**
|
||||||
|
* Runs two container/image filesystem probes; not for polling.
|
||||||
|
*
|
||||||
|
* **Not read-only, despite only reporting.** When the container is *stopped*
|
||||||
|
* the backend has to commit its writable layer to a throwaway image before it
|
||||||
|
* can read anything — `docker exec` needs a running container — so this writes
|
||||||
|
* (and then removes) an image. The result is cached per stop, so repeat calls
|
||||||
|
* while the container stays stopped are cheap, but the first one after each stop
|
||||||
|
* pays for a commit of the whole layer: seconds on a small project, tens of
|
||||||
|
* seconds on a large one. Do not add a caller that fires more often than "the
|
||||||
|
* container settled into a new state" without re-reading
|
||||||
|
* `get_container_staleness`'s doc comment first.
|
||||||
|
*/
|
||||||
export const getContainerStaleness = (projectId: string) =>
|
export const getContainerStaleness = (projectId: string) =>
|
||||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -205,16 +205,20 @@ interface AppState {
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
|
// Whether a program in the active terminal is holding mouse reporting open,
|
||||||
|
// and how to take it back. Surfaced so the release control can live in the
|
||||||
|
// status bar: painted over the terminal it would sit on top of whatever TUI
|
||||||
|
// is asking for the mouse, and swallow clicks aimed at that program's own
|
||||||
|
// top-right corner for as long as it ran. Only the active TerminalView
|
||||||
|
// writes these.
|
||||||
|
terminalMouseCaptured: boolean;
|
||||||
|
setTerminalMouseCaptured: (captured: boolean) => void;
|
||||||
|
releaseActiveMouse: () => void;
|
||||||
|
setReleaseActiveMouse: (fn: () => void) => void;
|
||||||
// STT toggle for the active session, registered by App so the terminal's
|
// STT toggle for the active session, registered by App so the terminal's
|
||||||
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
||||||
sttToggle: () => void;
|
sttToggle: () => void;
|
||||||
setSttToggle: (fn: () => void) => void;
|
setSttToggle: (fn: () => void) => void;
|
||||||
// Active terminal scroll state, surfaced so the status bar can host the
|
|
||||||
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
||||||
terminalAtBottom: boolean;
|
|
||||||
setTerminalAtBottom: (v: boolean) => void;
|
|
||||||
scrollActiveToBottom: () => void;
|
|
||||||
setScrollActiveToBottom: (fn: () => void) => void;
|
|
||||||
sidebarView: "projects" | "settings";
|
sidebarView: "projects" | "settings";
|
||||||
setSidebarView: (view: "projects" | "settings") => void;
|
setSidebarView: (view: "projects" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
|
terminalMouseCaptured: false,
|
||||||
|
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
|
||||||
|
releaseActiveMouse: () => {},
|
||||||
|
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
|
||||||
sttToggle: () => {},
|
sttToggle: () => {},
|
||||||
setSttToggle: (fn) => set({ sttToggle: fn }),
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
||||||
terminalAtBottom: true,
|
|
||||||
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
||||||
scrollActiveToBottom: () => {},
|
|
||||||
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
sidebarCollapsed: loadSidebarCollapsed(),
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
|||||||
@@ -639,8 +639,14 @@ fi
|
|||||||
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
||||||
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
||||||
# slow or offline network never blocks container readiness.
|
# slow or offline network never blocks container readiness.
|
||||||
|
# The lock is shared with the per-session update that every Claude terminal
|
||||||
|
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
|
||||||
|
# "Container ready" is printed *after* this finishes, so a user who starts a
|
||||||
|
# project and immediately opens a tab would otherwise have two updaters
|
||||||
|
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
|
||||||
|
# damage right before it ran the result.
|
||||||
echo "entrypoint: checking for Claude Code updates..."
|
echo "entrypoint: checking for Claude Code updates..."
|
||||||
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
|
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
|
||||||
&& echo "entrypoint: Claude Code is up to date" \
|
&& echo "entrypoint: Claude Code is up to date" \
|
||||||
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user