diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/Cargo.lock b/Cargo.lock index 7f47cf5..5ac1af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,6 +577,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deltae" version = "0.3.2" @@ -1753,8 +1759,10 @@ dependencies = [ name = "multicode-lib" version = "0.1.0" dependencies = [ + "base64", "diesel", "diesel_migrations", + "futures-util", "libsqlite3-sys", "octocrab", "openapiv3", @@ -1771,6 +1779,7 @@ dependencies = [ "syn 2.0.117", "tokio", "tokio-stream", + "tokio-tungstenite", "toml 1.0.6+spec-1.1.0", "tracing", "tracing-subscriber", @@ -1803,10 +1812,12 @@ dependencies = [ "multicode-lib", "ratatui", "rustix", + "serde_json", "size", "tokio", "toml 1.0.6+spec-1.1.0", "tracing", + "unicode-width", "url", ] @@ -3092,6 +3103,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3574,6 +3596,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3760,6 +3798,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3879,6 +3936,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/README.md b/README.md index 1650e66..3af425a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # multicode -… runs isolated [opencode](https://opencode.ai/) instances in parallel. +… runs isolated AI coding agent instances in parallel. The [Micronaut Project](https://micronaut.io/) gets hundreds of issue reports from users. Many of them are easy to solve, but still take time to understand, debug and fix. AI agents can solve many of these issues on their own. @@ -17,12 +17,123 @@ solve, but still take time to understand, debug and fix. AI agents can solve man cargo run --bin multicode-tui config.toml ``` +## Editor Tool + +The `e` shortcut in the TUI opens the selected workspace or issue repository in a configured IDE. +This is configurable in `config.toml` with the `[compare]` section. + +By default, multicode uses VS Code: + +```toml +[compare] +tool = "vscode" +``` + +Supported values are: + +- `vscode` +- `intellij` + +You can also override the launcher command explicitly: + +```toml +[compare] +tool = "intellij" +command = "~/Library/Application Support/JetBrains/Toolbox/scripts/idea" +``` + +Notes: + +- If `command` is omitted, multicode tries common launcher names and install locations for the + selected tool. +- For VS Code, that includes `code` and common macOS app bundle paths. +- For IntelliJ IDEA, that includes `idea`, common app bundle paths, and the JetBrains Toolbox + shell script location at `~/Library/Application Support/JetBrains/Toolbox/scripts/idea`. +- If you manage IntelliJ via JetBrains Toolbox, using the Toolbox-generated `idea` script is the + most stable option. If you have not enabled Toolbox shell scripts, you can instead point + `command` directly at the app binary inside the `.app` bundle. +- The `c` shortcut opens an in-terminal `git diff` view for the selected repository and returns to + the TUI when you exit. + ## Workspaces *multicode* parallelizes work in **workspaces**. They are short-lived and isolated. Typically, a workspace is used for -a single issue report. A workspace gets its own working directory and OpenCode session, so you can work from a blank +a single issue report. A workspace gets its own working directory and agent session, so you can work from a blank slate. +The workspace root is configurable with `workspace-directory`. If you omit it, multicode defaults +to `~/dev/multicode-workspaces`. + +## Autonomous queueing + +When a workspace is assigned to a GitHub repository, *multicode* can scan for issues and queue multiple issue tasks in +that workspace. + +Queueing is controlled in `config.toml` with the `[autonomous]` section: + +```toml +[autonomous] +max-parallel-issues = 5 +issue-scan-delay-seconds = 900 +scan-on-startup = true +``` + +Notes: + +- `max-parallel-issues` controls how many issue tasks a workspace may queue at once. +- The default `max-parallel-issues` value is `5`. +- If a workspace already has that many queued tasks, it will not scan in additional issues until you remove, finish, + or otherwise clear some of the existing tasks. +- `issue-scan-delay-seconds` controls how often background issue scans are retried. The default is `900` seconds + (15 minutes). +- `scan-on-startup` controls whether an assigned workspace immediately begins autonomous issue scanning when it starts + with an empty queue. The default is `true`. +- Set `scan-on-startup = false` if you want to start a workspace without it auto-populating issues. In that mode you + can still queue work manually with `i` or use `n` to queue the next available issue on demand. + +## Agent configuration + +The agent used inside workspaces is configured globally in `config.toml` with the `[agent]` section. + +OpenCode remains the default: + +```toml +[agent] +provider = "opencode" + +# Backwards-compatible top-level command list. +opencode = ["opencode-cli", "opencode"] + +[agent.opencode] +commands = ["opencode-cli", "opencode"] +``` + +To use Codex instead: + +```toml +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" +``` + +Notes: + +- `provider` is global for the whole multicode instance. A single TUI session uses either OpenCode or Codex for all workspaces. +- `commands` is the host-side command resolution order. The first installed command is used. +- OpenCode keeps the existing top-level `opencode = [...]` setting for backwards compatibility. If `[agent.opencode].commands` is omitted, multicode falls back to that list. +- Codex workspaces use `codex app-server` inside the isolate/container and `codex resume --remote ...` when attaching from the TUI. +- `profile` is optional for Codex. If you set it, it must name a real profile from your host `~/.codex/config.toml`. +- For Codex, `approval-policy = "never"` suppresses approval prompts, `sandbox-mode = "workspace-write"` keeps Codex's own sandbox active, and `sandbox-mode = "external-sandbox"` tells Codex to trust the outer multicode sandbox such as the Apple container runtime. +- `network-access = "enabled"` is the practical setting for issue fixing workflows that need GitHub, dependency downloads, or web access. With `external-sandbox`, this is sent as Codex app-server `sandboxPolicy.networkAccess`. +- `runtime.image` is still the global image override. If you want separate images, use `runtime.opencode-image` and `runtime.codex-image`. + ## Isolation Workspaces are *isolated* from each other. This isolation is for safety and convenience, it **does not provide @@ -37,6 +148,182 @@ Isolation is implemented using `systemd-run` (for resource constraints) and [`bwrap`](https://github.com/containers/bubblewrap) (for read/write isolation). These tools are **Linux only**, so *multicode* will not work on other operating systems. +On newer Apple Silicon Macs, there is also an experimental Apple `container` runtime backend. It +reuses the existing `[isolation]` configuration for readable, writable, isolated, and `tmpfs` +paths, and maps CPU / memory limits onto container allocation settings. + +### macOS setup for Apple containers + +If you want to run *multicode* on macOS with `backend = "apple-container"`, the practical +requirements are: + +- An Apple Silicon Mac. The Apple container backend is intended for newer Apple Silicon systems. +- The Apple `container` CLI installed and working on the host. `multicode` shells out to + `container run`, `container exec`, `container list`, and `container build`. +- A working Rust toolchain on the host so you can run `cargo run --bin multicode-tui ...`. +- `tmux` on the host. The TUI uses it for interactive attach sessions. +- The host-side agent CLI installed for the provider you choose: + - OpenCode: `opencode-cli` or `opencode` + - Codex: `codex` +- A local Apple container image for the selected provider. +- GitHub authentication on the host if you want issue scanning, PR creation, builds, review + status, or authenticated git pushes. + +There are also image-level requirements. The container image must contain the tools the agent uses +inside the isolated workspace, not just on the host: + +- `git` +- `gh` +- the provider CLI you selected: + - OpenCode image: `opencode` + - Codex image: `codex` +- the language/toolchain your repositories need, for example Java / Gradle for Micronaut work + +This repository already contains an Apple-container build recipe. To build the local images: + +```bash +./apple-container/build-local.sh +``` + +That script expects the host `container` CLI to be available and produces: + +- `multicode-java25:latest` and `multicode-opencode-java25:latest` for OpenCode +- `multicode-codex-java25:latest` for Codex + +Host configuration also matters because Apple-container workspaces deliberately reuse some host +state: + +- OpenCode reuses host config from `~/.config/opencode` and authentication from + `~/.local/share/opencode/auth.json` when you mount them. +- Codex workspaces synthesize a per-workspace `CODEX_HOME` from the host `~/.codex` + configuration, including `config.toml`, `auth.json`, `AGENTS.md`, and `skills`. +- Git uses your host `~/.gitconfig`, which multicode exposes automatically inside Apple + containers via `GIT_CONFIG_GLOBAL`. +- If you want GitHub integration or MCP access inside the container, configure `[github]` and + pass through any required token env vars with `[isolation].inherit-env`. + +The minimum setup flow on macOS is therefore: + +1. Install and verify the Apple `container` CLI on the host. +2. Install Rust and `tmux` on the host. +3. Install the host agent CLI you want to use (`opencode` or `codex`). +4. Authenticate that agent on the host so the reused host config files actually contain valid + credentials. +5. Build an Apple container image, or provide your own compatible image. +6. Set `[runtime].backend = "apple-container"` and point `image`, `opencode-image`, or + `codex-image` at that image. +7. Configure `[isolation]` mounts for caches and credentials you want the workspace to reuse, such + as `~/.gradle`, `~/.m2/repository`, `~/.config/gh`, and provider-specific config paths. + +If `container` is missing, the image does not include the selected agent CLI, or the host does not +have the matching CLI for TUI attach, Apple-container workspaces will start or attach incorrectly. + +OpenCode example: + +```toml +[agent] +provider = "opencode" + +[runtime] +backend = "apple-container" +opencode-image = "ghcr.io/example/multicode-opencode-java25:latest" + +[isolation] +writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] +readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] +isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR"] +memory-max = "16 GiB" +cpu = "300%" +``` + +Mounting `~/.config/opencode` read-only lets the container see the same profiles, models, +skills, and other OpenCode configuration as the host. This is useful if you manage local +profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` +isolated so session state remains per-workspace. + +Codex example: + +```toml +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[runtime] +backend = "apple-container" +codex-image = "ghcr.io/example/multicode-codex-java25:latest" + +[isolation] +add-skills-from = ["./workspace-skills"] +writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] +inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR"] +memory-max = "16 GiB" +cpu = "300%" +``` + +For Codex, multicode prepares a synthetic per-workspace `CODEX_HOME` inside the isolate/container. +It copies the host `~/.codex/config.toml`, `~/.codex/auth.json`, `~/.codex/AGENTS.md`, and +`~/.codex/skills`, then merges in any `add-skills-from` mounts. This keeps Codex session state +isolated per workspace while still reusing your host configuration and credentials. + +If you want Codex to behave more autonomously inside an Apple container, prefer: + +```toml +[agent.codex] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" +``` + +That combination keeps multicode's Apple container as the real isolation boundary while avoiding repeated Codex approval prompts for normal shell execution. + +If you maintain two images, the practical split is: + +```toml +[runtime] +backend = "apple-container" +opencode-image = "ghcr.io/example/multicode-opencode-java25:latest" +codex-image = "ghcr.io/example/multicode-codex-java25:latest" +``` + +Use the OpenCode image for the existing OpenCode workflow and a Codex image that includes the `codex` CLI and any Codex-specific bootstrap you need. + +To build both local Apple-container images from this repository: + +```bash +./apple-container/build-local.sh +``` + +The Codex image build pins the installed Codex CLI to the version declared in +[`apple-container/Containerfile`](/Users/graemerocher/dev/micronaut/multicode/apple-container/Containerfile) +via `CODEX_VERSION` so container behavior stays reproducible across rebuilds. Update that build arg +when you intentionally want to move the image to a newer Codex release. + +You can also override the pinned version at build time without editing the file: + +```bash +CODEX_VERSION=0.120 ./apple-container/build-local.sh +``` + +That script produces: + +- `multicode-java25:latest` and `multicode-opencode-java25:latest` for the OpenCode workflow +- `multicode-codex-java25:latest` for the Codex workflow + +The split keeps the existing OpenCode image compatible while allowing the Codex image to install Codex-specific tooling without changing the OpenCode bootstrap path. + +Apple workspaces also expose the host `~/.gitconfig` automatically. The runtime mounts it through +an internal read-only path and sets `GIT_CONFIG_GLOBAL` so git can use your host global identity +and defaults without requiring a direct file bind. + ## Git / GitHub integration With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR @@ -69,30 +356,55 @@ Alternatively, you can add entries manually in the TUI, but this is tedious. ### Authentication To authenticate with GitHub, you need to configure a [personal access token (PAT)](https://github.com/settings/tokens) -(required scopes: `public_repo`, `read:user`). In `config.toml`, there are two approaches to configuring this token: +(required scopes: `public_repo`, `read:user`). In `config.toml`, there are three approaches to configuring this token: ```toml [github] +# Token from macOS Keychain +token = {keychain-service = "multicode.github", keychain-account = "github-mcp-token"} # Token from environment variable token = {env = "GITHUB_MCP_TOKEN"} # Token from command (GitHub CLI) token = {command = "gh auth token"} ``` -The env variable approach is recommended. It is prudent to use a token that has more limited access than the GitHub CLI. +On macOS, the Keychain approach is recommended because the token is not left in shell startup files or other plaintext +config. The env variable and command approaches remain available as fallbacks. It is prudent to use a token that has +more limited access than the GitHub CLI. + +To store the token in Keychain and configure multicode to use it: + +```bash +security add-generic-password -U \ + -a github-mcp-token \ + -s multicode.github \ + -w 'YOUR_GITHUB_PAT' +``` + +You can verify the stored token can be read: + +```bash +security find-generic-password -a github-mcp-token -s multicode.github -w +``` + +Then configure: + +```toml +[github] +token = {keychain-service = "multicode.github", keychain-account = "github-mcp-token"} +populate-git-credentials = true +``` To give agents access to GitHub, there are two options to set. `populate-git-credentials` will set environment variables that authenticate `git` instances inside the isolate with GitHub. This allows the agents to push changes to -repos they have cloned with HTTPS. The `inherit_env` option allows you to inherit the token from the environment, which -you can then configure the GitHub MCP server to use. This allows the agent to e.g. create pull requests from the -changes it has pushed. +repos they have cloned with HTTPS. It also provides `GH_TOKEN` and `GITHUB_TOKEN` inside the isolate for GitHub-aware +tools. The `inherit_env` option is only needed if you explicitly want to pass through additional host environment +variables; it is not required when using the Keychain-backed GitHub token source. ```toml [github] token = ... populate-git-credentials = true -[isolation] -inherit_env = [..., "GITHUB_MCP_TOKEN"] ``` ## Description @@ -113,8 +425,8 @@ be moved to the bottom of the UI. You can unarchive it again when needed. *multicode-remote* is a helper tool to run a multicode instance on a remote machine. Features: -* Dependency installation (bubblewrap, opencode, ...) -* Synchronization of local opencode configuration (including authentication details) +* Dependency installation (bubblewrap, opencode, codex, ...) +* Synchronization of local agent configuration (including authentication details) * Synchronization of GitHub credentials * Bi-directional synchronization of the agent workspace * Opening links in the local browser or git diff viewer diff --git a/apple-container/Containerfile b/apple-container/Containerfile new file mode 100644 index 0000000..2bf2656 --- /dev/null +++ b/apple-container/Containerfile @@ -0,0 +1,65 @@ +FROM node:22-bookworm-slim AS node + +FROM ghcr.io/graalvm/native-image-community:25 + +ARG HOST_UID=1000 +ARG HOST_GID=1000 +ARG GH_VERSION=2.83.2 +ARG CODEX_VERSION=0.120 +ARG INSTALL_OPENCODE=1 +ARG INSTALL_CODEX=0 + +COPY --from=node /usr/local/ /usr/local/ + +RUN set -eux; \ + microdnf install -y \ + bash \ + ca-certificates \ + curl \ + git \ + openssh-clients \ + procps-ng \ + rsync \ + shadow-utils \ + tar \ + tmux \ + unzip \ + xz \ + zstd; \ + microdnf clean all; \ + arch="$(uname -m)"; \ + case "${arch}" in \ + aarch64|arm64) gh_arch="arm64" ;; \ + x86_64|amd64) gh_arch="amd64" ;; \ + *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + -o /tmp/gh.tar.gz; \ + tar -xzf /tmp/gh.tar.gz -C /tmp; \ + install "/tmp/gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \ + rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${gh_arch}"; \ + if [ "${INSTALL_OPENCODE}" = "1" ]; then \ + npm install -g opencode-ai; \ + fi; \ + if [ "${INSTALL_CODEX}" = "1" ]; then \ + npm install -g "@openai/codex@${CODEX_VERSION}"; \ + fi; \ + if ! getent group "${HOST_GID}" >/dev/null; then \ + groupadd --gid "${HOST_GID}" multicode; \ + fi; \ + useradd \ + --uid "${HOST_UID}" \ + --gid "${HOST_GID}" \ + --create-home \ + --shell /bin/bash \ + multicode + +ENV HOME=/home/multicode +ENV USER=multicode +ENV PATH=/usr/local/bin:${PATH} + +USER multicode +WORKDIR /workspace +ENTRYPOINT [] + +CMD ["/bin/bash"] diff --git a/apple-container/build-local.sh b/apple-container/build-local.sh new file mode 100755 index 0000000..a299f7e --- /dev/null +++ b/apple-container/build-local.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +HOST_UID=$(id -u) +HOST_GID=$(id -g) +CODEX_VERSION=${CODEX_VERSION:-0.120} + +container build \ + -t multicode-java25:latest \ + -t multicode-opencode-java25:latest \ + -f "$SCRIPT_DIR/Containerfile" \ + --build-arg "HOST_UID=$HOST_UID" \ + --build-arg "HOST_GID=$HOST_GID" \ + --build-arg "INSTALL_OPENCODE=1" \ + --build-arg "INSTALL_CODEX=0" \ + "$SCRIPT_DIR" + +exec container build \ + -t multicode-codex-java25:latest \ + -f "$SCRIPT_DIR/Containerfile" \ + --build-arg "HOST_UID=$HOST_UID" \ + --build-arg "HOST_GID=$HOST_GID" \ + --build-arg "CODEX_VERSION=$CODEX_VERSION" \ + --build-arg "INSTALL_OPENCODE=0" \ + --build-arg "INSTALL_CODEX=1" \ + "$SCRIPT_DIR" diff --git a/config.codex.yml b/config.codex.yml new file mode 100644 index 0000000..6e92a7f --- /dev/null +++ b/config.codex.yml @@ -0,0 +1,58 @@ +workspace-directory = "~/dev/multicode-workspaces" +opencode = ["opencode-cli", "opencode"] + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[compare] +tool = "intellij" +command = "~/Library/Application Support/JetBrains/Toolbox/scripts/idea" + +[runtime] +backend = "apple-container" +codex-image = "multicode-codex-java25:latest" + +[github] +token = {keychain-service = "multicode.github", keychain-account = "github-mcp-token"} +populate-git-credentials = true + +[autonomous] +scan-on-startup = false + +[isolation] +add-skills-from = ["./workspace-skills"] +writable = [ + "~/.gradle", + "~/.m2/repository", + "~/.config/gh", + "$XDG_RUNTIME_DIR", +] +tmpfs = ["/tmp"] +inherit-env = [ + "XDG_RUNTIME_DIR", + "HOME", + "PATH", + "LANG", + "TERM", + "COLORTERM", +] +memory-high = "6 GiB" +memory-max = "8 GiB" +cpu = "300%" + +[handler] +review = "/usr/bin/open ." +review-pty = false +web = "/usr/bin/open {}" + +[[tool]] +type = "exec" +name = "bash" +key = "b" +exec = "/bin/bash" diff --git a/config.toml b/config.toml index e5d3792..ef55c40 100644 --- a/config.toml +++ b/config.toml @@ -1,10 +1,33 @@ -workspace-directory = "~/dev/agent-work" +workspace-directory = "~/dev/multicode-workspaces" opencode = ["opencode-cli", "opencode"] # todo: find a solution that isn't bound to TUI lifecycle +[agent] +provider = "opencode" + +[agent.codex] +commands = ["codex"] +# profile = "default" +# model = "gpt-5-codex" +# model-provider = "openai" +# approval-policy = "never" +# sandbox-mode = "external-sandbox" +# network-access = "enabled" + +[compare] +tool = "vscode" +# For JetBrains Toolbox users you can switch to IntelliJ IDEA instead: +# tool = "intellij" +# command = "~/Library/Application Support/JetBrains/Toolbox/scripts/idea" + +[runtime] +backend = "apple-container" +# Local Apple container image. It should contain Java 25, git, gh, opencode, and codex. +image = "multicode-java25:latest" + [github] #token = {command = "gh auth token"} -token = {env = "GITHUB_MCP_TOKEN"} +token = {keychain-service = "multicode.github", keychain-account = "github-mcp-token"} populate-git-credentials = true [isolation] @@ -30,6 +53,7 @@ isolated = [ "~/.local/state/opencode", ] readable = [ + "~/.config/opencode", "~/.local/share/opencode/auth.json", ] tmpfs = [ @@ -38,12 +62,11 @@ tmpfs = [ ] inherit-env = [ "XDG_RUNTIME_DIR", - "DISPLAY", "HOME", + "PATH", "LANG", "TERM", "COLORTERM", - "GITHUB_MCP_TOKEN", ] memory-high = "12 GiB" memory-max = "16 GiB" @@ -51,9 +74,9 @@ cpu = "300%" [handler] -review = "/usr/bin/smerge ." +review = "/usr/bin/open ." review-pty = false -web = "/usr/bin/firefox {}" +web = "/usr/bin/open {}" [[tool]] type = "exec" @@ -76,7 +99,6 @@ dereference-symlinks = true local = "~/.local/share/opencode/auth.json" remote = "~/.local/share/opencode/auth.json" dereference-symlinks = true - [[remote.sync-bidi]] local = "/var/tmp/multicode-remote-workspace" remote = "~/dev/agent-work" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 340c363..eb5b5b1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -6,7 +6,7 @@ build = "build.rs" license = "Apache-2.0" [dependencies] -tokio = { version = "1", features = ["sync", "fs", "rt", "time", "process", "net"] } +tokio = { version = "1", features = ["sync", "fs", "rt", "time", "process", "net", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" @@ -16,6 +16,8 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "str progenitor-client = "0.13" regress = "0.10" tokio-stream = "0.1" +futures-util = "0.3" +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } diesel = { version = "2", features = ["sqlite", "r2d2"] } diesel_migrations = "2" libsqlite3-sys = { version = "0", features = ["bundled"] } @@ -25,6 +27,7 @@ tracing = "0" tracing-subscriber = { version = "0", features = ["fmt", "ansi"] } shell-words = "1" size = "0" +base64 = "0.22" [build-dependencies] openapiv3 = "2" diff --git a/lib/build.rs b/lib/build.rs index c92c624..a237c84 100644 --- a/lib/build.rs +++ b/lib/build.rs @@ -1,8 +1,7 @@ use std::{env, fs, path::PathBuf}; use serde_json::{Map, Value}; -const OPENAPI_SPEC_URL: &str = - "https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json"; +const OPENAPI_SPEC_URL: &str = "https://raw.githubusercontent.com/anomalyco/opencode/c98f61638535c9cc57a2b710decc780f7289fc2f/packages/sdk/openapi.json"; const GENERATED_FILE_NAME: &str = "opencode_client.rs"; fn main() { diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f8b70b4..e8ce221 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -17,7 +17,7 @@ pub use remote_action::{ pub use services::root_session_service::RootSessionStatus; pub use services::workspace_archive::WorkspaceArchiveFormat; -use std::{fmt, sync::Arc, time::SystemTime}; +use std::{collections::BTreeMap, fmt, sync::Arc, time::SystemTime}; use serde::{Deserialize, Serialize}; @@ -61,6 +61,107 @@ impl Default for CustomLinksPersistentSnapshot { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum WorkspaceTaskSource { + #[default] + Manual, + Scan, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WorkspaceIssueType { + Bug, + Docs, + Enhancement, + Improvement, + Regression, + DependencyUpgrade, +} + +pub fn workspace_issue_type_glyph(issue_type: WorkspaceIssueType) -> &'static str { + match issue_type { + WorkspaceIssueType::Bug => "\u{f188}", + WorkspaceIssueType::Docs => "\u{f02d}", + WorkspaceIssueType::Enhancement => "\u{f135}", + WorkspaceIssueType::Improvement => "\u{f0ad}", + WorkspaceIssueType::Regression => "\u{f1da}", + WorkspaceIssueType::DependencyUpgrade => "\u{f1b2}", + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTaskPersistentSnapshot { + pub id: String, + pub issue_url: String, + #[serde(default)] + pub backing_pr_url: Option, + #[serde(default)] + pub dependency_upgrade_backing_pr: bool, + #[serde(default)] + pub issue_type: Option, + #[serde(default)] + pub issue_type_glyph: Option, + #[serde(default)] + pub source: WorkspaceTaskSource, + #[serde(default)] + pub created_at: Option, +} + +impl WorkspaceTaskPersistentSnapshot { + pub fn new(id: String, issue_url: String, source: WorkspaceTaskSource) -> Self { + Self { + id, + issue_url, + backing_pr_url: None, + dependency_upgrade_backing_pr: false, + issue_type: None, + issue_type_glyph: None, + source, + created_at: Some(SystemTime::now()), + } + } + + pub fn with_backing_pr_url(mut self, backing_pr_url: Option) -> Self { + self.backing_pr_url = backing_pr_url; + self + } + + pub fn with_dependency_upgrade_backing_pr( + mut self, + dependency_upgrade_backing_pr: bool, + ) -> Self { + self.dependency_upgrade_backing_pr = dependency_upgrade_backing_pr; + self + } + + pub fn with_issue_type(mut self, issue_type: Option) -> Self { + self.set_issue_type(issue_type); + self + } + + pub fn set_issue_type(&mut self, issue_type: Option) { + self.issue_type = issue_type; + self.issue_type_glyph = issue_type.map(|kind| workspace_issue_type_glyph(kind).to_string()); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorkspaceTaskRuntimeSnapshot { + pub session_id: Option, + pub session_status: Option, + pub agent_state: Option, + pub status: Option, + pub resume_prompt: Option, + pub usage_total_tokens: Option, + pub waiting_on_vm: bool, + pub repository: Vec, + pub issue: Vec, + pub pr: Vec, + pub last_error: Option, +} + /// Workspace metadata that is saved in persistent storage, i.e. survives a host reboot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersistentWorkspaceSnapshot { @@ -68,11 +169,21 @@ pub struct PersistentWorkspaceSnapshot { pub description: String, pub created_at: Option, #[serde(default)] + pub assigned_repository: Option, + #[serde(default)] + pub automation_issue: Option, + #[serde(default)] + pub automation_paused: bool, + #[serde(default)] pub archive_format: Option, #[serde(default)] pub agent_provided: AgentProvidedPersistentSnapshot, #[serde(default)] pub custom_links: CustomLinksPersistentSnapshot, + #[serde(default)] + pub ignored_issue_urls: Vec, + #[serde(default)] + pub tasks: Vec, } impl Default for PersistentWorkspaceSnapshot { @@ -81,19 +192,63 @@ impl Default for PersistentWorkspaceSnapshot { archived: false, description: String::new(), created_at: None, + assigned_repository: None, + automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: AgentProvidedPersistentSnapshot::default(), custom_links: CustomLinksPersistentSnapshot::default(), + ignored_issue_urls: Vec::new(), + tasks: Vec::new(), } } } /// Workspace metadata that is saved in transient storage (`/run`) and does not survive a reboot. /// This is useful for process metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeBackend { + #[default] + LinuxSystemdBwrap, + AppleContainer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHandleSnapshot { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default, alias = "unit")] + pub id: String, + #[serde(default)] + pub metadata: BTreeMap, +} + +impl Default for RuntimeHandleSnapshot { + fn default() -> Self { + Self { + backend: RuntimeBackend::default(), + id: String::new(), + metadata: BTreeMap::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransientWorkspaceSnapshot { pub uri: String, - pub unit: String, + #[serde(flatten)] + pub runtime: RuntimeHandleSnapshot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutomationAgentState { + Working, + WaitingOnVm, + Question, + Review, + Idle, + Stale, } /// Holder for the HTTP connection to the opencode server. @@ -119,6 +274,14 @@ pub struct WorkspaceSnapshot { pub root_session_id: Option, pub root_session_title: Option, pub root_session_status: Option, + pub automation_session_id: Option, + pub automation_session_status: Option, + pub automation_agent_state: Option, + pub automation_status: Option, + pub automation_scan_request_nonce: u64, + pub automation_queue_next_request_nonce: u64, + pub active_task_id: Option, + pub task_states: BTreeMap, pub usage_total_tokens: Option, pub usage_total_cost: Option, pub usage_cpu_percent: Option, @@ -135,6 +298,14 @@ impl Default for WorkspaceSnapshot { root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, + automation_status: None, + automation_scan_request_nonce: 0, + automation_queue_next_request_nonce: 0, + active_task_id: None, + task_states: BTreeMap::new(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -143,3 +314,112 @@ impl Default for WorkspaceSnapshot { } } } + +impl WorkspaceSnapshot { + pub fn task_persistent_snapshot( + &self, + task_id: &str, + ) -> Option<&WorkspaceTaskPersistentSnapshot> { + self.persistent.tasks.iter().find(|task| task.id == task_id) + } + + pub fn task_issue_url_for_id(&self, task_id: &str) -> Option<&str> { + self.task_persistent_snapshot(task_id) + .map(|task| task.issue_url.as_str()) + } + + pub fn resolved_active_task_id(&self) -> Option { + self.active_task_id + .as_deref() + .filter(|task_id| self.task_persistent_snapshot(task_id).is_some()) + .map(ToOwned::to_owned) + .or_else(|| { + self.persistent + .automation_issue + .as_deref() + .and_then(|issue_url| { + self.persistent + .tasks + .iter() + .find(|task| task.issue_url == issue_url) + .map(|task| task.id.clone()) + }) + }) + } + + pub fn resolved_active_issue_url(&self) -> Option { + self.resolved_active_task_id() + .as_deref() + .and_then(|task_id| self.task_issue_url_for_id(task_id)) + .map(ToOwned::to_owned) + .or_else(|| self.persistent.automation_issue.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_active_task_id_preserves_explicit_review_task_selection() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-7".to_string(), + "https://github.com/example/repo/issues/7".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-7".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/7".to_string()); + snapshot.task_states.insert( + "task-7".to_string(), + WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-7".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert_eq!( + snapshot.resolved_active_task_id().as_deref(), + Some("task-7") + ); + assert_eq!( + snapshot.resolved_active_issue_url().as_deref(), + Some("https://github.com/example/repo/issues/7") + ); + } + + #[test] + fn resolved_active_task_id_falls_back_to_claimed_issue_when_active_task_id_missing() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-13".to_string(), + "https://github.com/example/repo/issues/13".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/13".to_string()); + snapshot.task_states.insert( + "task-13".to_string(), + WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-13".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert_eq!( + snapshot.resolved_active_task_id().as_deref(), + Some("task-13") + ); + } +} diff --git a/lib/src/manager.rs b/lib/src/manager.rs index 7bf12e1..5398d81 100644 --- a/lib/src/manager.rs +++ b/lib/src/manager.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeSet, HashMap}, - sync::RwLock, + sync::{Arc, RwLock}, }; use tokio::sync::watch; @@ -15,24 +15,45 @@ pub enum WorkspaceManagerError { #[derive(Debug, Clone)] pub struct Workspace { - snapshot_tx: watch::Sender, + snapshot_tx: Arc>>>, } impl Workspace { pub fn new(snapshot: WorkspaceSnapshot) -> Self { let (snapshot_tx, _) = watch::channel(snapshot); - Self { snapshot_tx } + Self { + snapshot_tx: Arc::new(RwLock::new(Some(snapshot_tx))), + } } pub fn subscribe(&self) -> watch::Receiver { - self.snapshot_tx.subscribe() + self.snapshot_tx + .read() + .expect("workspace lock poisoned") + .as_ref() + .expect("workspace is closed") + .subscribe() } pub fn update(&self, updater: F) where F: FnOnce(&mut WorkspaceSnapshot) -> bool, { - let _ = self.snapshot_tx.send_if_modified(updater); + if let Some(snapshot_tx) = self + .snapshot_tx + .read() + .expect("workspace lock poisoned") + .as_ref() + { + let _ = snapshot_tx.send_if_modified(updater); + } + } + + pub fn close(&self) { + self.snapshot_tx + .write() + .expect("workspace lock poisoned") + .take(); } } @@ -96,6 +117,18 @@ impl WorkspaceManager { self.workspace_keys_tx.subscribe() } + pub fn remove(&self, key: &str) -> Result<(), WorkspaceManagerError> { + let workspace = self + .workspaces + .write() + .expect("workspace lock poisoned") + .remove(key) + .ok_or_else(|| WorkspaceManagerError::WorkspaceNotFound(key.to_string()))?; + workspace.close(); + self.publish_workspace_keys(); + Ok(()) + } + fn publish_workspace_keys(&self) { let keys = self .workspaces @@ -111,7 +144,10 @@ impl WorkspaceManager { #[cfg(test)] mod tests { use super::*; - use crate::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, + }; #[test] fn add_notifies_workspace_set_watch() { @@ -190,7 +226,11 @@ mod tests { snapshot.persistent.description = "incrementally updated".to_string(); snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), - unit: "run-u42.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u42.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -199,9 +239,33 @@ mod tests { let updated = workspace_rx.borrow_and_update().clone(); assert_eq!(updated.persistent.description, "incrementally updated"); assert_eq!( - updated.transient.as_ref().map(|t| t.unit.as_str()), + updated.transient.as_ref().map(|t| t.runtime.id.as_str()), Some("run-u42.service") ); assert!(!workspace_set_rx.has_changed().expect("watch still open")); } + + #[test] + fn remove_notifies_workspace_set_watch_and_closes_workspace() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let manager = WorkspaceManager::new(); + let mut workspace_set_rx = manager.subscribe(); + let mut workspace_rx = manager.add("alpha").expect("workspace should be added"); + let _ = workspace_set_rx.borrow_and_update(); + + manager + .remove("alpha") + .expect("workspace should be removed"); + + assert!(workspace_set_rx.has_changed().expect("watch still open")); + assert!(workspace_set_rx.borrow_and_update().is_empty()); + assert!(workspace_rx.changed().await.is_err()); + assert!(manager.get_workspace("alpha").is_err()); + }); + } } diff --git a/lib/src/services/automation_state_file_service.rs b/lib/src/services/automation_state_file_service.rs new file mode 100644 index 0000000..25b8508 --- /dev/null +++ b/lib/src/services/automation_state_file_service.rs @@ -0,0 +1,1051 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use tokio::time::{MissedTickBehavior, interval}; + +use super::{ + root_session_service::RootSessionStatus, + runtime::{ + automation_state_file_source, automation_task_state_dir_source, + automation_task_state_file_source, + }, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + AutomationAgentState, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, + manager::Workspace, +}; + +const STATE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug)] +pub enum AutomationStateFileServiceError { + Manager(WorkspaceManagerError), +} + +impl From for AutomationStateFileServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub async fn automation_state_file_service( + manager: Arc, + workspace_directory_path: PathBuf, +) -> Result<(), AutomationStateFileServiceError> { + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let workspace_directory_path = workspace_directory_path.clone(); + async move { + tokio::spawn(async move { + watch_workspace(workspace, workspace_rx, workspace_directory_path, key).await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace( + workspace: Workspace, + mut workspace_rx: tokio::sync::watch::Receiver, + workspace_directory_path: PathBuf, + workspace_key: String, +) { + let mut refresh = interval(STATE_REFRESH_INTERVAL); + refresh.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + let snapshot = workspace_rx.borrow().clone(); + let should_track = snapshot.transient.is_some() + && !snapshot.persistent.archived + && !snapshot.persistent.automation_paused + && active_task_id_for_snapshot(&snapshot).is_some(); + + if should_track { + let task_snapshots = + read_task_state_snapshots(&workspace_directory_path, &workspace_key, &snapshot) + .await; + apply_task_state_snapshots(&workspace, task_snapshots); + let updated_snapshot = workspace.subscribe().borrow().clone(); + mirror_task_state_files(&workspace_directory_path, &workspace_key, &updated_snapshot) + .await; + } else { + clear_automation_state(&workspace); + } + + tokio::select! { + changed = workspace_rx.changed() => { + if changed.is_err() { + break; + } + } + _ = refresh.tick() => {} + } + } +} + +async fn read_task_state_snapshots( + workspace_directory_path: &Path, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, +) -> Vec<(String, Option)> { + let mut states = Vec::with_capacity(snapshot.persistent.tasks.len()); + for task in &snapshot.persistent.tasks { + let path = + automation_task_state_file_source(workspace_directory_path, workspace_key, &task.id); + states.push((task.id.clone(), read_state_file(&path).await)); + } + + if states.iter().all(|(_, state)| state.is_none()) { + let legacy = read_state_file(&automation_state_file_source( + workspace_directory_path, + workspace_key, + )) + .await; + if legacy.is_some() { + states.push(("__legacy__".to_string(), legacy)); + } + } + + states +} + +fn apply_task_state_snapshots( + workspace: &Workspace, + task_snapshots: Vec<(String, Option)>, +) { + for (task_id, next) in task_snapshots { + if task_id == "__legacy__" { + apply_state_file_snapshot(workspace, None, next); + } else { + apply_state_file_snapshot(workspace, Some(task_id.as_str()), next); + } + } +} + +fn apply_state_file_snapshot( + workspace: &Workspace, + fixed_task_id: Option<&str>, + next: Option, +) { + workspace.update(|snapshot| { + let resolved_active_task_id = active_task_id_for_snapshot(snapshot); + let Some(target_task_id) = state_update_target_task_id( + fixed_task_id, + snapshot, + resolved_active_task_id.as_deref(), + next.as_ref(), + ) else { + return if next.is_some() { + false + } else { + clear_automation_state_snapshot(snapshot) + }; + }; + if next.is_none() + && expected_session_id_for_active_task(snapshot, &target_task_id).is_some() + { + return false; + } + let next_session_id = state_snapshot_session_id(next.as_ref()); + let next_agent_state = next.as_ref().map(|state| state.state); + let next_session_status = next.as_ref().map(|state| state.state.root_status()); + if let Some(next_session_id) = next_session_id.as_deref() + && let Some(existing_task_state) = snapshot.task_states.get(&target_task_id) + && existing_task_state.session_id.as_deref().is_some() + && existing_task_state.session_id.as_deref() != Some(next_session_id) + { + return false; + } + if let (Some(next_agent_state), Some(next_session_id)) = + (next_agent_state, next_session_id.as_deref()) + && let Some(existing_task_state) = snapshot.task_states.get(&target_task_id) + && existing_task_state.session_id.as_deref() == Some(next_session_id) + && matches!( + normalized_task_agent_state(existing_task_state), + Some( + AutomationAgentState::Review + | AutomationAgentState::Question + | AutomationAgentState::Idle + ) + ) + && next_agent_state == AutomationAgentState::Working + { + return false; + } + let is_active_target = resolved_active_task_id.as_deref() == Some(target_task_id.as_str()); + let should_update_bridge_state = resolved_active_task_id.as_deref() + == Some(target_task_id.as_str()) + || resolved_active_task_id.is_none(); + + let mut changed = false; + if should_update_bridge_state { + if snapshot.automation_session_id != next_session_id { + snapshot.automation_session_id = next_session_id.clone(); + changed = true; + } + if snapshot.automation_agent_state != next_agent_state { + snapshot.automation_agent_state = next_agent_state; + changed = true; + } + if snapshot.automation_session_status != next_session_status { + snapshot.automation_session_status = next_session_status; + changed = true; + } + if snapshot.active_task_id.is_none() + && snapshot.active_task_id.as_deref() != Some(target_task_id.as_str()) + { + snapshot.active_task_id = Some(target_task_id.clone()); + changed = true; + } + } + let task_state = snapshot.task_states.entry(target_task_id).or_default(); + if task_state.session_id != next_session_id { + task_state.session_id = next_session_id.clone(); + changed = true; + } + if task_state.agent_state != next_agent_state { + task_state.agent_state = next_agent_state; + changed = true; + } + if task_state.session_status != next_session_status { + task_state.session_status = next_session_status; + changed = true; + } + let should_wait = task_should_wait_on_vm(is_active_target, next_agent_state); + if task_state.waiting_on_vm != should_wait { + task_state.waiting_on_vm = should_wait; + changed = true; + } + changed + }); +} + +fn state_snapshot_session_id(next: Option<&ParsedAutomationState>) -> Option { + match next { + Some(state) => state.thread_id.clone(), + None => None, + } +} + +fn clear_automation_state(workspace: &Workspace) { + workspace.update(clear_automation_state_snapshot); +} + +fn clear_automation_state_snapshot(snapshot: &mut WorkspaceSnapshot) -> bool { + let mut changed = false; + let active_task_id = active_task_id_for_snapshot(snapshot); + for (task_id, task_state) in &mut snapshot.task_states { + if active_task_id.as_deref() == Some(task_id.as_str()) { + if task_state.session_id.take().is_some() { + changed = true; + } + if task_state.agent_state.take().is_some() { + changed = true; + } + if task_state.session_status.take().is_some() { + changed = true; + } + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + } + if snapshot.automation_session_id.take().is_some() { + changed = true; + } + if snapshot.automation_agent_state.take().is_some() { + changed = true; + } + if snapshot.automation_session_status.take().is_some() { + changed = true; + } + changed +} + +fn active_task_id_for_snapshot(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_task_id() +} + +fn task_should_wait_on_vm(is_active: bool, agent_state: Option) -> bool { + !is_active + && !matches!( + agent_state, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ) +} + +fn state_update_target_task_id( + fixed_task_id: Option<&str>, + snapshot: &WorkspaceSnapshot, + resolved_active_task_id: Option<&str>, + next: Option<&ParsedAutomationState>, +) -> Option { + if let Some(task_id) = fixed_task_id { + return Some(task_id.to_string()); + } + + if let Some(thread_id) = next.and_then(|state| state.thread_id.as_deref()) { + if let Some(task_id) = task_id_for_session_id(snapshot, thread_id) { + return Some(task_id); + } + if let Some(active_task_id) = resolved_active_task_id { + let expected_session_id = expected_session_id_for_active_task(snapshot, active_task_id); + if expected_session_id.is_none() || expected_session_id == Some(thread_id) { + return Some(active_task_id.to_string()); + } + } + return None; + } + + resolved_active_task_id.map(ToOwned::to_owned) +} + +fn expected_session_id_for_active_task<'a>( + snapshot: &'a WorkspaceSnapshot, + active_task_id: &str, +) -> Option<&'a str> { + snapshot + .task_states + .get(active_task_id) + .and_then(|task_state| task_state.session_id.as_deref()) + .or(snapshot.automation_session_id.as_deref()) +} + +fn task_id_for_session_id(snapshot: &WorkspaceSnapshot, session_id: &str) -> Option { + snapshot + .task_states + .iter() + .find_map(|(task_id, task_state)| { + (task_state.session_id.as_deref() == Some(session_id)).then(|| task_id.clone()) + }) +} + +async fn read_state_file(path: &Path) -> Option { + tokio::fs::metadata(path).await.ok()?; + let contents = tokio::fs::read_to_string(path).await.ok()?; + parse_state_file(&contents) +} + +async fn mirror_task_state_files( + workspace_directory_path: &Path, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, +) { + let task_dir = automation_task_state_dir_source(workspace_directory_path, workspace_key); + let _ = tokio::fs::create_dir_all(&task_dir).await; + + for task in &snapshot.persistent.tasks { + let Some(task_state) = snapshot.task_states.get(&task.id) else { + continue; + }; + let Some(session_id) = task_state.session_id.as_deref() else { + continue; + }; + let Some(agent_state) = normalized_task_agent_state(task_state) else { + continue; + }; + if agent_state == AutomationAgentState::WaitingOnVm { + continue; + } + let line = format!("{}:{session_id}\n", state_label(agent_state)); + let path = + automation_task_state_file_source(workspace_directory_path, workspace_key, &task.id); + let _ = tokio::fs::write(path, line).await; + } +} + +fn state_label(state: AutomationAgentState) -> &'static str { + match state { + AutomationAgentState::Working => "working", + AutomationAgentState::WaitingOnVm => "working", + AutomationAgentState::Question => "question", + AutomationAgentState::Review => "review", + AutomationAgentState::Idle => "idle", + AutomationAgentState::Stale => "stale", + } +} + +fn normalized_task_agent_state( + task_state: &crate::WorkspaceTaskRuntimeSnapshot, +) -> Option { + match task_state.session_status { + Some(RootSessionStatus::Question) => Some(AutomationAgentState::Question), + Some(RootSessionStatus::Idle) if task_state.session_id.is_some() => { + Some(AutomationAgentState::Review) + } + Some(RootSessionStatus::Idle) => Some(AutomationAgentState::Idle), + Some(RootSessionStatus::Busy) => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => Some(AutomationAgentState::Working), + other => other, + }, + None => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => None, + other => other, + }, + } +} + +fn parse_state_file(contents: &str) -> Option { + let trimmed = contents.trim(); + let (state, thread_id) = + trimmed + .split_once(':') + .map_or((trimmed, None), |(state, thread_id)| { + let thread_id = thread_id.trim(); + ( + state.trim(), + (!thread_id.is_empty()).then(|| thread_id.to_string()), + ) + }); + + let state = if state.eq_ignore_ascii_case("working") { + AutomationAgentState::Working + } else if state.eq_ignore_ascii_case("question") { + AutomationAgentState::Question + } else if state.eq_ignore_ascii_case("review") { + AutomationAgentState::Review + } else if state.eq_ignore_ascii_case("idle") { + AutomationAgentState::Idle + } else { + AutomationAgentState::Stale + }; + + Some(ParsedAutomationState { state, thread_id }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedAutomationState { + state: AutomationAgentState, + thread_id: Option, +} + +impl AutomationAgentState { + fn root_status(self) -> RootSessionStatus { + match self { + AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::WaitingOnVm => RootSessionStatus::Idle, + AutomationAgentState::Question => RootSessionStatus::Question, + AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale => RootSessionStatus::Idle, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource}; + + #[test] + fn parse_state_file_maps_known_states() { + let parsed = parse_state_file("question:thread-123\n").expect("state exists"); + + assert_eq!(parsed.state, AutomationAgentState::Question); + assert_eq!(parsed.thread_id.as_deref(), Some("thread-123")); + } + + #[test] + fn parse_state_file_marks_unknown_state_as_stale() { + let parsed = parse_state_file("bogus\n").expect("state exists"); + + assert_eq!(parsed.state, AutomationAgentState::Stale); + } + + #[test] + fn stale_state_snapshot_preserves_thread_id_for_recovery_and_attach() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-42"), + Some(ParsedAutomationState { + state: AutomationAgentState::Stale, + thread_id: Some("thread-stale".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should be present"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-stale")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Stale)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("thread-stale") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Stale) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Idle) + ); + } + + #[test] + fn active_task_id_falls_back_to_automation_issue_mapping() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + + assert_eq!( + active_task_id_for_snapshot(&snapshot).as_deref(), + Some("task-42") + ); + } + + #[test] + fn apply_state_file_snapshot_populates_task_state_for_fallback_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + true + }); + + apply_state_file_snapshot( + &workspace, + None, + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-42")); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should be created"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn apply_state_file_snapshot_ignores_explicit_mismatched_session_id() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-42".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + true + }); + + apply_state_file_snapshot( + &workspace, + None, + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-old".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn apply_state_file_snapshot_ignores_stale_non_working_state_for_different_session() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-new".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-new".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-42"), + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-old".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("thread-new") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-new")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn apply_state_file_snapshot_preserves_existing_session_when_state_file_missing() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-42".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot(&workspace, None, None); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn apply_state_file_snapshot_updates_matching_task_without_reassigning_active_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-30".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-30".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-30".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot( + &workspace, + None, + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-30")); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-30")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_42.waiting_on_vm); + let task_30 = snapshot + .task_states + .get("task-30") + .expect("task 30 should remain"); + assert_eq!(task_30.session_id.as_deref(), Some("thread-30")); + assert_eq!(task_30.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn apply_state_file_snapshot_keeps_non_active_working_task_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + None, + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Busy)); + assert!(task_42.waiting_on_vm); + } + + #[test] + fn apply_state_file_snapshot_targets_explicit_task_file_without_reassigning_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-42"), + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-30")); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_42.waiting_on_vm); + } + + #[test] + fn clear_automation_state_snapshot_clears_waiting_flags_for_all_tasks() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-30".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-30".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + waiting_on_vm: true, + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + waiting_on_vm: true, + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-30".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + + assert!(clear_automation_state_snapshot(&mut snapshot)); + + let active_task = snapshot + .task_states + .get("task-30") + .expect("active task should remain"); + assert!(active_task.session_id.is_none()); + assert!(active_task.agent_state.is_none()); + assert!(active_task.session_status.is_none()); + assert!(!active_task.waiting_on_vm); + + let background_task = snapshot + .task_states + .get("task-42") + .expect("background task should remain"); + assert_eq!(background_task.session_id.as_deref(), Some("thread-42")); + assert_eq!( + background_task.agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + background_task.session_status, + Some(RootSessionStatus::Busy) + ); + assert!(!background_task.waiting_on_vm); + assert!(snapshot.automation_session_id.is_none()); + assert!(snapshot.automation_agent_state.is_none()); + assert!(snapshot.automation_session_status.is_none()); + } + + #[test] + fn normalized_task_agent_state_prefers_review_when_idle_session_is_present() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); + } + + #[test] + fn apply_state_file_snapshot_ignores_stale_working_update_for_review_task_session() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-39".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/example/repo/issues/39".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-39".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-39"), + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-39".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-39") + .expect("task 39 should remain"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + } +} diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs new file mode 100644 index 0000000..9d44292 --- /dev/null +++ b/lib/src/services/autonomous_workspace_service.rs @@ -0,0 +1,8555 @@ +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, + time::{Duration, SystemTime}, +}; + +use diesel::{ + Connection, QueryableByName, RunQueryDsl, connection::SimpleConnection, sql_query, + sqlite::SqliteConnection, +}; +use serde::Deserialize; +use tokio::{ + process::Command, + sync::watch, + task::spawn_blocking, + time::{Instant, sleep, sleep_until}, +}; +use url::Url; +use uuid::Uuid; + +use super::{ + CombinedService, GithubStatus, + codex_app_server::CodexAppServerClient, + runtime::{ + automation_state_file_source, automation_task_state_file_source, + synthetic_codex_home_source, + }, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + AutomationAgentState, RootSessionStatus, WorkspaceIssueType, WorkspaceManagerError, + WorkspaceSnapshot, WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, manager::Workspace, + opencode, services::config::AgentProvider, workspace_issue_type_glyph, +}; + +const ISSUE_PRIORITY_LABELS: [&str; 4] = [ + "type: bug", + "type:docs", + "type: improvement", + "type: enhancement", +]; +const ISSUE_PRIORITY_BOOST_LABELS: [&str; 2] = ["type: regression", "priority: high"]; +const DEPENDENCY_UPGRADE_LABEL: &str = "type: dependency-upgrade"; +const NON_MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 4] = ["minor", "patch", "pin", "digest"]; +const MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 1] = ["major"]; +const RENOVATE_LOGINS: [&str; 2] = ["renovate[bot]", "app/renovate"]; +const DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = + "Dependency upgrade follow-up for Renovate batch"; +const SINGLE_DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = + "Dependency upgrade follow-up for Renovate PR"; +const DEPENDENCY_UPGRADE_PR_MARKER_PREFIX: &str = "", + marker_prefix = DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX + ) +} + +fn single_dependency_upgrade_issue_body(pr: &SelectedPullRequest) -> String { + format!( + "Track dependency-upgrade automation for Renovate pull request {pr_url}.\n\n\ +Current Renovate candidate:\n\ +- {pr_url} ({pr_title})\n\n\ +This issue was created automatically by multicode to prrocess the PR.\n\ +If the update is still a non-major version bump and CI is passing, rebase and merge the PR without waiting for human review, then close this issue.\n\n\ +{marker_prefix}{pr_url} -->", + pr_url = pr.url, + pr_title = pr.title, + marker_prefix = DEPENDENCY_UPGRADE_PR_MARKER_PREFIX + ) +} + +fn dependency_upgrade_pr_urls_from_pull_requests(prs: &[SelectedPullRequest]) -> Vec { + prs.iter().map(|pr| pr.url.clone()).collect() +} + +async fn assign_issue_to_me( + assigned_repository: &str, + issue: &SelectedIssue, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + &issue.url, + "--repo", + assigned_repository, + "--add-assignee", + "@me", + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {}: {err}", issue.url))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {}: {}", + issue.url, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +pub(crate) async fn clear_issue_claim_for_ignore( + service: &CombinedService, + assigned_repository: &str, + issue_url: &str, +) -> Result<(), String> { + let token = resolved_gh_token(service).await?; + let Some(issue) = fetch_issue(assigned_repository, issue_url, &token).await? else { + return Ok(()); + }; + + for label in IN_PROGRESS_LABEL_ALIASES { + if issue.has_label(label) { + remove_issue_label(assigned_repository, &issue.url, label, &token).await?; + } + } + + remove_issue_assignee(assigned_repository, &issue.url, "@me", &token).await +} + +async fn add_issue_label( + assigned_repository: &str, + issue: &SelectedIssue, + label: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + &issue.url, + "--repo", + assigned_repository, + "--add-label", + label, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {}: {err}", issue.url))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {}: {}", + issue.url, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +async fn edit_issue_title_and_body( + assigned_repository: &str, + issue_url: &str, + title: &str, + body: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--title", + title, + "--body", + body, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +async fn remove_issue_label( + assigned_repository: &str, + issue_url: &str, + label: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--remove-label", + label, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +async fn remove_issue_assignee( + assigned_repository: &str, + issue_url: &str, + assignee: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--remove-assignee", + assignee, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +fn apply_gh_env(command: &mut Command, token: &str) { + command.env("GH_TOKEN", token); + command.env("GITHUB_TOKEN", token); +} + +async fn resolved_gh_token(service: &CombinedService) -> Result { + service + .github_status_service() + .resolved_github_token() + .await + .map_err(|err| format!("failed to resolve GitHub token: {err}")) +} + +fn gh_program() -> String { + std::env::var("MULTICODE_GH_COMMAND").unwrap_or_else(|_| "gh".to_string()) +} + +pub(crate) fn normalize_github_repository_spec(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix("https://github.com/") { + return normalize_github_repository_path(rest); + } + if let Some(rest) = trimmed.strip_prefix("http://github.com/") { + return normalize_github_repository_path(rest); + } + normalize_github_repository_path(trimmed) +} + +fn normalize_github_repository_path(path: &str) -> Option { + let mut segments = path + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + if segments.len() < 2 { + return None; + } + let owner = segments.remove(0); + let mut repo = segments.remove(0).to_string(); + if let Some(stripped) = repo.strip_suffix(".git") { + repo = stripped.to_string(); + } + (!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}")) +} + +pub(crate) fn issue_reference(url: &str) -> Option { + let stripped = url.strip_prefix("https://github.com/")?; + let segments = stripped.split('/').collect::>(); + if segments.len() < 4 { + return None; + } + let owner = segments[0]; + let repo = segments[1]; + let number = segments[3]; + Some(format!("{owner}/{repo}#{number}")) +} + +fn pull_request_reference(url: &str) -> Option { + let stripped = url.strip_prefix("https://github.com/")?; + let segments = stripped.split('/').collect::>(); + if segments.len() < 4 || segments[2] != "pull" { + return None; + } + Some(format!("#{}", segments[3])) +} + +pub(crate) fn normalize_github_issue_spec( + assigned_repository: &str, + input: &str, +) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix('#') + && let Ok(number) = rest.parse::() + { + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Ok(number) = trimmed.parse::() { + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Some((repository, issue_number)) = trimmed.split_once('#') + && normalize_github_repository_spec(repository)? == assigned_repository + { + let number = issue_number.trim().parse::().ok()?; + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Some(rest) = trimmed.strip_prefix("https://github.com/") { + return normalize_github_issue_path(assigned_repository, rest); + } + if let Some(rest) = trimmed.strip_prefix("http://github.com/") { + return normalize_github_issue_path(assigned_repository, rest); + } + + normalize_github_issue_path(assigned_repository, trimmed) +} + +fn normalize_github_issue_path(assigned_repository: &str, path: &str) -> Option { + let segments = path + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + let [owner, repo, kind, number, ..] = segments.as_slice() else { + return None; + }; + if *kind != "issues" { + return None; + } + let repository = normalize_github_repository_spec(&format!("{owner}/{repo}"))?; + if repository != assigned_repository { + return None; + } + let number = number.parse::().ok()?; + Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )) +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedIssue { + number: u64, + title: String, + url: String, + #[serde(rename = "createdAt")] + created_at: String, + state: Option, + #[serde(rename = "isPullRequest")] + is_pull_request: Option, + #[serde(default)] + body: Option, + labels: Vec, + #[serde(skip)] + dependency_upgrade_pr_urls: Vec, +} + +impl SelectedIssue { + fn has_label(&self, label: &str) -> bool { + self.labels + .iter() + .any(|candidate| candidate.name.eq_ignore_ascii_case(label)) + } + + fn is_in_progress(&self) -> bool { + IN_PROGRESS_LABEL_ALIASES + .iter() + .any(|label| self.has_label(label)) + } + + fn requires_validation(&self) -> bool { + self.has_label(AWAITING_VALIDATION_LABEL) + } + + fn has_priority_boost_label(&self) -> bool { + ISSUE_PRIORITY_BOOST_LABELS + .iter() + .any(|label| self.has_label(label)) + } + + fn primary_priority_rank(&self) -> usize { + ISSUE_PRIORITY_LABELS + .iter() + .position(|label| self.has_label(label)) + .unwrap_or(ISSUE_PRIORITY_LABELS.len()) + } + + fn display_reference(&self) -> String { + issue_reference(&self.url).unwrap_or_else(|| format!("#{}", self.number)) + } + + fn is_open_issue_candidate(&self) -> bool { + matches!(self.state.as_deref(), Some("OPEN") | Some("open")) + && self.is_pull_request != Some(true) + } + + fn dependency_upgrade_pr_urls(&self) -> Vec { + if !self.dependency_upgrade_pr_urls.is_empty() { + return self.dependency_upgrade_pr_urls.clone(); + } + self.body + .as_deref() + .map(extract_dependency_upgrade_pr_urls) + .unwrap_or_default() + } + + fn legacy_dependency_upgrade_pr_url(&self) -> Option<&str> { + self.body + .as_deref() + .and_then(extract_dependency_upgrade_pr_marker) + } + + fn issue_type(&self) -> Option { + if self.has_label(DEPENDENCY_UPGRADE_LABEL) { + return Some(WorkspaceIssueType::DependencyUpgrade); + } + if REGRESSION_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Regression); + } + if BUG_ISSUE_LABELS.iter().any(|label| self.has_label(label)) { + return Some(WorkspaceIssueType::Bug); + } + if DOC_ISSUE_LABELS.iter().any(|label| self.has_label(label)) { + return Some(WorkspaceIssueType::Docs); + } + if IMPROVEMENT_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Improvement); + } + if ENHANCEMENT_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Enhancement); + } + None + } +} + +fn issue_priority_cmp(left: &SelectedIssue, right: &SelectedIssue) -> std::cmp::Ordering { + right + .has_priority_boost_label() + .cmp(&left.has_priority_boost_label()) + .then_with(|| { + left.primary_priority_rank() + .cmp(&right.primary_priority_rank()) + }) + .then_with(|| right.created_at.cmp(&left.created_at)) +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedIssueLabel { + name: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedPullRequest { + number: u64, + title: String, + url: String, + #[serde(rename = "createdAt")] + created_at: Option, + #[serde(rename = "headRefName")] + head_ref_name: Option, + state: Option, + #[serde(rename = "isDraft")] + is_draft: Option, + #[serde(default)] + body: Option, + #[serde(default)] + labels: Vec, + author: Option, +} + +impl SelectedPullRequest { + fn has_label(&self, label: &str) -> bool { + self.labels + .iter() + .any(|candidate| candidate.name.eq_ignore_ascii_case(label)) + } + + fn author_login(&self) -> Option<&str> { + self.author.as_ref().map(|author| author.login.as_str()) + } + + fn is_open_dependency_upgrade_candidate(&self) -> bool { + matches!(self.state.as_deref(), Some("OPEN") | Some("open")) + && !self.is_draft.unwrap_or(false) + && self.has_label(DEPENDENCY_UPGRADE_LABEL) + && self + .author_login() + .is_some_and(|login| RENOVATE_LOGINS.iter().any(|candidate| login == *candidate)) + } + + fn is_non_major_dependency_upgrade(&self) -> bool { + if MAJOR_DEPENDENCY_UPGRADE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return false; + } + if NON_MAJOR_DEPENDENCY_UPGRADE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return true; + } + dependency_upgrade_versions_from_text(&self.title) + .or_else(|| { + self.body + .as_deref() + .and_then(dependency_upgrade_versions_from_text) + }) + .is_some_and(|(from_major, to_major)| from_major == to_major) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedGithubActor { + login: String, +} + +fn extract_dependency_upgrade_pr_marker(body: &str) -> Option<&str> { + let marker_start = body.find(DEPENDENCY_UPGRADE_PR_MARKER_PREFIX)?; + let content_start = marker_start + DEPENDENCY_UPGRADE_PR_MARKER_PREFIX.len(); + let content_end = body[content_start..] + .find("-->") + .map(|index| content_start + index) + .unwrap_or(body.len()); + let value = body[content_start..content_end].trim(); + (!value.is_empty()).then_some(value) +} + +fn extract_dependency_upgrade_pr_urls(body: &str) -> Vec { + if let Some(marker_start) = body.find(DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX) { + let content_start = marker_start + DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX.len(); + let content_end = body[content_start..] + .find("-->") + .map(|index| content_start + index) + .unwrap_or(body.len()); + let value = body[content_start..content_end].trim(); + if let Ok(pr_urls) = serde_json::from_str::>(value) { + return pr_urls; + } + } + + extract_dependency_upgrade_pr_marker(body) + .map(|url| vec![url.to_string()]) + .unwrap_or_default() +} + +fn dependency_upgrade_versions_from_text(text: &str) -> Option<(u64, u64)> { + dependency_upgrade_versions_from_from_to_text(text) + .or_else(|| dependency_upgrade_versions_from_arrow_text(text)) +} + +fn dependency_upgrade_versions_from_from_to_text(text: &str) -> Option<(u64, u64)> { + let lower = text.to_ascii_lowercase(); + let from_index = lower.find(" from ")?; + let to_index = lower[from_index + 6..].find(" to ")? + from_index + 6; + let from_version = extract_leading_version(&text[from_index + 6..to_index])?; + let to_version = extract_leading_version(&text[to_index + 4..])?; + Some((from_version, to_version)) +} + +fn dependency_upgrade_versions_from_arrow_text(text: &str) -> Option<(u64, u64)> { + for line in text.lines() { + let Some((left, right)) = line + .split_once('→') + .or_else(|| line.split_once("->")) + .or_else(|| line.split_once("=>")) + else { + continue; + }; + let from_version = extract_trailing_version(left)?; + let to_version = extract_leading_version(right)?; + return Some((from_version, to_version)); + } + None +} + +fn extract_leading_version(text: &str) -> Option { + let token = version_token_candidates(text) + .find(|candidate| candidate.chars().any(|ch| ch.is_ascii_digit()))?; + let token = token + .trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.' && ch != '-') + .trim_start_matches(['v', 'V']); + let major = token + .split(['.', '-']) + .next() + .filter(|segment| !segment.is_empty())?; + major.parse::().ok() +} + +fn extract_trailing_version(text: &str) -> Option { + let token = version_token_candidates(text) + .filter(|candidate| candidate.chars().any(|ch| ch.is_ascii_digit())) + .last()?; + let token = token + .trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.' && ch != '-') + .trim_start_matches(['v', 'V']); + let major = token + .split(['.', '-']) + .next() + .filter(|segment| !segment.is_empty())?; + major.parse::().ok() +} + +fn version_token_candidates(text: &str) -> impl Iterator { + text.split_whitespace() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceSnapshot; + use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; + use crate::services::github_status_service::{ + GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, + }; + use crate::test_support::ENV_VAR_LOCK; + use std::{ + collections::HashMap, + fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use tokio::sync::watch; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("multicode-autonomous-tests-{unique}")); + fs::create_dir_all(&path).expect("temp dir should be created"); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, value: &std::path::Path) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(original) = &self.original { + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::set_var(&self.key, original); + } + } else { + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::remove_var(&self.key); + } + } + } + } + + fn make_executable(path: &std::path::Path) { + let mut permissions = fs::metadata(path) + .expect("metadata should exist") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions should be set"); + } + + #[test] + fn normalize_github_repository_spec_accepts_owner_repo_and_urls() { + assert_eq!( + normalize_github_repository_spec("micronaut-projects/micronaut-core"), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!( + normalize_github_repository_spec( + "https://github.com/micronaut-projects/micronaut-core" + ), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!( + normalize_github_repository_spec( + "https://github.com/micronaut-projects/micronaut-core.git/" + ), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!(normalize_github_repository_spec("invalid"), None); + } + + #[test] + fn normalize_github_issue_spec_accepts_numbers_refs_and_urls_for_assigned_repo() { + let repository = "micronaut-projects/micronaut-core"; + assert_eq!( + normalize_github_issue_spec(repository, "42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec(repository, "#42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec(repository, "micronaut-projects/micronaut-core#42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec( + repository, + "https://github.com/micronaut-projects/micronaut-core/issues/42", + ), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec( + repository, + "https://github.com/micronaut-projects/micronaut-test/issues/42", + ), + None + ); + } + + #[test] + fn start_retry_is_blocked_only_for_same_scan_nonce() { + assert!(start_retry_is_blocked(Some(4), 4)); + assert!(!start_retry_is_blocked(Some(4), 5)); + assert!(!start_retry_is_blocked(None, 4)); + } + + fn unique_test_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("multicode-{name}-{nonce}")) + } + + #[test] + fn recover_codex_thread_candidates_from_session_logs_prefers_latest_for_matching_cwd() { + let codex_home = unique_test_dir("codex-session-recovery"); + let sessions_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/13"); + fs::create_dir_all(&sessions_dir).expect("session dir should be created"); + + let cwd = "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-39"; + let older = sessions_dir.join("rollout-2026-04-13T07-41-03-019d85c9.jsonl"); + let newer = sessions_dir.join("rollout-2026-04-13T07-46-51-019d85ce.jsonl"); + let unrelated = sessions_dir.join("rollout-2026-04-13T07-50-00-019d85ff.jsonl"); + + fs::write( + &older, + format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d85c9\",\"cwd\":\"{cwd}\",\"timestamp\":\"2026-04-13T07:41:03.609Z\"}}}}\n" + ), + ) + .expect("older session log should be written"); + fs::write( + &newer, + format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d85ce\",\"cwd\":\"{cwd}\",\"timestamp\":\"2026-04-13T07:46:51.609Z\"}}}}\n" + ), + ) + .expect("newer session log should be written"); + fs::write( + &unrelated, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"019d85ff\",\"cwd\":\"/tmp/other\",\"timestamp\":\"2026-04-13T07:50:00.000Z\"}}\n", + ) + .expect("unrelated session log should be written"); + + let recovered = + recover_codex_thread_candidates_from_session_logs(&codex_home, &[cwd.to_string()]); + let recovered = recovered + .get(cwd) + .expect("matching cwd should be present") + .iter() + .cloned() + .reduce(|current, next| { + if should_prefer_recovered_candidate(Some(&next), Some(¤t)) { + next + } else { + current + } + }); + + assert_eq!( + recovered.as_ref().map(|candidate| candidate.id.as_str()), + Some("019d85ce") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn recover_latest_codex_task_thread_candidate_prefers_autonomous_worker_over_newer_observer() { + let codex_home = unique_test_dir("codex-task-session-recovery"); + let worker_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/16"); + let observer_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/17"); + fs::create_dir_all(&worker_dir).expect("worker session dir should be created"); + fs::create_dir_all(&observer_dir).expect("observer session dir should be created"); + + let cwd = "/Users/graemerocher/dev/multicode-workspaces/sql/work/micronaut-sql-815"; + let issue_url = "https://github.com/micronaut-projects/micronaut-sql/issues/815"; + let pr_url = "https://github.com/micronaut-projects/micronaut-sql/pull/1921"; + let worker = worker_dir.join("rollout-2026-04-16T18-45-42-019d979d.jsonl"); + let observer = observer_dir.join("rollout-2026-04-17T07-28-28-019d9a57.jsonl"); + + fs::write( + &worker, + format!( + concat!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d979d\",\"cwd\":\"{}\",\"timestamp\":\"2026-04-16T18:45:42.032Z\"}}}}\n", + "{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"You are operating in an autonomous multicode workspace for repository micronaut-projects/micronaut-sql.\\nStart work on GitHub issue {}.\\nFor this task, write autonomous state updates to `/Users/graemerocher/dev/multicode-workspaces/.multicode/automation/sql/tasks/task-815.state`.\\nThis issue already has a PR associated {}.\"}}]}}}}\n" + ), + cwd, + issue_url, + pr_url, + ), + ) + .expect("worker session log should be written"); + fs::write( + &observer, + format!( + concat!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d9a57\",\"cwd\":\"{}\",\"timestamp\":\"2026-04-17T07:28:28.902Z\"}}}}\n", + "{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"this issue already has a PR associated {} ensure that is communicated to multicode UI\"}}]}}}}\n" + ), + cwd, + pr_url, + ), + ) + .expect("observer session log should be written"); + + let recovered = recover_latest_codex_task_thread_candidate( + &codex_home, + &CodexTaskRecoveryDescriptor { + task_id: "task-815".to_string(), + cwd: cwd.to_string(), + issue_url: issue_url.to_string(), + backing_pr_url: Some(pr_url.to_string()), + }, + ); + + assert_eq!( + recovered.as_ref().map(|candidate| candidate.id.as_str()), + Some("019d979d") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn recover_codex_thread_candidates_from_state_db_reads_wal_snapshot() { + let codex_home = unique_test_dir("codex-state-recovery"); + fs::create_dir_all(&codex_home).expect("codex home should be created"); + let state_db_path = codex_home.join("state_2026-04-14.sqlite"); + let mut connection = SqliteConnection::establish(state_db_path.to_string_lossy().as_ref()) + .expect("state database should be created"); + connection + .batch_execute( + "PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE threads ( + id TEXT NOT NULL, + cwd TEXT NOT NULL, + updated_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0 + );", + ) + .expect("threads table should be created"); + connection + .batch_execute( + "INSERT INTO threads ( + id, + cwd, + updated_at, + created_at, + archived, + has_user_event + ) VALUES ( + 'thread-322', + '/tmp/multicode-codex-workspaces/json-schema/work/micronaut-json-schema-322', + 1713183212, + 1713183200, + 0, + 1 + );", + ) + .expect("thread row should be inserted"); + drop(connection); + + let cwd = "/tmp/multicode-codex-workspaces/json-schema/work/micronaut-json-schema-322" + .to_string(); + let recovered = + recover_codex_thread_candidates_from_state_db(&codex_home, std::slice::from_ref(&cwd)) + .expect("WAL-backed state database should be recoverable"); + + assert_eq!( + recovered + .get(cwd.as_str()) + .map(|candidate| candidate.id.as_str()), + Some("thread-322") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn snapshot_codex_state_db_reuses_cached_copy_when_source_is_unchanged() { + let codex_home = unique_test_dir("codex-state-snapshot-reuse"); + fs::create_dir_all(&codex_home).expect("codex home should be created"); + let state_db_path = codex_home.join("state_2026-04-15.sqlite"); + let mut connection = SqliteConnection::establish(state_db_path.to_string_lossy().as_ref()) + .expect("state database should be created"); + connection + .batch_execute( + "PRAGMA journal_mode = WAL; + CREATE TABLE threads ( + id TEXT NOT NULL, + cwd TEXT NOT NULL, + updated_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO threads ( + id, + cwd, + updated_at, + created_at, + archived, + has_user_event + ) VALUES ( + 'thread-1', + '/tmp/workspace-1', + 1713183212, + 1713183200, + 0, + 1 + );", + ) + .expect("state database should be populated"); + drop(connection); + + let first_snapshot = + snapshot_codex_state_db(&state_db_path).expect("first snapshot should succeed"); + let second_snapshot = + snapshot_codex_state_db(&state_db_path).expect("second snapshot should succeed"); + + assert_eq!(first_snapshot.database_path, second_snapshot.database_path); + + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn snapshot_codex_state_db_refreshes_cached_copy_when_source_changes() { + let codex_home = unique_test_dir("codex-state-snapshot-refresh"); + fs::create_dir_all(&codex_home).expect("codex home should be created"); + let state_db_path = codex_home.join("state_2026-04-15.sqlite"); + let mut connection = SqliteConnection::establish(state_db_path.to_string_lossy().as_ref()) + .expect("state database should be created"); + connection + .batch_execute( + "PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE threads ( + id TEXT NOT NULL, + cwd TEXT NOT NULL, + updated_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO threads ( + id, + cwd, + updated_at, + created_at, + archived, + has_user_event + ) VALUES ( + 'thread-1', + '/tmp/workspace-1', + 1713183212, + 1713183200, + 0, + 1 + );", + ) + .expect("state database should be populated"); + drop(connection); + + let first_snapshot = + snapshot_codex_state_db(&state_db_path).expect("first snapshot should succeed"); + + let mut connection = SqliteConnection::establish(state_db_path.to_string_lossy().as_ref()) + .expect("state database should reopen"); + connection + .batch_execute( + "INSERT INTO threads ( + id, + cwd, + updated_at, + created_at, + archived, + has_user_event + ) VALUES ( + 'thread-2', + '/tmp/workspace-2', + 1713183213, + 1713183201, + 0, + 1 + );", + ) + .expect("second thread row should be inserted"); + drop(connection); + + let second_snapshot = + snapshot_codex_state_db(&state_db_path).expect("second snapshot should succeed"); + + assert_ne!(first_snapshot.database_path, second_snapshot.database_path); + + let _ = fs::remove_dir_all(&codex_home); + } + + fn test_issue( + number: u64, + title: &str, + url: &str, + created_at: &str, + labels: Vec, + ) -> SelectedIssue { + SelectedIssue { + number, + title: title.to_string(), + url: url.to_string(), + created_at: created_at.to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + body: None, + labels, + dependency_upgrade_pr_urls: Vec::new(), + } + } + + fn test_pull_request( + number: u64, + title: &str, + url: &str, + labels: Vec, + body: Option<&str>, + ) -> SelectedPullRequest { + SelectedPullRequest { + number, + title: title.to_string(), + url: url.to_string(), + created_at: Some("2026-04-09T10:00:00Z".to_string()), + head_ref_name: None, + state: Some("OPEN".to_string()), + is_draft: Some(false), + body: body.map(ToOwned::to_owned), + labels, + author: Some(SelectedGithubActor { + login: "renovate[bot]".to_string(), + }), + } + } + + #[test] + fn find_next_issue_prioritizes_boost_labels_then_base_priority_then_newest() { + let excluded = HashSet::from(["https://github.com/example/repo/issue/5".to_string()]); + let mut issues = vec![ + test_issue( + 5, + "already claimed", + "https://github.com/example/repo/issue/5", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + ), + test_issue( + 6, + "busy", + "https://github.com/example/repo/issue/6", + "2026-04-09T11:00:00Z", + vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: IN_PROGRESS_LABEL.to_string(), + }, + ], + ), + test_issue( + 7, + "plain bug", + "https://github.com/example/repo/issue/7", + "2026-04-09T09:00:00Z", + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + ), + test_issue( + 8, + "high priority enhancement", + "https://github.com/example/repo/issue/8", + "2026-04-09T08:00:00Z", + vec![ + SelectedIssueLabel { + name: "type: enhancement".to_string(), + }, + SelectedIssueLabel { + name: "priority: high".to_string(), + }, + ], + ), + test_issue( + 9, + "regression bug", + "https://github.com/example/repo/issue/9", + "2026-04-09T07:00:00Z", + vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: "type: regression".to_string(), + }, + ], + ), + ]; + + issues.sort_by(issue_priority_cmp); + + let selected = issues + .into_iter() + .filter(|issue| !issue.is_in_progress()) + .filter(|issue| !excluded.contains(&issue.url)) + .next() + .expect("one issue should remain"); + + assert_eq!(selected.number, 9); + } + + #[test] + fn selected_issue_treats_hyphenated_in_progress_label_as_in_progress() { + let issue = test_issue( + 10, + "busy elsewhere", + "https://github.com/example/repo/issue/10", + "2026-04-09T12:00:00Z", + vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: "status: in-progress".to_string(), + }, + ], + ); + + assert!(issue.is_in_progress()); + } + + #[test] + fn selected_issue_detects_awaiting_validation_label() { + let issue = test_issue( + 12, + "needs validation", + "https://github.com/example/repo/issues/12", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: AWAITING_VALIDATION_LABEL.to_string(), + }], + ); + + assert!(issue.requires_validation()); + assert!(!should_assign_issue_to_current_user(&issue)); + } + + #[test] + fn selected_issue_derives_issue_type_from_labels() { + let regression = test_issue( + 14, + "regression", + "https://github.com/example/repo/issues/14", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: "type: regression".to_string(), + }], + ); + let docs = test_issue( + 15, + "docs", + "https://github.com/example/repo/issues/15", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: "type: docs".to_string(), + }], + ); + let dependency = test_issue( + 16, + "deps", + "https://github.com/example/repo/issues/16", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + + assert_eq!( + regression.issue_type(), + Some(WorkspaceIssueType::Regression) + ); + assert_eq!(docs.issue_type(), Some(WorkspaceIssueType::Docs)); + assert_eq!( + dependency.issue_type(), + Some(WorkspaceIssueType::DependencyUpgrade) + ); + } + + #[test] + fn select_validation_issue_candidate_skips_excluded_and_claimed_issues() { + let excluded = HashSet::from(["https://github.com/example/repo/issues/13".to_string()]); + let selected = select_validation_issue_candidate( + vec![ + test_issue( + 13, + "excluded validation issue", + "https://github.com/example/repo/issues/13", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: AWAITING_VALIDATION_LABEL.to_string(), + }], + ), + test_issue( + 14, + "already claimed validation issue", + "https://github.com/example/repo/issues/14", + "2026-04-09T13:00:00Z", + vec![ + SelectedIssueLabel { + name: AWAITING_VALIDATION_LABEL.to_string(), + }, + SelectedIssueLabel { + name: IN_PROGRESS_LABEL.to_string(), + }, + ], + ), + test_issue( + 15, + "fresh validation issue", + "https://github.com/example/repo/issues/15", + "2026-04-09T11:00:00Z", + vec![SelectedIssueLabel { + name: AWAITING_VALIDATION_LABEL.to_string(), + }], + ), + ], + &excluded, + ) + .expect("one validation issue should remain"); + + assert_eq!(selected.number, 15); + } + + #[test] + fn discover_issue_backing_pr_url_prefers_explicit_issue_reference() { + let issue = test_issue( + 322, + "Dependency Injection Fails", + "https://github.com/example/repo/issues/322", + "2026-04-09T10:00:00Z", + vec![], + ); + let mut branch_only = test_pull_request( + 17, + "Refactor configurer support", + "https://github.com/example/repo/pull/17", + vec![], + None, + ); + branch_only.head_ref_name = Some("fix-322-configurer".to_string()); + + let explicit = test_pull_request( + 18, + "Add regression coverage for issue #322", + "https://github.com/example/repo/pull/18", + vec![], + Some("## Summary\n\nResolves #322"), + ); + + let discovered = + discover_issue_backing_pr_url("example/repo", &issue, &[branch_only, explicit]); + + assert_eq!( + discovered.as_deref(), + Some("https://github.com/example/repo/pull/18") + ); + } + + #[test] + fn discover_issue_backing_pr_url_falls_back_to_branch_issue_number() { + let issue = test_issue( + 161, + "Generate plugins", + "https://github.com/example/repo/issues/161", + "2026-04-09T10:00:00Z", + vec![], + ); + let mut branch_match = test_pull_request( + 21, + "WIP plugin generation changes", + "https://github.com/example/repo/pull/21", + vec![], + None, + ); + branch_match.head_ref_name = Some("fix-161-plugin-generation".to_string()); + + let discovered = discover_issue_backing_pr_url("example/repo", &issue, &[branch_match]); + + assert_eq!( + discovered.as_deref(), + Some("https://github.com/example/repo/pull/21") + ); + } + + #[test] + fn discover_issue_backing_pr_url_does_not_match_partial_issue_reference() { + let issue = test_issue( + 7, + "Redis issue", + "https://github.com/example/repo/issues/7", + "2026-04-09T10:00:00Z", + vec![], + ); + let unrelated = test_pull_request( + 735, + "chore(deps): update softprops/action-gh-release action to v2.6.2", + "https://github.com/example/repo/pull/735", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + Some( + "This release fixes #705, #708, and #741 while discussing issue #764 in the changelog.", + ), + ); + + let discovered = discover_issue_backing_pr_url("example/repo", &issue, &[unrelated]); + + assert_eq!(discovered, None); + } + + #[test] + fn discover_issue_backing_pr_url_allows_single_digit_issue_branch_with_keyword() { + let issue = test_issue( + 7, + "Redis issue", + "https://github.com/example/repo/issues/7", + "2026-04-09T10:00:00Z", + vec![], + ); + let mut branch_match = test_pull_request( + 22, + "Fix redis issue", + "https://github.com/example/repo/pull/22", + vec![], + None, + ); + branch_match.head_ref_name = Some("issue-7-redis-timeout".to_string()); + + let discovered = discover_issue_backing_pr_url("example/repo", &issue, &[branch_match]); + + assert_eq!( + discovered.as_deref(), + Some("https://github.com/example/repo/pull/22") + ); + } + + #[test] + fn startup_issue_scan_can_be_deferred_until_manual_request() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + + assert!(should_defer_startup_issue_scan( + true, &snapshot, false, false + )); + assert!(!should_defer_startup_issue_scan( + true, &snapshot, true, false + )); + assert!(!should_defer_startup_issue_scan( + true, &snapshot, false, true + )); + + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Manual, + )); + assert!(!should_defer_startup_issue_scan( + true, &snapshot, false, false + )); + } + + #[test] + fn issue_number_from_url_extracts_issue_number() { + assert_eq!( + issue_number_from_url("https://github.com/example/repo/issues/322"), + Some(322) + ); + assert_eq!( + issue_number_from_url("https://github.com/example/repo/pull/322"), + None + ); + } + + #[test] + fn issue_priority_cmp_prefers_newer_issue_with_same_priority_bucket() { + let older = test_issue( + 10, + "older", + "https://github.com/example/repo/issues/10", + "2026-04-09T07:00:00Z", + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + ); + let newer = test_issue( + 11, + "newer", + "https://github.com/example/repo/issues/11", + "2026-04-09T08:00:00Z", + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + ); + + assert_eq!( + issue_priority_cmp(&older, &newer), + std::cmp::Ordering::Greater + ); + assert_eq!(issue_priority_cmp(&newer, &older), std::cmp::Ordering::Less); + } + + #[test] + fn issue_reference_formats_owner_repo_and_number() { + assert_eq!( + issue_reference("https://github.com/example/repo/issues/42"), + Some("example/repo#42".to_string()) + ); + assert_eq!( + issue_reference("https://github.com/example/repo/issue/43"), + Some("example/repo#43".to_string()) + ); + } + + #[test] + fn issue_search_args_excludes_linked_pull_requests() { + let args = issue_search_args("example/repo", "type: bug"); + assert!(args.iter().any(|arg| arg == "-linked:pr")); + assert!(args.windows(2).any(|pair| pair == ["--state", "open"])); + } + + #[test] + fn available_issue_scan_slots_stops_at_zero_when_queue_is_full() { + assert_eq!(available_issue_scan_slots(5, 0), 5); + assert_eq!(available_issue_scan_slots(5, 4), 1); + assert_eq!(available_issue_scan_slots(5, 5), 0); + assert_eq!(available_issue_scan_slots(5, 6), 0); + } + + #[test] + fn selected_issue_candidate_must_be_open_and_not_a_pull_request() { + let open_issue = test_issue( + 1, + "candidate", + "https://github.com/example/repo/issues/1", + "2026-04-09T10:00:00Z", + vec![], + ); + assert!(open_issue.is_open_issue_candidate()); + + let closed_issue = SelectedIssue { + state: Some("CLOSED".to_string()), + ..open_issue.clone() + }; + assert!(!closed_issue.is_open_issue_candidate()); + + let pull_request = SelectedIssue { + is_pull_request: Some(true), + ..open_issue + }; + assert!(!pull_request.is_open_issue_candidate()); + } + + #[test] + fn ensure_workspace_task_claim_updates_workspace_state() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + let issue = test_issue( + 810, + "candidate", + "https://github.com/example/repo/issues/810", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + ); + + ensure_workspace_task_claim( + &workspace, + "example/repo", + &issue, + None, + false, + WorkspaceTaskSource::Scan, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + assert!(snapshot.persistent.automation_issue.is_none()); + assert_eq!(snapshot.persistent.tasks.len(), 1); + assert_eq!(snapshot.persistent.tasks[0].issue_url, issue.url); + assert_eq!( + snapshot.persistent.tasks[0].source, + WorkspaceTaskSource::Scan + ); + assert_eq!( + snapshot.persistent.tasks[0].issue_type, + Some(WorkspaceIssueType::Bug) + ); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-810")); + } + + #[test] + fn refresh_existing_task_backing_pr_urls_backfills_missing_issue_type() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' '[]'\n exit 0\nfi\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[{\"name\":\"type: bug\"}]}'\n exit 0\nfi\nexit 1\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + )); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + let updated = refresh_existing_task_backing_pr_urls( + &workspace, + &snapshot, + "example/repo", + "test-token", + ) + .await + .expect("refresh should succeed"); + + assert_eq!(updated, 1); + let next = workspace.subscribe().borrow().clone(); + assert_eq!( + next.persistent.tasks[0].issue_type, + Some(WorkspaceIssueType::Bug) + ); + assert_eq!( + next.persistent.tasks[0].issue_type_glyph.as_deref(), + Some(workspace_issue_type_glyph(WorkspaceIssueType::Bug)) + ); + }); + } + + #[test] + fn refresh_existing_task_backing_pr_urls_backfills_missing_issue_glyph_without_fetching_issue() + { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' '[]'\n exit 0\nfi\nprintf '%s\\n' \"unexpected gh invocation: $*\" >&2\nexit 1\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + let mut task = WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + ); + task.issue_type = Some(WorkspaceIssueType::Bug); + snapshot.persistent.tasks.push(task); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + let updated = refresh_existing_task_backing_pr_urls( + &workspace, + &snapshot, + "example/repo", + "test-token", + ) + .await + .expect("refresh should succeed"); + + assert_eq!(updated, 1); + let next = workspace.subscribe().borrow().clone(); + assert_eq!( + next.persistent.tasks[0].issue_type_glyph.as_deref(), + Some(workspace_issue_type_glyph(WorkspaceIssueType::Bug)) + ); + }); + } + + #[test] + fn sync_task_runtime_state_prunes_stale_entries_and_derives_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-810".to_string(), + "https://github.com/example/repo/issues/810".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/810".to_string()); + snapshot.task_states.insert( + "task-stale".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + waiting_on_vm: true, + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("stale-session".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-810")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/810") + ); + assert!(!next.task_states.contains_key("task-stale")); + let task_state = next + .task_states + .get("task-810") + .expect("task state should exist"); + assert_eq!(task_state.agent_state, None); + assert!(!task_state.waiting_on_vm); + } + + #[test] + fn sync_task_runtime_state_clears_bridge_state_without_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-missing".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/999".to_string()); + snapshot.automation_session_id = Some("stale-session".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(next.active_task_id.is_none()); + assert!(next.persistent.automation_issue.is_none()); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + } + + #[test] + fn sync_task_runtime_state_preserves_live_non_yieldable_active_task_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-48".to_string(), + "https://github.com/example/repo/issues/48".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-48".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/48".to_string()); + snapshot.task_states.insert( + "task-48".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-48".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + let mut stale_snapshot = workspace.subscribe().borrow().clone(); + stale_snapshot.active_task_id = Some("task-42".to_string()); + stale_snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + + sync_task_runtime_state(&workspace, &stale_snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-48")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/48") + ); + } + + #[test] + fn sync_task_runtime_state_releases_yielded_active_task_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-48".to_string(), + "https://github.com/example/repo/issues/48".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-48".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/48".to_string()); + snapshot.task_states.insert( + "task-48".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-48".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-48".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Review); + snapshot.automation_session_status = Some(RootSessionStatus::Idle); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(next.active_task_id.is_none()); + assert!(next.persistent.automation_issue.is_none()); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + let task_state = next + .task_states + .get("task-48") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-48")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_state.waiting_on_vm); + } + + #[test] + fn set_paused_automation_state_is_idempotent() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + snapshot.active_task_id = Some("task-48".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-48".to_string(), + "https://github.com/example/repo/issues/48".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.automation_session_id = Some("thread-48".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + snapshot.task_states.insert( + "task-48".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-48".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + set_paused_automation_state(&workspace, "repo"); + let paused_once = workspace.subscribe().borrow().clone(); + set_paused_automation_state(&workspace, "repo"); + let paused_twice = workspace.subscribe().borrow().clone(); + + assert_eq!( + paused_once.automation_status, + paused_twice.automation_status + ); + assert_eq!( + paused_once.automation_session_id, + paused_twice.automation_session_id + ); + assert_eq!( + paused_once.automation_agent_state, + paused_twice.automation_agent_state + ); + assert_eq!( + paused_once.automation_session_status, + paused_twice.automation_session_status + ); + assert_eq!(paused_once.task_states, paused_twice.task_states); + assert_eq!( + paused_twice.automation_status.as_deref(), + Some("Paused repo") + ); + assert!(paused_twice.automation_session_id.is_none()); + assert!(paused_twice.automation_agent_state.is_none()); + assert!(paused_twice.automation_session_status.is_none()); + let task_state = paused_twice + .task_states + .get("task-48") + .expect("task state should remain"); + assert!(task_state.session_id.is_none()); + assert!(task_state.agent_state.is_none()); + assert!(task_state.session_status.is_none()); + assert!(!task_state.waiting_on_vm); + } + + #[test] + fn clear_automation_issue_claim_only_clears_matching_issue() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + let issue = test_issue( + 810, + "candidate", + "https://github.com/example/repo/issues/810", + "2026-04-09T10:00:00Z", + vec![], + ); + + ensure_workspace_task_claim( + &workspace, + "example/repo", + &issue, + None, + false, + WorkspaceTaskSource::Scan, + ); + clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/999"); + let unchanged = workspace.subscribe().borrow().clone(); + assert_eq!( + unchanged.persistent.automation_issue.as_deref(), + Some(issue.url.as_str()) + ); + + clear_automation_issue_claim(&workspace, &issue.url); + let cleared = workspace.subscribe().borrow().clone(); + assert!(cleared.persistent.automation_issue.is_none()); + assert!(cleared.persistent.tasks.is_empty()); + assert_eq!( + cleared.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + } + + #[test] + fn task_session_id_is_current_rejects_stale_session_id() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.task_states.insert( + "task-39".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-new".to_string()), + ..Default::default() + }, + ); + true + }); + + assert!(task_session_id_is_current( + &workspace, + "task-39", + "thread-new" + )); + assert!(!task_session_id_is_current( + &workspace, + "task-39", + "thread-old" + )); + } + + #[test] + fn clear_automation_issue_claim_promotes_next_task_to_active_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-810".to_string(), + "https://github.com/example/repo/issues/810".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-811".to_string(), + "https://github.com/example/repo/issues/811".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-810".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/810".to_string()); + snapshot.task_states.insert( + "task-810".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-810".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/810"); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-811")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/811") + ); + assert!(!next.task_states.contains_key("task-810")); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + } + + #[test] + fn issue_progress_status_uses_explicit_automation_states() { + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Working), + ), + "Working example/repo#42" + ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::WaitingOnVm), + ), + "Waiting on VM example/repo#42" + ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Review), + ), + "Review example/repo#42" + ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Idle), + ), + "Wait close example/repo#42" + ); + } + + #[test] + fn sync_task_runtime_state_marks_only_blocked_non_active_tasks_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-3".to_string(), + "https://github.com/example/repo/issues/3".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-4".to_string(), + "https://github.com/example/repo/issues/4".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-3".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-4".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!( + !next + .task_states + .get("task-1") + .expect("active task should exist") + .waiting_on_vm + ); + assert!( + !next + .task_states + .get("task-2") + .expect("question task should exist") + .waiting_on_vm + ); + assert!( + !next + .task_states + .get("task-3") + .expect("idle task should exist") + .waiting_on_vm + ); + let waiting_task = next + .task_states + .get("task-4") + .expect("blocked task should exist"); + assert!(waiting_task.waiting_on_vm); + assert_eq!(waiting_task.agent_state, None); + } + + #[test] + fn sync_task_runtime_state_relabels_blocked_working_task_as_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-2".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + let task_state = next + .task_states + .get("task-2") + .expect("blocked task should exist"); + assert!(task_state.waiting_on_vm); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn sync_task_runtime_state_prefers_claimed_issue_over_stale_unpreserved_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/2".to_string()); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-2")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/2") + ); + } + + #[test] + fn sync_task_runtime_state_updates_bridge_state_from_normalized_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/1".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(next.active_task_id.is_none()); + assert_eq!(next.automation_agent_state, None); + assert_eq!(next.automation_session_status, None); + } + + #[test] + fn closed_background_task_issue_urls_only_returns_closed_non_active_tasks() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-26".to_string(), + "https://github.com/example/repo/issues/26".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-16".to_string(), + "https://github.com/example/repo/issues/16".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-14".to_string(), + "https://github.com/example/repo/issues/14".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-16".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/16".to_string()); + + let (closed_sender, closed_rx) = watch::channel(Some(GithubStatus::Issue( + super::super::github_status_service::GithubIssueStatus { + state: super::super::github_status_service::GithubIssueState::Closed, + fetched_at: std::time::SystemTime::now(), + }, + ))); + let (_open_sender, open_rx) = watch::channel(Some(GithubStatus::Issue( + super::super::github_status_service::GithubIssueStatus { + state: super::super::github_status_service::GithubIssueState::Open, + fetched_at: std::time::SystemTime::now(), + }, + ))); + let mut task_issue_status_rxs = HashMap::new(); + task_issue_status_rxs.insert( + "https://github.com/example/repo/issues/26".to_string(), + closed_rx, + ); + task_issue_status_rxs.insert( + "https://github.com/example/repo/issues/16".to_string(), + open_rx, + ); + drop(closed_sender); + + assert_eq!( + closed_background_task_issue_urls( + &snapshot, + Some("https://github.com/example/repo/issues/16"), + &task_issue_status_rxs + ), + vec!["https://github.com/example/repo/issues/26".to_string()] + ); + } + + #[test] + fn next_schedulable_task_issue_url_skips_tasks_with_live_or_terminal_agent_states() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-3".to_string(), + "https://github.com/example/repo/issues/3".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-4".to_string(), + "https://github.com/example/repo/issues/4".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-3".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert_eq!( + next_schedulable_task_issue_url(&snapshot, None).as_deref(), + Some("https://github.com/example/repo/issues/4") + ); + assert_eq!( + next_schedulable_task_issue_url( + &snapshot, + Some("https://github.com/example/repo/issues/4") + ), + None + ); + } + + #[test] + fn next_schedulable_task_issue_url_includes_waiting_on_vm_task_with_working_state() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + session_id: Some("thread-1".to_string()), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + session_id: Some("thread-2".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + + assert_eq!( + next_schedulable_task_issue_url(&snapshot, None).as_deref(), + Some("https://github.com/example/repo/issues/2") + ); + } + + #[test] + fn task_can_yield_vm_only_for_non_working_states() { + assert!(!task_can_yield_vm(Some(AutomationAgentState::Working))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Question))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Review))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Idle))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Stale))); + assert!(!task_can_yield_vm(None)); + } + + #[test] + fn active_task_can_yield_vm_requires_existing_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: None, + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + + assert!(!active_task_can_yield_vm(&snapshot)); + + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn active_task_can_yield_vm_requires_explicit_task_state() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Idle); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: None, + ..Default::default() + }, + ); + + assert!(!active_task_can_yield_vm(&snapshot)); + + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn set_automation_runtime_state_preserves_existing_task_session() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-5".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + set_automation_runtime_state( + &workspace, + Some("root-session".to_string()), + Some(RootSessionStatus::Idle), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("root-session") + ); + let task_state = snapshot + .task_states + .get("task-5") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("task-session-5")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn set_automation_runtime_state_updates_matching_task_session_to_working() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-7".to_string(), + "https://github.com/example/repo/issues/7".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-7".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/7".to_string()); + snapshot.task_states.insert( + "task-7".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-7".to_string()), + ..Default::default() + }, + ); + true + }); + + set_automation_runtime_state( + &workspace, + Some("task-session-7".to_string()), + Some(RootSessionStatus::Busy), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("task-session-7") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-7") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("task-session-7")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn codex_task_thread_status_maps_idle_to_review() { + assert_eq!( + codex_task_thread_status_to_agent_state(&CodexThreadStatus::Idle, None), + AutomationAgentState::Review + ); + assert_eq!( + agent_state_root_status(AutomationAgentState::Review), + RootSessionStatus::Idle + ); + } + + #[test] + fn codex_task_thread_status_maps_waiting_flags_to_question() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert_eq!( + codex_task_thread_status_to_agent_state(&status, None), + AutomationAgentState::Question + ); + assert_eq!( + agent_state_root_status(AutomationAgentState::Question), + RootSessionStatus::Question + ); + } + + #[test] + fn codex_task_thread_status_preserves_review_for_ambiguous_active_state() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + + assert_eq!( + codex_task_thread_status_to_agent_state(&status, Some(AutomationAgentState::Review)), + AutomationAgentState::Working + ); + assert_eq!( + codex_task_thread_status_to_agent_state(&status, Some(AutomationAgentState::Question)), + AutomationAgentState::Working + ); + } + + #[test] + fn set_task_runtime_state_from_codex_marks_reviewing_task_yieldable() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-7".to_string(), + "https://github.com/example/repo/issues/7".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-7".to_string()); + snapshot.task_states.insert( + "task-7".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-7".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-7", + "task-session-7", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + issues: vec!["https://github.com/example/repo/issues/7".to_string()], + prs: vec!["https://github.com/example/repo/pull/11".to_string()], + ..Default::default() + }), + Some(54_611), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("task-session-7") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Review) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Idle) + ); + let task_state = snapshot + .task_states + .get("task-7") + .expect("task state should remain"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + assert_eq!( + task_state.issue, + vec!["https://github.com/example/repo/issues/7".to_string()] + ); + assert_eq!( + task_state.pr, + vec!["https://github.com/example/repo/pull/11".to_string()] + ); + assert_eq!(task_state.usage_total_tokens, Some(54_611)); + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn set_task_runtime_state_from_codex_keeps_blocked_task_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-2".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-2", + "task-session-2", + RootSessionStatus::Busy, + AutomationAgentState::Working, + None, + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-2") + .expect("blocked task should exist"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + assert!(task_state.waiting_on_vm); + } + + #[test] + fn set_task_runtime_state_from_codex_sets_pr_created_status_for_review_tasks() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-33".to_string()); + snapshot.task_states.insert( + "task-33".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + status: Some("Resuming in background".to_string()), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-33", + "task-session-33", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + prs: vec!["https://github.com/example/repo/pull/56".to_string()], + ..Default::default() + }), + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-33") + .expect("task state should exist"); + assert_eq!(task_state.status.as_deref(), Some("PR created #56")); + } + + #[test] + fn set_task_runtime_state_from_codex_filters_foreign_metadata_prs() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.persistent.assigned_repository = + Some("micronaut-projects/micronaut-redis".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-733".to_string(), + "https://github.com/micronaut-projects/micronaut-redis/issues/733".to_string(), + WorkspaceTaskSource::Scan, + )); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-733", + "task-session-733", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + issues: vec![ + "https://github.com/micronaut-projects/micronaut-redis/issues/733".to_string(), + "https://github.com/example/repo/issues/733".to_string(), + ], + prs: vec![ + "https://github.com/example/repo/pull/338".to_string(), + "https://github.com/micronaut-projects/micronaut-redis/pull/733".to_string(), + ], + ..Default::default() + }), + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-733") + .expect("task state should exist"); + assert_eq!( + task_state.issue, + vec!["https://github.com/micronaut-projects/micronaut-redis/issues/733".to_string()] + ); + assert_eq!( + task_state.pr, + vec!["https://github.com/micronaut-projects/micronaut-redis/pull/733".to_string()] + ); + let task = snapshot + .persistent + .tasks + .iter() + .find(|task| task.id == "task-733") + .expect("task should exist"); + assert_eq!( + task.backing_pr_url.as_deref(), + Some("https://github.com/micronaut-projects/micronaut-redis/pull/733") + ); + } + + #[test] + fn should_refresh_task_links_after_codex_review_detects_resumed_background_completion() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + status: Some("Resuming in background".to_string()), + ..Default::default() + }; + + assert!(should_refresh_task_links_after_codex_review( + &task_state, + RootSessionStatus::Idle, + AutomationAgentState::Review + )); + } + + #[test] + fn should_refresh_task_links_after_codex_review_ignores_stable_review_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + status: Some("PR created #56".to_string()), + ..Default::default() + }; + + assert!(!should_refresh_task_links_after_codex_review( + &task_state, + RootSessionStatus::Idle, + AutomationAgentState::Review + )); + } + + #[test] + fn task_link_refresh_urls_collects_issue_and_prs_without_duplicates() { + let task = WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/56".to_string())); + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + pr: vec![ + "https://github.com/example/repo/pull/56".to_string(), + "https://github.com/example/repo/pull/57".to_string(), + ], + ..Default::default() + }; + let metadata = CodexTaskMetadata { + prs: vec![ + "https://github.com/example/repo/pull/57".to_string(), + "https://github.com/example/repo/pull/58".to_string(), + ], + ..Default::default() + }; + + assert_eq!( + task_link_refresh_urls(&task, &task_state, &metadata), + vec![ + "https://github.com/example/repo/issues/33".to_string(), + "https://github.com/example/repo/pull/57".to_string(), + "https://github.com/example/repo/pull/58".to_string(), + "https://github.com/example/repo/pull/56".to_string(), + ] + ); + } + + #[test] + fn codex_task_status_text_falls_back_to_persisted_backing_pr_url() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push( + WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/56".to_string())), + ); + + assert_eq!( + codex_task_status_text(AutomationAgentState::Review, None, "task-33", &snapshot) + .as_deref(), + Some("PR created #56") + ); + } + + #[test] + fn set_task_runtime_state_from_codex_clears_resuming_status_once_task_is_working() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-33".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + status: Some("Resuming in background".to_string()), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-33", + "task-session-33", + RootSessionStatus::Busy, + AutomationAgentState::Working, + Some(CodexTaskMetadata::default()), + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-33") + .expect("task state should exist"); + assert_eq!(task_state.status, None); + } + + #[test] + fn unloaded_codex_task_runtime_preserves_question_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }; + + assert_eq!( + unloaded_codex_task_runtime(&task_state, false), + (RootSessionStatus::Question, AutomationAgentState::Question) + ); + } + + #[test] + fn unloaded_codex_task_runtime_marks_interrupted_working_task_as_stale() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + + assert_eq!( + unloaded_codex_task_runtime(&task_state, false), + (RootSessionStatus::Idle, AutomationAgentState::Stale) + ); + } + + #[test] + fn codex_task_metadata_from_turns_extracts_issue_and_pr_tags() { + let turns = vec![crate::services::codex_app_server::CodexThreadTurn { + items: vec![serde_json::json!({ + "type": "agentMessage", + "text": "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1\nhttps://github.com/graemerocher/multicode-test/issues/1\nhttps://github.com/graemerocher/multicode-test/pull/8" + })], + }]; + + let metadata = codex_task_metadata_from_turns( + &turns, + "https://github.com/graemerocher/multicode-test/issues/1", + ); + + assert_eq!( + metadata.repositories, + vec!["/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1".to_string()] + ); + assert_eq!( + metadata.issues, + vec!["https://github.com/graemerocher/multicode-test/issues/1".to_string()] + ); + assert_eq!( + metadata.prs, + vec!["https://github.com/graemerocher/multicode-test/pull/8".to_string()] + ); + } + + #[test] + fn codex_task_metadata_from_turns_extracts_pr_tag_from_tool_output() { + let turns = vec![crate::services::codex_app_server::CodexThreadTurn { + items: vec![serde_json::json!({ + "type": "toolCallOutput", + "output": "Chunk ID: c4018f\nhttps://github.com/graemerocher/multicode-test/pull/338\n" + })], + }]; + + let metadata = codex_task_metadata_from_turns( + &turns, + "https://github.com/graemerocher/multicode-test/issues/322", + ); + + assert_eq!( + metadata.issues, + vec!["https://github.com/graemerocher/multicode-test/issues/322".to_string()] + ); + assert_eq!( + metadata.prs, + vec!["https://github.com/graemerocher/multicode-test/pull/338".to_string()] + ); + } + + #[test] + fn set_task_runtime_state_from_codex_persists_detected_pr_on_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-322".to_string(), + "https://github.com/example/repo/issues/322".to_string(), + WorkspaceTaskSource::Scan, + )); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-322", + "task-session-322", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + prs: vec!["https://github.com/example/repo/pull/338".to_string()], + ..Default::default() + }), + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task = snapshot + .persistent + .tasks + .iter() + .find(|task| task.id == "task-322") + .expect("task should exist"); + assert_eq!( + task.backing_pr_url.as_deref(), + Some("https://github.com/example/repo/pull/338") + ); + } + + #[test] + fn codex_usage_total_tokens_from_session_log_contents_reads_latest_token_count() { + let contents = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":123}}}} +{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":456789}}}} +"#; + + assert_eq!( + codex_usage_total_tokens_from_session_log_contents(contents), + Some(456_789) + ); + } + + #[test] + fn non_active_codex_working_task_keeps_working_state_for_runtime_tracking() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + let task_state = crate::WorkspaceTaskRuntimeSnapshot::default(); + assert_eq!( + codex_runtime_state_for_task(&status, &task_state), + (RootSessionStatus::Busy, AutomationAgentState::Working) + ); + } + + #[test] + fn active_codex_thread_does_not_preserve_review_state() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }; + + assert_eq!( + codex_runtime_state_for_task(&status, &task_state), + (RootSessionStatus::Busy, AutomationAgentState::Working) + ); + } + + #[test] + fn unloaded_codex_status_preserves_review_runtime_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }; + + let runtime = if matches!(CodexThreadStatus::NotLoaded, CodexThreadStatus::NotLoaded) { + unloaded_codex_task_runtime(&task_state, false) + } else { + unreachable!() + }; + + assert_eq!( + runtime, + (RootSessionStatus::Idle, AutomationAgentState::Review) + ); + } + + #[test] + fn normalized_task_agent_state_recovers_legacy_waiting_on_vm_review_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-8".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::WaitingOnVm), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); + } + + #[test] + fn normalized_task_agent_state_prefers_idle_session_status_over_stale_working_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-9".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); + } + + #[test] + fn unloaded_codex_status_preserves_review_for_pr_backed_working_task() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }; + + let runtime = if matches!(CodexThreadStatus::NotLoaded, CodexThreadStatus::NotLoaded) { + unloaded_codex_task_runtime(&task_state, true) + } else { + unreachable!() + }; + + assert_eq!( + runtime, + (RootSessionStatus::Idle, AutomationAgentState::Review) + ); + } + + #[test] + fn should_start_assigned_issue_work_when_active_task_has_no_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Idle); + snapshot.automation_session_status = Some(RootSessionStatus::Idle); + + assert!(should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + } + + #[test] + fn should_not_start_assigned_issue_work_when_active_task_already_has_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.task_states.insert( + "task-6".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-6".to_string()), + ..Default::default() + }, + ); + + assert!(!should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + assert!(!should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Busy + )); + } + + #[test] + fn should_start_assigned_issue_work_when_active_task_is_stale() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.task_states.insert( + "task-6".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-6".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Stale), + ..Default::default() + }, + ); + + assert!(should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + } + + #[test] + fn should_not_start_assigned_issue_work_when_stale_task_has_backing_pr() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push( + WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/66".to_string())), + ); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.task_states.insert( + "task-6".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-6".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Stale), + ..Default::default() + }, + ); + + assert!(!should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + } + + #[test] + fn should_start_assigned_issue_work_when_stale_task_has_dependency_upgrade_pr() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push( + WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/66".to_string())) + .with_dependency_upgrade_backing_pr(true), + ); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.task_states.insert( + "task-6".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-6".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Stale), + ..Default::default() + }, + ); + + assert!(should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + } + + #[test] + fn should_not_bridge_root_runtime_state_for_codex() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.root_session_id = Some("root-thread".to_string()); + + assert!(!should_bridge_root_runtime_state( + AgentProvider::Codex, + &snapshot, + false, + RootSessionStatus::Busy, + )); + } + + #[test] + fn should_bridge_root_runtime_state_for_non_codex_busy_root() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.root_session_id = Some("root-thread".to_string()); + + assert!(should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + false, + RootSessionStatus::Busy, + )); + assert!(!should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + true, + RootSessionStatus::Busy, + )); + assert!(!should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + false, + RootSessionStatus::Idle, + )); + } + + #[test] + fn build_issue_prompt_requires_skills_and_publish_approval() { + let issue = test_issue( + 980, + "candidate", + "https://github.com/example/repo/issues/980", + "2026-04-09T10:00:00Z", + vec![], + ); + + let prompt = build_issue_prompt( + "example/repo", + &issue, + None, + "thread-task-980", + std::path::Path::new("/tmp/work/example-repo-980"), + std::path::Path::new("/tmp/state/task-980.state"), + ); + + assert!(prompt.contains("`independent-fix`")); + assert!(prompt.contains("`machine-readable-pr`")); + assert!(prompt.contains("`autonomous-state`")); + assert!(prompt.contains("Primary checkout for this task: /tmp/work/example-repo-980")); + assert!(prompt.contains("write autonomous state updates to `/tmp/state/task-980.state`")); + assert!(prompt.contains("Use the existing checkout at `/tmp/work/example-repo-980`")); + assert!( + prompt + .contains("write autonomous state updates in the format `:thread-task-980`") + ); + assert!(prompt.contains( + "Run repository commands, builds, Gradle tasks, and focused tests as needed without asking for permission." + )); + assert!(prompt.contains("Do not commit, push, comment, or open/update a pull request until the user explicitly approves publishing.")); + assert!(prompt.contains( + "When the change is ready for review, stage the full task checkout with `git add -A`" + )); + assert!(prompt.contains("include an appropriate type label such as `type: docs`")); + assert!(prompt.contains("`type: bug`")); + assert!(prompt.contains("`type: improvement`")); + assert!(prompt.contains("`type: enhancement`")); + } + + #[test] + fn build_issue_prompt_with_generic_backing_pr_still_requires_publish_approval() { + let issue = test_issue( + 982, + "ordinary issue with associated PR", + "https://github.com/example/repo/issues/982", + "2026-04-09T10:00:00Z", + vec![], + ); + + let prompt = build_issue_prompt( + "example/repo", + &issue, + None, + "thread-task-982", + std::path::Path::new("/tmp/work/example-repo-982"), + std::path::Path::new("/tmp/state/task-982.state"), + ); + + assert!(prompt.contains("explicitly approves publishing")); + assert!(prompt.contains("stage the full task checkout with `git add -A`")); + assert!(!prompt.contains("backed by Renovate pull request")); + assert!(!prompt.contains("merge it without waiting for human review")); + } + + #[test] + fn build_issue_validation_prompt_enforces_qa_only_flow() { + let issue = test_issue( + 983, + "awaiting validation", + "https://github.com/example/repo/issues/983", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: AWAITING_VALIDATION_LABEL.to_string(), + }], + ); + + let prompt = build_issue_prompt( + "example/repo", + &issue, + None, + "thread-task-983", + std::path::Path::new("/tmp/work/example-repo-983"), + std::path::Path::new("/tmp/state/task-983.state"), + ); + + assert!(prompt.contains("acting as a QA engineer")); + assert!(prompt.contains("Do not implement a fix, do not commit, do not push")); + assert!(prompt.contains("Do not assign the issue to anyone.")); + assert!(prompt.contains("status: bug")); + assert!(prompt.contains("closed: cannot reproduce")); + assert!(prompt.contains("Replace `status: awaiting validation`")); + assert!(prompt.contains("leave the issue in review state")); + assert!(!prompt.contains("explicitly approves publishing")); + assert!(!prompt.contains("3. Implement the fix.")); + } + + #[test] + fn resolved_autonomous_task_prompt_prefers_saved_resume_prompt() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.task_states.insert( + "task-361".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + resume_prompt: Some( + "Apply the extra Redis fix from the attached session.".to_string(), + ), + ..Default::default() + }, + ); + let issue = test_issue( + 361, + "Redis issue", + "https://github.com/example/repo/issues/361", + "2026-04-09T10:00:00Z", + vec![], + ); + + let prompt = resolved_autonomous_task_prompt( + &snapshot, + "task-361", + "example/repo", + &issue, + None, + "thread-task-361", + std::path::Path::new("/tmp/work/example-repo-361"), + std::path::Path::new("/tmp/state/task-361.state"), + ); + + assert_eq!( + prompt, + "Apply the extra Redis fix from the attached session." + ); + } + + #[test] + fn build_issue_prompt_for_dependency_upgrade_requires_combined_pr() { + let mut issue = test_issue( + 981, + "dependency upgrade", + "https://github.com/example/repo/issues/981", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + issue.dependency_upgrade_pr_urls = vec![ + "https://github.com/example/repo/pull/88".to_string(), + "https://github.com/example/repo/pull/91".to_string(), + ]; + + let prompt = build_issue_prompt( + "example/repo", + &issue, + Some("https://github.com/example/repo/pull/999"), + "thread-task-981", + std::path::Path::new("/tmp/work/example-repo-981"), + std::path::Path::new("/tmp/state/task-981.state"), + ); + + assert!(prompt.contains("tracks the following Renovate pull requests")); + assert!(prompt.contains("https://github.com/example/repo/pull/88")); + assert!(prompt.contains("https://github.com/example/repo/pull/91")); + assert!(prompt.contains( + "Treat https://github.com/example/repo/pull/999 as the single combined dependency-upgrade PR" + )); + assert!(prompt.contains( + "Monitor CI for https://github.com/example/repo/pull/999 until it completes." + )); + assert!(prompt.contains( + "push follow-up fixes to the combined PR, and keep monitoring until all required checks are green" + )); + assert!(prompt.contains("Do not merge the individual Renovate PRs directly.")); + assert!(prompt.contains("close GitHub issue https://github.com/example/repo/issues/981")); + assert!(prompt.contains( + "Do not leave placeholder comments, placeholder reviews, or dummy approvals" + )); + assert!(!prompt.contains("explicitly approves publishing")); + } + + #[test] + fn build_issue_prompt_for_single_dependency_upgrade_merges_direct_pr() { + let mut issue = test_issue( + 982, + "single dependency upgrade", + "https://github.com/example/repo/issues/982", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + issue.dependency_upgrade_pr_urls = + vec!["https://github.com/example/repo/pull/999".to_string()]; + + let prompt = build_issue_prompt( + "example/repo", + &issue, + Some("https://github.com/example/repo/pull/999"), + "thread-task-982", + std::path::Path::new("/tmp/work/example-repo-982"), + std::path::Path::new("/tmp/state/task-982.state"), + ); + + assert!(prompt.contains("tracks the following Renovate pull request")); + assert!(prompt.contains( + "Confirm https://github.com/example/repo/pull/999 is still a safe non-major update." + )); + assert!( + prompt.contains("rebase and merge https://github.com/example/repo/pull/999 directly") + ); + assert!(prompt.contains("Do not create a combined dependency-upgrade pull request")); + assert!(!prompt.contains("Treat https://github.com/example/repo/pull/999 as the single combined dependency-upgrade PR")); + } + + #[test] + fn dependency_upgrade_issue_body_uses_updated_queue_text() { + let body = dependency_upgrade_issue_body(&[ + test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 92, + "Update dependency io.micronaut:micronaut-json-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/92", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); + + assert!(body.contains("Current Renovate candidates:")); + assert!(body.contains("https://github.com/example/repo/pull/91")); + assert!(body.contains("https://github.com/example/repo/pull/92")); + assert!(body.contains( + "Create or update a single combined dependency-upgrade pull request associated with this issue." + )); + assert!(body.contains( + "Monitor CI for that combined PR until it completes; if any required check fails, investigate it, push follow-up fixes, and keep monitoring until all required checks are green." + )); + assert!(body.contains("Do not merge the individual Renovate PRs directly.")); + } + + #[test] + fn single_dependency_upgrade_issue_body_uses_direct_merge_text() { + let body = single_dependency_upgrade_issue_body(&test_pull_request( + 734, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/example/repo/pull/734", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert!(body.contains("Track dependency-upgrade automation for Renovate pull request https://github.com/example/repo/pull/734.")); + assert!( + body.contains("This issue was created automatically by multicode to prrocess the PR.") + ); + assert!(body.contains("rebase and merge the PR without waiting for human review")); + assert!(!body.contains("single combined dependency-upgrade pull request")); + assert_eq!( + extract_dependency_upgrade_pr_urls(&body), + vec!["https://github.com/example/repo/pull/734".to_string()] + ); + } + + #[test] + fn merged_dependency_upgrade_issue_urls_ignores_non_dependency_tasks() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push( + WorkspaceTaskPersistentSnapshot::new( + "task-485".to_string(), + "https://github.com/example/repo/issues/485".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/900".to_string())), + ); + let (tx, rx) = watch::channel(Some(GithubStatus::Pr(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: std::time::SystemTime::UNIX_EPOCH, + }))); + let _ = tx; + let receivers = HashMap::from([(snapshot.persistent.tasks[0].issue_url.clone(), rx)]); + + assert!(merged_dependency_upgrade_issue_urls(&snapshot, &receivers).is_empty()); + } + + #[test] + fn merged_dependency_upgrade_issue_urls_includes_dependency_upgrade_tasks() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push( + WorkspaceTaskPersistentSnapshot::new( + "task-981".to_string(), + "https://github.com/example/repo/issues/981".to_string(), + WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some("https://github.com/example/repo/pull/88".to_string())) + .with_dependency_upgrade_backing_pr(true), + ); + let (tx, rx) = watch::channel(Some(GithubStatus::Pr(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: std::time::SystemTime::UNIX_EPOCH, + }))); + let _ = tx; + let receivers = HashMap::from([(snapshot.persistent.tasks[0].issue_url.clone(), rx)]); + + assert_eq!( + merged_dependency_upgrade_issue_urls(&snapshot, &receivers), + vec!["https://github.com/example/repo/issues/981".to_string()] + ); + } + + #[test] + fn selected_pull_request_non_major_detection_prefers_safe_signals() { + let patch = test_pull_request( + 88, + "Update dependency io.micronaut:micronaut-http-client from 4.4.0 to 4.4.1", + "https://github.com/example/repo/pull/88", + vec![ + SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }, + SelectedIssueLabel { + name: "patch".to_string(), + }, + ], + None, + ); + assert!(patch.is_open_dependency_upgrade_candidate()); + assert!(patch.is_non_major_dependency_upgrade()); + + let major = test_pull_request( + 89, + "Update dependency io.micronaut:micronaut-http-client from 4.4.1 to 5.0.0", + "https://github.com/example/repo/pull/89", + vec![ + SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }, + SelectedIssueLabel { + name: "major".to_string(), + }, + ], + None, + ); + assert!(!major.is_non_major_dependency_upgrade()); + + let inferred_minor = test_pull_request( + 90, + "Update dependency io.micronaut:micronaut-http-client from 4.4.1 to 4.5.0", + "https://github.com/example/repo/pull/90", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ); + assert!(inferred_minor.is_non_major_dependency_upgrade()); + } + + #[test] + fn extract_dependency_upgrade_pr_urls_reads_hidden_comment() { + let body = dependency_upgrade_issue_body(&[ + test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 92, + "Update dependency io.micronaut:micronaut-json-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/92", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); + + assert_eq!( + extract_dependency_upgrade_pr_urls(&body), + vec![ + "https://github.com/example/repo/pull/91".to_string(), + "https://github.com/example/repo/pull/92".to_string() + ] + ); + } + + #[test] + fn dependency_upgrade_issue_title_includes_batch_size() { + let queries = dependency_upgrade_issue_title(&[ + test_pull_request( + 728, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/micronaut-projects/micronaut-redis/pull/728", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 729, + "fix(deps): update dependency io.micronaut.redis:micronaut-redis-lettuce to v6.6.0", + "https://github.com/micronaut-projects/micronaut-redis/pull/729", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); + + assert_eq!( + queries, + "Dependency upgrade follow-up for Renovate batch (2 PRs)".to_string() + ); + } + + #[test] + fn single_dependency_upgrade_issue_title_uses_pull_request_number() { + let title = single_dependency_upgrade_issue_title(&test_pull_request( + 728, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/example/repo/pull/728", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert_eq!(title, "Dependency upgrade follow-up for Renovate PR #728"); + } + + #[test] + fn dependency_upgrade_versions_from_arrow_text_parses_renovate_table_rows() { + let body = "This PR contains the following updates:\n\n| Package | Change |\n|---|---|\n| io.micronaut.security:micronaut-security-bom | `4.16.1` → `4.17.1` |\n"; + + assert_eq!(dependency_upgrade_versions_from_text(body), Some((4, 4))); + } + + #[test] + fn dependency_upgrade_versions_from_arrow_text_detects_major_renovate_rows() { + let body = "This PR contains the following updates:\n\n| Package | Type | Change |\n|---|---|---|\n| softprops/action-gh-release | action | `v2.5.0` → `v3.0.0` |\n"; + + assert_eq!(dependency_upgrade_versions_from_text(body), Some((2, 3))); + } + + #[test] + fn dependency_upgrade_non_major_detection_accepts_renovate_body_without_minor_label() { + let body = "This PR contains the following updates:\n\n| Package | Change |\n|---|---|\n| io.micronaut.security:micronaut-security-bom | `4.16.1` → `4.17.1` |\n"; + let pr = test_pull_request( + 682, + "fix(deps): update dependency io.micronaut.security:micronaut-security-bom to v4.17.1", + "https://github.com/example/repo/pull/682", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + Some(body), + ); + + assert!(pr.is_non_major_dependency_upgrade()); + } + + #[test] + fn dependency_upgrade_non_major_detection_rejects_renovate_major_body_without_major_label() { + let body = "This PR contains the following updates:\n\n| Package | Type | Change |\n|---|---|---|\n| softprops/action-gh-release | action | `v2.5.0` → `v3.0.0` |\n"; + let pr = test_pull_request( + 696, + "chore(deps): update softprops/action-gh-release action to v3", + "https://github.com/example/repo/pull/696", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + Some(body), + ); + + assert!(!pr.is_non_major_dependency_upgrade()); + } +} diff --git a/lib/src/services/codex_app_server.rs b/lib/src/services/codex_app_server.rs new file mode 100644 index 0000000..4dccb4a --- /dev/null +++ b/lib/src/services/codex_app_server.rs @@ -0,0 +1,644 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, OnceLock, + atomic::{AtomicI64, Ordering}, + }, + time::Duration, +}; + +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use super::config::{CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}; + +const INITIALIZE_REQUEST_ID: i64 = 1; +const INITIAL_REQUEST_ID: i64 = 2; + +type CodexSocket = + tokio_tungstenite::WebSocketStream>; + +static NEXT_REQUEST_ID: AtomicI64 = AtomicI64::new(INITIAL_REQUEST_ID); +static SHARED_CONNECTIONS: OnceLock>>> = + OnceLock::new(); + +#[derive(Debug, Clone)] +pub struct CodexAppServerClient { + uri: String, +} + +#[derive(Debug)] +struct SharedCodexConnection { + uri: String, + socket: tokio::sync::Mutex>, +} + +impl CodexAppServerClient { + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into() } + } + + pub fn uri(&self) -> &str { + &self.uri + } + + pub async fn probe(&self) -> Result<(), String> { + let _: ThreadListResponse = self + .request( + "thread/list", + json!({ + "limit": 1, + "archived": false, + "sourceKinds": ["appServer"], + }), + ) + .await?; + Ok(()) + } + + pub async fn thread_list(&self, cwd: &str) -> Result { + self.request( + "thread/list", + json!({ + "cwd": cwd, + "archived": false, + "limit": 100, + "sourceKinds": ["appServer"], + "sortKey": "updated_at", + }), + ) + .await + } + + pub async fn thread_read(&self, thread_id: &str) -> Result { + self.thread_read_with_turns(thread_id, false).await + } + + pub async fn thread_read_with_turns( + &self, + thread_id: &str, + include_turns: bool, + ) -> Result { + self.request( + "thread/read", + json!({ + "threadId": thread_id, + "includeTurns": include_turns, + }), + ) + .await + } + + pub async fn thread_start( + &self, + cwd: &str, + config: &CodexAgentConfig, + ) -> Result { + self.request("thread/start", build_thread_start_params(cwd, config)) + .await + } + + pub async fn turn_start( + &self, + thread_id: &str, + prompt: &str, + config: &CodexAgentConfig, + ) -> Result { + self.request( + "turn/start", + build_turn_start_params(thread_id, prompt, config), + ) + .await + } + + pub async fn stream_notifications( + &self, + tx: tokio::sync::broadcast::Sender, + ) -> Result<(), String> { + let mut socket = self.connect_initialized().await?; + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let Ok(notification) = serde_json::from_str::(&text) + else { + continue; + }; + let _ = tx.send(notification.into_notification()); + } + Ok(()) + } + + async fn request(&self, method: &str, params: Value) -> Result + where + T: for<'de> Deserialize<'de>, + { + let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + self.shared_connection() + .request(request_id, method, params) + .await + } + + async fn connect_initialized(&self) -> Result { + connect_initialized_socket(&self.uri).await + } + + fn shared_connection(&self) -> Arc { + let registry = SHARED_CONNECTIONS.get_or_init(Default::default); + let mut registry = registry + .lock() + .expect("codex shared connection registry poisoned"); + registry + .entry(self.uri.clone()) + .or_insert_with(|| { + Arc::new(SharedCodexConnection { + uri: self.uri.clone(), + socket: tokio::sync::Mutex::new(None), + }) + }) + .clone() + } +} + +impl SharedCodexConnection { + async fn request(&self, request_id: i64, method: &str, params: Value) -> Result + where + T: for<'de> Deserialize<'de>, + { + let request = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }) + .to_string(); + + let mut socket = self.socket.lock().await; + for attempt in 0..2 { + if socket.is_none() { + *socket = Some(connect_initialized_socket(&self.uri).await?); + } + + let Some(active_socket) = socket.as_mut() else { + continue; + }; + + if let Err(err) = active_socket + .send(Message::Text(request.clone().into())) + .await + { + *socket = None; + if attempt == 0 { + continue; + } + return Err(err.to_string()); + } + + loop { + match active_socket.next().await { + Some(Ok(message)) => { + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = + serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(request_id) { + continue; + } + if let Some(result) = value.get("result") { + return serde_json::from_value(result.clone()) + .map_err(|err| err.to_string()); + } + if let Some(error) = value.get("error") { + return Err(error.to_string()); + } + } + Some(Err(err)) => { + *socket = None; + if attempt == 0 { + break; + } + return Err(err.to_string()); + } + None => { + *socket = None; + if attempt == 0 { + break; + } + return Err(format!( + "codex app-server connection to '{}' closed", + self.uri + )); + } + } + } + } + + Err(format!( + "failed to complete codex app-server request '{}' for '{}'", + method, self.uri + )) + } +} + +async fn connect_initialized_socket(uri: &str) -> Result { + let (mut socket, _) = connect_async(uri).await.map_err(|err| err.to_string())?; + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": INITIALIZE_REQUEST_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "multicode", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { + "experimentalApi": true, + }, + } + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(INITIALIZE_REQUEST_ID) { + continue; + } + if value.get("error").is_some() { + return Err(value["error"].to_string()); + } + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "method": "initialized", + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + return Ok(socket); + } + + Err(format!( + "codex app-server initialize did not complete for '{}'", + uri + )) +} + +fn build_thread_start_params(cwd: &str, config: &CodexAgentConfig) -> Value { + json!({ + "cwd": cwd, + "model": config.model.as_deref(), + "modelProvider": config.model_provider.as_deref(), + "sandbox": thread_start_sandbox(config.sandbox_mode), + "approvalPolicy": approval_policy_value(config.approval_policy), + "personality": "pragmatic", + }) +} + +fn build_turn_start_params(thread_id: &str, prompt: &str, config: &CodexAgentConfig) -> Value { + json!({ + "threadId": thread_id, + "approvalPolicy": approval_policy_value(config.approval_policy), + "personality": "pragmatic", + "sandboxPolicy": sandbox_policy_value(config.sandbox_mode, config.network_access), + "input": [ + { + "type": "text", + "text": prompt, + } + ], + }) +} + +fn approval_policy_value(policy: CodexApprovalPolicy) -> &'static str { + match policy { + CodexApprovalPolicy::Untrusted => "untrusted", + CodexApprovalPolicy::OnFailure => "on-failure", + CodexApprovalPolicy::OnRequest => "on-request", + CodexApprovalPolicy::Never => "never", + } +} + +fn thread_start_sandbox(mode: CodexSandboxMode) -> &'static str { + match mode { + CodexSandboxMode::ReadOnly => "read-only", + CodexSandboxMode::WorkspaceWrite => "workspace-write", + CodexSandboxMode::DangerFullAccess | CodexSandboxMode::ExternalSandbox => { + "danger-full-access" + } + } +} + +fn sandbox_policy_value(mode: CodexSandboxMode, network: CodexNetworkAccess) -> Value { + match mode { + CodexSandboxMode::ReadOnly => json!({ + "type": "readOnly", + "networkAccess": matches!(network, CodexNetworkAccess::Enabled), + }), + CodexSandboxMode::WorkspaceWrite => json!({ + "type": "workspaceWrite", + "networkAccess": matches!(network, CodexNetworkAccess::Enabled), + }), + CodexSandboxMode::DangerFullAccess => json!({ + "type": "dangerFullAccess", + }), + CodexSandboxMode::ExternalSandbox => json!({ + "type": "externalSandbox", + "networkAccess": match network { + CodexNetworkAccess::Restricted => "restricted", + CodexNetworkAccess::Enabled => "enabled", + }, + }), + } +} + +fn message_to_text(message: Message) -> Option { + match message { + Message::Text(text) => Some(text.to_string()), + Message::Binary(bytes) => String::from_utf8(bytes.to_vec()).ok(), + Message::Close(_) => None, + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thread_start_params_include_codex_permissions() { + let params = build_thread_start_params( + "/workspace", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!(params["cwd"], "/workspace"); + assert_eq!(params["model"], "gpt-5-codex"); + assert_eq!(params["modelProvider"], "openai"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(params["sandbox"], "danger-full-access"); + assert_eq!(params["personality"], "pragmatic"); + } + + #[test] + fn turn_start_params_use_external_sandbox_policy() { + let params = build_turn_start_params( + "thread-123", + "Fix the bug", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!(params["threadId"], "thread-123"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(params["personality"], "pragmatic"); + assert_eq!(params["sandboxPolicy"]["type"], "externalSandbox"); + assert_eq!(params["sandboxPolicy"]["networkAccess"], "enabled"); + assert_eq!(params["input"][0]["type"], "text"); + assert_eq!(params["input"][0]["text"], "Fix the bug"); + } + + #[test] + fn turn_start_params_use_workspace_write_policy() { + let params = build_turn_start_params( + "thread-123", + "Fix the bug", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::OnRequest, + sandbox_mode: CodexSandboxMode::WorkspaceWrite, + network_access: CodexNetworkAccess::Restricted, + }, + ); + + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(params["sandboxPolicy"]["networkAccess"], false); + } + + #[test] + fn codex_thread_status_treats_command_approvals_as_human_input() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert!(status.waits_for_human_input()); + } + + #[test] + fn codex_thread_status_marks_system_error_for_replacement() { + assert!(CodexThreadStatus::SystemError.requires_replacement()); + assert!(!CodexThreadStatus::Idle.requires_replacement()); + } +} + +pub async fn forward_codex_notifications_forever( + client: CodexAppServerClient, + tx: tokio::sync::broadcast::Sender, +) { + loop { + let _ = client.stream_notifications(tx.clone()).await; + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadListResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadStartResponse { + pub thread: CodexThread, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadReadResponse { + pub thread: CodexThreadRead, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TurnStartResponse { + pub turn: CodexTurn, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexTurn { + pub id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThreadRead { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub turns: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThreadTurn { + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThread { + pub id: String, + #[serde(default)] + pub title: Option, + pub status: CodexThreadStatus, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum CodexThreadStatus { + #[serde(rename = "notLoaded")] + NotLoaded, + #[serde(rename = "idle")] + Idle, + #[serde(rename = "systemError")] + SystemError, + #[serde(rename = "active")] + Active { + #[serde(default, rename = "activeFlags")] + active_flags: Vec, + }, +} + +impl CodexThreadStatus { + pub fn is_idle(&self) -> bool { + matches!(self, Self::Idle) + } + + pub fn requires_replacement(&self) -> bool { + matches!(self, Self::SystemError) + } + + pub fn waits_for_human_input(&self) -> bool { + matches!( + self, + Self::Active { + active_flags + } if active_flags.contains(&CodexThreadActiveFlag::WaitingOnUserInput) + || active_flags.contains(&CodexThreadActiveFlag::WaitingOnApproval) + ) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub enum CodexThreadActiveFlag { + #[serde(rename = "waitingOnApproval")] + WaitingOnApproval, + #[serde(rename = "waitingOnUserInput")] + WaitingOnUserInput, +} + +#[derive(Debug, Clone)] +pub enum CodexServerNotification { + ThreadStarted { + thread: CodexThread, + }, + ThreadStatusChanged { + thread_id: String, + status: CodexThreadStatus, + }, + TurnStarted { + thread_id: String, + }, + TurnCompleted { + thread_id: String, + }, + Other, +} + +#[derive(Debug, Deserialize)] +struct CodexServerNotificationEnvelope { + method: String, + #[serde(default)] + params: Value, +} + +impl CodexServerNotificationEnvelope { + fn into_notification(self) -> CodexServerNotification { + match self.method.as_str() { + "thread/started" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::ThreadStarted { + thread: params.thread, + }) + .unwrap_or(CodexServerNotification::Other), + "thread/status/changed" => { + serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::ThreadStatusChanged { + thread_id: params.thread_id, + status: params.status, + }) + .unwrap_or(CodexServerNotification::Other) + } + "turn/started" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::TurnStarted { + thread_id: params.thread_id, + }) + .unwrap_or(CodexServerNotification::Other), + "turn/completed" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::TurnCompleted { + thread_id: params.thread_id, + }) + .unwrap_or(CodexServerNotification::Other), + _ => CodexServerNotification::Other, + } + } +} + +#[derive(Debug, Deserialize)] +struct ThreadStartedNotification { + thread: CodexThread, +} + +#[derive(Debug, Deserialize)] +struct ThreadStatusChangedNotification { + #[serde(rename = "threadId")] + thread_id: String, + status: CodexThreadStatus, +} + +#[derive(Debug, Deserialize)] +struct TurnLifecycleNotification { + #[serde(rename = "threadId")] + thread_id: String, +} diff --git a/lib/src/services/codex_root_session_service.rs b/lib/src/services/codex_root_session_service.rs new file mode 100644 index 0000000..c6e1b7a --- /dev/null +++ b/lib/src/services/codex_root_session_service.rs @@ -0,0 +1,672 @@ +use std::{path::PathBuf, sync::Arc}; + +use tokio::sync::broadcast; + +use super::{ + codex_app_server::{ + CodexAppServerClient, CodexServerNotification, CodexThread, + forward_codex_notifications_forever, + }, + config::CodexAgentConfig, + workspace_task_watch::watch_workspace_task, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{RootSessionStatus, WorkspaceManager, WorkspaceManagerError, manager::Workspace}; + +#[derive(Debug)] +pub enum CodexRootSessionServiceError { + Manager(WorkspaceManagerError), +} + +impl From for CodexRootSessionServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +#[derive(Clone, PartialEq, Eq)] +struct RootSessionTaskKey { + uri: String, + cwd: String, +} + +pub async fn codex_root_session_service( + manager: Arc, + workspace_directory_path: PathBuf, + config: CodexAgentConfig, +) -> Result<(), CodexRootSessionServiceError> { + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let workspace_directory_path = workspace_directory_path.clone(); + let config = config.clone(); + async move { + let cwd = workspace_directory_path + .join(key) + .to_string_lossy() + .into_owned(); + tokio::spawn(async move { + watch_workspace_snapshot(workspace, workspace_rx, cwd, config).await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace_snapshot( + workspace: Workspace, + workspace_rx: tokio::sync::watch::Receiver, + cwd: String, + config: CodexAgentConfig, +) { + watch_workspace_task( + workspace, + workspace_rx, + |snapshot| { + let transient = snapshot.transient.as_ref()?; + let parsed = url::Url::parse(&transient.uri).ok()?; + if !matches!(parsed.scheme(), "ws" | "wss") { + return None; + } + Some(RootSessionTaskKey { + uri: transient.uri.clone(), + cwd: cwd.clone(), + }) + }, + clear_root_session_if_detached, + clear_root_session_on_uri_change, + move |workspace, key| { + let task_workspace = workspace.clone(); + let task_key = key.clone(); + let task_config = config.clone(); + tokio::spawn(async move { + sync_root_session(task_workspace, task_key, task_config).await; + }) + }, + ) + .await; +} + +fn clear_root_session_if_detached(workspace: &Workspace) { + workspace.update(|snapshot| { + let should_clear = snapshot.transient.is_none() + && (snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some()); + if should_clear { + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + true + } else { + false + } + }); +} + +fn clear_root_session_on_uri_change( + workspace: &Workspace, + next_key: &RootSessionTaskKey, + previous_key: Option, +) { + if previous_key + .as_ref() + .is_none_or(|previous_key| previous_key.uri == next_key.uri) + { + return; + } + + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(next_key.uri.as_str()) + { + return false; + } + let changed = snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some(); + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + changed + }); +} + +async fn sync_root_session( + workspace: Workspace, + key: RootSessionTaskKey, + config: CodexAgentConfig, +) { + tracing::info!(uri = %key.uri, cwd = %key.cwd, "starting codex root session sync"); + let client = CodexAppServerClient::new(key.uri.clone()); + let event_tx = broadcast::channel(256).0; + let forwarder = tokio::spawn(forward_codex_notifications_forever( + client.clone(), + event_tx.clone(), + )); + let mut event_rx = event_tx.subscribe(); + let mut refresh_interval = tokio::time::interval(std::time::Duration::from_secs(5)); + refresh_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut active_turn_thread_id: Option = None; + + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + + loop { + tokio::select! { + _ = refresh_interval.tick() => { + refresh_root_session(&workspace, &client, &key, &config, active_turn_thread_id.as_deref()).await; + } + event = event_rx.recv() => { + match event { + Ok(CodexServerNotification::ThreadStarted { thread }) => { + update_from_started_thread( + &workspace, + &key, + &thread, + active_turn_thread_id.as_deref(), + ); + } + Ok(CodexServerNotification::ThreadStatusChanged { thread_id, status }) => { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.as_deref() != Some(thread_id.as_str()) { + return false; + } + let next_status = + effective_status(&thread_id, &status, active_turn_thread_id.as_deref()); + if snapshot.root_session_status == Some(next_status) { + false + } else { + snapshot.root_session_status = Some(next_status); + true + } + }); + } + Ok(CodexServerNotification::TurnStarted { thread_id }) => { + active_turn_thread_id = Some(thread_id.clone()); + workspace.update(|snapshot| { + if snapshot.root_session_id.as_deref() == Some(thread_id.as_str()) + && snapshot.root_session_status != Some(RootSessionStatus::Busy) + { + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + } else { + false + } + }); + } + Ok(CodexServerNotification::TurnCompleted { thread_id }) => { + if active_turn_thread_id.as_deref() == Some(thread_id.as_str()) { + active_turn_thread_id = None; + } + if workspace.subscribe().borrow().root_session_id.as_deref() + == Some(thread_id.as_str()) + { + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + } + } + Ok(CodexServerNotification::Other) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + + forwarder.abort(); +} + +async fn refresh_root_session( + workspace: &Workspace, + client: &CodexAppServerClient, + key: &RootSessionTaskKey, + config: &CodexAgentConfig, + active_turn_thread_id: Option<&str>, +) { + let response = match client.thread_list(&key.cwd).await { + Ok(response) => response, + Err(error) => { + tracing::warn!( + uri = %key.uri, + cwd = %key.cwd, + error = %error, + "failed to list codex root threads" + ); + return; + } + }; + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + thread_count = response.data.len(), + "listed codex root threads" + ); + + let current_root_session_id = workspace.subscribe().borrow().root_session_id.clone(); + let thread = match select_thread_for_tracking( + current_root_session_id.as_deref(), + &response.data, + ) { + Some(thread) => thread, + None => { + if let Some(current_root_session_id) = current_root_session_id.as_deref() { + match client.thread_read(current_root_session_id).await { + Ok(response) => { + if let Some(status) = response.thread.status.as_ref() { + if matches!( + status, + super::codex_app_server::CodexThreadStatus::NotLoaded + ) || status.requires_replacement() + { + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + status = ?status, + "clearing stale codex root thread after thread/read" + ); + clear_tracked_root_session( + workspace, + key, + Some(current_root_session_id), + ); + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + status = ?status, + "retaining existing codex root thread based on thread/read" + ); + update_from_read( + workspace, + key, + current_root_session_id, + status, + active_turn_thread_id, + ); + return; + } + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + "retaining existing codex root thread without status from thread/read" + ); + return; + } + } + Err(error) => { + if error.contains("thread not loaded") { + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + error = %error, + "clearing stale codex root thread after thread/read failure" + ); + clear_tracked_root_session( + workspace, + key, + Some(current_root_session_id), + ); + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + error = %error, + "retaining existing codex root thread while it is not yet materialized in thread/list" + ); + return; + } + } + } + } + match client.thread_start(&key.cwd, config).await { + Ok(response) => response.thread, + Err(error) => { + tracing::warn!( + uri = %key.uri, + cwd = %key.cwd, + error = %error, + "failed to start codex root thread" + ); + return; + } + } + } + }; + + update_from_thread(workspace, key, &thread, active_turn_thread_id); +} + +fn select_thread_for_tracking( + current_thread_id: Option<&str>, + threads: &[CodexThread], +) -> Option { + if let Some(current_thread_id) = current_thread_id + && let Some(thread) = threads + .iter() + .find(|thread| thread.id == current_thread_id && !thread.status.requires_replacement()) + { + return Some(thread.clone()); + } + + threads + .iter() + .find(|thread| !thread.status.requires_replacement()) + .cloned() +} + +fn update_from_started_thread( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread: &CodexThread, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.is_some() + && snapshot.root_session_id.as_deref() != Some(thread.id.as_str()) + { + return false; + } + + let next_title = thread.title.clone().unwrap_or_else(|| "Codex".to_string()); + let next_status = effective_status(&thread.id, &thread.status, active_turn_thread_id); + if snapshot.root_session_id.as_deref() == Some(thread.id.as_str()) + && snapshot.root_session_title.as_deref() == Some(next_title.as_str()) + && snapshot.root_session_status == Some(next_status) + { + return false; + } + + snapshot.root_session_id = Some(thread.id.clone()); + snapshot.root_session_title = Some(next_title); + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn update_from_thread( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread: &CodexThread, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + + let next_title = thread.title.clone().unwrap_or_else(|| "Codex".to_string()); + let next_status = effective_status(&thread.id, &thread.status, active_turn_thread_id); + if snapshot.root_session_id.as_deref() == Some(thread.id.as_str()) + && snapshot.root_session_title.as_deref() == Some(next_title.as_str()) + && snapshot.root_session_status == Some(next_status) + { + return false; + } + + snapshot.root_session_id = Some(thread.id.clone()); + snapshot.root_session_title = Some(next_title); + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn update_from_read( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread_id: &str, + status: &super::codex_app_server::CodexThreadStatus, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.as_deref() != Some(thread_id) { + return false; + } + + let next_status = effective_status(thread_id, status, active_turn_thread_id); + if snapshot.root_session_status == Some(next_status) { + return false; + } + + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn clear_tracked_root_session( + workspace: &Workspace, + key: &RootSessionTaskKey, + expected_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if expected_thread_id.is_some() && snapshot.root_session_id.as_deref() != expected_thread_id + { + return false; + } + let changed = snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some(); + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + changed + }); +} + +fn effective_status( + thread_id: &str, + status: &super::codex_app_server::CodexThreadStatus, + active_turn_thread_id: Option<&str>, +) -> RootSessionStatus { + match map_status(status) { + RootSessionStatus::Idle if active_turn_thread_id == Some(thread_id) => { + RootSessionStatus::Busy + } + other => other, + } +} + +fn map_status(status: &super::codex_app_server::CodexThreadStatus) -> RootSessionStatus { + if status.waits_for_human_input() { + RootSessionStatus::Question + } else if status.is_idle() { + RootSessionStatus::Idle + } else { + RootSessionStatus::Busy + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; + use crate::{ + RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, WorkspaceSnapshot, + }; + + #[test] + fn map_status_treats_waiting_on_approval_as_question() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert_eq!(map_status(&status), RootSessionStatus::Question); + } + + #[test] + fn effective_status_keeps_idle_thread_busy_while_turn_is_active() { + assert_eq!( + effective_status("thread-1", &CodexThreadStatus::Idle, Some("thread-1")), + RootSessionStatus::Busy + ); + assert_eq!( + effective_status("thread-2", &CodexThreadStatus::Idle, Some("thread-1")), + RootSessionStatus::Idle + ); + } + + #[test] + fn select_thread_for_tracking_prefers_current_thread_over_newer_idle_thread() { + let current_busy = CodexThread { + id: "thread-busy".to_string(), + title: Some("Busy".to_string()), + status: CodexThreadStatus::Active { + active_flags: Vec::new(), + }, + }; + let newer_idle = CodexThread { + id: "thread-idle".to_string(), + title: Some("Idle".to_string()), + status: CodexThreadStatus::Idle, + }; + + let selected = select_thread_for_tracking( + Some("thread-busy"), + &[newer_idle.clone(), current_busy.clone()], + ) + .expect("current thread should be selected"); + + assert_eq!(selected.id, current_busy.id); + } + + #[test] + fn update_from_started_thread_ignores_unrelated_new_thread_when_already_tracking_one() { + let workspace = WorkspaceSnapshot::default(); + let workspace = crate::manager::Workspace::new(workspace); + workspace.update(|snapshot| { + snapshot.transient = Some(TransientWorkspaceSnapshot { + uri: "ws://127.0.0.1:31337".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-current".to_string()); + snapshot.root_session_title = Some("Current".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + }); + + update_from_started_thread( + &workspace, + &RootSessionTaskKey { + uri: "ws://127.0.0.1:31337".to_string(), + cwd: "/tmp/workspace".to_string(), + }, + &CodexThread { + id: "thread-other".to_string(), + title: Some("Other".to_string()), + status: CodexThreadStatus::Idle, + }, + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.root_session_id.as_deref(), Some("thread-current")); + assert_eq!(snapshot.root_session_title.as_deref(), Some("Current")); + assert_eq!(snapshot.root_session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn select_thread_for_tracking_returns_none_when_current_thread_is_unmaterialized() { + assert!(select_thread_for_tracking(Some("thread-current"), &[]).is_none()); + } + + #[test] + fn clear_tracked_root_session_clears_matching_thread() { + let workspace = WorkspaceSnapshot::default(); + let workspace = crate::manager::Workspace::new(workspace); + let key = RootSessionTaskKey { + uri: "ws://127.0.0.1:31337".to_string(), + cwd: "/tmp/workspace".to_string(), + }; + workspace.update(|snapshot| { + snapshot.transient = Some(TransientWorkspaceSnapshot { + uri: key.uri.clone(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-current".to_string()); + snapshot.root_session_title = Some("Current".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + }); + + clear_tracked_root_session(&workspace, &key, Some("thread-current")); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.root_session_id, None); + assert_eq!(snapshot.root_session_title, None); + assert_eq!(snapshot.root_session_status, None); + } +} diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 626fd9e..bd07979 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -1,213 +1,48 @@ use std::{ - env, + collections::HashMap, io::ErrorKind, path::{Path, PathBuf}, - process::{ExitStatus, Stdio}, + process::{ExitStatus, Output, Stdio}, sync::Arc, - time::SystemTime, + time::{Duration, SystemTime}, }; use tokio::process::Command; use uuid::Uuid; -fn shell_escape_arg(arg: &str) -> String { - if arg.is_empty() { - "''".to_string() - } else if arg - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '_' | '-' | '.' | '=')) - { - arg.to_string() - } else { - format!("'{}'", arg.replace('\'', "'\\''")) - } -} - -fn format_command_line(program: &str, args: &[String]) -> String { - std::iter::once(program) - .chain(args.iter().map(String::as_str)) - .map(shell_escape_arg) - .collect::>() - .join(" ") -} - -fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { - for (name, _) in env { - args.push("--setenv".to_string()); - args.push(name.clone()); - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnCommand { + pub program: String, pub args: Vec, pub inherited_env: Vec<(String, String)>, } use crate::{ - TransientWorkspaceSnapshot, WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, - database::Database, logging, + WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, + WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, database::Database, logging, opencode, }; use super::{ GithubStatusService, GithubStatusServiceError, WorkspaceDirectoryError, + automation_state_file_service::automation_state_file_service, + autonomous_workspace_service::autonomous_workspace_service, + codex_app_server::CodexAppServerClient, + codex_root_session_service::codex_root_session_service, config::{ - AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, path_looks_like_file, - read_config, resolve_opencode_command, validate_handler_config, validate_remote_config, - validate_tool_config_entries, validate_workspace_key, + AddedSkillMount, AgentProvider, CodexAgentConfig, Config, ExpandedIsolationConfig, + expand_shell_path, inherited_env_value, read_config, resolve_agent_command, + validate_handler_config, validate_remote_config, validate_tool_config_entries, + validate_workspace_key, }, multicode_metadata_service, opencode_client_service, persistent_storage, - resource_usage_service, root_session_service, transient_storage, usage_aggregation_service, + resource_usage_service, root_session_service, + runtime::{WorkspaceRuntime, automation_task_state_file_source}, + runtime_reconciliation_service::runtime_reconciliation_service, + transient_storage, usage_aggregation_service, workspace_archive::ArchiveWorkspaceEntry, workspace_directory, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum MountKind { - Readable, - Writable, - Isolated, - Tmpfs, -} - -#[derive(Debug, Clone)] -struct MountSpec { - target: PathBuf, - source: Option, - kind: MountKind, - is_file: bool, -} - -impl MountSpec { - fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { - let is_file = match source.as_ref() { - Some(source) => std::fs::metadata(source) - .map(|metadata| metadata.is_file()) - .unwrap_or(false), - None => std::fs::metadata(&target) - .map(|metadata| metadata.is_file()) - .unwrap_or_else(|_| path_looks_like_file(&target)), - }; - Self { - target, - source, - kind, - is_file, - } - } - - fn depth(&self) -> usize { - self.target.components().count() - } - - fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { - for prior_mount in prior_mounts.iter().rev() { - if path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) { - let relative = path - .strip_prefix(&prior_mount.mount.target) - .expect("path should be under prior mount target"); - return prior_mount.effective_source.join(relative); - } - } - path.to_path_buf() - } - - fn resolve_effective(&self, prior_mounts: &[ResolvedMountSpec]) -> ResolvedMountSpec { - let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); - let effective_source = match self.kind { - MountKind::Isolated => self - .source - .as_ref() - .map(|source| Self::resolve_backing_path(source, prior_mounts)) - .unwrap_or_else(|| effective_target.clone()), - MountKind::Readable | MountKind::Writable => { - self.source.clone().unwrap_or_else(|| self.target.clone()) - } - MountKind::Tmpfs => effective_target.clone(), - }; - ResolvedMountSpec { - mount: self.clone(), - effective_target, - effective_source, - } - } -} - -#[derive(Debug, Clone)] -struct ResolvedMountSpec { - mount: MountSpec, - effective_target: PathBuf, - effective_source: PathBuf, -} - -impl ResolvedMountSpec { - async fn prepare_source_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - self.prepare_node( - &self.effective_source, - owns_node, - self.mount - .source - .as_ref() - .filter(|original| *original != &self.effective_source), - ) - .await - } - - async fn prepare_target_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - let should_materialize = owns_node - && (!self.mount.is_file - || matches!(self.mount.kind, MountKind::Writable | MountKind::Isolated)); - self.prepare_node(&self.effective_target, should_materialize, None) - .await - } - - async fn prepare_node( - &self, - path: &Path, - materialize_node: bool, - seed_file: Option<&PathBuf>, - ) -> Result<(), CombinedServiceError> { - if self.mount.is_file { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - if materialize_node && tokio::fs::metadata(path).await.is_err() { - if let Some(seed_file) = seed_file { - if tokio::fs::metadata(seed_file).await.is_ok() { - tokio::fs::copy(seed_file, path).await?; - return Ok(()); - } - } - tokio::fs::File::create(path).await?; - } - } else if materialize_node { - tokio::fs::create_dir_all(path).await?; - } else if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - Ok(()) - } - - fn append_args(&self, args: &mut Vec) { - match self.mount.kind { - MountKind::Readable => { - args.push("--ro-bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Writable | MountKind::Isolated => { - args.push("--bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Tmpfs => { - args.push("--tmpfs".to_string()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - } - } -} - #[derive(Debug, Clone)] pub struct CombinedService { pub config: Config, @@ -216,7 +51,9 @@ pub struct CombinedService { github_status_service: GithubStatusService, workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, - opencode_command: String, + agent_command: String, + agent_provider: AgentProvider, + runtime: WorkspaceRuntime, github_git_credentials_env: Option, } @@ -246,7 +83,10 @@ impl CombinedService { validate_tool_config_entries(&config.tool)?; validate_handler_config(&config.handler)?; validate_remote_config(config.remote.as_ref())?; - let opencode_command = resolve_opencode_command(&config.opencode)?; + let agent_provider = config.agent.provider; + let agent_command = resolve_agent_command(&agent_command_candidates(&config))?; + let container_agent_command = + resolve_container_agent_command(agent_provider, config.runtime.backend, &config); let workspace_directory_path = expand_shell_path(&config.workspace_directory)?; if let Err(err) = logging::enable_workspace_file_logging(&workspace_directory_path).await { logging::log_file_enable_failed( @@ -273,6 +113,15 @@ impl CombinedService { GithubStatusService::new(database.clone(), config.github.token.clone()).await?; let github_git_credentials_env = github_git_credentials_env_from_config(&config, &github_status_service).await?; + let runtime = WorkspaceRuntime::new( + config.runtime.clone(), + workspace_directory_path.clone(), + expanded_isolation.clone(), + agent_provider, + agent_command.clone(), + container_agent_command, + config.agent.codex.clone(), + ); let persistent_path = workspace_directory_path .join(".multicode") @@ -287,22 +136,38 @@ impl CombinedService { workspace_directory_path.clone(), ); spawn_transient_storage(manager.clone(), transient_link); - spawn_opencode_client_service(manager.clone()); - spawn_root_session_service(manager.clone()); + spawn_runtime_reconciliation_service(manager.clone(), runtime.clone()); + if agent_provider == AgentProvider::Opencode { + spawn_opencode_client_service(manager.clone()); + spawn_root_session_service(manager.clone()); + } else { + spawn_codex_root_session_service( + manager.clone(), + workspace_directory_path.clone(), + config.agent.codex.clone(), + ); + } spawn_multicode_metadata_service(manager.clone()); spawn_usage_aggregation_service(manager.clone()); spawn_resource_usage_service(manager.clone()); + spawn_automation_state_file_service(manager.clone(), workspace_directory_path.clone()); - Ok(Self { + let service = Self { config, manager, database, github_status_service, workspace_directory_path, expanded_isolation, - opencode_command, + agent_command, + agent_provider, + runtime, github_git_credentials_env, - }) + }; + + spawn_autonomous_workspace_service(service.clone()); + + Ok(service) } pub fn workspace_directory_path(&self) -> &Path { @@ -334,6 +199,17 @@ impl CombinedService { Ok(()) } + pub async fn create_workspace_with_repository( + &self, + key: &str, + repository: &str, + ) -> Result { + let normalized = normalize_repository_spec(repository)?; + self.create_workspace(key).await?; + self.set_workspace_repository(key, Some(normalized.clone()))?; + Ok(normalized) + } + pub async fn start_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; @@ -341,44 +217,27 @@ impl CombinedService { let workspace = self.manager.get_workspace(&key)?; let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; + strip_workspace_git_identity_overrides(&workspace_path).await?; - let password = generate_random_password(); - let port = pick_random_free_port().await?; - let unit = generate_transient_unit_name(); - let args = self - .build_systemd_bwrap_command(&key, &password, port, &unit) + let inherited_env = self + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; + let start = self.runtime.start_server(&key, &inherited_env).await?; tracing::info!( workspace_key = %key, - command = %format_command_line("systemd-run", &args.args), - "starting application via systemd-run opencode serve" + backend = ?self.config.runtime.backend, + runtime_id = %start.transient.runtime.id, + "started workspace runtime" ); - let mut command = Command::new("systemd-run"); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .args(&args.args); - for (name, value) in &args.inherited_env { - command.env(name, value); - } - let output = command.output().await?; - - if !output.status.success() { - return Err(CombinedServiceError::StartWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - - let uri = format!("http://opencode:{password}@127.0.0.1:{port}/"); - let transient = TransientWorkspaceSnapshot { - uri, - unit: unit.clone(), - }; let mut replaced = false; workspace.update(|snapshot| { if snapshot.transient.is_none() { - snapshot.transient = Some(transient.clone()); + snapshot.transient = Some(start.transient.clone()); + snapshot.persistent.automation_paused = false; + if snapshot.persistent.assigned_repository.is_some() { + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + } replaced = true; true } else { @@ -387,7 +246,7 @@ impl CombinedService { }); if !replaced { - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&start.transient.runtime).await?; return Err(CombinedServiceError::TransientSnapshotAlreadyPresent( key.to_string(), )); @@ -396,21 +255,327 @@ impl CombinedService { Ok(()) } + pub async fn assign_workspace_repository( + &self, + key: &str, + repository: Option<&str>, + ) -> Result, CombinedServiceError> { + let key = validate_workspace_key(key)?; + let normalized = repository.map(normalize_repository_spec).transpose()?; + self.set_workspace_repository(&key, normalized.clone())?; + Ok(normalized) + } + + pub async fn assign_workspace_issue( + &self, + key: &str, + issue: Option<&str>, + ) -> Result, CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let assigned_repository = workspace + .subscribe() + .borrow() + .persistent + .assigned_repository + .clone() + .ok_or_else(|| CombinedServiceError::WorkspaceRepositoryRequired(key.clone()))?; + let normalized = issue + .map(|issue| normalize_issue_spec(&assigned_repository, issue)) + .transpose()?; + + workspace.update(|snapshot| { + let Some(issue_url) = normalized.clone() else { + return false; + }; + snapshot + .persistent + .ignored_issue_urls + .retain(|ignored| ignored != &issue_url); + if snapshot + .persistent + .tasks + .iter() + .any(|task| task.issue_url == issue_url) + { + snapshot.automation_status = Some(format!( + "Issue already queued for workspace '{key}': {}", + format_issue_reference(&issue_url) + )); + return true; + } + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + format!("task-{}", Uuid::new_v4().simple()), + issue_url.clone(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_paused = false; + snapshot.automation_status = Some(format!( + "Issue queued; start queued for {}", + format_issue_reference(&issue_url) + )); + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + true + }); + + Ok(normalized) + } + + pub fn request_workspace_issue_scan(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + workspace.update(|snapshot| { + if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { + if snapshot.active_task_id.is_none() { + snapshot.automation_status = Some(format!("Scan requested for {repository}")); + } + } + snapshot.persistent.automation_paused = false; + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + true + }); + Ok(()) + } + + pub fn request_workspace_queue_next(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + workspace.update(|snapshot| { + if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { + snapshot.automation_status = Some(format!("Queue next requested for {repository}")); + } + snapshot.persistent.automation_paused = false; + snapshot.automation_queue_next_request_nonce = snapshot + .automation_queue_next_request_nonce + .saturating_add(1); + true + }); + Ok(()) + } + + pub fn resume_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { + self.request_workspace_issue_scan(key) + } + + pub async fn pause_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let snapshot = workspace.subscribe().borrow().clone(); + let _runtime_handle = snapshot + .transient + .as_ref() + .map(|transient| transient.runtime.clone()) + .ok_or_else(|| CombinedServiceError::TransientSnapshotMissing(key.clone()))?; + + if self.agent_provider == AgentProvider::Opencode + && let Some(opencode_client) = snapshot.opencode_client.as_ref() + { + let active_session_id = snapshot + .active_task_id + .as_deref() + .and_then(|task_id| snapshot.task_states.get(task_id)) + .and_then(|task_state| task_state.session_id.as_deref()) + .or(snapshot.automation_session_id.as_deref()) + .or(snapshot.root_session_id.as_deref()); + if let Some(session_id) = active_session_id + && let Ok(session_id) = + opencode::client::types::SessionAbortSessionId::try_from(session_id) + && let Err(err) = opencode_client + .client + .session_abort(&session_id, None, None) + .await + { + tracing::warn!( + workspace_key = %key, + error = ?err, + "failed to abort active opencode session while pausing workspace" + ); + } + } + + workspace.update(|snapshot| { + let mut changed = false; + if !snapshot.persistent.automation_paused { + snapshot.persistent.automation_paused = true; + changed = true; + } + let next_status = snapshot + .persistent + .assigned_repository + .as_ref() + .map(|repository| format!("Paused {repository}")) + .or_else(|| { + (!snapshot.persistent.tasks.is_empty()).then_some("Paused".to_string()) + }); + if snapshot.automation_status != next_status { + snapshot.automation_status = next_status; + changed = true; + } + changed + }); + + Ok(()) + } + + fn set_workspace_repository( + &self, + key: &str, + repository: Option, + ) -> Result<(), CombinedServiceError> { + let workspace = self.manager.get_workspace(key)?; + workspace.update(|snapshot| { + if snapshot.persistent.assigned_repository == repository { + return false; + } + snapshot.persistent.assigned_repository = repository.clone(); + snapshot.persistent.automation_issue = None; + snapshot.persistent.ignored_issue_urls.clear(); + snapshot.persistent.tasks.clear(); + snapshot.active_task_id = None; + snapshot.task_states.clear(); + snapshot.persistent.automation_paused = false; + snapshot.automation_status = repository + .as_ref() + .map(|repository| format!("Repository assigned; scan queued for {repository}")); + if repository.is_some() { + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + } + true + }); + Ok(()) + } + + pub fn workspace_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path.join(key) + } + + pub fn workspace_repo_root_path(&self, key: &str, repository: &str) -> PathBuf { + self.workspace_path_for_key(key) + .join(repository_repo_name(repository)) + } + + pub fn workspace_task_checkout_path( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> PathBuf { + self.workspace_path_for_key(key).join("work").join(format!( + "{}-{}", + repository_repo_name(repository), + issue_url_number(issue_url).unwrap_or("task") + )) + } + + pub async fn ensure_workspace_task_checkout( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> Result { + let key = validate_workspace_key(key)?; + let repository = normalize_repository_spec(repository)?; + let repo_root = self.workspace_repo_root_path(&key, &repository); + let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); + + tokio::fs::create_dir_all(self.workspace_path_for_key(&key)).await?; + self.ensure_repository_checkout(&repository, &repo_root) + .await?; + self.ensure_task_worktree(&repo_root, &task_root).await?; + unset_repo_local_git_config(&repo_root, "user.name").await?; + unset_repo_local_git_config(&repo_root, "user.email").await?; + unset_repo_local_git_config(&task_root, "user.name").await?; + unset_repo_local_git_config(&task_root, "user.email").await?; + + Ok(task_root) + } + + pub async fn remove_workspace_task_checkout( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let repository = normalize_repository_spec(repository)?; + let repo_root = self.workspace_repo_root_path(&key, &repository); + let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); + + if !path_has_git_entry(&task_root).await? && tokio::fs::metadata(&task_root).await.is_err() + { + return Ok(()); + } + + if path_has_git_entry(&repo_root).await? { + let mut command = Command::new(git_program()); + command + .arg("-C") + .arg(&repo_root) + .args(["worktree", "remove", "--force"]) + .arg(&task_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + + let output = command.output().await?; + if !output.status.success() && path_has_git_entry(&task_root).await? { + return Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to remove task worktree '{}': {}", + task_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + + let mut prune = Command::new(git_program()); + prune + .arg("-C") + .arg(&repo_root) + .args(["worktree", "prune"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + prune.env(name, value); + } + let _ = prune.status().await; + } + + if tokio::fs::metadata(&task_root).await.is_ok() { + remove_path_if_exists(&task_root).await?; + } + Ok(()) + } + pub async fn stop_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; let workspace_rx = workspace.subscribe(); - let unit = workspace_rx + let runtime_handle = workspace_rx .borrow() .transient .as_ref() - .map(|transient| transient.unit.clone()) + .map(|transient| transient.runtime.clone()) .ok_or_else(|| CombinedServiceError::TransientSnapshotMissing(key.clone()))?; - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&runtime_handle).await?; workspace.update(|snapshot| { if snapshot.transient.is_some() { snapshot.transient = None; + snapshot.persistent.automation_paused = true; + snapshot.automation_status = snapshot + .persistent + .assigned_repository + .as_ref() + .map(|repository| format!("Stopped {repository}")); true } else { false @@ -419,6 +584,151 @@ impl CombinedService { Ok(()) } + pub async fn delete_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let snapshot = workspace.subscribe().borrow().clone(); + + if !snapshot.persistent.tasks.is_empty() { + return Err(CombinedServiceError::WorkspaceHasTasks { + key: key.clone(), + task_count: snapshot.persistent.tasks.len(), + }); + } + + if let Some(transient) = snapshot.transient.as_ref() { + self.runtime.stop_server(&transient.runtime).await?; + } + + self.remove_workspace_disk_state(&key).await?; + self.manager.remove(&key)?; + Ok(()) + } + + pub async fn delete_workspace_task( + &self, + key: &str, + task_id: &str, + ) -> Result<(), CombinedServiceError> { + self.remove_workspace_task(key, task_id, false).await + } + + pub async fn remove_workspace_task( + &self, + key: &str, + task_id: &str, + ignore_future_scans: bool, + ) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let snapshot = workspace.subscribe().borrow().clone(); + + let task = snapshot + .persistent + .tasks + .iter() + .find(|task| task.id == task_id) + .cloned() + .ok_or_else(|| CombinedServiceError::WorkspaceTaskMissing { + key: key.clone(), + task_id: task_id.to_string(), + })?; + let assigned_repository = snapshot.persistent.assigned_repository.clone(); + + if self.agent_provider == AgentProvider::Opencode + && let Some(task_state) = snapshot.task_states.get(task_id) + && let (Some(opencode_client), Some(session_id)) = ( + snapshot.opencode_client.as_ref(), + task_state.session_id.as_deref(), + ) + && let Ok(session_id) = + opencode::client::types::SessionDeleteSessionId::try_from(session_id) + { + let _ = opencode_client + .client + .session_delete(&session_id, None, None) + .await; + } + + if ignore_future_scans && let Some(assigned_repository) = assigned_repository.as_deref() { + super::autonomous_workspace_service::clear_issue_claim_for_ignore( + self, + assigned_repository, + &task.issue_url, + ) + .await + .map_err(CombinedServiceError::InvalidToolExecution)?; + } + + if let Some(assigned_repository) = assigned_repository.as_deref() { + self.remove_workspace_task_checkout(&key, assigned_repository, &task.issue_url) + .await?; + } + remove_path_if_exists(&automation_task_state_file_source( + &self.workspace_directory_path, + &key, + task_id, + )) + .await?; + + workspace.update(|next| { + let mut changed = false; + let before = next.persistent.tasks.len(); + next.persistent.tasks.retain(|entry| entry.id != task_id); + if next.persistent.tasks.len() != before { + changed = true; + } + if ignore_future_scans + && !next + .persistent + .ignored_issue_urls + .iter() + .any(|ignored| ignored == &task.issue_url) + { + next.persistent + .ignored_issue_urls + .push(task.issue_url.clone()); + changed = true; + } + if next.task_states.remove(task_id).is_some() { + changed = true; + } + if next.active_task_id.as_deref() == Some(task_id) { + next.active_task_id = next.persistent.tasks.first().map(|task| task.id.clone()); + next.automation_session_id = None; + next.automation_agent_state = None; + next.automation_session_status = None; + changed = true; + } else if next + .active_task_id + .as_deref() + .is_some_and(|active_task_id| { + !next + .persistent + .tasks + .iter() + .any(|entry| entry.id == active_task_id) + }) + { + next.active_task_id = next.persistent.tasks.first().map(|entry| entry.id.clone()); + changed = true; + } + let next_active_issue = next.active_task_id.as_deref().and_then(|active_task_id| { + next.persistent + .tasks + .iter() + .find(|entry| entry.id == active_task_id) + .map(|entry| entry.issue_url.clone()) + }); + if next.persistent.automation_issue != next_active_issue { + next.persistent.automation_issue = next_active_issue; + changed = true; + } + changed + }); + Ok(()) + } + /// Build a command to run a user-defined exec-type tool. pub async fn build_exec_tool_command( &self, @@ -449,31 +759,24 @@ impl CombinedService { "PTY tool command must not be empty".to_string(), )); } + let runtime_handle = self + .manager + .get_workspace(&key)? + .subscribe() + .borrow() + .transient + .as_ref() + .map(|transient| transient.runtime.clone()); let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; - let unit = generate_transient_unit_name(); - let mut args = vec![ - "--user".to_string(), - "--wait".to_string(), - "--collect".to_string(), - "--pty".to_string(), - ]; let inherited_env = self .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit); - self.append_systemd_limits(&mut args); - self.append_bwrap_sandbox_args(&mut args, &key).await?; - args.extend(command); - - Ok(SpawnCommand { - args, - inherited_env, - }) + self.runtime + .build_pty_command(&key, runtime_handle.as_ref(), &inherited_env, command) + .await } pub async fn archive_workspace( @@ -620,10 +923,468 @@ impl CombinedService { Ok(()) } - pub fn opencode_command(&self) -> &str { - &self.opencode_command + pub fn agent_command(&self) -> &str { + &self.agent_command + } + + pub fn agent_provider(&self) -> AgentProvider { + self.agent_provider + } + + pub async fn prompt_root_session( + &self, + snapshot: &crate::WorkspaceSnapshot, + prompt: &str, + ) -> Result<(), String> { + let root_session_id = snapshot + .root_session_id + .clone() + .ok_or_else(|| "workspace has no root session id".to_string())?; + + self.prompt_session(snapshot, &root_session_id, prompt) + .await + } + + pub async fn prompt_session( + &self, + snapshot: &crate::WorkspaceSnapshot, + session_id: &str, + prompt: &str, + ) -> Result<(), String> { + let session_id = session_id.to_string(); + match self.agent_provider { + AgentProvider::Opencode => { + let opencode_client = snapshot + .opencode_client + .as_ref() + .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; + let session_id = session_id + .parse::() + .map_err(|err| format!("invalid session id '{session_id}': {err}"))?; + let prompt_body = opencode::client::types::SessionPromptAsyncBody { + agent: None, + format: None, + message_id: None, + model: None, + no_reply: None, + parts: vec![ + opencode::client::types::TextPartInput { + id: None, + ignored: None, + metadata: Default::default(), + synthetic: None, + text: prompt.to_string(), + time: None, + type_: opencode::client::types::TextPartInputType::Text, + } + .into(), + ], + system: None, + tools: HashMap::new(), + variant: None, + }; + opencode_client + .client + .session_prompt_async(&session_id, None, None, &prompt_body) + .await + .map(|_| ()) + .map_err(|err| format!("failed to send prompt: {err}")) + } + AgentProvider::Codex => { + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| "workspace has no active runtime uri".to_string())?; + tracing::info!( + session_id, + uri = %uri, + prompt_len = prompt.len(), + "dispatching codex session prompt" + ); + let response = Self::prompt_codex_session_with_retry( + &uri, + &session_id, + prompt, + &self.config.agent.codex, + ) + .await; + match response { + Ok(_) => { + tracing::info!( + session_id, + uri = %uri, + "codex session prompt dispatched" + ); + Ok(()) + } + Err(err) => { + tracing::warn!( + session_id, + uri = %uri, + error = %err, + "codex session prompt dispatch failed" + ); + Err(err) + } + } + } + } + } + + pub async fn prompt_task_session( + &self, + workspace_key: &str, + snapshot: &crate::WorkspaceSnapshot, + task_id: &str, + prompt: &str, + ) -> Result<(), String> { + tracing::info!( + workspace_key, + task_id, + provider = ?self.agent_provider, + "dispatching task session prompt" + ); + if self.agent_provider != AgentProvider::Codex { + let session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.as_deref()) + .ok_or_else(|| format!("task '{task_id}' does not have a resumable session"))?; + return self.prompt_session(snapshot, session_id, prompt).await; + } + + let workspace = self + .manager + .get_workspace(workspace_key) + .map_err(|err| format!("failed to load workspace '{workspace_key}': {err:?}"))?; + let live_snapshot = workspace.subscribe().borrow().clone(); + let snapshot = &live_snapshot; + let task = snapshot + .task_persistent_snapshot(task_id) + .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; + let assigned_repository = resolve_task_repository(snapshot, task).ok_or_else(|| { + format!("workspace '{workspace_key}' does not have a repository for task '{task_id}'") + })?; + if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository.as_str()) + { + workspace.update(|next| { + if next.persistent.assigned_repository.as_deref() + == Some(assigned_repository.as_str()) + { + false + } else { + next.persistent.assigned_repository = Some(assigned_repository.clone()); + true + } + }); + } + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an active runtime") + })?; + self.ensure_workspace_task_checkout(workspace_key, &assigned_repository, &task.issue_url) + .await + .map_err(|err| err.summary())?; + let existing_session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.clone()); + + if let Some(session_id) = existing_session_id.as_deref() { + tracing::info!( + workspace_key, + task_id, + session_id, + "sending codex prompt to existing task session" + ); + match Self::prompt_codex_session_with_retry( + &uri, + session_id, + prompt, + &self.config.agent.codex, + ) + .await + { + Ok(()) => return Ok(()), + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + tracing::warn!( + workspace_key, + task_id, + session_id, + error = %error, + "replacing stale codex task session after interrupted attach" + ); + } + Err(error) => { + tracing::warn!( + workspace_key, + task_id, + session_id, + error = %error, + "failed to dispatch codex prompt to existing task session" + ); + return Err(error); + } + } + } + + self.start_fresh_codex_task_session( + workspace_key, + task_id, + prompt, + existing_session_id.as_deref(), + ) + .await + } + + pub async fn restart_task_session( + &self, + workspace_key: &str, + snapshot: &crate::WorkspaceSnapshot, + task_id: &str, + prompt: &str, + ) -> Result<(), String> { + if self.agent_provider != AgentProvider::Codex { + return self + .prompt_task_session(workspace_key, snapshot, task_id, prompt) + .await; + } + + tracing::info!(workspace_key, task_id, "restarting codex task session"); + let previous_session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.as_deref()); + self.start_fresh_codex_task_session(workspace_key, task_id, prompt, previous_session_id) + .await + } + + async fn start_fresh_codex_task_session( + &self, + workspace_key: &str, + task_id: &str, + prompt: &str, + previous_session_id: Option<&str>, + ) -> Result<(), String> { + tracing::info!( + workspace_key, + task_id, + previous_session_id, + "starting fresh codex task session" + ); + let workspace = self + .manager + .get_workspace(workspace_key) + .map_err(|err| format!("failed to load workspace '{workspace_key}': {err:?}"))?; + let live_snapshot = workspace.subscribe().borrow().clone(); + let snapshot = &live_snapshot; + let task = snapshot + .task_persistent_snapshot(task_id) + .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; + let assigned_repository = resolve_task_repository(snapshot, task).ok_or_else(|| { + format!("workspace '{workspace_key}' does not have a repository for task '{task_id}'") + })?; + if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository.as_str()) + { + workspace.update(|next| { + if next.persistent.assigned_repository.as_deref() + == Some(assigned_repository.as_str()) + { + false + } else { + next.persistent.assigned_repository = Some(assigned_repository.clone()); + true + } + }); + } + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an active runtime") + })?; + let cwd = self + .ensure_workspace_task_checkout(workspace_key, &assigned_repository, &task.issue_url) + .await + .map_err(|err| err.summary())?; + let client = CodexAppServerClient::new(uri.clone()); + let session_id = client + .thread_start(cwd.to_string_lossy().as_ref(), &self.config.agent.codex) + .await? + .thread + .id; + tracing::info!( + workspace_key, + task_id, + session_id, + previous_session_id, + "created fresh codex task session" + ); + let prompt = + Self::rewrite_codex_task_prompt_session_id(prompt, previous_session_id, &session_id); + Self::wait_for_codex_thread_ready(&client, &session_id).await?; + workspace.update(|next| { + let task_state = next.task_states.entry(task_id.to_string()).or_default(); + let mut changed = false; + if task_state.session_id.as_deref() != Some(session_id.as_str()) { + task_state.session_id = Some(session_id.clone()); + changed = true; + } + if next.active_task_id.as_deref() == Some(task_id) { + if next.automation_session_id.as_deref() != Some(session_id.as_str()) { + next.automation_session_id = Some(session_id.clone()); + changed = true; + } + if next.automation_agent_state != Some(crate::AutomationAgentState::Working) { + next.automation_agent_state = Some(crate::AutomationAgentState::Working); + changed = true; + } + if next.automation_session_status + != Some(super::root_session_service::RootSessionStatus::Busy) + { + next.automation_session_status = + Some(super::root_session_service::RootSessionStatus::Busy); + changed = true; + } + } + changed + }); + let result = Self::prompt_codex_session_with_retry( + &uri, + &session_id, + &prompt, + &self.config.agent.codex, + ) + .await; + match &result { + Ok(()) => tracing::info!( + workspace_key, + task_id, + session_id, + "dispatched codex prompt to fresh task session" + ), + Err(error) => tracing::warn!( + workspace_key, + task_id, + session_id, + error = %error, + "failed to dispatch codex prompt to fresh task session" + ), + } + result + } + + fn rewrite_codex_task_prompt_session_id( + prompt: &str, + previous_session_id: Option<&str>, + session_id: &str, + ) -> String { + match previous_session_id { + Some(previous_session_id) + if !previous_session_id.is_empty() && previous_session_id != session_id => + { + prompt.replace(previous_session_id, session_id) + } + _ => prompt.to_string(), + } + } + + async fn prompt_codex_session_with_retry( + uri: &str, + session_id: &str, + prompt: &str, + config: &CodexAgentConfig, + ) -> Result<(), String> { + let client = CodexAppServerClient::new(uri.to_string()); + let mut last_error: Option = None; + + for attempt in 0..15 { + match client.turn_start(session_id, prompt, config).await { + Ok(_) => return Ok(()), + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + tracing::info!( + session_id, + uri = %uri, + attempt, + error = %error, + "codex session prompt hit transient thread materialization error; waiting for thread" + ); + last_error = Some(error); + match Self::wait_for_codex_thread_ready(&client, session_id).await { + Ok(()) => {} + Err(wait_error) => { + tracing::info!( + session_id, + uri = %uri, + attempt, + error = %wait_error, + "codex thread still not ready after wait; retrying turn_start" + ); + last_error = Some(wait_error); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => return Err(error), + } + } + + Err(last_error.unwrap_or_else(|| { + format!("failed to dispatch codex prompt for session '{session_id}'") + })) + } + + async fn wait_for_codex_thread_ready( + client: &CodexAppServerClient, + session_id: &str, + ) -> Result<(), String> { + let mut last_error: Option = None; + + for attempt in 0..25 { + match client.thread_read(session_id).await { + Ok(response) => { + let ready = response.thread.status.as_ref().is_some_and(|status| { + !matches!( + status, + super::codex_app_server::CodexThreadStatus::NotLoaded + ) + }); + if ready { + return Ok(()); + } + last_error = Some(format!( + "thread '{session_id}' read succeeded but is not ready yet" + )); + } + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + + if attempt < 24 { + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + Err(last_error.unwrap_or_else(|| { + format!("timed out waiting for codex thread '{session_id}' to materialize") + })) + } + + fn is_codex_thread_materialization_error(error: &str) -> bool { + error.contains("thread not found") + || error.contains("thread not loaded") + || error.contains("not ready yet") + || error.contains("timed out waiting for codex thread") } + #[cfg_attr(not(test), allow(dead_code))] async fn build_systemd_bwrap_command( &self, key: &str, @@ -631,50 +1392,12 @@ impl CombinedService { port: u16, unit: &str, ) -> Result { - let mut args = vec!["--user".to_string(), "--no-block".to_string()]; let inherited_env = self - .sandbox_env_pairs(vec![ - ( - "OPENCODE_SERVER_USERNAME".to_string(), - "opencode".to_string(), - ), - ("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string()), - ]) + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit.to_string()); - self.append_systemd_limits(&mut args); - - self.append_bwrap_sandbox_args(&mut args, key).await?; - args.push(self.opencode_command.clone()); - args.push("serve".to_string()); - args.push("--hostname".to_string()); - args.push("127.0.0.1".to_string()); - args.push("--port".to_string()); - args.push(port.to_string()); - - Ok(SpawnCommand { - args, - inherited_env, - }) - } - - fn append_systemd_limits(&self, args: &mut Vec) { - if let Some(memory_high_bytes) = self.expanded_isolation.memory_high_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryHigh={memory_high_bytes}")); - } - if let Some(memory_max_bytes) = self.expanded_isolation.memory_max_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryMax={memory_max_bytes}")); - args.push("-p".to_string()); - args.push("MemorySwapMax=0".to_string()); - } - if let Some(cpu) = &self.expanded_isolation.cpu { - args.push("-p".to_string()); - args.push(format!("CPUQuota={cpu}")); - } + self.runtime + .build_linux_start_command(key, password, port, unit, &inherited_env) + .await } async fn sandbox_env_pairs( @@ -688,147 +1411,247 @@ impl CombinedService { .inherit_env .iter() .filter_map(|env_name| { - env::var(env_name) - .ok() - .map(|env_value| (env_name.clone(), env_value)) + inherited_env_value(env_name).map(|value| (env_name.clone(), value)) }), ); Ok(env) } - async fn append_bwrap_sandbox_args( + fn isolate_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path + .join(".multicode") + .join("isolate") + .join(key) + } + + fn persistent_snapshot_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path + .join(".multicode") + .join("persistent") + .join(format!("{key}.json")) + } + + fn transient_snapshot_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path + .join(".multicode") + .join("transient") + .join(format!("{key}.json")) + } + + async fn remove_workspace_disk_state(&self, key: &str) -> Result<(), CombinedServiceError> { + remove_path_if_exists(&self.workspace_directory_path.join(key)).await?; + remove_path_if_exists(&self.isolate_path_for_key(key)).await?; + remove_path_if_exists(&self.persistent_snapshot_path_for_key(key)).await?; + remove_path_if_exists(&self.transient_snapshot_path_for_key(key)).await?; + + for format in WorkspaceArchiveFormat::all() { + let archive_entry = ArchiveWorkspaceEntry::new(key, format); + remove_path_if_exists(&archive_entry.to_path(&self.workspace_directory_path)).await?; + remove_path_if_exists(&archive_entry.to_isolate_path(&self.workspace_directory_path)) + .await?; + } + + Ok(()) + } + + fn github_git_credentials_env_vars(&self) -> Vec<(String, String)> { + github_git_credentials_env_vars(self.github_git_credentials_env.as_ref()) + } + + async fn ensure_repository_checkout( &self, - args: &mut Vec, - key: &str, + repository: &str, + repo_root: &Path, ) -> Result<(), CombinedServiceError> { - let workspace_path = self.workspace_directory_path.join(key); - let workspace_path_str = workspace_path.to_string_lossy().into_owned(); + if path_has_git_entry(repo_root).await? { + return Ok(()); + } - args.push("bwrap".to_string()); - args.push("--chdir".to_string()); - args.push(workspace_path_str.clone()); + if tokio::fs::metadata(repo_root).await.is_ok() { + remove_path_if_exists(repo_root).await?; + } + if let Some(parent) = repo_root.parent() { + tokio::fs::create_dir_all(parent).await?; + } - args.push("--ro-bind".to_string()); - args.push("/".to_string()); - args.push("/".to_string()); + let mut command = Command::new(git_program()); + command + .args(["clone", &repository_clone_url(repository)]) + .arg(repo_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + let output = command.output().await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to clone {repository} into '{}': {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + } - let mut mount_specs = Vec::new(); - mount_specs.extend( - self.expanded_isolation - .readable - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Readable)), - ); - mount_specs.extend( - self.expanded_isolation - .writable - .iter() - .cloned() - .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), - ); - mount_specs.push(MountSpec::new( - workspace_path.clone(), - Some(workspace_path.clone()), - MountKind::Writable, - )); - mount_specs.extend( - self.expanded_isolation - .isolated - .iter() - .cloned() - .map(|path| { - let source = self.isolated_storage_path(key, &path); - MountSpec::new(path.clone(), Some(source), MountKind::Isolated) - }), - ); - mount_specs.extend( - self.expanded_isolation - .tmpfs - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), - ); - mount_specs.extend( - self.expanded_isolation - .added_skills - .iter() - .cloned() - .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), - ); - mount_specs.sort_by(|a, b| { - a.depth() - .cmp(&b.depth()) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.kind.cmp(&b.kind)) - }); + async fn ensure_task_worktree( + &self, + repo_root: &Path, + task_root: &Path, + ) -> Result<(), CombinedServiceError> { + if path_has_git_entry(task_root).await? { + return Ok(()); + } - let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); - for (index, mount_spec) in mount_specs.iter().enumerate() { - let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); - let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { - other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target - }); - let owns_source_node = owns_node - || (mount_spec.is_file - && mount_spec - .source - .as_ref() - .is_some_and(|source| source != &resolved_mount.effective_source)); - resolved_mount.prepare_source_node(owns_source_node).await?; - resolved_mount.prepare_target_node(owns_node).await?; - resolved_mounts.push(resolved_mount); + if tokio::fs::metadata(task_root).await.is_ok() { + remove_path_if_exists(task_root).await?; + } + if let Some(parent) = task_root.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut prune = Command::new(git_program()); + prune + .arg("-C") + .arg(repo_root) + .args(["worktree", "prune"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + prune.env(name, value); + } + let _ = prune.status().await; + + let base_ref = self.resolve_task_worktree_base(repo_root).await?; + let mut command = Command::new(git_program()); + command + .arg("-C") + .arg(repo_root) + .args(["worktree", "add", "--detach"]) + .arg(task_root) + .arg(&base_ref) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + let output = command.output().await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to create task worktree '{}': {}", + task_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))) } + } - for resolved_mount in resolved_mounts { - resolved_mount.append_args(args); + async fn resolve_task_worktree_base( + &self, + repo_root: &Path, + ) -> Result { + if !self.git_remote_exists(repo_root, "origin").await? { + return Ok("HEAD".to_string()); } - args.push("--proc".to_string()); - args.push("/proc".to_string()); - args.push("--dev".to_string()); - args.push("/dev".to_string()); - args.push("--die-with-parent".to_string()); + self.run_git_for_task_base( + repo_root, + ["fetch", "--prune", "origin"], + "refresh repository state from origin", + ) + .await?; + let _ = self + .run_git_for_task_base( + repo_root, + ["remote", "set-head", "origin", "--auto"], + "refresh origin default branch", + ) + .await; + + if let Some(default_branch_ref) = self + .git_stdout( + repo_root, + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + ) + .await? + { + return Ok(default_branch_ref); + } - Ok(()) + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to determine default branch tip for '{}'; origin/HEAD is unavailable", + repo_root.display() + ))) } - fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { - let relative = target - .strip_prefix("/") - .expect("isolated path is validated as absolute"); - self.isolate_path_for_key(key).join(relative) + async fn git_remote_exists( + &self, + repo_root: &Path, + remote: &str, + ) -> Result { + let output = self + .run_git_output(repo_root, ["remote", "get-url", remote]) + .await?; + Ok(output.status.success()) } - fn isolate_path_for_key(&self, key: &str) -> PathBuf { - self.workspace_directory_path - .join(".multicode") - .join("isolate") - .join(key) + async fn git_stdout( + &self, + repo_root: &Path, + args: [&str; N], + ) -> Result, CombinedServiceError> { + let output = self.run_git_output(repo_root, args).await?; + if !output.status.success() { + return Ok(None); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(None) + } else { + Ok(Some(stdout)) + } } - fn github_git_credentials_env_vars(&self) -> Vec<(String, String)> { - let Some(github_git_credentials_env) = &self.github_git_credentials_env else { - return Vec::new(); - }; + async fn run_git_for_task_base( + &self, + repo_root: &Path, + args: [&str; N], + description: &str, + ) -> Result<(), CombinedServiceError> { + let output = self.run_git_output(repo_root, args).await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to {description} for '{}': {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + } - let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; - vec![ - ( - "MULTICODE_GITHUB_USERNAME".to_string(), - github_git_credentials_env.username.clone(), - ), - ( - "MULTICODE_GITHUB_TOKEN".to_string(), - github_git_credentials_env.token.clone(), - ), - ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), - ( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ), - ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), - ] + async fn run_git_output( + &self, + repo_root: &Path, + args: [&str; N], + ) -> Result { + let mut command = Command::new(git_program()); + command + .arg("-C") + .arg(repo_root) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + Ok(command.output().await?) } async fn compress_directory_to_archive( @@ -973,18 +1796,275 @@ impl CombinedService { } } -async fn github_git_credentials_env_from_config( +fn agent_command_candidates(config: &Config) -> Vec { + match config.agent.provider { + AgentProvider::Opencode => { + if config.agent.opencode.commands.is_empty() { + config.opencode.clone() + } else { + config.agent.opencode.commands.clone() + } + } + AgentProvider::Codex => config.agent.codex.commands.clone(), + } +} + +fn resolve_container_agent_command( + provider: AgentProvider, + backend: crate::RuntimeBackend, config: &Config, - github_status_service: &GithubStatusService, -) -> Result, CombinedServiceError> { - if !config.github.populate_git_credentials { - return Ok(None); +) -> String { + let candidates = agent_command_candidates(config); + if provider == AgentProvider::Codex { + return "codex".to_string(); } - if config.github.token.is_none() { - return Err(CombinedServiceError::GithubGitCredentials( - "`github.populate-git-credentials` requires `github.token` to be configured" - .to_string(), - )); + + if backend == crate::RuntimeBackend::AppleContainer { + return candidates + .iter() + .filter_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + let name = Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate); + if name == "opencode" { + Some("opencode".to_string()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| "opencode".to_string()); + } + + candidates + .iter() + .find_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + Some( + Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate) + .to_string(), + ) + }) + .unwrap_or_else(|| "opencode".to_string()) +} + +fn github_git_credentials_env_vars( + github_git_credentials_env: Option<&GithubGitCredentialsEnv>, +) -> Vec<(String, String)> { + let Some(github_git_credentials_env) = github_git_credentials_env else { + return Vec::new(); + }; + + let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; + vec![ + ( + "MULTICODE_GITHUB_USERNAME".to_string(), + github_git_credentials_env.username.clone(), + ), + ( + "MULTICODE_GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GH_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ), + ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), + ] +} + +async fn path_has_git_entry(path: &Path) -> Result { + match tokio::fs::symlink_metadata(path.join(".git")).await { + Ok(_) => { + let output = Command::new(git_program()) + .arg("-C") + .arg(path) + .args(["rev-parse", "--git-dir"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await?; + Ok(output.status.success()) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.into()), + } +} + +fn repository_repo_name(repository: &str) -> &str { + repository.rsplit('/').next().unwrap_or(repository) +} + +fn issue_url_number(issue_url: &str) -> Option<&str> { + let issue_number = issue_url.rsplit('/').next()?.trim(); + (!issue_number.is_empty()).then_some(issue_number) +} + +fn repository_clone_url(repository: &str) -> String { + format!("https://github.com/{repository}.git") +} + +fn git_program() -> String { + for candidate in [ + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + "git", + ] { + let path = Path::new(candidate); + let available = if path.components().count() > 1 { + std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + } else { + std::env::var_os("PATH").is_some_and(|path_var| { + std::env::split_paths(&path_var) + .map(|directory| directory.join(candidate)) + .any(|resolved| { + std::fs::metadata(&resolved) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + }) + }) + }; + if available { + return candidate.to_string(); + } + } + + "git".to_string() +} + +async fn remove_path_if_exists(path: &Path) -> Result<(), CombinedServiceError> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.is_dir() => { + tokio::fs::remove_dir_all(path).await?; + Ok(()) + } + Ok(_) => { + tokio::fs::remove_file(path).await?; + Ok(()) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +async fn strip_workspace_git_identity_overrides( + workspace_path: &Path, +) -> Result<(), CombinedServiceError> { + let workspace_path = workspace_path.to_path_buf(); + let repo_roots = tokio::task::spawn_blocking(move || find_git_repo_roots(&workspace_path)) + .await + .map_err(|err| std::io::Error::other(err.to_string()))??; + + for repo_root in repo_roots { + unset_repo_local_git_config(&repo_root, "user.name").await?; + unset_repo_local_git_config(&repo_root, "user.email").await?; + } + + Ok(()) +} + +fn find_git_repo_roots(workspace_path: &Path) -> Result, std::io::Error> { + let mut stack = vec![workspace_path.to_path_buf()]; + let mut repo_roots = std::collections::BTreeSet::new(); + + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if entry.file_name() == ".git" { + if git_repository_is_valid(&directory)? { + repo_roots.insert(directory.clone()); + } + continue; + } + if file_type.is_dir() { + stack.push(path); + } + } + } + + Ok(repo_roots.into_iter().collect()) +} + +fn git_repository_is_valid(path: &Path) -> Result { + let output = std::process::Command::new(git_program()) + .arg("-C") + .arg(path) + .args(["rev-parse", "--git-dir"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output()?; + Ok(output.status.success()) +} + +async fn unset_repo_local_git_config( + repo_root: &Path, + key: &str, +) -> Result<(), CombinedServiceError> { + let output = Command::new(git_program()) + .arg("-C") + .arg(repo_root) + .args(["config", "--local", "--unset-all", key]) + .stdin(Stdio::null()) + .output() + .await?; + + if output.status.success() || output.status.code() == Some(5) { + return Ok(()); + } + + Err(std::io::Error::other(format!( + "failed to remove repo-local git config {key} from {}: {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + )) + .into()) +} + +async fn github_git_credentials_env_from_config( + config: &Config, + github_status_service: &GithubStatusService, +) -> Result, CombinedServiceError> { + if !config.github.populate_git_credentials { + return Ok(None); + } + if config.github.token.is_none() { + return Err(CombinedServiceError::GithubGitCredentials( + "`github.populate-git-credentials` requires `github.token` to be configured" + .to_string(), + )); } let token = github_status_service.resolved_github_token().await?; @@ -1041,7 +2121,24 @@ pub enum CombinedServiceError { field: String, message: String, }, + InvalidRuntimeConfig { + field: String, + message: String, + }, InvalidToolExecution(String), + InvalidRepositorySpec(String), + InvalidIssueSpec(String), + RepositoryPreparation(String), + UnsupportedRuntimeBackend(String), + WorkspaceRepositoryRequired(String), + WorkspaceTaskMissing { + key: String, + task_id: String, + }, + WorkspaceHasTasks { + key: String, + task_count: usize, + }, WorkspaceArchived(String), WorkspaceNotArchived(String), ArchiveWorkspaceRunning(String), @@ -1054,11 +2151,109 @@ pub enum CombinedServiceError { key: String, status: Option, }, - OpencodeCommandNotFound { + AgentCommandNotFound { candidates: Vec, }, } +impl CombinedServiceError { + pub fn summary(&self) -> String { + match self { + Self::StartWorkspaceFailed { status, stderr } => { + summarize_workspace_start_failure(*status, stderr) + } + Self::StopWorkspaceFailed { status, stderr } => { + summarize_workspace_stop_failure(*status, stderr) + } + _ => format!("{self:?}"), + } + } +} + +fn normalize_repository_spec(repository: &str) -> Result { + super::autonomous_workspace_service::normalize_github_repository_spec(repository) + .ok_or_else(|| CombinedServiceError::InvalidRepositorySpec(repository.trim().to_string())) +} + +fn resolve_task_repository( + snapshot: &crate::WorkspaceSnapshot, + task: &crate::WorkspaceTaskPersistentSnapshot, +) -> Option { + snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(super::autonomous_workspace_service::normalize_github_repository_spec) + .or_else(|| { + super::autonomous_workspace_service::normalize_github_repository_spec(&task.issue_url) + }) + .or_else(|| { + task.backing_pr_url + .as_deref() + .and_then(super::autonomous_workspace_service::normalize_github_repository_spec) + }) +} + +fn normalize_issue_spec( + assigned_repository: &str, + issue: &str, +) -> Result { + super::autonomous_workspace_service::normalize_github_issue_spec(assigned_repository, issue) + .ok_or_else(|| CombinedServiceError::InvalidIssueSpec(issue.trim().to_string())) +} + +fn format_issue_reference(issue_url: &str) -> String { + super::autonomous_workspace_service::issue_reference(issue_url) + .unwrap_or_else(|| issue_url.to_string()) +} + +pub fn summarize_workspace_start_failure(status: Option, stderr: &str) -> String { + let stderr = compact_process_stderr(stderr); + if stderr.contains("no free indices are available for allocation") { + return format!( + "Apple container vmnet allocator exhausted{}; restart the Apple container backend", + exit_status_suffix(status), + ); + } + + if stderr.is_empty() { + format!("workspace start failed{}", exit_status_suffix(status)) + } else { + format!( + "workspace start failed{}: {stderr}", + exit_status_suffix(status), + ) + } +} + +fn summarize_workspace_stop_failure(status: Option, stderr: &str) -> String { + let stderr = compact_process_stderr(stderr); + if stderr.is_empty() { + format!("workspace stop failed{}", exit_status_suffix(status)) + } else { + format!( + "workspace stop failed{}: {stderr}", + exit_status_suffix(status), + ) + } +} + +fn compact_process_stderr(stderr: &str) -> String { + stderr + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .trim_matches('"') + .to_string() +} + +fn exit_status_suffix(status: Option) -> String { + status + .map(|status| format!(" (exit {status})")) + .unwrap_or_default() +} + impl From for CombinedServiceError { fn from(value: std::io::Error) -> Self { Self::Io(value) @@ -1095,38 +2290,7 @@ impl From for CombinedServiceError { } } -fn generate_random_password() -> String { - Uuid::new_v4().as_simple().to_string() -} - -fn generate_transient_unit_name() -> String { - format!("multicode-{}.service", Uuid::new_v4().as_simple()) -} - -async fn pick_random_free_port() -> Result { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -async fn stop_systemd_unit(unit: &str) -> Result<(), CombinedServiceError> { - let args = stop_systemd_args(unit); - let output = Command::new("systemctl") - .args(args) - .stdin(Stdio::null()) - .output() - .await?; - if output.status.success() { - Ok(()) - } else { - Err(CombinedServiceError::StopWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } -} - +#[cfg_attr(not(test), allow(dead_code))] fn stop_systemd_args(unit: &str) -> Vec { vec![ "--user".to_string(), @@ -1156,6 +2320,14 @@ fn spawn_transient_storage(manager: Arc, transient_link: PathB }); } +fn spawn_runtime_reconciliation_service(manager: Arc, runtime: WorkspaceRuntime) { + tokio::spawn(async move { + if let Err(err) = runtime_reconciliation_service(manager, runtime).await { + tracing::error!(error = ?err, "runtime reconciliation service exited with error"); + } + }); +} + fn spawn_opencode_client_service(manager: Arc) { tokio::spawn(async move { if let Err(err) = opencode_client_service(manager).await { @@ -1172,6 +2344,20 @@ fn spawn_root_session_service(manager: Arc) { }); } +fn spawn_codex_root_session_service( + manager: Arc, + workspace_directory_path: PathBuf, + config: super::config::CodexAgentConfig, +) { + tokio::spawn(async move { + if let Err(err) = + codex_root_session_service(manager, workspace_directory_path, config).await + { + tracing::error!(error = ?err, "codex root session service exited with error"); + } + }); +} + fn spawn_multicode_metadata_service(manager: Arc) { tokio::spawn(async move { if let Err(err) = multicode_metadata_service(manager).await { @@ -1196,10 +2382,34 @@ fn spawn_resource_usage_service(manager: Arc) { }); } +fn spawn_automation_state_file_service( + manager: Arc, + workspace_directory_path: PathBuf, +) { + tokio::spawn(async move { + if let Err(err) = automation_state_file_service(manager, workspace_directory_path).await { + tracing::error!(error = ?err, "automation state file service terminated"); + } + }); +} + +fn spawn_autonomous_workspace_service(service: CombinedService) { + tokio::spawn(async move { + if let Err(err) = autonomous_workspace_service(service).await { + tracing::error!(error = ?err, "autonomous workspace service exited with error"); + } + }); +} + #[cfg(test)] mod tests { use super::*; - use crate::services::{GithubTokenConfig, ToolType}; + use crate::WorkspaceSnapshot; + use crate::services::{ + CompareTool, GithubTokenConfig, ToolType, + config::{CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}, + runtime::{MountKind, MountSpec}, + }; use crate::test_support::ENV_VAR_LOCK; use diesel::{QueryableByName, RunQueryDsl, sql_query, sqlite::SqliteConnection}; use std::os::unix::fs::PermissionsExt; @@ -1229,6 +2439,19 @@ mod tests { time::{Duration, SystemTime, UNIX_EPOCH}, }; + #[test] + fn summarize_workspace_start_failure_reports_allocator_exhaustion_hint() { + let summary = summarize_workspace_start_failure( + Some(1), + r#"Error: failed to bootstrap container (cause: "unknown: "no free indices are available for allocation"")"#, + ); + + assert_eq!( + summary, + "Apple container vmnet allocator exhausted (exit 1); restart the Apple container backend" + ); + } + struct TestDir { path: PathBuf, } @@ -1272,6 +2495,14 @@ mod tests { } Self { key, old_value } } + + fn set_value(key: &'static str, value: impl Into) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::set_var(key, value.into()); + } + Self { key, old_value } + } } impl Drop for EnvVarGuard { @@ -1294,6 +2525,22 @@ mod tests { ) } + fn default_config() -> Config { + Config { + workspace_directory: "~/dev/multicode-workspaces".to_string(), + isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), + agent: Default::default(), + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + compare: Default::default(), + tool: Vec::new(), + handler: Default::default(), + remote: None, + github: Default::default(), + } + } + #[test] fn config_parses_github_token_env_source() { let config: Config = toml::from_str( @@ -1313,91 +2560,359 @@ token = { env = "GITHUB_TOKEN" } Some(GithubTokenConfig { env: Some("GITHUB_TOKEN".to_string()), command: None, + keychain_service: None, + keychain_account: None, }) ); } #[test] - fn config_parses_github_populate_git_credentials_flag() { + fn config_parses_codex_agent_provider_settings() { let config: Config = toml::from_str( r#" workspace-directory = "/tmp/workspaces" -[github] -populate-git-credentials = true -token = { env = "GITHUB_TOKEN" } +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex-nightly", "codex"] +profile = "default" +model = "gpt-5-codex" +model-provider = "openai" [isolation] "#, ) .expect("config should parse"); - assert!(config.github.populate_git_credentials); + assert_eq!(config.agent.provider, AgentProvider::Codex); + assert_eq!(config.agent.codex.commands, vec!["codex-nightly", "codex"]); + assert_eq!(config.agent.codex.profile.as_deref(), Some("default")); + assert_eq!(config.agent.codex.model.as_deref(), Some("gpt-5-codex")); + assert_eq!(config.agent.codex.model_provider.as_deref(), Some("openai")); assert_eq!( - config.github.token, - Some(GithubTokenConfig { - env: Some("GITHUB_TOKEN".to_string()), - command: None, - }) + config.agent.codex.approval_policy, + CodexApprovalPolicy::OnRequest + ); + assert_eq!( + config.agent.codex.sandbox_mode, + CodexSandboxMode::WorkspaceWrite + ); + assert_eq!( + config.agent.codex.network_access, + CodexNetworkAccess::Enabled ); } #[test] - fn config_parses_github_token_command_source() { + fn config_parses_compare_settings() { let config: Config = toml::from_str( r#" workspace-directory = "/tmp/workspaces" -[github] -token = { command = "gh auth token" } +[compare] +tool = "intellij" +command = "~/Library/Application Support/JetBrains/Toolbox/scripts/idea" [isolation] "#, ) .expect("config should parse"); + assert_eq!(config.compare.tool, CompareTool::Intellij); assert_eq!( - config.github.token, - Some(GithubTokenConfig { - env: None, - command: Some("gh auth token".to_string()), - }) + config.compare.command.as_deref(), + Some("~/Library/Application Support/JetBrains/Toolbox/scripts/idea") ); } - fn make_executable(path: &Path) { - let mut perms = fs::metadata(path) - .expect("executable metadata should be readable") - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms).expect("executable permissions should be set"); + #[test] + fn codex_thread_materialization_errors_include_not_ready_variants() { + assert!(CombinedService::is_codex_thread_materialization_error( + "thread not found: 123" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "thread not loaded: 123" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "thread '123' read succeeded but is not ready yet" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "timed out waiting for codex thread '123' to materialize" + )); + assert!(!CombinedService::is_codex_thread_materialization_error( + "permission denied" + )); } #[test] - fn github_git_credentials_helper_script_returns_expected_helper() { + fn config_parses_autonomous_scan_on_startup_flag() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[autonomous] +scan-on-startup = false + +[isolation] +"#, + ) + .expect("config should parse"); + + assert!(!config.autonomous.scan_on_startup); + } + + #[test] + fn config_parses_codex_autonomy_settings() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[agent] +provider = "codex" + +[agent.codex] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!(config.agent.provider, AgentProvider::Codex); assert_eq!( - r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#, - r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"# + config.agent.codex.approval_policy, + CodexApprovalPolicy::Never + ); + assert_eq!( + config.agent.codex.sandbox_mode, + CodexSandboxMode::ExternalSandbox + ); + assert_eq!( + config.agent.codex.network_access, + CodexNetworkAccess::Enabled ); } #[test] - fn github_git_credentials_env_requires_token_when_enabled() { + fn runtime_config_prefers_provider_specific_images_when_global_override_is_absent() { let config: Config = toml::from_str( r#" workspace-directory = "/tmp/workspaces" -[github] -populate-git-credentials = true +[runtime] +backend = "apple-container" +opencode-image = "example/opencode:latest" +codex-image = "example/codex:latest" [isolation] "#, ) .expect("config should parse"); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() + assert_eq!( + config.runtime.resolved_image(AgentProvider::Opencode), + Some("example/opencode:latest") + ); + assert_eq!( + config.runtime.resolved_image(AgentProvider::Codex), + Some("example/codex:latest") + ); + } + + #[test] + fn resolve_container_agent_command_prefers_opencode_for_apple_backend() { + assert_eq!( + resolve_container_agent_command( + AgentProvider::Opencode, + crate::RuntimeBackend::AppleContainer, + &Config { + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + ..default_config() + } + ), + "opencode" + ); + assert_eq!( + resolve_container_agent_command( + AgentProvider::Opencode, + crate::RuntimeBackend::AppleContainer, + &Config { + opencode: vec!["/opt/homebrew/bin/opencode-cli".to_string()], + ..default_config() + } + ), + "opencode" + ); + } + + #[test] + fn resolve_container_agent_command_keeps_first_candidate_for_linux_backend() { + assert_eq!( + resolve_container_agent_command( + AgentProvider::Opencode, + crate::RuntimeBackend::LinuxSystemdBwrap, + &Config { + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + ..default_config() + } + ), + "opencode-cli" + ); + } + + #[test] + fn config_parses_github_populate_git_credentials_flag() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[github] +populate-git-credentials = true +token = { env = "GITHUB_TOKEN" } + +[isolation] +"#, + ) + .expect("config should parse"); + + assert!(config.github.populate_git_credentials); + assert_eq!( + config.github.token, + Some(GithubTokenConfig { + env: Some("GITHUB_TOKEN".to_string()), + command: None, + keychain_service: None, + keychain_account: None, + }) + ); + } + + #[test] + fn config_parses_github_token_command_source() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[github] +token = { command = "gh auth token" } + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!( + config.github.token, + Some(GithubTokenConfig { + env: None, + command: Some("gh auth token".to_string()), + keychain_service: None, + keychain_account: None, + }) + ); + } + + #[test] + fn config_parses_github_token_keychain_source() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[github] +token = { keychain-service = "multicode.github", keychain-account = "github-mcp-token" } + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!( + config.github.token, + Some(GithubTokenConfig { + env: None, + command: None, + keychain_service: Some("multicode.github".to_string()), + keychain_account: Some("github-mcp-token".to_string()), + }) + ); + } + + fn make_executable(path: &Path) { + let mut perms = fs::metadata(path) + .expect("executable metadata should be readable") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("executable permissions should be set"); + } + + fn run_git(repo_root: &Path, args: &[&str]) { + let status = std::process::Command::new(git_program()) + .arg("-C") + .arg(repo_root) + .args(args) + .status() + .expect("git command should run"); + assert!( + status.success(), + "git command should succeed: git -C {repo_root:?} {}", + args.join(" ") + ); + } + + fn init_test_git_repository(repo_root: &Path) { + fs::create_dir_all(repo_root).expect("repo root should exist"); + run_git(repo_root, &["init", "--initial-branch=main"]); + run_git(repo_root, &["config", "user.email", "test@example.com"]); + run_git(repo_root, &["config", "user.name", "Test User"]); + fs::write(repo_root.join("README.md"), "hello\n").expect("repo file should be written"); + run_git(repo_root, &["add", "README.md"]); + run_git(repo_root, &["commit", "-m", "initial"]); + } + + fn git_stdout(repo_root: &Path, args: &[&str]) -> String { + let output = std::process::Command::new(git_program()) + .arg("-C") + .arg(repo_root) + .args(args) + .output() + .expect("git command should run"); + assert!( + output.status.success(), + "git command should succeed: git -C {repo_root:?} {}", + args.join(" ") + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + #[test] + fn github_git_credentials_helper_script_returns_expected_helper() { + assert_eq!( + r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#, + r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"# + ); + } + + #[test] + fn github_git_credentials_env_requires_token_when_enabled() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[github] +populate-git-credentials = true + +[isolation] +"#, + ) + .expect("config should parse"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .expect("tokio runtime should build"); let err = runtime.block_on(async { @@ -1550,21 +3065,31 @@ populate-git-credentials = true } #[test] - fn combined_service_requires_workspace_directory_in_config() { + fn combined_service_uses_default_workspace_directory_when_omitted() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime should build"); runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let root = TestDir::new(); + let home = root.path().join("home"); + fs::create_dir_all(home.join("dev/multicode-workspaces")) + .expect("default workspace directory should exist"); + let _home_guard = EnvVarGuard::set("HOME", &home); let config_path = root.path().join("config.toml"); fs::write(&config_path, "[isolation]\n").expect("config should be written"); - let err = CombinedService::from_config_path(&config_path) + let service = CombinedService::from_config_path(&config_path) .await - .expect_err("config without workspace_directory should fail"); - assert!(matches!(err, CombinedServiceError::ParseToml(_))); + .expect("config without workspace_directory should use default"); + assert_eq!( + service.config.workspace_directory, + "~/dev/multicode-workspaces" + ); }); } @@ -1650,23 +3175,1163 @@ populate-git-credentials = true .await .expect("combined service should start"); service - .create_workspace("beta") + .create_workspace("beta") + .await + .expect("first workspace creation should succeed"); + + let err = service + .create_workspace("beta") + .await + .expect_err("duplicate workspace creation should fail"); + assert!(matches!( + err, + CombinedServiceError::Manager(WorkspaceManagerError::WorkspaceAlreadyExists(key)) if key == "beta" + )); + }); + } + + #[test] + fn combined_service_resolves_first_available_agent_command() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace directory should exist"); + + let fallback = bin_dir.join("opencode"); + fs::write(&fallback, "#!/bin/sh\nexit 0\n").expect("fallback command should be written"); + make_executable(&fallback); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode-cli\", \"opencode\"]\ncreate-ssh-agent = false\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + assert_eq!(service.config.opencode, vec!["opencode-cli", "opencode"]); + assert_eq!(service.agent_command(), fallback.to_string_lossy()); + }); + } + + #[test] + fn combined_service_fails_when_no_agent_command_is_available() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let empty_bin = root.path().join("empty-bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&empty_bin).expect("empty bin should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace directory should exist"); + + let _path_guard = EnvVarGuard::set("PATH", &empty_bin); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"missing-a\", \"missing-b\"]\ncreate-ssh-agent = false\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let err = CombinedService::from_config_path(&config_path) + .await + .expect_err("missing commands should fail"); + + match err { + CombinedServiceError::AgentCommandNotFound { candidates } => { + assert_eq!(candidates, vec!["missing-a", "missing-b"]); + } + other => panic!("unexpected error: {other:?}"), + } + }); + } + + #[test] + fn start_workspace_builds_github_git_credentials_bind_mount_when_enabled() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime should exist"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config: Config = toml::from_str( + r#" +workspace-directory = "~/workspaces" + +[github] +populate-git-credentials = true +token = { command = "printf test-token" } + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR"] +"#, + ) + .expect("config should parse"); + + let service = CombinedService::from_config(config) + .await + .expect_err("startup should fail without live GitHub username lookup in test env"); + assert!(matches!( + service, + CombinedServiceError::GithubStatusService(_) + )); + }); + } + + #[test] + fn github_git_credentials_env_vars_include_helper_and_secrets() { + let env_vars = github_git_credentials_env_vars(Some(&GithubGitCredentialsEnv { + username: "sandbox-user".to_string(), + token: "secret-token".to_string(), + })); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_USERNAME".to_string(), + "sandbox-user".to_string(), + ))); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_TOKEN".to_string(), + "secret-token".to_string(), + ))); + assert!(env_vars.contains(&("GH_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GITHUB_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GIT_CONFIG_COUNT".to_string(), "1".to_string(),))); + assert!(env_vars.contains(&( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ))); + assert!(env_vars.contains(&( + "GIT_CONFIG_VALUE_0".to_string(), + r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#.to_string(), + ))); + } + + #[test] + fn assign_workspace_repository_normalizes_and_clears_automation_issue() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = + EnvVarGuard::set_value("PATH", "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"{}\"]\n\n[isolation]\n", + workspace_directory.display(), + fake_opencode.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issue/1".to_string()); + true + }); + + let normalized = service + .assign_workspace_repository("alpha", Some("https://github.com/example/repo.git")) + .await + .expect("repository assignment should succeed"); + assert_eq!(normalized.as_deref(), Some("example/repo")); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert_eq!( + snapshot.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + assert!(snapshot.persistent.automation_issue.is_none()); + assert!(snapshot.persistent.tasks.is_empty()); + assert_eq!(snapshot.automation_scan_request_nonce, 1); + + let cleared = service + .assign_workspace_repository("alpha", None) + .await + .expect("clearing repository assignment should succeed"); + assert!(cleared.is_none()); + let cleared_snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert!(cleared_snapshot.persistent.assigned_repository.is_none()); + assert!(cleared_snapshot.persistent.tasks.is_empty()); + assert_eq!(cleared_snapshot.automation_scan_request_nonce, 1); + }); + } + + #[test] + fn assign_workspace_issue_creates_manual_task_and_requests_autonomous_start() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + let normalized = service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + assert_eq!( + normalized.as_deref(), + Some("https://github.com/example/repo/issues/42") + ); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert!(snapshot.persistent.automation_issue.is_none()); + assert_eq!(snapshot.persistent.tasks.len(), 1); + assert_eq!( + snapshot.persistent.tasks[0].issue_url, + "https://github.com/example/repo/issues/42" + ); + assert_eq!( + snapshot.persistent.tasks[0].source, + WorkspaceTaskSource::Manual + ); + assert_eq!(snapshot.automation_scan_request_nonce, 2); + + let cleared = service + .assign_workspace_issue("alpha", None) + .await + .expect("empty issue assignment should be ignored"); + assert!(cleared.is_none()); + + let cleared_snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert!(cleared_snapshot.persistent.automation_issue.is_none()); + assert_eq!(cleared_snapshot.persistent.tasks.len(), 1); + assert_eq!(cleared_snapshot.automation_scan_request_nonce, 2); + }); + } + + #[test] + fn assign_workspace_issue_removes_matching_ignored_issue() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + workspace.update(|snapshot| { + snapshot + .persistent + .ignored_issue_urls + .push("https://github.com/example/repo/issues/42".to_string()); + true + }); + + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.persistent.ignored_issue_urls.is_empty()); + assert_eq!(snapshot.persistent.tasks.len(), 1); + }); + } + + #[test] + fn assign_workspace_issue_does_not_duplicate_existing_task() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("initial issue assignment should succeed"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("duplicate issue assignment should succeed"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert_eq!(snapshot.persistent.tasks.len(), 1); + }); + } + + #[test] + fn request_workspace_issue_scan_clears_manual_pause() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.automation_paused = true; + true + }); + + service + .request_workspace_issue_scan("alpha") + .expect("scan request should succeed"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + + assert!(!snapshot.persistent.automation_paused); + assert_eq!(snapshot.automation_scan_request_nonce, 2); + }); + } + + #[test] + fn request_workspace_queue_next_clears_manual_pause_and_increments_queue_nonce() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.automation_paused = true; + true + }); + + service + .request_workspace_queue_next("alpha") + .expect("queue-next request should succeed"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + + assert!(!snapshot.persistent.automation_paused); + assert_eq!(snapshot.automation_scan_request_nonce, 1); + assert_eq!(snapshot.automation_queue_next_request_nonce, 1); + assert_eq!( + snapshot.automation_status.as_deref(), + Some("Queue next requested for example/repo") + ); + }); + } + + #[test] + fn delete_workspace_stops_runtime_and_removes_workspace_state() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let stop_log = root.path().join("systemctl-stop.log"); + let fake_systemctl = bin_dir.join("systemctl"); + fs::write( + &fake_systemctl, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> '{}'\nprintf -- '---\\n' >> '{}'\nexit 0\n", + stop_log.display(), + stop_log.display() + ), + ) + .expect("fake systemctl should be written"); + make_executable(&fake_systemctl); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + workspace.update(|snapshot| { + snapshot.transient = Some(crate::TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: crate::RuntimeHandleSnapshot { + backend: crate::RuntimeBackend::LinuxSystemdBwrap, + id: "alpha.service".to_string(), + metadata: Default::default(), + }, + }); + true + }); + + let live_dir = workspace_directory.join("alpha"); + let isolate_dir = workspace_directory.join(".multicode").join("isolate").join("alpha"); + let persistent_snapshot = workspace_directory + .join(".multicode") + .join("persistent") + .join("alpha.json"); + let transient_snapshot = workspace_directory + .join(".multicode") + .join("transient") + .join("alpha.json"); + let archive_path = + ArchiveWorkspaceEntry::new("alpha", WorkspaceArchiveFormat::TarZstd) + .to_path(&workspace_directory); + let isolate_archive_path = + ArchiveWorkspaceEntry::new("alpha", WorkspaceArchiveFormat::TarZstd) + .to_isolate_path(&workspace_directory); + + tokio::fs::create_dir_all(&live_dir) + .await + .expect("live dir should exist"); + tokio::fs::create_dir_all(&isolate_dir) + .await + .expect("isolate dir should exist"); + tokio::fs::write(live_dir.join("README.md"), "workspace") + .await + .expect("workspace file should exist"); + tokio::fs::write(isolate_dir.join("marker.txt"), "isolate") + .await + .expect("isolate file should exist"); + tokio::fs::write(&persistent_snapshot, "{}") + .await + .expect("persistent snapshot should exist"); + tokio::fs::write(&transient_snapshot, "{}") + .await + .expect("transient snapshot should exist"); + if let Some(parent) = archive_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .expect("archive parent should exist"); + } + if let Some(parent) = isolate_archive_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .expect("isolate archive parent should exist"); + } + tokio::fs::write(&archive_path, "archive") + .await + .expect("archive should exist"); + tokio::fs::write(&isolate_archive_path, "isolate archive") + .await + .expect("isolate archive should exist"); + + service + .delete_workspace("alpha") + .await + .expect("workspace deletion should succeed"); + + assert!(service.manager.get_workspace("alpha").is_err()); + assert!(!live_dir.exists()); + assert!(!isolate_dir.exists()); + assert!(!persistent_snapshot.exists()); + assert!(!transient_snapshot.exists()); + assert!(!archive_path.exists()); + assert!(!isolate_archive_path.exists()); + + let stop_invocation = + fs::read_to_string(&stop_log).expect("runtime stop should be recorded"); + assert!(stop_invocation.contains("alpha.service")); + }); + } + + #[test] + fn delete_workspace_rejects_workspace_with_tasks() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let err = service + .delete_workspace("alpha") + .await + .expect_err("workspace deletion should be rejected while tasks exist"); + assert!(matches!( + err, + CombinedServiceError::WorkspaceHasTasks { + key, + task_count: 1 + } if key == "alpha" + )); + }); + } + + #[test] + fn delete_workspace_task_removes_task_and_clears_active_session_fields() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + let task_id = workspace + .subscribe() + .borrow() + .persistent + .tasks + .first() + .expect("task should exist") + .id + .clone(); + workspace.update(|snapshot| { + snapshot.active_task_id = Some(task_id.clone()); + snapshot.automation_session_id = Some("ses-task".to_string()); + snapshot.automation_agent_state = Some(crate::AutomationAgentState::Working); + snapshot.automation_session_status = Some(crate::RootSessionStatus::Busy); + snapshot.task_states.insert( + task_id.clone(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-task".to_string()), + agent_state: Some(crate::AutomationAgentState::Working), + session_status: Some(crate::RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + service + .delete_workspace_task("alpha", &task_id) + .await + .expect("task deletion should succeed"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.persistent.tasks.is_empty()); + assert!(snapshot.active_task_id.is_none()); + assert!(snapshot.automation_session_id.is_none()); + assert!(snapshot.automation_agent_state.is_none()); + assert!(snapshot.automation_session_status.is_none()); + assert!(!snapshot.task_states.contains_key(&task_id)); + }); + } + + #[test] + fn remove_workspace_task_can_ignore_issue_for_future_scans() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[]}'\n exit 0\nfi\nexit 0\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[github]\ntoken = {{ command = \"printf test-token\" }}\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + let task_id = workspace + .subscribe() + .borrow() + .persistent + .tasks + .first() + .expect("task should exist") + .id + .clone(); + + service + .remove_workspace_task("alpha", &task_id, true) + .await + .expect("task removal with ignore should succeed"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.persistent.tasks.is_empty()); + assert_eq!( + snapshot.persistent.ignored_issue_urls, + vec!["https://github.com/example/repo/issues/42".to_string()] + ); + }); + } + + #[test] + fn remove_and_ignore_workspace_task_clears_issue_assignment_and_in_progress_label() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let gh_log = root.path().join("gh.log"); + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> '{}'\nprintf -- '---\\n' >> '{}'\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[{{\"name\":\"status: in-progress\"}}],\"assignees\":[{{\"login\":\"graemerocher\"}}]}}'\n exit 0\nfi\nexit 0\n", + gh_log.display(), + gh_log.display() + ), + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[github]\ntoken = {{ command = \"printf test-token\" }}\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") .await - .expect("first workspace creation should succeed"); + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); - let err = service - .create_workspace("beta") + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + let task_id = workspace + .subscribe() + .borrow() + .persistent + .tasks + .first() + .expect("task should exist") + .id + .clone(); + + service + .remove_workspace_task("alpha", &task_id, true) .await - .expect_err("duplicate workspace creation should fail"); - assert!(matches!( - err, - CombinedServiceError::Manager(WorkspaceManagerError::WorkspaceAlreadyExists(key)) if key == "beta" + .expect("task removal with ignore should succeed"); + + let gh_calls = fs::read_to_string(&gh_log).expect("gh log should exist"); + assert!(gh_calls.contains("issue\nview\nhttps://github.com/example/repo/issues/42")); + assert!(gh_calls.contains( + "issue\nedit\nhttps://github.com/example/repo/issues/42\n--repo\nexample/repo\n--remove-label\nstatus: in-progress" + )); + assert!(gh_calls.contains( + "issue\nedit\nhttps://github.com/example/repo/issues/42\n--repo\nexample/repo\n--remove-assignee\n@me" )); }); } #[test] - fn combined_service_resolves_first_available_opencode_command() { + fn ensure_and_remove_workspace_task_checkout_manage_git_worktree() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1682,13 +4347,13 @@ populate-git-credentials = true let runtime_dir = root.path().join("runtime"); let workspace_directory = home.join("workspaces"); fs::create_dir_all(&bin_dir).expect("bin dir should exist"); - fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); - fs::create_dir_all(&workspace_directory).expect("workspace directory should exist"); - let fallback = bin_dir.join("opencode"); - fs::write(&fallback, "#!/bin/sh\nexit 0\n").expect("fallback command should be written"); - make_executable(&fallback); + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); let _path_guard = EnvVarGuard::set("PATH", &bin_dir); let _home_guard = EnvVarGuard::set("HOME", &home); @@ -1698,7 +4363,7 @@ populate-git-credentials = true fs::write( &config_path, format!( - "workspace-directory = \"{}\"\nopencode = [\"opencode-cli\", \"opencode\"]\ncreate-ssh-agent = false\n\n[isolation]\n", + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", workspace_directory.display() ), ) @@ -1707,14 +4372,59 @@ populate-git-credentials = true let service = CombinedService::from_config_path(&config_path) .await .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); - assert_eq!(service.config.opencode, vec!["opencode-cli", "opencode"]); - assert_eq!(service.opencode_command(), fallback.to_string_lossy()); + let repo_root = service.workspace_repo_root_path("alpha", "example/repo"); + init_test_git_repository(&repo_root); + + let issue_url = "https://github.com/example/repo/issues/42"; + let task_root = service + .ensure_workspace_task_checkout("alpha", "example/repo", issue_url) + .await + .expect("task checkout should be prepared"); + + assert_eq!( + task_root, + service.workspace_task_checkout_path("alpha", "example/repo", issue_url) + ); + assert!( + tokio::fs::symlink_metadata(task_root.join(".git")) + .await + .expect("worktree should have git entry") + .file_type() + .is_file(), + "git worktree should expose a .git file" + ); + assert!( + tokio::fs::metadata(task_root.join("README.md")) + .await + .is_ok(), + "worktree should contain repository files" + ); + + service + .remove_workspace_task_checkout("alpha", "example/repo", issue_url) + .await + .expect("task checkout should be removed"); + + assert!( + tokio::fs::metadata(&task_root).await.is_err(), + "task worktree should be removed" + ); + assert!( + tokio::fs::symlink_metadata(repo_root.join(".git")) + .await + .is_ok(), + "base checkout should remain" + ); }); } #[test] - fn combined_service_fails_when_no_opencode_command_is_available() { + fn task_worktree_uses_origin_default_branch_tip_instead_of_local_repo_head() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1725,16 +4435,22 @@ populate-git-credentials = true .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let root = TestDir::new(); - let empty_bin = root.path().join("empty-bin"); + let bin_dir = root.path().join("bin"); let home = root.path().join("home"); let runtime_dir = root.path().join("runtime"); let workspace_directory = home.join("workspaces"); - fs::create_dir_all(&empty_bin).expect("empty bin should exist"); - fs::create_dir_all(&home).expect("home should exist"); + let remote_root = root.path().join("remote.git"); + let source_root = root.path().join("source"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); - fs::create_dir_all(&workspace_directory).expect("workspace directory should exist"); - let _path_guard = EnvVarGuard::set("PATH", &empty_bin); + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); @@ -1742,176 +4458,189 @@ populate-git-credentials = true fs::write( &config_path, format!( - "workspace-directory = \"{}\"\nopencode = [\"missing-a\", \"missing-b\"]\ncreate-ssh-agent = false\n\n[isolation]\n", + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", workspace_directory.display() ), ) .expect("config should be written"); - let err = CombinedService::from_config_path(&config_path) + let service = CombinedService::from_config_path(&config_path) .await - .expect_err("missing commands should fail"); + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); - match err { - CombinedServiceError::OpencodeCommandNotFound { candidates } => { - assert_eq!(candidates, vec!["missing-a", "missing-b"]); - } - other => panic!("unexpected error: {other:?}"), - } + run_git( + root.path(), + &[ + "init", + "--bare", + remote_root.to_str().expect("remote path should be utf-8"), + ], + ); + init_test_git_repository(&source_root); + run_git( + &source_root, + &[ + "remote", + "add", + "origin", + remote_root.to_str().expect("remote path should be utf-8"), + ], + ); + run_git(&source_root, &["push", "-u", "origin", "main"]); + + let repo_root = service.workspace_repo_root_path("alpha", "example/repo"); + run_git( + root.path(), + &[ + "clone", + remote_root.to_str().expect("remote path should be utf-8"), + repo_root.to_str().expect("repo root should be utf-8"), + ], + ); + + run_git(&repo_root, &["checkout", "-b", "topic"]); + fs::write(repo_root.join("topic.txt"), "topic\n") + .expect("topic file should be written"); + run_git(&repo_root, &["add", "topic.txt"]); + run_git(&repo_root, &["commit", "-m", "topic"]); + let topic_head = git_stdout(&repo_root, &["rev-parse", "HEAD"]); + + fs::write(source_root.join("README.md"), "upstream\n") + .expect("source readme should be updated"); + run_git(&source_root, &["add", "README.md"]); + run_git(&source_root, &["commit", "-m", "upstream"]); + run_git(&source_root, &["push", "origin", "main"]); + let remote_main_head = git_stdout(&source_root, &["rev-parse", "HEAD"]); + + let issue_url = "https://github.com/example/repo/issues/43"; + let task_root = service + .ensure_workspace_task_checkout("alpha", "example/repo", issue_url) + .await + .expect("task checkout should be prepared"); + + let task_head = git_stdout(&task_root, &["rev-parse", "HEAD"]); + let task_branch = git_stdout( + &repo_root, + &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + ); + + assert_eq!( + task_branch, "refs/remotes/origin/main", + "test remote should advertise main as the default branch" + ); + assert_eq!( + task_head, remote_main_head, + "task worktree should start from the latest origin default branch tip" + ); + assert_ne!( + task_head, topic_head, + "task worktree should not inherit the local repo_root branch head" + ); }); } #[test] - fn start_workspace_builds_github_git_credentials_bind_mount_when_enabled() { + fn strip_workspace_git_identity_overrides_removes_repo_local_user_identity() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime should build"); runtime.block_on(async { - let _env_lock = ENV_VAR_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); let root = TestDir::new(); - let home = root.path().join("home"); - let runtime_dir = root.path().join("runtime"); - fs::create_dir_all(&home).expect("home should exist"); - fs::create_dir_all(&runtime_dir).expect("runtime should exist"); - let workspace_directory = home.join("workspaces"); - fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); - - let _home_guard = EnvVarGuard::set("HOME", &home); - let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); - - let config: Config = toml::from_str( - r#" -workspace-directory = "~/workspaces" - -[github] -populate-git-credentials = true -token = { command = "printf test-token" } + let workspace = root.path().join("workspace"); + let repo = workspace.join("repo"); + fs::create_dir_all(&repo).expect("repo dir should exist"); + let git = git_program(); + + let init = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["init"]) + .stdin(Stdio::null()) + .output() + .expect("git init should run"); + assert!(init.status.success(), "git init should succeed"); + + let set_name = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.name", "Local Name"]) + .stdin(Stdio::null()) + .output() + .expect("git config user.name should run"); + assert!( + set_name.status.success(), + "git config user.name should succeed" + ); -[isolation] -inherit-env = ["HOME", "XDG_RUNTIME_DIR"] -"#, - ) - .expect("config should parse"); + let set_email = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.email", "local@example.com"]) + .stdin(Stdio::null()) + .output() + .expect("git config user.email should run"); + assert!( + set_email.status.success(), + "git config user.email should succeed" + ); - let service = CombinedService::from_config(config) + strip_workspace_git_identity_overrides(&workspace) .await - .expect_err("startup should fail without live GitHub username lookup in test env"); - assert!(matches!( - service, - CombinedServiceError::GithubStatusService(_) - )); + .expect("workspace git identity cleanup should succeed"); + + let get_name = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.name"]) + .stdin(Stdio::null()) + .output() + .expect("git config get user.name should run"); + assert_eq!(get_name.status.code(), Some(1)); + + let get_email = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.email"]) + .stdin(Stdio::null()) + .output() + .expect("git config get user.email should run"); + assert_eq!(get_email.status.code(), Some(1)); + + let remote = std::process::Command::new(&git) + .arg("-C") + .arg(&repo) + .args(["config", "--local", "core.repositoryformatversion"]) + .stdin(Stdio::null()) + .output() + .expect("git config core.repositoryformatversion should run"); + assert!(remote.status.success(), "repo config should remain intact"); }); } #[test] - fn github_git_credentials_env_vars_include_helper_and_secrets() { + fn strip_workspace_git_identity_overrides_ignores_dangling_worktree_git_files() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime should build"); runtime.block_on(async { - let _env_lock = ENV_VAR_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); let root = TestDir::new(); - let home = root.path().join("home"); - let runtime_dir = root.path().join("runtime"); - let github_api_dir = root.path().join("github-api"); - let github_server = github_api_dir.join("server.py"); - let github_port = 38492; - fs::create_dir_all(&home).expect("home should exist"); - fs::create_dir_all(&runtime_dir).expect("runtime should exist"); - fs::create_dir_all(&github_api_dir).expect("github api dir should exist"); - let workspace_directory = home.join("workspaces"); - fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); - - fs::write( - &github_server, - format!( - r#"from http.server import BaseHTTPRequestHandler, HTTPServer -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/user": - body = b'{{"login":"sandbox-user","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://example.com/avatar","gravatar_id":"","url":"https://api.github.com/users/sandbox-user","html_url":"https://github.com/sandbox-user","followers_url":"https://api.github.com/users/sandbox-user/followers","following_url":"https://api.github.com/users/sandbox-user/following{{/other_user}}","gists_url":"https://api.github.com/users/sandbox-user/gists{{/gist_id}}","starred_url":"https://api.github.com/users/sandbox-user/starred{{/owner}}{{/repo}}","subscriptions_url":"https://api.github.com/users/sandbox-user/subscriptions","organizations_url":"https://api.github.com/users/sandbox-user/orgs","repos_url":"https://api.github.com/users/sandbox-user/repos","events_url":"https://api.github.com/users/sandbox-user/events{{/privacy}}","received_events_url":"https://api.github.com/users/sandbox-user/received_events","type":"User","site_admin":false,"name":"Sandbox User","company":null,"blog":"","location":null,"email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","private_gists":0,"total_private_repos":0,"owned_private_repos":0,"disk_usage":0,"collaborators":0,"two_factor_authentication":false}}' - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - def log_message(self, format, *args): - pass -HTTPServer(("127.0.0.1", {github_port}), Handler).serve_forever() -"# - ), - ) - .expect("github server script should be written"); - let mut github_process = std::process::Command::new("python3") - .arg(&github_server) - .spawn() - .expect("github api server should start"); - std::thread::sleep(std::time::Duration::from_millis(250)); - - let _home_guard = EnvVarGuard::set("HOME", &home); - let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); - unsafe { - std::env::set_var("MULTICODE_GITHUB_TEST_TOKEN", "secret-token"); - std::env::set_var("GITHUB_API_URL", format!("http://127.0.0.1:{github_port}")); - } - - let config: Config = toml::from_str( - &format!( - r#"workspace-directory = "{}" - -[github] -populate-git-credentials = true -token = {{ env = "MULTICODE_GITHUB_TEST_TOKEN" }} - -[isolation] -"#, - workspace_directory.display() - ), - ) - .expect("config should parse"); + let workspace = root.path().join("workspace"); + let broken_worktree = workspace.join("work").join("micronaut-sql-1508"); + fs::create_dir_all(&broken_worktree).expect("broken worktree dir should exist"); + fs::write(broken_worktree.join(".git"), "gitdir: /missing/admin/dir\n") + .expect("dangling worktree git file should be written"); - let service = CombinedService::from_config(config) + strip_workspace_git_identity_overrides(&workspace) .await - .expect("combined service should start"); - let env_vars = service.github_git_credentials_env_vars(); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_USERNAME".to_string(), - "sandbox-user".to_string(), - ))); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_TOKEN".to_string(), - "secret-token".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_COUNT".to_string(), - "1".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_VALUE_0".to_string(), - r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#.to_string(), - ))); - - unsafe { - std::env::remove_var("MULTICODE_GITHUB_TEST_TOKEN"); - std::env::remove_var("GITHUB_API_URL"); - } - let _ = github_process.kill(); - let _ = github_process.wait(); + .expect("dangling worktree git files should be ignored"); }); } @@ -2049,7 +4778,7 @@ cpu = "400%" assert!(contains_sequence( &args, &[ - service.opencode_command(), + service.agent_command(), "serve", "--hostname", "127.0.0.1", @@ -3347,6 +6076,79 @@ inherit-env = ["TERM", "COLORTERM"] }); } + #[test] + fn pause_workspace_keeps_runtime_and_marks_automation_paused() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime should exist"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write(&config_path, config_with_isolation("~/workspaces")) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + snapshot.transient = Some(crate::TransientWorkspaceSnapshot { + uri: "http://127.0.0.1:3000".to_string(), + runtime: crate::RuntimeHandleSnapshot { + backend: crate::RuntimeBackend::LinuxSystemdBwrap, + id: "alpha.service".to_string(), + metadata: Default::default(), + }, + }); + true + }); + + service + .pause_workspace("alpha") + .await + .expect("pause should succeed for started workspace"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + + assert!(snapshot.transient.is_some()); + assert!(snapshot.persistent.automation_paused); + assert_eq!( + snapshot.automation_status.as_deref(), + Some("Paused example/repo") + ); + }); + } + #[test] fn unarchive_workspace_reactivates_legacy_archived_directory_without_snapshot_flag_loaded() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -3597,6 +6399,63 @@ isolated = ["~/.config/opencode"] ); } + #[test] + fn rewrite_codex_task_prompt_session_id_replaces_stale_task_session_id() { + let prompt = "For this task session/thread, write autonomous state updates in the format `:old-session` so multicode can attribute the state to this specific session.\nreview:old-session"; + let rewritten = CombinedService::rewrite_codex_task_prompt_session_id( + prompt, + Some("old-session"), + "new-session", + ); + + assert!(rewritten.contains(":new-session")); + assert!(rewritten.contains("review:new-session")); + assert!(!rewritten.contains("old-session")); + } + + #[test] + fn rewrite_codex_task_prompt_session_id_leaves_prompt_unchanged_without_prior_session() { + let prompt = "create a PR"; + let rewritten = + CombinedService::rewrite_codex_task_prompt_session_id(prompt, None, "new-session"); + + assert_eq!(rewritten, prompt); + } + + #[test] + fn resolve_task_repository_falls_back_to_task_issue_or_pr_when_workspace_repo_missing() { + let mut snapshot = WorkspaceSnapshot::default(); + let issue_task = WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/micronaut-projects/micronaut-kafka/issues/873".to_string(), + WorkspaceTaskSource::Manual, + ); + assert_eq!( + resolve_task_repository(&snapshot, &issue_task).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + + let pr_task = WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "not-a-github-url".to_string(), + WorkspaceTaskSource::Manual, + ) + .with_backing_pr_url(Some( + "https://github.com/micronaut-projects/micronaut-kafka/pull/1308".to_string(), + )); + assert_eq!( + resolve_task_repository(&snapshot, &pr_task).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + + snapshot.persistent.assigned_repository = + Some("https://github.com/example/repo.git".to_string()); + assert_eq!( + resolve_task_repository(&snapshot, &issue_task).as_deref(), + Some("example/repo") + ); + } + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { args.windows(sequence.len()).any(|window| { window diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 2527899..9624c22 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -9,15 +9,25 @@ use serde::{Deserialize, Serialize}; use size::Size; use super::CombinedServiceError; +use crate::RuntimeBackend; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct Config { + #[serde(default = "default_workspace_directory")] pub workspace_directory: String, pub isolation: IsolationConfig, + #[serde(default)] + pub runtime: RuntimeConfig, + #[serde(default)] + pub autonomous: AutonomousConfig, + #[serde(default)] + pub agent: AgentConfig, #[serde(default = "default_opencode_commands")] pub opencode: Vec, #[serde(default)] + pub compare: CompareConfig, + #[serde(default)] pub tool: Vec, #[serde(default)] pub handler: HandlerConfig, @@ -27,6 +37,142 @@ pub struct Config { pub github: GithubConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AutonomousConfig { + #[serde( + default = "default_issue_scan_delay_seconds", + alias = "issue-scan-delay-seconds" + )] + pub issue_scan_delay_seconds: u64, + #[serde(default = "default_max_parallel_issues", alias = "max-parallel-issues")] + pub max_parallel_issues: usize, + #[serde(default = "default_scan_on_startup", alias = "scan-on-startup")] + pub scan_on_startup: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct CompareConfig { + #[serde(default)] + pub tool: CompareTool, + #[serde(default)] + pub command: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CompareTool { + #[default] + Vscode, + Intellij, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AgentProvider { + #[default] + Opencode, + Codex, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct AgentConfig { + #[serde(default)] + pub provider: AgentProvider, + #[serde(default)] + pub opencode: OpencodeAgentConfig, + #[serde(default)] + pub codex: CodexAgentConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct OpencodeAgentConfig { + #[serde(default)] + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct CodexAgentConfig { + #[serde(default = "default_codex_commands")] + pub commands: Vec, + #[serde(default)] + pub profile: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub model_provider: Option, + #[serde(default)] + pub approval_policy: CodexApprovalPolicy, + #[serde(default)] + pub sandbox_mode: CodexSandboxMode, + #[serde(default)] + pub network_access: CodexNetworkAccess, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexApprovalPolicy { + Untrusted, + OnFailure, + #[default] + OnRequest, + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexSandboxMode { + ReadOnly, + #[default] + WorkspaceWrite, + DangerFullAccess, + ExternalSandbox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexNetworkAccess { + Restricted, + #[default] + Enabled, +} + +impl Default for AutonomousConfig { + fn default() -> Self { + Self { + issue_scan_delay_seconds: default_issue_scan_delay_seconds(), + max_parallel_issues: default_max_parallel_issues(), + scan_on_startup: default_scan_on_startup(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct RuntimeConfig { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub opencode_image: Option, + #[serde(default)] + pub codex_image: Option, +} + +impl RuntimeConfig { + pub fn resolved_image(&self, provider: AgentProvider) -> Option<&str> { + self.image.as_deref().or(match provider { + AgentProvider::Opencode => self.opencode_image.as_deref(), + AgentProvider::Codex => self.codex_image.as_deref(), + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "kebab-case")] pub struct GithubConfig { @@ -43,6 +189,10 @@ pub struct GithubTokenConfig { pub env: Option, #[serde(default)] pub command: Option, + #[serde(default)] + pub keychain_service: Option, + #[serde(default)] + pub keychain_account: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -99,10 +249,30 @@ fn default_opencode_commands() -> Vec { vec!["opencode-cli".to_string(), "opencode".to_string()] } +fn default_workspace_directory() -> String { + "~/dev/multicode-workspaces".to_string() +} + +fn default_codex_commands() -> Vec { + vec!["codex".to_string()] +} + fn default_remote_sync_interval_seconds() -> u64 { 2 } +fn default_issue_scan_delay_seconds() -> u64 { + 15 * 60 +} + +fn default_max_parallel_issues() -> usize { + 5 +} + +fn default_scan_on_startup() -> bool { + true +} + fn default_handler_review() -> String { "/usr/bin/smerge".to_string() } @@ -260,9 +430,7 @@ pub async fn read_config(config_path: &Path) -> Result Result { +pub(super) fn resolve_agent_command(candidates: &[String]) -> Result { let normalized = candidates .iter() .map(|candidate| candidate.trim()) @@ -276,7 +444,7 @@ pub(super) fn resolve_opencode_command( } } - Err(CombinedServiceError::OpencodeCommandNotFound { + Err(CombinedServiceError::AgentCommandNotFound { candidates: normalized, }) } @@ -303,7 +471,10 @@ fn is_executable_file(path: &Path) -> bool { pub(super) fn path_looks_like_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains('.')) + .is_some_and(|name| { + let trimmed = name.trim_start_matches('.'); + !trimmed.is_empty() && trimmed.contains('.') + }) } pub(super) fn validate_workspace_key(key: &str) -> Result { @@ -315,11 +486,44 @@ pub(super) fn validate_workspace_key(key: &str) -> Result Result { - let expanded = shellexpand::full(value) - .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; + let expanded = shellexpand::full_with_context( + value, + || env::var("HOME").ok(), + |name| match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(synthesized_env_value(name)), + Err(err) => Err(err.to_string()), + }, + ) + .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; Ok(PathBuf::from(expanded.into_owned())) } +pub(super) fn inherited_env_value(name: &str) -> Option { + env::var(name).ok().or_else(|| synthesized_env_value(name)) +} + +pub(super) fn synthesized_env_value(name: &str) -> Option { + match name { + "XDG_RUNTIME_DIR" => { + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + } + _ => None, + } +} + +pub(super) fn synthesized_xdg_runtime_dir() -> Option { + #[cfg(target_os = "macos")] + { + Some(env::temp_dir().join("multicode-runtime")) + } + + #[cfg(not(target_os = "macos"))] + { + None + } +} + fn expand_isolation_paths( paths: &[String], field: &str, @@ -375,7 +579,7 @@ pub(super) fn validate_tool_config_entries( tools: &[ToolConfig], ) -> Result<(), CombinedServiceError> { let mut seen_keys = HashSet::new(); - let reserved = ['q', 'a', 'd', 's']; + let reserved = ['q', 'a', 'd', 'f', 's']; for (index, tool) in tools.iter().enumerate() { if tool.name.trim().is_empty() { @@ -557,3 +761,101 @@ fn validate_handler_template( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + ffi::OsString, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; + + static ENV_VAR_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + old_value: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, old_value } + } + + fn remove(key: &'static str) -> Self { + let old_value = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, old_value } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + env::set_var(self.key, value); + } + } else { + unsafe { + env::remove_var(self.key); + } + } + } + } + + #[test] + fn expand_shell_path_expands_existing_environment_variables() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let runtime_dir = env::temp_dir().join(format!("multicode-config-test-{unique}")); + let _guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!(path, runtime_dir.join("opencode")); + } + + #[test] + fn config_defaults_workspace_directory_when_omitted() { + let config: Config = toml::from_str("[isolation]\n") + .expect("config without workspace-directory should parse"); + + assert_eq!(config.workspace_directory, "~/dev/multicode-workspaces"); + } + + #[cfg(target_os = "macos")] + #[test] + fn expand_shell_path_synthesizes_xdg_runtime_dir_on_macos() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!( + path, + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("opencode") + ); + assert_eq!( + inherited_env_value("XDG_RUNTIME_DIR"), + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + ); + } +} diff --git a/lib/src/services/github_status_service.rs b/lib/src/services/github_status_service.rs index 4155e7e..7618c0b 100644 --- a/lib/src/services/github_status_service.rs +++ b/lib/src/services/github_status_service.rs @@ -217,6 +217,20 @@ impl CachedLinkStatus { } } + fn new_pr_error_placeholder(reference: GithubLinkRef, now_epoch_seconds: i64) -> Self { + Self { + reference, + issue_state: None, + pr_state: Some(GithubPrState::Open), + build_state: Some(GithubPrBuildState::Building), + review_state: Some(GithubPrReviewState::None), + pr_is_draft: Some(false), + fetched_at_epoch_seconds: Some(now_epoch_seconds), + refresh_after_epoch_seconds: Some(now_epoch_seconds), + last_error: None, + } + } + fn issue_status(&self) -> Option { Some(GithubIssueStatus { state: self.issue_state?, @@ -336,6 +350,7 @@ pub struct GithubStatusService { watch_entries: Arc>>>, token_source: Option, token: Arc>>, + client: Arc>>, authenticated_login: Arc>>, } @@ -357,6 +372,7 @@ impl GithubStatusService { watch_entries: Arc::new(Mutex::new(HashMap::new())), token_source, token: Arc::new(Mutex::new(None)), + client: Arc::new(Mutex::new(None)), authenticated_login: Arc::new(Mutex::new(None)), }) } @@ -507,12 +523,26 @@ impl GithubStatusService { } Err(error) => { let now = now_epoch_seconds(); - let mut next_status = current_status.take().unwrap_or_else(|| { - CachedLinkStatus::new_pending(entry.reference.clone(), now) - }); + let mut next_status = + current_status + .take() + .unwrap_or_else(|| match entry.reference.kind { + GithubLinkKind::PullRequest => { + CachedLinkStatus::new_pr_error_placeholder( + entry.reference.clone(), + now, + ) + } + GithubLinkKind::Issue => { + CachedLinkStatus::new_pending(entry.reference.clone(), now) + } + }); next_status.last_error = Some(error.to_string()); next_status.refresh_after_epoch_seconds = Some(now + FETCH_ERROR_RETRY_INTERVAL.as_secs() as i64); + if let Some(status) = next_status.github_status() { + entry.sender.send_replace(Some(status)); + } if let Some(row) = next_status.to_row() && let Err(persist_error) = upsert_cache_row(self.database.pool().clone(), row).await @@ -685,12 +715,22 @@ impl GithubStatusService { } async fn github_client(&self) -> Result { + if let Some(client) = self + .client + .lock() + .expect("github client lock poisoned") + .clone() + { + return Ok(client); + } + let token = self.github_token().await?; let mut builder = Octocrab::builder().personal_token(token); if let Ok(base_uri) = std::env::var("GITHUB_API_URL") { builder = builder.base_uri(base_uri)?; } let client = builder.build()?; + *self.client.lock().expect("github client lock poisoned") = Some(client.clone()); Ok(client) } @@ -715,6 +755,8 @@ impl GithubStatusService { Some(GithubTokenConfig { env: Some(env), command: None, + keychain_service: None, + keychain_account: None, }) => { let token = std::env::var(env).map_err(|err| { GithubStatusServiceError::Auth(format!( @@ -726,18 +768,54 @@ impl GithubStatusService { Some(GithubTokenConfig { env: None, command: Some(command), + keychain_service: None, + keychain_account: None, }) => load_github_token_from_command(command).await, + Some(GithubTokenConfig { + env: None, + command: None, + keychain_service: Some(service), + keychain_account, + }) => load_github_token_from_keychain(service, keychain_account.as_deref()).await, + Some(GithubTokenConfig { + env: None, + command: None, + keychain_service: None, + keychain_account: Some(_), + }) => Err(GithubStatusServiceError::Auth( + "GitHub token config cannot set `keychain-account` without `keychain-service`" + .to_string(), + )), Some(GithubTokenConfig { env: Some(_), command: Some(_), + keychain_service: _, + keychain_account: _, + }) + | Some(GithubTokenConfig { + env: Some(_), + command: None, + keychain_service: Some(_), + keychain_account: _, + }) + | Some(GithubTokenConfig { + env: None, + command: Some(_), + keychain_service: Some(_), + keychain_account: _, }) => Err(GithubStatusServiceError::Auth( - "GitHub token config must set exactly one of `env` or `command`".to_string(), + "GitHub token config must set exactly one of `env`, `command`, or `keychain-service`".to_string(), )), Some(GithubTokenConfig { env: None, command: None, + keychain_service: None, + keychain_account: None, }) => Err(GithubStatusServiceError::Auth( - "GitHub token config must set exactly one of `env` or `command`".to_string(), + "GitHub token config must set exactly one of `env`, `command`, or `keychain-service`".to_string(), + )), + Some(_) => Err(GithubStatusServiceError::Auth( + "GitHub token config must set exactly one of `env`, `command`, or `keychain-service`".to_string(), )), None => load_github_token_from_command("gh auth token").await, } @@ -758,7 +836,8 @@ fn validate_github_token( } async fn load_github_token_from_command(command: &str) -> Result { - let output = Command::new("sh").args(["-c", command]).output().await?; + let shell = if cfg!(unix) { "/bin/sh" } else { "sh" }; + let output = Command::new(shell).args(["-c", command]).output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let message = if stderr.is_empty() { @@ -779,6 +858,53 @@ async fn load_github_token_from_command(command: &str) -> Result, +) -> Result { + #[cfg(target_os = "macos")] + { + let mut command = Command::new("security"); + command.arg("find-generic-password").arg("-s").arg(service); + if let Some(account) = account { + command.arg("-a").arg(account); + } + command.arg("-w"); + let output = command.output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let source_description = account + .map(|account| format!("service `{service}` account `{account}`")) + .unwrap_or_else(|| format!("service `{service}`")); + let message = if stderr.is_empty() { + format!( + "GitHub token Keychain lookup for {source_description} failed with status {}", + output.status + ) + } else { + format!("GitHub token Keychain lookup for {source_description} failed: {stderr}") + }; + return Err(GithubStatusServiceError::Auth(message)); + } + + let source_description = account + .map(|account| format!("macOS Keychain item service `{service}` account `{account}`")) + .unwrap_or_else(|| format!("macOS Keychain item service `{service}`")); + return validate_github_token( + &String::from_utf8_lossy(&output.stdout), + &source_description, + ); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (service, account); + Err(GithubStatusServiceError::Auth( + "GitHub token `keychain-service` is only supported on macOS".to_string(), + )) + } +} + #[cfg(test)] fn parse_github_issue_reference(url: &str) -> Option { let reference = parse_github_status_reference(url)?; @@ -1165,10 +1291,14 @@ mod tests { use super::*; use std::{ fs, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, time::Duration, }; + static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + struct TestDir { path: PathBuf, } @@ -1179,10 +1309,12 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time should be after unix epoch") .as_nanos(); + let counter = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( - "multicode-github-status-service-{}-{}", + "multicode-github-status-service-{}-{}-{}", std::process::id(), - unique + unique, + counter )); fs::create_dir_all(&path).expect("test dir should be created"); Self { path } @@ -1584,6 +1716,46 @@ mod tests { ); } + #[test] + fn pr_error_placeholder_produces_renderable_and_persistable_status() { + let now = 2_000_i64; + let status = CachedLinkStatus::new_pr_error_placeholder( + GithubLinkRef { + kind: GithubLinkKind::PullRequest, + url: "https://github.com/owner/repo/pull/7".to_string(), + host: "github.com".to_string(), + owner: "owner".to_string(), + repo: "repo".to_string(), + resource_number: 7, + }, + now, + ); + + assert_eq!( + status.github_status(), + Some(GithubStatus::Pr(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Building, + review: GithubPrReviewState::None, + is_draft: false, + fetched_at: system_time_from_epoch_seconds(now), + })) + ); + + let row = status.to_row().expect("placeholder should persist"); + let round_trip = CachedLinkStatus::from_row(row).expect("placeholder should reload"); + assert_eq!( + round_trip.github_status(), + Some(GithubStatus::Pr(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Building, + review: GithubPrReviewState::None, + is_draft: false, + fetched_at: system_time_from_epoch_seconds(now), + })) + ); + } + #[test] fn service_loads_github_token_from_environment_variable() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -1612,6 +1784,8 @@ mod tests { Some(GithubTokenConfig { env: Some(variable_name.to_string()), command: None, + keychain_service: None, + keychain_account: None, }), ) .await @@ -1652,6 +1826,8 @@ mod tests { Some(GithubTokenConfig { env: None, command: Some("printf 'command-token-value\n'".to_string()), + keychain_service: None, + keychain_account: None, }), ) .await @@ -1665,6 +1841,77 @@ mod tests { }); } + #[cfg(target_os = "macos")] + #[test] + fn service_loads_github_token_from_keychain() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + tokio::fs::create_dir_all(&workspace_root) + .await + .expect("workspace root should exist"); + let database = Database::open_in_workspace(&workspace_root) + .await + .expect("database should open"); + + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + let security_path = bin_dir.join("security"); + fs::write( + &security_path, + "#!/bin/sh\n[ \"$1\" = \"find-generic-password\" ] || exit 11\n[ \"$2\" = \"-s\" ] || exit 12\n[ \"$3\" = \"multicode.github\" ] || exit 13\n[ \"$4\" = \"-a\" ] || exit 14\n[ \"$5\" = \"github-mcp-token\" ] || exit 15\n[ \"$6\" = \"-w\" ] || exit 16\nprintf 'keychain-token-value\\n'\n", + ) + .expect("fake security should be written"); + let mut perms = fs::metadata(&security_path) + .expect("fake security metadata should be readable") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&security_path, perms) + .expect("fake security should be executable"); + + let previous_path = std::env::var_os("PATH"); + let path = match &previous_path { + Some(previous_path) => format!( + "{}:{}", + bin_dir.display(), + previous_path.to_string_lossy() + ), + None => bin_dir.display().to_string(), + }; + unsafe { + std::env::set_var("PATH", path); + } + + let service = GithubStatusService::new( + database, + Some(GithubTokenConfig { + env: None, + command: None, + keychain_service: Some("multicode.github".to_string()), + keychain_account: Some("github-mcp-token".to_string()), + }), + ) + .await + .expect("service should construct"); + + let token = service + .github_token() + .await + .expect("token should load from keychain"); + assert_eq!(token, "keychain-token-value"); + + match previous_path { + Some(value) => unsafe { std::env::set_var("PATH", value) }, + None => unsafe { std::env::remove_var("PATH") }, + } + }); + } + #[test] fn watch_status_rejects_non_github_links() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/src/services/mod.rs b/lib/src/services/mod.rs index c1c24f2..32eb587 100644 --- a/lib/src/services/mod.rs +++ b/lib/src/services/mod.rs @@ -1,3 +1,7 @@ +pub mod automation_state_file_service; +pub mod autonomous_workspace_service; +pub mod codex_app_server; +pub mod codex_root_session_service; pub mod combined; pub mod config; pub mod github_status_service; @@ -6,6 +10,8 @@ pub mod opencode_client_service; pub mod persistent_storage; pub mod resource_usage_service; pub mod root_session_service; +pub mod runtime; +pub(crate) mod runtime_reconciliation_service; pub mod transient_storage; pub mod usage_aggregation_service; pub mod workspace_archive; @@ -14,9 +20,14 @@ pub(crate) mod workspace_task_watch; pub(crate) mod workspace_watch; pub use crate::database::{Database, DatabaseError}; -pub use combined::{CombinedService, CombinedServiceError}; +pub use automation_state_file_service::{ + AutomationStateFileServiceError, automation_state_file_service, +}; +pub use combined::{CombinedService, CombinedServiceError, summarize_workspace_start_failure}; pub use config::{ - Config, GithubTokenConfig, HandlerConfig, ToolConfig, ToolType, parse_optional_size_bytes, + AgentConfig, AgentProvider, AutonomousConfig, CodexAgentConfig, CompareConfig, CompareTool, + Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, ToolType, + parse_optional_size_bytes, }; pub use github_status_service::{ GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, diff --git a/lib/src/services/multicode_metadata_service.rs b/lib/src/services/multicode_metadata_service.rs index 22de5fa..b4b2807 100644 --- a/lib/src/services/multicode_metadata_service.rs +++ b/lib/src/services/multicode_metadata_service.rs @@ -3,7 +3,11 @@ use std::{collections::BTreeSet, sync::Arc, time::Duration}; use tokio::sync::{broadcast, watch}; use super::{ - workspace_task_watch::watch_workspace_task, workspace_watch::monitor_workspace_snapshots, + codex_app_server::{ + CodexAppServerClient, CodexServerNotification, forward_codex_notifications_forever, + }, + workspace_task_watch::watch_workspace_task, + workspace_watch::monitor_workspace_snapshots, }; use crate::{ WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, @@ -33,6 +37,56 @@ struct MulticodeMetadata { prs: BTreeSet, } +#[derive(Clone)] +enum MetadataTaskKey { + Opencode { + session_id: String, + client: Arc, + event_tx: broadcast::Sender, + uri: String, + }, + Codex { + thread_id: String, + uri: String, + }, +} + +impl PartialEq for MetadataTaskKey { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + Self::Opencode { + session_id: left_session_id, + client: left_client, + uri: left_uri, + .. + }, + Self::Opencode { + session_id: right_session_id, + client: right_client, + uri: right_uri, + .. + }, + ) => { + left_session_id == right_session_id + && left_uri == right_uri + && Arc::ptr_eq(left_client, right_client) + } + ( + Self::Codex { + thread_id: left_thread_id, + uri: left_uri, + }, + Self::Codex { + thread_id: right_thread_id, + uri: right_uri, + }, + ) => left_thread_id == right_thread_id && left_uri == right_uri, + _ => false, + } + } +} + /// Watch the agent transcript for machine-readable metadata as specified by /// /workspace-skills/machine-readable-* pub async fn multicode_metadata_service( @@ -47,22 +101,6 @@ pub async fn multicode_metadata_service( .await } -#[derive(Clone)] -struct MetadataTaskKey { - session_id: String, - client: Arc, - event_tx: broadcast::Sender, - uri: String, -} - -impl PartialEq for MetadataTaskKey { - fn eq(&self, other: &Self) -> bool { - self.session_id == other.session_id - && self.uri == other.uri - && Arc::ptr_eq(&self.client, &other.client) - } -} - async fn watch_workspace_snapshot( workspace: Workspace, workspace_rx: watch::Receiver, @@ -71,30 +109,51 @@ async fn watch_workspace_snapshot( workspace, workspace_rx, |snapshot| { - Some(MetadataTaskKey { - session_id: snapshot.root_session_id.clone()?, - client: snapshot.opencode_client.as_ref()?.client.clone(), - event_tx: snapshot.opencode_client.as_ref()?.events.clone(), - uri: normalize_base_uri(&snapshot.transient.as_ref()?.uri), + let session_or_thread_id = snapshot.root_session_id.clone()?; + let transient_uri = snapshot.transient.as_ref()?.uri.clone(); + let parsed_uri = url::Url::parse(&transient_uri).ok()?; + + if matches!(parsed_uri.scheme(), "ws" | "wss") { + return Some(MetadataTaskKey::Codex { + thread_id: session_or_thread_id, + uri: normalize_base_uri(&transient_uri), + }); + } + + let opencode_client = snapshot.opencode_client.as_ref()?; + Some(MetadataTaskKey::Opencode { + session_id: session_or_thread_id, + client: opencode_client.client.clone(), + event_tx: opencode_client.events.clone(), + uri: normalize_base_uri(&transient_uri), }) }, |_: &Workspace| {}, |_: &Workspace, _: &MetadataTaskKey, _: Option| {}, |workspace: &Workspace, key: &MetadataTaskKey| { let task_workspace = workspace.clone(); - let task_client = key.client.clone(); - let task_session_id = key.session_id.clone(); - let task_uri = key.uri.clone(); - let task_event_tx = key.event_tx.clone(); + let task_key = key.clone(); tokio::spawn(async move { - sync_multicode_metadata_from_history_and_events( - task_workspace, - task_client, - task_event_tx, - task_session_id, - task_uri, - ) - .await; + match task_key { + MetadataTaskKey::Opencode { + session_id, + client, + event_tx, + uri, + } => { + sync_multicode_metadata_from_history_and_events( + task_workspace, + client, + event_tx, + session_id, + uri, + ) + .await; + } + MetadataTaskKey::Codex { thread_id, uri } => { + sync_codex_multicode_metadata(task_workspace, thread_id, uri).await; + } + } }) }, ) @@ -222,12 +281,18 @@ fn should_refresh_from_event( session_id: &str, ) -> bool { match &event.payload { - opencode::client::types::Event::MessageUpdated(message_updated) => { + opencode::client::types::GlobalEventPayload::EventMessageUpdated(message_updated) => { message_session_id(&message_updated.properties.info) == Some(session_id) } - opencode::client::types::Event::MessageRemoved(message_removed) => { + opencode::client::types::GlobalEventPayload::SyncEventMessageUpdated(message_updated) => { + message_session_id(&message_updated.data.info) == Some(session_id) + } + opencode::client::types::GlobalEventPayload::EventMessageRemoved(message_removed) => { message_removed.properties.session_id.as_str() == session_id } + opencode::client::types::GlobalEventPayload::SyncEventMessageRemoved(message_removed) => { + message_removed.data.session_id.as_str() == session_id + } _ => false, } } @@ -340,6 +405,139 @@ fn normalize_base_uri(uri: &str) -> String { uri.trim_end_matches('/').to_string() } +async fn sync_codex_multicode_metadata( + workspace: Workspace, + thread_id: String, + expected_uri: String, +) { + let client = CodexAppServerClient::new(expected_uri.clone()); + let event_tx = broadcast::channel(256).0; + let forwarder = tokio::spawn(forward_codex_notifications_forever( + client.clone(), + event_tx.clone(), + )); + let mut event_rx = event_tx.subscribe(); + + refresh_snapshot_codex_multicode_metadata(&workspace, &client, &thread_id, &expected_uri).await; + + loop { + match event_rx.recv().await { + Ok(CodexServerNotification::ThreadStarted { thread }) if thread.id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(CodexServerNotification::TurnCompleted { + thread_id: completed_thread_id, + }) if completed_thread_id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(CodexServerNotification::ThreadStatusChanged { + thread_id: changed_thread_id, + .. + }) if changed_thread_id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(_) => {} + Err(broadcast::error::RecvError::Lagged(_)) => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + + forwarder.abort(); +} + +async fn refresh_snapshot_codex_multicode_metadata( + workspace: &Workspace, + client: &CodexAppServerClient, + thread_id: &str, + expected_uri: &str, +) { + let Ok(response) = client.thread_read_with_turns(thread_id, true).await else { + return; + }; + let metadata = collect_metadata_from_codex_turns(response.thread.turns.iter()); + let repositories = metadata.repositories.iter().cloned().collect::>(); + let issues = metadata.issues.iter().cloned().collect::>(); + let prs = metadata.prs.iter().cloned().collect::>(); + + workspace.update(|snapshot| { + let still_tracking_same_session = snapshot.root_session_id.as_deref() == Some(thread_id); + let still_attached_to_expected_uri = snapshot + .transient + .as_ref() + .map(|transient| normalize_base_uri(&transient.uri)) + .as_deref() + == Some(expected_uri); + let should_update = still_tracking_same_session + && still_attached_to_expected_uri + && (snapshot.persistent.agent_provided.repo != repositories + || snapshot.persistent.agent_provided.issue != issues + || snapshot.persistent.agent_provided.pr != prs); + if should_update { + snapshot.persistent.agent_provided.repo = repositories; + snapshot.persistent.agent_provided.issue = issues; + snapshot.persistent.agent_provided.pr = prs; + true + } else { + false + } + }); +} + +fn collect_metadata_from_codex_turns<'a>( + turns: impl IntoIterator, +) -> MulticodeMetadata { + let mut metadata = MulticodeMetadata::default(); + for turn in turns { + for item in &turn.items { + merge_json_metadata(item, &mut metadata); + } + } + metadata +} + +fn merge_json_metadata(item: &serde_json::Value, metadata: &mut MulticodeMetadata) { + match item { + serde_json::Value::String(text) => merge_text_metadata(text, metadata), + serde_json::Value::Array(items) => { + for value in items { + merge_json_metadata(value, metadata); + } + } + serde_json::Value::Object(entries) => { + for value in entries.values() { + merge_json_metadata(value, metadata); + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -441,7 +639,8 @@ mod tests { "payload": { "type": "message.updated", "properties": { - "info": assistant_message_json(message_id, session_id, text) + "info": assistant_message_json(message_id, session_id, text), + "sessionID": session_id } } })) @@ -576,4 +775,60 @@ mod tests { "ses-root", )); } + + #[test] + fn collects_metadata_from_codex_turn_items() { + let turns = vec![super::super::codex_app_server::CodexThreadTurn { + items: vec![ + serde_json::json!({ + "type": "agentMessage", + "text": "/srv/work/core" + }), + serde_json::json!({ + "type": "agentMessage", + "text": "https://github.com/acme/core/issue/42" + }), + serde_json::json!({ + "type": "agentMessage", + "text": "https://github.com/acme/core/pull/99" + }), + serde_json::json!({ + "type": "userMessage", + "content": [{ "type": "text", "text": "ignored" }] + }), + ], + }]; + + let metadata = collect_metadata_from_codex_turns(turns.iter()); + + assert_eq!( + metadata.repositories, + BTreeSet::from(["/srv/work/core".to_string()]) + ); + assert_eq!( + metadata.issues, + BTreeSet::from(["https://github.com/acme/core/issue/42".to_string()]) + ); + assert_eq!( + metadata.prs, + BTreeSet::from(["https://github.com/acme/core/pull/99".to_string()]) + ); + } + + #[test] + fn collects_metadata_from_codex_tool_output_items() { + let turns = vec![super::super::codex_app_server::CodexThreadTurn { + items: vec![serde_json::json!({ + "type": "toolCallOutput", + "output": "stdout\nhttps://github.com/acme/core/pull/101\n" + })], + }]; + + let metadata = collect_metadata_from_codex_turns(turns.iter()); + + assert_eq!( + metadata.prs, + BTreeSet::from(["https://github.com/acme/core/pull/101".to_string()]) + ); + } } diff --git a/lib/src/services/opencode_client_service.rs b/lib/src/services/opencode_client_service.rs index 8d079e4..4bda479 100644 --- a/lib/src/services/opencode_client_service.rs +++ b/lib/src/services/opencode_client_service.rs @@ -7,14 +7,19 @@ use std::{ time::Duration, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use tokio::{ process::Command, sync::{broadcast, watch}, task::JoinHandle, }; use tokio_stream::StreamExt; +use url::Url; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeActivity, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{ OpencodeClientSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, @@ -191,8 +196,8 @@ async fn watch_workspace_snapshot( abort_event_forward_task(&mut event_forward_task); event_generation.fetch_add(1, Ordering::Relaxed); - match read_unit_activity(&transient.unit).await { - UnitActivity::Stopped => { + match WorkspaceRuntime::read_activity(&transient.runtime).await { + RuntimeActivity::Stopped => { workspace.update(|next| { if next.transient.as_ref() == Some(&transient) { let mut changed = false; @@ -211,7 +216,7 @@ async fn watch_workspace_snapshot( }); last_client_uri = None; } - UnitActivity::Active | UnitActivity::Unknown => {} + RuntimeActivity::Active | RuntimeActivity::Unknown => {} } if !wait_for_change_or_timeout(&mut workspace_rx, HEALTH_RETRY_INTERVAL).await { @@ -256,10 +261,18 @@ fn create_opencode_client( current_uri: &str, shared_http_client: Option<&reqwest::Client>, ) -> opencode::client::Client { + let (baseurl, auth_header) = opencode_client_target(current_uri); + if let Some(auth_header) = auth_header { + return opencode::client::Client::new_with_client( + &baseurl, + build_authenticated_http_client(auth_header), + ); + } + if let Some(shared_http_client) = shared_http_client { - opencode::client::Client::new_with_client(current_uri, shared_http_client.clone()) + opencode::client::Client::new_with_client(&baseurl, shared_http_client.clone()) } else { - opencode::client::Client::new(current_uri) + opencode::client::Client::new(&baseurl) } } @@ -272,6 +285,46 @@ fn build_shared_http_client() -> Option { .ok() } +fn build_authenticated_http_client(auth_header: String) -> reqwest::Client { + let timeout = Duration::from_secs(15); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&auth_header) + .expect("generated basic auth header should be valid"), + ); + reqwest::Client::builder() + .connect_timeout(timeout) + .timeout(timeout) + .default_headers(headers) + .build() + .expect("authenticated opencode http client should build") +} + +fn opencode_client_target(current_uri: &str) -> (String, Option) { + let Ok(mut url) = Url::parse(current_uri) else { + return (current_uri.to_string(), None); + }; + + let username = url.username().to_string(); + let password = url.password().map(str::to_string); + if username.is_empty() { + return (current_uri.to_string(), None); + } + + let _ = url.set_username(""); + let _ = url.set_password(None); + let credentials = match password { + Some(password) => format!("{username}:{password}"), + None => format!("{username}:"), + }; + let encoded = BASE64_STANDARD.encode(credentials); + ( + url.to_string().trim_end_matches('/').to_string(), + Some(format!("Basic {encoded}")), + ) +} + async fn forward_global_events( client: Arc, event_tx: broadcast::Sender, @@ -396,14 +449,8 @@ async fn wait_for_change_or_timeout( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitActivity { - Active, - Stopped, - Unknown, -} - -async fn read_unit_activity(unit: &str) -> UnitActivity { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_activity(unit: &str) -> RuntimeActivity { let output = match Command::new("systemctl") .args([ "--user", @@ -420,21 +467,21 @@ async fn read_unit_activity(unit: &str) -> UnitActivity { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } }; if !output.status.success() { - return UnitActivity::Stopped; + return RuntimeActivity::Stopped; } let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); if matches!(state.as_str(), "active" | "activating") { - UnitActivity::Active + RuntimeActivity::Active } else { - UnitActivity::Stopped + RuntimeActivity::Stopped } } @@ -450,7 +497,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use crate::TransientWorkspaceSnapshot; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; struct TestDir { @@ -512,6 +559,31 @@ mod tests { } } + #[test] + fn opencode_client_target_extracts_basic_auth_header_and_strips_userinfo() { + let (baseurl, auth_header) = + opencode_client_target("http://opencode:secret@127.0.0.1:1234/"); + assert_eq!(baseurl, "http://127.0.0.1:1234"); + assert_eq!( + auth_header, + Some(format!( + "Basic {}", + BASE64_STANDARD.encode("opencode:secret") + )) + ); + } + + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + #[test] fn health_probe_client_reuses_cached_client_for_same_uri() { let mut cached_probe_client = None; @@ -591,10 +663,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-health.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-health.service", + )); true }); @@ -692,10 +764,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}/"), - unit: "run-u-health-trailing-slash.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}/"), + "run-u-health-trailing-slash.service", + )); true }); @@ -786,10 +858,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-events.service", + )); true }); @@ -876,10 +948,10 @@ mod tests { let mut workspace_rx = workspace.subscribe(); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-stopped.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-stopped.service", + )); true }); @@ -945,10 +1017,10 @@ mod tests { .expect("workspace should exist"); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-active.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-active.service", + )); true }); @@ -980,7 +1052,7 @@ mod tests { let _path_guard = EnvVarGuard::set("PATH", empty_bin.as_os_str()); let activity = read_unit_activity("missing.service").await; - assert_eq!(activity, UnitActivity::Unknown); + assert_eq!(activity, RuntimeActivity::Unknown); }); } } diff --git a/lib/src/services/persistent_storage.rs b/lib/src/services/persistent_storage.rs index 2569c90..a69b3f2 100644 --- a/lib/src/services/persistent_storage.rs +++ b/lib/src/services/persistent_storage.rs @@ -282,9 +282,14 @@ mod tests { archived: true, description: "loaded from disk".to_string(), created_at: Some(UNIX_EPOCH + Duration::from_secs(10)), + assigned_repository: None, + automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + ignored_issue_urls: Vec::new(), + tasks: Vec::new(), }; let snapshot_path = storage_dir.join("alpha.json"); tokio::fs::write( @@ -368,9 +373,14 @@ mod tests { archived: true, description: "added later".to_string(), created_at: Some(UNIX_EPOCH + Duration::from_secs(20)), + assigned_repository: None, + automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + ignored_issue_urls: Vec::new(), + tasks: Vec::new(), }; tokio::fs::write( storage_dir.join("beta.json"), @@ -484,9 +494,14 @@ mod tests { archived: false, description: "missing created_at".to_string(), created_at: None, + assigned_repository: None, + automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + ignored_issue_urls: Vec::new(), + tasks: Vec::new(), }; let snapshot_path = storage_dir.join("gamma.json"); tokio::fs::write( diff --git a/lib/src/services/resource_usage_service.rs b/lib/src/services/resource_usage_service.rs index 36b4a57..e27a48e 100644 --- a/lib/src/services/resource_usage_service.rs +++ b/lib/src/services/resource_usage_service.rs @@ -6,7 +6,10 @@ use std::{ use tokio::{process::Command, sync::watch}; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeActivity, RuntimeUsageSample, RuntimeUsageState, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace}; const RESOURCE_MONITOR_INTERVAL: Duration = Duration::from_secs(2); @@ -22,19 +25,6 @@ impl From for ResourceUsageServiceError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct UnitUsageSample { - memory_current: Option, - cpu_usage_nsec: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitUsageState { - Active(UnitUsageSample), - Stopped, - Unknown, -} - pub async fn resource_usage_service( manager: Arc, ) -> Result<(), ResourceUsageServiceError> { @@ -70,33 +60,48 @@ async fn watch_workspace_snapshot( continue; }; - let unit_changed = sampled_unit.as_deref() != Some(transient.unit.as_str()); + let unit_changed = sampled_unit.as_deref() != Some(transient.runtime.id.as_str()); if unit_changed { previous_cpu_sample = None; - sampled_unit = Some(transient.unit.clone()); + sampled_unit = Some(transient.runtime.id.clone()); next_sample_at = None; } let now = Instant::now(); if should_sample_usage(now, next_sample_at) { - match read_unit_usage(&transient.unit).await { - UnitUsageState::Active(usage_sample) => { - let (cpu_percent, next_cpu_sample) = cpu_percent_from_sample( - previous_cpu_sample, - usage_sample.cpu_usage_nsec, - now, - ); + match WorkspaceRuntime::read_activity(&transient.runtime).await { + RuntimeActivity::Stopped => { + previous_cpu_sample = None; + clear_stale_runtime_for_unit(&workspace, &transient.runtime.id); + next_sample_at = Some(now + RESOURCE_MONITOR_INTERVAL); + let wait_timeout = next_poll_timeout(Instant::now(), next_sample_at); + if !wait_for_change_or_timeout(&mut workspace_rx, wait_timeout).await { + break; + } + continue; + } + RuntimeActivity::Active | RuntimeActivity::Unknown => {} + } + + match WorkspaceRuntime::read_usage(&transient.runtime).await { + RuntimeUsageSample { + state: Some(RuntimeUsageState::Active), + memory_current, + cpu_usage_nsec, + } => { + let (cpu_percent, next_cpu_sample) = + cpu_percent_from_sample(previous_cpu_sample, cpu_usage_nsec, now); previous_cpu_sample = next_cpu_sample; refresh_resource_usage( &workspace, - &transient.unit, + &transient.runtime.id, cpu_percent, - usage_sample.memory_current, + memory_current, ); } - UnitUsageState::Stopped | UnitUsageState::Unknown => { + RuntimeUsageSample { .. } => { previous_cpu_sample = None; - clear_resource_usage_for_unit(&workspace, &transient.unit); + clear_resource_usage_for_unit(&workspace, &transient.runtime.id); } } next_sample_at = Some(now + RESOURCE_MONITOR_INTERVAL); @@ -133,7 +138,7 @@ fn refresh_resource_usage( let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); let should_update = still_tracking_same_unit && (snapshot.usage_cpu_percent != cpu_percent || snapshot.usage_ram_bytes != ram_bytes); @@ -166,7 +171,7 @@ fn clear_resource_usage_for_unit(workspace: &Workspace, unit: &str) { let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); if has_usage && still_tracking_same_unit { snapshot.usage_cpu_percent = None; @@ -178,6 +183,79 @@ fn clear_resource_usage_for_unit(workspace: &Workspace, unit: &str) { }); } +fn clear_stale_runtime_for_unit(workspace: &Workspace, unit: &str) { + workspace.update(|snapshot| { + let still_tracking_same_unit = snapshot + .transient + .as_ref() + .map(|transient| transient.runtime.id.as_str() == unit) + .unwrap_or(false); + if !still_tracking_same_unit { + return false; + } + + let mut changed = false; + if snapshot.transient.is_some() { + snapshot.transient = None; + changed = true; + } + if snapshot.opencode_client.is_some() { + snapshot.opencode_client = None; + changed = true; + } + if snapshot.root_session_id.is_some() { + snapshot.root_session_id = None; + changed = true; + } + if snapshot.root_session_title.is_some() { + snapshot.root_session_title = None; + changed = true; + } + if snapshot.root_session_status.is_some() { + snapshot.root_session_status = None; + changed = true; + } + if snapshot.automation_session_id.is_some() { + snapshot.automation_session_id = None; + changed = true; + } + if snapshot.automation_session_status.is_some() { + snapshot.automation_session_status = None; + changed = true; + } + if snapshot.automation_agent_state.is_some() { + snapshot.automation_agent_state = None; + changed = true; + } + if let Some(active_task_id) = snapshot.active_task_id.clone() + && let Some(task_state) = snapshot.task_states.get_mut(&active_task_id) + { + if task_state.session_id.take().is_some() { + changed = true; + } + if task_state.session_status.take().is_some() { + changed = true; + } + if task_state.agent_state.take().is_some() { + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + } + if snapshot.usage_cpu_percent.is_some() { + snapshot.usage_cpu_percent = None; + changed = true; + } + if snapshot.usage_ram_bytes.is_some() { + snapshot.usage_ram_bytes = None; + changed = true; + } + changed + }); +} + fn cpu_percent_from_sample( previous_sample: Option<(u64, Instant)>, current_cpu_usage_nsec: Option, @@ -201,7 +279,8 @@ fn cpu_percent_from_sample( (cpu_percent, Some((current_cpu_usage_nsec, now))) } -async fn read_unit_usage(unit: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_usage(unit: &str) -> RuntimeUsageSample { let output = match Command::new("systemctl") .args([ "--user", @@ -221,20 +300,30 @@ async fn read_unit_usage(unit: &str) -> UnitUsageState { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } }; if !output.status.success() { - return UnitUsageState::Stopped; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; } parse_unit_usage_show_output(&String::from_utf8_lossy(&output.stdout)) } -fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +fn parse_unit_usage_show_output(output: &str) -> RuntimeUsageSample { let mut active_state: Option<&str> = None; let mut memory_current: Option = None; let mut cpu_usage_nsec: Option = None; @@ -253,16 +342,18 @@ fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { } let active_state = active_state.unwrap_or_default(); - if !matches!(active_state, "active" | "activating") { - return UnitUsageState::Stopped; - } - - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current, cpu_usage_nsec, - }) + state: Some(if matches!(active_state, "active" | "activating") { + RuntimeUsageState::Active + } else { + RuntimeUsageState::Stopped + }), + } } +#[cfg_attr(not(test), allow(dead_code))] fn parse_systemctl_u64(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() || trimmed == "[not set]" { @@ -290,10 +381,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=4096\nCPUUsageNSec=2000000000\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(4096), cpu_usage_nsec: Some(2_000_000_000), - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -302,10 +394,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=[not set]\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: None, cpu_usage_nsec: None, - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -313,7 +406,11 @@ mod tests { fn parse_unit_usage_show_output_reports_stopped_for_inactive_state() { assert_eq!( parse_unit_usage_show_output("ActiveState=inactive\nMemoryCurrent=0\nCPUUsageNSec=0\n"), - UnitUsageState::Stopped + RuntimeUsageSample { + memory_current: Some(0), + cpu_usage_nsec: Some(0), + state: Some(RuntimeUsageState::Stopped), + } ); } @@ -322,10 +419,11 @@ mod tests { let output = "CPUUsageNSec=300\nActiveState=active\nMemoryCurrent=1024\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(1024), cpu_usage_nsec: Some(300), - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -349,6 +447,54 @@ mod tests { assert_eq!(next_sample, None); } + #[test] + fn clear_stale_runtime_for_unit_clears_matching_runtime_state() { + let workspace = crate::manager::Workspace::new(crate::WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.transient = Some(crate::TransientWorkspaceSnapshot { + uri: "ws://127.0.0.1:1234".to_string(), + runtime: crate::RuntimeHandleSnapshot { + backend: crate::RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-1".to_string()); + snapshot.root_session_title = Some("Codex".to_string()); + snapshot.root_session_status = Some(crate::RootSessionStatus::Busy); + snapshot.active_task_id = Some("task-42".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-42".to_string()), + session_status: Some(crate::RootSessionStatus::Busy), + agent_state: Some(crate::AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.usage_cpu_percent = Some(25); + snapshot.usage_ram_bytes = Some(1024); + true + }); + + clear_stale_runtime_for_unit(&workspace, "runtime-1"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.transient.is_none()); + assert!(snapshot.root_session_id.is_none()); + assert!(snapshot.root_session_title.is_none()); + assert!(snapshot.root_session_status.is_none()); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert!(task_state.session_id.is_none()); + assert!(task_state.session_status.is_none()); + assert!(task_state.agent_state.is_none()); + assert!(snapshot.usage_cpu_percent.is_none()); + assert!(snapshot.usage_ram_bytes.is_none()); + } + #[test] fn should_sample_usage_only_when_interval_has_elapsed() { let now = Instant::now(); diff --git a/lib/src/services/root_session_service.rs b/lib/src/services/root_session_service.rs index 8a511a0..d9cec40 100644 --- a/lib/src/services/root_session_service.rs +++ b/lib/src/services/root_session_service.rs @@ -244,8 +244,16 @@ async fn query_current_root_session_details( .collect::>(), }; - let Some(root_session) = select_root_session(root_session_candidate, &sessions) else { - return Ok(None); + let root_session = match select_root_session(root_session_candidate, &sessions) { + Some(root_session) => root_session, + None => match bootstrap_root_session(client).await { + Ok(Some(root_session)) => root_session, + Ok(None) => return Ok(None), + Err(err) => { + tracing::warn!(error = %err, "failed to bootstrap root session"); + return Ok(None); + } + }, }; let root_session_id: String = root_session.id.clone().into(); @@ -275,6 +283,20 @@ async fn query_current_root_session_details( })) } +async fn bootstrap_root_session( + client: &opencode::client::Client, +) -> Result, String> { + client + .session_create( + None, + None, + &opencode::client::types::SessionCreateBody::default(), + ) + .await + .map(|response| Some(response.into_inner())) + .map_err(|err| err.to_string()) +} + fn select_root_session( root_session_candidate: Option, sessions: &[opencode::client::types::Session], @@ -395,17 +417,20 @@ fn map_session_status(status: &opencode::client::types::SessionStatus) -> RootSe fn should_refresh_from_event(event: &opencode::client::types::GlobalEvent) -> bool { matches!( &event.payload, - opencode::client::types::Event::SessionStatus(_) - | opencode::client::types::Event::SessionIdle(_) - | opencode::client::types::Event::SessionCompacted(_) - | opencode::client::types::Event::SessionCreated(_) - | opencode::client::types::Event::SessionUpdated(_) - | opencode::client::types::Event::SessionDeleted(_) - | opencode::client::types::Event::SessionDiff(_) - | opencode::client::types::Event::SessionError(_) - | opencode::client::types::Event::QuestionAsked(_) - | opencode::client::types::Event::QuestionReplied(_) - | opencode::client::types::Event::QuestionRejected(_) + opencode::client::types::GlobalEventPayload::EventSessionStatus(_) + | opencode::client::types::GlobalEventPayload::EventSessionIdle(_) + | opencode::client::types::GlobalEventPayload::EventSessionCompacted(_) + | opencode::client::types::GlobalEventPayload::EventSessionCreated(_) + | opencode::client::types::GlobalEventPayload::EventSessionUpdated(_) + | opencode::client::types::GlobalEventPayload::EventSessionDeleted(_) + | opencode::client::types::GlobalEventPayload::SyncEventSessionCreated(_) + | opencode::client::types::GlobalEventPayload::SyncEventSessionUpdated(_) + | opencode::client::types::GlobalEventPayload::SyncEventSessionDeleted(_) + | opencode::client::types::GlobalEventPayload::EventSessionDiff(_) + | opencode::client::types::GlobalEventPayload::EventSessionError(_) + | opencode::client::types::GlobalEventPayload::EventQuestionAsked(_) + | opencode::client::types::GlobalEventPayload::EventQuestionReplied(_) + | opencode::client::types::GlobalEventPayload::EventQuestionRejected(_) ) } @@ -416,7 +441,9 @@ fn normalize_base_uri(uri: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::Notify, @@ -440,6 +467,33 @@ mod tests { .to_string() } + fn single_session_json(session_id: &str, title: &str) -> String { + serde_json::json!({ + "directory": "/workspace", + "id": session_id, + "projectID": "project-1", + "slug": "root", + "time": { + "created": 1, + "updated": 1 + }, + "title": title, + "version": "1" + }) + .to_string() + } + + fn transient_snapshot(uri: &str, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri: uri.to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn sessions_json_with_subagent( root_session_id: &str, root_title: &str, @@ -552,6 +606,7 @@ mod tests { "payload": { "type": "session.updated", "properties": { + "sessionID": session_id, "info": { "directory": "/workspace", "id": session_id, @@ -651,10 +706,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -687,6 +742,98 @@ mod tests { }); } + #[test] + fn service_bootstraps_root_session_when_server_starts_empty() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let addr = listener + .local_addr() + .expect("listener should expose local addr"); + let server_task = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + let mut buffer = vec![0_u8; 4096]; + let read = socket.read(&mut buffer).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buffer[..read]); + + if request.contains("GET /question") { + let body = "[]"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.contains("GET /session/status") { + let body = "{}"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.starts_with("POST /session") { + let body = + single_session_json("ses-root-created", "Root session created"); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.contains("GET /session") { + let body = "[]"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + let response = + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + }); + } + }); + + let base_uri = format!("http://{addr}"); + let client = opencode::client::Client::new(&base_uri); + let root_session = query_current_root_session_details(&client) + .await + .expect("query should succeed") + .expect("root session should be bootstrapped when session list is empty"); + assert_eq!(root_session.id, "ses-root-created"); + assert_eq!(root_session.title, "Root session created"); + assert_eq!(root_session.status, None); + + server_task.abort(); + }); + } + #[test] fn service_marks_pending_question_for_root_session() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -781,10 +928,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -894,10 +1041,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1053,10 +1200,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1194,10 +1341,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1310,10 +1457,8 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-race.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-race.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1437,10 +1582,8 @@ mod tests { let base_uri = format!("http://{addr}"); let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-stale.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-stale.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client.clone(), events: old_event_tx.clone(), @@ -1558,10 +1701,10 @@ mod tests { let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-same-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-same-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1688,10 +1831,10 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-ignore-non-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-ignore-non-session.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1750,10 +1893,10 @@ mod tests { let (old_event_tx, _) = broadcast::channel(64); let old_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:9")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9/".to_string(), - unit: "run-u-root-old-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9/", + "run-u-root-old-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1774,10 +1917,10 @@ mod tests { let (new_event_tx, _) = broadcast::channel(64); let new_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:10")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:10/".to_string(), - unit: "run-u-root-new-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:10/", + "run-u-root-new-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: new_client, events: new_event_tx, diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs new file mode 100644 index 0000000..f592c3a --- /dev/null +++ b/lib/src/services/runtime.rs @@ -0,0 +1,3206 @@ +use std::{ + collections::BTreeMap, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Output, Stdio}, + sync::OnceLock, +}; + +use serde::Deserialize; +use tokio::{io::AsyncWriteExt, process::Command, sync::Mutex}; +use uuid::Uuid; + +use super::{ + combined::{CombinedServiceError, SpawnCommand}, + config::{ + AgentProvider, CodexAgentConfig, CodexApprovalPolicy, CodexSandboxMode, + ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file, + }, +}; +use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + +pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; +const APPLE_GITCONFIG_DIR: &str = "/multicode-host/git"; +const APPLE_GITCONFIG_FILE_NAME: &str = ".gitconfig"; +const SYNTHETIC_CODEX_HOME: &str = "/multicode-agent/codex-home"; +pub(crate) const AUTOMATION_STATE_DIR: &str = "/multicode-agent/automation"; +pub(crate) const AUTOMATION_STATE_ENV: &str = "MULTICODE_AUTONOMOUS_STATE_PATH"; +pub(crate) const AUTOMATION_STATE_FILE_NAME: &str = "state"; + +fn is_synthetic_container_target(path: &Path) -> bool { + path.starts_with("/multicode-agent") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeActivity { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeUsageState { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct RuntimeUsageSample { + pub(super) memory_current: Option, + pub(super) cpu_usage_nsec: Option, + pub(super) state: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RuntimeStartResult { + pub(super) transient: TransientWorkspaceSnapshot, +} + +#[derive(Debug, Clone)] +struct RuntimeContext { + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + agent_provider: AgentProvider, + host_agent_command: String, + container_agent_command: String, + codex: CodexAgentConfig, +} + +#[derive(Debug, Clone)] +pub(super) enum WorkspaceRuntime { + Linux(LinuxSystemdBwrapRuntime), + AppleContainer(AppleContainerRuntime), +} + +fn append_agent_env(context: &RuntimeContext, env: &mut Vec<(String, String)>, password: &str) { + env.push(( + AUTOMATION_STATE_ENV.to_string(), + format!("{AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}"), + )); + match context.agent_provider { + AgentProvider::Opencode => { + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + } + AgentProvider::Codex => { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } + } +} + +fn start_command_args( + context: &RuntimeContext, + command: &str, + host: &str, + port: u16, +) -> Vec { + match context.agent_provider { + AgentProvider::Opencode => vec![ + command.to_string(), + "serve".to_string(), + "--hostname".to_string(), + host.to_string(), + "--port".to_string(), + port.to_string(), + ], + AgentProvider::Codex => { + vec![ + command.to_string(), + "app-server".to_string(), + "--listen".to_string(), + format!("ws://{host}:{port}"), + ] + } + } +} + +fn server_uri(context: &RuntimeContext, password: &str, port: u16) -> String { + match context.agent_provider { + AgentProvider::Opencode => format!("http://opencode:{password}@127.0.0.1:{port}/"), + AgentProvider::Codex => format!("ws://127.0.0.1:{port}"), + } +} + +pub(crate) fn synthetic_codex_home_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("codex") + .join(key) + .join("home") +} + +pub(crate) fn automation_state_dir_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("automation") + .join(key) +} + +pub(crate) fn automation_state_file_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + automation_state_dir_source(workspace_directory_path, key).join(AUTOMATION_STATE_FILE_NAME) +} + +pub(crate) fn automation_task_state_dir_source( + workspace_directory_path: &Path, + key: &str, +) -> PathBuf { + automation_state_dir_source(workspace_directory_path, key).join("tasks") +} + +pub(crate) fn automation_task_state_file_source( + workspace_directory_path: &Path, + key: &str, + task_id: &str, +) -> PathBuf { + automation_task_state_dir_source(workspace_directory_path, key).join(format!("{task_id}.state")) +} + +async fn prepare_synthetic_codex_home( + source_root: &Path, + added_skills: &[super::config::AddedSkillMount], + config: &CodexAgentConfig, + materialize_host_auth: bool, +) -> Result<(), CombinedServiceError> { + tokio::fs::create_dir_all(source_root).await?; + let host_codex_home = host_codex_home_path()?; + let target_config = source_root.join("config.toml"); + let target_auth = source_root.join("auth.json"); + let host_auth = host_codex_auth_path()?; + + copy_optional_host_file(&host_codex_home.join("config.toml"), &target_config).await?; + write_synthetic_codex_config(&target_config, config).await?; + match tokio::fs::remove_file(&target_auth).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + if materialize_host_auth { + if let Some(host_auth) = host_auth.as_ref() { + copy_optional_host_file(host_auth, &target_auth).await?; + } + } else if host_auth.is_some() { + let mut auth_placeholder = tokio::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&target_auth) + .await?; + auth_placeholder.flush().await?; + tokio::fs::set_permissions(&target_auth, std::fs::Permissions::from_mode(0o600)).await?; + } + copy_optional_host_file( + &host_codex_home.join("AGENTS.md"), + &source_root.join("AGENTS.md"), + ) + .await?; + + let target_skills_root = source_root.join("skills"); + clear_directory_contents(&target_skills_root).await?; + let host_skills_root = host_codex_home.join("skills"); + if tokio::fs::metadata(&host_skills_root) + .await + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + copy_directory_tree(&host_skills_root, &target_skills_root).await?; + } + for skill in added_skills { + let Some(skill_name) = skill.target.file_name() else { + return Err(CombinedServiceError::InvalidRuntimeConfig { + field: "isolation.add-skills-from".to_string(), + message: format!( + "added skill target '{}' is missing a terminal directory name", + skill.target.display() + ), + }); + }; + copy_directory_tree(&skill.source, &target_skills_root.join(skill_name)).await?; + } + + Ok(()) +} + +fn host_codex_home_path() -> Result { + let host_home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| { + CombinedServiceError::ShellExpand("HOME environment variable not found".to_string()) + })?; + Ok(host_home.join(".codex")) +} + +fn host_codex_auth_path() -> Result, CombinedServiceError> { + let path = host_codex_home_path()?.join("auth.json"); + Ok((path.is_absolute() && path.is_file() && std::fs::read(&path).is_ok()).then_some(path)) +} + +async fn write_synthetic_codex_config( + target: &Path, + config: &CodexAgentConfig, +) -> Result<(), std::io::Error> { + let contents = match tokio::fs::read_to_string(target).await { + Ok(existing) => existing, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + let contents = rewrite_synthetic_codex_config(&contents, config); + tokio::fs::write(target, contents).await +} + +fn render_multicode_codex_config_overrides(config: &CodexAgentConfig) -> String { + let mut lines = vec!["# Managed by multicode".to_string()]; + + if let Some(profile) = config.profile.as_deref() { + lines.push(format!( + "profile = {}", + toml::Value::String(profile.to_string()) + )); + } + if let Some(model) = config.model.as_deref() { + lines.push(format!( + "model = {}", + toml::Value::String(model.to_string()) + )); + } + if let Some(model_provider) = config.model_provider.as_deref() { + lines.push(format!( + "model_provider = {}", + toml::Value::String(model_provider.to_string()) + )); + } + lines.push(format!( + "approval_policy = {}", + toml::Value::String(codex_approval_policy_config_value(config.approval_policy).to_string()) + )); + lines.push(format!( + "sandbox_mode = {}", + toml::Value::String(codex_sandbox_mode_config_value(config.sandbox_mode).to_string()) + )); + + lines.join("\n") + "\n" +} + +fn rewrite_synthetic_codex_config(existing: &str, config: &CodexAgentConfig) -> String { + let mut root_lines = Vec::new(); + let mut section_lines = Vec::new(); + let mut in_root = true; + let mut skipping_managed_block = false; + + for line in existing.lines() { + let trimmed = line.trim(); + if trimmed == "# Managed by multicode" { + skipping_managed_block = true; + continue; + } + if skipping_managed_block { + if trimmed.is_empty() || is_root_codex_override_line(trimmed, config) { + continue; + } + skipping_managed_block = false; + } + + if trimmed.starts_with('[') { + in_root = false; + } + + if in_root && is_root_codex_override_line(trimmed, config) { + continue; + } + + if in_root { + root_lines.push(line); + } else { + section_lines.push(line); + } + } + + while root_lines.last().is_some_and(|line| line.trim().is_empty()) { + root_lines.pop(); + } + + let mut rewritten = String::new(); + if !root_lines.is_empty() { + rewritten.push_str(&root_lines.join("\n")); + rewritten.push('\n'); + if !rewritten.ends_with("\n\n") { + rewritten.push('\n'); + } + } + rewritten.push_str(&render_multicode_codex_config_overrides(config)); + + let section_body = section_lines.join("\n"); + if !section_body.trim().is_empty() { + if !rewritten.ends_with("\n\n") { + rewritten.push('\n'); + } + rewritten.push_str(§ion_body); + if !rewritten.ends_with('\n') { + rewritten.push('\n'); + } + } + + rewritten +} + +fn is_root_codex_override_line(line: &str, config: &CodexAgentConfig) -> bool { + if line.starts_with("approval_policy =") || line.starts_with("sandbox_mode =") { + return true; + } + if config.profile.is_some() && line.starts_with("profile =") { + return true; + } + if config.model.is_some() && line.starts_with("model =") { + return true; + } + if config.model_provider.is_some() && line.starts_with("model_provider =") { + return true; + } + false +} + +fn codex_approval_policy_config_value(policy: CodexApprovalPolicy) -> &'static str { + match policy { + CodexApprovalPolicy::Untrusted => "untrusted", + CodexApprovalPolicy::OnFailure => "on-failure", + CodexApprovalPolicy::OnRequest => "on-request", + CodexApprovalPolicy::Never => "never", + } +} + +fn codex_sandbox_mode_config_value(mode: CodexSandboxMode) -> &'static str { + match mode { + CodexSandboxMode::ReadOnly => "read-only", + CodexSandboxMode::WorkspaceWrite => "workspace-write", + CodexSandboxMode::DangerFullAccess => "danger-full-access", + CodexSandboxMode::ExternalSandbox => "danger-full-access", + } +} + +async fn copy_optional_host_file(source: &Path, target: &Path) -> Result<(), std::io::Error> { + let Ok(metadata) = tokio::fs::metadata(source).await else { + return Ok(()); + }; + if !metadata.is_file() { + return Ok(()); + } + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(source, target).await?; + Ok(()) +} + +impl WorkspaceRuntime { + pub(super) fn new( + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + agent_provider: AgentProvider, + host_agent_command: String, + container_agent_command: String, + codex: CodexAgentConfig, + ) -> Self { + let context = RuntimeContext { + runtime: runtime.clone(), + workspace_directory_path, + expanded_isolation, + agent_provider, + host_agent_command, + container_agent_command, + codex, + }; + match runtime.backend { + RuntimeBackend::LinuxSystemdBwrap => Self::Linux(LinuxSystemdBwrapRuntime { context }), + RuntimeBackend::AppleContainer => { + Self::AppleContainer(AppleContainerRuntime { context }) + } + } + } + + pub(super) async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => runtime.start_server(key, inherited_env).await, + Self::AppleContainer(runtime) => runtime.start_server(key, inherited_env).await, + } + } + + pub(super) async fn stop_server( + &self, + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::stop_server(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::stop_server(runtime_handle).await + } + } + } + + pub(super) async fn build_pty_command( + &self, + key: &str, + runtime_handle: Option<&RuntimeHandleSnapshot>, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + match self { + Self::Linux(runtime) => { + runtime + .build_pty_command(key, runtime_handle, inherited_env, command) + .await + } + Self::AppleContainer(runtime) => { + runtime + .build_pty_command(key, runtime_handle, inherited_env, command) + .await + } + } + } + + pub(super) async fn build_linux_start_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => { + runtime + .build_systemd_bwrap_command(key, password, port, unit, inherited_env) + .await + } + Self::AppleContainer(_) => Err(CombinedServiceError::UnsupportedRuntimeBackend( + "build_systemd_bwrap_command is only available for the linux-systemd-bwrap backend" + .to_string(), + )), + } + } + + pub(super) async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_activity(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_activity(runtime_handle).await + } + } + } + + pub(super) async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_usage(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_usage(runtime_handle).await + } + } + } + + pub(super) fn backend(&self) -> RuntimeBackend { + match self { + Self::Linux(_) => RuntimeBackend::LinuxSystemdBwrap, + Self::AppleContainer(_) => RuntimeBackend::AppleContainer, + } + } + + pub(super) fn runtime_spec(&self) -> String { + let context = match self { + Self::Linux(runtime) => &runtime.context, + Self::AppleContainer(runtime) => &runtime.context, + }; + + let mut parts = vec![ + format!("backend={:?}", context.runtime.backend), + format!("agent-provider={:?}", context.agent_provider), + format!( + "image={}", + context.runtime.image.as_deref().unwrap_or_default() + ), + format!("host-agent={}", context.host_agent_command), + format!("container-agent={}", context.container_agent_command), + format!( + "readable={}", + format_path_list(&context.expanded_isolation.readable) + ), + format!( + "writable={}", + format_path_list(&context.expanded_isolation.writable) + ), + format!( + "isolated={}", + format_path_list(&context.expanded_isolation.isolated) + ), + format!( + "tmpfs={}", + format_path_list(&context.expanded_isolation.tmpfs) + ), + format!( + "skills={}", + format_skill_mounts(&context.expanded_isolation.added_skills) + ), + format!( + "inherit-env={}", + context.expanded_isolation.inherit_env.join(",") + ), + format!( + "memory-high={}", + context + .expanded_isolation + .memory_high_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "memory-max={}", + context + .expanded_isolation + .memory_max_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "cpu={}", + context + .expanded_isolation + .cpu + .as_deref() + .unwrap_or_default() + ), + ]; + parts.push(format!( + "workspace-root={}", + context.workspace_directory_path.to_string_lossy() + )); + parts.join("\n") + } +} + +#[derive(Debug, Clone)] +pub(super) struct LinuxSystemdBwrapRuntime { + context: RuntimeContext, +} + +impl LinuxSystemdBwrapRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let unit = generate_linux_runtime_id(); + let command = self + .build_systemd_bwrap_command(key, &password, port, &unit, inherited_env) + .await?; + let mut process = Command::new(&command.program); + process + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .args(&command.args); + for (name, value) in &command.inherited_env { + process.env(name, value); + } + let output = process.output().await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: server_uri(&self.context, &password, port), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: unit, + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::Linux(self.clone()).runtime_spec(), + )]), + }, + }, + }) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let args = vec![ + "--user".to_string(), + "stop".to_string(), + "--no-block".to_string(), + runtime_handle.id.clone(), + ]; + let output = Command::new("systemctl") + .args(args) + .stdin(Stdio::null()) + .output() + .await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + _runtime_handle: Option<&RuntimeHandleSnapshot>, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let unit = generate_linux_runtime_id(); + let mut env = inherited_env.to_vec(); + if self.context.agent_provider == AgentProvider::Codex { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } + let mut args = vec![ + "--user".to_string(), + "--wait".to_string(), + "--collect".to_string(), + "--pty".to_string(), + ]; + append_systemd_run_inherit_env(&mut args, &env); + args.push("--unit".to_string()); + args.push(unit); + self.append_systemd_limits(&mut args); + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.extend(command); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: env, + }) + } + + async fn build_systemd_bwrap_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + let mut args = vec!["--user".to_string(), "--no-block".to_string()]; + let mut env = inherited_env.to_vec(); + append_agent_env(&self.context, &mut env, password); + append_systemd_run_inherit_env(&mut args, &env); + args.push("--unit".to_string()); + args.push(unit.to_string()); + self.append_systemd_limits(&mut args); + + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.extend(start_command_args( + &self.context, + &self.context.host_agent_command, + "127.0.0.1", + port, + )); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: env, + }) + } + + fn append_systemd_limits(&self, args: &mut Vec) { + if let Some(memory_high_bytes) = self.context.expanded_isolation.memory_high_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryHigh={memory_high_bytes}")); + } + if let Some(memory_max_bytes) = self.context.expanded_isolation.memory_max_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryMax={memory_max_bytes}")); + args.push("-p".to_string()); + args.push("MemorySwapMax=0".to_string()); + } + if let Some(cpu) = &self.context.expanded_isolation.cpu { + args.push("-p".to_string()); + args.push(format!("CPUQuota={cpu}")); + } + } + + async fn append_bwrap_sandbox_args( + &self, + args: &mut Vec, + key: &str, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let workspace_path_str = workspace_path.to_string_lossy().into_owned(); + + args.push("bwrap".to_string()); + args.push("--chdir".to_string()); + args.push(workspace_path_str.clone()); + + args.push("--ro-bind".to_string()); + args.push("/".to_string()); + args.push("/".to_string()); + + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), + ); + mount_specs.push(MountSpec::new( + PathBuf::from(AUTOMATION_STATE_DIR), + Some(automation_state_dir_source( + &self.context.workspace_directory_path, + key, + )), + MountKind::Writable, + )); + if self.context.agent_provider == AgentProvider::Codex { + let source = synthetic_codex_home_source(&self.context.workspace_directory_path, key); + prepare_synthetic_codex_home( + &source, + &self.context.expanded_isolation.added_skills, + &self.context.codex, + false, + ) + .await?; + mount_specs.push(MountSpec::new( + PathBuf::from(SYNTHETIC_CODEX_HOME), + Some(source), + MountKind::Writable, + )); + if let Some(host_codex_auth) = host_codex_auth_path()? { + mount_specs.push(MountSpec::new( + PathBuf::from(SYNTHETIC_CODEX_HOME).join("auth.json"), + Some(host_codex_auth), + MountKind::Readable, + )); + } + } + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + if !is_synthetic_container_target(&resolved_mount.mount.target) { + resolved_mount.prepare_target_node(owns_node).await?; + } + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_args(args); + } + + args.push("--proc".to_string()); + args.push("/proc".to_string()); + args.push("--dev".to_string()); + args.push("/dev".to_string()); + args.push("--die-with-parent".to_string()); + + Ok(()) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.context + .workspace_directory_path + .join(".multicode") + .join("isolate") + .join(key) + .join(relative) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--value", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + + if !output.status.success() { + return RuntimeActivity::Stopped; + } + + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if matches!(state.as_str(), "active" | "activating") { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--property", + "MemoryCurrent", + "--property", + "CPUUsageNSec", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + if !output.status.success() { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + } + + parse_linux_unit_usage(&String::from_utf8_lossy(&output.stdout)) + } +} + +#[derive(Debug, Clone)] +pub(super) struct AppleContainerRuntime { + context: RuntimeContext, +} + +impl AppleContainerRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let _start_guard = apple_container_start_lock().lock().await; + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let container_name = self.generate_runtime_id(key); + let command = self + .build_run_command(key, &container_name, &password, port, inherited_env) + .await?; + + let output = self + .run_container_start_command(command.program.clone(), command.args.clone()) + .await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let mut metadata = BTreeMap::new(); + metadata.insert("workspace-key".to_string(), key.to_string()); + metadata.insert("port".to_string(), port.to_string()); + metadata.insert( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::AppleContainer(self.clone()).runtime_spec(), + ); + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: server_uri(&self.context, &password, port), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: container_name, + metadata, + }, + }, + }) + } + + async fn run_container_start_command( + &self, + program: String, + args: Vec, + ) -> Result { + let output = run_blocking_process(program.clone(), args.clone()).await?; + if output.status.success() { + return Ok(output); + } + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !container_start_reports_allocator_exhaustion(&stderr) { + return Ok(output); + } + + tracing::warn!( + stderr = %stderr.trim(), + "apple container allocator exhausted; pruning containers before retry" + ); + let prune_output = run_blocking_process( + container_program(), + vec!["prune".to_string(), "-f".to_string()], + ) + .await?; + if !prune_output.status.success() { + let prune_stderr = String::from_utf8_lossy(&prune_output.stderr) + .trim() + .to_string(); + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: if prune_stderr.is_empty() { + format!("{stderr}\nautomatic container prune failed") + } else { + format!("{stderr}\nautomatic container prune failed: {prune_stderr}") + }, + }); + } + + tracing::info!("apple container prune succeeded; retrying workspace startup"); + let retry_output = run_blocking_process(program, args).await?; + if !retry_output.status.success() + && container_start_reports_allocator_exhaustion(&String::from_utf8_lossy( + &retry_output.stderr, + )) + { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: retry_output.status.code(), + stderr: format!( + "{}\nautomatic container prune was insufficient; restart the Apple container backend", + String::from_utf8_lossy(&retry_output.stderr).trim() + ), + }); + } + Ok(retry_output) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let output = run_blocking_process( + container_program(), + vec![ + "rm".to_string(), + "-f".to_string(), + runtime_handle.id.clone(), + ], + ) + .await?; + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() || container_delete_reports_missing(&stderr) { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: stderr.into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + runtime_handle: Option<&RuntimeHandleSnapshot>, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + if let Some(runtime_handle) = runtime_handle + && runtime_handle.backend == RuntimeBackend::AppleContainer + { + return self + .build_exec_command(runtime_handle, key, inherited_env, command) + .await; + } + + let image = self + .context + .runtime + .resolved_image(self.context.agent_provider) + .ok_or_else(|| CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: format!( + "apple-container backend requires a runtime image for provider '{}'", + match self.context.agent_provider { + AgentProvider::Opencode => "opencode", + AgentProvider::Codex => "codex", + } + ), + })?; + let mut env = inherited_env.to_vec(); + ensure_pty_terminal_env(&mut env); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + let env_file = self.write_env_file(key, "exec.env", &env).await?; + let workspace_path = self.context.workspace_directory_path.join(key); + let mut args = vec![ + "run".to_string(), + "--rm".to_string(), + "--tty".to_string(), + "--interactive".to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(args.as_mut(), key, host_gitconfig.as_deref()) + .await?; + args.push(image.to_string()); + args.extend(command); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + async fn build_exec_command( + &self, + runtime_handle: &RuntimeHandleSnapshot, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let mut env = inherited_env.to_vec(); + ensure_pty_terminal_env(&mut env); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + let env_file = self.write_env_file(key, "exec.env", &env).await?; + let workspace_path = self.context.workspace_directory_path.join(key); + let args = vec![ + "exec".to_string(), + "--tty".to_string(), + "--interactive".to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + runtime_handle.id.clone(), + ] + .into_iter() + .chain(command) + .collect(); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let inspect_output = run_blocking_process( + container_program(), + vec!["inspect".to_string(), runtime_handle.id.clone()], + ) + .await; + if let Ok(output) = inspect_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains(r#""status":"running""#) { + return RuntimeActivity::Active; + } + if stdout.contains(r#""status":"stopped""#) + || stdout.contains(r#""status":"exited""#) + { + return RuntimeActivity::Stopped; + } + } else { + return RuntimeActivity::Stopped; + } + } + + let output = match run_blocking_process(container_program(), vec!["list".to_string()]).await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + if !output.status.success() { + return RuntimeActivity::Unknown; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout + .lines() + .any(|line| line.contains(runtime_handle.id.as_str())) + { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(_runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + let output = match run_blocking_process( + container_program(), + vec![ + "stats".to_string(), + "--format".to_string(), + "json".to_string(), + "--no-stream".to_string(), + _runtime_handle.id.clone(), + ], + ) + .await + { + Ok(output) => output, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + if !output.status.success() { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + } + + parse_apple_container_usage(&String::from_utf8_lossy(&output.stdout)) + } + + async fn build_run_command( + &self, + key: &str, + container_name: &str, + password: &str, + port: u16, + inherited_env: &[(String, String)], + ) -> Result { + let image = self + .context + .runtime + .resolved_image(self.context.agent_provider) + .ok_or_else(|| CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: format!( + "apple-container backend requires a runtime image for provider '{}'", + match self.context.agent_provider { + AgentProvider::Opencode => "opencode", + AgentProvider::Codex => "codex", + } + ), + })?; + let mut env = inherited_env.to_vec(); + append_agent_env(&self.context, &mut env, password); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + + let workspace_path = self.context.workspace_directory_path.join(key); + tokio::fs::create_dir_all(&workspace_path).await?; + let env_file = self.write_env_file(key, "server.env", &env).await?; + + let mut args = vec![ + "run".to_string(), + "--detach".to_string(), + "--rm".to_string(), + "--name".to_string(), + container_name.to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + "--publish".to_string(), + format!("127.0.0.1:{port}:{port}/tcp"), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(&mut args, key, host_gitconfig.as_deref()) + .await?; + args.push(image.to_string()); + args.extend(start_command_args( + &self.context, + &self.context.container_agent_command, + "0.0.0.0", + port, + )); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + fn append_container_limits(&self, args: &mut Vec) { + if let Some(cpu) = self + .context + .expanded_isolation + .cpu + .as_deref() + .and_then(container_cpu_value) + { + args.push("--cpus".to_string()); + args.push(cpu); + } + + let memory_limit = self + .context + .expanded_isolation + .memory_max_bytes + .or(self.context.expanded_isolation.memory_high_bytes); + if let Some(memory_limit) = memory_limit { + args.push("--memory".to_string()); + args.push(memory_limit.to_string()); + } + } + + async fn append_container_mounts( + &self, + args: &mut Vec, + key: &str, + host_gitconfig: Option<&Path>, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let implicit_gitconfig_mount = self + .build_implicit_gitconfig_mount(key, host_gitconfig) + .await?; + let aggregated_skill_mount = self.build_aggregated_skill_mount(key).await?; + let aggregated_skill_target = aggregated_skill_mount + .as_ref() + .map(|mount| mount.target.clone()); + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .filter(|path| !self.is_implicitly_handled_gitconfig(path, host_gitconfig)) + .filter(|path| aggregated_skill_target.as_ref() != Some(path)) + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + if let Some(skill_mount) = aggregated_skill_mount { + mount_specs.push(skill_mount); + } else { + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| { + MountSpec::new(mount.target, Some(mount.source), MountKind::Readable) + }), + ); + } + if let Some(implicit_gitconfig_mount) = implicit_gitconfig_mount { + mount_specs.push(implicit_gitconfig_mount); + } + mount_specs.push(MountSpec::new( + PathBuf::from(AUTOMATION_STATE_DIR), + Some(automation_state_dir_source( + &self.context.workspace_directory_path, + key, + )), + MountKind::Writable, + )); + if self.context.agent_provider == AgentProvider::Codex { + let source = synthetic_codex_home_source(&self.context.workspace_directory_path, key); + prepare_synthetic_codex_home( + &source, + &self.context.expanded_isolation.added_skills, + &self.context.codex, + true, + ) + .await?; + mount_specs.push(MountSpec::new( + PathBuf::from(SYNTHETIC_CODEX_HOME), + Some(source), + MountKind::Writable, + )); + } + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + if resolved_mount.needs_container_target_materialization() { + resolved_mount.prepare_target_node(owns_node).await?; + resolved_mount.prepare_container_materialized_file().await?; + } + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_container_args(args); + } + + Ok(()) + } + + async fn append_implicit_env( + &self, + env: &mut Vec<(String, String)>, + host_gitconfig: Option<&Path>, + ) -> Result<(), CombinedServiceError> { + env.push(( + AUTOMATION_STATE_ENV.to_string(), + format!("{AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}"), + )); + if self.context.agent_provider == AgentProvider::Codex { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } + if host_gitconfig.is_some() { + env.push(( + "GIT_CONFIG_GLOBAL".to_string(), + format!("{APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}"), + )); + } + Ok(()) + } + + async fn build_implicit_gitconfig_mount( + &self, + key: &str, + host_gitconfig: Option<&Path>, + ) -> Result, CombinedServiceError> { + let Some(host_gitconfig) = host_gitconfig else { + return Ok(None); + }; + + let source_root = self.apple_runtime_root(key).join("gitconfig"); + clear_directory_contents(&source_root).await?; + let gitconfig_contents = std::fs::read(host_gitconfig)?; + tokio::fs::write( + source_root.join(APPLE_GITCONFIG_FILE_NAME), + gitconfig_contents, + ) + .await?; + + Ok(Some(MountSpec::new( + PathBuf::from(APPLE_GITCONFIG_DIR), + Some(source_root), + MountKind::Readable, + ))) + } + + fn host_gitconfig_path_for_env(&self, env: &[(String, String)]) -> Option { + let home = env + .iter() + .find(|(name, _)| name == "HOME") + .map(|(_, value)| value)?; + let path = PathBuf::from(home).join(".gitconfig"); + (path.is_absolute() && path.is_file() && std::fs::read(&path).is_ok()).then_some(path) + } + + fn is_implicitly_handled_gitconfig(&self, path: &Path, host_gitconfig: Option<&Path>) -> bool { + host_gitconfig.is_some_and(|gitconfig| gitconfig == path) + } + + async fn build_aggregated_skill_mount( + &self, + key: &str, + ) -> Result, CombinedServiceError> { + let added_skills = &self.context.expanded_isolation.added_skills; + if added_skills.is_empty() { + return Ok(None); + } + + let Some(target_root) = added_skills + .first() + .and_then(|mount| mount.target.parent()) + .map(Path::to_path_buf) + else { + return Ok(None); + }; + + if added_skills + .iter() + .any(|mount| mount.target.parent() != Some(target_root.as_path())) + { + return Ok(None); + } + + let overlay_target = self + .readable_parent_for_path(&target_root) + .unwrap_or_else(|| target_root.clone()); + let aggregate_root = self.apple_runtime_root(key).join("skills"); + clear_directory_contents(&aggregate_root).await?; + + if tokio::fs::metadata(&overlay_target).await.is_ok() { + copy_directory_tree(&overlay_target, &aggregate_root).await?; + } + + for skill in added_skills { + let relative = skill + .target + .strip_prefix(&overlay_target) + .or_else(|_| skill.target.strip_prefix(&target_root)) + .map_err(|_| CombinedServiceError::InvalidRuntimeConfig { + field: "isolation.add-skills-from".to_string(), + message: format!( + "skill target '{}' is outside overlay target '{}'", + skill.target.display(), + overlay_target.display() + ), + })?; + copy_directory_tree(&skill.source, &aggregate_root.join(relative)).await?; + } + + Ok(Some(MountSpec::new( + overlay_target, + Some(aggregate_root), + MountKind::Readable, + ))) + } + + fn readable_parent_for_path(&self, path: &Path) -> Option { + self.context + .expanded_isolation + .readable + .iter() + .filter(|candidate| path.starts_with(candidate.as_path())) + .max_by_key(|candidate| candidate.components().count()) + .cloned() + } + + async fn write_env_file( + &self, + key: &str, + file_name: &str, + env: &[(String, String)], + ) -> Result { + let runtime_root = self.apple_runtime_root(key); + tokio::fs::create_dir_all(&runtime_root).await?; + let path = runtime_root.join(file_name); + let mut content = String::new(); + for (name, value) in env { + if value.contains('\n') || value.contains('\r') { + return Err(CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.env-file".to_string(), + message: format!( + "environment variable '{name}' contains newlines and cannot be written to a container env file" + ), + }); + } + content.push_str(name); + content.push('='); + content.push_str(value); + content.push('\n'); + } + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&path) + .await?; + file.write_all(content.as_bytes()).await?; + file.flush().await?; + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?; + Ok(path) + } + + fn apple_runtime_root(&self, key: &str) -> PathBuf { + self.context + .workspace_directory_path + .join(".multicode") + .join("apple-container") + .join(key) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.apple_runtime_root(key).join("isolate").join(relative) + } + + fn generate_runtime_id(&self, key: &str) -> String { + format!("multicode-{key}-{}", Uuid::new_v4().as_simple()) + } +} + +fn ensure_pty_terminal_env(env: &mut Vec<(String, String)>) { + upsert_env(env, "TERM", "xterm-256color"); + if env.iter().all(|(name, _)| name != "COLORTERM") { + env.push(("COLORTERM".to_string(), "truecolor".to_string())); + } +} + +fn upsert_env(env: &mut Vec<(String, String)>, name: &str, value: &str) { + if let Some((_, current)) = env.iter_mut().find(|(candidate, _)| candidate == name) { + *current = value.to_string(); + } else { + env.push((name.to_string(), value.to_string())); + } +} + +fn format_path_list(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>() + .join(",") +} + +#[derive(Debug, Clone, Deserialize)] +struct AppleContainerStatsEntry { + #[serde(rename = "memoryUsageBytes")] + memory_usage_bytes: Option, + #[serde(rename = "cpuUsageUsec")] + cpu_usage_usec: Option, +} + +fn parse_apple_container_usage(output: &str) -> RuntimeUsageSample { + let entries = match serde_json::from_str::>(output) { + Ok(entries) => entries, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + let Some(entry) = entries.into_iter().next() else { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + }; + + RuntimeUsageSample { + memory_current: entry.memory_usage_bytes, + cpu_usage_nsec: entry + .cpu_usage_usec + .map(|value| value.saturating_mul(1_000)), + state: Some(RuntimeUsageState::Active), + } +} + +fn format_skill_mounts(skills: &[super::config::AddedSkillMount]) -> String { + let mut pairs = skills + .iter() + .map(|skill| { + format!( + "{}=>{}", + skill.source.to_string_lossy(), + skill.target.to_string_lossy() + ) + }) + .collect::>(); + pairs.sort(); + pairs.join(",") +} + +fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { + for (name, _) in env { + args.push("--setenv".to_string()); + args.push(name.clone()); + } +} + +fn generate_random_password() -> String { + Uuid::new_v4().as_simple().to_string() +} + +fn generate_linux_runtime_id() -> String { + format!("multicode-{}.service", Uuid::new_v4().as_simple()) +} + +async fn pick_random_free_port() -> Result { + if let Some(port) = std::env::var_os("MULTICODE_FIXED_PORT") { + let port = port.to_string_lossy(); + let parsed = + port.parse::() + .map_err(|err| CombinedServiceError::InvalidRuntimeConfig { + field: "MULTICODE_FIXED_PORT".to_string(), + message: err.to_string(), + })?; + return Ok(parsed); + } + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +fn parse_linux_unit_usage(output: &str) -> RuntimeUsageSample { + let mut active_state: Option<&str> = None; + let mut memory_current: Option = None; + let mut cpu_usage_nsec: Option = None; + + for line in output.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "ActiveState" => active_state = Some(value), + "MemoryCurrent" => memory_current = parse_systemctl_u64(value), + "CPUUsageNSec" => cpu_usage_nsec = parse_systemctl_u64(value), + _ => {} + } + } + + let state = match active_state.unwrap_or_default() { + "active" | "activating" => RuntimeUsageState::Active, + "" => RuntimeUsageState::Unknown, + _ => RuntimeUsageState::Stopped, + }; + + RuntimeUsageSample { + memory_current, + cpu_usage_nsec, + state: Some(state), + } +} + +fn parse_systemctl_u64(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "[not set]" { + return None; + } + trimmed.parse::().ok() +} + +fn container_cpu_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let cpus = if let Some(percent) = value.strip_suffix('%') { + percent.trim().parse::().ok()? / 100.0 + } else { + value.parse::().ok()? + }; + if !cpus.is_finite() || cpus <= 0.0 { + return None; + } + Some(cpus.ceil().max(1.0).to_string()) +} + +fn container_program() -> String { + std::env::var("MULTICODE_CONTAINER_COMMAND").unwrap_or_else(|_| "container".to_string()) +} + +fn apple_container_start_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn container_delete_reports_missing(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("not found") + || stderr.contains("no such") + || stderr.contains("no matching containers") + || stderr.contains("does not exist") +} + +fn container_start_reports_allocator_exhaustion(stderr: &str) -> bool { + stderr + .to_ascii_lowercase() + .contains("no free indices are available for allocation") +} + +async fn run_blocking_process( + program: String, + args: Vec, +) -> Result { + tokio::task::spawn_blocking(move || { + std::process::Command::new(program) + .args(args) + .stdin(Stdio::null()) + .output() + }) + .await + .map_err(|err| std::io::Error::other(err.to_string()))? +} + +async fn copy_directory_tree(source: &Path, target: &Path) -> Result<(), std::io::Error> { + let mut pending = vec![(source.to_path_buf(), target.to_path_buf())]; + + while let Some((source_dir, target_dir)) = pending.pop() { + tokio::fs::create_dir_all(&target_dir).await?; + let mut entries = tokio::fs::read_dir(&source_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let metadata = tokio::fs::metadata(&source_path).await?; + if metadata.is_dir() { + pending.push((source_path, target_path)); + } else if metadata.is_file() { + tokio::fs::copy(&source_path, &target_path).await?; + } + } + } + + Ok(()) +} + +async fn clear_directory_contents(path: &Path) -> Result<(), std::io::Error> { + tokio::fs::create_dir_all(path).await?; + let mut entries = tokio::fs::read_dir(path).await?; + while let Some(entry) = entries.next_entry().await? { + let entry_path = entry.path(); + let metadata = entry.metadata().await?; + if metadata.is_dir() { + tokio::fs::remove_dir_all(entry_path).await?; + } else { + tokio::fs::remove_file(entry_path).await?; + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum MountKind { + Readable, + Writable, + Isolated, + Tmpfs, +} + +#[derive(Debug, Clone)] +pub(crate) struct MountSpec { + target: PathBuf, + source: Option, + kind: MountKind, + is_file: bool, +} + +impl MountSpec { + pub(crate) fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { + let is_file = match source.as_ref() { + Some(source) => std::fs::metadata(source) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + path_looks_like_file(source) || path_looks_like_file(&target) + }) + }), + None => std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| path_looks_like_file(&target)), + }; + Self { + target, + source, + kind, + is_file, + } + } + + fn depth(&self) -> usize { + self.target.components().count() + } + + fn resolve_backing_mount<'a>( + path: &Path, + prior_mounts: &'a [ResolvedMountSpec], + ) -> Option<&'a ResolvedMountSpec> { + prior_mounts.iter().rev().find(|prior_mount| { + path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) + }) + } + + fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { + if let Some(prior_mount) = Self::resolve_backing_mount(path, prior_mounts) { + let relative = path + .strip_prefix(&prior_mount.mount.target) + .expect("path should be under prior mount target"); + prior_mount.effective_source.join(relative) + } else { + path.to_path_buf() + } + } + + pub(crate) fn resolve_effective( + &self, + prior_mounts: &[ResolvedMountSpec], + ) -> ResolvedMountSpec { + let backing_mount_kind = + Self::resolve_backing_mount(&self.target, prior_mounts).map(|mount| mount.mount.kind); + let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); + let effective_source = match self.kind { + MountKind::Isolated => self + .source + .as_ref() + .map(|source| Self::resolve_backing_path(source, prior_mounts)) + .unwrap_or_else(|| effective_target.clone()), + MountKind::Readable | MountKind::Writable => { + self.source.clone().unwrap_or_else(|| self.target.clone()) + } + MountKind::Tmpfs => effective_target.clone(), + }; + ResolvedMountSpec { + mount: self.clone(), + backing_mount_kind, + effective_target, + effective_source, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedMountSpec { + mount: MountSpec, + backing_mount_kind: Option, + effective_target: PathBuf, + effective_source: PathBuf, +} + +impl ResolvedMountSpec { + fn needs_container_target_materialization(&self) -> bool { + self.backing_mount_kind.is_some() && self.effective_target != self.mount.target + } + + pub(crate) async fn prepare_source_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + self.prepare_node( + &self.effective_source, + owns_node, + self.mount + .source + .as_ref() + .filter(|original| *original != &self.effective_source), + ) + .await + } + + pub(crate) async fn prepare_target_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + let should_materialize = if self.mount.is_file { owns_node } else { true }; + self.prepare_node(&self.effective_target, should_materialize, None) + .await + } + + pub(crate) async fn prepare_container_materialized_file( + &self, + ) -> Result<(), CombinedServiceError> { + if !self.should_materialize_container_file() { + return Ok(()); + } + + if let Some(parent) = self.effective_target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + if tokio::fs::metadata(&self.effective_source).await.is_ok() { + tokio::fs::copy(&self.effective_source, &self.effective_target).await?; + } else if tokio::fs::metadata(&self.effective_target).await.is_err() { + tokio::fs::File::create(&self.effective_target).await?; + } + + Ok(()) + } + + async fn prepare_node( + &self, + path: &Path, + materialize_node: bool, + seed_file: Option<&PathBuf>, + ) -> Result<(), CombinedServiceError> { + if self.mount.is_file { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + if materialize_node && tokio::fs::metadata(path).await.is_err() { + if let Some(seed_file) = seed_file { + if tokio::fs::metadata(seed_file).await.is_ok() { + tokio::fs::copy(seed_file, path).await?; + return Ok(()); + } + } + tokio::fs::File::create(path).await?; + } + } else if materialize_node { + tokio::fs::create_dir_all(path).await?; + } else if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + Ok(()) + } + + fn append_args(&self, args: &mut Vec) { + match self.mount.kind { + MountKind::Readable => { + args.push("--ro-bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Writable | MountKind::Isolated => { + args.push("--bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + } + } + + fn append_container_args(&self, args: &mut Vec) { + if self.should_materialize_container_file() { + return; + } + + match self.mount.kind { + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Readable | MountKind::Writable | MountKind::Isolated => { + args.push("--mount".to_string()); + let mut mount = format!( + "type=bind,source={},target={}", + self.effective_source.to_string_lossy(), + self.mount.target.to_string_lossy() + ); + if matches!(self.mount.kind, MountKind::Readable) { + mount.push_str(",readonly"); + } + args.push(mount); + } + } + } + + fn should_materialize_container_file(&self) -> bool { + self.mount.kind == MountKind::Readable + && self.mount.is_file + && self.backing_mount_kind == Some(MountKind::Isolated) + && self.effective_target != self.mount.target + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::config::{ + AddedSkillMount, AgentProvider, CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, + CodexSandboxMode, IsolationConfig, + }; + use std::fs; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "multicode-runtime-test-{}-{}", + std::process::id(), + Uuid::new_v4().as_simple() + )); + fs::create_dir_all(&path).expect("test dir should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn apple_runtime(root: &TestDir, isolation: IsolationConfig) -> AppleContainerRuntime { + let expanded_isolation = + ExpandedIsolationConfig::from_config(&isolation, None).expect("config should expand"); + AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: root.path().join("workspaces"), + expanded_isolation, + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), + }, + } + } + + fn apple_codex_runtime( + root: &TestDir, + isolation: IsolationConfig, + codex: CodexAgentConfig, + ) -> AppleContainerRuntime { + let expanded_isolation = + ExpandedIsolationConfig::from_config(&isolation, None).expect("config should expand"); + AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: root.path().join("workspaces"), + expanded_isolation, + agent_provider: AgentProvider::Codex, + host_agent_command: "/opt/homebrew/bin/codex".to_string(), + container_agent_command: "codex".to_string(), + codex, + }, + } + } + + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { + args.windows(sequence.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(sequence.iter().copied()) + }) + } + + #[test] + fn container_cpu_value_converts_percent_to_cpu_count() { + assert_eq!(container_cpu_value("300%"), Some("3".to_string())); + assert_eq!(container_cpu_value("150%"), Some("2".to_string())); + assert_eq!(container_cpu_value("2"), Some("2".to_string())); + assert_eq!(container_cpu_value("1.5"), Some("2".to_string())); + assert_eq!(container_cpu_value(""), None); + } + + #[test] + fn container_delete_reports_missing_matches_common_container_rm_errors() { + assert!(container_delete_reports_missing( + "Error: failed to delete one or more containers: [\"multicode-alpha\"]: no matching containers found" + )); + assert!(container_delete_reports_missing( + "Error: container not found" + )); + assert!(container_delete_reports_missing("Error: No such container")); + assert!(!container_delete_reports_missing( + "Error: failed to delete one or more containers: permission denied" + )); + } + + #[test] + fn container_start_reports_allocator_exhaustion_matches_apple_error() { + assert!(container_start_reports_allocator_exhaustion( + "Error: failed to bootstrap container (cause: \"unknown: \"no free indices are available for allocation\"\")" + )); + assert!(!container_start_reports_allocator_exhaustion( + "Error: failed to bootstrap container: permission denied" + )); + } + + #[test] + fn apple_container_run_command_honors_limits_and_mounts() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let readable = root.path().join("readonly"); + let writable = root.path().join("writable"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&readable).expect("readable should exist"); + fs::create_dir_all(&writable).expect("writable should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![readable.to_string_lossy().into_owned()], + writable: vec![writable.to_string_lossy().into_owned()], + isolated: vec!["/var/tmp".to_string()], + tmpfs: vec!["/tmp".to_string()], + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: Some("8 GB".to_string()), + memory_max: Some("10 GB".to_string()), + cpu: Some("300%".to_string()), + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--detach", "--rm"] + )); + assert!(contains_sequence( + &command.args, + &["--name", "multicode-alpha"] + )); + assert!(contains_sequence(&command.args, &["--cpus", "3"])); + assert!(contains_sequence( + &command.args, + &["--memory", "10000000000"] + )); + assert!(contains_sequence( + &command.args, + &["--publish", "127.0.0.1:31337:31337/tcp"] + )); + assert!(contains_sequence(&command.args, &["--tmpfs", "/tmp"])); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("type=bind") && arg.contains("readonly")) + ); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("/var/tmp") && arg.contains("type=bind")) + ); + assert!(contains_sequence( + &command.args, + &[ + "ghcr.io/example/multicode-java25:latest", + "opencode", + "serve", + "--hostname", + "0.0.0.0", + "--port", + "31337" + ] + )); + }); + } + + #[test] + fn apple_container_run_command_supports_codex_provider() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let host_codex = home.join(".codex"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(host_codex.join("skills")) + .expect("host codex skills directory should exist"); + fs::write(host_codex.join("config.toml"), "model = \"gpt-5-codex\"\n") + .expect("codex config should exist"); + fs::write(host_codex.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should exist"); + fs::write(host_codex.join("skills/example.md"), "# example") + .expect("codex skill should exist"); + + let previous_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &home); + } + + let runtime = apple_codex_runtime( + &root, + IsolationConfig::default(), + CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[("HOME".to_string(), home.to_string_lossy().into_owned())], + ) + .await + .expect("command should build"); + let server_env = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + + if let Some(previous_home) = previous_home { + unsafe { + std::env::set_var("HOME", previous_home); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + + assert!(contains_sequence( + &command.args, + &[ + "ghcr.io/example/multicode-java25:latest", + "codex", + "app-server", + "--listen", + "ws://0.0.0.0:31337", + ] + )); + assert!( + command.args.iter().any(|arg| { + arg.contains("type=bind") + && arg.contains(&format!("target={SYNTHETIC_CODEX_HOME}")) + }), + "apple backend should mount a synthetic CODEX_HOME" + ); + assert!( + command.args.iter().all(|arg| { + !(arg.contains("type=bind") + && arg.contains(&format!("target={}/auth.json", SYNTHETIC_CODEX_HOME))) + }), + "apple backend should not emit a separate auth.json bind mount" + ); + assert!( + command.args.iter().any(|arg| { + arg.contains("type=bind") + && arg.contains(&format!("target={AUTOMATION_STATE_DIR}")) + }), + "apple backend should mount the automation state directory" + ); + + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!( + env_contents.contains(&format!("CODEX_HOME={SYNTHETIC_CODEX_HOME}")), + "apple backend should export CODEX_HOME for codex" + ); + assert!( + env_contents.contains(&format!( + "{AUTOMATION_STATE_ENV}={AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}" + )), + "apple backend should export the automation state file path" + ); + let server_env_mode = fs::metadata(&server_env) + .expect("server env metadata should exist") + .permissions() + .mode() + & 0o777; + assert_eq!( + server_env_mode, 0o600, + "apple backend should write env files with 0600 permissions" + ); + assert_eq!( + fs::read_to_string( + workspace_root + .join(".multicode") + .join("codex") + .join("alpha") + .join("home") + .join("config.toml") + ) + .expect("synthetic codex config should exist"), + concat!( + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ) + ); + let persisted_auth = workspace_root + .join(".multicode") + .join("codex") + .join("alpha") + .join("home") + .join("auth.json"); + assert_eq!( + fs::read_to_string(&persisted_auth).expect("synthetic auth should be readable"), + r#"{"token":"codex"}"# + ); + }); + } + + #[test] + fn synthetic_codex_config_overrides_external_sandbox_with_dangerous_access() { + assert_eq!( + render_multicode_codex_config_overrides(&CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }), + concat!( + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ) + ); + } + + #[test] + fn synthetic_codex_config_rewrites_root_overrides_before_tables() { + let existing = concat!( + "approval_policy = \"on-request\"\n", + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "sandbox_mode = \"workspace-write\"\n", + "web_search_request = true\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + "\n", + "[notice.model_migrations]\n", + "\"gpt-5.3-codex\" = \"gpt-5.4\"\n", + "# Managed by multicode\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ); + + let rewritten = rewrite_synthetic_codex_config( + existing, + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!( + rewritten, + concat!( + "web_search_request = true\n", + "\n", + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + "\n", + "[notice.model_migrations]\n", + "\"gpt-5.3-codex\" = \"gpt-5.4\"\n", + ) + ); + } + + #[test] + fn synthetic_codex_config_preserves_host_provider_when_not_overridden() { + let existing = concat!( + "approval_policy = \"on-request\"\n", + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "sandbox_mode = \"workspace-write\"\n", + "web_search_request = true\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + ); + + let rewritten = rewrite_synthetic_codex_config( + existing, + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!( + rewritten, + concat!( + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "web_search_request = true\n", + "\n", + "# Managed by multicode\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + ) + ); + } + + #[test] + fn apple_container_implicitly_mounts_host_gitconfig() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let gitconfig = home.join(".gitconfig"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(&gitconfig, "[user]\nname = Test User\n").expect("gitconfig should exist"); + + let previous_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &home); + } + + let runtime = apple_runtime(&root, IsolationConfig::default()); + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[("HOME".to_string(), home.to_string_lossy().into_owned())], + ) + .await + .expect("command should build"); + let server_env = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + + if let Some(previous_home) = previous_home { + unsafe { + std::env::set_var("HOME", previous_home); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + + let gitconfig_mount = format!( + "type=bind,source={},target={},readonly", + workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("gitconfig") + .to_string_lossy(), + APPLE_GITCONFIG_DIR + ); + assert!( + command.args.iter().any(|arg| arg == &gitconfig_mount), + "apple backend should implicitly mount host gitconfig through a synthetic directory" + ); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!( + env_contents.contains(&format!( + "GIT_CONFIG_GLOBAL={APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}" + )), + "apple backend should point git at the synthetic mounted gitconfig" + ); + }); + } + + #[test] + fn apple_container_pty_command_uses_one_shot_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + fs::create_dir_all(workspace_root.join("alpha")).expect("workspace should exist"); + let runtime = apple_runtime(&root, IsolationConfig::default()); + + let command = runtime + .build_pty_command( + "alpha", + None, + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + vec!["/bin/bash".to_string()], + ) + .await + .expect("pty command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--rm", "--tty", "--interactive"] + )); + let env_file = command + .args + .iter() + .find(|arg| arg.ends_with("exec.env")) + .expect("exec env file should be present") + .clone(); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + let env_contents = + fs::read_to_string(env_file).expect("exec env file should be written"); + assert!( + env_contents.contains("TERM=xterm-256color\n"), + "apple PTY runs should normalize TERM for container shells" + ); + assert!( + env_contents.contains("COLORTERM=truecolor\n"), + "apple PTY runs should set a portable COLORTERM for container shells" + ); + assert!(command.inherited_env.is_empty()); + }); + } + + #[test] + fn apple_container_pty_command_uses_container_exec_for_running_workspace() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + fs::create_dir_all(workspace_root.join("alpha")).expect("workspace should exist"); + let runtime = apple_runtime(&root, IsolationConfig::default()); + let runtime_handle = RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha-running".to_string(), + metadata: BTreeMap::new(), + }; + + let command = runtime + .build_pty_command( + "alpha", + Some(&runtime_handle), + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + vec!["/bin/bash".to_string()], + ) + .await + .expect("pty command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["exec", "--tty", "--interactive", "--env-file",] + )); + let env_file = command + .args + .iter() + .find(|arg| arg.ends_with("exec.env")) + .expect("exec env file should be present") + .clone(); + assert!(contains_sequence( + &command.args, + &[ + "--workdir", + workspace_root.join("alpha").to_string_lossy().as_ref(), + "multicode-alpha-running", + "/bin/bash", + ] + )); + assert!( + !command.args.iter().any(|arg| arg == "run"), + "running workspaces should reuse the active container" + ); + let env_contents = + fs::read_to_string(env_file).expect("exec env file should be written"); + assert!( + env_contents.contains("TERM=xterm-256color\n"), + "apple PTY exec should normalize TERM for container shells" + ); + assert!( + env_contents.contains("COLORTERM=truecolor\n"), + "apple PTY exec should set a portable COLORTERM for container shells" + ); + assert!(command.inherited_env.is_empty()); + }); + } + + #[test] + fn apple_container_materializes_nested_readable_file_inside_isolated_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let auth_dir = home.join(".local/share/opencode"); + let auth_file = auth_dir.join("auth.json"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&auth_dir).expect("auth directory should exist"); + fs::write(&auth_file, r#"{"token":"apple"}"#).expect("auth file should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![auth_file.to_string_lossy().into_owned()], + writable: Vec::new(), + isolated: vec![auth_dir.to_string_lossy().into_owned()], + tmpfs: Vec::new(), + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: None, + memory_max: None, + cpu: None, + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + home.to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + let isolated_storage = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("isolate") + .join( + auth_dir + .strip_prefix("/") + .expect("auth directory should be absolute"), + ); + let materialized_auth = isolated_storage.join("auth.json"); + let host_auth_mount = format!( + "type=bind,source={},target={}", + auth_file.to_string_lossy(), + auth_file.to_string_lossy() + ); + let isolated_dir_mount = format!( + "type=bind,source={},target={}", + isolated_storage.to_string_lossy(), + auth_dir.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &isolated_dir_mount), + "isolated parent directory should still be mounted" + ); + assert!( + command.args.iter().all(|arg| arg != &host_auth_mount), + "nested readable file should be materialized into the isolated backing tree instead of emitted as a separate bind mount" + ); + assert_eq!( + fs::read_to_string(&materialized_auth).expect("materialized auth should exist"), + r#"{"token":"apple"}"# + ); + }); + } + + #[test] + fn apple_container_coalesces_added_skills_into_single_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let skills_root = root.path().join("workspace-skills"); + let skill_one = skills_root.join("skill-one"); + let skill_two = skills_root.join("skill-two"); + let container_skills_target = + root.path().join("container-home/.config/opencode/skills"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&skill_one).expect("first skill should exist"); + fs::create_dir_all(&skill_two).expect("second skill should exist"); + fs::write(skill_one.join("SKILL.md"), "# one").expect("first skill file should exist"); + fs::write(skill_two.join("SKILL.md"), "# two").expect("second skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: Vec::new(), + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![ + AddedSkillMount { + source: skill_one.clone(), + target: container_skills_target.join("skill-one"), + }, + AddedSkillMount { + source: skill_two.clone(), + target: container_skills_target.join("skill-two"), + }, + ], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + container_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should mount one aggregated skills directory" + ); + assert!( + command + .args + .iter() + .all(|arg| !arg.contains("container-home/.config/opencode/skills/skill-one")), + "individual skill mounts should be omitted" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-one/SKILL.md")) + .expect("aggregated skill one should exist"), + "# one" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-two/SKILL.md")) + .expect("aggregated skill two should exist"), + "# two" + ); + }); + } + + #[test] + fn apple_container_aggregated_skill_mount_preserves_host_skills() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let host_home = root.path().join("host-home"); + let host_skills_target = host_home.join(".config/opencode/skills"); + let host_skill = host_skills_target.join("host-skill"); + let added_skills_root = root.path().join("workspace-skills"); + let added_skill = added_skills_root.join("workspace-skill"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&host_skill).expect("host skill should exist"); + fs::create_dir_all(&added_skill).expect("added skill should exist"); + fs::write(host_skill.join("SKILL.md"), "# host").expect("host skill file should exist"); + fs::write(added_skill.join("SKILL.md"), "# workspace") + .expect("added skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: vec![host_home.join(".config/opencode")], + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![AddedSkillMount { + source: added_skill.clone(), + target: host_skills_target.join("workspace-skill"), + }], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let host_opencode_target = host_home.join(".config/opencode"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + host_opencode_target.to_string_lossy() + ); + let raw_host_mount = format!( + "type=bind,source={},target={},readonly", + host_opencode_target.to_string_lossy(), + host_opencode_target.to_string_lossy() + ); + let nested_skills_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + host_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should expose one merged readable mount" + ); + assert!( + command.args.iter().all(|arg| arg != &raw_host_mount), + "apple backend should replace the raw readable parent mount with the merged overlay" + ); + assert!( + command.args.iter().all(|arg| arg != &nested_skills_mount), + "apple backend should not emit a nested skills bind mount under the readable parent" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skills/host-skill/SKILL.md")) + .expect("host skill should be preserved"), + "# host" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skills/workspace-skill/SKILL.md")) + .expect("workspace skill should be included"), + "# workspace" + ); + }); + } + + #[test] + fn apple_container_reuses_aggregated_skills_directory_across_commands() { + use std::os::unix::fs::MetadataExt; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let host_home = root.path().join("host-home"); + let host_skills_target = host_home.join(".config/opencode/skills"); + let skill_root = root.path().join("workspace-skills"); + let skill = skill_root.join("machine-readable-pr"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&skill).expect("skill should exist"); + fs::write(skill.join("SKILL.md"), "# pr").expect("skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: vec![host_home.join(".config/opencode")], + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![AddedSkillMount { + source: skill.clone(), + target: host_skills_target.join("machine-readable-pr"), + }], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), + }, + }; + + runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("run command should build"); + let aggregate_root = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let first_ino = fs::metadata(&aggregate_root) + .expect("aggregate root should exist") + .ino(); + + runtime + .build_pty_command("alpha", None, &[], vec!["/bin/sh".to_string()]) + .await + .expect("pty command should build"); + let second_ino = fs::metadata(&aggregate_root) + .expect("aggregate root should still exist") + .ino(); + + assert_eq!( + first_ino, second_ino, + "apple backend should update the aggregated skills directory in place so existing mounts stay valid" + ); + assert_eq!( + fs::read_to_string(aggregate_root.join("skills/machine-readable-pr/SKILL.md")) + .expect("aggregated skill should remain present"), + "# pr" + ); + }); + } + + #[test] + fn parse_apple_container_usage_reads_memory_and_cpu() { + let output = r#"[{"memoryUsageBytes":4075261952,"cpuUsageUsec":437059128}]"#; + + assert_eq!( + parse_apple_container_usage(output), + RuntimeUsageSample { + memory_current: Some(4_075_261_952), + cpu_usage_nsec: Some(437_059_128_000), + state: Some(RuntimeUsageState::Active), + } + ); + } + + #[test] + fn parse_apple_container_usage_reports_stopped_for_empty_results() { + assert_eq!( + parse_apple_container_usage("[]"), + RuntimeUsageSample { + memory_current: None, + cpu_usage_nsec: None, + state: Some(RuntimeUsageState::Stopped), + } + ); + } + + #[test] + fn parse_apple_container_usage_reports_unknown_for_invalid_json() { + assert_eq!( + parse_apple_container_usage("not-json"), + RuntimeUsageSample { + memory_current: None, + cpu_usage_nsec: None, + state: Some(RuntimeUsageState::Unknown), + } + ); + } +} diff --git a/lib/src/services/runtime_reconciliation_service.rs b/lib/src/services/runtime_reconciliation_service.rs new file mode 100644 index 0000000..6c4f202 --- /dev/null +++ b/lib/src/services/runtime_reconciliation_service.rs @@ -0,0 +1,159 @@ +use std::sync::Arc; + +use tokio::sync::watch; + +use super::{ + runtime::{RUNTIME_SPEC_METADATA_KEY, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + RuntimeBackend, TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, + WorkspaceSnapshot, manager::Workspace, +}; + +#[derive(Debug)] +#[allow(dead_code)] +pub(super) enum RuntimeReconciliationServiceError { + Manager(WorkspaceManagerError), +} + +impl From for RuntimeReconciliationServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub(super) async fn runtime_reconciliation_service( + manager: Arc, + runtime: WorkspaceRuntime, +) -> Result<(), RuntimeReconciliationServiceError> { + let expected_backend = runtime.backend(); + let expected_spec = runtime.runtime_spec(); + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let runtime = runtime.clone(); + let expected_spec = expected_spec.clone(); + async move { + tokio::spawn(async move { + watch_workspace_snapshot( + key, + workspace, + workspace_rx, + runtime, + expected_backend, + expected_spec, + ) + .await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace_snapshot( + key: String, + workspace: Workspace, + mut workspace_rx: watch::Receiver, + runtime: WorkspaceRuntime, + expected_backend: RuntimeBackend, + expected_spec: String, +) { + loop { + let current_transient = workspace_rx.borrow().transient.clone(); + if let Some(transient) = current_transient { + if should_invalidate_runtime(&transient, expected_backend, &expected_spec) { + tracing::info!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + expected_backend = ?expected_backend, + actual_backend = ?transient.runtime.backend, + "stopping stale workspace runtime because the runtime specification changed" + ); + if let Err(err) = runtime.stop_server(&transient.runtime).await { + tracing::warn!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + error = ?err, + "failed to stop stale workspace runtime during reconciliation" + ); + } + workspace.update(|snapshot| { + if snapshot.transient.as_ref() == Some(&transient) { + snapshot.transient = None; + true + } else { + false + } + }); + } + } + + if workspace_rx.changed().await.is_err() { + break; + } + } +} + +fn should_invalidate_runtime( + transient: &TransientWorkspaceSnapshot, + expected_backend: RuntimeBackend, + expected_spec: &str, +) -> bool { + if transient.runtime.backend != expected_backend { + return true; + } + + transient.runtime.backend == RuntimeBackend::AppleContainer + && transient + .runtime + .metadata + .get(RUNTIME_SPEC_METADATA_KEY) + .map(String::as_str) + != Some(expected_spec) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + use std::collections::BTreeMap; + + #[test] + fn runtime_reconciliation_invalidates_apple_runtime_without_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::new(), + }, + }; + + assert!(should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } + + #[test] + fn runtime_reconciliation_keeps_apple_runtime_with_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + "expected".to_string(), + )]), + }, + }; + + assert!(!should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } +} diff --git a/lib/src/services/transient_storage.rs b/lib/src/services/transient_storage.rs index 8119795..ca26319 100644 --- a/lib/src/services/transient_storage.rs +++ b/lib/src/services/transient_storage.rs @@ -6,7 +6,7 @@ use std::{ use tokio::sync::watch; use uuid::Uuid; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{config::synthesized_xdg_runtime_dir, workspace_watch::monitor_workspace_snapshots}; use crate::{ TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, }; @@ -55,7 +55,7 @@ pub async fn transient_storage( let snapshot_path = snapshot_file_path(storage_dir.as_ref(), &key); let transient_snapshot = read_transient_snapshot(&snapshot_path).await?; workspace.update(|snapshot| { - if snapshot.transient != transient_snapshot { + if snapshot.transient.is_none() && transient_snapshot.is_some() { snapshot.transient = transient_snapshot.clone(); true } else { @@ -65,6 +65,12 @@ pub async fn transient_storage( let current_transient = workspace_rx.borrow_and_update().transient.clone(); tokio::spawn(async move { + if let Err(err) = + persist_transient_snapshot(&snapshot_path, current_transient.as_ref()).await + { + tracing::error!(error = ?err, "failed to persist initial transient snapshot"); + return; + } if let Err(err) = watch_workspace_snapshot(snapshot_path, workspace_rx, current_transient).await { @@ -113,8 +119,9 @@ async fn ensure_storage_directory( } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .or_else(synthesized_xdg_runtime_dir) .ok_or(TransientStorageError::MissingXdgRuntimeDir)?; - let runtime_dir = PathBuf::from(runtime_dir); if !runtime_dir.is_absolute() { return Err(TransientStorageError::InvalidXdgRuntimeDir(runtime_dir)); } @@ -271,14 +278,15 @@ async fn persist_transient_snapshot( #[cfg(test)] mod tests { use super::*; - use crate::WorkspaceManager; use crate::test_support::ENV_VAR_LOCK; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, WorkspaceManager}; use std::{ ffi::OsString, fs, path::{Path, PathBuf}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::Duration, }; + use uuid::Uuid; struct TestDir { path: PathBuf, @@ -286,14 +294,10 @@ mod tests { impl TestDir { fn new() -> Self { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); let path = std::env::temp_dir().join(format!( "multicode-transient-storage-{}-{}", std::process::id(), - unique + Uuid::new_v4().as_simple() )); fs::create_dir_all(&path).expect("test dir should be created"); Self { path } @@ -323,6 +327,14 @@ mod tests { } Self { key, old_value } } + + fn remove(key: &'static str) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, old_value } + } } impl Drop for EnvVarGuard { @@ -397,6 +409,36 @@ mod tests { }); } + #[cfg(target_os = "macos")] + #[test] + fn ensure_storage_directory_synthesizes_xdg_runtime_dir_on_macos() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let link = root.path().join("state/transient-link"); + let target = ensure_storage_directory(&link) + .await + .expect("storage directory should be created"); + + assert!( + target.starts_with( + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("multicode") + ) + ); + }); + } + #[test] fn ensure_storage_directory_creates_missing_symlink_target() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -450,7 +492,11 @@ mod tests { let initial_transient = TransientWorkspaceSnapshot { uri: "file:///initial".to_string(), - unit: "run-u-initial.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-initial.service".to_string(), + metadata: Default::default(), + }, }; let snapshot_path = storage_dir.join("alpha.json"); tokio::fs::write( @@ -479,7 +525,11 @@ mod tests { .update(|snapshot| { snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "file:///updated".to_string(), - unit: "run-u-updated.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-updated.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -491,7 +541,7 @@ mod tests { .expect("snapshot file should stay readable"); let snapshot: TransientWorkspaceSnapshot = serde_json::from_slice(&content).expect("snapshot should parse"); - if snapshot.unit == "run-u-updated.service" { + if snapshot.runtime.id == "run-u-updated.service" { break; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -576,4 +626,74 @@ mod tests { service_task.abort(); }); } + + #[test] + fn transient_storage_does_not_clobber_live_transient_state_when_disk_snapshot_is_missing() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let storage_dir = root.path().join("storage"); + tokio::fs::create_dir_all(&storage_dir) + .await + .expect("storage dir should exist"); + + let link = root.path().join("transient-link"); + tokio::fs::symlink(&storage_dir, &link) + .await + .expect("symlink should be created"); + + let manager = Arc::new(WorkspaceManager::new()); + manager + .add("alpha") + .expect("workspace should be added before service starts"); + + let live_transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: Default::default(), + }, + }; + manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.transient = Some(live_transient.clone()); + true + }); + + let alpha_rx = manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe(); + let service_task = tokio::spawn(transient_storage(manager.clone(), link.clone())); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(alpha_rx.borrow().transient, Some(live_transient.clone())); + + let snapshot_path = storage_dir.join("alpha.json"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let content = tokio::fs::read(&snapshot_path) + .await + .expect("snapshot file should be written"); + let snapshot: TransientWorkspaceSnapshot = + serde_json::from_slice(&content).expect("snapshot should parse"); + if snapshot == live_transient { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("live transient state should be persisted"); + + service_task.abort(); + }); + } } diff --git a/lib/src/services/usage_aggregation_service.rs b/lib/src/services/usage_aggregation_service.rs index a7a5c13..4e9d512 100644 --- a/lib/src/services/usage_aggregation_service.rs +++ b/lib/src/services/usage_aggregation_service.rs @@ -252,7 +252,7 @@ fn apply_usage_event( usage_by_message: &mut HashMap, ) -> bool { match &event.payload { - opencode::client::types::Event::MessageUpdated(message_updated) => { + opencode::client::types::GlobalEventPayload::EventMessageUpdated(message_updated) => { let Some((message_id, updated_session_id, usage)) = usage_update_from_message(&message_updated.properties.info) else { @@ -269,7 +269,24 @@ fn apply_usage_event( None => usage_by_message.remove(&message_id).is_some(), } } - opencode::client::types::Event::MessageRemoved(message_removed) => { + opencode::client::types::GlobalEventPayload::SyncEventMessageUpdated(message_updated) => { + let Some((message_id, updated_session_id, usage)) = + usage_update_from_message(&message_updated.data.info) + else { + return false; + }; + if updated_session_id != session_id { + return false; + } + match usage { + Some(usage) => match usage_by_message.insert(message_id, usage) { + Some(previous) => previous != usage, + None => true, + }, + None => usage_by_message.remove(&message_id).is_some(), + } + } + opencode::client::types::GlobalEventPayload::EventMessageRemoved(message_removed) => { if message_removed.properties.session_id.as_str() != session_id { return false; } @@ -277,6 +294,14 @@ fn apply_usage_event( .remove(message_removed.properties.message_id.as_str()) .is_some() } + opencode::client::types::GlobalEventPayload::SyncEventMessageRemoved(message_removed) => { + if message_removed.data.session_id.as_str() != session_id { + return false; + } + usage_by_message + .remove(message_removed.data.message_id.as_str()) + .is_some() + } _ => false, } } @@ -354,9 +379,22 @@ fn sum_usage(usage_by_message: &HashMap) -> (u64, f64) { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn assistant_message_json( message_id: &str, session_id: &str, @@ -416,6 +454,7 @@ mod tests { "payload": { "type": "message.updated", "properties": { + "sessionID": session_id, "info": assistant_message_json( message_id, session_id, @@ -507,10 +546,10 @@ mod tests { events: event_tx, }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true @@ -653,10 +692,10 @@ mod tests { events: event_tx.clone(), }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage-events.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true diff --git a/lib/tests/apple_container_runtime_integration.rs b/lib/tests/apple_container_runtime_integration.rs new file mode 100644 index 0000000..dae4989 --- /dev/null +++ b/lib/tests/apple_container_runtime_integration.rs @@ -0,0 +1,1097 @@ +use std::{ + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use multicode_lib::{RuntimeBackend, services::CombinedService}; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let root = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::current_dir() + .expect("current directory should be available") + .join("target") + .join("test-tmp") + }); + let path = root.join(format!( + "multicode-apple-container-integration-{}-{}", + std::process::id(), + unique + )); + fs::create_dir_all(&path).expect("test root should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct EnvVarGuard { + key: &'static str, + old_value: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, old_value } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + std::env::set_var(self.key, value); + } + } else { + unsafe { + std::env::remove_var(self.key); + } + } + } +} + +fn make_executable(path: &Path) { + let mut perms = fs::metadata(path) + .expect("executable metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("permissions should be updated"); +} + +fn write_fake_container_cli(path: &Path) { + fs::write( + path, + r#"#!/bin/bash +set -euo pipefail +root="${MULTICODE_FAKE_CONTAINER_ROOT:?missing MULTICODE_FAKE_CONTAINER_ROOT}" +state_dir="$root/state" +mkdir -p "$state_dir" +printf '%s\n' "$*" >> "$root/commands.log" + +cmd="${1:-}" +shift || true +case "$cmd" in + run) + name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --name) + name="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + if [ -n "$name" ]; then + : > "$state_dir/$name" + fi + ;; + rm) + if [ "${1:-}" = "-f" ] && [ -n "${2:-}" ]; then + rm -f "$state_dir/$2" + fi + ;; + inspect) + if [ -n "${1:-}" ] && [ -e "$state_dir/$1" ]; then + printf '[{\"status\":\"running\"}]\n' + exit 0 + fi + exit 1 + ;; + list) + for file in "$state_dir"/*; do + [ -e "$file" ] || continue + basename "$file" + done + ;; + *) + ;; +esac +"#, + ) + .expect("fake container script should be written"); + make_executable(path); +} + +fn write_fake_opencode(path: &Path) { + fs::write(path, "#!/bin/bash\nexit 0\n").expect("fake opencode should be written"); + make_executable(path); +} + +fn write_fake_codex(path: &Path) { + fs::write(path, "#!/bin/bash\nexit 0\n").expect("fake codex should be written"); + make_executable(path); +} + +fn read_commands(path: &Path) -> Vec { + fs::read_to_string(path) + .expect("commands log should be readable") + .lines() + .map(ToOwned::to_owned) + .collect() +} + +#[test] +fn starts_and_stops_workspace_with_apple_container_backend() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["{home}/.gradle", "{home}/.config/gh"] +isolated = ["{home}/.local/share/opencode", "{home}/.local/state/opencode", "/var/tmp"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "16 GiB" +cpu = "300%" +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + assert!( + transient.runtime.id.starts_with("multicode-alpha-"), + "apple runtime id should include the workspace key and a unique suffix" + ); + assert!(transient.uri.starts_with("http://opencode:")); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!(run_command.contains(&format!("--name {}", transient.runtime.id))); + assert!(run_command.contains("--cpus 3")); + assert!(run_command.contains("--memory 17179869184")); + assert!(run_command.contains("--tmpfs /tmp")); + assert!(run_command.contains("ghcr.io/example/multicode-java25:latest")); + assert!(run_command.contains("opencode serve --hostname 0.0.0.0")); + + let server_env = workspace_directory + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!(env_contents.contains("OPENCODE_SERVER_USERNAME=opencode")); + assert!(env_contents.contains("OPENCODE_SERVER_PASSWORD=")); + assert!(env_contents.contains(&format!("HOME={}", home.display()))); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + + let stopped = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + if snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(stopped.is_ok(), "workspace should clear transient state"); + + let commands = read_commands(&fake_container_root.join("commands.log")); + assert!( + commands + .iter() + .any(|line| line == &format!("rm -f {}", transient.runtime.id)), + "stop should remove the container" + ); + }); +} + +#[test] +fn starts_workspace_with_apple_container_backend_and_codex_provider() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let host_codex_dir = home.join(".codex"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + fs::create_dir_all(host_codex_dir.join("skills")).expect("host codex skills should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_codex(&bin_dir.join("codex")); + fs::write( + host_codex_dir.join("config.toml"), + "model = \"gpt-5-codex\"\n", + ) + .expect("codex config should be written"); + fs::write(host_codex_dir.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should be written"); + fs::write(host_codex_dir.join("AGENTS.md"), "# Host instructions\n") + .expect("codex AGENTS should be written"); + fs::write( + host_codex_dir.join("skills/host-skill.md"), + "# host skill\n", + ) + .expect("codex skill should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43124"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["{home}/.gradle", "{home}/.config/gh"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "16 GiB" +cpu = "300%" +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + assert!(transient.uri.starts_with("ws://127.0.0.1:43124")); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!(run_command.contains(&format!("--name {}", transient.runtime.id))); + assert!(run_command.contains("--cpus 3")); + assert!(run_command.contains("--memory 17179869184")); + assert!(run_command.contains("codex app-server --listen ws://0.0.0.0:43124")); + assert!(!run_command.contains("target=/multicode-agent/codex-home/auth.json")); + + let server_env = workspace_directory + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!(env_contents.contains("CODEX_HOME=/multicode-agent/codex-home")); + assert!(env_contents.contains(&format!("HOME={}", home.display()))); + let server_env_mode = fs::metadata(&server_env) + .expect("server env metadata should exist") + .permissions() + .mode() + & 0o777; + assert_eq!(server_env_mode, 0o600); + + let synthetic_codex_home = workspace_directory + .join(".multicode") + .join("codex") + .join("alpha") + .join("home"); + let synthetic_config = fs::read_to_string(synthetic_codex_home.join("config.toml")) + .expect("synthetic codex config should exist"); + assert!( + synthetic_config.contains("# Managed by multicode\n"), + "synthetic codex config should include the managed multicode block" + ); + assert!( + synthetic_config.contains("model = \"gpt-5-codex\"\n"), + "synthetic codex config should preserve the configured model" + ); + assert!( + synthetic_config.contains("model_provider = \"openai\"\n"), + "synthetic codex config should include the managed provider override" + ); + let persisted_auth = fs::read_to_string(synthetic_codex_home.join("auth.json")) + .expect("synthetic codex auth should exist"); + assert_eq!(persisted_auth, r#"{"token":"codex"}"#); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("AGENTS.md")) + .expect("synthetic codex AGENTS should exist"), + "# Host instructions\n" + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/host-skill.md")) + .expect("synthetic codex skill should exist"), + "# host skill\n" + ); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + }); +} + +#[test] +fn apple_container_codex_provider_merges_added_skills_into_synthetic_home() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let host_codex_dir = home.join(".codex"); + let workspace_skills = root.path().join("workspace-skills"); + let added_skill = workspace_skills.join("workspace-skill"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + fs::create_dir_all(host_codex_dir.join("skills/host-skill")) + .expect("host codex skills should exist"); + fs::create_dir_all(&added_skill).expect("added skill should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_codex(&bin_dir.join("codex")); + fs::write( + host_codex_dir.join("config.toml"), + "model = \"gpt-5-codex\"\n", + ) + .expect("codex config should be written"); + fs::write(host_codex_dir.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should be written"); + fs::write( + host_codex_dir.join("skills/host-skill/SKILL.md"), + "# Host Skill\n", + ) + .expect("host skill should be written"); + fs::write(added_skill.join("SKILL.md"), "# Workspace Skill\n") + .expect("added skill should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43125"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +add-skills-from = ["./workspace-skills"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let synthetic_codex_home = workspace_directory + .join(".multicode") + .join("codex") + .join("alpha") + .join("home"); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/host-skill/SKILL.md")) + .expect("host skill should be copied"), + "# Host Skill\n" + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/workspace-skill/SKILL.md")) + .expect("added skill should be copied"), + "# Workspace Skill\n" + ); + }); +} + +#[test] +fn start_workspace_uses_unique_runtime_id_even_when_stale_named_container_exists() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start even if a stale fixed-name container exists"); + + let transient = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone() + .transient + .expect("transient snapshot should be present"); + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!( + run_command.contains(&format!("--name {}", transient.runtime.id)), + "apple backend should start a uniquely named runtime" + ); + assert!( + transient.runtime.id != "multicode-alpha", + "apple backend should not reuse the stale fixed container name" + ); + }); +} + +#[test] +fn build_exec_tool_command_uses_one_shot_apple_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(workspace_directory.join("alpha")).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = EnvVarGuard::set( + "MULTICODE_FAKE_CONTAINER_ROOT", + root.path().join("fake-root"), + ); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "8 GiB" +cpu = "200%" +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let command = service + .build_exec_tool_command("alpha", "/bin/bash") + .await + .expect("exec tool command should build"); + assert_eq!(command.program, bin_dir.join("container").to_string_lossy()); + assert_eq!(command.inherited_env, Vec::<(String, String)>::new()); + assert!( + command.args.windows(4).any(|window| { + window + == ["run", "--rm", "--tty", "--interactive"] + .iter() + .map(|v| v.to_string()) + .collect::>() + }), + "apple backend should use one-shot container run for PTY tools" + ); + assert!(command.args.iter().any(|arg| arg == "--cpus")); + assert!(command.args.iter().any(|arg| arg == "2")); + assert!(command.args.iter().any(|arg| arg == "--memory")); + assert!(command.args.iter().any(|arg| arg == "8589934592")); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + }); +} + +#[test] +fn stale_apple_container_transient_is_cleared_on_startup() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let workspace_path = workspace_directory.join("alpha"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let transient_dir = root.path().join("transient-store"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_path).expect("workspace should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&transient_dir).expect("transient dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let transient_link = workspace_directory.join(".multicode").join("transient"); + fs::create_dir_all( + transient_link + .parent() + .expect("transient link parent should be available"), + ) + .expect("transient link parent should exist"); + std::os::unix::fs::symlink(&transient_dir, &transient_link) + .expect("transient link should be created"); + fs::write( + transient_dir.join("alpha.json"), + serde_json::to_vec_pretty(&multicode_lib::TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: multicode_lib::RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: std::collections::BTreeMap::new(), + }, + }) + .expect("transient snapshot should serialize"), + ) + .expect("transient snapshot should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +readable = ["{home}/.config/opencode"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let commands_log = fake_container_root.join("commands.log"); + let cleared = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let removed_stale_container = fs::read_to_string(&commands_log) + .map(|content| content.lines().any(|line| line == "rm -f multicode-alpha")) + .unwrap_or(false); + if removed_stale_container && snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(cleared.is_ok(), "stale transient should be cleared"); + + let commands = read_commands(&commands_log); + assert!( + commands.iter().any(|line| line == "rm -f multicode-alpha"), + "stale apple container should be removed during reconciliation" + ); + }); +} + +#[test] +#[ignore = "requires a real Apple container image with opencode installed"] +fn real_apple_container_backend_starts_and_stops_with_supplied_image() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let image = std::env::var("MULTICODE_APPLE_CONTAINER_TEST_IMAGE").expect( + "set MULTICODE_APPLE_CONTAINER_TEST_IMAGE to a real image that contains opencode", + ); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "{image}" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "4 GiB" +cpu = "100%" +"#, + workspace_directory = workspace_directory.display(), + image = image, + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start with real container backend"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + }); +} + +#[test] +#[ignore = "requires a real Apple container image with codex installed"] +fn real_apple_container_backend_starts_and_stops_codex_with_supplied_image() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let image = std::env::var("MULTICODE_APPLE_CONTAINER_TEST_IMAGE") + .expect("set MULTICODE_APPLE_CONTAINER_TEST_IMAGE to a real image that contains codex"); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(home.join(".codex/skills")).expect("codex skills dir should exist"); + fs::write(home.join(".codex/config.toml"), "model = \"gpt-5-codex\"\n") + .expect("codex config should exist"); + fs::write(home.join(".codex/auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[runtime] +backend = "apple-container" +image = "{image}" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "4 GiB" +cpu = "100%" +"#, + workspace_directory = workspace_directory.display(), + image = image, + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start with real codex container backend"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert!( + transient.uri.starts_with("ws://127.0.0.1:"), + "codex runtime should publish a websocket uri" + ); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop with real codex container backend"); + }); +} diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index ecb675d..9ac6287 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -529,7 +529,9 @@ fn should_sync_bidirectional_mapping_up( remote_latest: Option, ) -> bool { match (local_latest, remote_latest) { - (Some(_), Some(_)) => compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less, + (Some(_), Some(_)) => { + compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less + } (Some(_), None) => true, (None, Some(_)) => false, (None, None) => true, @@ -1408,8 +1410,7 @@ fn remote_tui_sync_mapping( #[cfg(test)] mod tests { use super::*; - use multicode_lib::services::config::IsolationConfig; - use multicode_lib::services::config::RemoteConfig; + use multicode_lib::services::config::{AgentConfig, IsolationConfig, RemoteConfig}; use std::fs; #[test] @@ -1489,7 +1490,11 @@ mod tests { Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], + compare: Default::default(), tool: Vec::new(), handler: Default::default(), remote: Some(sample_remote_config()), @@ -1655,7 +1660,11 @@ mod tests { &Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], + compare: Default::default(), tool: Vec::new(), handler: Default::default(), remote: Some(config), @@ -1707,7 +1716,11 @@ mod tests { add_skills_from: vec!["extra-skills".to_string()], ..Default::default() }, + runtime: Default::default(), + autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], + compare: Default::default(), tool: Vec::new(), handler: Default::default(), remote: Some(sample_remote_config()), @@ -1721,7 +1734,10 @@ mod tests { .expect("config should resolve"); assert_eq!(resolved.config_support_sync_up.len(), 1); - assert_eq!(resolved.config_support_sync_up[0].local, skill_dir); + assert_eq!( + resolved.config_support_sync_up[0].local, + std::fs::canonicalize(&skill_dir).expect("skill dir should canonicalize") + ); assert_eq!( resolved.config_support_sync_up[0].remote, PathBuf::from( @@ -1742,7 +1758,11 @@ mod tests { add_skills_from: vec!["workspace-skills".to_string()], ..Default::default() }, + runtime: Default::default(), + autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], + compare: Default::default(), tool: Vec::new(), handler: Default::default(), remote: Some(sample_remote_config()), @@ -1869,7 +1889,6 @@ mod tests { "-e".to_string(), "ssh -o StrictHostKeyChecking=yes".to_string(), "--update".to_string(), - "--mkpath".to_string(), "--delete".to_string(), "--exclude".to_string(), "target".to_string(), @@ -2115,7 +2134,7 @@ mod tests { Path::new(".multicode/remote/relay/file.sock"), &exclude )); - assert!(!tree_scan::is_excluded_relative_path( + assert!(tree_scan::is_excluded_relative_path( Path::new(".multicode"), &exclude )); @@ -2179,7 +2198,9 @@ mod tests { std::fs::create_dir_all(&local_dir).expect("local dir should be created"); let mapping = ResolvedSyncPathMapping { local: local_dir.clone(), - remote: PathBuf::from("/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha"), + remote: PathBuf::from( + "/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha", + ), exclude: Vec::new(), dereference_symlinks: false, local_is_dir: true, @@ -2194,7 +2215,10 @@ mod tests { ) .expect("directory sync args should build"); - assert!(args.iter().any(|arg| arg == &format!("{}/", local_dir.to_string_lossy()))); + assert!( + args.iter() + .any(|arg| arg == &format!("{}/", local_dir.to_string_lossy())) + ); assert!(!args.iter().any(|arg| arg == "--mkpath")); assert!(args.iter().any(|arg| { arg == "alice@example.com:/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha/" @@ -2300,7 +2324,11 @@ mod tests { &Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], + compare: Default::default(), tool: Vec::new(), handler: Default::default(), remote: Some(config), diff --git a/remote/tests/docker_remote_integration.rs b/remote/tests/docker_remote_integration.rs index 68090b3..17c76ab 100644 --- a/remote/tests/docker_remote_integration.rs +++ b/remote/tests/docker_remote_integration.rs @@ -135,9 +135,15 @@ fn build_probe_binary() -> PathBuf { .current_dir(&repo_root) .status() .expect("cargo build for multicode-tui should run"); - assert!(build_status.success(), "multicode-tui should build for integration test"); + assert!( + build_status.success(), + "multicode-tui should build for integration test" + ); let probe_binary = repo_root.join("target/debug/multicode-tui"); - assert!(probe_binary.exists(), "built multicode-tui binary should exist"); + assert!( + probe_binary.exists(), + "built multicode-tui binary should exist" + ); probe_binary } @@ -201,7 +207,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] ) .expect("dockerfile should be written"); - let image = format!("multicode-remote-test-bidi-matrix-{}:{}", case.test_name(), std::process::id()); + let image = format!( + "multicode-remote-test-bidi-matrix-{}:{}", + case.test_name(), + std::process::id() + ); let build = StdCommand::new("docker") .args(["build", "-t", &image, "."]) .current_dir(root.path()) @@ -210,7 +220,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] assert!(build.success(), "docker build should succeed"); let port = reserve_tcp_port(); - let container_name = format!("multicode-remote-test-bidi-matrix-{}-{}", case.test_name(), std::process::id()); + let container_name = format!( + "multicode-remote-test-bidi-matrix-{}-{}", + case.test_name(), + std::process::id() + ); let run = StdCommand::new("docker") .args([ "run", @@ -229,7 +243,9 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .expect("docker run should execute"); assert!(run.success(), "docker run should succeed"); - let _container = DockerContainerGuard { name: container_name.clone() }; + let _container = DockerContainerGuard { + name: container_name.clone(), + }; wait_for_ssh(port, &key_path, &known_hosts).await; @@ -320,8 +336,13 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .output() .await .expect("remote seed probe should run"); - assert!(remote_seed.status.success(), "remote seed probe should succeed"); - let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout).trim().to_string(); + assert!( + remote_seed.status.success(), + "remote seed probe should succeed" + ); + let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout) + .trim() + .to_string(); let remote_parent_probe = Command::new("ssh") .args([ @@ -339,31 +360,67 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .await .expect("remote parent probe should run"); - assert!(remote_parent_probe.success(), "bidi sync must not place files in the remote parent directory"); + assert!( + remote_parent_probe.success(), + "bidi sync must not place files in the remote parent directory" + ); let local_seed_path = bidi_local.join("seed.txt"); - let local_seed_text = fs::read_to_string(&local_seed_path).ok().map(|text| text.trim().to_string()); + let local_seed_text = fs::read_to_string(&local_seed_path) + .ok() + .map(|text| text.trim().to_string()); assert!( - !bidi_local.parent().expect("bidi local parent should exist").join("seed.txt").exists(), + !bidi_local + .parent() + .expect("bidi local parent should exist") + .join("seed.txt") + .exists(), "bidi sync must not place files in the local parent directory" ); match case { BidiExistenceCase::LocalAndRemoteMissing => { - assert_eq!(remote_seed_text, "", "remote destination should remain empty when both sides start empty"); - assert!(!local_seed_path.exists(), "local destination should remain empty when both sides start empty"); + assert_eq!( + remote_seed_text, "", + "remote destination should remain empty when both sides start empty" + ); + assert!( + !local_seed_path.exists(), + "local destination should remain empty when both sides start empty" + ); } BidiExistenceCase::LocalOnly => { - assert_eq!(remote_seed_text, "local-seed", "initial upload should seed the exact remote destination from the local directory"); - assert_eq!(local_seed_text.as_deref(), Some("local-seed"), "local seed should remain in the configured local directory"); + assert_eq!( + remote_seed_text, "local-seed", + "initial upload should seed the exact remote destination from the local directory" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("local-seed"), + "local seed should remain in the configured local directory" + ); } BidiExistenceCase::RemoteOnly => { - assert_eq!(remote_seed_text, "remote-seed", "remote-only case should preserve the exact remote destination contents"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "final sync-down should place remote contents into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "remote-only case should preserve the exact remote destination contents" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "final sync-down should place remote contents into the configured local directory" + ); } BidiExistenceCase::LocalAndRemotePresent => { - assert_eq!(remote_seed_text, "remote-seed", "newer remote content should win within the configured remote destination"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "newer remote content should sync down into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "newer remote content should win within the configured remote destination" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "newer remote content should sync down into the configured local directory" + ); } } } @@ -675,7 +732,6 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] }); } - #[test] fn docker_remote_flow_bidi_sync_handles_both_missing() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 13f50f7..22b0c85 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -15,3 +15,5 @@ tracing = "0" size = "0" toml = "1" rustix = { version = "1", features = ["fs"] } +serde_json = "1" +unicode-width = "0.2" diff --git a/tui/src/app.rs b/tui/src/app.rs index a8621b4..c7db20d 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -1,10 +1,312 @@ use crate::ops::*; use crate::system::*; use crate::*; -use multicode_lib::services::GithubTokenConfig; +use multicode_lib::services::{ + GithubTokenConfig, + codex_app_server::{CodexAppServerClient, CodexThreadStatus}, +}; use std::os::unix::fs::FileTypeExt; const NERD_FONT_GITHUB_GLYPH: &str = "\u{f408}"; +const CODEX_AUTO_RESUME_PROMPT: &str = "Continue autonomously from where you left off. Do not wait for approval for repository commands, builds, Gradle tasks, or focused tests. Only stop to ask before committing, pushing, commenting on GitHub, or opening or updating a pull request."; +const CODEX_CREATE_PR_APPROVAL_PROMPT: &str = "The local changes for this task are approved for publishing. Create or update the pull request now from this task checkout. Push the branch if needed, use the correct upstream base branch, include an appropriate type label such as `type: docs` for documentation-only changes, `type: bug` for bug fixes, `type: improvement` for minor improvements, or `type: enhancement` for broader enhancements, assign the pull request to yourself, assign it automatically to the next Micronaut project release at the organization level under https://github.com/orgs/micronaut-projects/projects, prefer the next semantically versioned release project that is typically suffixed with a milestone such as `5.0.0-M2` and otherwise suffixed with `Release` such as `5.0.0 Release`, request Copilot review, emit the link, and stop once the PR is ready for human review. If a PR already exists, update it instead of creating a duplicate. Do not merge the PR."; +const CODEX_FIX_CI_INSTRUCTIONS: &str = "Fix any failing CI checks for this task's existing pull request, including Sonar failures. Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior. Push fixes as needed, monitor CI after each push, and continue until the pull request is green. Do not create a new pull request. Do not merge the pull request. Stop only when all CI is passing or you need human input."; + +pub(crate) fn build_codex_fix_ci_prompt( + assigned_repository: &str, + issue_url: &str, + backing_pr_url: Option<&str>, + cwd: &std::path::Path, + task_state_path: &std::path::Path, + task_session_id: Option<&str>, +) -> String { + let pr_instruction = backing_pr_url.map_or_else( + || "If a pull request already exists for this issue, use that existing pull request instead of creating a duplicate.".to_string(), + |backing_pr_url| format!("Use the existing pull request {backing_pr_url}."), + ); + let session_instruction = task_session_id.map_or_else( + String::new, + |task_session_id| { + format!( + "For this task session/thread, write autonomous state updates in the format `:{task_session_id}` so multicode can attribute the state to this specific session.\n\\\n" + ) + }, + ); + format!( + "You are operating in an autonomous multicode workspace for repository {assigned_repository}.\n\ +Continue autonomously from where you left off.\n\ +Start from the existing checkout for GitHub issue {issue_url}.\n\ +Primary checkout for this task: {cwd}\n\ +Before you proceed, load and follow these workspace skills as appropriate: `independent-fix`, `machine-readable-clone`, `machine-readable-issue`, `machine-readable-pr`, `git-commit-coauthorship`, `micronaut-projects-guide`, and `autonomous-state`.\n\ +For this task, write autonomous state updates to `{task_state_path}`. Do not write task state to any shared workspace file.\n\ +{session_instruction}\ +{pr_instruction}\n\ +Your job is to:\n\ +1. Inspect the current failing CI status for this task and understand every failing check.\n\ +2. Reproduce and fix the underlying problems in the existing checkout.\n\ +3. Run focused verification locally.\n\ +4. Commit and push branch updates as needed.\n\ +5. Monitor CI after each push and keep addressing failures until it is green.\n\ +6. Emit the machine-readable repository / issue / PR tags while you work.\n\ +7. Run repository commands, builds, Gradle tasks, focused tests, git commits, branch pushes, and pull request updates as needed without asking for permission.\n\ +{instructions}\n\ +\n\ +Keep going until CI is green or you need human feedback.", + cwd = cwd.display(), + task_state_path = task_state_path.display(), + session_instruction = session_instruction, + instructions = CODEX_FIX_CI_INSTRUCTIONS + ) +} + +pub(crate) fn repository_diff_shell_command() -> &'static str { + r#"tmp="$(mktemp -t multicode-diff.XXXXXX)" || exit 1 +{ + echo "Git status" + echo "==========" + git status --short + echo + echo "Git diff" + echo "========" + git --no-pager diff --color=always --stat --patch HEAD -- +} >"$tmp" +if [ ! -s "$tmp" ]; then + printf 'No local changes\n' >"$tmp" +fi +if command -v less >/dev/null 2>&1; then + less -R -X "$tmp" +else + cat "$tmp" + printf '\nPress Enter to return...' + read -r _ +fi +rm -f "$tmp""# +} + +pub(crate) fn shell_command_in_repo(repo_dir: &str, shell_command: &str) -> String { + format!("cd -- {} && {}", shell_escape_arg(repo_dir), shell_command) +} + +pub(crate) fn count_codex_session_turn_metrics(contents: &str) -> CodexSessionTurnMetrics { + CodexSessionTurnMetrics { + started: contents.matches("\"type\":\"task_started\"").count(), + completed: contents.matches("\"type\":\"task_complete\"").count(), + aborted: contents.matches("\"type\":\"turn_aborted\"").count(), + } +} + +fn codex_session_log_root( + workspace_directory_path: &std::path::Path, + workspace_key: &str, +) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("codex") + .join(workspace_key) + .join("home") + .join("sessions") +} + +fn find_codex_session_log_path( + workspace_directory_path: &std::path::Path, + workspace_key: &str, + session_id: &str, +) -> Option { + let root = codex_session_log_root(workspace_directory_path, workspace_key); + let mut stack = vec![root]; + let suffix = format!("{session_id}.jsonl"); + while let Some(path) = stack.pop() { + let entries = std::fs::read_dir(&path).ok()?; + for entry in entries.flatten() { + let entry_path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + stack.push(entry_path); + continue; + } + if file_type.is_file() + && entry_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(entry_path); + } + } + } + None +} + +fn read_codex_session_turn_metrics( + workspace_directory_path: &std::path::Path, + workspace_key: &str, + session_id: &str, +) -> Option { + let path = find_codex_session_log_path(workspace_directory_path, workspace_key, session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + Some(count_codex_session_turn_metrics(&contents)) +} + +pub(crate) fn last_user_message_from_codex_session_log_contents(contents: &str) -> Option { + contents + .lines() + .filter_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("user_message") { + return None; + } + payload + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToOwned::to_owned) + }) + .last() +} + +fn first_user_message_from_codex_session_log_contents(contents: &str) -> Option { + contents.lines().find_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("user_message") { + return None; + } + payload + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn interrupted_codex_resume_prompt_from_session_log_contents(contents: &str) -> Option { + let first = first_user_message_from_codex_session_log_contents(contents); + let last = last_user_message_from_codex_session_log_contents(contents); + match (first, last) { + (Some(first), Some(last)) if first != last => Some(format!( + "{first}\n\nAdditional user instruction from the interrupted interactive attach:\n{last}" + )), + (_, Some(last)) => Some(last), + (Some(first), None) => Some(first), + (None, None) => None, + } +} + +async fn read_last_codex_session_user_message( + workspace_directory_path: std::path::PathBuf, + workspace_key: String, + session_id: String, +) -> Option { + tokio::task::spawn_blocking(move || { + let path = + find_codex_session_log_path(&workspace_directory_path, &workspace_key, &session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + last_user_message_from_codex_session_log_contents(&contents) + }) + .await + .ok() + .flatten() +} + +async fn read_interrupted_codex_resume_prompt( + workspace_directory_path: std::path::PathBuf, + workspace_key: String, + session_id: String, +) -> Option { + tokio::task::spawn_blocking(move || { + let path = + find_codex_session_log_path(&workspace_directory_path, &workspace_key, &session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + interrupted_codex_resume_prompt_from_session_log_contents(&contents) + }) + .await + .ok() + .flatten() +} + +pub(crate) fn should_resume_codex_task_after_incomplete_attached_turn( + initial_metrics: Option, + current_metrics: Option, + thread_status: Option<&CodexThreadStatus>, +) -> bool { + let Some(initial_metrics) = initial_metrics else { + return false; + }; + let Some(current_metrics) = current_metrics else { + return false; + }; + let started_new_turn = current_metrics.started > initial_metrics.started; + let aborted_new_turn = current_metrics.aborted > initial_metrics.aborted; + if !started_new_turn && !aborted_new_turn { + return false; + } + if current_metrics.completed > initial_metrics.completed && !aborted_new_turn { + return false; + } + match thread_status { + Some(CodexThreadStatus::Active { .. }) => false, + Some(CodexThreadStatus::SystemError) => false, + Some(CodexThreadStatus::Idle | CodexThreadStatus::NotLoaded) | None => true, + } +} + +pub(crate) fn should_restart_codex_task_for_pr_request( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> bool { + let Some(task_state) = task_state else { + return false; + }; + + if task_state.agent_state == Some(AutomationAgentState::Stale) { + return true; + } + + matches!(task_state.status.as_deref(), Some("NotLoaded")) +} + +pub(crate) fn should_restart_codex_task_for_ci_fix( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> bool { + let Some(task_state) = task_state else { + return false; + }; + + if should_restart_codex_task_for_pr_request(Some(task_state)) { + return true; + } + + if task_state.session_id.is_none() { + return false; + } + + match task_state.session_status { + Some(RootSessionStatus::Busy) => false, + Some(RootSessionStatus::Idle) | Some(RootSessionStatus::Question) | None => { + task_state.agent_state != Some(AutomationAgentState::Working) + } + } +} + +pub(crate) fn should_offer_codex_ci_fix( + has_pr_link: bool, + pr_status: Option, +) -> bool { + has_pr_link + && match pr_status { + Some(GithubPrStatus { + state: GithubPrState::Open, + build, + .. + }) => build != GithubPrBuildState::Succeeded, + Some(_) => false, + None => true, + } +} pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { let url = Url::parse(target).ok()?; @@ -26,7 +328,359 @@ pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { Some(format!("{NERD_FONT_GITHUB_GLYPH} {owner}/{repo}#{number}")) } +pub(crate) fn github_repository_url(repository: &str) -> Option { + let repository = github_repository_spec(repository)?; + Some(format!("https://github.com/{repository}")) +} + +pub(crate) fn github_repository_spec(repository: &str) -> Option { + let trimmed = repository.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(url) = Url::parse(trimmed) { + if url.host_str()? != "github.com" { + return None; + } + let mut segments = url.path_segments()?.filter(|segment| !segment.is_empty()); + let owner = segments.next()?; + let repo = segments.next()?; + return Some(format!("{owner}/{repo}")); + } + + let mut segments = trimmed.split('/').filter(|segment| !segment.is_empty()); + let owner = segments.next()?; + let repo = segments.next()?; + if segments.next().is_some() { + return None; + } + + Some(format!("{owner}/{repo}")) +} + +pub(crate) fn task_repository_spec(snapshot: &WorkspaceSnapshot, task_id: &str) -> Option { + if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { + return github_repository_spec(repository); + } + + let task = task_persistent_snapshot(snapshot, task_id)?; + github_repository_spec(&task.issue_url).or_else(|| { + task_pr_link(task, task_runtime_snapshot(snapshot, task_id)) + .and_then(github_repository_spec) + }) +} + +pub(crate) fn has_available_task_slot( + snapshot: &WorkspaceSnapshot, + max_parallel_issues: usize, +) -> bool { + snapshot.persistent.tasks.len() < max_parallel_issues +} + +pub(crate) fn should_request_autonomous_issue_scan( + snapshot: &WorkspaceSnapshot, + max_parallel_issues: usize, +) -> bool { + snapshot.persistent.assigned_repository.is_some() + && !snapshot.persistent.archived + && has_available_task_slot(snapshot, max_parallel_issues) +} + +pub(crate) fn workspace_can_queue_next_issue(snapshot: &WorkspaceSnapshot) -> bool { + workspace_is_usable(snapshot) && snapshot.persistent.assigned_repository.is_some() +} + +pub(crate) fn should_auto_resume_autonomous_codex_after_attach( + snapshot: &WorkspaceSnapshot, +) -> bool { + if snapshot.persistent.assigned_repository.is_none() + || snapshot.resolved_active_task_id().is_none() + { + return false; + } + + match snapshot.automation_agent_state { + Some(AutomationAgentState::Working) => true, + Some( + AutomationAgentState::WaitingOnVm + | AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale, + ) => false, + None => matches!( + snapshot + .automation_session_status + .or(snapshot.root_session_status), + Some(RootSessionStatus::Busy) + ), + } +} + +pub(crate) fn should_auto_resume_task_codex_after_attach( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, + attached_session_id: Option<&str>, + initial_agent_state: Option, +) -> bool { + let Some(task_state) = task_state else { + return matches!(initial_agent_state, Some(AutomationAgentState::Working)); + }; + + if let Some(attached_session_id) = attached_session_id + && let Some(current_session_id) = task_state.session_id.as_deref() + && current_session_id != attached_session_id + { + return false; + } + + if task_state.agent_state == Some(AutomationAgentState::Stale) { + return matches!(initial_agent_state, Some(AutomationAgentState::Working)); + } + + let effective_agent_state = task_effective_agent_state(Some(task_state)); + if attached_session_id.is_some() + && task_state.session_id.as_deref() == attached_session_id + && effective_agent_state == Some(AutomationAgentState::Working) + { + return false; + } + + match effective_agent_state { + Some(AutomationAgentState::Working) => true, + Some( + AutomationAgentState::WaitingOnVm + | AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle, + ) => false, + Some(AutomationAgentState::Stale) => false, + None if attached_session_id.is_some() && task_state.session_id.is_none() => { + matches!(initial_agent_state, Some(AutomationAgentState::Working)) + } + None => matches!(initial_agent_state, Some(AutomationAgentState::Working)), + } +} + +pub(crate) fn should_restart_task_codex_after_attach( + attached_session_id: Option<&str>, + fresh_codex_session: bool, +) -> bool { + attached_session_id.is_some() && !fresh_codex_session +} + +pub(crate) fn working_codex_task_attach_target( + snapshot: &WorkspaceSnapshot, + task_id: Option<&str>, + _cwd: Option, +) -> io::Result> { + let Some(task_id) = task_id else { + return Ok(None); + }; + let Some(task_state) = task_runtime_snapshot(snapshot, task_id) else { + return Ok(None); + }; + if task_effective_agent_state(Some(task_state)) != Some(AutomationAgentState::Working) { + return Ok(None); + } + + let Some(uri) = codex_attach_uri(snapshot)? else { + return Ok(None); + }; + Ok(Some(AttachTarget::Codex { + uri, + thread_id: task_state.session_id.clone(), + })) +} + +pub(crate) fn should_retry_codex_task_attach_with_last_thread( + provider: multicode_lib::services::AgentProvider, + snapshot: Option<&WorkspaceSnapshot>, + workspace_key: &str, + attached_workspace_key: Option<&str>, + task_id: Option<&str>, + session_id: Option<&str>, +) -> Option { + if provider != multicode_lib::services::AgentProvider::Codex + || attached_workspace_key != Some(workspace_key) + || session_id.is_none() + { + return None; + } + + let snapshot = snapshot?; + let task_state = task_runtime_snapshot(snapshot, task_id?)?; + if !matches!(task_state.status.as_deref(), Some("NotLoaded")) { + return None; + } + + let Ok(Some(uri)) = codex_attach_uri(snapshot) else { + return None; + }; + Some(AttachTarget::Codex { + uri, + thread_id: None, + }) +} + +pub(crate) fn should_start_fresh_codex_task_session_after_failed_attach( + provider: multicode_lib::services::AgentProvider, + attached_workspace_key: Option<&str>, + workspace_key: &str, + task_id: Option<&str>, + session_id: Option<&str>, + initial_turn_metrics: Option, + current_turn_metrics: Option, + current_thread_status: Option<&CodexThreadStatus>, +) -> bool { + let Some(_task_id) = task_id else { + return false; + }; + let Some(_session_id) = session_id else { + return false; + }; + let Some(initial_turn_metrics) = initial_turn_metrics else { + return false; + }; + let Some(current_turn_metrics) = current_turn_metrics else { + return false; + }; + + provider == multicode_lib::services::AgentProvider::Codex + && attached_workspace_key == Some(workspace_key) + && initial_turn_metrics.completed == 0 + && initial_turn_metrics.aborted > 0 + && current_turn_metrics == initial_turn_metrics + && matches!( + current_thread_status, + Some(CodexThreadStatus::NotLoaded | CodexThreadStatus::SystemError) + ) +} + +fn task_can_yield_vm_for_attach(task_state: &WorkspaceTaskRuntimeSnapshot) -> bool { + matches!( + task_effective_agent_state(Some(task_state)), + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ) +} + +pub(crate) fn should_queue_task_codex_resume_until_vm_available( + snapshot: &WorkspaceSnapshot, + task_id: &str, +) -> bool { + let Some(active_task_id) = snapshot + .active_task_id + .clone() + .or_else(|| snapshot.resolved_active_task_id()) + else { + return matches!( + snapshot.root_session_status, + Some(RootSessionStatus::Busy | RootSessionStatus::Question) + ); + }; + if active_task_id == task_id { + return false; + } + let Some(active_task_state) = snapshot.task_states.get(&active_task_id) else { + return matches!( + snapshot.root_session_status, + Some(RootSessionStatus::Busy | RootSessionStatus::Question) + ); + }; + if active_task_state.waiting_on_vm { + return false; + } + !task_can_yield_vm_for_attach(active_task_state) +} + +pub(crate) fn restored_selected_row( + entries: &[TableEntry], + previous_selected_entry: Option<&TableEntry>, + current_selected_row: usize, +) -> usize { + let max_row = entries.len().saturating_sub(1); + let Some(previous_selected_entry) = previous_selected_entry else { + return current_selected_row.min(max_row); + }; + + if matches!(previous_selected_entry, TableEntry::Create) { + return 0; + } + + if let Some(position) = entries + .iter() + .position(|entry| entry == previous_selected_entry) + { + return position; + } + + if let TableEntry::Task { workspace_key, .. } = previous_selected_entry + && let Some(position) = entries.iter().position(|entry| { + matches!( + entry, + TableEntry::Workspace { + workspace_key: candidate + } if candidate == workspace_key + ) + }) + { + return position; + } + + current_selected_row.min(max_row) +} + impl TuiState { + pub(crate) fn table_entries(&self) -> Vec { + let mut entries = Vec::with_capacity(self.ordered_keys.len() + 1); + entries.push(TableEntry::Create); + for key in &self.ordered_keys { + entries.push(TableEntry::Workspace { + workspace_key: key.clone(), + }); + if let Some(snapshot) = self.snapshots.get(key) { + for task in &snapshot.persistent.tasks { + entries.push(TableEntry::Task { + workspace_key: key.clone(), + task_id: task.id.clone(), + }); + } + } + } + entries + } + + fn selected_entry(&self) -> Option { + self.table_entries().get(self.selected_row).cloned() + } + + pub(crate) fn selected_task_id(&self) -> Option<&str> { + if self.selected_row == 0 { + return None; + } + let mut row = 1usize; + for key in &self.ordered_keys { + if row == self.selected_row { + return None; + } + row += 1; + if let Some(snapshot) = self.snapshots.get(key) { + for task in &snapshot.persistent.tasks { + if row == self.selected_row { + return Some(task.id.as_str()); + } + row += 1; + } + } + } + None + } + pub(crate) async fn new( config_path: PathBuf, relay_socket: Option, @@ -39,6 +693,8 @@ impl TuiState { config.github.token = Some(GithubTokenConfig { env: Some(github_token_env), command: None, + keychain_service: None, + keychain_account: None, }); } let service = CombinedService::from_config_path(&config_path) @@ -62,12 +718,19 @@ impl TuiState { selected_link_target_index: 0, mode: UiMode::Normal, create_input: String::new(), + repository_input: String::new(), + create_field: CreateModalField::Key, edit_input: String::new(), + issue_input: String::new(), custom_link_input: String::new(), custom_link_kind: None, custom_link_action: None, custom_link_original_value: None, + pending_delete_target: None, + pending_task_removal_action: TaskRemovalAction::default(), + attached_session: None, starting_workspace_key: None, + starting_attach_when_ready: false, started_wait_since: None, previous_machine_cpu_totals: None, machine_cpu_count: 1, @@ -120,12 +783,12 @@ impl TuiState { } pub(crate) fn sync_from_manager(&mut self) { - let previous_selected_key = if self.selected_row > 0 { - self.ordered_keys.get(self.selected_row - 1).cloned() - } else { - None - }; - let selected_create_row = self.selected_row == 0; + let previous_selected_entry = self.selected_entry(); + let previous_selected_workspace_key = self.selected_workspace_key().map(str::to_string); + let previous_selected_automation_status = previous_selected_workspace_key + .as_ref() + .and_then(|key| self.snapshots.get(key)) + .and_then(|snapshot| snapshot.automation_status.clone()); let workspace_keys = self.workspace_keys_rx.borrow().clone(); @@ -154,20 +817,23 @@ impl TuiState { self.refresh_workspace_link_validations(); self.refresh_github_link_statuses(); - if selected_create_row { - self.selected_row = 0; - } else if let Some(previous_selected_key) = previous_selected_key { - self.selected_row = self - .ordered_keys - .iter() - .position(|key| key == &previous_selected_key) - .map(|position| position + 1) - .unwrap_or(0); - } else { - let max_row = self.ordered_keys.len(); - if self.selected_row > max_row { - self.selected_row = max_row; - } + let table_entries = self.table_entries(); + self.selected_row = restored_selected_row( + &table_entries, + previous_selected_entry.as_ref(), + self.selected_row, + ); + + if let Some(key) = self.selected_workspace_key() + && previous_selected_workspace_key.as_deref() == Some(key) + && let Some(current_status) = self + .snapshots + .get(key) + .and_then(|snapshot| snapshot.automation_status.as_deref()) + && current_status.starts_with("Scan failed") + && previous_selected_automation_status.as_deref() != Some(current_status) + { + self.status = current_status.to_string(); } self.normalize_selected_link_index(); @@ -178,6 +844,10 @@ impl TuiState { self.mode = UiMode::Normal; self.edit_input.clear(); } + UiMode::EditIssue => { + self.mode = UiMode::Normal; + self.issue_input.clear(); + } UiMode::EditCustomLink => { self.mode = UiMode::Normal; self.custom_link_input.clear(); @@ -185,6 +855,16 @@ impl TuiState { self.custom_link_action = None; self.custom_link_original_value = None; } + UiMode::ConfirmDelete => { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } + UiMode::ConfirmTaskRemoval => { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } _ => {} } } @@ -196,19 +876,30 @@ impl TuiState { .and_then(|key| self.snapshots.get(key).map(workspace_state)); match starting_state { Some(WorkspaceUiState::Starting) => {} - Some(WorkspaceUiState::Started) => {} + Some(WorkspaceUiState::Started) => { + if !self.starting_attach_when_ready { + if let Some(key) = self.starting_workspace_key.as_deref() { + self.status = format!("Started workspace '{key}'"); + } + self.mode = UiMode::Normal; + self.starting_workspace_key = None; + self.starting_attach_when_ready = false; + self.started_wait_since = None; + } + } Some(WorkspaceUiState::Stopped) => { if let Some(key) = self.starting_workspace_key.as_deref() { - self.status = - format!("Workspace '{key}' failed to start; server is still stopped"); + self.status = starting_modal_failure_status(key, self.snapshots.get(key)); } self.mode = UiMode::Normal; self.starting_workspace_key = None; + self.starting_attach_when_ready = false; self.started_wait_since = None; } None => { self.mode = UiMode::Normal; self.starting_workspace_key = None; + self.starting_attach_when_ready = false; self.started_wait_since = None; } } @@ -225,16 +916,46 @@ impl TuiState { self.running_operation = None; } } + + if matches!( + self.mode, + UiMode::ConfirmDelete | UiMode::ConfirmTaskRemoval + ) && self + .pending_delete_target + .as_ref() + .is_some_and(|target| match target { + PendingDeleteTarget::Workspace { workspace_key } + | PendingDeleteTarget::Task { workspace_key, .. } => { + !self.snapshots.contains_key(workspace_key) + } + }) + { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } } pub(crate) fn selected_workspace_key(&self) -> Option<&str> { if self.selected_row == 0 { - None - } else { - self.ordered_keys - .get(self.selected_row - 1) - .map(String::as_str) + return None; + } + let mut row = 1usize; + for key in &self.ordered_keys { + if row == self.selected_row { + return Some(key.as_str()); + } + row += 1; + if let Some(snapshot) = self.snapshots.get(key) { + for _task in &snapshot.persistent.tasks { + if row == self.selected_row { + return Some(key.as_str()); + } + row += 1; + } + } } + None } pub(crate) fn selected_workspace_snapshot(&self) -> Option<&WorkspaceSnapshot> { @@ -242,6 +963,57 @@ impl TuiState { .and_then(|key| self.snapshots.get(key)) } + pub(crate) fn selected_workspace_can_diff(&self) -> bool { + if self.selected_link_index.is_some() { + return false; + } + if self.selected_task_id().is_none() { + return false; + } + + self.selected_workspace_snapshot() + .is_some_and(workspace_is_usable) + && self.selected_workspace_repo_path().is_some() + } + + pub(crate) fn selected_workspace_can_edit(&self) -> bool { + self.selected_workspace_can_diff() + && compare_tool_is_available(&self.service.config.compare) + } + + fn selected_workspace_github_repository_url(&self) -> Option { + if self.selected_task_id().is_some() { + return None; + } + + self.selected_workspace_snapshot()? + .persistent + .assigned_repository + .as_deref() + .and_then(github_repository_url) + } + + fn selected_workspace_repo_path(&self) -> Option { + let key = self.selected_workspace_key()?; + let snapshot = self.snapshots.get(key)?; + let workspace_path = self.service.workspace_directory_path().join(key); + if let Some(task_id) = self.selected_task_id() + && let Some(task) = task_persistent_snapshot(snapshot, task_id) + { + return compare_target_path_for_task( + snapshot, + task, + task_runtime_snapshot(snapshot, task_id), + &workspace_path, + ); + } + compare_target_path( + snapshot, + &self.workspace_link_validation_results, + &workspace_path, + ) + } + fn selected_workspace_link_targets(&self) -> Vec<(WorkspaceLink, String)> { let Some(link) = self.selected_workspace_link() else { return Vec::new(); @@ -253,24 +1025,38 @@ impl TuiState { let Some(snapshot) = self.selected_workspace_snapshot() else { return Vec::new(); }; - - validated_workspace_links_by_kind( - snapshot, - &self.workspace_link_validation_results, - link.kind, - ) - .into_iter() - .filter_map(|candidate| { - self.workspace_link_validation_results - .get(&candidate) - .and_then(|result| match result { - WorkspaceLinkValidationResult::Valid(argument) => { - Some((candidate, argument.clone())) - } - WorkspaceLinkValidationResult::Invalid(_) => None, + let candidates = if let Some(task_id) = self.selected_task_id() { + task_persistent_snapshot(snapshot, task_id) + .map(|task| { + visible_task_links( + task, + task_runtime_snapshot(snapshot, task_id), + &self.workspace_link_validation_results, + ) }) - }) - .collect() + .unwrap_or_default() + } else { + validated_workspace_links_by_kind( + snapshot, + &self.workspace_link_validation_results, + link.kind, + ) + }; + + candidates + .into_iter() + .filter(|candidate| candidate.kind == link.kind) + .filter_map(|candidate| { + self.workspace_link_validation_results + .get(&candidate) + .and_then(|result| match result { + WorkspaceLinkValidationResult::Valid(argument) => { + Some((candidate, argument.clone())) + } + WorkspaceLinkValidationResult::Invalid(_) => None, + }) + }) + .collect() } fn normalize_selected_link_target_index(&mut self) { @@ -345,33 +1131,50 @@ impl TuiState { } fn can_add_custom_link_for_selected_kind(&self) -> Option { + if self.selected_task_id().is_some() { + return None; + } self.selected_workspace_link() .filter(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) .map(|link| link.kind) } fn selected_workspace_selectable_links(&self) -> Vec { - self.selected_workspace_key() - .and_then(|key| self.snapshots.get(key)) - .map(|snapshot| { - selectable_workspace_links( - snapshot, - &self.workspace_link_validation_results, - &self.github_link_statuses, - ) - }) - .unwrap_or_default() - } + let Some(key) = self.selected_workspace_key() else { + return Vec::new(); + }; + let Some(snapshot) = self.snapshots.get(key) else { + return Vec::new(); + }; - fn selected_workspace_link_argument(&self, link: &WorkspaceLink) -> Option<&str> { - match self.workspace_link_validation_results.get(link) { - Some(WorkspaceLinkValidationResult::Valid(argument)) => Some(argument.as_str()), + if let Some(task_id) = self.selected_task_id() { + return task_persistent_snapshot(snapshot, task_id) + .map(|task| { + visible_task_links( + task, + task_runtime_snapshot(snapshot, task_id), + &self.workspace_link_validation_results, + ) + }) + .unwrap_or_default(); + } + + selectable_workspace_links( + snapshot, + &self.workspace_link_validation_results, + &self.github_link_statuses, + ) + } + + fn selected_workspace_link_argument(&self, link: &WorkspaceLink) -> Option<&str> { + match self.workspace_link_validation_results.get(link) { + Some(WorkspaceLinkValidationResult::Valid(argument)) => Some(argument.as_str()), Some(WorkspaceLinkValidationResult::Invalid(_)) | None => None, } } fn normalize_selected_link_index(&mut self) { - if self.selected_row == 0 { + if matches!(self.selected_entry(), Some(TableEntry::Create)) { self.selected_link_index = None; return; } @@ -389,11 +1192,14 @@ impl TuiState { } fn refresh_workspace_link_validations(&mut self) { - let active_links = self - .snapshots - .values() - .flat_map(workspace_links) - .collect::>(); + let mut active_links = HashSet::new(); + for snapshot in self.snapshots.values() { + active_links.extend(workspace_links(snapshot)); + active_links.extend(workspace_issue_pr_links(snapshot)); + for task in &snapshot.persistent.tasks { + active_links.extend(task_links(task, task_runtime_snapshot(snapshot, &task.id))); + } + } self.workspace_link_validation_results .retain(|link, _| active_links.contains(link)); @@ -449,12 +1255,21 @@ impl TuiState { } fn refresh_github_link_statuses(&mut self) { - let active_issue_or_pr_links = self - .snapshots - .values() - .flat_map(workspace_links) - .filter(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) - .collect::>(); + let mut active_issue_or_pr_links = HashSet::new(); + for snapshot in self.snapshots.values() { + active_issue_or_pr_links.extend(workspace_issue_pr_links(snapshot).into_iter().filter( + |link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr), + )); + for task in &snapshot.persistent.tasks { + active_issue_or_pr_links.extend( + task_links(task, task_runtime_snapshot(snapshot, &task.id)) + .into_iter() + .filter(|link| { + matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + }), + ); + } + } self.github_link_status_rxs .retain(|link, _| active_issue_or_pr_links.contains(link)); @@ -480,6 +1295,10 @@ impl TuiState { continue; }; self.github_link_status_rxs.insert(link.clone(), receiver); + let _ = self + .service + .github_status_service() + .request_refresh(validated_link); } let status = self @@ -509,9 +1328,15 @@ impl TuiState { } pub(crate) fn selected_workspace_has_refreshable_github_link(&self) -> bool { - self.selected_workspace_selectable_links() - .into_iter() - .any(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) + self.selected_workspace_snapshot().is_some_and(|snapshot| { + should_request_autonomous_issue_scan( + snapshot, + self.service.config.autonomous.max_parallel_issues, + ) || self + .selected_workspace_selectable_links() + .into_iter() + .any(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) + }) } pub(crate) fn selected_workspace_link(&self) -> Option { @@ -525,6 +1350,17 @@ impl TuiState { let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { return; }; + let autonomous_scan_requested = self + .snapshots + .get(&workspace_key) + .filter(|snapshot| { + should_request_autonomous_issue_scan( + snapshot, + self.service.config.autonomous.max_parallel_issues, + ) + }) + .map(|_| self.service.request_workspace_issue_scan(&workspace_key)) + .transpose(); let requested_refreshes = self .selected_workspace_selectable_links() @@ -534,16 +1370,140 @@ impl TuiState { .filter(|url| self.service.github_status_service().request_refresh(url)) .count(); - if requested_refreshes > 0 { - self.status = - format!("Requested GitHub status recheck for workspace '{workspace_key}'"); - } else { - self.status = format!( - "No refreshable GitHub status links available for workspace '{workspace_key}'" - ); + match autonomous_scan_requested { + Err(err) => { + self.status = format!( + "Failed to request autonomous issue scan for workspace '{workspace_key}': {err:?}" + ); + } + Ok(Some(())) => { + if requested_refreshes > 0 { + self.status = format!( + "Requested GitHub status recheck and autonomous issue scan for workspace '{workspace_key}'" + ); + } else { + self.status = + format!("Requested autonomous issue scan for workspace '{workspace_key}'"); + } + } + Ok(None) => { + if requested_refreshes > 0 { + self.status = + format!("Requested GitHub status recheck for workspace '{workspace_key}'"); + } else { + self.status = format!( + "No refreshable GitHub status links available for workspace '{workspace_key}'" + ); + } + } + } + } + + fn request_selected_workspace_queue_next_issue(&mut self) { + let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let Some(snapshot) = self.snapshots.get(&workspace_key) else { + return; + }; + if !workspace_can_queue_next_issue(snapshot) { + return; + } + + match self.service.request_workspace_queue_next(&workspace_key) { + Ok(()) => { + self.status = format!("Requested next issue for workspace '{workspace_key}'"); + } + Err(err) => { + self.status = format!( + "Failed to request next issue for workspace '{workspace_key}': {err:?}" + ); + } } } + fn start_workspace_operation(&mut self, workspace_key: String, attach_when_ready: bool) { + let initial_progress = if attach_when_ready { + format!("Starting workspace '{workspace_key}' before attaching...") + } else { + format!("Starting workspace '{workspace_key}'...") + }; + let (progress_tx, progress_rx) = watch::channel(initial_progress); + let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let workspace_key_for_task = workspace_key.clone(); + + tokio::spawn(async move { + let _ = progress_tx.send(format!( + "Starting runtime for workspace '{workspace_key_for_task}'..." + )); + let result = service + .start_workspace(&workspace_key_for_task) + .await + .map_err(|err| err.summary()); + if result.is_ok() { + let _ = progress_tx.send(format!( + "Runtime started for workspace '{workspace_key_for_task}'. Waiting for server readiness..." + )); + } + let _ = result_tx.send(result); + }); + + self.running_operation = Some(RunningOperation { + workspace_key: workspace_key.clone(), + operation_name: "Start".to_string(), + success_status: None, + progress_rx, + result_rx, + completion_action: RunningOperationCompletionAction::WaitForWorkspaceStart { + attach_when_ready, + }, + cancel: None, + }); + self.mode = UiMode::ToolProgressModal; + self.status = if attach_when_ready { + format!("Starting workspace '{workspace_key}' before attaching") + } else { + format!("Starting workspace '{workspace_key}'") + }; + } + + fn start_stop_workspace_operation(&mut self, workspace_key: String) { + let (progress_tx, progress_rx) = + watch::channel(format!("Stopping workspace '{workspace_key}'...")); + let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let workspace_key_for_task = workspace_key.clone(); + + tokio::spawn(async move { + let _ = progress_tx.send(format!( + "Stopping runtime for workspace '{workspace_key_for_task}'..." + )); + let result = service + .stop_workspace(&workspace_key_for_task) + .await + .map_err(|err| err.summary()); + if result.is_ok() { + let _ = progress_tx.send(format!( + "Stopped workspace '{workspace_key_for_task}'. Refreshing UI state..." + )); + } + let _ = result_tx.send(result); + }); + + self.running_operation = Some(RunningOperation { + workspace_key: workspace_key.clone(), + operation_name: "Stop".to_string(), + success_status: Some(format!("Stopped workspace '{workspace_key}'")), + progress_rx, + result_rx, + completion_action: RunningOperationCompletionAction::None, + cancel: None, + }); + self.mode = UiMode::ToolProgressModal; + self.status = format!("Stopping workspace '{workspace_key}'"); + } + fn move_selected_link_right(&mut self) { let link_count = self.selected_workspace_link_count(); self.selected_link_index = next_link_selection_right(self.selected_link_index, link_count); @@ -583,10 +1543,42 @@ impl TuiState { } pub(crate) fn contextual_tool_hotkeys(&self) -> Vec<(String, String)> { - contextual_tool_hotkeys( - &self.service.config.tool, - self.selected_workspace_snapshot(), - ) + let Some(snapshot) = self.selected_workspace_snapshot() else { + return Vec::new(); + }; + let mut seen = HashSet::new(); + + if self.selected_task_id().is_some() { + if self.selected_workspace_repo_path().is_none() { + return Vec::new(); + } + return self + .service + .config + .tool + .iter() + .filter(|tool| matches!(tool.type_, ToolType::Exec)) + .filter(|tool| tool_is_usable(tool, snapshot)) + .filter_map(|tool| { + let ch = tool_key_char(tool)?; + seen.insert(ch) + .then_some((ch.to_string(), tool.name.clone())) + }) + .collect(); + } + + self.service + .config + .tool + .iter() + .filter(|tool| matches!(tool.type_, ToolType::Prompt)) + .filter(|tool| tool_is_usable(tool, snapshot)) + .filter_map(|tool| { + let ch = tool_key_char(tool)?; + seen.insert(ch) + .then_some((ch.to_string(), tool.name.clone())) + }) + .collect() } fn auto_attach_ready_key(&mut self, now: Instant) -> Option { @@ -599,10 +1591,466 @@ impl TuiState { } fn snapshot_attach_target(&self, key: &str) -> io::Result { - self.snapshots + let snapshot = self + .snapshots .get(key) - .ok_or_else(|| io::Error::other(format!("workspace snapshot missing for '{key}'"))) - .and_then(workspace_attach_target) + .ok_or_else(|| io::Error::other(format!("workspace snapshot missing for '{key}'")))?; + if self.service.agent_provider() == multicode_lib::services::AgentProvider::Codex { + let cwd = self + .attach_cwd_for_workspace(key) + .map(|path| path.to_string_lossy().into_owned()); + if let Some(target) = + working_codex_task_attach_target(snapshot, self.selected_task_id(), cwd)? + { + return Ok(target); + } + } + snapshot_attach_target_for_selection(snapshot, self.selected_task_id()) + } + + fn record_attached_session(&mut self, key: &str, target: &AttachTarget) { + let task_id = self.selected_task_id().map(str::to_string); + let fresh_codex_session = matches!(target, AttachTarget::CodexNew { .. }); + let initial_agent_state = self.snapshots.get(key).and_then(|snapshot| { + task_id + .as_deref() + .and_then(|task_id| task_runtime_snapshot(snapshot, task_id)) + .and_then(|task_state| task_effective_agent_state(Some(task_state))) + .or_else(|| { + if task_id.is_none() { + snapshot.automation_agent_state + } else { + None + } + }) + }); + let session_id = match target { + AttachTarget::Opencode { session_id, .. } => session_id.clone(), + AttachTarget::Codex { thread_id, .. } => thread_id.clone(), + AttachTarget::CodexNew { .. } => None, + }; + let initial_turn_metrics = + if self.service.agent_provider() == multicode_lib::services::AgentProvider::Codex { + if fresh_codex_session && task_id.is_some() { + Some(CodexSessionTurnMetrics::default()) + } else { + session_id.as_deref().and_then(|session_id| { + read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + key, + session_id, + ) + }) + } + } else { + None + }; + tracing::info!( + workspace_key = %key, + task_id = task_id.as_deref().unwrap_or(""), + session_id = session_id.as_deref().unwrap_or(""), + initial_agent_state = ?initial_agent_state, + initial_turn_metrics = ?initial_turn_metrics, + "recorded attached session" + ); + self.attached_session = Some(AttachedSession { + workspace_key: key.to_string(), + task_id, + session_id, + initial_agent_state, + initial_turn_metrics, + fresh_codex_session, + }); + } + + fn attach_env_for_workspace(&self, key: &str) -> Vec<(String, String)> { + if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + return Vec::new(); + } + + vec![( + "CODEX_HOME".to_string(), + self.service + .workspace_directory_path() + .join(".multicode") + .join("codex") + .join(key) + .join("home") + .to_string_lossy() + .into_owned(), + )] + } + + fn attach_cwd_for_workspace(&self, key: &str) -> Option { + let snapshot = self.snapshots.get(key)?; + let workspace_path = self.service.workspace_directory_path().join(key); + snapshot_attach_cwd_for_selection( + snapshot, + self.selected_task_id(), + &self.workspace_link_validation_results, + &workspace_path, + ) + } + + fn selected_task_can_request_pr_creation(&self) -> bool { + if self.selected_link_index.is_some() { + return false; + } + if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + return false; + } + let Some(snapshot) = self.selected_workspace_snapshot() else { + return false; + }; + let Some(task_id) = self.selected_task_id() else { + return false; + }; + workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Started + && task_persistent_snapshot(snapshot, task_id).is_some() + } + + fn selected_task_pr_link(&self) -> Option { + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + Some(task_pr_link(task, task_runtime_snapshot(snapshot, task_id))?.to_string()) + } + + fn selected_task_pr_status(&self) -> Option { + let pr_link = self.selected_task_pr_link()?; + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: pr_link, + source: WorkspaceLinkSource::Task, + }; + match self.github_link_statuses.get(&link) { + Some(GithubLinkStatusView::Pr(pr_status)) => Some(*pr_status), + _ => None, + } + } + + pub(crate) fn selected_task_can_request_ci_fix(&self) -> bool { + self.selected_task_can_request_pr_creation() + && should_offer_codex_ci_fix( + self.selected_task_pr_link().is_some(), + self.selected_task_pr_status(), + ) + && self.selected_task_ci_fix_prompt().is_some() + } + + fn selected_task_ci_fix_prompt(&self) -> Option { + let workspace_key = self.selected_workspace_key()?; + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + let assigned_repository = task_repository_spec(snapshot, task_id)?; + let cwd = self.service.workspace_task_checkout_path( + workspace_key, + &assigned_repository, + &task.issue_url, + ); + let task_state_path = self + .service + .workspace_directory_path() + .join(".multicode") + .join("automation") + .join(workspace_key) + .join("tasks") + .join(format!("{task_id}.state")); + Some(build_codex_fix_ci_prompt( + &assigned_repository, + &task.issue_url, + task_pr_link(task, task_runtime_snapshot(snapshot, task_id)) + .or(task.backing_pr_url.as_deref()), + &cwd, + &task_state_path, + task_runtime_snapshot(snapshot, task_id) + .and_then(|task_state| task_state.session_id.as_deref()), + )) + } + + fn selected_task_default_github_url(&self) -> Option { + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + Some(task_issue_link(task, task_runtime_snapshot(snapshot, task_id)).to_string()) + } + + async fn approve_selected_task_for_pr_creation(&mut self) { + if !self.selected_task_can_request_pr_creation() { + return; + } + let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let Some(task_id) = self.selected_task_id().map(str::to_string) else { + return; + }; + let Some(snapshot) = self.snapshots.get(&workspace_key).cloned() else { + return; + }; + let previous_snapshot = snapshot.clone(); + let should_restart = + should_restart_codex_task_for_ci_fix(task_runtime_snapshot(&snapshot, &task_id)); + let (progress_tx, progress_rx) = + watch::channel("Preparing PR approval request...".to_string()); + let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let workspace_key_for_task = workspace_key.clone(); + let task_id_for_task = task_id.clone(); + + self.mark_task_resuming_in_background(&workspace_key, &task_id); + tokio::spawn(async move { + let progress_message = if should_restart { + "Restarting the Codex review session before asking for PR creation..." + } else { + "Asking Codex to create or update the PR..." + }; + let _ = progress_tx.send(progress_message.to_string()); + let result = if should_restart { + service + .restart_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_CREATE_PR_APPROVAL_PROMPT, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_CREATE_PR_APPROVAL_PROMPT, + ) + .await + }; + + let result = match result { + Ok(()) => { + service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_AUTO_RESUME_PROMPT, + ) + .await + } + Err(err) => Err(err), + }; + + if let Err(err) = result { + if let Ok(workspace) = service.manager.get_workspace(&workspace_key_for_task) { + workspace.update(|next| { + let mut changed = false; + match previous_snapshot + .task_states + .get(&task_id_for_task) + .cloned() + { + Some(previous_task_state) => { + if next.task_states.get(&task_id_for_task) + != Some(&previous_task_state) + { + next.task_states + .insert(task_id_for_task.clone(), previous_task_state); + changed = true; + } + } + None => { + if next.task_states.remove(&task_id_for_task).is_some() { + changed = true; + } + } + } + if next.active_task_id != previous_snapshot.active_task_id { + next.active_task_id = previous_snapshot.active_task_id.clone(); + changed = true; + } + if next.automation_agent_state != previous_snapshot.automation_agent_state { + next.automation_agent_state = previous_snapshot.automation_agent_state; + changed = true; + } + if next.automation_session_status + != previous_snapshot.automation_session_status + { + next.automation_session_status = + previous_snapshot.automation_session_status; + changed = true; + } + if next.automation_status != previous_snapshot.automation_status { + next.automation_status = previous_snapshot.automation_status.clone(); + changed = true; + } + changed + }); + } + let _ = result_tx.send(Err(err)); + return; + } + + let _ = progress_tx.send( + "Codex accepted the PR request and is continuing in the background.".to_string(), + ); + let _ = result_tx.send(Ok(())); + }); + + self.running_operation = Some(RunningOperation { + workspace_key: workspace_key.clone(), + operation_name: format!("Approve {task_id}"), + success_status: Some(format!( + "PR request sent for '{task_id}' in workspace '{workspace_key}'; Codex is continuing in the background" + )), + progress_rx, + result_rx, + completion_action: RunningOperationCompletionAction::None, + cancel: None, + }); + self.status = format!( + "Approved local changes for '{task_id}' in workspace '{workspace_key}'; sending the PR request to Codex in the background" + ); + } + + async fn fix_selected_task_ci(&mut self) { + if !self.selected_task_can_request_ci_fix() { + self.status = "Fix CI is unavailable for the selected task".to_string(); + return; + } + let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + self.status = + "Fix CI is unavailable because the selected workspace could not be resolved" + .to_string(); + return; + }; + let Some(task_id) = self.selected_task_id().map(str::to_string) else { + self.status = + "Fix CI is unavailable because the selected task could not be resolved".to_string(); + return; + }; + let Some(prompt) = self.selected_task_ci_fix_prompt() else { + self.status = format!( + "Fix CI is unavailable for '{task_id}' in workspace '{workspace_key}' because its repository or checkout context could not be resolved" + ); + return; + }; + let Some(snapshot) = self.snapshots.get(&workspace_key).cloned() else { + self.status = format!( + "Fix CI is unavailable for '{task_id}' in workspace '{workspace_key}' because the latest workspace snapshot is missing" + ); + return; + }; + let previous_snapshot = snapshot.clone(); + let should_restart = + should_restart_codex_task_for_ci_fix(task_runtime_snapshot(&snapshot, &task_id)); + let (progress_tx, progress_rx) = watch::channel("Preparing CI fix request...".to_string()); + let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let workspace_key_for_task = workspace_key.clone(); + let task_id_for_task = task_id.clone(); + self.persist_task_resume_prompt(&workspace_key, &task_id, &prompt); + + self.mark_task_resuming_in_background(&workspace_key, &task_id); + tokio::spawn(async move { + let progress_message = if should_restart { + "Restarting the Codex task session before asking it to fix CI..." + } else { + "Asking Codex to fix CI failures and continue in the background..." + }; + let _ = progress_tx.send(progress_message.to_string()); + let result = if should_restart { + service + .restart_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + &prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + &prompt, + ) + .await + }; + + if let Err(err) = result { + if let Ok(workspace) = service.manager.get_workspace(&workspace_key_for_task) { + workspace.update(|next| { + let mut changed = false; + match previous_snapshot + .task_states + .get(&task_id_for_task) + .cloned() + { + Some(previous_task_state) => { + if next.task_states.get(&task_id_for_task) + != Some(&previous_task_state) + { + next.task_states + .insert(task_id_for_task.clone(), previous_task_state); + changed = true; + } + } + None => { + if next.task_states.remove(&task_id_for_task).is_some() { + changed = true; + } + } + } + if next.active_task_id != previous_snapshot.active_task_id { + next.active_task_id = previous_snapshot.active_task_id.clone(); + changed = true; + } + if next.automation_agent_state != previous_snapshot.automation_agent_state { + next.automation_agent_state = previous_snapshot.automation_agent_state; + changed = true; + } + if next.automation_session_status + != previous_snapshot.automation_session_status + { + next.automation_session_status = + previous_snapshot.automation_session_status; + changed = true; + } + if next.automation_status != previous_snapshot.automation_status { + next.automation_status = previous_snapshot.automation_status.clone(); + changed = true; + } + changed + }); + } + let _ = result_tx.send(Err(err)); + return; + } + + let _ = progress_tx.send( + "Codex accepted the CI fix request and is continuing in the background." + .to_string(), + ); + let _ = result_tx.send(Ok(())); + }); + + self.running_operation = Some(RunningOperation { + workspace_key: workspace_key.clone(), + operation_name: format!("Fix CI {task_id}"), + success_status: Some(format!( + "CI fix request sent for '{task_id}' in workspace '{workspace_key}'; Codex is continuing in the background" + )), + progress_rx, + result_rx, + completion_action: RunningOperationCompletionAction::None, + cancel: None, + }); + self.status = format!( + "Requested CI fixes for '{task_id}' in workspace '{workspace_key}'; sending the request to Codex in the background" + ); } pub(crate) async fn handle_key( @@ -618,52 +2066,867 @@ impl TuiState { UiMode::Normal => self.handle_normal_key(terminal, key).await, UiMode::CreateModal => self.handle_create_modal_key(key).await, UiMode::EditDescription => self.handle_edit_key(key), + UiMode::EditIssue => self.handle_issue_key(key).await, UiMode::EditCustomLink => self.handle_custom_link_key(key), + UiMode::ConfirmDelete => self.handle_confirm_delete_key(key).await, + UiMode::ConfirmTaskRemoval => self.handle_confirm_task_removal_key(key).await, UiMode::StartingModal => {} UiMode::ToolProgressModal => self.handle_tool_progress_key(key), } } - pub(crate) async fn handle_auto_attach_when_ready( - &mut self, - terminal: &mut Terminal>, - ) { - let Some(key) = self.auto_attach_ready_key(Instant::now()) else { - return; - }; + pub(crate) async fn handle_auto_attach_when_ready( + &mut self, + terminal: &mut Terminal>, + ) { + if !self.starting_attach_when_ready { + return; + } + let Some(key) = self.auto_attach_ready_key(Instant::now()) else { + return; + }; + + self.mode = UiMode::Normal; + self.starting_workspace_key = None; + self.starting_attach_when_ready = false; + self.started_wait_since = None; + + match self.snapshot_attach_target(&key) { + Ok(target) => { + self.record_attached_session(&key, &target); + let custom_description = self + .snapshots + .get(&key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + match attach_in_tmux( + terminal, + self.service.agent_command(), + &target, + self.attach_cwd_for_workspace(&key).as_deref(), + &self.attach_env_for_workspace(&key), + &key, + &custom_description, + ) + .await + { + Ok(_) => { + if !self + .retry_codex_task_attach_with_fresh_session(terminal, &key) + .await + && !self + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await + { + self.handle_attach_exit(&key).await; + } + } + Err(err) => { + tracing::warn!( + workspace_key = %key, + error = %err, + "attach session exited with error" + ); + if !self + .retry_codex_task_attach_with_fresh_session(terminal, &key) + .await + && !self + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await + && !self.handle_attach_exit_after_error(&key, &err).await + { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + } + Err(err) => { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + + async fn handle_attach_exit(&mut self, key: &str) { + tracing::info!(workspace_key = %key, "handling attach exit"); + if self.maybe_resume_autonomous_codex_after_attach(key).await { + return; + } + self.status = format!("Detached from workspace '{key}' agent session"); + } + + async fn handle_attach_exit_after_error(&mut self, key: &str, err: &io::Error) -> bool { + tracing::info!( + workspace_key = %key, + error = %err, + attached_session = ?self.attached_session, + "handling attach exit after error" + ); + if self + .attached_session + .as_ref() + .is_some_and(|attached| attached.workspace_key == key) + && self.maybe_resume_autonomous_codex_after_attach(key).await + { + return true; + } + false + } + + async fn retry_codex_task_attach_with_last_thread( + &mut self, + terminal: &mut Terminal>, + key: &str, + ) -> bool { + let attached_session = self.attached_session.clone(); + let Some(target) = should_retry_codex_task_attach_with_last_thread( + self.service.agent_provider(), + self.snapshots.get(key), + key, + attached_session + .as_ref() + .map(|attached| attached.workspace_key.as_str()), + attached_session + .as_ref() + .and_then(|attached| attached.task_id.as_deref()), + attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()), + ) else { + return false; + }; + + let previous_session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + .unwrap_or(""); + let custom_description = self + .snapshots + .get(key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + tracing::info!( + workspace_key = %key, + previous_session_id, + "retrying codex task attach with last thread after explicit thread exited" + ); + self.record_attached_session(key, &target); + match attach_in_tmux( + terminal, + self.service.agent_command(), + &target, + self.attach_cwd_for_workspace(key).as_deref(), + &self.attach_env_for_workspace(key), + key, + &custom_description, + ) + .await + { + Ok(_) => { + self.handle_attach_exit(key).await; + } + Err(err) => { + tracing::warn!( + workspace_key = %key, + error = %err, + "codex retry attach session exited with error" + ); + if !self.handle_attach_exit_after_error(key, &err).await { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + true + } + + async fn retry_codex_task_attach_with_fresh_session( + &mut self, + terminal: &mut Terminal>, + key: &str, + ) -> bool { + let attached_session = self.attached_session.clone(); + let session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()); + let current_turn_metrics = session_id.and_then(|session_id| { + read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + key, + session_id, + ) + }); + let current_thread_status = match ( + self.snapshots + .get(key) + .and_then(|snapshot| snapshot.transient.as_ref()), + session_id, + ) { + (Some(transient), Some(session_id)) => CodexAppServerClient::new(transient.uri.clone()) + .thread_read(session_id) + .await + .ok() + .and_then(|response| response.thread.status), + _ => None, + }; + let should_start_fresh = should_start_fresh_codex_task_session_after_failed_attach( + self.service.agent_provider(), + attached_session + .as_ref() + .map(|attached| attached.workspace_key.as_str()), + key, + attached_session + .as_ref() + .and_then(|attached| attached.task_id.as_deref()), + session_id, + attached_session + .as_ref() + .and_then(|attached| attached.initial_turn_metrics), + current_turn_metrics, + current_thread_status.as_ref(), + ); + tracing::info!( + workspace_key = %key, + session_id = session_id.unwrap_or(""), + initial_turn_metrics = ?attached_session + .as_ref() + .and_then(|attached| attached.initial_turn_metrics), + current_turn_metrics = ?current_turn_metrics, + current_thread_status = ?current_thread_status, + should_start_fresh, + "evaluated fresh codex task session fallback after failed attach" + ); + if !should_start_fresh { + return false; + } + + let Some(snapshot) = self.snapshots.get(key) else { + return false; + }; + let Ok(Some(uri)) = codex_attach_uri(snapshot) else { + return false; + }; + let cwd = self + .attach_cwd_for_workspace(key) + .map(|path| path.to_string_lossy().into_owned()); + let prompt = if let Some(session_id) = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + { + read_interrupted_codex_resume_prompt( + self.service.workspace_directory_path().to_path_buf(), + key.to_string(), + session_id.to_string(), + ) + .await + .or_else(|| Some("Continue work on this task.".to_string())) + } else { + Some("Continue work on this task.".to_string()) + }; + let target = AttachTarget::CodexNew { uri, cwd, prompt }; + let custom_description = self + .snapshots + .get(key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + tracing::info!( + workspace_key = %key, + previous_session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + .unwrap_or(""), + "starting fresh codex task session after failed resume attach" + ); + self.record_attached_session(key, &target); + match attach_in_tmux( + terminal, + self.service.agent_command(), + &target, + self.attach_cwd_for_workspace(key).as_deref(), + &self.attach_env_for_workspace(key), + key, + &custom_description, + ) + .await + { + Ok(_) => { + self.handle_attach_exit(key).await; + } + Err(err) => { + tracing::warn!( + workspace_key = %key, + error = %err, + "fresh codex attach session exited with error" + ); + if !self.handle_attach_exit_after_error(key, &err).await { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + true + } + + fn mark_task_resuming_in_background(&mut self, workspace_key: &str, task_id: &str) { + let Ok(workspace) = self.service.manager.get_workspace(workspace_key) else { + return; + }; + workspace.update(|snapshot| { + let task_state = snapshot.task_states.entry(task_id.to_string()).or_default(); + let mut changed = false; + if task_state.agent_state != Some(AutomationAgentState::Working) { + task_state.agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if task_state.session_status != Some(RootSessionStatus::Busy) { + task_state.session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let task_status = Some("Resuming in background".to_string()); + if task_state.status != task_status { + task_state.status = task_status; + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + if snapshot.active_task_id.as_deref() != Some(task_id) { + snapshot.active_task_id = Some(task_id.to_string()); + changed = true; + } + if snapshot.automation_agent_state != Some(AutomationAgentState::Working) { + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if snapshot.automation_session_status != Some(RootSessionStatus::Busy) { + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let automation_status = Some(format!("Resuming {task_id} in background")); + if snapshot.automation_status != automation_status { + snapshot.automation_status = automation_status; + changed = true; + } + changed + }); + } + + fn mark_task_waiting_on_vm_after_attach(&mut self, workspace_key: &str, task_id: &str) { + let Ok(workspace) = self.service.manager.get_workspace(workspace_key) else { + return; + }; + workspace.update(|snapshot| { + let task_state = snapshot.task_states.entry(task_id.to_string()).or_default(); + let mut changed = false; + if task_state.agent_state != Some(AutomationAgentState::Working) { + task_state.agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if task_state.session_status != Some(RootSessionStatus::Busy) { + task_state.session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let task_status = Some("Queued until VM is free".to_string()); + if task_state.status != task_status { + task_state.status = task_status; + changed = true; + } + if !task_state.waiting_on_vm { + task_state.waiting_on_vm = true; + changed = true; + } + changed + }); + } + + fn persist_task_resume_prompt(&self, workspace_key: &str, task_id: &str, resume_prompt: &str) { + let Ok(workspace) = self.service.manager.get_workspace(workspace_key) else { + return; + }; + let resume_prompt = resume_prompt.trim().to_string(); + if resume_prompt.is_empty() { + return; + } + workspace.update(|snapshot| { + let task_state = snapshot.task_states.entry(task_id.to_string()).or_default(); + if task_state.resume_prompt.as_deref() == Some(resume_prompt.as_str()) { + false + } else { + task_state.resume_prompt = Some(resume_prompt.clone()); + true + } + }); + } + + fn queue_task_codex_resume_until_vm_available( + &self, + workspace_key: String, + task_id: String, + attached_session: AttachedSession, + resume_prompt: Option, + ) { + let service = self.service.clone(); + tokio::spawn(async move { + loop { + let Ok(workspace) = service.manager.get_workspace(&workspace_key) else { + return; + }; + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = task_runtime_snapshot(&snapshot, &task_id); + let should_resume = should_auto_resume_task_codex_after_attach( + task_state, + attached_session.session_id.as_deref(), + attached_session.initial_agent_state, + ) || resume_prompt.is_some(); + if !should_resume { + return; + } + if should_queue_task_codex_resume_until_vm_available(&snapshot, &task_id) { + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + + workspace.update(|snapshot| { + let task_state = snapshot.task_states.entry(task_id.clone()).or_default(); + let mut changed = false; + if task_state.agent_state != Some(AutomationAgentState::Working) { + task_state.agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if task_state.session_status != Some(RootSessionStatus::Busy) { + task_state.session_status = Some(RootSessionStatus::Busy); + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + let task_status = Some("Resuming in background".to_string()); + if task_state.status != task_status { + task_state.status = task_status; + changed = true; + } + if snapshot.active_task_id.as_deref() != Some(task_id.as_str()) { + snapshot.active_task_id = Some(task_id.clone()); + changed = true; + } + if snapshot.automation_agent_state != Some(AutomationAgentState::Working) { + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if snapshot.automation_session_status != Some(RootSessionStatus::Busy) { + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let automation_status = Some(format!("Resuming {task_id} in background")); + if snapshot.automation_status != automation_status { + snapshot.automation_status = automation_status; + changed = true; + } + changed + }); + + let resume_result = if let Some(resume_prompt) = resume_prompt.clone() { + if !resume_prompt.trim().is_empty() { + let Ok(workspace) = service.manager.get_workspace(&workspace_key) else { + return; + }; + workspace.update(|snapshot| { + let task_state = + snapshot.task_states.entry(task_id.clone()).or_default(); + if task_state.resume_prompt.as_deref() == Some(resume_prompt.as_str()) { + false + } else { + task_state.resume_prompt = Some(resume_prompt.clone()); + true + } + }); + } + if should_restart_codex_task_for_pr_request(task_runtime_snapshot( + &snapshot, &task_id, + )) { + service + .restart_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } + } else if let Some(session_id) = attached_session.session_id.clone() { + let resume_prompt = read_last_codex_session_user_message( + service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id.clone(), + ) + .await + .unwrap_or_else(|| CODEX_AUTO_RESUME_PROMPT.to_string()); + if !resume_prompt.trim().is_empty() { + let Ok(workspace) = service.manager.get_workspace(&workspace_key) else { + return; + }; + workspace.update(|snapshot| { + let task_state = + snapshot.task_states.entry(task_id.clone()).or_default(); + if task_state.resume_prompt.as_deref() == Some(resume_prompt.as_str()) { + false + } else { + task_state.resume_prompt = Some(resume_prompt.clone()); + true + } + }); + } + if should_restart_task_codex_after_attach( + Some(session_id.as_str()), + attached_session.fresh_codex_session, + ) { + service + .restart_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot, + &task_id, + CODEX_AUTO_RESUME_PROMPT, + ) + .await + }; + tracing::info!( + workspace_key = %workspace_key, + task_id = %task_id, + resume_result = ?resume_result, + "finished deferred codex auto-resume attempt after attach" + ); + return; + } + }); + } + + async fn maybe_resume_autonomous_codex_after_attach(&mut self, key: &str) -> bool { + if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + tracing::info!( + workspace_key = %key, + provider = ?self.service.agent_provider(), + "skipping codex auto-resume because agent provider is not Codex" + ); + self.attached_session = None; + return false; + } - self.mode = UiMode::Normal; - self.starting_workspace_key = None; - self.started_wait_since = None; + let attached_session = self.attached_session.clone(); + tracing::info!( + workspace_key = %key, + attached_session = ?attached_session, + "evaluating codex auto-resume after attach" + ); + for _ in 0..8 { + self.sync_from_manager(); + let snapshot = self.snapshots.get(key).cloned(); + let Some(snapshot) = snapshot else { + tracing::info!( + workspace_key = %key, + "workspace disappeared while evaluating codex auto-resume after attach" + ); + self.attached_session = None; + return false; + }; - match self.snapshot_attach_target(&key) { - Ok(target) => { - let custom_description = self - .snapshots - .get(&key) - .map(|snapshot| snapshot.persistent.description.clone()) - .unwrap_or_default(); - match attach_in_tmux( - terminal, - self.service.opencode_command(), - &target, - &key, - &custom_description, - ) - .await - { - Ok(_) => { - self.status = format!("Detached from workspace '{key}' opencode client"); + let effective_task_session_id = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + session_id, + fresh_codex_session, + .. + }) if workspace_key == key => session_id.clone().or_else(|| { + if *fresh_codex_session { + task_runtime_snapshot(&snapshot, task_id) + .and_then(|task_state| task_state.session_id.clone()) + } else { + None } - Err(err) => { - self.status = format!("Failed to attach to workspace '{key}': {err}"); + }), + _ => None, + }; + let resume_attached_session = attached_session.clone().map(|mut attached| { + if attached.fresh_codex_session && attached.session_id.is_none() { + attached.session_id = effective_task_session_id.clone(); + } + attached + }); + + let interrupted_resume_prompt = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(_), + initial_turn_metrics, + .. + }) if workspace_key == key => { + let should_resume_interrupted = self + .should_resume_interrupted_task_codex_after_attach( + &snapshot, + workspace_key, + effective_task_session_id.as_deref(), + *initial_turn_metrics, + ) + .await; + if should_resume_interrupted { + if let Some(session_id) = effective_task_session_id.clone() { + read_interrupted_codex_resume_prompt( + self.service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id, + ) + .await + } else { + None + } + } else { + None } } + _ => None, + }; + + let should_resume = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + initial_agent_state, + .. + }) if workspace_key == key => { + should_auto_resume_task_codex_after_attach( + task_runtime_snapshot(&snapshot, task_id), + effective_task_session_id.as_deref(), + *initial_agent_state, + ) || interrupted_resume_prompt.is_some() + } + _ => should_auto_resume_autonomous_codex_after_attach(&snapshot), + }; + tracing::info!( + workspace_key = %key, + should_resume, + automation_agent_state = ?snapshot.automation_agent_state, + automation_session_status = ?snapshot.automation_session_status, + root_session_status = ?snapshot.root_session_status, + "evaluated codex auto-resume after attach" + ); + + if should_resume { + if let Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + .. + }) = attached_session.as_ref() + && workspace_key == key + { + if should_queue_task_codex_resume_until_vm_available(&snapshot, task_id) { + if let Some(resume_prompt) = interrupted_resume_prompt.as_deref() { + self.persist_task_resume_prompt(key, task_id, resume_prompt); + } + self.mark_task_waiting_on_vm_after_attach(key, task_id); + self.queue_task_codex_resume_until_vm_available( + key.to_string(), + task_id.clone(), + resume_attached_session + .clone() + .expect("attached task session should exist"), + interrupted_resume_prompt.clone(), + ); + self.status = format!( + "Detached from workspace '{key}'; task {task_id} is queued until the VM is free" + ); + self.attached_session = None; + return true; + } + self.mark_task_resuming_in_background(key, task_id); + self.sync_from_manager(); + } + let service = self.service.clone(); + let workspace_key = key.to_string(); + let snapshot_for_resume = snapshot.clone(); + let attached_session_for_resume = resume_attached_session.clone(); + tokio::spawn(async move { + let resume_result = match attached_session_for_resume.as_ref() { + Some(AttachedSession { + workspace_key: attached_workspace_key, + task_id: Some(task_id), + session_id: Some(session_id), + fresh_codex_session, + .. + }) if attached_workspace_key == &workspace_key => { + if let Some(resume_prompt) = interrupted_resume_prompt.clone() { + if !resume_prompt.trim().is_empty() { + let Ok(workspace) = + service.manager.get_workspace(&workspace_key) + else { + return; + }; + workspace.update(|snapshot| { + let task_state = snapshot + .task_states + .entry(task_id.clone()) + .or_default(); + if task_state.resume_prompt.as_deref() + == Some(resume_prompt.as_str()) + { + false + } else { + task_state.resume_prompt = Some(resume_prompt.clone()); + true + } + }); + } + service + .restart_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } else { + let resume_prompt = read_last_codex_session_user_message( + service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id.clone(), + ) + .await + .unwrap_or_else(|| CODEX_AUTO_RESUME_PROMPT.to_string()); + if !resume_prompt.trim().is_empty() { + let Ok(workspace) = + service.manager.get_workspace(&workspace_key) + else { + return; + }; + workspace.update(|snapshot| { + let task_state = snapshot + .task_states + .entry(task_id.clone()) + .or_default(); + if task_state.resume_prompt.as_deref() + == Some(resume_prompt.as_str()) + { + false + } else { + task_state.resume_prompt = Some(resume_prompt.clone()); + true + } + }); + } + if should_restart_task_codex_after_attach( + Some(session_id.as_str()), + *fresh_codex_session, + ) { + service + .restart_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } + } + } + _ => { + service + .prompt_root_session(&snapshot_for_resume, CODEX_AUTO_RESUME_PROMPT) + .await + } + }; + tracing::info!( + workspace_key = %workspace_key, + resume_result = ?resume_result, + "finished codex auto-resume attempt after attach" + ); + }); + self.status = format!( + "Detached from workspace '{key}'; autonomous Codex resume was scheduled in the background" + ); + self.attached_session = None; + return true; } - Err(err) => { - self.status = format!("Failed to attach to workspace '{key}': {err}"); - } + + tokio::time::sleep(Duration::from_millis(100)).await; } + + self.attached_session = None; + false + } + + async fn should_resume_interrupted_task_codex_after_attach( + &self, + snapshot: &WorkspaceSnapshot, + workspace_key: &str, + session_id: Option<&str>, + initial_turn_metrics: Option, + ) -> bool { + let Some(session_id) = session_id else { + return false; + }; + let current_turn_metrics = read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + workspace_key, + session_id, + ); + let current_thread_status = match snapshot.transient.as_ref() { + Some(transient) => CodexAppServerClient::new(transient.uri.clone()) + .thread_read(session_id) + .await + .ok() + .and_then(|response| response.thread.status), + None => None, + }; + let should_resume = should_resume_codex_task_after_incomplete_attached_turn( + initial_turn_metrics, + current_turn_metrics, + current_thread_status.as_ref(), + ); + tracing::info!( + workspace_key = %workspace_key, + session_id, + initial_turn_metrics = ?initial_turn_metrics, + current_turn_metrics = ?current_turn_metrics, + current_thread_status = ?current_thread_status, + should_resume, + "evaluated interrupted codex task resume after attach" + ); + should_resume } pub(crate) fn poll_running_prompt_tool(&mut self) { @@ -681,15 +2944,39 @@ impl TuiState { if let Some(result) = completion && let Some(running_tool) = self.running_operation.take() { - self.mode = UiMode::Normal; match result { - Ok(()) => { - self.status = format!( - "{} completed for workspace '{}'", - running_tool.operation_name, running_tool.workspace_key - ); - } + Ok(()) => match running_tool.completion_action { + RunningOperationCompletionAction::None => { + self.mode = UiMode::Normal; + self.status = running_tool.success_status.unwrap_or_else(|| { + format!( + "{} completed for workspace '{}'", + running_tool.operation_name, running_tool.workspace_key + ) + }); + } + RunningOperationCompletionAction::WaitForWorkspaceStart { + attach_when_ready, + } => { + self.mode = UiMode::StartingModal; + self.starting_workspace_key = Some(running_tool.workspace_key.clone()); + self.starting_attach_when_ready = attach_when_ready; + self.started_wait_since = None; + self.status = if attach_when_ready { + format!( + "Starting workspace '{}' before attaching", + running_tool.workspace_key + ) + } else { + format!( + "Waiting for workspace '{}' to become ready", + running_tool.workspace_key + ) + }; + } + }, Err(err) => { + self.mode = UiMode::Normal; self.status = format!( "{} failed for workspace '{}': {err}", running_tool.operation_name, running_tool.workspace_key @@ -708,10 +2995,26 @@ impl TuiState { }) } + pub(crate) fn running_operation_is_cancellable(&self) -> bool { + self.running_operation + .as_ref() + .and_then(|operation| operation.cancel.as_ref()) + .is_some() + } + fn handle_tool_progress_key(&mut self, key: KeyEvent) { if key.code != KeyCode::Esc { return; } + if !self.running_operation_is_cancellable() { + if let Some(operation) = self.running_operation.as_ref() { + self.status = format!( + "{} is still running for workspace '{}'", + operation.operation_name, operation.workspace_key + ); + } + return; + } let Some(mut operation) = self.running_operation.take() else { self.mode = UiMode::Normal; return; @@ -740,8 +3043,14 @@ impl TuiState { let Some(tool) = find_tool_for_key(&self.service.config.tool, key_char) else { return; }; + let selected_repo_path = self.selected_workspace_repo_path(); + let selected_task = self.selected_task_id().is_some(); - if !tool_is_usable(&tool, &snapshot) { + if !tool_is_usable(&tool, &snapshot) + || (selected_task && !matches!(tool.type_, ToolType::Exec)) + || (selected_task && selected_repo_path.is_none()) + || (!selected_task && !matches!(tool.type_, ToolType::Prompt)) + { self.status = format!( "Tool '{}' is unavailable for workspace '{}' in its current state", tool.name, workspace_key @@ -755,8 +3064,14 @@ impl TuiState { self.status = format!("Tool '{}' is missing its exec command", tool.name); return; }; - self.run_exec_tool(terminal, &workspace_key, &tool.name, exec_command) - .await; + self.run_exec_tool( + terminal, + &workspace_key, + &tool.name, + exec_command, + selected_repo_path.as_deref(), + ) + .await; } ToolType::Prompt => { let Some(prompt) = tool.prompt.as_deref().map(str::trim) else { @@ -775,62 +3090,192 @@ impl TuiState { workspace_key: &str, tool_name: &str, exec_command: &str, + repo_path: Option<&Path>, ) { - let exec_command = match self - .service - .build_exec_tool_command(workspace_key, exec_command) + let result = if let Some(repo_path) = repo_path { + self.run_repo_shell_command_in_pty(terminal, workspace_key, repo_path, exec_command) + .await + } else { + let exec_command = match self + .service + .build_exec_tool_command(workspace_key, exec_command) + .await + { + Ok(command) => command, + Err(err) => { + self.status = format!("Failed to prepare exec tool '{}': {err:?}", tool_name); + return; + } + }; + + let inherited_env = exec_command.inherited_env; + let tmux_command = std::iter::once(exec_command.program) + .chain(exec_command.args) + .collect::>(); + let custom_description = self + .snapshots + .get(workspace_key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + tracing::info!( + workspace_key = workspace_key, + tool_name = tool_name, + command = %format_command_line("tmux", &{ + let mut debug_command = vec![ + "new-session".to_string(), + "-d".to_string(), + "-s".to_string(), + "".to_string(), + "env".to_string(), + ]; + debug_command.extend( + inherited_env + .iter() + .map(|(name, value)| format!("{name}={value}")), + ); + debug_command.extend(tmux_command.clone()); + debug_command + }), + "launching exec tool tmux command" + ); + run_tmux_new_session_command( + terminal, + &inherited_env, + tmux_command, + None, + workspace_key, + &custom_description, + ) .await - { - Ok(command) => command, - Err(err) => { - self.status = format!("Failed to prepare exec tool '{}': {err:?}", tool_name); - return; - } }; - let mut tmux_command = vec!["systemd-run".to_string()]; - let inherited_env = exec_command.inherited_env; - tmux_command.extend(exec_command.args); + self.status = match result { + Ok(()) => format!( + "Tool '{}' finished for workspace '{}'", + tool_name, workspace_key + ), + Err(err) => format!("Failed to run tool '{}': {err}", tool_name), + }; + } + + async fn run_repo_shell_command_in_pty( + &mut self, + terminal: &mut Terminal>, + workspace_key: &str, + repo_path: &Path, + shell_command: &str, + ) -> io::Result<()> { + let repo_dir = repo_path.to_string_lossy().into_owned(); + let command = self + .service + .build_pty_tool_command( + workspace_key, + vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + shell_command_in_repo(&repo_dir, shell_command), + ], + ) + .await + .map_err(|err| io::Error::other(format!("failed to prepare PTY command: {err:?}")))?; + + let inherited_env = command.inherited_env; + let tmux_command = std::iter::once(command.program) + .chain(command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) .map(|snapshot| snapshot.persistent.description.clone()) .unwrap_or_default(); - tracing::info!( - workspace_key = workspace_key, - tool_name = tool_name, - command = %format_command_line("tmux", &{ - let mut debug_command = vec![ - "new-session".to_string(), - "-d".to_string(), - "-s".to_string(), - "".to_string(), - "env".to_string(), - ]; - debug_command.extend( - inherited_env - .iter() - .map(|(name, value)| format!("{name}={value}")), - ); - debug_command.extend(tmux_command.clone()); - debug_command - }), - "launching exec tool tmux command" - ); - self.status = match run_tmux_new_session_command( + run_tmux_new_session_command( terminal, &inherited_env, tmux_command, + None, workspace_key, &custom_description, ) .await + } + + async fn open_selected_workspace_in_editor(&mut self) { + if let Some(key) = self.selected_workspace_key().map(str::to_string) { + let Some(snapshot) = self.snapshots.get(&key) else { + return; + }; + if !workspace_is_usable(snapshot) { + self.status = format!("Workspace '{key}' is archived and cannot be opened"); + return; + } + let Some(repo_path) = self.selected_workspace_repo_path() else { + self.status = format!("Workspace '{key}' does not have a repository to edit"); + return; + }; + + let compare_tool_name = compare_tool_name(self.service.config.compare.tool); + match compare_open_repo_command(&self.service.config.compare, &repo_path) { + Ok((program, args)) => { + tracing::info!( + command = %format_command_line(&program, &args), + workspace = %key, + repo = %repo_path.display(), + compare_tool = compare_tool_name, + "opening workspace repository in editor" + ); + let mut command = Command::new(&program); + match command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(_) => { + self.status = + format!("Opened workspace '{key}' in {compare_tool_name}"); + } + Err(err) => { + self.status = format!( + "Failed to open workspace '{key}' in {compare_tool_name}: {err}" + ); + } + } + } + Err(err) => { + self.status = + format!("Failed to open workspace '{key}' in {compare_tool_name}: {err}"); + } + } + } + } + + async fn open_selected_workspace_diff_in_terminal( + &mut self, + terminal: &mut Terminal>, + ) { + let Some(key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let Some(snapshot) = self.snapshots.get(&key) else { + return; + }; + if !workspace_is_usable(snapshot) { + self.status = format!("Workspace '{key}' is archived and cannot be compared"); + return; + } + let Some(repo_path) = self.selected_workspace_repo_path() else { + self.status = format!("Workspace '{key}' does not have a repository to compare"); + return; + }; + + let diff_command = repository_diff_shell_command(); + self.status = match self + .run_repo_shell_command_in_pty(terminal, &key, &repo_path, diff_command) + .await { - Ok(()) => format!( - "Tool '{}' finished for workspace '{}'", - tool_name, workspace_key - ), - Err(err) => format!("Failed to run tool '{}': {err}", tool_name), + Ok(()) => format!("Compared workspace '{key}'"), + Err(err) => format!("Failed to compare workspace '{key}': {err}"), }; } @@ -861,9 +3306,10 @@ impl TuiState { io::Error::other(format!("failed to prepare PTY review handler: {err:?}")) })?; - let mut tmux_command = vec!["systemd-run".to_string()]; let inherited_env = command.inherited_env; - tmux_command.extend(command.args); + let tmux_command = std::iter::once(command.program) + .chain(command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) @@ -873,6 +3319,7 @@ impl TuiState { terminal, &inherited_env, tmux_command, + None, workspace_key, &custom_description, ) @@ -886,16 +3333,6 @@ impl TuiState { tool_name: &str, prompt: &str, ) { - let Some(opencode_client) = snapshot.opencode_client.as_ref() else { - self.status = format!( - "Tool '{}' requires a started workspace with a healthy client", - tool_name - ); - return; - }; - - let client = opencode_client.client.clone(); - let events = opencode_client.events.clone(); let Some(root_session_id) = snapshot.root_session_id.clone() else { self.status = format!( "Tool '{}' requires a known root session ID for workspace '{}'", @@ -910,19 +3347,43 @@ impl TuiState { let (progress_tx, progress_rx) = watch::channel(format!("Preparing tool '{}'...", tool_name_owned)); let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let snapshot = snapshot.clone(); + let task_tool_name = tool_name_owned.clone(); tokio::spawn(async move { let result = - run_prompt_tool_workflow(client, events, root_session_id, prompt_text, progress_tx) - .await; + if service.agent_provider() == multicode_lib::services::AgentProvider::Opencode { + let Some(opencode_client) = snapshot.opencode_client.as_ref() else { + let _ = result_tx + .send(Err("workspace has no healthy opencode client".to_string())); + return; + }; + run_prompt_tool_workflow( + opencode_client.client.clone(), + opencode_client.events.clone(), + root_session_id, + prompt_text, + progress_tx, + ) + .await + } else { + let _ = progress_tx.send(format!("Starting tool '{}'...", task_tool_name)); + service + .prompt_root_session(&snapshot, &prompt_text) + .await + .map_err(|err| err.to_string()) + }; let _ = result_tx.send(result); }); self.running_operation = Some(RunningOperation { workspace_key: workspace_key_owned, operation_name: tool_name_owned.clone(), + success_status: None, progress_rx, result_rx, + completion_action: RunningOperationCompletionAction::None, cancel: None, }); self.mode = UiMode::ToolProgressModal; @@ -1007,9 +3468,80 @@ impl TuiState { } Err(err) => { self.status = format!( - "Failed to open {} link for workspace '{}': {err}", - link.label(), - workspace_key + "Failed to open {} link for workspace '{}': {err}", + link.label(), + workspace_key + ); + } + } + } + + async fn open_selected_github_target(&mut self) { + let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let Some(snapshot) = self.snapshots.get(&workspace_key) else { + return; + }; + if snapshot.persistent.archived { + self.status = format!( + "GitHub links for workspace '{}' are unavailable while archived", + workspace_key + ); + return; + } + + let (target_description, url) = if let Some(link) = self.selected_workspace_link() { + if link.value.is_empty() + || !matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + { + return; + } + let targets = self.selected_workspace_link_targets(); + let Some((_, argument)) = targets.get(self.selected_link_target_index) else { + self.status = format!( + "{} link for workspace '{}' is still validating or invalid", + link.label(), + workspace_key + ); + return; + }; + ("GitHub link", argument.clone()) + } else if let Some(url) = self.selected_task_default_github_url() { + ("GitHub issue", url) + } else { + let Some(url) = self.selected_workspace_github_repository_url() else { + return; + }; + ("GitHub repository", url) + }; + + let (program, args) = match build_handler_command( + &self.service.config.handler.web, + multicode_lib::HandlerArgumentMode::Argument, + &url, + ) { + Ok(command) => command, + Err(err) => { + self.status = format!( + "Invalid web handler configuration for workspace '{}': {err}", + workspace_key + ); + return; + } + }; + + let result = + dispatch_web_handler_action(self.relay_socket.as_deref(), &program, &args, &url).await; + + match result { + Ok(()) => { + self.status = + format!("Opened {target_description} for workspace '{workspace_key}'"); + } + Err(err) => { + self.status = format!( + "Failed to open {target_description} for workspace '{workspace_key}': {err}" ); } } @@ -1021,23 +3553,11 @@ impl TuiState { key: KeyEvent, ) { let link_selected = self.selected_link_index.is_some(); - let control_held = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Char('q') => self.should_quit = true, KeyCode::Up => { if link_selected { self.move_selected_link_target_up(); - } else if control_held { - if let Some(row) = next_non_stopped_row( - self.selected_row, - &self.ordered_keys, - &self.snapshots, - -1, - ) { - self.selected_row = row; - self.selected_link_index = None; - self.selected_link_target_index = 0; - } } else if self.selected_row > 0 { self.selected_row -= 1; self.selected_link_index = None; @@ -1047,18 +3567,7 @@ impl TuiState { KeyCode::Down => { if link_selected { self.move_selected_link_target_down(); - } else if control_held { - if let Some(row) = next_non_stopped_row( - self.selected_row, - &self.ordered_keys, - &self.snapshots, - 1, - ) { - self.selected_row = row; - self.selected_link_index = None; - self.selected_link_target_index = 0; - } - } else if self.selected_row < self.ordered_keys.len() { + } else if self.selected_row + 1 < self.table_entries().len() { self.selected_row += 1; self.selected_link_index = None; self.selected_link_target_index = 0; @@ -1085,6 +3594,8 @@ impl TuiState { } KeyCode::Enter if self.selected_row == 0 => { self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; self.mode = UiMode::CreateModal; } KeyCode::Enter => { @@ -1111,6 +3622,7 @@ impl TuiState { } match self.snapshot_attach_target(&key) { Ok(target) => { + self.record_attached_session(&key, &target); let custom_description = self .snapshots .get(&key) @@ -1118,22 +3630,54 @@ impl TuiState { .unwrap_or_default(); match attach_in_tmux( terminal, - self.service.opencode_command(), + self.service.agent_command(), &target, + self.attach_cwd_for_workspace(&key).as_deref(), + &self.attach_env_for_workspace(&key), &key, &custom_description, ) .await { Ok(_) => { - self.status = format!( - "Detached from workspace '{key}' opencode client" - ) + if !self + .retry_codex_task_attach_with_fresh_session( + terminal, &key, + ) + .await + && !self + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await + { + self.handle_attach_exit(&key).await; + } } Err(err) => { - self.status = format!( - "Failed to attach to workspace '{key}': {err}" - ) + tracing::warn!( + workspace_key = %key, + error = %err, + "attach session exited with error" + ); + if !self + .retry_codex_task_attach_with_fresh_session( + terminal, &key, + ) + .await + && !self + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await + && !self + .handle_attach_exit_after_error(&key, &err) + .await + { + self.status = format!( + "Failed to attach to workspace '{key}': {err}" + ); + } } } } @@ -1158,20 +3702,7 @@ impl TuiState { format!("Workspace '{key}' is archived and cannot be started"); return; } - match self.service.start_workspace(&key).await { - Ok(_) => { - self.mode = UiMode::StartingModal; - self.starting_workspace_key = Some(key.clone()); - self.started_wait_since = None; - self.status = - format!("Starting workspace '{key}' before attaching"); - } - Err(err) => { - self.status = format!( - "Failed to start workspace '{key}' before attaching: {err:?}" - ); - } - } + self.start_workspace_operation(key.clone(), true); } None => {} } @@ -1184,6 +3715,10 @@ impl TuiState { } return; } + if self.selected_task_id().is_some() { + self.approve_selected_task_for_pr_creation().await; + return; + } if let Some(key) = self.selected_workspace_key().map(str::to_string) { let archived = self .snapshots @@ -1212,14 +3747,48 @@ impl TuiState { self.running_operation = Some(RunningOperation { workspace_key: key.clone(), operation_name: operation_name.to_string(), + success_status: None, progress_rx, result_rx, + completion_action: RunningOperationCompletionAction::None, cancel: Some(join_handle.abort_handle()), }); self.mode = UiMode::ToolProgressModal; self.status = format!("{} workspace '{}'", operation_name, key); } } + KeyCode::Char('f') => { + if link_selected { + return; + } + if self.selected_task_id().is_none() { + return; + } + if !self.selected_task_can_request_ci_fix() { + self.status = "Fix CI is unavailable for the selected task".to_string(); + return; + } + self.fix_selected_task_ci().await; + } + KeyCode::Char('c') => { + if link_selected { + return; + } + if self.selected_task_id().is_none() { + return; + } + self.open_selected_workspace_diff_in_terminal(terminal) + .await; + } + KeyCode::Char('e') => { + if link_selected { + return; + } + if self.selected_task_id().is_none() { + return; + } + self.open_selected_workspace_in_editor().await; + } KeyCode::Char('d') => { if link_selected { if let Some(link) = self @@ -1230,6 +3799,9 @@ impl TuiState { } return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key() { let current = self .snapshots @@ -1240,10 +3812,46 @@ impl TuiState { self.mode = UiMode::EditDescription; } } + KeyCode::Char('i') => { + if link_selected { + return; + } + if self.selected_task_id().is_some() { + return; + } + if let Some(key) = self.selected_workspace_key() { + let Some(snapshot) = self.snapshots.get(key) else { + return; + }; + if !workspace_is_usable(snapshot) { + return; + } + if snapshot.persistent.assigned_repository.is_none() { + return; + } + self.issue_input.clear(); + self.mode = UiMode::EditIssue; + } + } + KeyCode::Char('n') => { + if link_selected { + return; + } + if self.selected_task_id().is_some() { + return; + } + self.request_selected_workspace_queue_next_issue(); + } + KeyCode::Char('o') => { + self.open_selected_github_target().await; + } KeyCode::Char('s') => { if link_selected { return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key().map(str::to_string) { let state = self.snapshots.get(&key).map(workspace_state); match state { @@ -1257,31 +3865,90 @@ impl TuiState { format!("Workspace '{key}' is archived and cannot be started"); return; } - match self.service.start_workspace(&key).await { - Ok(_) => self.status = format!("Starting workspace '{key}'"), - Err(err) => { - self.status = format!("Failed to start workspace: {err:?}") - } - } + self.start_workspace_operation(key, false); } Some(WorkspaceUiState::Starting) | Some(WorkspaceUiState::Started) => { - match self.service.stop_workspace(&key).await { - Ok(_) => self.status = format!("Stopped workspace '{key}'"), - Err(err) => { - self.status = format!("Failed to stop workspace: {err:?}") - } - } + self.start_stop_workspace_operation(key); } None => {} } } } + KeyCode::Char('p') => { + if link_selected { + return; + } + if self.selected_task_id().is_some() { + return; + } + if let Some(key) = self.selected_workspace_key().map(str::to_string) { + let Some(snapshot) = self.snapshots.get(&key).cloned() else { + return; + }; + if !workspace_supports_pause(&snapshot) { + return; + } + if snapshot.persistent.automation_paused { + match self.service.resume_workspace(&key) { + Ok(()) => { + self.status = + format!("Resumed autonomous work for workspace '{key}'") + } + Err(err) => { + self.status = format!( + "Failed to resume autonomous work for workspace '{key}': {err:?}" + ) + } + } + } else { + match self.service.pause_workspace(&key).await { + Ok(()) => { + self.status = + format!("Paused autonomous work for workspace '{key}'") + } + Err(err) => { + self.status = format!( + "Failed to pause autonomous work for workspace '{key}': {err:?}" + ) + } + } + } + } + } KeyCode::Char('r') => { if link_selected { return; } + if self.selected_task_id().is_some() { + return; + } self.request_selected_workspace_github_status_refresh(); } + KeyCode::Char('x') => { + if link_selected { + return; + } + match self.selected_entry() { + Some(TableEntry::Workspace { workspace_key }) => { + self.pending_delete_target = + Some(PendingDeleteTarget::Workspace { workspace_key }); + self.pending_task_removal_action = TaskRemovalAction::default(); + self.mode = UiMode::ConfirmDelete; + } + Some(TableEntry::Task { + workspace_key, + task_id, + }) => { + self.pending_delete_target = Some(PendingDeleteTarget::Task { + workspace_key, + task_id, + }); + self.pending_task_removal_action = TaskRemovalAction::default(); + self.mode = UiMode::ConfirmTaskRemoval; + } + _ => {} + } + } KeyCode::Char(ch) => { if link_selected { return; @@ -1297,25 +3964,48 @@ impl TuiState { KeyCode::Esc => { self.mode = UiMode::Normal; self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; } KeyCode::Backspace => { - self.create_input.pop(); + self.active_create_modal_input_mut().pop(); } KeyCode::Char(ch) => { - self.create_input.push(ch); + self.active_create_modal_input_mut().push(ch); + } + KeyCode::Tab | KeyCode::Down => { + self.create_field = CreateModalField::Repository; + } + KeyCode::BackTab | KeyCode::Up => { + self.create_field = CreateModalField::Key; } KeyCode::Enter => { let key = self.create_input.trim().to_string(); + let repository = self.repository_input.trim().to_string(); if key.is_empty() { self.status = "Workspace key cannot be empty".to_string(); + self.create_field = CreateModalField::Key; + return; + } + if repository.is_empty() { + self.status = "Repository cannot be empty".to_string(); + self.create_field = CreateModalField::Repository; return; } - match self.service.create_workspace(&key).await { - Ok(_) => { - self.status = format!("Created workspace '{key}'"); + match self + .service + .create_workspace_with_repository(&key, &repository) + .await + { + Ok(normalized_repository) => { + self.status = format!( + "Created workspace '{key}' for repository '{normalized_repository}'" + ); self.mode = UiMode::Normal; self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; self.sync_from_manager(); if let Some(position) = self.ordered_keys.iter().position(|item| item == &key) @@ -1324,7 +4014,7 @@ impl TuiState { } } Err(err) => { - self.status = format!("Failed to create workspace: {err:?}"); + self.status = format!("Failed to create workspace: {}", err.summary()); } } } @@ -1332,6 +4022,13 @@ impl TuiState { } } + fn active_create_modal_input_mut(&mut self) -> &mut String { + match self.create_field { + CreateModalField::Key => &mut self.create_input, + CreateModalField::Repository => &mut self.repository_input, + } + } + fn handle_edit_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -1371,6 +4068,178 @@ impl TuiState { } } + async fn handle_issue_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.mode = UiMode::Normal; + self.issue_input.clear(); + } + KeyCode::Backspace => { + self.issue_input.pop(); + } + KeyCode::Char(ch) => { + self.issue_input.push(ch); + } + KeyCode::Enter => { + let Some(key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let issue = self.issue_input.trim().to_string(); + let issue = (!issue.is_empty()).then_some(issue); + match self + .service + .assign_workspace_issue(&key, issue.as_deref()) + .await + { + Ok(Some(normalized)) => { + self.status = format!("Queued issue '{normalized}' for workspace '{key}'"); + self.mode = UiMode::Normal; + self.issue_input.clear(); + } + Ok(None) => { + self.status = format!("No issue queued for workspace '{key}'"); + self.mode = UiMode::Normal; + self.issue_input.clear(); + } + Err(err) => { + self.status = format!("Failed to update issue assignment: {err:?}"); + } + } + } + _ => {} + } + } + + async fn handle_confirm_delete_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } + KeyCode::Enter => { + let Some(target) = self.pending_delete_target.clone() else { + self.mode = UiMode::Normal; + return; + }; + match target { + PendingDeleteTarget::Workspace { workspace_key } => { + match self.service.delete_workspace(&workspace_key).await { + Ok(()) => { + self.status = format!("Deleted workspace '{workspace_key}'"); + } + Err(err) => { + self.status = format!( + "Failed to delete workspace '{workspace_key}': {}", + err.summary() + ); + } + } + } + PendingDeleteTarget::Task { + workspace_key, + task_id, + } => match self + .service + .delete_workspace_task(&workspace_key, &task_id) + .await + { + Ok(()) => { + self.status = format!( + "Deleted task '{task_id}' from workspace '{workspace_key}'" + ); + } + Err(err) => { + self.status = format!( + "Failed to delete task '{task_id}' from workspace '{workspace_key}': {}", + err.summary() + ); + } + }, + } + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } + _ => {} + } + } + + async fn handle_confirm_task_removal_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } + KeyCode::Left | KeyCode::Up | KeyCode::BackTab => { + self.pending_task_removal_action = self.pending_task_removal_action.previous(); + } + KeyCode::Right | KeyCode::Down | KeyCode::Tab => { + self.pending_task_removal_action = self.pending_task_removal_action.next(); + } + KeyCode::Enter => { + let Some(PendingDeleteTarget::Task { + workspace_key, + task_id, + }) = self.pending_delete_target.clone() + else { + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + return; + }; + + match self.pending_task_removal_action { + TaskRemovalAction::Remove => { + match self + .service + .remove_workspace_task(&workspace_key, &task_id, false) + .await + { + Ok(()) => { + self.status = format!( + "Removed task '{task_id}' from workspace '{workspace_key}'" + ); + } + Err(err) => { + self.status = format!( + "Failed to remove task '{task_id}' from workspace '{workspace_key}': {}", + err.summary() + ); + } + } + } + TaskRemovalAction::RemoveAndIgnore => { + match self + .service + .remove_workspace_task(&workspace_key, &task_id, true) + .await + { + Ok(()) => { + self.status = format!( + "Removed and ignored task '{task_id}' in workspace '{workspace_key}'" + ); + } + Err(err) => { + self.status = format!( + "Failed to remove and ignore task '{task_id}' from workspace '{workspace_key}': {}", + err.summary() + ); + } + } + } + TaskRemovalAction::Cancel => {} + } + + self.mode = UiMode::Normal; + self.pending_delete_target = None; + self.pending_task_removal_action = TaskRemovalAction::default(); + } + _ => {} + } + } + fn handle_custom_link_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -1482,6 +4351,87 @@ impl TuiState { } } +pub(crate) fn snapshot_attach_target_for_selection( + snapshot: &WorkspaceSnapshot, + selected_task_id: Option<&str>, +) -> io::Result { + if let Some(task_id) = selected_task_id { + let task_state = task_runtime_snapshot(snapshot, task_id); + let has_task = snapshot.task_persistent_snapshot(task_id).is_some(); + let should_use_last_codex_thread = has_task + && (matches!( + task_effective_agent_state(task_state), + Some(AutomationAgentState::Stale) + ) || task_state.is_some_and(|task_state| { + matches!(task_state.status.as_deref(), Some("NotLoaded")) + }) || snapshot.persistent.automation_paused + && task_state + .and_then(|task_state| task_state.session_id.as_deref()) + .is_none()); + if should_use_last_codex_thread { + if let Some(uri) = codex_attach_uri(snapshot)? { + return Ok(AttachTarget::Codex { + uri, + thread_id: None, + }); + } + return workspace_attach_target(snapshot); + } + if let Some(task_state) = task_state { + return task_attach_target(snapshot, task_state); + } + } + workspace_attach_target(snapshot) +} + +fn codex_attach_uri(snapshot: &WorkspaceSnapshot) -> io::Result> { + let Some(uri) = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + else { + return Ok(None); + }; + let parsed = Url::parse(uri) + .map_err(|err| io::Error::other(format!("workspace attach URI is invalid: {err}")))?; + Ok(matches!(parsed.scheme(), "ws" | "wss").then(|| parsed.to_string())) +} + +pub(crate) fn snapshot_attach_cwd_for_selection( + snapshot: &WorkspaceSnapshot, + selected_task_id: Option<&str>, + validations: &HashMap, + workspace_path: &Path, +) -> Option { + if let Some(task_id) = selected_task_id + && let Some(task) = task_persistent_snapshot(snapshot, task_id) + { + return compare_target_path_for_task( + snapshot, + task, + task_runtime_snapshot(snapshot, task_id), + workspace_path, + ); + } + + compare_target_path(snapshot, validations, workspace_path) +} + +pub(crate) fn starting_modal_failure_status( + key: &str, + snapshot: Option<&WorkspaceSnapshot>, +) -> String { + if let Some(automation_status) = snapshot + .and_then(|snapshot| snapshot.automation_status.as_deref()) + .map(str::trim) + .filter(|status| !status.is_empty()) + { + return format!("Workspace '{key}' failed to start: {automation_status}"); + } + + format!("Workspace '{key}' failed to start; server is still stopped") +} + pub(crate) async fn dispatch_handler_action( relay_socket: Option<&Path>, program: &str, @@ -1543,3 +4493,48 @@ pub(crate) async fn dispatch_handler_action( } command.spawn().map(|_| ()) } + +async fn dispatch_web_handler_action( + relay_socket: Option<&Path>, + program: &str, + args: &[String], + argument: &str, +) -> io::Result<()> { + if let Some(socket_path) = relay_socket { + let request = multicode_lib::RemoteActionRequest { + action: multicode_lib::RemoteAction::Web, + argument: argument.to_string(), + }; + let payload = multicode_lib::encode_remote_action_request(&request); + let mut stream = tokio::net::UnixStream::connect(&socket_path).await?; + use tokio::io::AsyncWriteExt; + stream.write_all(payload.as_bytes()).await?; + stream.write_all(b"\n").await?; + stream.shutdown().await?; + return Ok(()); + } + + tracing::info!( + command = %std::iter::once(program) + .chain(args.iter().map(String::as_str)) + .map(|arg| { + if arg.is_empty() { + "''".to_string() + } else if arg.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '_' | '-' | '.' | '=')) { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" "), + "starting web handler via local spawn" + ); + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.spawn().map(|_| ()) +} diff --git a/tui/src/icons.rs b/tui/src/icons.rs index f25555e..8342afb 100644 --- a/tui/src/icons.rs +++ b/tui/src/icons.rs @@ -1,5 +1,18 @@ use crate::*; +pub(crate) fn issue_type_icon_kind_and_color( + issue_type: WorkspaceIssueType, +) -> (StatusIconKind, Color) { + match issue_type { + WorkspaceIssueType::Bug => (StatusIconKind::Bug, Color::Red), + WorkspaceIssueType::Docs => (StatusIconKind::Docs, Color::LightBlue), + WorkspaceIssueType::Enhancement => (StatusIconKind::Enhancement, Color::Green), + WorkspaceIssueType::Improvement => (StatusIconKind::Improvement, Color::Yellow), + WorkspaceIssueType::Regression => (StatusIconKind::Regression, Color::Magenta), + WorkspaceIssueType::DependencyUpgrade => (StatusIconKind::DependencyUpgrade, Color::Cyan), + } +} + pub(crate) fn issue_icon_kind_and_color(state: GithubIssueState) -> (StatusIconKind, Color) { match state { GithubIssueState::Open => (StatusIconKind::IssueOpened, Color::Green), @@ -49,6 +62,12 @@ pub(crate) fn icon_glyph(kind: StatusIconKind) -> &'static str { StatusIconKind::Eye => "\u{f441}", StatusIconKind::Server => "\u{f473}", StatusIconKind::FileDiff => "\u{f4d2}", + StatusIconKind::Bug => "\u{f188}", + StatusIconKind::Docs => "\u{f02d}", + StatusIconKind::Enhancement => "\u{f135}", + StatusIconKind::Improvement => "\u{f0ad}", + StatusIconKind::Regression => "\u{f1da}", + StatusIconKind::DependencyUpgrade => "\u{f1b2}", StatusIconKind::GitPullRequest => "\u{f407}", StatusIconKind::GitPullRequestDraft => "\u{f4dd}", StatusIconKind::GitPullRequestClosed => "\u{f4dc}", diff --git a/tui/src/main.rs b/tui/src/main.rs index 43b8d45..236ce09 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -9,12 +9,13 @@ use std::{ use clap::Parser; use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use multicode_lib::{ - RootSessionStatus, WorkspaceSnapshot, logging, opencode, + AutomationAgentState, RootSessionStatus, WorkspaceIssueType, WorkspaceSnapshot, + WorkspaceTaskPersistentSnapshot, WorkspaceTaskRuntimeSnapshot, logging, opencode, services::{ CombinedService, GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, GithubStatus, ToolConfig, ToolType, @@ -34,6 +35,7 @@ use tokio::{ process::Command, sync::{oneshot, watch}, }; +use unicode_width::UnicodeWidthStr; use url::Url; use crate::render::draw_ui; @@ -46,6 +48,8 @@ mod system; #[cfg(test)] mod tests; +use crate::icons::{icon_glyph, issue_type_icon_kind_and_color}; + const CREATE_ROW_LABEL: &str = "Create new workspace…"; const SECONDARY_ROW_COLOR: Color = Color::DarkGray; const CREATE_ROW_COLOR: Color = Color::LightBlue; @@ -58,6 +62,7 @@ const OOM_COLOR: Color = Color::Red; const RAM_LIMIT_WARNING_HEADROOM_BYTES: u64 = 512 * 1024 * 1024; const RAM_COLUMN_WIDTH: u16 = 10; const LINK_COLUMN_WIDTH: u16 = 2; +const TYPE_COLUMN_WIDTH: u16 = 1; const STATUS_COLUMN_WIDTH: u16 = 2; const REVIEW_STATUS_COLUMN_WIDTH: u16 = 2; const CPU_COLUMN_MIN_WIDTH: u16 = 5; @@ -65,31 +70,89 @@ const MACHINE_USAGE_SAMPLE_INTERVAL: Duration = Duration::from_secs(2); const ROOT_SESSION_ATTACH_WAIT_TIMEOUT: Duration = Duration::from_secs(1); const PROMPT_TOOL_IDLE_TIMEOUT: Duration = Duration::from_secs(300); const UI_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(16); -const CREATE_MODAL_WIDTH: u16 = 56; -const CREATE_MODAL_HEIGHT: u16 = 9; +const CREATE_MODAL_WIDTH: u16 = 72; +const CREATE_MODAL_HEIGHT: u16 = 13; const STARTING_MODAL_WIDTH: u16 = 62; const STARTING_MODAL_HEIGHT: u16 = 8; const TOOL_PROGRESS_MODAL_WIDTH: u16 = 72; const TOOL_PROGRESS_MODAL_HEIGHT: u16 = 14; const CUSTOM_LINK_MODAL_WIDTH: u16 = 72; const CUSTOM_LINK_MODAL_HEIGHT: u16 = 10; +const CONFIRM_DELETE_MODAL_WIDTH: u16 = 72; +const CONFIRM_DELETE_MODAL_HEIGHT: u16 = 9; +const CONFIRM_TASK_REMOVAL_MODAL_WIDTH: u16 = 76; +const CONFIRM_TASK_REMOVAL_MODAL_HEIGHT: u16 = 12; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiMode { Normal, CreateModal, EditDescription, + EditIssue, EditCustomLink, + ConfirmDelete, + ConfirmTaskRemoval, StartingModal, ToolProgressModal, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreateModalField { + Key, + Repository, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CustomLinkModalAction { Add, Edit, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingDeleteTarget { + Workspace { + workspace_key: String, + }, + Task { + workspace_key: String, + task_id: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum TaskRemovalAction { + #[default] + Remove, + RemoveAndIgnore, + Cancel, +} + +impl TaskRemovalAction { + fn label(self) -> &'static str { + match self { + Self::Remove => "Remove Issue", + Self::RemoveAndIgnore => "Remove and Ignore Issue", + Self::Cancel => "Cancel", + } + } + + fn next(self) -> Self { + match self { + Self::Remove => Self::RemoveAndIgnore, + Self::RemoveAndIgnore => Self::Cancel, + Self::Cancel => Self::Remove, + } + } + + fn previous(self) -> Self { + match self { + Self::Remove => Self::Cancel, + Self::RemoveAndIgnore => Self::Remove, + Self::Cancel => Self::RemoveAndIgnore, + } + } +} + struct TuiState { service: CombinedService, relay_socket: Option, @@ -107,12 +170,19 @@ struct TuiState { selected_link_target_index: usize, mode: UiMode, create_input: String, + repository_input: String, + create_field: CreateModalField, edit_input: String, + issue_input: String, custom_link_input: String, custom_link_kind: Option, custom_link_action: Option, custom_link_original_value: Option, + pending_delete_target: Option, + pending_task_removal_action: TaskRemovalAction, + attached_session: Option, starting_workspace_key: Option, + starting_attach_when_ready: bool, started_wait_since: Option, previous_machine_cpu_totals: Option, machine_cpu_count: usize, @@ -126,18 +196,76 @@ struct TuiState { should_quit: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AttachedSession { + workspace_key: String, + task_id: Option, + session_id: Option, + initial_agent_state: Option, + initial_turn_metrics: Option, + fresh_codex_session: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct CodexSessionTurnMetrics { + started: usize, + completed: usize, + aborted: usize, +} + struct RunningOperation { workspace_key: String, operation_name: String, + success_status: Option, progress_rx: watch::Receiver, result_rx: oneshot::Receiver>, + completion_action: RunningOperationCompletionAction, cancel: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum RunningOperationCompletionAction { + #[default] + None, + WaitForWorkspaceStart { + attach_when_ready: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TableEntry { + Create, + Workspace { + workspace_key: String, + }, + Task { + workspace_key: String, + task_id: String, + }, +} + fn workspace_is_usable(snapshot: &WorkspaceSnapshot) -> bool { !snapshot.persistent.archived } +fn workspace_supports_pause(snapshot: &WorkspaceSnapshot) -> bool { + workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Started + && (snapshot.persistent.automation_paused + || snapshot.persistent.assigned_repository.is_some() + || !snapshot.persistent.tasks.is_empty()) +} + +fn workspace_supports_task_approval(snapshot: &WorkspaceSnapshot) -> bool { + workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Started + && snapshot + .transient + .as_ref() + .and_then(|transient| Url::parse(&transient.uri).ok()) + .is_some_and(|uri| matches!(uri.scheme(), "ws" | "wss")) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum WorkspaceLinkKind { Review, @@ -155,7 +283,9 @@ struct WorkspaceLink { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum WorkspaceLinkSource { Custom, + Automation, AgentProvided, + Task, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -175,6 +305,12 @@ enum StatusIconKind { Eye, Server, FileDiff, + Bug, + Docs, + Enhancement, + Improvement, + Regression, + DependencyUpgrade, GitPullRequest, GitPullRequestDraft, GitPullRequestClosed, @@ -210,11 +346,22 @@ impl WorkspaceLinkKind { } #[derive(Debug, Clone, PartialEq, Eq)] -struct AttachTarget { - uri: String, - username: String, - password: String, - session_id: Option, +enum AttachTarget { + Opencode { + uri: String, + username: String, + password: String, + session_id: Option, + }, + Codex { + uri: String, + thread_id: Option, + }, + CodexNew { + uri: String, + cwd: Option, + prompt: Option, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -237,16 +384,229 @@ struct DiskUsage { } fn workspace_state(snapshot: &WorkspaceSnapshot) -> WorkspaceUiState { - match ( - snapshot.transient.is_some(), - snapshot.opencode_client.is_some(), - ) { + let agent_ready = snapshot + .transient + .as_ref() + .and_then(|transient| url::Url::parse(&transient.uri).ok()) + .map(|uri| match uri.scheme() { + "ws" | "wss" => snapshot.root_session_id.is_some(), + _ => snapshot.opencode_client.is_some(), + }) + .unwrap_or(false); + match (snapshot.transient.is_some(), agent_ready) { (false, _) => WorkspaceUiState::Stopped, (true, false) => WorkspaceUiState::Starting, (true, true) => WorkspaceUiState::Started, } } +fn task_persistent_snapshot<'a>( + snapshot: &'a WorkspaceSnapshot, + task_id: &str, +) -> Option<&'a WorkspaceTaskPersistentSnapshot> { + snapshot.task_persistent_snapshot(task_id) +} + +fn task_runtime_snapshot<'a>( + snapshot: &'a WorkspaceSnapshot, + task_id: &str, +) -> Option<&'a WorkspaceTaskRuntimeSnapshot> { + snapshot.task_states.get(task_id) +} + +fn compact_task_repo_label(repo: &str) -> &str { + repo.strip_prefix("micronaut-").unwrap_or(repo) +} + +fn task_issue_reference(task: &WorkspaceTaskPersistentSnapshot) -> String { + let Some(url) = Url::parse(&task.issue_url).ok() else { + return task.issue_url.clone(); + }; + let segments = url + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + if segments.len() >= 4 { + let repo = compact_task_repo_label(segments[1]); + let number = segments[3]; + return format!("{repo}#{number}"); + } + task.issue_url.clone() +} + +fn task_row_label(task: &WorkspaceTaskPersistentSnapshot) -> String { + format!("➡️ {}", task_issue_reference(task)) +} + +fn task_issue_link<'a>( + task: &'a WorkspaceTaskPersistentSnapshot, + task_state: Option<&'a WorkspaceTaskRuntimeSnapshot>, +) -> &'a str { + task_state + .and_then(|state| state.issue.first().map(String::as_str)) + .unwrap_or(task.issue_url.as_str()) +} + +fn task_pr_link<'a>( + task: &'a WorkspaceTaskPersistentSnapshot, + task_state: Option<&'a WorkspaceTaskRuntimeSnapshot>, +) -> Option<&'a str> { + task_state + .and_then(|state| state.pr.first().map(String::as_str)) + .or(task.backing_pr_url.as_deref()) +} + +fn github_link_badge(url: &str) -> String { + let Some(parsed) = Url::parse(url).ok() else { + return String::new(); + }; + let segments = parsed + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + let Some(number) = segments.last().filter(|segment| !segment.is_empty()) else { + return String::new(); + }; + format!("#{number}") +} + +fn task_pr_created_status( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> Option { + task_pr_link(task, task_state) + .map(github_link_badge) + .filter(|reference| !reference.is_empty()) + .map(|reference| format!("PR created {reference}")) +} + +fn workspace_active_task<'a>( + snapshot: &'a WorkspaceSnapshot, +) -> Option<&'a WorkspaceTaskPersistentSnapshot> { + snapshot + .active_task_id + .clone() + .or_else(|| snapshot.resolved_active_task_id()) + .and_then(|task_id| snapshot.task_persistent_snapshot(&task_id)) +} + +fn workspace_issue_type(snapshot: &WorkspaceSnapshot) -> Option { + workspace_active_task(snapshot).and_then(|task| task.issue_type) +} + +fn issue_type_cell( + issue_type: Option, + issue_type_glyph: Option<&str>, + archived: bool, +) -> Cell<'static> { + issue_type.map_or_else(Cell::default, |issue_type| { + let (kind, color) = issue_type_icon_kind_and_color(issue_type); + let glyph = issue_type_glyph.unwrap_or_else(|| icon_glyph(kind)); + Cell::from(glyph.to_string()).style( + Style::default() + .fg(if archived { Color::DarkGray } else { color }) + .bg(Color::Reset), + ) + }) +} + +fn is_generic_review_task_status(status: &str) -> bool { + let status = status.trim(); + status.starts_with("Review ") + || status.starts_with("Wait close ") + || status.starts_with("PR created ") +} + +fn task_server_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> &'static str { + if task_state.is_some_and(|state| state.waiting_on_vm) { + return "Waiting on VM"; + } + match task_effective_agent_state(task_state) { + Some(AutomationAgentState::Working) => "Busy", + Some(AutomationAgentState::Question) => "Question", + Some(AutomationAgentState::Review | AutomationAgentState::Idle) => "Idle", + Some(AutomationAgentState::WaitingOnVm) => "Waiting on VM", + Some(AutomationAgentState::Stale) => "Stale", + None => { + if task_state.is_some_and(|state| state.waiting_on_vm) { + "Waiting on VM" + } else { + "" + } + } + } +} + +fn task_server_style(task_state: Option<&WorkspaceTaskRuntimeSnapshot>, archived: bool) -> Style { + if archived { + return Style::default(); + } + match task_server_label(task_state) { + "Idle" => Style::default().fg(IDLE_COLOR), + "Busy" => Style::default().fg(BUSY_COLOR), + "Question" => Style::default().fg(WAITING_FOR_INPUT_COLOR), + "Waiting on VM" => Style::default().fg(Color::Blue), + "Stale" => Style::default().fg(OOM_COLOR), + _ => Style::default(), + } +} + +fn task_description( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> String { + let pr_created_status = task_pr_created_status(task, task_state); + if let Some(status) = task_state.and_then(|state| state.status.as_deref()) + && !status.trim().is_empty() + { + if matches!( + task_effective_agent_state(task_state), + Some(AutomationAgentState::Review | AutomationAgentState::Idle) + ) && is_generic_review_task_status(status) + && let Some(pr_created_status) = pr_created_status.clone() + { + return pr_created_status; + } + return status.trim().to_string(); + } + if task_state.is_some_and(|state| state.waiting_on_vm) { + return "Queued until VM is free".to_string(); + } + match task_effective_agent_state(task_state) { + Some(AutomationAgentState::Working) => format!("Working {}", task_issue_reference(task)), + Some(AutomationAgentState::Question) => format!("Question {}", task_issue_reference(task)), + Some(AutomationAgentState::Review) => { + pr_created_status.unwrap_or_else(|| format!("Review {}", task_issue_reference(task))) + } + Some(AutomationAgentState::WaitingOnVm) => "Queued until VM is free".to_string(), + Some(AutomationAgentState::Idle) => pr_created_status + .unwrap_or_else(|| format!("Wait close {}", task_issue_reference(task))), + Some(AutomationAgentState::Stale) => format!("Stalled {}", task_issue_reference(task)), + None => task_issue_reference(task), + } +} + +fn task_effective_agent_state( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> Option { + let task_state = task_state?; + match task_state.session_status { + Some(RootSessionStatus::Question) => Some(AutomationAgentState::Question), + Some(RootSessionStatus::Idle) if task_state.session_id.is_some() => { + Some(AutomationAgentState::Review) + } + Some(RootSessionStatus::Idle) => Some(AutomationAgentState::Idle), + Some(RootSessionStatus::Busy) => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => Some(AutomationAgentState::Working), + other => other, + }, + None => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) if !task_state.waiting_on_vm => None, + other => other, + }, + } +} + fn next_non_stopped_row( current_row: usize, ordered_keys: &[String], @@ -276,10 +636,7 @@ fn server_cell_label(snapshot: &WorkspaceSnapshot) -> &'static str { match workspace_state(snapshot) { WorkspaceUiState::Stopped => "", WorkspaceUiState::Starting => "Starting", - WorkspaceUiState::Started => match snapshot - .root_session_status - .unwrap_or(RootSessionStatus::Idle) - { + WorkspaceUiState::Started => match effective_server_status(snapshot) { RootSessionStatus::Idle => "Idle", RootSessionStatus::Busy => "Busy", RootSessionStatus::Question => "Question", @@ -287,31 +644,69 @@ fn server_cell_label(snapshot: &WorkspaceSnapshot) -> &'static str { } } -fn format_tokens_spaced(tokens: u64) -> String { - let digits = tokens.to_string(); - let mut reversed = String::with_capacity(digits.len() + digits.len() / 3); - for (index, ch) in digits.chars().rev().enumerate() { - if index > 0 && index % 3 == 0 { - reversed.push(' '); +fn effective_server_status(snapshot: &WorkspaceSnapshot) -> RootSessionStatus { + if active_task_issue_url(snapshot).is_some() { + if let Some(agent_state) = snapshot.automation_agent_state { + return match agent_state { + AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::WaitingOnVm => RootSessionStatus::Idle, + AutomationAgentState::Question => RootSessionStatus::Question, + AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale => RootSessionStatus::Idle, + }; + } + if let Some(status) = snapshot.automation_session_status { + return status; } - reversed.push(ch); } - reversed.chars().rev().collect() + snapshot + .root_session_status + .unwrap_or(RootSessionStatus::Idle) +} + +fn format_tokens_compact(tokens: u64) -> String { + if tokens < 1_000 { + return tokens.to_string(); + } + format!("{}k", tokens / 1_000) } fn format_price(cost: f64) -> String { format!("${cost:.2}") } +fn task_cost_cell_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> String { + if let Some(tokens) = task_state.and_then(|state| state.usage_total_tokens) { + return format_tokens_compact(tokens); + } + String::new() +} + +fn workspace_usage_totals(snapshot: &WorkspaceSnapshot) -> (Option, Option) { + let task_tokens = snapshot + .task_states + .values() + .filter_map(|task_state| task_state.usage_total_tokens) + .reduce(|sum, tokens| sum.saturating_add(tokens)); + if task_tokens.is_some() { + return (None, task_tokens); + } + ( + snapshot + .usage_total_cost + .filter(|cost| cost.is_finite() && *cost > 0.0), + snapshot.usage_total_tokens, + ) +} + fn cost_cell_label(snapshot: &WorkspaceSnapshot) -> String { - if let Some(cost) = snapshot - .usage_total_cost - .filter(|cost| cost.is_finite() && *cost > 0.0) - { + let (cost, tokens) = workspace_usage_totals(snapshot); + if let Some(cost) = cost { return format_price(cost); } - if let Some(tokens) = snapshot.usage_total_tokens { - return format_tokens_spaced(tokens); + if let Some(tokens) = tokens { + return format_tokens_compact(tokens); } String::new() } @@ -393,18 +788,23 @@ fn machine_description( #[cfg(test)] fn description_cell_text(snapshot: &WorkspaceSnapshot, user_description: &str) -> String { + let automation_status = snapshot.automation_status.as_deref().unwrap_or("").trim(); let session_title = snapshot.root_session_title.as_deref().unwrap_or("").trim(); - if session_title.is_empty() { - return user_description.to_string(); + let mut parts = Vec::new(); + if !user_description.is_empty() { + parts.push(user_description.to_string()); + } + if !automation_status.is_empty() { + parts.push(automation_status.to_string()); } - if user_description.is_empty() { - return session_title.to_string(); + if !session_title.is_empty() { + parts.push(session_title.to_string()); } - format!("{user_description} · {session_title}") + parts.join(" · ") } fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { - let mut links = Vec::new(); + let mut links = workspace_primary_issue_pr_links(snapshot); links.extend( snapshot @@ -475,6 +875,104 @@ fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { links } +fn workspace_primary_issue_pr_links(snapshot: &WorkspaceSnapshot) -> Vec { + let mut links: Vec<_> = active_task_issue_url(snapshot) + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::Automation, + }) + .collect(); + + if let Some(active_task_id) = snapshot + .active_task_id + .clone() + .or_else(|| snapshot.resolved_active_task_id()) + && let Some(task) = snapshot.task_persistent_snapshot(&active_task_id) + && let Some(pr) = task_pr_link(task, task_runtime_snapshot(snapshot, &active_task_id)) + { + links.push(WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: pr.to_string(), + source: WorkspaceLinkSource::Task, + }); + } + + links +} + +fn workspace_issue_pr_links(snapshot: &WorkspaceSnapshot) -> Vec { + if let Some(active_task_id) = snapshot + .active_task_id + .clone() + .or_else(|| snapshot.resolved_active_task_id()) + && let Some(task) = snapshot.task_persistent_snapshot(&active_task_id) + { + return task_links(task, task_runtime_snapshot(snapshot, &active_task_id)); + } + + let mut links = Vec::new(); + links.extend( + active_task_issue_url(snapshot) + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::Automation, + }), + ); + links.extend( + snapshot + .persistent + .agent_provided + .issue + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::AgentProvided, + }), + ); + links.extend( + snapshot + .persistent + .agent_provided + .pr + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value, + source: WorkspaceLinkSource::AgentProvided, + }), + ); + + links +} + +fn task_links( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> Vec { + let mut links = vec![WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: task_issue_link(task, task_state).to_string(), + source: WorkspaceLinkSource::Task, + }]; + if let Some(pr) = task_pr_link(task, task_state) { + links.push(WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: pr.to_string(), + source: WorkspaceLinkSource::Task, + }); + } + links +} + #[cfg(test)] fn description_line( snapshot: &WorkspaceSnapshot, @@ -490,7 +988,9 @@ fn description_line_for_snapshot( archived: bool, ) -> Line<'static> { let session_title = snapshot.root_session_title.as_deref().unwrap_or("").trim(); + let automation_status = snapshot.automation_status.as_deref().unwrap_or("").trim(); let has_session_title = !session_title.is_empty(); + let has_automation_status = !automation_status.is_empty(); let has_description = !user_description.is_empty(); let mut spans = Vec::new(); @@ -515,6 +1015,19 @@ fn description_line_for_snapshot( } } + if has_automation_status { + if has_content { + spans.push(Span::raw(" · ")); + } + let automation_text = if archived || !automation_status_shows_activity(automation_status) { + automation_status.to_string() + } else { + format!("{} {}", automation_activity_glyph(), automation_status) + }; + spans.push(Span::raw(automation_text)); + has_content = true; + } + if has_session_title { if has_content { spans.push(Span::raw(" · ")); @@ -525,6 +1038,24 @@ fn description_line_for_snapshot( Line::from(spans) } +fn automation_activity_glyph() -> &'static str { + const FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; + let frame = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| ((elapsed.as_millis() / 200) as usize) % FRAMES.len()) + .unwrap_or(0); + FRAMES[frame] +} + +fn automation_status_shows_activity(status: &str) -> bool { + !matches!( + status, + status if status.starts_with("Start failed") + || status.starts_with("Scan failed") + || status.starts_with("No issues") + ) +} + fn first_validated_workspace_link_by_kind( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -556,6 +1087,171 @@ fn validated_workspace_links_by_kind( .collect() } +fn compare_target_path( + snapshot: &WorkspaceSnapshot, + validations: &HashMap, + workspace_path: &Path, +) -> Option { + first_validated_workspace_link_by_kind(snapshot, validations, WorkspaceLinkKind::Review) + .and_then(|link| match validations.get(&link) { + Some(WorkspaceLinkValidationResult::Valid(path)) => Some(PathBuf::from(path)), + _ => None, + }) + .or_else(|| compare_target_path_from_workspace(snapshot, workspace_path)) +} + +fn compare_target_path_for_task( + snapshot: &WorkspaceSnapshot, + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, + workspace_path: &Path, +) -> Option { + let repo_name = snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(|repository| repository.rsplit('/').next()) + .filter(|segment| !segment.is_empty())?; + let issue_number = task + .issue_url + .rsplit('/') + .next() + .filter(|segment| !segment.is_empty())?; + + let worktree_name = format!("{repo_name}-{issue_number}"); + let mut candidates = Vec::new(); + + if let Some(task_state) = task_state { + for repository in &task_state.repository { + let candidate = PathBuf::from(repository); + if candidate + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == worktree_name) + { + push_unique_candidate(&mut candidates, candidate); + } + } + } + + push_unique_candidate( + &mut candidates, + workspace_path.join("work").join(&worktree_name), + ); + + if let Some(task_state) = task_state { + for repository in &task_state.repository { + push_unique_candidate(&mut candidates, PathBuf::from(repository)); + } + } + + push_unique_candidate(&mut candidates, workspace_path.join(repo_name)); + + candidates + .into_iter() + .find(|candidate| is_git_checkout(candidate)) +} + +fn compare_target_path_from_workspace( + snapshot: &WorkspaceSnapshot, + workspace_path: &Path, +) -> Option { + let repo_name = snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(|repository| repository.rsplit('/').next()) + .filter(|segment| !segment.is_empty()); + let active_issue_url = active_task_issue_url(snapshot); + let issue_number = active_issue_url + .as_deref() + .and_then(|issue| issue.rsplit('/').next()) + .filter(|segment| !segment.is_empty()); + + let mut candidates = Vec::new(); + if let (Some(repo_name), Some(issue_number)) = (repo_name, issue_number) { + candidates.push( + workspace_path + .join("work") + .join(format!("{repo_name}-{issue_number}")), + ); + } + if let Some(repo_name) = repo_name { + candidates.push(workspace_path.join(repo_name)); + } + + candidates + .into_iter() + .find(|candidate| is_git_checkout(candidate)) +} + +fn is_git_checkout(path: &Path) -> bool { + let Some(git_dir) = checkout_git_dir(path) else { + return false; + }; + + if !git_dir.is_dir() || !git_dir.join("HEAD").exists() { + return false; + } + + let commondir_path = git_dir.join("commondir"); + if !commondir_path.is_file() { + return true; + } + + let Ok(commondir) = std::fs::read_to_string(&commondir_path) else { + return false; + }; + let commondir = commondir.trim(); + if commondir.is_empty() { + return false; + } + + let common_dir = resolve_git_path(&git_dir, commondir); + common_dir.is_dir() + && (common_dir.join("config").exists() + || common_dir.join("HEAD").exists() + || common_dir.join("objects").exists()) +} + +fn checkout_git_dir(path: &Path) -> Option { + let git_path = path.join(".git"); + let metadata = std::fs::symlink_metadata(&git_path).ok()?; + if metadata.is_dir() { + return Some(git_path); + } + if !metadata.is_file() { + return None; + } + + let contents = std::fs::read_to_string(&git_path).ok()?; + let git_dir = contents.strip_prefix("gitdir:")?.trim(); + if git_dir.is_empty() { + return None; + } + + Some(resolve_git_path(path, git_dir)) +} + +fn resolve_git_path(base: &Path, target: &str) -> PathBuf { + let target_path = Path::new(target); + if target_path.is_absolute() { + target_path.to_path_buf() + } else { + base.join(target_path) + } +} + +fn push_unique_candidate(candidates: &mut Vec, candidate: PathBuf) { + if !candidates.iter().any(|existing| existing == &candidate) { + candidates.push(candidate); + } +} + +fn active_task_issue_url(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_issue_url() +} + fn visible_workspace_links( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -570,9 +1266,10 @@ fn visible_workspace_links( } for kind in [WorkspaceLinkKind::Issue, WorkspaceLinkKind::Pr] { - if let Some(link) = first_validated_workspace_link_by_kind(snapshot, validations, kind) { + let next_link = first_validated_workspace_link_by_kind(snapshot, validations, kind); + if let Some(link) = next_link { visible.push(link); - } else { + } else if snapshot.persistent.tasks.is_empty() { visible.push(WorkspaceLink { kind, value: String::new(), @@ -584,6 +1281,22 @@ fn visible_workspace_links( visible } +fn visible_task_links( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, + validations: &HashMap, +) -> Vec { + task_links(task, task_state) + .into_iter() + .filter(|link| { + matches!( + validations.get(link), + Some(WorkspaceLinkValidationResult::Valid(_)) + ) + }) + .collect() +} + fn selectable_workspace_links( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -643,7 +1356,7 @@ fn next_link_selection_right(current: Option, link_count: usize) -> Optio } fn content_width(text: &str) -> u16 { - text.chars().count().min(u16::MAX as usize) as u16 + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 } fn right_align_cell_text(text: &str, width: u16) -> String { @@ -661,7 +1374,7 @@ fn table_column_widths( create_row_server: &str, create_row_cpu: &str, create_row_ram: &str, -) -> (u16, u16, u16, u16, u16, u16, u16, u16, u16, u16) { +) -> (u16, u16, u16, u16, u16, u16, u16, u16, u16, u16, u16) { let mut workspace_width = content_width("Workspace").max(content_width(CREATE_ROW_LABEL)); let mut server_width = content_width("Server").max(content_width(create_row_server)); let mut cpu_width = content_width("CPU") @@ -675,6 +1388,7 @@ fn table_column_widths( let mut cost_width = content_width("Cost"); let re_width = content_width("RE").max(LINK_COLUMN_WIDTH); let is_width = content_width("IS").max(LINK_COLUMN_WIDTH); + let t_width = content_width("T").max(TYPE_COLUMN_WIDTH); let pr_width = content_width("PR").max(LINK_COLUMN_WIDTH); let build_width = content_width("B").max(STATUS_COLUMN_WIDTH); let review_width = content_width("R").max(REVIEW_STATUS_COLUMN_WIDTH); @@ -684,6 +1398,15 @@ fn table_column_widths( server_width = server_width.max(content_width(server_cell_label(snapshot))); cpu_width = cpu_width.max(content_width(&cpu_cell_label(snapshot))); cost_width = cost_width.max(content_width(&cost_cell_label(snapshot))); + for task in &snapshot.persistent.tasks { + workspace_width = workspace_width.max(content_width(&task_row_label(task))); + server_width = server_width.max(content_width(task_server_label( + task_runtime_snapshot(snapshot, &task.id), + ))); + cost_width = cost_width.max(content_width(&task_cost_cell_label( + task_runtime_snapshot(snapshot, &task.id), + ))); + } } } ( @@ -694,6 +1417,7 @@ fn table_column_widths( cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -720,12 +1444,18 @@ fn help_line( selected_row: usize, workspace_count: usize, selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, selected_workspace_link_count: usize, selected_link_index: Option, selected_link_is_custom: bool, selected_link_is_placeholder: bool, selected_link_kind: Option, selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_issue: bool, + selected_workspace_can_diff: bool, + selected_workspace_can_edit: bool, + selected_task_can_fix_ci: bool, + tool_progress_can_cancel: bool, tool_hotkeys: &[(String, String)], status: &str, ) -> Line<'static> { @@ -738,6 +1468,57 @@ fn help_line( push_hotkey(&mut spans, "Enter", " create "); } Some(snapshot) => { + if selected_task_row { + if selected_link_index.is_some() { + push_hotkey(&mut spans, "↑/↓", " select target "); + push_hotkey(&mut spans, "Enter", " open link "); + if matches!( + selected_link_kind, + Some(WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + ) { + push_hotkey(&mut spans, "o", " open GitHub "); + } + push_hotkey(&mut spans, "Esc", " row focus "); + push_hotkey(&mut spans, "q", " quit"); + if !status.is_empty() { + spans.push(Span::raw(" | ")); + spans.push(Span::raw(status.to_string())); + } + return Line::from(spans); + } + if workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Started + { + push_hotkey(&mut spans, "Enter", " attach "); + } else if workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Stopped + { + push_hotkey(&mut spans, "Enter", " start+attach "); + } + if selected_workspace_can_diff { + push_hotkey(&mut spans, "c", " compare "); + } + if selected_workspace_can_edit { + push_hotkey(&mut spans, "e", " edit "); + } + if workspace_supports_task_approval(snapshot) { + push_hotkey(&mut spans, "a", " approve "); + if selected_task_can_fix_ci { + push_hotkey(&mut spans, "f", " fix CI "); + } + } + push_hotkey(&mut spans, "o", " open GitHub "); + for (tool_key, tool_name) in tool_hotkeys { + push_hotkey(&mut spans, tool_key.clone(), format!(" {} ", tool_name)); + } + push_hotkey(&mut spans, "x", " remove issue "); + push_hotkey(&mut spans, "q", " quit"); + if !status.is_empty() { + spans.push(Span::raw(" | ")); + spans.push(Span::raw(status.to_string())); + } + return Line::from(spans); + } if selected_workspace_link_count > 0 { push_hotkey(&mut spans, "←/→", " select link "); } @@ -752,6 +1533,12 @@ fn help_line( push_hotkey(&mut spans, "Enter", add_label); } else { push_hotkey(&mut spans, "Enter", " open link "); + if matches!( + selected_link_kind, + Some(WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + ) { + push_hotkey(&mut spans, "o", " open GitHub "); + } push_hotkey(&mut spans, "a", " add link "); } if selected_link_is_custom && !selected_link_is_placeholder { @@ -776,11 +1563,31 @@ fn help_line( " stop " }; push_hotkey(&mut spans, "s", start_stop_action); + if workspace_supports_pause(snapshot) { + let pause_action = if snapshot.persistent.automation_paused { + " resume " + } else { + " pause " + }; + push_hotkey(&mut spans, "p", pause_action); + } if selected_workspace_has_refreshable_github_link { push_hotkey(&mut spans, "r", " recheck GH status "); } } + if selected_workspace_can_assign_issue { + push_hotkey(&mut spans, "o", " open repo "); + push_hotkey(&mut spans, "i", " issue "); + push_hotkey(&mut spans, "n", " queue next "); + } + if selected_workspace_can_diff { + push_hotkey(&mut spans, "c", " compare "); + } + if selected_workspace_can_edit { + push_hotkey(&mut spans, "e", " edit "); + } push_hotkey(&mut spans, "d", " edit description "); + push_hotkey(&mut spans, "x", " delete "); let archive_action = if snapshot.persistent.archived { " unarchive " } else { @@ -796,7 +1603,8 @@ fn help_line( push_hotkey(&mut spans, "q", " quit"); } UiMode::CreateModal => { - spans.push(Span::raw("Create workspace: type key, ")); + spans.push(Span::raw("Create workspace: type key and repository, ")); + push_hotkey(&mut spans, "Tab", " next field, "); push_hotkey(&mut spans, "Enter", " confirm, "); push_hotkey(&mut spans, "Esc", " cancel"); } @@ -805,12 +1613,28 @@ fn help_line( push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Esc", " cancel"); } + UiMode::EditIssue => { + spans.push(Span::raw("Queue issue: type number or GitHub issue URL, ")); + push_hotkey(&mut spans, "Enter", " save, "); + push_hotkey(&mut spans, "Esc", " cancel"); + } UiMode::EditCustomLink => { spans.push(Span::raw("Edit link: type, ")); push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Del", " delete, "); push_hotkey(&mut spans, "Esc", " cancel"); } + UiMode::ConfirmDelete => { + spans.push(Span::raw("Delete item: ")); + push_hotkey(&mut spans, "Enter", " confirm, "); + push_hotkey(&mut spans, "Esc", " cancel"); + } + UiMode::ConfirmTaskRemoval => { + spans.push(Span::raw("Remove issue: ")); + push_hotkey(&mut spans, "←/→", " select action, "); + push_hotkey(&mut spans, "Enter", " confirm, "); + push_hotkey(&mut spans, "Esc", " cancel"); + } UiMode::StartingModal => { spans.push(Span::raw( "Starting workspace and waiting for server readiness...", @@ -820,7 +1644,11 @@ fn help_line( spans.push(Span::raw( "Operation is running in the selected workspace... ", )); - push_hotkey(&mut spans, "Esc", " cancel"); + if tool_progress_can_cancel { + push_hotkey(&mut spans, "Esc", " cancel"); + } else { + spans.push(Span::raw("Waiting for it to finish...")); + } } } if !status.is_empty() { diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 024894d..fb0d2b3 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -1,4 +1,125 @@ use crate::*; +use multicode_lib::services::{CompareConfig, CompareTool}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +fn vscode_command_candidates() -> Vec { + let mut candidates = vec![PathBuf::from( + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + )]; + if let Some(home) = std::env::var_os("HOME") { + candidates.push( + PathBuf::from(home) + .join("Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"), + ); + } + candidates +} + +fn intellij_command_candidates() -> Vec { + let mut candidates = vec![ + PathBuf::from("/Applications/IntelliJ IDEA.app/Contents/MacOS/idea"), + PathBuf::from("/Applications/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea"), + PathBuf::from("/Applications/IntelliJ IDEA CE.app/Contents/MacOS/idea"), + PathBuf::from("/Applications/IntelliJ IDEA Community Edition.app/Contents/MacOS/idea"), + PathBuf::from("/usr/local/bin/idea"), + PathBuf::from("/opt/idea/bin/idea.sh"), + ]; + if let Some(home) = std::env::var_os("HOME") { + candidates + .push(PathBuf::from(&home).join("Applications/IntelliJ IDEA.app/Contents/MacOS/idea")); + candidates.push( + PathBuf::from(&home) + .join("Applications/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea"), + ); + candidates.push( + PathBuf::from(&home).join("Applications/IntelliJ IDEA CE.app/Contents/MacOS/idea"), + ); + candidates.push( + PathBuf::from(&home) + .join("Applications/IntelliJ IDEA Community Edition.app/Contents/MacOS/idea"), + ); + candidates.push( + PathBuf::from(&home).join("Library/Application Support/JetBrains/Toolbox/scripts/idea"), + ); + } + candidates +} + +fn compare_command_candidates(tool: CompareTool) -> Vec { + match tool { + CompareTool::Vscode => { + let mut candidates = vec![PathBuf::from("code")]; + candidates.extend(vscode_command_candidates()); + candidates + } + CompareTool::Intellij => { + let mut candidates = vec![PathBuf::from("idea")]; + candidates.extend(intellij_command_candidates()); + candidates + } + } +} + +pub(crate) fn compare_tool_name(tool: CompareTool) -> &'static str { + match tool { + CompareTool::Vscode => "VS Code", + CompareTool::Intellij => "IntelliJ IDEA", + } +} + +fn compare_command_path(config: &CompareConfig) -> Option { + if let Some(command) = config.command.as_deref().map(str::trim) + && !command.is_empty() + && command_exists(command) + { + return Some(PathBuf::from(command)); + } + + compare_command_candidates(config.tool) + .into_iter() + .find(|candidate| command_exists(candidate.to_string_lossy().as_ref())) +} + +pub(crate) fn compare_tool_is_available(config: &CompareConfig) -> bool { + compare_command_path(config).is_some() +} + +fn compare_open_command( + config: &CompareConfig, + paths: &[PathBuf], +) -> io::Result<(String, Vec)> { + let program = compare_command_path(config).ok_or_else(|| { + let configured = config.command.as_deref().map(str::trim).unwrap_or_default(); + if configured.is_empty() { + io::Error::other(format!( + "{} is not installed or its CLI launcher is unavailable", + compare_tool_name(config.tool) + )) + } else { + io::Error::other(format!( + "{} compare command '{}' is unavailable", + compare_tool_name(config.tool), + configured + )) + } + })?; + + let mut args = Vec::new(); + if matches!(config.tool, CompareTool::Vscode) { + args.push("--reuse-window".to_string()); + } + args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned())); + + Ok((program.to_string_lossy().into_owned(), args)) +} + +pub(crate) fn compare_open_repo_command( + compare: &CompareConfig, + repo_path: &Path, +) -> io::Result<(String, Vec)> { + compare_open_command(compare, &[repo_path.to_path_buf()]) +} pub(crate) fn shell_escape_arg(arg: &str) -> String { if arg.is_empty() { @@ -37,6 +158,13 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul let mut parsed = Url::parse(uri) .map_err(|err| io::Error::other(format!("workspace attach URI is invalid: {err}")))?; + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(AttachTarget::Codex { + uri: parsed.to_string(), + thread_id: snapshot.root_session_id.clone(), + }); + } + let username = parsed.username().to_string(); if username.is_empty() { return Err(io::Error::other( @@ -55,7 +183,7 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul .set_password(None) .map_err(|_| io::Error::other("failed to sanitize workspace attach URI password"))?; - Ok(AttachTarget { + Ok(AttachTarget::Opencode { uri: parsed.to_string(), username, password, @@ -63,6 +191,63 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul }) } +pub(crate) fn task_attach_target( + snapshot: &WorkspaceSnapshot, + task_state: &multicode_lib::WorkspaceTaskRuntimeSnapshot, +) -> io::Result { + if workspace_state(snapshot) != WorkspaceUiState::Started { + return Err(io::Error::other( + "workspace must be in Started state before attaching", + )); + } + + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + .ok_or_else(|| io::Error::other("workspace is missing transient attach URI"))?; + + let mut parsed = Url::parse(uri) + .map_err(|err| io::Error::other(format!("workspace attach URI is invalid: {err}")))?; + + let session_id = task_state + .session_id + .clone() + .ok_or_else(|| io::Error::other("task does not have a resumable session yet"))?; + + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(AttachTarget::Codex { + uri: parsed.to_string(), + thread_id: Some(session_id), + }); + } + + let username = parsed.username().to_string(); + if username.is_empty() { + return Err(io::Error::other( + "workspace attach URI is missing username credentials", + )); + } + let password = parsed + .password() + .map(str::to_string) + .ok_or_else(|| io::Error::other("workspace attach URI is missing password credentials"))?; + + parsed + .set_username("") + .map_err(|_| io::Error::other("failed to sanitize workspace attach URI username"))?; + parsed + .set_password(None) + .map_err(|_| io::Error::other("failed to sanitize workspace attach URI password"))?; + + Ok(AttachTarget::Opencode { + uri: parsed.to_string(), + username, + password, + session_id: Some(session_id), + }) +} + pub(crate) fn build_handler_command( template: &str, argument_mode: multicode_lib::HandlerArgumentMode, @@ -118,18 +303,12 @@ pub(crate) async fn validate_workspace_link_target( } let git_dir = repo_path.join(".git"); - let git_metadata = tokio::fs::metadata(&git_dir).await.map_err(|err| { + tokio::fs::symlink_metadata(&git_dir).await.map_err(|err| { io::Error::other(format!( - "review path '{}' must contain a '.git' folder: {err}", + "review path '{}' must contain a '.git' entry: {err}", repo_path.display() )) })?; - if !git_metadata.is_dir() { - return Err(io::Error::other(format!( - "review path '{}' must contain a '.git' folder", - repo_path.display() - ))); - } Ok(repo_path.to_string_lossy().into_owned()) } @@ -154,14 +333,49 @@ pub(crate) async fn validate_workspace_link_target( } } -pub(crate) fn attach_cli_args(opencode_command: &str, target: &AttachTarget) -> Vec { - let mut args = vec![opencode_command.to_string(), "attach".to_string()]; - if let Some(session_id) = target.session_id.as_deref() { - args.push("--session".to_string()); - args.push(session_id.to_string()); +pub(crate) fn attach_cli_args(agent_command: &str, target: &AttachTarget) -> Vec { + match target { + AttachTarget::Opencode { + uri, session_id, .. + } => { + let mut args = vec![agent_command.to_string(), "attach".to_string()]; + if let Some(session_id) = session_id.as_deref() { + args.push("--session".to_string()); + args.push(session_id.to_string()); + } + args.push(uri.clone()); + args + } + AttachTarget::Codex { uri, thread_id } => { + let mut args = vec![ + agent_command.to_string(), + "resume".to_string(), + "--remote".to_string(), + uri.clone(), + ]; + if let Some(thread_id) = thread_id.as_deref() { + args.push(thread_id.to_string()); + } else { + args.push("--last".to_string()); + } + args + } + AttachTarget::CodexNew { uri, cwd, prompt } => { + let mut args = vec![ + agent_command.to_string(), + "--remote".to_string(), + uri.clone(), + ]; + if let Some(cwd) = cwd.as_deref() { + args.push("-C".to_string()); + args.push(cwd.to_string()); + } + if let Some(prompt) = prompt.as_deref() { + args.push(prompt.to_string()); + } + args + } } - args.push(target.uri.clone()); - args } pub(crate) fn tmux_session_command( @@ -179,22 +393,36 @@ pub(crate) fn tmux_session_command( pub(crate) async fn attach_in_tmux( terminal: &mut Terminal>, - opencode_command: &str, + agent_command: &str, target: &AttachTarget, + cwd: Option<&Path>, + extra_env: &[(String, String)], workspace_key: &str, custom_description: &str, ) -> io::Result<()> { let original_term = std::env::var("TERM").ok(); - let attach_command = vec![ - format!("OPENCODE_SERVER_USERNAME={}", target.username), - format!("OPENCODE_SERVER_PASSWORD={}", target.password), - ]; - let mut attach_command = tmux_session_command(attach_command, original_term.as_deref()); - attach_command.extend(attach_cli_args(opencode_command, target)); + let mut session_env = extra_env.to_vec(); + if let Some(term) = original_term.as_deref() { + session_env.push(("TERM".to_string(), term.to_string())); + } + let attach_env = extra_env + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>(); + if let AttachTarget::Opencode { + username, password, .. + } = target + { + session_env.push(("OPENCODE_SERVER_USERNAME".to_string(), username.to_string())); + session_env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + } + let mut attach_command = tmux_session_command(attach_env, None); + attach_command.extend(attach_cli_args(agent_command, target)); run_tmux_new_session_command( terminal, - &[], + &session_env, attach_command, + cwd, workspace_key, custom_description, ) @@ -205,9 +433,22 @@ pub(crate) async fn run_tmux_new_session_command( terminal: &mut Terminal>, env: &[(String, String)], command: Vec, + cwd: Option<&Path>, workspace_key: &str, custom_description: &str, ) -> io::Result<()> { + if !command_exists("tmux") { + let debug_command = command + .split_first() + .map(|(program, args)| format_command_line(program, args)) + .unwrap_or_else(|| "".to_string()); + tracing::info!( + command = %debug_command, + "tmux unavailable; running interactive command directly" + ); + return run_interactive_command(terminal, env, &command, cwd).await; + } + restore_terminal(terminal)?; let session_name = generate_tmux_session_name(workspace_key); @@ -232,11 +473,15 @@ pub(crate) async fn run_tmux_new_session_command( let mut create_process = Command::new("tmux"); create_process.env("TERM", "xterm-256color"); - let create_result = create_process + create_process .arg("new-session") .arg("-d") .arg("-s") - .arg(&session_name) + .arg(&session_name); + if let Some(cwd) = cwd { + create_process.arg("-c").arg(cwd); + } + let create_result = create_process .arg("env") .args(env.iter().map(|(name, value)| format!("{name}={value}"))) .args(command) @@ -301,9 +546,11 @@ pub(crate) async fn run_tmux_new_session_command( { Ok(status) if status.success() => {} Ok(status) => { - run_error = Some(io::Error::other(format!( - "tmux attach-session exited with status {status}" - ))); + if tmux_session_exists(&session_name).await? { + run_error = Some(io::Error::other(format!( + "tmux attach-session exited with status {status}" + ))); + } } Err(err) => { run_error = Some(err); @@ -322,6 +569,62 @@ pub(crate) async fn run_tmux_new_session_command( } } +pub(crate) fn command_exists(command: &str) -> bool { + if command.contains('/') { + return is_executable_file(Path::new(command)); + } + + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path) + .map(|directory| directory.join(command)) + .any(|candidate| is_executable_file(&candidate)) +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +async fn run_interactive_command( + terminal: &mut Terminal>, + env: &[(String, String)], + command: &[String], + cwd: Option<&Path>, +) -> io::Result<()> { + let Some((program, args)) = command.split_first() else { + return Err(io::Error::other("interactive command must not be empty")); + }; + + restore_terminal(terminal)?; + let mut process = Command::new(program); + process + .args(args) + .envs(env.iter().cloned()) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(cwd) = cwd { + process.current_dir(cwd); + } + let status = process.status().await; + let setup_result = setup_terminal().map(|new_terminal| { + *terminal = new_terminal; + }); + + match (status, setup_result) { + (_, Err(err)) => Err(err), + (Ok(status), Ok(())) if status.success() => Ok(()), + (Ok(status), Ok(())) => Err(io::Error::other(format!( + "interactive command exited with status {status}" + ))), + (Err(err), Ok(())) => Err(err), + } +} + pub(crate) async fn set_tmux_session_option( session_name: &str, option: &str, @@ -358,6 +661,19 @@ pub(crate) async fn set_tmux_session_option( } } +async fn tmux_session_exists(session_name: &str) -> io::Result { + let status = Command::new("tmux") + .arg("has-session") + .arg("-t") + .arg(session_name) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await?; + Ok(status.success()) +} + pub(crate) fn generate_tmux_session_name(workspace_key: &str) -> String { let sanitized: String = workspace_key .chars() diff --git a/tui/src/render.rs b/tui/src/render.rs index b33cc45..abc41b1 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -18,6 +18,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.machine_total_ram_bytes, app.machine_agent_directory_disk_usage, ); + let entries = app.table_entries(); let ( workspace_width, server_width, @@ -26,6 +27,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -36,6 +38,22 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { &create_row_cpu_raw, &create_row_ram_raw, ); + let workspace_width = entries.iter().fold(workspace_width, |width, entry| { + let label = match entry { + TableEntry::Create => CREATE_ROW_LABEL.to_string(), + TableEntry::Workspace { workspace_key } => workspace_key.clone(), + TableEntry::Task { + workspace_key, + task_id, + } => app + .snapshots + .get(workspace_key) + .and_then(|snapshot| task_persistent_snapshot(snapshot, task_id)) + .map(task_row_label) + .unwrap_or_else(|| "➡️ task".to_string()), + }; + width.max(content_width(&label)) + }); let create_row_cpu = right_align_cell_text(&create_row_cpu_raw, cpu_width); let create_row_ram = right_align_cell_text(&create_row_ram_raw, ram_width); let workspace_memory_high_bytes = multicode_lib::services::parse_optional_size_bytes( @@ -45,7 +63,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { .ok() .flatten(); - let mut rows = Vec::with_capacity(app.ordered_keys.len() + 1); + let mut rows = Vec::with_capacity(entries.len()); rows.push( Row::new(vec![ Cell::from(CREATE_ROW_LABEL), @@ -58,138 +76,311 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(""), Cell::from(""), Cell::from(""), + Cell::from(""), Cell::from(create_row_description), ]) .style(Style::default().fg(CREATE_ROW_COLOR)), ); - for key in &app.ordered_keys { - if let Some(snapshot) = app.snapshots.get(key) { - let archived = snapshot.persistent.archived; - let user_description = if app.mode == UiMode::EditDescription - && app.selected_workspace_key() == Some(key.as_str()) - { - format!("{}▏", app.edit_input) - } else { - snapshot.persistent.description.clone() - }; - let selected_link_index: Option = if app.mode == UiMode::Normal - && app.selected_workspace_key() == Some(key.as_str()) - { - app.selected_link_index - } else { - None - }; - let links = selectable_workspace_links( - snapshot, - &app.workspace_link_validation_results, - &app.github_link_statuses, - ); - let selected_link_kind = selected_link_index - .and_then(|index| links.get(index)) - .map(|link| link.kind); - let review_link = links - .iter() - .find(|link| link.kind == WorkspaceLinkKind::Review); - let issue_link = links - .iter() - .find(|link| link.kind == WorkspaceLinkKind::Issue); - let pr_link = links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); - let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); - let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); - let description = Cell::from(description_line_for_snapshot( - snapshot, - user_description.as_str(), - archived, - )); - let cpu = cpu_cell_label(snapshot); - let cpu = right_align_cell_text(&cpu, cpu_width); - let ram = ram_cell_label(snapshot); - let ram = right_align_cell_text(&ram, ram_width); - let cost = cost_cell_label(snapshot); - let cost = right_align_cell_text(&cost, cost_width); - - let review_cell = review_link.map_or_else(Cell::default, |_| { - status_icon_cell( - StatusIconKind::FileDiff, - if archived { - Color::DarkGray - } else { - AGENT_LINK_COLOR - }, - selected_link_kind == Some(WorkspaceLinkKind::Review), - ) - }); - let issue_cell = if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { - let (kind, color) = issue_icon_kind_and_color(issue_status.state); - status_icon_cell( - kind, - if archived { Color::DarkGray } else { color }, - selected_link_kind == Some(WorkspaceLinkKind::Issue), - ) - } else { - Cell::default() - }; - let (pr_cell, build_cell, review_status_cell) = - if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { - let (kind, color) = pr_icon_kind_and_color(*pr_status); - ( + for entry in entries.iter().skip(1) { + match entry { + TableEntry::Workspace { workspace_key: key } => { + let Some(snapshot) = app.snapshots.get(key) else { + continue; + }; + let archived = snapshot.persistent.archived; + let user_description = if app.mode == UiMode::EditDescription + && app.selected_workspace_key() == Some(key.as_str()) + && app.selected_task_id().is_none() + { + format!("{}▏", app.edit_input) + } else { + snapshot.persistent.description.clone() + }; + let selected_link_index: Option = if app.mode == UiMode::Normal + && app.selected_workspace_key() == Some(key.as_str()) + && app.selected_task_id().is_none() + { + app.selected_link_index + } else { + None + }; + let links = selectable_workspace_links( + snapshot, + &app.workspace_link_validation_results, + &app.github_link_statuses, + ); + let selected_link_kind = selected_link_index + .and_then(|index| links.get(index)) + .map(|link| link.kind); + let review_link = links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Review); + let issue_link = links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Issue); + let pr_link = links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); + let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); + let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); + let description = Cell::from(description_line_for_snapshot( + snapshot, + user_description.as_str(), + archived, + )); + let cpu = cpu_cell_label(snapshot); + let cpu = right_align_cell_text(&cpu, cpu_width); + let ram = ram_cell_label(snapshot); + let ram = right_align_cell_text(&ram, ram_width); + let cost = cost_cell_label(snapshot); + let cost = right_align_cell_text(&cost, cost_width); + + let review_cell = review_link.map_or_else(Cell::default, |_| { + status_icon_cell( + StatusIconKind::FileDiff, + if archived { + Color::DarkGray + } else { + AGENT_LINK_COLOR + }, + selected_link_kind == Some(WorkspaceLinkKind::Review), + ) + }); + let issue_cell = + if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { + let (kind, color) = issue_icon_kind_and_color(issue_status.state); status_icon_cell( kind, if archived { Color::DarkGray } else { color }, - selected_link_kind == Some(WorkspaceLinkKind::Pr), - ), - pr_build_icon_color(*pr_status).map_or_else(Cell::default, |build_color| { + selected_link_kind == Some(WorkspaceLinkKind::Issue), + ) + } else { + Cell::default() + }; + let task_type_cell = issue_type_cell( + workspace_issue_type(snapshot), + workspace_active_task(snapshot) + .and_then(|task| task.issue_type_glyph.as_deref()), + archived, + ); + let (pr_cell, build_cell, review_status_cell) = + if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + let (kind, color) = pr_icon_kind_and_color(*pr_status); + ( + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Pr), + ), + pr_build_icon_color(*pr_status).map_or_else( + Cell::default, + |build_color| { + status_icon_cell( + StatusIconKind::Server, + if archived { + Color::DarkGray + } else { + build_color + }, + false, + ) + }, + ), + pr_review_icon_color(*pr_status).map_or_else( + Cell::default, + |review_color| { + status_icon_cell( + StatusIconKind::Eye, + if archived { + Color::DarkGray + } else { + review_color + }, + false, + ) + }, + ), + ) + } else if pr_link.is_some() { + ( status_icon_cell( - StatusIconKind::Server, + StatusIconKind::GitPullRequest, if archived { Color::DarkGray } else { - build_color + Color::Green }, - false, - ) - }), - pr_review_icon_color(*pr_status).map_or_else( - Cell::default, - |review_color| { - status_icon_cell( - StatusIconKind::Eye, - if archived { - Color::DarkGray - } else { - review_color - }, - false, - ) + selected_link_kind == Some(WorkspaceLinkKind::Pr), + ), + Cell::default(), + Cell::default(), + ) + } else { + (Cell::default(), Cell::default(), Cell::default()) + }; + + rows.push( + Row::new(vec![ + Cell::from(key.clone()), + Cell::from(server_cell_label(snapshot)) + .style(server_cell_style(snapshot, archived)), + Cell::from(cpu), + Cell::from(ram).style(ram_cell_style( + snapshot, + workspace_memory_high_bytes, + archived, + )), + Cell::from(cost), + review_cell, + issue_cell, + task_type_cell, + pr_cell, + build_cell, + review_status_cell, + description, + ]) + .style(workspace_row_style(snapshot)), + ); + } + TableEntry::Task { + workspace_key, + task_id, + } => { + let Some(snapshot) = app.snapshots.get(workspace_key) else { + continue; + }; + let Some(task) = task_persistent_snapshot(snapshot, task_id) else { + continue; + }; + let task_state = task_runtime_snapshot(snapshot, task_id); + let archived = snapshot.persistent.archived; + let selected_link_index: Option = if app.mode == UiMode::Normal + && app.selected_workspace_key() == Some(workspace_key.as_str()) + && app.selected_task_id() == Some(task_id.as_str()) + { + app.selected_link_index + } else { + None + }; + let task_links = + visible_task_links(task, task_state, &app.workspace_link_validation_results); + let selected_link_kind = selected_link_index + .and_then(|index| task_links.get(index)) + .map(|link| link.kind); + let issue_link = task_links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Issue); + let pr_link = task_links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Pr); + let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); + let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); + let issue_cell = + if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { + let (kind, color) = issue_icon_kind_and_color(issue_status.state); + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Issue), + ) + } else { + Cell::from(github_link_badge(task_issue_link(task, task_state))).style( + if selected_link_kind == Some(WorkspaceLinkKind::Issue) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() }, - ), + ) + }; + let pr_cell = if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + let (kind, color) = pr_icon_kind_and_color(*pr_status); + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Pr), + ) + } else if pr_link.is_some() { + status_icon_cell( + StatusIconKind::GitPullRequest, + if archived { + Color::DarkGray + } else { + Color::Green + }, + selected_link_kind == Some(WorkspaceLinkKind::Pr), ) } else { - (Cell::default(), Cell::default(), Cell::default()) + Cell::from( + task_pr_link(task, task_state) + .map(github_link_badge) + .unwrap_or_default(), + ) + .style( + if selected_link_kind == Some(WorkspaceLinkKind::Pr) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }, + ) }; - - rows.push( - Row::new(vec![ - Cell::from(key.clone()), - Cell::from(server_cell_label(snapshot)) - .style(server_cell_style(snapshot, archived)), - Cell::from(cpu), - Cell::from(ram).style(ram_cell_style( - snapshot, - workspace_memory_high_bytes, - archived, - )), - Cell::from(cost), - review_cell, - issue_cell, - pr_cell, - build_cell, - review_status_cell, - description, - ]) - .style(workspace_row_style(snapshot)), - ); + let cost = task_cost_cell_label(task_state); + let cost = right_align_cell_text(&cost, cost_width); + let (build_cell, review_status_cell) = + if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + ( + pr_build_icon_color(*pr_status).map_or_else( + Cell::default, + |build_color| { + status_icon_cell( + StatusIconKind::Server, + if archived { + Color::DarkGray + } else { + build_color + }, + false, + ) + }, + ), + pr_review_icon_color(*pr_status).map_or_else( + Cell::default, + |review_color| { + status_icon_cell( + StatusIconKind::Eye, + if archived { + Color::DarkGray + } else { + review_color + }, + false, + ) + }, + ), + ) + } else { + (Cell::default(), Cell::default()) + }; + let task_type_cell = + issue_type_cell(task.issue_type, task.issue_type_glyph.as_deref(), archived); + rows.push( + Row::new(vec![ + Cell::from(task_row_label(task)), + Cell::from(task_server_label(task_state)) + .style(task_server_style(task_state, archived)), + Cell::from(""), + Cell::from(""), + Cell::from(cost), + Cell::from(""), + issue_cell, + task_type_cell, + pr_cell, + build_cell, + review_status_cell, + Cell::from(task_description(task, task_state)), + ]) + .style(workspace_row_style(snapshot)), + ); + } + TableEntry::Create => {} } } @@ -203,6 +394,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Constraint::Length(cost_width), Constraint::Length(re_width), Constraint::Length(is_width), + Constraint::Length(t_width), Constraint::Length(pr_width), Constraint::Length(build_width), Constraint::Length(review_width), @@ -219,6 +411,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(right_align_cell_text("Cost", cost_width)), Cell::from("RE"), Cell::from("IS"), + Cell::from("T"), Cell::from("PR"), Cell::from("B"), Cell::from("R"), @@ -235,13 +428,15 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { let mut table_state = TableState::default(); table_state.select(Some(app.selected_row)); + frame.render_widget(Clear, chunks[0]); frame.render_stateful_widget(table, chunks[0], &mut table_state); let help = help_line( app.mode, app.selected_row, - app.ordered_keys.len(), + entries.len().saturating_sub(1), app.selected_workspace_snapshot(), + app.selected_task_id().is_some(), app.selected_workspace_link_count(), app.selected_link_index, app.selected_workspace_link() @@ -249,14 +444,31 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.selected_workspace_link() .is_some_and(|link| link.value.is_empty()), app.selected_workspace_link().map(|link| link.kind), - app.selected_workspace_has_refreshable_github_link(), + app.selected_workspace_has_refreshable_github_link() + || app + .selected_workspace_snapshot() + .is_some_and(|snapshot| snapshot.persistent.assigned_repository.is_some()), + app.selected_workspace_snapshot().is_some_and(|snapshot| { + workspace_is_usable(snapshot) && snapshot.persistent.assigned_repository.is_some() + }) && app.selected_link_index.is_none(), + app.selected_workspace_can_diff(), + app.selected_workspace_can_edit(), + app.selected_task_can_request_ci_fix(), + app.running_operation_is_cancellable(), &app.contextual_tool_hotkeys(), &app.status, ); frame.render_widget(Paragraph::new(help), chunks[1]); if app.mode == UiMode::CreateModal { - draw_create_modal(frame, &app.create_input); + draw_create_modal( + frame, + &app.create_input, + &app.repository_input, + app.create_field, + ); + } else if app.mode == UiMode::EditIssue { + draw_issue_modal(frame, &app.issue_input); } else if app.mode == UiMode::EditCustomLink { draw_custom_link_modal( frame, @@ -264,10 +476,65 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.custom_link_action, &app.custom_link_input, ); + } else if app.mode == UiMode::ConfirmDelete + && let Some(target) = app.pending_delete_target.as_ref() + { + match target { + PendingDeleteTarget::Workspace { workspace_key } => { + draw_confirm_delete_modal( + frame, + " Delete workspace ", + &format!( + "Delete workspace '{workspace_key}'? This stops the workspace and removes its files and containers." + ), + ); + } + PendingDeleteTarget::Task { + workspace_key, + task_id, + } => { + let task_label = app + .snapshots + .get(workspace_key) + .and_then(|snapshot| task_persistent_snapshot(snapshot, task_id)) + .map(task_row_label) + .unwrap_or_else(|| task_id.clone()); + draw_confirm_delete_modal( + frame, + " Delete task ", + &format!( + "Delete task '{task_label}' from workspace '{workspace_key}'? This removes the task worktree and multicode tracking." + ), + ); + } + } + } else if app.mode == UiMode::ConfirmTaskRemoval + && let Some(PendingDeleteTarget::Task { + workspace_key, + task_id, + }) = app.pending_delete_target.as_ref() + { + let task_label = app + .snapshots + .get(workspace_key) + .and_then(|snapshot| task_persistent_snapshot(snapshot, task_id)) + .map(task_row_label) + .unwrap_or_else(|| task_id.clone()); + draw_confirm_task_removal_modal( + frame, + &format!( + "Remove task '{task_label}' from workspace '{workspace_key}'? This removes the issue from the queue." + ), + app.pending_task_removal_action, + ); } else if app.mode == UiMode::StartingModal && let Some(workspace_key) = app.starting_workspace_key.as_deref() { - draw_starting_modal(frame, workspace_key); + let detail = app + .snapshots + .get(workspace_key) + .and_then(|snapshot| snapshot.automation_status.as_deref()); + draw_starting_modal(frame, workspace_key, detail); } else if app.mode == UiMode::ToolProgressModal && let Some((tool_name, progress)) = app.running_tool_progress() { @@ -290,6 +557,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -305,8 +573,14 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } } -fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str) { - let block = Block::default().borders(Borders::ALL); +fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str, active: bool) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(if active { + Style::default().fg(Color::LightBlue) + } else { + Style::default() + }); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget( @@ -314,12 +588,38 @@ fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str) { inner, ); - let cursor_offset = input.chars().count() as u16; - let max_offset = inner.width.saturating_sub(1); - frame.set_cursor_position(( - inner.x.saturating_add(cursor_offset.min(max_offset)), - inner.y, - )); + if active { + let cursor_offset = input.chars().count() as u16; + let max_offset = inner.width.saturating_sub(1); + frame.set_cursor_position(( + inner.x.saturating_add(cursor_offset.min(max_offset)), + inner.y, + )); + } +} + +fn draw_issue_modal(frame: &mut Frame, input: &str) { + let area = centered_rect_fixed( + CREATE_MODAL_WIDTH.max(72), + CREATE_MODAL_HEIGHT, + frame.area(), + ); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Assign issue ") + .borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Length(3)]) + .split(inner); + frame.render_widget( + Paragraph::new("Issue number or GitHub issue URL. Leave empty to clear.") + .wrap(Wrap { trim: true }), + vertical[0], + ); + draw_modal_text_input(frame, vertical[1], input, true); } pub(crate) fn selected_link_tooltip_area( @@ -327,7 +627,7 @@ pub(crate) fn selected_link_tooltip_area( selected_row: usize, selected_link_kind: WorkspaceLinkKind, targets: &[(String, bool)], - column_widths: [u16; 10], + column_widths: [u16; 11], ) -> Option { if selected_row == 0 || targets.is_empty() { return None; @@ -343,7 +643,7 @@ pub(crate) fn selected_link_tooltip_area( let tooltip_column_index = match selected_link_kind { WorkspaceLinkKind::Review => 5, WorkspaceLinkKind::Issue => 6, - WorkspaceLinkKind::Pr => 7, + WorkspaceLinkKind::Pr => 8, }; let mut x = table_inner.x; for width in column_widths.iter().take(tooltip_column_index) { @@ -453,7 +753,12 @@ fn status_icon_cell(kind: StatusIconKind, color: Color, reversed: bool) -> Cell< Cell::from(format!("{} ", icon_glyph(kind))).style(style) } -pub(crate) fn draw_create_modal(frame: &mut Frame, input: &str) { +pub(crate) fn draw_create_modal( + frame: &mut Frame, + key_input: &str, + repository_input: &str, + active_field: CreateModalField, +) { let area = centered_rect_fixed(CREATE_MODAL_WIDTH, CREATE_MODAL_HEIGHT, frame.area()); frame.render_widget(Clear, area); @@ -467,23 +772,145 @@ pub(crate) fn draw_create_modal(frame: &mut Frame, input: &str) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Fill(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new("Workspace key").style(Style::default().fg(Color::DarkGray)), + rows[0], + ); + draw_modal_text_input( + frame, + rows[1], + key_input, + active_field == CreateModalField::Key, + ); + frame.render_widget( + Paragraph::new("GitHub repository (owner/repo or URL)") + .style(Style::default().fg(Color::DarkGray)), + rows[2], + ); + draw_modal_text_input( + frame, + rows[3], + repository_input, + active_field == CreateModalField::Repository, + ); + frame.render_widget( + Paragraph::new("Tab to switch fields · Enter to create · Esc to cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)), + rows[4], + ); +} + +fn draw_confirm_delete_modal(frame: &mut Frame, title: &str, message: &str) { + let area = centered_rect_fixed( + CONFIRM_DELETE_MODAL_WIDTH, + CONFIRM_DELETE_MODAL_HEIGHT, + frame.area(), + ); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(1), Constraint::Fill(1), ]) .split(inner); frame.render_widget( - Paragraph::new("Enter a workspace key") + Paragraph::new(message) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + rows[1], + ); + frame.render_widget( + Paragraph::new("Enter to delete · Esc to cancel") .alignment(Alignment::Center) .style(Style::default().fg(Color::DarkGray)), + rows[2], + ); +} + +fn draw_confirm_task_removal_modal( + frame: &mut Frame, + message: &str, + selected_action: TaskRemovalAction, +) { + let area = centered_rect_fixed( + CONFIRM_TASK_REMOVAL_MODAL_WIDTH, + CONFIRM_TASK_REMOVAL_MODAL_HEIGHT, + frame.area(), + ); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Remove issue ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(message) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), rows[1], ); - draw_modal_text_input(frame, rows[2], input); + + let actions = [ + TaskRemovalAction::Remove, + TaskRemovalAction::RemoveAndIgnore, + TaskRemovalAction::Cancel, + ]; + let mut spans = Vec::new(); + for (index, action) in actions.into_iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + let style = if action == selected_action { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(format!(" {} ", action.label()), style)); + } frame.render_widget( - Paragraph::new("Enter to create · Esc to cancel") + Paragraph::new(Line::from(spans)).alignment(Alignment::Center), + rows[2], + ); + frame.render_widget( + Paragraph::new("Left/Right to choose · Enter to confirm · Esc to cancel") .alignment(Alignment::Center) .style(Style::default().fg(Color::DarkGray)), rows[3], @@ -540,7 +967,7 @@ pub(crate) fn draw_custom_link_modal( .style(Style::default().fg(Color::DarkGray)), rows[1], ); - draw_modal_text_input(frame, rows[2], input); + draw_modal_text_input(frame, rows[2], input, true); frame.render_widget( Paragraph::new(footer) .alignment(Alignment::Center) @@ -549,7 +976,7 @@ pub(crate) fn draw_custom_link_modal( ); } -pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str) { +pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str, detail: Option<&str>) { let area = centered_rect_fixed(STARTING_MODAL_WIDTH, STARTING_MODAL_HEIGHT, frame.area()); frame.render_widget(Clear, area); @@ -579,9 +1006,19 @@ pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str) { frame.render_widget( Paragraph::new("Waiting for server readiness...") .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) .style(Style::default().fg(Color::DarkGray)), rows[2], ); + if let Some(detail) = detail.map(str::trim).filter(|detail| !detail.is_empty()) { + frame.render_widget( + Paragraph::new(detail.to_string()) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Gray)), + rows[3], + ); + } } pub(crate) fn draw_tool_progress_modal(frame: &mut Frame, tool_name: &str, progress: &str) { diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 866ebc3..128f6cc 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -2,17 +2,37 @@ use crate::*; #[cfg(test)] mod tests { - use multicode_lib::services::HandlerConfig; + use multicode_lib::{ + AutomationAgentState, RootSessionStatus, + services::{ + CompareConfig, CompareTool, HandlerConfig, codex_app_server::CodexThreadStatus, + }, + }; use super::*; - use crate::app::compact_github_tooltip_target; + use crate::app::{ + build_codex_fix_ci_prompt, compact_github_tooltip_target, count_codex_session_turn_metrics, + github_repository_spec, github_repository_url, + last_user_message_from_codex_session_log_contents, repository_diff_shell_command, + restored_selected_row, shell_command_in_repo, + should_auto_resume_autonomous_codex_after_attach, + should_auto_resume_task_codex_after_attach, should_offer_codex_ci_fix, + should_queue_task_codex_resume_until_vm_available, should_restart_codex_task_for_ci_fix, + should_restart_codex_task_for_pr_request, should_restart_task_codex_after_attach, + should_resume_codex_task_after_incomplete_attached_turn, + should_retry_codex_task_attach_with_last_thread, + should_start_fresh_codex_task_session_after_failed_attach, + snapshot_attach_cwd_for_selection, snapshot_attach_target_for_selection, + starting_modal_failure_status, task_repository_spec, working_codex_task_attach_target, + }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, pr_review_icon_color, }; use crate::ops::{ - SessionWaitState, attach_cli_args, build_handler_command, session_wait_state_for_entry, - tmux_session_command, tmux_status_left, validate_workspace_link_target, + SessionWaitState, attach_cli_args, build_handler_command, command_exists, + compare_tool_is_available, compare_tool_name, session_wait_state_for_entry, + task_attach_target, tmux_session_command, tmux_status_left, validate_workspace_link_target, workspace_attach_target, workspace_ordering, }; use crate::render::selected_link_tooltip_area; @@ -21,28 +41,120 @@ mod tests { parse_proc_meminfo_total_ram_bytes, parse_proc_meminfo_used_ram_bytes, started_workspace_attach_ready, }; - use multicode_lib::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use multicode_lib::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, services::AgentProvider, + }; use std::{ fs, + os::unix::fs::PermissionsExt, path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use tokio::sync::broadcast; + static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + struct TestDir { path: PathBuf, } + fn help_line( + mode: UiMode, + selected_row: usize, + workspace_count: usize, + selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, + selected_workspace_link_count: usize, + selected_link_index: Option, + selected_link_is_custom: bool, + selected_link_is_placeholder: bool, + selected_link_kind: Option, + selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_issue: bool, + selected_workspace_can_diff: bool, + selected_workspace_can_edit: bool, + tool_progress_can_cancel: bool, + tool_hotkeys: &[(String, String)], + status: &str, + ) -> Line<'static> { + super::help_line( + mode, + selected_row, + workspace_count, + selected_workspace, + selected_task_row, + selected_workspace_link_count, + selected_link_index, + selected_link_is_custom, + selected_link_is_placeholder, + selected_link_kind, + selected_workspace_has_refreshable_github_link, + selected_workspace_can_assign_issue, + selected_workspace_can_diff, + selected_workspace_can_edit, + false, + tool_progress_can_cancel, + tool_hotkeys, + status, + ) + } + + fn help_line_with_task_fix( + mode: UiMode, + selected_row: usize, + workspace_count: usize, + selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, + selected_workspace_link_count: usize, + selected_link_index: Option, + selected_link_is_custom: bool, + selected_link_is_placeholder: bool, + selected_link_kind: Option, + selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_issue: bool, + selected_workspace_can_diff: bool, + selected_workspace_can_edit: bool, + selected_task_can_fix_ci: bool, + tool_progress_can_cancel: bool, + tool_hotkeys: &[(String, String)], + status: &str, + ) -> Line<'static> { + super::help_line( + mode, + selected_row, + workspace_count, + selected_workspace, + selected_task_row, + selected_workspace_link_count, + selected_link_index, + selected_link_is_custom, + selected_link_is_placeholder, + selected_link_kind, + selected_workspace_has_refreshable_github_link, + selected_workspace_can_assign_issue, + selected_workspace_can_diff, + selected_workspace_can_edit, + selected_task_can_fix_ci, + tool_progress_can_cancel, + tool_hotkeys, + status, + ) + } + impl TestDir { fn new() -> Self { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after unix epoch") .as_nanos(); + let counter = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( - "multicode-tui-test-{}-{}", + "multicode-tui-test-{}-{}-{}", std::process::id(), - unique + unique, + counter, )); fs::create_dir_all(&path).expect("test dir should be created"); Self { path } @@ -59,12 +171,46 @@ mod tests { } } + fn create_fake_git_repo(path: &std::path::Path) { + fs::create_dir_all(path).expect("repo root should be created"); + let git_dir = path.join(".git"); + fs::create_dir_all(&git_dir).expect("git dir should be created"); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD should be created"); + fs::write( + git_dir.join("config"), + "[core]\n\trepositoryformatversion = 0\n", + ) + .expect("config should be created"); + fs::create_dir_all(git_dir.join("objects")).expect("objects dir should be created"); + } + + fn create_fake_git_worktree(path: &std::path::Path, common_dir: &std::path::Path) { + fs::create_dir_all(path).expect("worktree root should be created"); + let git_dir = common_dir + .join("worktrees") + .join(path.file_name().expect("worktree should have name")); + fs::create_dir_all(&git_dir).expect("worktree git dir should be created"); + fs::write( + path.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .expect("worktree .git file should be created"); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n") + .expect("worktree HEAD should be created"); + fs::write(git_dir.join("commondir"), "../..\n") + .expect("worktree commondir should be created"); + } + fn snapshot(started: bool, uri: Option<&str>) -> WorkspaceSnapshot { WorkspaceSnapshot { persistent: PersistentWorkspaceSnapshot::default(), transient: uri.map(|uri| TransientWorkspaceSnapshot { uri: uri.to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: started.then(|| multicode_lib::OpencodeClientSnapshot { client: std::sync::Arc::new(multicode_lib::opencode::client::Client::new( @@ -75,6 +221,14 @@ mod tests { root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, + automation_status: None, + automation_scan_request_nonce: 0, + automation_queue_next_request_nonce: 0, + active_task_id: None, + task_states: Default::default(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -88,12 +242,24 @@ mod tests { persistent: PersistentWorkspaceSnapshot::default(), transient: Some(TransientWorkspaceSnapshot { uri: "http://127.0.0.1".to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: None, root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, + automation_status: None, + automation_scan_request_nonce: 0, + automation_queue_next_request_nonce: 0, + active_task_id: None, + task_states: Default::default(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -106,759 +272,2568 @@ mod tests { &[].as_slice() } + fn assign_active_task(snapshot: &mut WorkspaceSnapshot, issue_url: &str) { + let task_id = "task-42".to_string(); + snapshot + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + task_id.clone(), + issue_url.to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some(task_id); + } + #[test] - fn workspace_attach_target_requires_started_state() { - let err = workspace_attach_target(&snapshot(false, Some("http://example"))) - .expect_err("non-started workspace should not provide attach URI"); - assert!( - err.to_string() - .contains("workspace must be in Started state before attaching") + fn restored_selected_row_preserves_selected_task_row() { + let entries = vec![ + TableEntry::Create, + TableEntry::Workspace { + workspace_key: "test123".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-16".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }, + ]; + + let selected = restored_selected_row( + &entries, + Some(&TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }), + 3, ); + + assert_eq!(selected, 3); } #[test] - fn workspace_attach_target_requires_transient_uri() { - let err = workspace_attach_target(&WorkspaceSnapshot::default()) - .expect_err("stopped workspace without transient snapshot should fail"); - assert!( - err.to_string() - .contains("workspace must be in Started state before attaching") + fn restored_selected_row_falls_back_to_workspace_when_task_disappears() { + let entries = vec![ + TableEntry::Create, + TableEntry::Workspace { + workspace_key: "test123".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-16".to_string(), + }, + ]; + + let selected = restored_selected_row( + &entries, + Some(&TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }), + 3, ); + + assert_eq!(selected, 1); } #[test] - fn workspace_attach_target_extracts_credentials_and_sanitizes_uri() { - let started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); - let target = workspace_attach_target(&started) - .expect("started workspace should expose attach target with auth"); - assert_eq!( - target, - AttachTarget { - uri: "http://127.0.0.1:3000/".to_string(), - username: "opencode".to_string(), - password: "secret".to_string(), - session_id: None, - } + fn task_issue_reference_uses_repo_and_issue_number_only() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-8".to_string(), + "https://github.com/graemerocher/multicode-test/issues/8".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, ); + + assert_eq!(crate::task_row_label(&task), "➡️ multicode-test#8"); } #[test] - fn workspace_attach_target_includes_latest_root_session_id() { - let mut started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); - started.root_session_id = Some("ses-root-latest".to_string()); - - let target = workspace_attach_target(&started) - .expect("started workspace should expose attach target with latest root session id"); + fn task_issue_reference_trims_micronaut_prefix_from_repo_name() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-12".to_string(), + "https://github.com/micronaut-projects/micronaut-graphql/issues/12".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); - assert_eq!(target.session_id.as_deref(), Some("ses-root-latest")); + assert_eq!(crate::task_row_label(&task), "➡️ graphql#12"); } #[test] - fn attach_cli_args_appends_session_when_present() { - let target = AttachTarget { - uri: "http://127.0.0.1:3000/".to_string(), - username: "opencode".to_string(), - password: "secret".to_string(), - session_id: Some("ses-root-latest".to_string()), - }; + fn task_issue_link_defaults_to_persistent_issue_url() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); assert_eq!( - attach_cli_args("opencode", &target), - vec![ - "opencode".to_string(), - "attach".to_string(), - "--session".to_string(), - "ses-root-latest".to_string(), - "http://127.0.0.1:3000/".to_string(), - ] + crate::task_issue_link(&task, None), + "https://github.com/graemerocher/multicode-test/issues/1" ); } #[test] - fn attach_cli_args_omits_session_when_unavailable() { - let target = AttachTarget { - uri: "http://127.0.0.1:3000/".to_string(), - username: "opencode".to_string(), - password: "secret".to_string(), - session_id: None, - }; - + fn github_link_badge_uses_terminal_issue_or_pr_number() { assert_eq!( - attach_cli_args("opencode", &target), - vec![ - "opencode".to_string(), - "attach".to_string(), - "http://127.0.0.1:3000/".to_string(), - ] + crate::github_link_badge("https://github.com/graemerocher/multicode-test/issues/7"), + "#7" + ); + assert_eq!( + crate::github_link_badge("https://github.com/graemerocher/multicode-test/pull/12"), + "#12" ); } #[test] - fn tmux_session_command_restores_original_term_inside_session() { - let command = vec!["opencode".to_string(), "attach".to_string()]; - - assert_eq!( - tmux_session_command(command, Some("screen-256color")), - vec![ - "env".to_string(), - "TERM=screen-256color".to_string(), - "opencode".to_string(), - "attach".to_string(), - ] + fn task_links_expose_issue_and_pr_for_task_rows() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, ); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + pr: vec!["https://github.com/graemerocher/multicode-test/pull/8".to_string()], + ..Default::default() + }; + + let links = crate::task_links(&task, Some(&task_state)); + assert_eq!(links.len(), 2); + assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); } #[test] - fn tmux_session_command_skips_term_override_when_unset() { - let command = vec!["opencode".to_string(), "attach".to_string()]; + fn last_user_message_from_codex_session_log_prefers_real_user_events() { + let contents = r#"{"type":"event_msg","payload":{"type":"user_message","message":"first prompt"}} +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\nThe user interrupted the previous turn on purpose.\n"}]}} +{"type":"event_msg","payload":{"type":"user_message","message":"create a PR"}}"#; assert_eq!( - tmux_session_command(command, None), - vec![ - "env".to_string(), - "opencode".to_string(), - "attach".to_string(), - ] + last_user_message_from_codex_session_log_contents(contents).as_deref(), + Some("create a PR") ); } #[test] - fn tui_cli_args_accept_optional_relay_socket() { - let parsed = crate::CliArgs::try_parse_from([ - "multicode-tui", - "config.toml", - "--relay-socket", - "/tmp/multicode-relay.sock", - ]) - .expect("cli args should parse"); - assert_eq!(parsed.config_path, PathBuf::from("config.toml")); + fn task_links_fall_back_to_persistent_backing_pr_url() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some( + "https://github.com/graemerocher/multicode-test/pull/8".to_string(), + )); + + let links = crate::task_links(&task, None); + assert_eq!(links.len(), 2); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); assert_eq!( - parsed.relay_socket, - Some(PathBuf::from("/tmp/multicode-relay.sock")) + links[1].value, + "https://github.com/graemerocher/multicode-test/pull/8" ); } #[test] - fn tui_cli_args_accept_hidden_recency_scan_flags() { - let parsed = crate::CliArgs::try_parse_from([ - "multicode-tui", - "config.toml", - "--recency-scan-path", - "/tmp/worktree", - "--recency-scan-is-dir", - "--recency-scan-exclude", - ".multicode/remote", - "--recency-scan-exclude", - "node_modules", - ]) - .expect("hidden recency scan args should parse"); - assert_eq!(parsed.config_path, PathBuf::from("config.toml")); + fn issue_type_icon_mappings_use_expected_glyph_kinds() { assert_eq!( - parsed.recency_scan_path, - Some(PathBuf::from("/tmp/worktree")) + crate::icons::issue_type_icon_kind_and_color(multicode_lib::WorkspaceIssueType::Bug), + (StatusIconKind::Bug, Color::Red) ); - assert!(parsed.recency_scan_is_dir); assert_eq!( - parsed.recency_scan_exclude, - vec![".multicode/remote".to_string(), "node_modules".to_string()] + crate::icons::issue_type_icon_kind_and_color(multicode_lib::WorkspaceIssueType::Docs), + (StatusIconKind::Docs, Color::LightBlue) + ); + assert_eq!( + crate::icons::issue_type_icon_kind_and_color( + multicode_lib::WorkspaceIssueType::DependencyUpgrade + ), + (StatusIconKind::DependencyUpgrade, Color::Cyan) ); } #[test] - fn workspace_attach_target_requires_username_and_password() { - let missing_username = snapshot(true, Some("http://:secret@127.0.0.1/")); - let err = workspace_attach_target(&missing_username) - .expect_err("URI without username should fail"); - assert!( - err.to_string() - .contains("workspace attach URI is missing username credentials") - ); - - let missing_password = snapshot(true, Some("http://opencode@127.0.0.1/")); - let err = workspace_attach_target(&missing_password) - .expect_err("URI without password should fail"); - assert!( - err.to_string() - .contains("workspace attach URI is missing password credentials") - ); + fn content_width_uses_terminal_display_width() { + assert_eq!(crate::content_width("A"), 1); + assert_eq!(crate::content_width(icon_glyph(StatusIconKind::Bug)), 1); + assert_eq!(crate::content_width(icon_glyph(StatusIconKind::Docs)), 1); } #[test] - fn build_handler_command_replaces_placeholder_argument() { - let (program, args) = build_handler_command( - "/usr/bin/firefox {}", - multicode_lib::HandlerArgumentMode::Argument, - "https://example.com", - ) - .expect("handler command should parse"); - assert_eq!(program, "/usr/bin/firefox"); - assert_eq!(args, vec!["https://example.com".to_string()]); + fn workspace_links_prefer_active_task_issue_and_pr_when_tasks_exist() { + let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + if let Some(task) = started + .persistent + .tasks + .iter_mut() + .find(|task| task.id == "task-42") + { + task.backing_pr_url = Some("https://github.com/example/repo/pull/322".to_string()); + } + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + ..Default::default() + }, + ); + + let links = workspace_issue_pr_links(&started); + assert_eq!(links.len(), 2); + assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[0].value, "https://github.com/example/repo/issues/42"); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); + assert_eq!(links[1].value, "https://github.com/example/repo/pull/322"); } #[test] - fn build_handler_command_accepts_review_without_placeholder() { - let (program, args) = build_handler_command( - "/usr/bin/smerge", - multicode_lib::HandlerArgumentMode::Chdir, - "/tmp/repo", - ) - .expect("review handler without placeholder should parse"); - assert_eq!(program, "/usr/bin/smerge"); - assert!(args.is_empty()); + fn workspace_links_keep_active_task_pr_when_task_is_in_review() { + let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + if let Some(task) = started + .persistent + .tasks + .iter_mut() + .find(|task| task.id == "task-42") + { + task.backing_pr_url = Some("https://github.com/example/repo/pull/322".to_string()); + } + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + let links = workspace_issue_pr_links(&started); + assert_eq!(links.len(), 2); + assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); + assert_eq!(links[1].value, "https://github.com/example/repo/pull/322"); } #[test] - fn validate_workspace_link_target_accepts_repo_under_workspace_directory() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); + fn should_request_autonomous_issue_scan_for_assigned_workspace_without_active_issue() { + let mut stopped = WorkspaceSnapshot::default(); + stopped.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + assert!(crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); - runtime.block_on(async { - let root = TestDir::new(); - let workspace_dir = root.path().join("agent-work"); - let repo_dir = workspace_dir.join("core12299").join("micronaut-core"); - fs::create_dir_all(&repo_dir).expect("repo dir should be created"); - fs::create_dir(repo_dir.join(".git")).expect(".git folder should be created"); + stopped + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-989".to_string(), + "https://github.com/micronaut-projects/micronaut-serialization/issues/989" + .to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + assert!(crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); + assert!(!crate::app::should_request_autonomous_issue_scan( + &stopped, 1 + )); - let link = WorkspaceLink { - kind: WorkspaceLinkKind::Review, - value: repo_dir.to_string_lossy().into_owned(), - source: WorkspaceLinkSource::AgentProvided, - }; - let validated = validate_workspace_link_target(&link, &workspace_dir) - .await - .expect("repo directory under workspace root should be accepted"); - assert!(PathBuf::from(validated).is_dir()); - }); + stopped.persistent.archived = true; + assert!(!crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); + + let unassigned = WorkspaceSnapshot::default(); + assert!(!crate::app::should_request_autonomous_issue_scan( + &unassigned, + 5 + )); } #[test] - fn validate_workspace_link_target_rejects_repo_outside_workspace_directory() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); + fn auto_resume_after_attach_only_resumes_active_autonomous_work() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + assign_active_task(&mut snapshot, "https://github.com/example/repo/issues/42"); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); - runtime.block_on(async { - let root = TestDir::new(); - let workspace_dir = root.path().join("agent-work"); - let outside_dir = root.path().join("outside-repo"); - fs::create_dir_all(&workspace_dir).expect("workspace dir should be created"); - fs::create_dir_all(&outside_dir).expect("outside dir should be created"); + assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); - let link = WorkspaceLink { - kind: WorkspaceLinkKind::Review, - value: outside_dir.to_string_lossy().into_owned(), - source: WorkspaceLinkSource::AgentProvided, - }; - let err = validate_workspace_link_target(&link, &workspace_dir) - .await - .expect_err("repo outside workspace root should be rejected"); - assert!(err.to_string().contains("outside workspace directory")); - }); + snapshot.automation_agent_state = Some(AutomationAgentState::Review); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.automation_agent_state = Some(AutomationAgentState::Question); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.automation_agent_state = None; + snapshot.root_session_status = Some(RootSessionStatus::Busy); + assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.root_session_status = Some(RootSessionStatus::Idle); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.active_task_id = None; + snapshot.persistent.tasks.clear(); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); } #[test] - fn validate_workspace_link_target_rejects_repo_without_git_folder() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); - - runtime.block_on(async { - let root = TestDir::new(); - let workspace_dir = root.path().join("agent-work"); - let repo_dir = workspace_dir.join("core12299").join("micronaut-core"); - fs::create_dir_all(&repo_dir).expect("repo dir should be created"); + fn task_server_label_prefers_idle_session_status_over_stale_working_agent_state() { + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/graemerocher/multicode-test/issues/39".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); - let link = WorkspaceLink { - kind: WorkspaceLinkKind::Review, - value: repo_dir.to_string_lossy().into_owned(), - source: WorkspaceLinkSource::AgentProvided, - }; - let err = validate_workspace_link_target(&link, &workspace_dir) - .await - .expect_err("repo without .git folder should be rejected"); - assert!(err.to_string().contains("must contain a '.git' folder")); - }); + assert_eq!(crate::task_server_label(Some(&task_state)), "Idle"); + assert_eq!( + crate::task_description(&task, Some(&task_state)), + "Review multicode-test#39" + ); } #[test] - fn validate_workspace_link_target_rejects_non_https_urls() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); + fn task_description_prefers_pr_created_for_review_task_with_persisted_pr() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/graemerocher/multicode-test/issues/39".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some( + "https://github.com/graemerocher/multicode-test/pull/338".to_string(), + )); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + status: Some("Review multicode-test#39".to_string()), + ..Default::default() + }; - runtime.block_on(async { - let root = TestDir::new(); - let link = WorkspaceLink { - kind: WorkspaceLinkKind::Issue, - value: "http://example.com/issue/1".to_string(), - source: WorkspaceLinkSource::AgentProvided, - }; - let err = validate_workspace_link_target(&link, root.path()) - .await - .expect_err("non-https URL should be rejected"); - assert!(err.to_string().contains("must use https")); - }); + assert_eq!( + crate::task_description(&task, Some(&task_state)), + "PR created #338" + ); } #[test] - fn workspace_links_collect_review_issue_and_pr_entries() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; - started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; - started.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; + fn task_auto_resume_after_attach_only_resumes_when_task_is_still_working() { + let review_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + assert!(!should_auto_resume_task_codex_after_attach( + Some(&review_state), + Some("thread-4"), + Some(AutomationAgentState::Review) + )); - let links = workspace_links(&started); - assert_eq!( - links, - vec![ - WorkspaceLink { - kind: WorkspaceLinkKind::Review, - value: "/tmp/repo-a".to_string(), - source: WorkspaceLinkSource::AgentProvided, - }, - WorkspaceLink { - kind: WorkspaceLinkKind::Issue, - value: "https://example.com/issue/1".to_string(), - source: WorkspaceLinkSource::AgentProvided, - }, - WorkspaceLink { - kind: WorkspaceLinkKind::Pr, - value: "https://example.com/pull/2".to_string(), - source: WorkspaceLinkSource::AgentProvided, - }, - ] - ); + let busy_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + assert!(!should_auto_resume_task_codex_after_attach( + Some(&busy_state), + Some("thread-4"), + Some(AutomationAgentState::Working) + )); } #[test] - fn workspace_links_put_custom_issue_and_pr_before_automatic_links() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.custom_links.issue = - vec!["https://example.com/custom-issue/1".to_string()]; - started.persistent.custom_links.pr = vec!["https://example.com/custom-pr/2".to_string()]; - started.persistent.agent_provided.issue = vec!["https://example.com/issue/3".to_string()]; - started.persistent.agent_provided.pr = vec!["https://example.com/pull/4".to_string()]; + fn task_auto_resume_after_attach_resumes_busy_task_when_session_is_missing() { + let busy_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; - let links = workspace_links(&started); - assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); - assert_eq!(links[0].source, WorkspaceLinkSource::Custom); - assert_eq!(links[1].kind, WorkspaceLinkKind::Issue); - assert_eq!(links[1].source, WorkspaceLinkSource::AgentProvided); - assert_eq!(links[2].kind, WorkspaceLinkKind::Pr); - assert_eq!(links[2].source, WorkspaceLinkSource::Custom); - assert_eq!(links[3].kind, WorkspaceLinkKind::Pr); - assert_eq!(links[3].source, WorkspaceLinkSource::AgentProvided); + assert!(should_auto_resume_task_codex_after_attach( + Some(&busy_state), + None, + Some(AutomationAgentState::Working) + )); } #[test] - fn next_link_selection_right_cycles_back_to_row_after_last_link() { - assert_eq!(next_link_selection_right(None, 0), None); - assert_eq!(next_link_selection_right(None, 3), Some(0)); - assert_eq!(next_link_selection_right(Some(0), 3), Some(1)); - assert_eq!(next_link_selection_right(Some(1), 3), Some(2)); - assert_eq!(next_link_selection_right(Some(2), 3), None); + fn task_auto_resume_after_attach_resumes_when_working_task_loses_session() { + let lost_session_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: None, + session_status: None, + agent_state: None, + ..Default::default() + }; + + assert!(should_auto_resume_task_codex_after_attach( + Some(&lost_session_state), + Some("thread-4"), + Some(AutomationAgentState::Working) + )); } #[test] - fn next_non_stopped_row_skips_stopped_workspaces_in_both_directions() { - let ordered_keys = vec![ - "alpha".to_string(), - "beta".to_string(), - "gamma".to_string(), - "delta".to_string(), - ]; - let snapshots = HashMap::from([ - ("alpha".to_string(), snapshot(false, None)), - ("beta".to_string(), starting_snapshot()), - ("gamma".to_string(), snapshot(false, None)), - ("delta".to_string(), snapshot(true, Some("http://example"))), - ]); + fn task_auto_resume_after_attach_resumes_stale_working_task() { + let stale_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Stale), + ..Default::default() + }; - assert_eq!( - next_non_stopped_row(0, &ordered_keys, &snapshots, 1), - Some(2) - ); - assert_eq!( - next_non_stopped_row(2, &ordered_keys, &snapshots, 1), - Some(4) - ); - assert_eq!( - next_non_stopped_row(4, &ordered_keys, &snapshots, -1), - Some(2) - ); + assert!(should_auto_resume_task_codex_after_attach( + Some(&stale_state), + Some("thread-4"), + Some(AutomationAgentState::Working) + )); } #[test] - fn next_non_stopped_row_returns_none_when_no_match_exists() { - let ordered_keys = vec!["alpha".to_string(), "beta".to_string()]; - let snapshots = HashMap::from([ - ("alpha".to_string(), snapshot(false, None)), - ("beta".to_string(), snapshot(false, None)), - ]); - - assert_eq!(next_non_stopped_row(0, &ordered_keys, &snapshots, 1), None); - assert_eq!(next_non_stopped_row(2, &ordered_keys, &snapshots, -1), None); + fn direct_task_attach_restarts_background_codex_session_after_detach() { + assert!(should_restart_task_codex_after_attach( + Some("thread-4"), + false + )); } #[test] - fn visible_workspace_links_only_include_validated_entries() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; - started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; - started.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; + fn fresh_task_attach_keeps_existing_background_codex_session() { + assert!(!should_restart_task_codex_after_attach( + Some("thread-4"), + true + )); + assert!(!should_restart_task_codex_after_attach(None, true)); + } - let all_links = workspace_links(&started); - let mut validations = HashMap::new(); - validations.insert( - all_links[0].clone(), - WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + #[test] + fn detached_task_resume_queues_when_another_task_owns_vm() { + let mut snapshot = multicode_lib::WorkspaceSnapshot::default(); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + snapshot.task_states.insert( + "task-1".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, ); - validations.insert( - all_links[1].clone(), - WorkspaceLinkValidationResult::Invalid("rejected".to_string()), + snapshot.task_states.insert( + "task-2".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-2".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, ); - let visible = visible_workspace_links(&started, &validations); - assert_eq!(visible[0], all_links[0].clone()); - assert_eq!(visible[1].kind, WorkspaceLinkKind::Issue); - assert!(visible[1].value.is_empty()); - assert_eq!(visible[2].kind, WorkspaceLinkKind::Pr); - assert!(visible[2].value.is_empty()); + assert!(should_queue_task_codex_resume_until_vm_available( + &snapshot, "task-2" + )); } #[test] - fn selectable_workspace_links_include_custom_issue_and_pr_without_github_status() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; - started.persistent.custom_links.issue = vec!["https://example.com/issue/1".to_string()]; - started.persistent.custom_links.pr = vec!["https://example.com/pull/2".to_string()]; - - let all_links = workspace_links(&started); - let mut validations = HashMap::new(); - validations.insert( - all_links[0].clone(), - WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), - ); - validations.insert( - all_links[1].clone(), - WorkspaceLinkValidationResult::Valid("https://example.com/issue/1".to_string()), + fn detached_task_resume_does_not_queue_when_active_task_can_yield_vm() { + let mut snapshot = multicode_lib::WorkspaceSnapshot::default(); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Idle); + snapshot.task_states.insert( + "task-1".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, ); - validations.insert( - all_links[2].clone(), - WorkspaceLinkValidationResult::Valid("https://example.com/pull/2".to_string()), + snapshot.task_states.insert( + "task-2".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-2".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, ); - let selectable = selectable_workspace_links(&started, &validations, &HashMap::new()); - assert_eq!(selectable, all_links); + assert!(!should_queue_task_codex_resume_until_vm_available( + &snapshot, "task-2" + )); } #[test] - fn selectable_workspace_links_include_empty_issue_and_pr_slots_for_custom_add_flow() { - let started = snapshot(true, Some("http://example")); + fn detached_task_resume_does_not_queue_when_it_already_owns_vm() { + let mut snapshot = multicode_lib::WorkspaceSnapshot::default(); + snapshot.active_task_id = Some("task-2".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + snapshot.task_states.insert( + "task-2".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-2".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); - let selectable = selectable_workspace_links(&started, &HashMap::new(), &HashMap::new()); - assert_eq!(selectable.len(), 2); - assert_eq!(selectable[0].kind, WorkspaceLinkKind::Issue); - assert!(selectable[0].value.is_empty()); - assert_eq!(selectable[1].kind, WorkspaceLinkKind::Pr); - assert!(selectable[1].value.is_empty()); + assert!(!should_queue_task_codex_resume_until_vm_available( + &snapshot, "task-2" + )); } #[test] - fn archived_workspace_links_keep_issue_and_pr_but_hide_review() { - let mut archived = snapshot(false, None); - archived.persistent.archived = true; - archived.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; - archived.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; - archived.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; - - let all_links = workspace_links(&archived); - let mut validations = HashMap::new(); - validations.insert( - all_links[0].clone(), - WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), - ); - validations.insert( - all_links[1].clone(), - WorkspaceLinkValidationResult::Valid("https://example.com/issue/1".to_string()), - ); - validations.insert( - all_links[2].clone(), - WorkspaceLinkValidationResult::Valid("https://example.com/pull/2".to_string()), + fn codex_session_turn_metrics_count_started_and_completed_turns() { + let metrics = count_codex_session_turn_metrics( + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}\n\ + {\"type\":\"event_msg\",\"payload\":{\"type\":\"task_complete\"}}\n\ + {\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}\n", ); - let visible = visible_workspace_links(&archived, &validations); - assert_eq!(visible.len(), 2); - assert_eq!(visible[0].kind, WorkspaceLinkKind::Issue); - assert_eq!(visible[1].kind, WorkspaceLinkKind::Pr); + assert_eq!(metrics.started, 2); + assert_eq!(metrics.completed, 1); } #[test] - fn validated_workspace_links_by_kind_keeps_multiple_targets() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.agent_provided.repo = vec![ - "/tmp/repo-a".to_string(), - "/tmp/repo-b".to_string(), - "/tmp/repo-c".to_string(), - ]; - - let all_links = workspace_links(&started); - let mut validations = HashMap::new(); - validations.insert( - all_links[0].clone(), - WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), - ); - validations.insert( - all_links[1].clone(), - WorkspaceLinkValidationResult::Valid("/tmp/repo-b".to_string()), - ); - validations.insert( - all_links[2].clone(), - WorkspaceLinkValidationResult::Invalid("skip".to_string()), - ); + fn incomplete_attached_codex_turn_resumes_when_thread_is_idle() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 3, + aborted: 0, + }), + Some(&CodexThreadStatus::Idle), + )); + } - assert_eq!( - validated_workspace_links_by_kind(&started, &validations, WorkspaceLinkKind::Review,), - vec![all_links[0].clone(), all_links[1].clone()] - ); + #[test] + fn completed_attached_codex_turn_does_not_resume() { + assert!(!should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 4, + aborted: 0, + }), + Some(&CodexThreadStatus::Idle), + )); } #[test] - fn compact_github_tooltip_target_shortens_issue_and_pr_links() { - assert_eq!( - compact_github_tooltip_target( - "https://github.com/micronaut-projects/micronaut-core/issues/1234" - ), - Some("\u{f408} micronaut-projects/micronaut-core#1234".to_string()) + fn active_attached_codex_turn_does_not_resume() { + assert!(!should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 3, + aborted: 0, + }), + Some(&CodexThreadStatus::Active { + active_flags: Vec::new(), + }), + )); + } + + #[test] + fn incomplete_attached_codex_turn_resumes_when_thread_status_is_unavailable() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 1, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 0, + }), + None, + )); + } + + #[test] + fn aborted_attached_codex_turn_resumes_even_if_started_count_did_not_advance() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 1, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + + #[test] + fn workspace_attach_target_requires_started_state() { + let err = workspace_attach_target(&snapshot(false, Some("http://example"))) + .expect_err("non-started workspace should not provide attach URI"); + assert!( + err.to_string() + .contains("workspace must be in Started state before attaching") ); - assert_eq!( - compact_github_tooltip_target( - "https://github.com/micronaut-projects/micronaut-core/pull/5678" - ), - Some("\u{f408} micronaut-projects/micronaut-core#5678".to_string()) + } + + #[test] + fn workspace_attach_target_requires_transient_uri() { + let err = workspace_attach_target(&WorkspaceSnapshot::default()) + .expect_err("stopped workspace without transient snapshot should fail"); + assert!( + err.to_string() + .contains("workspace must be in Started state before attaching") ); } #[test] - fn compact_github_tooltip_target_leaves_other_urls_unmatched() { + fn workspace_attach_target_extracts_credentials_and_sanitizes_uri() { + let started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + let target = workspace_attach_target(&started) + .expect("started workspace should expose attach target with auth"); assert_eq!( - compact_github_tooltip_target("https://example.com/issues/1234"), - None + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: None, + } ); + } + + #[test] + fn workspace_attach_target_includes_latest_root_session_id() { + let mut started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + started.root_session_id = Some("ses-root-latest".to_string()); + + let target = workspace_attach_target(&started) + .expect("started workspace should expose attach target with latest root session id"); + assert_eq!( - compact_github_tooltip_target("https://github.com/micronaut-projects/micronaut-core"), - None + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-root-latest".to_string()), + } ); } #[test] - fn selected_link_tooltip_area_prefers_space_below_link() { - let targets = vec![("target".to_string(), false)]; - let area = selected_link_tooltip_area( - Rect::new(0, 0, 80, 20), - 2, - WorkspaceLinkKind::Review, - &targets, - [10, 10, 5, 5, 5, 2, 2, 2, 2, 2], - ) - .expect("tooltip area should exist"); + fn attach_cli_args_appends_session_when_present() { + let target = AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-root-latest".to_string()), + }; - assert_eq!(area.x, 41); - assert_eq!(area.y, 5); - assert_eq!(area.height, 3); + assert_eq!( + attach_cli_args("opencode", &target), + vec![ + "opencode".to_string(), + "attach".to_string(), + "--session".to_string(), + "ses-root-latest".to_string(), + "http://127.0.0.1:3000/".to_string(), + ] + ); } #[test] - fn selected_link_tooltip_area_flips_above_when_below_space_is_too_small() { - let targets = vec![ - ("one".to_string(), false), - ("two".to_string(), false), - ("three".to_string(), false), - ]; - let area = selected_link_tooltip_area( - Rect::new(0, 0, 80, 8), - 4, - WorkspaceLinkKind::Pr, - &targets, - [10, 10, 5, 5, 5, 2, 2, 2, 2, 2], - ) - .expect("tooltip area should exist"); + fn attach_cli_args_omits_session_when_unavailable() { + let target = AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: None, + }; - assert_eq!(area.x, 47); - assert_eq!(area.y, 1); - assert_eq!(area.height, 5); + assert_eq!( + attach_cli_args("opencode", &target), + vec![ + "opencode".to_string(), + "attach".to_string(), + "http://127.0.0.1:3000/".to_string(), + ] + ); } #[test] - fn description_line_does_not_embed_agent_links() { - let mut started = snapshot(true, Some("http://example")); - started.root_session_title = Some("Root session title".to_string()); - started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + fn workspace_attach_target_uses_codex_variant_for_websocket_uri() { + let mut started = snapshot(false, Some("ws://127.0.0.1:3456")); + started.root_session_id = Some("thread-123".to_string()); - let line = description_line(&started, "Custom description", false); - let text = line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); + let target = workspace_attach_target(&started) + .expect("codex workspace should expose websocket attach target"); - assert_eq!(text, "Custom description · Root session title"); + assert_eq!( + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: Some("thread-123".to_string()), + } + ); } #[test] - fn workspace_link_kind_uses_short_labels() { - assert_eq!(WorkspaceLinkKind::Review.short_label(), "RE"); - assert_eq!(WorkspaceLinkKind::Issue.short_label(), "IS"); - assert_eq!(WorkspaceLinkKind::Pr.short_label(), "PR"); + fn task_attach_target_uses_task_session_for_opencode() { + let started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-task-1".to_string()), + ..Default::default() + }; + + let target = task_attach_target(&started, &task_state) + .expect("task attach target should use task session id"); + + assert_eq!( + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-task-1".to_string()), + } + ); } #[test] - fn archived_icon_cells_use_dimmed_foreground_color() { - let style = Style::default().fg(Color::DarkGray).bg(Color::Reset); - assert_eq!(style.fg, Some(Color::DarkGray)); - assert_eq!(style.bg, Some(Color::Reset)); + fn task_attach_target_uses_task_thread_for_codex() { + let mut started = snapshot(false, Some("ws://127.0.0.1:3456")); + started.root_session_id = Some("thread-root".to_string()); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-1".to_string()), + ..Default::default() + }; + + let target = task_attach_target(&started, &task_state) + .expect("task attach target should use task thread id"); + + assert_eq!( + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: Some("thread-task-1".to_string()), + } + ); } #[test] - fn every_status_icon_kind_has_a_nerd_font_glyph() { - for kind in [ - StatusIconKind::Eye, - StatusIconKind::Server, - StatusIconKind::FileDiff, - StatusIconKind::GitPullRequest, - StatusIconKind::GitPullRequestDraft, - StatusIconKind::GitPullRequestClosed, - StatusIconKind::GitMerge, - StatusIconKind::IssueOpened, - StatusIconKind::IssueClosed, - ] { - let glyph = icon_glyph(kind); - assert!(!glyph.is_empty()); - assert_eq!(glyph.chars().count(), 1); - } + fn snapshot_attach_target_for_selection_falls_back_to_workspace_attach_when_paused_opencode_task_has_no_session() + { + let mut started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + started.root_session_id = Some("ses-root-1".to_string()); + started.persistent.automation_paused = true; + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("paused task selection should fall back to workspace attach"); + + assert_eq!( + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-root-1".to_string()), + } + ); } #[test] - fn status_icon_mappings_use_expected_glyph_kinds() { + fn snapshot_attach_target_for_selection_uses_last_codex_thread_when_paused_task_has_no_session() + { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + started.persistent.automation_paused = true; + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("paused codex task selection should attach via last task thread"); + assert_eq!( - issue_icon_kind_and_color(GithubIssueState::Open), - (StatusIconKind::IssueOpened, Color::Green) + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + } ); - assert_eq!( - issue_icon_kind_and_color(GithubIssueState::Closed), - (StatusIconKind::IssueClosed, Color::Magenta) + } + + #[test] + fn snapshot_attach_target_for_selection_uses_last_codex_thread_when_task_is_stale() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-stale".to_string()), + agent_state: Some(AutomationAgentState::Stale), + ..Default::default() + }, ); + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("stale codex task should attach via last thread"); + assert_eq!( - pr_icon_kind_and_color(GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }), - (StatusIconKind::GitPullRequest, Color::Green) + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + } ); - assert_eq!( - pr_icon_kind_and_color(GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: true, - fetched_at: SystemTime::UNIX_EPOCH, - }), - (StatusIconKind::GitPullRequestDraft, Color::DarkGray) + } + + #[test] + fn snapshot_attach_target_for_selection_uses_last_codex_thread_when_task_is_not_loaded() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-missing".to_string()), + agent_state: Some(AutomationAgentState::Review), + status: Some("NotLoaded".to_string()), + ..Default::default() + }, ); + + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("not-loaded codex task should attach via last thread"); + assert_eq!( - pr_icon_kind_and_color(GithubPrStatus { - state: GithubPrState::Rejected, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }), - (StatusIconKind::GitPullRequestClosed, Color::Red) + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + } ); - assert_eq!( - pr_icon_kind_and_color(GithubPrStatus { - state: GithubPrState::Merged, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }), + } + + #[test] + fn should_retry_codex_task_attach_with_last_thread_for_not_loaded_task() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-stale".to_string()), + agent_state: Some(AutomationAgentState::Review), + status: Some("NotLoaded".to_string()), + ..Default::default() + }, + ); + + let target = should_retry_codex_task_attach_with_last_thread( + AgentProvider::Codex, + Some(&started), + "ws", + Some("ws"), + Some("task-42"), + Some("thread-stale"), + ); + + assert_eq!( + target, + Some(AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + }) + ); + } + + #[test] + fn should_not_retry_codex_task_attach_without_explicit_thread() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + status: Some("NotLoaded".to_string()), + ..Default::default() + }, + ); + + let target = should_retry_codex_task_attach_with_last_thread( + AgentProvider::Codex, + Some(&started), + "ws", + Some("ws"), + Some("task-42"), + None, + ); + + assert_eq!(target, None); + } + + #[test] + fn should_start_fresh_codex_task_session_after_failed_attach_for_aborted_only_thread() { + assert!(should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + + #[test] + fn should_not_start_fresh_codex_task_session_when_attach_changed_thread_metrics() { + assert!(!should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 1, + }), + Some(CodexSessionTurnMetrics { + started: 5, + completed: 0, + aborted: 1, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + + #[test] + fn should_not_start_fresh_codex_task_session_when_thread_is_still_idle() { + assert!(!should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(&CodexThreadStatus::Idle), + )); + } + + #[test] + fn working_codex_task_attach_target_uses_existing_task_thread() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + + let target = working_codex_task_attach_target( + &started, + Some("task-42"), + Some("/tmp/task-42".to_string()), + ) + .expect("working codex task attach target should be computed"); + + assert_eq!( + target, + Some(AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: Some("thread-42".to_string()), + }) + ); + } + + #[test] + fn snapshot_attach_target_for_selection_prefers_task_attach_when_task_session_exists() { + let mut started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + started.root_session_id = Some("ses-root-1".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-task-42".to_string()), + ..Default::default() + }, + ); + + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("task session should still be preferred"); + + assert_eq!( + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-task-42".to_string()), + } + ); + } + + #[test] + fn attach_cli_args_use_codex_resume_for_codex_target() { + let target = AttachTarget::Codex { + uri: "ws://127.0.0.1:3456".to_string(), + thread_id: Some("thread-123".to_string()), + }; + + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "resume".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "thread-123".to_string(), + ] + ); + } + + #[test] + fn attach_cli_args_use_last_for_codex_when_thread_is_unavailable() { + let target = AttachTarget::Codex { + uri: "ws://127.0.0.1:3456".to_string(), + thread_id: None, + }; + + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "resume".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "--last".to_string(), + ] + ); + } + + #[test] + fn attach_cli_args_start_fresh_remote_codex_session_in_task_checkout() { + let target = AttachTarget::CodexNew { + uri: "ws://127.0.0.1:3456".to_string(), + cwd: Some("/tmp/task".to_string()), + prompt: Some("Continue work on this task.".to_string()), + }; + + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "-C".to_string(), + "/tmp/task".to_string(), + "Continue work on this task.".to_string(), + ] + ); + } + + #[test] + fn tmux_session_command_restores_original_term_inside_session() { + let command = vec!["opencode".to_string(), "attach".to_string()]; + + assert_eq!( + tmux_session_command(command, Some("screen-256color")), + vec![ + "env".to_string(), + "TERM=screen-256color".to_string(), + "opencode".to_string(), + "attach".to_string(), + ] + ); + } + + #[test] + fn tmux_session_command_skips_term_override_when_unset() { + let command = vec!["opencode".to_string(), "attach".to_string()]; + + assert_eq!( + tmux_session_command(command, None), + vec![ + "env".to_string(), + "opencode".to_string(), + "attach".to_string(), + ] + ); + } + + #[test] + fn command_exists_detects_executable_files_on_path() { + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + let tool_path = bin_dir.join("multicode-test-tool"); + fs::write(&tool_path, "#!/bin/sh\nexit 0\n").expect("tool should be written"); + let mut perms = fs::metadata(&tool_path) + .expect("tool metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&tool_path, perms).expect("tool should be executable"); + + let old_path = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", bin_dir.as_os_str()); + } + + assert!(command_exists("multicode-test-tool")); + assert!(!command_exists("missing-tool")); + + if let Some(path) = old_path { + unsafe { + std::env::set_var("PATH", path); + } + } else { + unsafe { + std::env::remove_var("PATH"); + } + } + } + + #[test] + fn compare_tool_is_available_uses_configured_command_path() { + let root = TestDir::new(); + let tool_path = root.path().join("idea"); + fs::write(&tool_path, "#!/bin/sh\nexit 0\n").expect("tool should be written"); + let mut perms = fs::metadata(&tool_path) + .expect("tool metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&tool_path, perms).expect("tool should be executable"); + + assert!(compare_tool_is_available(&CompareConfig { + tool: CompareTool::Intellij, + command: Some(tool_path.to_string_lossy().into_owned()), + })); + } + + #[test] + fn compare_tool_name_labels_supported_tools() { + assert_eq!(compare_tool_name(CompareTool::Vscode), "VS Code"); + assert_eq!(compare_tool_name(CompareTool::Intellij), "IntelliJ IDEA"); + } + + #[test] + fn repository_diff_shell_command_keeps_pager_open_and_shows_status() { + let command = repository_diff_shell_command(); + assert!(command.contains("git status --short")); + assert!(command.contains("git --no-pager diff")); + assert!(command.contains("less -R -X")); + assert!(!command.contains("less -R -F -X")); + } + + #[test] + fn shell_command_in_repo_does_not_prefix_exec() { + let command = shell_command_in_repo("/tmp/repo", "tmp=1; echo hi"); + assert!(command.contains("cd -- /tmp/repo && ")); + assert!(!command.contains("&& exec ")); + } + + #[test] + fn tui_cli_args_accept_optional_relay_socket() { + let parsed = crate::CliArgs::try_parse_from([ + "multicode-tui", + "config.toml", + "--relay-socket", + "/tmp/multicode-relay.sock", + ]) + .expect("cli args should parse"); + assert_eq!(parsed.config_path, PathBuf::from("config.toml")); + assert_eq!( + parsed.relay_socket, + Some(PathBuf::from("/tmp/multicode-relay.sock")) + ); + } + + #[test] + fn tui_cli_args_accept_hidden_recency_scan_flags() { + let parsed = crate::CliArgs::try_parse_from([ + "multicode-tui", + "config.toml", + "--recency-scan-path", + "/tmp/worktree", + "--recency-scan-is-dir", + "--recency-scan-exclude", + ".multicode/remote", + "--recency-scan-exclude", + "node_modules", + ]) + .expect("hidden recency scan args should parse"); + assert_eq!(parsed.config_path, PathBuf::from("config.toml")); + assert_eq!( + parsed.recency_scan_path, + Some(PathBuf::from("/tmp/worktree")) + ); + assert!(parsed.recency_scan_is_dir); + assert_eq!( + parsed.recency_scan_exclude, + vec![".multicode/remote".to_string(), "node_modules".to_string()] + ); + } + + #[test] + fn workspace_attach_target_requires_username_and_password() { + let missing_username = snapshot(true, Some("http://:secret@127.0.0.1/")); + let err = workspace_attach_target(&missing_username) + .expect_err("URI without username should fail"); + assert!( + err.to_string() + .contains("workspace attach URI is missing username credentials") + ); + + let missing_password = snapshot(true, Some("http://opencode@127.0.0.1/")); + let err = workspace_attach_target(&missing_password) + .expect_err("URI without password should fail"); + assert!( + err.to_string() + .contains("workspace attach URI is missing password credentials") + ); + } + + #[test] + fn build_handler_command_replaces_placeholder_argument() { + let (program, args) = build_handler_command( + "/usr/bin/firefox {}", + multicode_lib::HandlerArgumentMode::Argument, + "https://example.com", + ) + .expect("handler command should parse"); + assert_eq!(program, "/usr/bin/firefox"); + assert_eq!(args, vec!["https://example.com".to_string()]); + } + + #[test] + fn build_handler_command_accepts_review_without_placeholder() { + let (program, args) = build_handler_command( + "/usr/bin/smerge", + multicode_lib::HandlerArgumentMode::Chdir, + "/tmp/repo", + ) + .expect("review handler without placeholder should parse"); + assert_eq!(program, "/usr/bin/smerge"); + assert!(args.is_empty()); + } + + #[test] + fn validate_workspace_link_target_accepts_repo_under_workspace_directory() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_dir = root.path().join("agent-work"); + fs::create_dir_all(&workspace_dir).expect("workspace dir should be created"); + let repo_dir = workspace_dir.join("micronaut-core"); + fs::create_dir_all(&repo_dir).expect("repo dir should be created"); + fs::create_dir(repo_dir.join(".git")).expect(".git folder should be created"); + + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Review, + value: repo_dir.to_string_lossy().into_owned(), + source: WorkspaceLinkSource::AgentProvided, + }; + let validated = validate_workspace_link_target(&link, &workspace_dir) + .await + .expect("repo directory under workspace root should be accepted"); + assert!(PathBuf::from(validated).is_dir()); + }); + } + + #[test] + fn validate_workspace_link_target_rejects_repo_outside_workspace_directory() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_dir = root.path().join("agent-work"); + let outside_dir = root.path().join("outside-repo"); + fs::create_dir_all(&workspace_dir).expect("workspace dir should be created"); + fs::create_dir_all(&outside_dir).expect("outside dir should be created"); + + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Review, + value: outside_dir.to_string_lossy().into_owned(), + source: WorkspaceLinkSource::AgentProvided, + }; + let err = validate_workspace_link_target(&link, &workspace_dir) + .await + .expect_err("repo outside workspace root should be rejected"); + let message = err.to_string(); + assert!( + message.contains("outside workspace directory") + || message.contains("must contain a '.git' entry") + ); + }); + } + + #[test] + fn validate_workspace_link_target_rejects_repo_without_git_entry() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_dir = root.path().join("agent-work"); + let repo_dir = workspace_dir.join("core12299").join("micronaut-core"); + fs::create_dir_all(&repo_dir).expect("repo dir should be created"); + + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Review, + value: repo_dir.to_string_lossy().into_owned(), + source: WorkspaceLinkSource::AgentProvided, + }; + let err = validate_workspace_link_target(&link, &workspace_dir) + .await + .expect_err("repo without .git entry should be rejected"); + assert!(err.to_string().contains("must contain a '.git' entry")); + }); + } + + #[test] + fn validate_workspace_link_target_rejects_non_https_urls() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: "http://example.com/issue/1".to_string(), + source: WorkspaceLinkSource::AgentProvided, + }; + let err = validate_workspace_link_target(&link, root.path()) + .await + .expect_err("non-https URL should be rejected"); + assert!(err.to_string().contains("must use https")); + }); + } + + #[test] + fn workspace_links_collect_review_issue_and_pr_entries() { + let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; + started.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; + + let links = workspace_links(&started); + assert_eq!( + links, + vec![ + WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: "https://github.com/example/repo/issues/42".to_string(), + source: WorkspaceLinkSource::Automation, + }, + WorkspaceLink { + kind: WorkspaceLinkKind::Review, + value: "/tmp/repo-a".to_string(), + source: WorkspaceLinkSource::AgentProvided, + }, + WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: "https://example.com/issue/1".to_string(), + source: WorkspaceLinkSource::AgentProvided, + }, + WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: "https://example.com/pull/2".to_string(), + source: WorkspaceLinkSource::AgentProvided, + }, + ] + ); + } + + #[test] + fn workspace_links_put_custom_issue_and_pr_before_automatic_links() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.custom_links.issue = + vec!["https://example.com/custom-issue/1".to_string()]; + started.persistent.custom_links.pr = vec!["https://example.com/custom-pr/2".to_string()]; + started.persistent.agent_provided.issue = vec!["https://example.com/issue/3".to_string()]; + started.persistent.agent_provided.pr = vec!["https://example.com/pull/4".to_string()]; + + let links = workspace_links(&started); + assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[0].source, WorkspaceLinkSource::Custom); + assert_eq!(links[1].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[1].source, WorkspaceLinkSource::AgentProvided); + assert_eq!(links[2].kind, WorkspaceLinkKind::Pr); + assert_eq!(links[2].source, WorkspaceLinkSource::Custom); + assert_eq!(links[3].kind, WorkspaceLinkKind::Pr); + assert_eq!(links[3].source, WorkspaceLinkSource::AgentProvided); + } + + #[test] + fn next_link_selection_right_cycles_back_to_row_after_last_link() { + assert_eq!(next_link_selection_right(None, 0), None); + assert_eq!(next_link_selection_right(None, 3), Some(0)); + assert_eq!(next_link_selection_right(Some(0), 3), Some(1)); + assert_eq!(next_link_selection_right(Some(1), 3), Some(2)); + assert_eq!(next_link_selection_right(Some(2), 3), None); + } + + #[test] + fn next_non_stopped_row_skips_stopped_workspaces_in_both_directions() { + let ordered_keys = vec![ + "alpha".to_string(), + "beta".to_string(), + "gamma".to_string(), + "delta".to_string(), + ]; + let snapshots = HashMap::from([ + ("alpha".to_string(), snapshot(false, None)), + ("beta".to_string(), starting_snapshot()), + ("gamma".to_string(), snapshot(false, None)), + ("delta".to_string(), snapshot(true, Some("http://example"))), + ]); + + assert_eq!( + next_non_stopped_row(0, &ordered_keys, &snapshots, 1), + Some(2) + ); + assert_eq!( + next_non_stopped_row(2, &ordered_keys, &snapshots, 1), + Some(4) + ); + assert_eq!( + next_non_stopped_row(4, &ordered_keys, &snapshots, -1), + Some(2) + ); + } + + #[test] + fn next_non_stopped_row_returns_none_when_no_match_exists() { + let ordered_keys = vec!["alpha".to_string(), "beta".to_string()]; + let snapshots = HashMap::from([ + ("alpha".to_string(), snapshot(false, None)), + ("beta".to_string(), snapshot(false, None)), + ]); + + assert_eq!(next_non_stopped_row(0, &ordered_keys, &snapshots, 1), None); + assert_eq!(next_non_stopped_row(2, &ordered_keys, &snapshots, -1), None); + } + + #[test] + fn visible_workspace_links_only_include_validated_entries() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; + started.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; + + let all_links = workspace_links(&started); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + validations.insert( + all_links[1].clone(), + WorkspaceLinkValidationResult::Invalid("rejected".to_string()), + ); + + let visible = visible_workspace_links(&started, &validations); + assert_eq!(visible[0], all_links[0].clone()); + assert_eq!(visible[1].kind, WorkspaceLinkKind::Issue); + assert!(visible[1].value.is_empty()); + assert_eq!(visible[2].kind, WorkspaceLinkKind::Pr); + assert!(visible[2].value.is_empty()); + } + + #[test] + fn compare_target_path_prefers_validated_review_repo() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + let workspace = TestDir::new(); + + let all_links = workspace_links(&started); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + + assert_eq!( + compare_target_path(&started, &validations, workspace.path()), + Some(PathBuf::from("/tmp/repo-a")) + ); + } + + #[test] + fn compare_target_path_falls_back_to_issue_worktree() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + started.persistent.automation_issue = Some( + "https://github.com/micronaut-projects/micronaut-serialization/issues/921".to_string(), + ); + let workspace = TestDir::new(); + let repo_path = workspace.path().join("work/micronaut-serialization-921"); + fs::create_dir_all(&repo_path).expect("issue worktree repo should be created"); + create_fake_git_repo(&repo_path); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + Some(workspace.path().join("work/micronaut-serialization-921")) + ); + } + + #[test] + fn compare_target_path_falls_back_to_assigned_repository_root() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + let workspace = TestDir::new(); + create_fake_git_repo(&workspace.path().join("micronaut-serialization")); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + Some(workspace.path().join("micronaut-serialization")) + ); + } + + #[test] + fn compare_target_path_for_task_prefers_runtime_task_checkout_metadata() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-redis".to_string()); + assign_active_task( + &mut started, + "https://github.com/micronaut-projects/micronaut-redis/issues/726", + ); + let workspace = TestDir::new(); + let repo_root = workspace.path().join("micronaut-redis"); + create_fake_git_repo(&repo_root); + let worktree = workspace.path().join("work/micronaut-redis-726"); + fs::create_dir_all(&worktree).expect("task worktree should be created"); + create_fake_git_worktree(&worktree, &repo_root.join(".git")); + started.task_states.insert( + "task-42".to_string(), + WorkspaceTaskRuntimeSnapshot { + repository: vec![ + repo_root.display().to_string(), + worktree.display().to_string(), + ], + ..Default::default() + }, + ); + + let task = started + .task_persistent_snapshot("task-42") + .expect("task should exist"); + assert_eq!( + compare_target_path_for_task( + &started, + task, + started.task_states.get("task-42"), + workspace.path() + ), + Some(worktree) + ); + } + + #[test] + fn compare_target_path_for_task_skips_broken_worktree_and_falls_back() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-redis".to_string()); + assign_active_task( + &mut started, + "https://github.com/micronaut-projects/micronaut-redis/issues/726", + ); + let workspace = TestDir::new(); + let repo_root = workspace.path().join("micronaut-redis"); + create_fake_git_repo(&repo_root); + let worktree = workspace.path().join("work/micronaut-redis-726"); + fs::create_dir_all(&worktree).expect("task worktree should be created"); + fs::write( + worktree.join(".git"), + format!( + "gitdir: {}\n", + repo_root + .join(".git/worktrees/micronaut-redis-726") + .display() + ), + ) + .expect("broken worktree git file should be created"); + started.task_states.insert( + "task-42".to_string(), + WorkspaceTaskRuntimeSnapshot { + repository: vec![ + worktree.display().to_string(), + repo_root.display().to_string(), + ], + ..Default::default() + }, + ); + + let task = started + .task_persistent_snapshot("task-42") + .expect("task should exist"); + assert_eq!( + compare_target_path_for_task( + &started, + task, + started.task_states.get("task-42"), + workspace.path() + ), + Some(repo_root) + ); + } + + #[test] + fn compare_target_path_is_none_without_review_repo_or_workspace_repo() { + let started = snapshot(true, Some("http://example")); + let workspace = TestDir::new(); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + None + ); + } + + #[test] + fn snapshot_attach_cwd_for_selection_prefers_task_checkout() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + assign_active_task( + &mut started, + "https://github.com/micronaut-projects/micronaut-serialization/issues/921", + ); + let workspace = TestDir::new(); + let repo_root = workspace.path().join("micronaut-serialization"); + create_fake_git_repo(&repo_root); + let task_checkout = workspace.path().join("work/micronaut-serialization-921"); + fs::create_dir_all(&task_checkout).expect("task checkout should be created"); + create_fake_git_worktree(&task_checkout, &repo_root.join(".git")); + + assert_eq!( + snapshot_attach_cwd_for_selection( + &started, + Some("task-42"), + &HashMap::new(), + workspace.path(), + ), + Some(task_checkout) + ); + } + + #[test] + fn snapshot_attach_cwd_for_selection_falls_back_to_workspace_checkout() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + let workspace = TestDir::new(); + create_fake_git_repo(&workspace.path().join("micronaut-serialization")); + + assert_eq!( + snapshot_attach_cwd_for_selection(&started, None, &HashMap::new(), workspace.path()), + Some(workspace.path().join("micronaut-serialization")) + ); + } + + #[test] + fn selectable_workspace_links_include_custom_issue_and_pr_without_github_status() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + started.persistent.custom_links.issue = vec!["https://example.com/issue/1".to_string()]; + started.persistent.custom_links.pr = vec!["https://example.com/pull/2".to_string()]; + + let all_links = workspace_links(&started); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + validations.insert( + all_links[1].clone(), + WorkspaceLinkValidationResult::Valid("https://example.com/issue/1".to_string()), + ); + validations.insert( + all_links[2].clone(), + WorkspaceLinkValidationResult::Valid("https://example.com/pull/2".to_string()), + ); + + let selectable = selectable_workspace_links(&started, &validations, &HashMap::new()); + assert_eq!(selectable, all_links); + } + + #[test] + fn selectable_workspace_links_include_empty_issue_and_pr_slots_for_custom_add_flow() { + let started = snapshot(true, Some("http://example")); + + let selectable = selectable_workspace_links(&started, &HashMap::new(), &HashMap::new()); + assert_eq!(selectable.len(), 2); + assert_eq!(selectable[0].kind, WorkspaceLinkKind::Issue); + assert!(selectable[0].value.is_empty()); + assert_eq!(selectable[1].kind, WorkspaceLinkKind::Pr); + assert!(selectable[1].value.is_empty()); + } + + #[test] + fn archived_workspace_links_keep_issue_and_pr_but_hide_review() { + let mut archived = snapshot(false, None); + archived.persistent.archived = true; + archived.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + archived.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; + archived.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; + + let all_links = workspace_links(&archived); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + validations.insert( + all_links[1].clone(), + WorkspaceLinkValidationResult::Valid("https://example.com/issue/1".to_string()), + ); + validations.insert( + all_links[2].clone(), + WorkspaceLinkValidationResult::Valid("https://example.com/pull/2".to_string()), + ); + + let visible = visible_workspace_links(&archived, &validations); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(visible[1].kind, WorkspaceLinkKind::Pr); + } + + #[test] + fn validated_workspace_links_by_kind_keeps_multiple_targets() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec![ + "/tmp/repo-a".to_string(), + "/tmp/repo-b".to_string(), + "/tmp/repo-c".to_string(), + ]; + + let all_links = workspace_links(&started); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + validations.insert( + all_links[1].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-b".to_string()), + ); + validations.insert( + all_links[2].clone(), + WorkspaceLinkValidationResult::Invalid("skip".to_string()), + ); + + assert_eq!( + validated_workspace_links_by_kind(&started, &validations, WorkspaceLinkKind::Review,), + vec![all_links[0].clone(), all_links[1].clone()] + ); + } + + #[test] + fn compact_github_tooltip_target_shortens_issue_and_pr_links() { + assert_eq!( + compact_github_tooltip_target( + "https://github.com/micronaut-projects/micronaut-core/issues/1234" + ), + Some("\u{f408} micronaut-projects/micronaut-core#1234".to_string()) + ); + assert_eq!( + compact_github_tooltip_target( + "https://github.com/micronaut-projects/micronaut-core/pull/5678" + ), + Some("\u{f408} micronaut-projects/micronaut-core#5678".to_string()) + ); + } + + #[test] + fn compact_github_tooltip_target_leaves_other_urls_unmatched() { + assert_eq!( + compact_github_tooltip_target("https://example.com/issues/1234"), + None + ); + assert_eq!( + compact_github_tooltip_target("https://github.com/micronaut-projects/micronaut-core"), + None + ); + } + + #[test] + fn github_repository_url_normalizes_repository_specs() { + assert_eq!( + github_repository_url("micronaut-projects/micronaut-core"), + Some("https://github.com/micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!( + github_repository_url("https://github.com/micronaut-projects/micronaut-core/issues"), + Some("https://github.com/micronaut-projects/micronaut-core".to_string()) + ); + } + + #[test] + fn github_repository_url_rejects_invalid_specs() { + assert_eq!(github_repository_url(""), None); + assert_eq!(github_repository_url("owner/repo/extra"), None); + assert_eq!( + github_repository_url("https://example.com/owner/repo"), + None + ); + } + + #[test] + fn selected_link_tooltip_area_prefers_space_below_link() { + let targets = vec![("target".to_string(), false)]; + let area = selected_link_tooltip_area( + Rect::new(0, 0, 80, 20), + 2, + WorkspaceLinkKind::Review, + &targets, + [10, 10, 5, 5, 5, 2, 2, 2, 2, 2, 2], + ) + .expect("tooltip area should exist"); + + assert_eq!(area.x, 41); + assert_eq!(area.y, 5); + assert_eq!(area.height, 3); + } + + #[test] + fn selected_link_tooltip_area_flips_above_when_below_space_is_too_small() { + let targets = vec![ + ("one".to_string(), false), + ("two".to_string(), false), + ("three".to_string(), false), + ]; + let area = selected_link_tooltip_area( + Rect::new(0, 0, 80, 8), + 4, + WorkspaceLinkKind::Pr, + &targets, + [10, 10, 5, 5, 5, 2, 2, 2, 2, 2, 2], + ) + .expect("tooltip area should exist"); + + assert_eq!(area.x, 50); + assert_eq!(area.y, 1); + assert_eq!(area.height, 5); + } + + #[test] + fn description_line_does_not_embed_agent_links() { + let mut started = snapshot(true, Some("http://example")); + started.root_session_title = Some("Root session title".to_string()); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + + let line = description_line(&started, "Custom description", false); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(text, "Custom description · Root session title"); + } + + #[test] + fn workspace_link_kind_uses_short_labels() { + assert_eq!(WorkspaceLinkKind::Review.short_label(), "RE"); + assert_eq!(WorkspaceLinkKind::Issue.short_label(), "IS"); + assert_eq!(WorkspaceLinkKind::Pr.short_label(), "PR"); + } + + #[test] + fn archived_icon_cells_use_dimmed_foreground_color() { + let style = Style::default().fg(Color::DarkGray).bg(Color::Reset); + assert_eq!(style.fg, Some(Color::DarkGray)); + assert_eq!(style.bg, Some(Color::Reset)); + } + + #[test] + fn every_status_icon_kind_has_a_nerd_font_glyph() { + for kind in [ + StatusIconKind::Eye, + StatusIconKind::Server, + StatusIconKind::FileDiff, + StatusIconKind::Bug, + StatusIconKind::Docs, + StatusIconKind::Enhancement, + StatusIconKind::Improvement, + StatusIconKind::Regression, + StatusIconKind::DependencyUpgrade, + StatusIconKind::GitPullRequest, + StatusIconKind::GitPullRequestDraft, + StatusIconKind::GitPullRequestClosed, + StatusIconKind::GitMerge, + StatusIconKind::IssueOpened, + StatusIconKind::IssueClosed, + ] { + let glyph = icon_glyph(kind); + assert!(!glyph.is_empty()); + assert_eq!(glyph.chars().count(), 1); + } + } + + #[test] + fn status_icon_mappings_use_expected_glyph_kinds() { + assert_eq!( + issue_icon_kind_and_color(GithubIssueState::Open), + (StatusIconKind::IssueOpened, Color::Green) + ); + assert_eq!( + issue_icon_kind_and_color(GithubIssueState::Closed), + (StatusIconKind::IssueClosed, Color::Magenta) + ); + + assert_eq!( + pr_icon_kind_and_color(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }), + (StatusIconKind::GitPullRequest, Color::Green) + ); + assert_eq!( + pr_icon_kind_and_color(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: true, + fetched_at: SystemTime::UNIX_EPOCH, + }), + (StatusIconKind::GitPullRequestDraft, Color::DarkGray) + ); + assert_eq!( + pr_icon_kind_and_color(GithubPrStatus { + state: GithubPrState::Rejected, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }), + (StatusIconKind::GitPullRequestClosed, Color::Red) + ); + assert_eq!( + pr_icon_kind_and_color(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }), (StatusIconKind::GitMerge, Color::Magenta) ); - let open_pr = GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }; - assert_eq!(pr_build_icon_color(open_pr), Some(Color::Green)); - assert_eq!(pr_review_icon_color(open_pr), Some(Color::Green)); + let open_pr = GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }; + assert_eq!(pr_build_icon_color(open_pr), Some(Color::Green)); + assert_eq!(pr_review_icon_color(open_pr), Some(Color::Green)); + + let unreviewed_pr = GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::None, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }; + assert_eq!(pr_review_icon_color(unreviewed_pr), Some(Color::DarkGray)); + + let pending_review_pr = GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }; + assert_eq!(pr_review_icon_color(pending_review_pr), Some(Color::Yellow)); + + let rejected_review_pr = GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Rejected, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }; + assert_eq!(pr_review_icon_color(rejected_review_pr), Some(Color::Red)); + + let merged_pr = GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Accepted, + is_draft: false, + fetched_at: SystemTime::UNIX_EPOCH, + }; + assert_eq!(pr_build_icon_color(merged_pr), None); + assert_eq!(pr_review_icon_color(merged_pr), None); + } + + #[test] + fn help_line_shows_link_navigation_when_workspace_has_agent_links() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; + + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 1, + Some(0), + false, + false, + Some(WorkspaceLinkKind::Issue), + true, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("←/→ select link")); + assert!(text.contains("Enter open link")); + assert!(text.contains("o open GitHub")); + assert!(text.contains("↑/↓ select target")); + assert!(text.contains("a add link")); + assert!(text.contains("Esc row focus")); + assert!(!text.contains("edit description")); + assert!(!text.contains("archive")); + assert!(!text.contains("recheck GH status")); + } + + #[test] + fn help_line_shows_edit_delete_for_selected_custom_link() { + let started = snapshot(true, Some("http://example")); + + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 1, + Some(0), + true, + false, + Some(WorkspaceLinkKind::Issue), + true, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("d edit/delete link")); + } + + #[test] + fn help_line_shows_add_only_for_empty_issue_placeholder() { + let started = snapshot(true, Some("http://example")); + + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 1, + Some(0), + true, + true, + Some(WorkspaceLinkKind::Issue), + true, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("Enter add issue link")); + assert!(!text.contains("a add link")); + assert!(!text.contains("d edit/delete link")); + } + + #[test] + fn help_line_shows_recheck_hotkey_only_when_refreshable_github_links_exist() { + let started = snapshot(true, Some("http://example")); + + let enabled_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 1, + Some(0), + false, + false, + None, + true, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let enabled_text = enabled_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!enabled_text.contains("r recheck GH status")); + + let disabled_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 1, + Some(0), + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let disabled_text = disabled_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!disabled_text.contains("r recheck GH status")); + } + + #[test] + fn help_line_shows_only_create_actions_on_create_row() { + let line = help_line( + UiMode::Normal, + 0, + 0, + None, + false, + 0, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("Enter create")); + assert!(!text.contains("archive")); + assert!(!text.contains("start")); + } + + #[test] + fn help_line_shows_attach_only_for_started_workspace() { + let started = snapshot(true, Some("http://example")); + let started_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let started_text = started_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(started_text.contains("Enter attach")); + + let stopped = snapshot(false, None); + let stopped_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&stopped), + false, + 0, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let stopped_text = stopped_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!stopped_text.contains("Enter attach")); + assert!(stopped_text.contains("Enter start+attach")); + } + + #[test] + fn help_line_shows_pause_or_resume_only_for_started_automation_workspace() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = Some("example/repo".to_string()); + let started_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + true, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let started_text = started_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(started_text.contains("p pause")); + + started.persistent.automation_paused = true; + let paused_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + true, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let paused_text = paused_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(paused_text.contains("p resume")); + + let stopped = snapshot(false, None); + let stopped_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&stopped), + false, + 0, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let stopped_text = stopped_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!stopped_text.contains("p pause")); + assert!(!stopped_text.contains("p resume")); + } + + #[test] + fn help_line_shows_issue_hotkey_for_workspace_with_assigned_repository() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = Some("example/repo".to_string()); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + true, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("o open repo")); + assert!(text.contains("i issue")); + assert!(text.contains("n queue next")); + } + + #[test] + fn help_line_hides_issue_hotkey_without_assigned_repository() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(!text.contains("i issue")); + assert!(!text.contains("n queue next")); + } - let unreviewed_pr = GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::None, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }; - assert_eq!(pr_review_icon_color(unreviewed_pr), Some(Color::DarkGray)); + #[test] + fn help_line_shows_delete_hotkey_for_workspace_row_focus() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + false, + 0, + None, + false, + false, + None, + false, + true, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); - let pending_review_pr = GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Outstanding, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }; - assert_eq!(pr_review_icon_color(pending_review_pr), Some(Color::Yellow)); + assert!(text.contains("x delete")); + } - let rejected_review_pr = GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Rejected, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }; - assert_eq!(pr_review_icon_color(rejected_review_pr), Some(Color::Red)); + #[test] + fn help_line_limits_actions_for_task_row_focus() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 0, + None, + false, + false, + None, + false, + false, + true, + true, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); - let merged_pr = GithubPrStatus { - state: GithubPrState::Merged, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Accepted, - is_draft: false, - fetched_at: SystemTime::UNIX_EPOCH, - }; - assert_eq!(pr_build_icon_color(merged_pr), None); - assert_eq!(pr_review_icon_color(merged_pr), None); + assert!(text.contains("Enter attach")); + assert!(text.contains("c compare")); + assert!(text.contains("e edit")); + assert!(text.contains("x remove issue")); + assert!(!text.contains("a approve")); + assert!(!text.contains("i issue")); + assert!(!text.contains("d edit description")); + assert!(!text.contains("a archive")); + assert!(!text.contains("r recheck GH status")); } #[test] - fn help_line_shows_link_navigation_when_workspace_has_agent_links() { - let mut started = snapshot(true, Some("http://example")); - started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; - + fn help_line_shows_approve_hotkey_for_codex_task_row_focus() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); let line = help_line( UiMode::Normal, - 1, - 1, + 2, + 2, Some(&started), - 1, - Some(0), + true, + 0, + None, false, false, - Some(WorkspaceLinkKind::Issue), + None, + false, + false, + true, true, + false, no_tool_hotkeys(), "", ); @@ -868,31 +2843,229 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("←/→ select link")); - assert!(text.contains("Enter open link")); - assert!(text.contains("↑/↓ select target")); - assert!(text.contains("a add link")); - assert!(text.contains("Esc row focus")); - assert!(!text.contains("edit description")); - assert!(!text.contains("archive")); - assert!(!text.contains("recheck GH status")); + assert!(text.contains("a approve")); + assert!(!text.contains("a archive")); + } + + #[test] + fn help_line_shows_fix_ci_hotkey_for_failing_task_row() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + let line = help_line_with_task_fix( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 0, + None, + false, + false, + None, + false, + false, + true, + true, + true, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("a approve")); + assert!(text.contains("f fix CI")); + assert!(text.contains("o open GitHub")); + } + + #[test] + fn help_line_shows_open_github_for_task_row_focus_without_selected_link() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 2, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("o open GitHub")); + assert!(text.contains("x remove issue")); + } + + #[test] + fn should_offer_codex_ci_fix_requires_pr_link_and_open_non_green_pr() { + assert!(should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Building, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(!should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(!should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(should_offer_codex_ci_fix(true, None)); + assert!(!should_offer_codex_ci_fix(false, None)); + } + + #[test] + fn build_codex_fix_ci_prompt_includes_autonomous_context_and_ci_instructions() { + let prompt = build_codex_fix_ci_prompt( + "micronaut-projects/micronaut-kafka", + "https://github.com/micronaut-projects/micronaut-kafka/issues/873", + Some("https://github.com/micronaut-projects/micronaut-kafka/pull/1308"), + std::path::Path::new("/tmp/work/micronaut-kafka-873"), + std::path::Path::new("/tmp/state/task-873.state"), + Some("thread-task-873"), + ); + + assert!(prompt.contains( + "You are operating in an autonomous multicode workspace for repository micronaut-projects/micronaut-kafka." + )); + assert!(prompt.contains("Continue autonomously from where you left off.")); + assert!(prompt.contains( + "Start from the existing checkout for GitHub issue https://github.com/micronaut-projects/micronaut-kafka/issues/873." + )); + assert!(prompt.contains("Primary checkout for this task: /tmp/work/micronaut-kafka-873")); + assert!(prompt.contains("write autonomous state updates to `/tmp/state/task-873.state`")); + assert!( + prompt + .contains("write autonomous state updates in the format `:thread-task-873`") + ); + assert!(prompt.contains("Use the existing pull request https://github.com/micronaut-projects/micronaut-kafka/pull/1308.")); + assert!(prompt.contains("`machine-readable-pr`")); + assert!(prompt.contains("`autonomous-state`")); + assert!(prompt.contains( + "Run repository commands, builds, Gradle tasks, focused tests, git commits, branch pushes, and pull request updates as needed without asking for permission." + )); + assert!(prompt.contains( + "Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior." + )); + assert!(prompt.contains("Do not create a new pull request.")); + assert!(prompt.contains("Do not merge the pull request.")); + } + + #[test] + fn github_repository_spec_accepts_repo_and_github_urls() { + assert_eq!( + github_repository_spec("micronaut-projects/micronaut-kafka").as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec("https://github.com/micronaut-projects/micronaut-kafka") + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec( + "https://github.com/micronaut-projects/micronaut-kafka/issues/873" + ) + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec( + "https://github.com/micronaut-projects/micronaut-kafka/pull/1308" + ) + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert!(github_repository_spec("https://example.com/not-github/repo").is_none()); + } + + #[test] + fn task_repository_spec_falls_back_to_task_issue_when_workspace_repo_missing() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + let issue_url = "https://github.com/micronaut-projects/micronaut-kafka/issues/873"; + let task_id = "task-873".to_string(); + started + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + task_id.clone(), + issue_url.to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + started.active_task_id = Some(task_id.clone()); + + assert_eq!( + task_repository_spec(&started, &task_id).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); } #[test] - fn help_line_shows_edit_delete_for_selected_custom_link() { + fn help_line_shows_open_github_for_selected_issue_on_task_row() { let started = snapshot(true, Some("http://example")); - let line = help_line( UiMode::Normal, - 1, - 1, + 2, + 2, Some(&started), - 1, - Some(0), true, + 2, + Some(0), + false, false, Some(WorkspaceLinkKind::Issue), - true, + false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -902,24 +3075,31 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("d edit/delete link")); + assert!(text.contains("Enter open link")); + assert!(text.contains("o open GitHub")); + assert!(text.contains("Esc row focus")); + assert!(!text.contains("x remove issue")); } #[test] - fn help_line_shows_add_only_for_empty_issue_placeholder() { + fn help_line_hides_open_github_for_selected_review_on_task_row() { let started = snapshot(true, Some("http://example")); - let line = help_line( UiMode::Normal, - 1, - 1, + 2, + 2, Some(&started), - 1, - Some(0), - true, - true, - Some(WorkspaceLinkKind::Issue), true, + 2, + Some(0), + false, + false, + Some(WorkspaceLinkKind::Review), + false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -929,26 +3109,30 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("Enter add issue link")); - assert!(!text.contains("a add link")); - assert!(!text.contains("d edit/delete link")); + assert!(text.contains("Enter open link")); + assert!(!text.contains("o open GitHub")); + assert!(text.contains("Esc row focus")); } #[test] - fn help_line_shows_recheck_hotkey_only_when_refreshable_github_links_exist() { + fn help_line_shows_compare_hotkey_only_when_enabled() { let started = snapshot(true, Some("http://example")); - let enabled_line = help_line( UiMode::Normal, - 1, + 2, 1, Some(&started), - 1, - Some(0), + true, + 0, + None, false, false, None, + false, + false, + true, true, + false, no_tool_hotkeys(), "", ); @@ -957,19 +3141,25 @@ mod tests { .iter() .map(|span| span.content.as_ref()) .collect::(); - assert!(!enabled_text.contains("r recheck GH status")); + assert!(enabled_text.contains("c compare")); + assert!(enabled_text.contains("e edit")); let disabled_line = help_line( UiMode::Normal, - 1, + 2, 1, Some(&started), - 1, - Some(0), + true, + 0, + None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -978,22 +3168,28 @@ mod tests { .iter() .map(|span| span.content.as_ref()) .collect::(); - assert!(!disabled_text.contains("r recheck GH status")); + assert!(!disabled_text.contains("c compare")); + assert!(!disabled_text.contains("e edit")); } #[test] - fn help_line_shows_only_create_actions_on_create_row() { + fn help_line_shows_starting_message_in_starting_modal() { let line = help_line( - UiMode::Normal, - 0, - 0, - None, + UiMode::StartingModal, + 1, + 1, + Some(&snapshot(false, None)), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1003,72 +3199,90 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("Enter create")); - assert!(!text.contains("archive")); - assert!(!text.contains("start")); + assert!(text.contains("Starting workspace and waiting for server readiness")); } #[test] - fn help_line_shows_attach_only_for_started_workspace() { - let started = snapshot(true, Some("http://example")); - let started_line = help_line( - UiMode::Normal, + fn help_line_shows_wait_message_for_non_cancellable_tool_progress_modal() { + let line = help_line( + UiMode::ToolProgressModal, 1, 1, - Some(&started), + Some(&snapshot(false, None)), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); - let started_text = started_line + let text = line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); - assert!(started_text.contains("Enter attach")); - let stopped = snapshot(false, None); - let stopped_line = help_line( - UiMode::Normal, + assert!(text.contains("Operation is running in the selected workspace")); + assert!(text.contains("Waiting for it to finish")); + assert!(!text.contains("Esc cancel")); + } + + #[test] + fn help_line_shows_confirm_delete_message() { + let line = help_line( + UiMode::ConfirmDelete, 1, 1, - Some(&stopped), + Some(&snapshot(false, None)), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); - let stopped_text = stopped_line + let text = line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); - assert!(!stopped_text.contains("Enter attach")); - assert!(stopped_text.contains("Enter start+attach")); + + assert!(text.contains("Delete item:")); + assert!(text.contains("Enter")); } #[test] - fn help_line_shows_starting_message_in_starting_modal() { + fn help_line_shows_confirm_task_removal_message() { let line = help_line( - UiMode::StartingModal, + UiMode::ConfirmTaskRemoval, 1, 1, Some(&snapshot(false, None)), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1078,7 +3292,9 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("Starting workspace and waiting for server readiness")); + assert!(text.contains("Remove issue:")); + assert!(text.contains("←/→")); + assert!(text.contains("Enter")); } #[test] @@ -1087,6 +3303,9 @@ mod tests { let mut starting_workspace_key = Some("alpha".to_string()); let mut started_wait_since = Some(Instant::now()); let mut status = String::new(); + let mut failed_snapshot = WorkspaceSnapshot::default(); + failed_snapshot.automation_status = + Some("Start failed alpha: workspace start failed".to_string()); let starting_state = Some(WorkspaceUiState::Stopped); match starting_state { @@ -1094,7 +3313,7 @@ mod tests { Some(WorkspaceUiState::Started) => {} Some(WorkspaceUiState::Stopped) => { if let Some(key) = starting_workspace_key.as_deref() { - status = format!("Workspace '{key}' failed to start; server is still stopped"); + status = starting_modal_failure_status(key, Some(&failed_snapshot)); } mode = UiMode::Normal; starting_workspace_key = None; @@ -1110,7 +3329,19 @@ mod tests { assert_eq!(mode, UiMode::Normal); assert!(starting_workspace_key.is_none()); assert!(started_wait_since.is_none()); - assert!(status.contains("failed to start")); + assert!(status.contains("Start failed alpha")); + } + + #[test] + fn starting_modal_failure_status_prefers_workspace_automation_status() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.automation_status = + Some("Start failed serialization: Apple container allocator exhausted".to_string()); + + assert_eq!( + starting_modal_failure_status("serialization", Some(&snapshot)), + "Workspace 'serialization' failed to start: Start failed serialization: Apple container allocator exhausted" + ); } #[test] @@ -1122,12 +3353,17 @@ mod tests { 1, 1, Some(&started), + true, 0, None, false, false, None, false, + false, + false, + false, + false, &tool_hotkeys, "", ); @@ -1282,12 +3518,17 @@ mod tests { 1, 1, Some(&active), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1305,12 +3546,17 @@ mod tests { 1, 1, Some(&archived), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1348,6 +3594,24 @@ mod tests { assert_eq!(server_cell_label(&started), "Question"); } + #[test] + fn server_cell_label_uses_automation_question_state() { + let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.automation_agent_state = Some(AutomationAgentState::Question); + + assert_eq!(server_cell_label(&started), "Question"); + } + + #[test] + fn server_cell_label_uses_automation_review_state_as_idle() { + let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.automation_agent_state = Some(AutomationAgentState::Review); + + assert_eq!(server_cell_label(&started), "Idle"); + } + #[test] fn description_cell_text_appends_root_session_title_after_description() { let mut started = snapshot(true, Some("http://example")); @@ -1360,6 +3624,19 @@ mod tests { ); } + #[test] + fn description_cell_text_includes_automation_status_before_root_session_title() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.description = "Custom description".to_string(); + started.automation_status = Some("Working on example/repo#42".to_string()); + started.root_session_title = Some("Root session title".to_string()); + + assert_eq!( + description_cell_text(&started, &started.persistent.description), + "Custom description · Working on example/repo#42 · Root session title" + ); + } + #[test] fn description_line_styles_custom_description_cyan() { let mut started = snapshot(true, Some("http://example")); @@ -1371,6 +3648,19 @@ mod tests { assert_eq!(line.spans[2].content, "Root session title"); } + #[test] + fn description_line_does_not_prefix_spinner_for_failure_status() { + let mut started = snapshot(true, Some("http://example")); + started.automation_status = Some("Start failed repo: workspace start failed".to_string()); + + let line = description_line(&started, "", false); + + assert_eq!( + line.spans[0].content, + "Start failed repo: workspace start failed" + ); + } + #[test] fn description_line_shows_bold_red_oom_prefix_before_description() { let mut started = snapshot(true, Some("http://example")); @@ -1413,10 +3703,44 @@ mod tests { assert_eq!(cost_cell_label(&snapshot), "$2.50"); snapshot.usage_total_cost = Some(0.0); - assert_eq!(cost_cell_label(&snapshot), "1 234 567"); + assert_eq!(cost_cell_label(&snapshot), "1234k"); snapshot.usage_total_cost = None; - assert_eq!(cost_cell_label(&snapshot), "1 234 567"); + assert_eq!(cost_cell_label(&snapshot), "1234k"); + } + + #[test] + fn task_cost_cell_label_uses_compact_task_tokens() { + let task_state = WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(987_654), + ..Default::default() + }; + + assert_eq!(task_cost_cell_label(Some(&task_state)), "987k"); + assert_eq!(task_cost_cell_label(None), ""); + } + + #[test] + fn workspace_cost_cell_label_sums_task_tokens_before_workspace_usage() { + let mut snapshot = snapshot(true, Some("http://example")); + snapshot.usage_total_cost = Some(2.5); + snapshot.usage_total_tokens = Some(1_234_567); + snapshot.task_states.insert( + "task-1".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(111), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(222), + ..Default::default() + }, + ); + + assert_eq!(cost_cell_label(&snapshot), "333"); } #[test] @@ -1565,12 +3889,17 @@ mod tests { 1, 1, Some(&snapshot(true, Some("http://example"))), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1589,12 +3918,17 @@ mod tests { 0, 2, None, + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1612,12 +3946,17 @@ mod tests { 2, 2, Some(&last_workspace), + false, 0, None, false, false, None, false, + false, + false, + false, + false, no_tool_hotkeys(), "", ); @@ -1650,6 +3989,7 @@ mod tests { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -1678,6 +4018,7 @@ mod tests { assert!(cost_width >= content_width("$12.34")); assert_eq!(re_width, content_width("RE").max(LINK_COLUMN_WIDTH)); assert_eq!(is_width, content_width("IS").max(LINK_COLUMN_WIDTH)); + assert_eq!(t_width, content_width("T").max(TYPE_COLUMN_WIDTH)); assert_eq!(pr_width, content_width("PR").max(LINK_COLUMN_WIDTH)); assert_eq!(build_width, content_width("B").max(STATUS_COLUMN_WIDTH)); assert_eq!( @@ -1686,6 +4027,106 @@ mod tests { ); } + #[test] + fn table_column_widths_include_task_cost_and_server_labels() { + let mut workspace = snapshot(true, Some("http://example")); + workspace + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/graemerocher/multicode-test/issues/39".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + )); + workspace.task_states.insert( + "task-39".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(123_456_789), + waiting_on_vm: true, + ..Default::default() + }, + ); + let mut snapshots = HashMap::new(); + snapshots.insert("e2e-test".to_string(), workspace); + let ordered_keys = vec!["e2e-test".to_string()]; + + let (_, server_width, _, _, cost_width, _, _, _, _, _, _) = table_column_widths( + &ordered_keys, + &snapshots, + "Machine:", + "2200%", + &machine_ram_cell_label(Some(0)), + ); + + assert!(server_width >= content_width("Waiting on VM")); + assert!(cost_width >= content_width("123456k")); + } + + #[test] + fn approve_restarts_codex_task_when_runtime_is_not_loaded() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + status: Some("NotLoaded".to_string()), + ..Default::default() + }; + + assert!(should_restart_codex_task_for_pr_request(Some(&task_state))); + } + + #[test] + fn approve_keeps_existing_codex_task_when_review_session_is_idle() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + status: Some("Idle".to_string()), + ..Default::default() + }; + + assert!(!should_restart_codex_task_for_pr_request(Some(&task_state))); + } + + #[test] + fn ci_fix_restarts_idle_review_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + status: Some("Idle".to_string()), + ..Default::default() + }; + + assert!(should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + + #[test] + fn ci_fix_keeps_busy_working_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + status: Some("Active".to_string()), + ..Default::default() + }; + + assert!(!should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + + #[test] + fn ci_fix_restarts_question_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Question), + status: Some("Idle".to_string()), + ..Default::default() + }; + + assert!(should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + #[test] fn workspace_ordering_keeps_archived_last_and_newest_first() { let mut snapshots = HashMap::new(); diff --git a/workspace-skills/autonomous-state/SKILL.md b/workspace-skills/autonomous-state/SKILL.md new file mode 100644 index 0000000..bc49297 --- /dev/null +++ b/workspace-skills/autonomous-state/SKILL.md @@ -0,0 +1,48 @@ +--- +name: autonomous-state +description: Maintain the multicode autonomous state file while working autonomously so the host can detect working, question, review, idle, and stalled states. +--- + +When operating in a multicode autonomous workspace, multicode may either: + +- provide an explicit task-specific state file path in the prompt, or +- provide a fallback path via the environment variable `MULTICODE_AUTONOMOUS_STATE_PATH`. + +Always prefer the explicit task-specific path from the prompt when one is provided. Only fall back to +`MULTICODE_AUTONOMOUS_STATE_PATH` when no explicit task file path was given. + +Write exactly one line to that file. + +If multicode tells you the current task session or thread id, write the state as: + +- `working:` +- `question:` +- `review:` +- `idle:` + +If no session/thread id was provided, fall back to the plain state word: + +- `working` +- `question` +- `review` +- `idle` + +Use shell commands like: + +```sh +STATE_FILE="${MULTICODE_AUTONOMOUS_STATE_PATH}" +mkdir -p "$(dirname "$STATE_FILE")" +printf '%s\n' working > "$STATE_FILE" +``` + +Required workflow: + +- As soon as you begin autonomous work, write `working`. +- Before any potentially long-running investigation, edit, build, or test step, write `working` again to refresh the heartbeat. +- If you need human input, approval, clarification, or are blocked waiting for a response, write `question` before stopping. +- When the change is ready for human review or publish approval, write `review` before you stop. +- Only write `idle` if the issue is fully complete and no further action is pending. +- After resuming from an interruption, attach, or restart, immediately write the current state again before continuing. +- When a session/thread id was provided for the task, include it after the colon every time you write the state so multicode can distinguish parallel sessions on the same VM. + +Do not write anything except the single state word, or the state followed by `:`, to this file. diff --git a/workspace-skills/git-commit-coauthorship/SKILL.md b/workspace-skills/git-commit-coauthorship/SKILL.md index 6b883cb..3938f5a 100644 --- a/workspace-skills/git-commit-coauthorship/SKILL.md +++ b/workspace-skills/git-commit-coauthorship/SKILL.md @@ -3,10 +3,21 @@ name: git-commit-coauthorship description: Rules for git commit authorship. Load when creating any form of git commit. --- -When creating any git commit, use the default git author information (from git-config). Additionally, sign your commit with: +When creating any git commit, use the default git author information (from git-config). Additionally, sign your commit with a co-author line that includes the actual agent name, the actual model name, and an agent-appropriate email address for the system that produced the change. + +Format it like this: ``` -Co-Authored-By: multicode +Co-Authored-By: with <> ``` -This line should be at the bottom of the commit message, preceded by an empty line. \ No newline at end of file +Examples: + +```text +Co-Authored-By: Codex with GPT-5 +Co-Authored-By: OpenCode with Claude 4 +``` + +Do not hard-code `Codex with GPT-5 ` unless that is actually the agent, model, and email identity used. + +This line should be at the bottom of the commit message, preceded by an empty line. diff --git a/workspace-skills/independent-fix/SKILL.md b/workspace-skills/independent-fix/SKILL.md index 62f5ae0..0d6c97c 100644 --- a/workspace-skills/independent-fix/SKILL.md +++ b/workspace-skills/independent-fix/SKILL.md @@ -29,3 +29,5 @@ Some issues may require changes in multiple GitHub repositories, or the fix may report. You are permitted to clone and locally modify other repositories, and propose changes there. **Do not comment on issues or create pull requests without permission.** + +**Do not merge pull requests yourself. PRs should be merged by a human, except for an explicit dependency-upgrade workflow where automated merging is already intended.** diff --git a/workspace-skills/machine-readable-pr/SKILL.md b/workspace-skills/machine-readable-pr/SKILL.md index cd157d7..796e0aa 100644 --- a/workspace-skills/machine-readable-pr/SKILL.md +++ b/workspace-skills/machine-readable-pr/SKILL.md @@ -3,7 +3,23 @@ name: machine-readable-pr description: Rules for machine-readable pull request metadata. Load when actively working on or creating any GitHub pull request. --- -When actively working on or creating a GitHub pull request, immediately emit the link to the PR like this: +When actively working on or creating a GitHub pull request, assign the PR to yourself as soon as the PR exists. + +If you are using `gh`, prefer: + +```bash +gh pr edit --add-assignee @me +``` + +For Micronaut projects, also assign the PR to the next Micronaut project release at the organization level under `https://github.com/orgs/micronaut-projects/projects`. + +- Prefer the next semantically versioned release project. +- The next available project is typically suffixed with a milestone number, for example `5.0.0-M2`. +- Otherwise, use the semantically versioned project suffixed with `Release`, for example `5.0.0 Release`. + +After assigning the PR to yourself, request a review from Copilot as soon as the PR exists. + +After creating the PR, assigning yourself, and requesting the Copilot review, immediately emit the link to the PR like this: ``` https://github.com/example/example-core/pull/12345 @@ -12,3 +28,31 @@ When actively working on or creating a GitHub pull request, immediately emit the If the PR resolves a specific issue, when writing the PR description, end it with `Resolves #1234`, where 1234 is the issue number. If you have write permission to the upstream repo, prefer pushing the PR branch there instead of in a fork. + +When creating or updating the PR, add an appropriate type label. Choose the closest match for the actual change, for example: + +- `type: docs` for documentation-only updates +- `type: bug` for bug fixes +- `type: improvement` for minor improvements +- `type: enhancement` for broader enhancements + +Do not skip the label when the repository has a matching label available. + +If you mention agent authorship anywhere in the PR title, body, comments, or related text, do not use `multicode `. Use the actual agent name, actual model name, and an agent-appropriate email address instead: + +``` +Co-Authored-By: with <> +``` + +Examples: + +```text +Co-Authored-By: Codex with GPT-5 +Co-Authored-By: OpenCode with Claude 4 +``` + +Do not hard-code `Codex with GPT-5 ` unless that is actually the agent, model, and email identity used. + +Do not merge pull requests yourself. A PR should be merged by a human, not by the agent. + +The only exception is an explicit dependency-upgrade workflow where the user or repository policy already allows automated merging for that upgrade task. Outside that narrow case, stop at review-ready and leave the PR open. diff --git a/workspace-skills/micronaut-projects-guide/SKILL.md b/workspace-skills/micronaut-projects-guide/SKILL.md index 25f2bec..3d6bcc0 100644 --- a/workspace-skills/micronaut-projects-guide/SKILL.md +++ b/workspace-skills/micronaut-projects-guide/SKILL.md @@ -17,7 +17,9 @@ Note that for projects using the Micronaut build systems, gradle modules have a Rules for creating new tests: - Prefer junit over spock, unless there is already a spock test that can easily be altered to test this issue +- If the test runs with native image avoid using Mockito since it creates issue with Native Image - Where available, prefer writing a TCK test over a test for a specific module, even if the TCK fails for another module +- When Docker / Testcontainers is used for testing and the Docker environment is not available write a unit test that doesn't require docker as well as the docker-based test then rely on dowstream CI checks for Docker-based testing results ## Multi-project development @@ -29,14 +31,37 @@ When a fix needs validation across multiple Gradle projects: You can also use these features to verify patches against a user-provided or out-of-tree reproducer. +## Documentation + +When writing documentation: + +- Prefer the `snippet:` macro instead of inline code blocks so snippets can be generated for all supported languages. +- Unless the project only supports a narrower set, create snippets for Java, Kotlin, and Groovy. +- Resolve documentation snippets from the project's `doc-examples` subdirectory. +- Structure `doc-examples` in the same style used by `micronaut-graphql`'s `docs-examples` reference project on the `5.0.x` branch. +- For configuration examples, prefer the `configuration` macro: + +```adoc +[configuration] +---- +YAML GOES HERE +---- +``` + +- Do not use `[source,yaml]` for configuration snippets when the `configuration` macro applies, because `configuration` renders the example across configuration formats such as properties, YAML, and TOML. + ## PR creation Unless requested otherwise, target fixes against the default branch, which will be the next minor release. +Do not merge Micronaut pull requests yourself. Leave the PR open for human review and human merge. + +The only exception is an explicit dependency-upgrade use case where automated merge is already intended by the workflow or requested by the user. + Tag PRs with the following GitHub tags where appropriate: +- `type: docs` - `type: bug` - `type: improvement` +- `type: enhancement` - `type: breaking` - -