diff --git a/.dockerignore b/.dockerignore index f0f4e60679..4ecc220501 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,10 @@ .git -.github +.github/* +!.github/scripts/ +!.github/scripts/install-claude-native.sh +!.github/scripts/install-dolt-archive.sh +!.github/requirements/ +!.github/requirements/mcp-agent-mail.txt .claude docs test diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f438212aba..d178216136 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -54,6 +54,7 @@ if command -v npm >/dev/null 2>&1; then # Vite preview so a bundle that builds but won't serve is caught # before CI. make dashboard-check dashboard-smoke + git add -f cmd/gc/dashboard/web/src/generated git add cmd/gc/dashboard/web/dist fi else diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..188821cd7e --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,9 @@ +self-hosted-runner: + labels: + - blacksmith-2vcpu-ubuntu-2404 + - blacksmith-4vcpu-ubuntu-2404 + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-16vcpu-ubuntu-2404 + - blacksmith-32vcpu-ubuntu-2404 + - blacksmith-6vcpu-macos-15 + - blacksmith-12vcpu-macos-15 diff --git a/.github/actions/setup-gascity-macos/action.yml b/.github/actions/setup-gascity-macos/action.yml index 0e3ad0da2d..0cf38fd712 100644 --- a/.github/actions/setup-gascity-macos/action.yml +++ b/.github/actions/setup-gascity-macos/action.yml @@ -1,11 +1,11 @@ name: Setup Gas City macOS CI -description: Install the shared macOS dependencies for Gas City CI jobs on the self-hosted ARM64 runner +description: Install the shared macOS dependencies for Gas City CI jobs on ARM64 macOS runners inputs: go-version: description: Go version to install. Default matches setup-gascity-ubuntu; bump both together. required: false - default: "1.25.8" + default: "1.25.9" node-version: description: Node.js version to install required: false @@ -20,8 +20,12 @@ inputs: description: Whether to install the Claude CLI required: false default: "true" + claude-version: + description: Claude Code version to install with the native binary installer + required: false + default: "2.1.123" install-system-deps: - description: Whether to run brew to install tmux, jq, and flock (set to false when the self-hosted runner already has them) + description: Whether to run brew to install tmux, jq, and flock required: false default: "true" @@ -41,15 +45,14 @@ runs: exit 1 fi - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: # Keep this default in lock-step with setup-gascity-ubuntu — # a split between Mac and Linux toolchains would surface as # false "Mac-only" regressions. Track the same pin both # actions use today; bump them together. go-version: ${{ inputs.go-version }} - # self-hosted macstadium runners reuse the same GOPATH across jobs; - # actions/setup-go's cache layer is flaky on reused runners, so skip it. + # Keep macOS parity deterministic across hosted and reused runners. cache: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -108,97 +111,23 @@ runs: - name: Install dolt v${{ inputs.dolt-version }} shell: bash - run: | - set -euo pipefail - version="${{ inputs.dolt-version }}" - arch="$(uname -m)" - case "$arch" in - arm64) platform_tuple=darwin-arm64 ;; - x86_64) platform_tuple=darwin-amd64 ;; - *) - echo "Unsupported macOS arch: $arch" >&2 - exit 1 - ;; - esac - # Pin an install prefix we can write without sudo on a self-hosted - # runner. Prefer $RUNNER_TOOL_CACHE when present (persistent across - # GitHub Actions jobs) and fall back to $HOME/.local. - cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" - install_root="$cache_root/gascity-dolt/$version/$platform_tuple" - bin_dir="$install_root/bin" - if [[ ! -x "$bin_dir/dolt" ]]; then - echo "Installing dolt $version for $platform_tuple into $install_root" - mkdir -p "$install_root" - archive="dolt-${platform_tuple}.tar.gz" - tmp="$RUNNER_TEMP/dolt-${version}-${platform_tuple}" - rm -rf "$tmp" - mkdir -p "$tmp" - curl -fsSL -o "$tmp/$archive" \ - "https://github.com/dolthub/dolt/releases/download/v${version}/${archive}" - tar -xzf "$tmp/$archive" -C "$tmp" - # The tarball root is "dolt-${platform_tuple}" with a bin/ subdir. - cp -R "$tmp/dolt-${platform_tuple}/." "$install_root/" - rm -rf "$tmp" - else - echo "Reusing cached dolt $version at $install_root" - fi - echo "$bin_dir" >> "$GITHUB_PATH" - "$bin_dir/dolt" version + run: ${{ github.action_path }}/../../scripts/install-dolt-archive.sh "${{ inputs.dolt-version }}" --cache - name: Install released bd v${{ inputs.bd-version }} shell: bash - run: | - set -euo pipefail - version="${{ inputs.bd-version }}" - arch="$(uname -m)" - case "$arch" in - arm64) bd_arch=arm64 ;; - x86_64) bd_arch=amd64 ;; - *) - echo "Unsupported runner architecture: $arch" >&2 - exit 1 - ;; - esac - cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" - install_root="$cache_root/gascity-bd/${version}/darwin_${bd_arch}" - bin_dir="$install_root/bin" - if [[ ! -x "$bin_dir/bd" ]]; then - echo "Installing bd $version for darwin_${bd_arch} into $install_root" - mkdir -p "$bin_dir" - archive="beads_${version#v}_darwin_${bd_arch}.tar.gz" - tmp="$RUNNER_TEMP/bd-${version}-darwin_${bd_arch}" - rm -rf "$tmp" - mkdir -p "$tmp" - curl -fsSL -o "$tmp/$archive" \ - "https://github.com/gastownhall/beads/releases/download/${version}/${archive}" - # Strip the top-level directory (beads__darwin_/) - # so `bd` lands directly in $tmp. - tar -xzf "$tmp/$archive" -C "$tmp" --strip-components=1 - install -m 0755 "$tmp/bd" "$bin_dir/bd" - rm -rf "$tmp" - else - echo "Reusing cached bd $version at $install_root" - fi - echo "$bin_dir" >> "$GITHUB_PATH" - "$bin_dir/bd" version + run: ${{ github.action_path }}/../../scripts/install-bd-archive.sh "${{ inputs.bd-version }}" --cache - name: Install Claude CLI if: ${{ inputs.install-claude-cli == 'true' }} shell: bash - run: | - set -euo pipefail - # setup-node configures an npm prefix that's writable without sudo, - # so a plain `npm install -g` works on the self-hosted runner. - npm install -g @anthropic-ai/claude-code + run: ${{ github.action_path }}/../../scripts/install-claude-native.sh "${{ inputs.claude-version }}" --cache - name: Pin CI git identity shell: bash run: | set -euo pipefail # Dolt inherits its commit identity from the user's global git config - # (see cmd/gc/gc-beads-bd ensure_dolt_identity). The ubuntu-latest - # hosted runner ships with user.name/user.email baked in; the - # self-hosted macstadium runner does not. + # (see cmd/gc/gc-beads-bd ensure_dolt_identity). # # Force-set a deterministic CI identity unconditionally. Don't log # the resolved value — on a reused runner any preexisting identity diff --git a/.github/actions/setup-gascity-ubuntu/action.yml b/.github/actions/setup-gascity-ubuntu/action.yml index 20e0d2a48a..b47a2bcd0c 100644 --- a/.github/actions/setup-gascity-ubuntu/action.yml +++ b/.github/actions/setup-gascity-ubuntu/action.yml @@ -5,7 +5,7 @@ inputs: go-version: description: Go version to install required: false - default: "1.25.8" + default: "1.25.9" node-version: description: Node.js version to install required: false @@ -20,11 +20,15 @@ inputs: description: Whether to install the Claude CLI required: false default: "true" + claude-version: + description: Claude Code version to install with the native binary installer + required: false + default: "2.1.123" runs: using: composite steps: - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ inputs.go-version }} @@ -38,31 +42,13 @@ runs: - name: Install dolt v${{ inputs.dolt-version }} shell: bash - run: | - curl -fsSL "https://github.com/dolthub/dolt/releases/download/v${{ inputs.dolt-version }}/install.sh" | sudo bash - dolt version + run: ${{ github.action_path }}/../../scripts/install-dolt-archive.sh "${{ inputs.dolt-version }}" - name: Install released bd v${{ inputs.bd-version }} shell: bash - run: | - version="${{ inputs.bd-version }}" - case "$(uname -m)" in - x86_64|amd64) bd_arch=amd64 ;; - aarch64|arm64) bd_arch=arm64 ;; - *) - echo "Unsupported runner architecture: $(uname -m)" >&2 - exit 1 - ;; - esac - archive="beads_${version#v}_linux_${bd_arch}.tar.gz" - mkdir -p "$RUNNER_TEMP/beads" - curl -fsSL -o "$RUNNER_TEMP/$archive" \ - "https://github.com/gastownhall/beads/releases/download/${version}/${archive}" - tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP/beads" bd - sudo install -m 0755 "$RUNNER_TEMP/beads/bd" /usr/local/bin/bd - bd version + run: ${{ github.action_path }}/../../scripts/install-bd-archive.sh "${{ inputs.bd-version }}" - name: Install Claude CLI if: ${{ inputs.install-claude-cli == 'true' }} shell: bash - run: npm install -g @anthropic-ai/claude-code + run: ${{ github.action_path }}/../../scripts/install-claude-native.sh "${{ inputs.claude-version }}" diff --git a/.github/blacksmith-allowlist.txt b/.github/blacksmith-allowlist.txt new file mode 100644 index 0000000000..426a4cf6af --- /dev/null +++ b/.github/blacksmith-allowlist.txt @@ -0,0 +1,9 @@ +# GitHub logins allowed to run sponsored Blacksmith CI automatically. +# One login per line. Blank lines and # comments are ignored. +# +# Blacksmith is limited to pull requests from these users. Pushes, +# schedules, manual runs, and other contributors use GitHub-hosted runners. +julianknutsen +csells +sjarmak +quad341 diff --git a/.github/requirements/mcp-agent-mail.in b/.github/requirements/mcp-agent-mail.in new file mode 100644 index 0000000000..b5cd004433 --- /dev/null +++ b/.github/requirements/mcp-agent-mail.in @@ -0,0 +1,3 @@ +# PyPI is still at 0.1.0; pin the v0.3.2 release commit until upstream +# publishes current wheel/sdist assets. +mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz diff --git a/.github/requirements/mcp-agent-mail.txt b/.github/requirements/mcp-agent-mail.txt new file mode 100644 index 0000000000..cd09cdf146 --- /dev/null +++ b/.github/requirements/mcp-agent-mail.txt @@ -0,0 +1,2992 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile .github/requirements/mcp-agent-mail.in --generate-hashes --python-version 3.12 --python-platform linux --output-file .github/requirements/mcp-agent-mail.txt +aiohappyeyeballs==2.6.1 \ + --hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558 \ + --hash=sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8 + # via aiohttp +aiohttp==3.13.4 \ + --hash=sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144 \ + --hash=sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9 \ + --hash=sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed \ + --hash=sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182 \ + --hash=sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576 \ + --hash=sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118 \ + --hash=sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965 \ + --hash=sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e \ + --hash=sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c \ + --hash=sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97 \ + --hash=sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e \ + --hash=sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933 \ + --hash=sha256:1746338dc2a33cf706cd7446575d13d451f28f9860bebc908c7632b22e71ae3f \ + --hash=sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726 \ + --hash=sha256:19f60011ad60e40a01d242238bb335399e3a4d8df958c63cbb835add8d5c3b5a \ + --hash=sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5 \ + --hash=sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871 \ + --hash=sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758 \ + --hash=sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0 \ + --hash=sha256:26ed03f7d3d6453634729e2c7600d7255d65e879559c5a48fe1bb78355cde74b \ + --hash=sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7 \ + --hash=sha256:2d15e7e4f1099d9e4d863eaf77a8eee5dcb002b7d7188061b0fbee37f845899e \ + --hash=sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e \ + --hash=sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f \ + --hash=sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d \ + --hash=sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927 \ + --hash=sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7 \ + --hash=sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3 \ + --hash=sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2 \ + --hash=sha256:3b4e07d8803a70dd886b5f38588e5b49f894995ca8e132b06c31a2583ae2ef6e \ + --hash=sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7 \ + --hash=sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9 \ + --hash=sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329 \ + --hash=sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1 \ + --hash=sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819 \ + --hash=sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42 \ + --hash=sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70 \ + --hash=sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7 \ + --hash=sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9 \ + --hash=sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2 \ + --hash=sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c \ + --hash=sha256:4c3f733916e85506b8000dddc071c6b82f8c68f56c99adb328d6550017db062d \ + --hash=sha256:4e2e68085730a03704beb2cff035fa8648f62c9f93758d7e6d70add7f7bb5b3b \ + --hash=sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a \ + --hash=sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3 \ + --hash=sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e \ + --hash=sha256:5539ec0d6a3a5c6799b661b7e79166ad1b7ae71ccb59a92fcb6b4ef89295bc94 \ + --hash=sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2 \ + --hash=sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d \ + --hash=sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be \ + --hash=sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77 \ + --hash=sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023 \ + --hash=sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722 \ + --hash=sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538 \ + --hash=sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453 \ + --hash=sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee \ + --hash=sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f \ + --hash=sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b \ + --hash=sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7 \ + --hash=sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123 \ + --hash=sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e \ + --hash=sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3 \ + --hash=sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c \ + --hash=sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845 \ + --hash=sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a \ + --hash=sha256:797613182ffaaca0b9ad5f3b3d3ce5d21242c768f75e66c750b8292bd97c9de3 \ + --hash=sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763 \ + --hash=sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb \ + --hash=sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c \ + --hash=sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83 \ + --hash=sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8 \ + --hash=sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942 \ + --hash=sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab \ + --hash=sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1 \ + --hash=sha256:907ad36b6a65cff7d88d7aca0f77c650546ba850a4f92c92ecb83590d4613249 \ + --hash=sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073 \ + --hash=sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde \ + --hash=sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27 \ + --hash=sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c \ + --hash=sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500 \ + --hash=sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069 \ + --hash=sha256:a5444dce2e6fba0a1dc2d58d026e674f25f21de178c6f844342629bcef019f2f \ + --hash=sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9 \ + --hash=sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab \ + --hash=sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d \ + --hash=sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21 \ + --hash=sha256:b3d525648fe7c8b4977e460c18098f9f81d7991d72edfdc2f13cf96068f279bc \ + --hash=sha256:b3f00bb9403728b08eb3951e982ca0a409c7a871d709684623daeab79465b181 \ + --hash=sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8 \ + --hash=sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb \ + --hash=sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3 \ + --hash=sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145 \ + --hash=sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d \ + --hash=sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165 \ + --hash=sha256:c344c47e85678e410b064fc2ace14db86bb69db7ed5520c234bf13aed603ec30 \ + --hash=sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8 \ + --hash=sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30 \ + --hash=sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954 \ + --hash=sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7 \ + --hash=sha256:cb15595eb52870f84248d7cc97013a76f52ab02ff74d394be093b1d9b8b82bc0 \ + --hash=sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba \ + --hash=sha256:ce7320a945aac4bf0bb8901600e4f9409eb602f25ce3ef4d275b48f6d704a862 \ + --hash=sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9 \ + --hash=sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349 \ + --hash=sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393 \ + --hash=sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97 \ + --hash=sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12 \ + --hash=sha256:d904084985ca66459e93797e5e05985c048a9c0633655331144c089943e53d12 \ + --hash=sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38 \ + --hash=sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b \ + --hash=sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551 \ + --hash=sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57 \ + --hash=sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c \ + --hash=sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb \ + --hash=sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d \ + --hash=sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532 \ + --hash=sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360 \ + --hash=sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f \ + --hash=sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c \ + --hash=sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791 + # via litellm +aiolimiter==1.2.1 \ + --hash=sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7 \ + --hash=sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9 + # via mcp-agent-mail +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + # via aiohttp +aiosqlite==0.22.1 \ + --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \ + --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb + # via mcp-agent-mail +annotated-doc==0.0.4 \ + --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ + --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 + # via + # fastapi + # typer +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc + # via + # httpx + # mcp + # openai + # sse-starlette + # starlette + # watchfiles +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # aiohttp + # cyclopts + # jsonschema + # mcp-agent-mail + # referencing +authlib==1.5.2 \ + --hash=sha256:8804dd4402ac5e4a0435ac49e0b6e19e395357cfa632a3f624dcb4f6df13b4b1 \ + --hash=sha256:fe85ec7e50c5f86f1e2603518bb3b4f632985eb4a355e52256530790e326c512 + # via + # fastmcp + # mcp-agent-mail +beartype==0.22.9 \ + --hash=sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f \ + --hash=sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2 + # via + # py-key-value-aio + # py-key-value-shared +bleach==6.3.0 \ + --hash=sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22 \ + --hash=sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6 + # via mcp-agent-mail +cachetools==7.0.6 \ + --hash=sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b \ + --hash=sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24 + # via py-key-value-aio +certifi==2026.4.22 \ + --hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \ + --hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580 + # via + # httpcore + # httpx + # requests +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via + # cryptography + # pynacl +charset-normalizer==3.4.7 \ + --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ + --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ + --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \ + --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \ + --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ + --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ + --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ + --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \ + --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \ + --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \ + --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \ + --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \ + --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \ + --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \ + --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ + --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \ + --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \ + --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \ + --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ + --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ + --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ + --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \ + --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ + --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \ + --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \ + --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \ + --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ + --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \ + --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \ + --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ + --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ + --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ + --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ + --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ + --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ + --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ + --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ + --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \ + --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \ + --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \ + --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ + --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ + --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ + --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ + --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ + --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ + --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \ + --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ + --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \ + --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \ + --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \ + --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \ + --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ + --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ + --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \ + --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \ + --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ + --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ + --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ + --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \ + --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ + --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ + --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \ + --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ + --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ + --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \ + --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \ + --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \ + --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \ + --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ + --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \ + --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ + --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \ + --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ + --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \ + --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ + --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ + --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ + --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \ + --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ + --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \ + --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \ + --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ + --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ + --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \ + --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \ + --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \ + --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ + --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \ + --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \ + --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ + --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \ + --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ + --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \ + --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ + --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ + --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ + --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \ + --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \ + --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ + --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ + --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \ + --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \ + --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \ + --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ + --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \ + --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \ + --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \ + --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \ + --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ + --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \ + --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ + --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \ + --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \ + --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ + --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ + --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \ + --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ + --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ + --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \ + --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ + --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ + --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \ + --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ + --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ + --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ + --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ + --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ + --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 + # via requests +click==8.1.8 \ + --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ + --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a + # via + # litellm + # typer + # uvicorn +cryptography==47.0.0 \ + --hash=sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7 \ + --hash=sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27 \ + --hash=sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd \ + --hash=sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7 \ + --hash=sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001 \ + --hash=sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4 \ + --hash=sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca \ + --hash=sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0 \ + --hash=sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe \ + --hash=sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93 \ + --hash=sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475 \ + --hash=sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe \ + --hash=sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515 \ + --hash=sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10 \ + --hash=sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7 \ + --hash=sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92 \ + --hash=sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829 \ + --hash=sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8 \ + --hash=sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52 \ + --hash=sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b \ + --hash=sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc \ + --hash=sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c \ + --hash=sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63 \ + --hash=sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac \ + --hash=sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31 \ + --hash=sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7 \ + --hash=sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1 \ + --hash=sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203 \ + --hash=sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7 \ + --hash=sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769 \ + --hash=sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923 \ + --hash=sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74 \ + --hash=sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b \ + --hash=sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb \ + --hash=sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab \ + --hash=sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76 \ + --hash=sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f \ + --hash=sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7 \ + --hash=sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973 \ + --hash=sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0 \ + --hash=sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8 \ + --hash=sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310 \ + --hash=sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b \ + --hash=sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318 \ + --hash=sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab \ + --hash=sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8 \ + --hash=sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa \ + --hash=sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50 \ + --hash=sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736 + # via + # authlib + # pyjwt + # secretstorage +cyclopts==4.11.0 \ + --hash=sha256:1ffcb9990dbd56b90da19980d31596de9e99019980a215a5d76cf88fe452e94d \ + --hash=sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d + # via fastmcp +diskcache==5.6.3 \ + --hash=sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc \ + --hash=sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19 + # via py-key-value-aio +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via openai +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f + # via email-validator +docstring-parser==0.18.0 \ + --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b + # via cyclopts +docutils==0.22.4 \ + --hash=sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968 \ + --hash=sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de + # via rich-rst +email-validator==2.3.0 \ + --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ + --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 + # via pydantic +exceptiongroup==1.3.1 \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + # via fastmcp +fastapi==0.136.1 \ + --hash=sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f \ + --hash=sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f + # via mcp-agent-mail +fastmcp==2.13.0.2 \ + --hash=sha256:d35386561b6f3cde195ba2b5892dc89b8919a721e6b39b98e7a16f9a7c0b8e8b \ + --hash=sha256:eb381eb073a101aabbc0ac44b05e23fef0cd1619344b7703115c825c8755fa1c + # via mcp-agent-mail +fastuuid==0.14.0 \ + --hash=sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1 \ + --hash=sha256:0737606764b29785566f968bd8005eace73d3666bd0862f33a760796e26d1ede \ + --hash=sha256:089c18018fdbdda88a6dafd7d139f8703a1e7c799618e33ea25eb52503d28a11 \ + --hash=sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995 \ + --hash=sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc \ + --hash=sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796 \ + --hash=sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed \ + --hash=sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7 \ + --hash=sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab \ + --hash=sha256:139d7ff12bb400b4a0c76be64c28cbe2e2edf60b09826cbfd85f33ed3d0bbe8b \ + --hash=sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00 \ + --hash=sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26 \ + --hash=sha256:193ca10ff553cf3cc461572da83b5780fc0e3eea28659c16f89ae5202f3958d4 \ + --hash=sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219 \ + --hash=sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75 \ + --hash=sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714 \ + --hash=sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b \ + --hash=sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94 \ + --hash=sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36 \ + --hash=sha256:2dce5d0756f046fa792a40763f36accd7e466525c5710d2195a038f93ff96346 \ + --hash=sha256:2ec3d94e13712a133137b2805073b65ecef4a47217d5bac15d8ac62376cefdb4 \ + --hash=sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8 \ + --hash=sha256:2fc37479517d4d70c08696960fad85494a8a7a0af4e93e9a00af04d74c59f9e3 \ + --hash=sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87 \ + --hash=sha256:3964bab460c528692c70ab6b2e469dd7a7b152fbe8c18616c58d34c93a6cf8d4 \ + --hash=sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8 \ + --hash=sha256:448aa6833f7a84bfe37dd47e33df83250f404d591eb83527fa2cac8d1e57d7f3 \ + --hash=sha256:47c821f2dfe95909ead0085d4cb18d5149bca704a2b03e03fb3f81a5202d8cea \ + --hash=sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6 \ + --hash=sha256:5816d41f81782b209843e52fdef757a361b448d782452d96abedc53d545da722 \ + --hash=sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a \ + --hash=sha256:6fbc49a86173e7f074b1a9ec8cf12ca0d54d8070a85a06ebf0e76c309b84f0d0 \ + --hash=sha256:73657c9f778aba530bc96a943d30e1a7c80edb8278df77894fe9457540df4f85 \ + --hash=sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34 \ + --hash=sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021 \ + --hash=sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a \ + --hash=sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d \ + --hash=sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a \ + --hash=sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09 \ + --hash=sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8 \ + --hash=sha256:84b0779c5abbdec2a9511d5ffbfcd2e53079bf889824b32be170c0d8ef5fc74c \ + --hash=sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176 \ + --hash=sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4 \ + --hash=sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc \ + --hash=sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad \ + --hash=sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24 \ + --hash=sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f \ + --hash=sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f \ + --hash=sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f \ + --hash=sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741 \ + --hash=sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5 \ + --hash=sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4 \ + --hash=sha256:af5967c666b7d6a377098849b07f83462c4fedbafcf8eb8bc8ff05dcbe8aa209 \ + --hash=sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470 \ + --hash=sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad \ + --hash=sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057 \ + --hash=sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8 \ + --hash=sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe \ + --hash=sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73 \ + --hash=sha256:c0eb25f0fd935e376ac4334927a59e7c823b36062080e2e13acbaf2af15db836 \ + --hash=sha256:c3091e63acf42f56a6f74dc65cfdb6f99bfc79b5913c8a9ac498eb7ca09770a8 \ + --hash=sha256:c501561e025b7aea3508719c5801c360c711d5218fc4ad5d77bf1c37c1a75779 \ + --hash=sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b \ + --hash=sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d \ + --hash=sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022 \ + --hash=sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7 \ + --hash=sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070 \ + --hash=sha256:d31f8c257046b5617fc6af9c69be066d2412bdef1edaa4bdf6a214cf57806105 \ + --hash=sha256:d55b7e96531216fc4f071909e33e35e5bfa47962ae67d9e84b00a04d6e8b7173 \ + --hash=sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397 \ + --hash=sha256:de01280eabcd82f7542828ecd67ebf1551d37203ecdfd7ab1f2e534edb78d505 \ + --hash=sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a \ + --hash=sha256:e0976c0dff7e222513d206e06341503f07423aceb1db0b83ff6851c008ceee06 \ + --hash=sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa \ + --hash=sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06 \ + --hash=sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8 \ + --hash=sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad \ + --hash=sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d + # via litellm +filelock==3.29.0 \ + --hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \ + --hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258 + # via + # huggingface-hub + # mcp-agent-mail +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \ + --hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \ + --hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \ + --hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \ + --hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \ + --hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \ + --hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \ + --hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \ + --hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \ + --hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \ + --hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \ + --hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \ + --hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \ + --hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \ + --hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \ + --hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \ + --hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \ + --hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \ + --hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \ + --hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd + # via + # aiohttp + # aiosignal +fsspec==2026.3.0 \ + --hash=sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41 \ + --hash=sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4 + # via huggingface-hub +gitdb==4.0.12 \ + --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ + --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf + # via gitpython +gitpython==3.1.49 \ + --hash=sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c \ + --hash=sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1 + # via mcp-agent-mail +greenlet==3.5.0 \ + --hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \ + --hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \ + --hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \ + --hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \ + --hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \ + --hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \ + --hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \ + --hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \ + --hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \ + --hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \ + --hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \ + --hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \ + --hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \ + --hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \ + --hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \ + --hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \ + --hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \ + --hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \ + --hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \ + --hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \ + --hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \ + --hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \ + --hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \ + --hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \ + --hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \ + --hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \ + --hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \ + --hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \ + --hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \ + --hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \ + --hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \ + --hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \ + --hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \ + --hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \ + --hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \ + --hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \ + --hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \ + --hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \ + --hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \ + --hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \ + --hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \ + --hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \ + --hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \ + --hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \ + --hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \ + --hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \ + --hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \ + --hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \ + --hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \ + --hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \ + --hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \ + --hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \ + --hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \ + --hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \ + --hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \ + --hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \ + --hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \ + --hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \ + --hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564 + # via sqlalchemy +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # uvicorn +h2==4.3.0 \ + --hash=sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1 \ + --hash=sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd + # via httpx +hf-xet==1.4.3 \ + --hash=sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07 \ + --hash=sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2 \ + --hash=sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3 \ + --hash=sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8 \ + --hash=sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a \ + --hash=sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4 \ + --hash=sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f \ + --hash=sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b \ + --hash=sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac \ + --hash=sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6 \ + --hash=sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74 \ + --hash=sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075 \ + --hash=sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021 \ + --hash=sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144 \ + --hash=sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba \ + --hash=sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47 \ + --hash=sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791 \ + --hash=sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113 \ + --hash=sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8 \ + --hash=sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f \ + --hash=sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd \ + --hash=sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025 \ + --hash=sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653 \ + --hash=sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583 \ + --hash=sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08 + # via huggingface-hub +hiredis==3.3.1 \ + --hash=sha256:002fc0201b9af1cc8960e27cdc501ad1f8cdd6dbadb2091c6ddbd4e5ace6cb77 \ + --hash=sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5 \ + --hash=sha256:01cf82a514bc4fd145b99333c28523e61b7a9ad051a245804323ebf4e7b1c6a6 \ + --hash=sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e \ + --hash=sha256:03baa381964b8df356d19ec4e3a6ae656044249a87b0def257fe1e08dbaf6094 \ + --hash=sha256:042e57de8a2cae91e3e7c0af32960ea2c5107b2f27f68a740295861e68780a8a \ + --hash=sha256:09d41a3a965f7c261223d516ebda607aee4d8440dd7637f01af9a4c05872f0c4 \ + --hash=sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4 \ + --hash=sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa \ + --hash=sha256:0caf3fc8af0767794b335753781c3fa35f2a3e975c098edbc8f733d35d6a95e4 \ + --hash=sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f \ + --hash=sha256:113e098e4a6b3cc5500e05e7cb1548ba9e83de5fe755941b11f6020a76e6c03a \ + --hash=sha256:137c14905ea6f2933967200bc7b2a0c8ec9387888b273fd0004f25b994fd0343 \ + --hash=sha256:156be6a0c736ee145cfe0fb155d0e96cec8d4872cf8b4f76ad6a2ee6ab391d0a \ + --hash=sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838 \ + --hash=sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21 \ + --hash=sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34 \ + --hash=sha256:1ebc307a87b099d0877dbd2bdc0bae427258e7ec67f60a951e89027f8dc2568f \ + --hash=sha256:1f7bceb03a1b934872ffe3942eaeed7c7e09096e67b53f095b81f39c7a819113 \ + --hash=sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a \ + --hash=sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0 \ + --hash=sha256:26f899cde0279e4b7d370716ff80320601c2bd93cdf3e774a42bdd44f65b41f8 \ + --hash=sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355 \ + --hash=sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10 \ + --hash=sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b \ + --hash=sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192 \ + --hash=sha256:2f1c1b2e8f00b71e6214234d313f655a3a27cd4384b054126ce04073c1d47045 \ + --hash=sha256:304481241e081bc26f0778b2c2b99f9c43917e4e724a016dcc9439b7ab12c726 \ + --hash=sha256:318f772dd321404075d406825266e574ee0f4751be1831424c2ebd5722609398 \ + --hash=sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400 \ + --hash=sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075 \ + --hash=sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929 \ + --hash=sha256:40ae8a7041fcb328a6bc7202d8c4e6e0d38d434b2e3880b1ee8ed754f17cd836 \ + --hash=sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8 \ + --hash=sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0 \ + --hash=sha256:4479e36d263251dba8ab8ea81adf07e7f1163603c7102c5de1e130b83b4fad3b \ + --hash=sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297 \ + --hash=sha256:48ff424f8aa36aacd9fdaa68efeb27d2e8771f293af4305bdb15d92194ca6631 \ + --hash=sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f \ + --hash=sha256:526db52e5234a9463520e960a509d6c1bd5128d1ab1b569cbf459fe39189e8ab \ + --hash=sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8 \ + --hash=sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3 \ + --hash=sha256:583de2f16528e66081cbdfe510d8488c2de73039dc00aada7d22bd49d73a4a94 \ + --hash=sha256:5e55d90b431b0c6b64ae5a624208d4aea318566d31872e595ee723c0f5b9a79f \ + --hash=sha256:5f316cf2d0558f5027aab19dde7d7e4901c26c21fa95367bc37784e8f547bbf2 \ + --hash=sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e \ + --hash=sha256:62cc62284541bb2a86c898c7d5e8388661cade91c184cb862095ed547e80588f \ + --hash=sha256:65c05b79cb8366c123357b354a16f9fc3f7187159422f143638d1c26b7240ed4 \ + --hash=sha256:65f6ac06a9f0c32c254660ec6a9329d81d589e8f5d0a9837a941d5424a6be1ef \ + --hash=sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1 \ + --hash=sha256:6e2e1024f0a021777740cb7c633a0efb2c4a4bc570f508223a8dcbcf79f99ef9 \ + --hash=sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6 \ + --hash=sha256:743b85bd6902856cac457ddd8cd7dd48c89c47d641b6016ff5e4d015bfbd4799 \ + --hash=sha256:77c5d2bebbc9d06691abb512a31d0f54e1562af0b872891463a67a949b5278ef \ + --hash=sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4 \ + --hash=sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f \ + --hash=sha256:81a1669b6631976b1dc9d3d58ed1ab3333e9f52feb91a2a1fb8241101ac3b665 \ + --hash=sha256:8597c35c9e82f65fd5897c4a2188c65d7daf10607b102960137b23d261cd957b \ + --hash=sha256:8650158217b469d8b6087f490929211b0493a9121154c4efaafd1dec9e19319e \ + --hash=sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424 \ + --hash=sha256:8a52b24cd710690c4a7e191c7e300136ad2ecb3c68ffe7e95b598e76de166e5e \ + --hash=sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa \ + --hash=sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920 \ + --hash=sha256:90d6b9f2652303aefd2c5a26a5e14cb74a3a63d10faa642c08d790e99442a088 \ + --hash=sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d \ + --hash=sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34 \ + --hash=sha256:9ebae74ce2b977c2fcb22d6a10aa0acb730022406977b2bcb6ddd6788f5c414a \ + --hash=sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708 \ + --hash=sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358 \ + --hash=sha256:a2f049c3f3c83e886cd1f53958e2a1ebb369be626bef9e50d8b24d79864f1df6 \ + --hash=sha256:a3af4e9f277d6b8acd369dc44a723a055752fca9d045094383af39f90a3e3729 \ + --hash=sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa \ + --hash=sha256:a58a58cef0d911b1717154179a9ff47852249c536ea5966bde4370b6b20638ff \ + --hash=sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809 \ + --hash=sha256:ad940dc2db545dc978cb41cb9a683e2ff328f3ef581230b9ca40ff6c3d01d542 \ + --hash=sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba \ + --hash=sha256:b1e3b9f4bf9a4120510ba77a77b2fb674893cd6795653545152bb11a79eecfcb \ + --hash=sha256:b2390ad81c03d93ef1d5afd18ffcf5935de827f1a2b96b2c829437968bdabccb \ + --hash=sha256:b37df4b10cb15dedfc203f69312d8eedd617b941c21df58c13af59496c53ad0f \ + --hash=sha256:b3df9447f9209f9aa0434ca74050e9509670c1ad99398fe5807abb90e5f3a014 \ + --hash=sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6 \ + --hash=sha256:c1d68c6980d4690a4550bd3db6c03146f7be68ef5d08d38bb1fb68b3e9c32fe3 \ + --hash=sha256:c24c1460486b6b36083252c2db21a814becf8495ccd0e76b7286623e37239b63 \ + --hash=sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce \ + --hash=sha256:c74bd9926954e7e575f9cd9890f63defd90cd8f812dfbf8e1efb72acc9355456 \ + --hash=sha256:c8139e9011117822391c5bcfd674c5948fb1e4b8cb9adf6f13d9890859ee3a1a \ + --hash=sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc \ + --hash=sha256:d14229beaa76e66c3a25f9477d973336441ca820df853679a98796256813316f \ + --hash=sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75 \ + --hash=sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c \ + --hash=sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698 \ + --hash=sha256:db46baf157feefd88724e6a7f145fe996a5990a8604ed9292b45d563360e513b \ + --hash=sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c \ + --hash=sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a \ + --hash=sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f \ + --hash=sha256:e0db44cf81e4d7b94f3776b9f89111f74ed6bbdbfd42a22bc4a5ce0644d3e060 \ + --hash=sha256:e31e92b61d56244047ad600812e16f7587a6172f74810fd919ff993af12b9149 \ + --hash=sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404 \ + --hash=sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8 \ + --hash=sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92 \ + --hash=sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580 \ + --hash=sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17 \ + --hash=sha256:f2f94355affd51088f57f8674b0e294704c3c7c3d7d3b1545310f5b135d4843b \ + --hash=sha256:f525734382a47f9828c9d6a1501522c78d5935466d8e2be1a41ba40ca5bb922b \ + --hash=sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736 + # via redis +hpack==4.1.0 \ + --hash=sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496 \ + --hash=sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca + # via h2 +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httptools==0.7.1 \ + --hash=sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c \ + --hash=sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad \ + --hash=sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1 \ + --hash=sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78 \ + --hash=sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb \ + --hash=sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03 \ + --hash=sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6 \ + --hash=sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df \ + --hash=sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5 \ + --hash=sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321 \ + --hash=sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346 \ + --hash=sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650 \ + --hash=sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657 \ + --hash=sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28 \ + --hash=sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023 \ + --hash=sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca \ + --hash=sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed \ + --hash=sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66 \ + --hash=sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3 \ + --hash=sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca \ + --hash=sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3 \ + --hash=sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2 \ + --hash=sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4 \ + --hash=sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70 \ + --hash=sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9 \ + --hash=sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4 \ + --hash=sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517 \ + --hash=sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a \ + --hash=sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270 \ + --hash=sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05 \ + --hash=sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e \ + --hash=sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568 \ + --hash=sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96 \ + --hash=sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf \ + --hash=sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b \ + --hash=sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a \ + --hash=sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b \ + --hash=sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c \ + --hash=sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274 \ + --hash=sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60 \ + --hash=sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5 \ + --hash=sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec \ + --hash=sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362 + # via uvicorn +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via + # fastmcp + # huggingface-hub + # litellm + # mcp + # mcp-agent-mail + # openai +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ + --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d + # via mcp +huggingface-hub==1.12.0 \ + --hash=sha256:7c3fe85e24b652334e5d456d7a812cd9a071e75630fac4365d9165ab5e4a34b6 \ + --hash=sha256:d74939969585ee35748bd66de09baf84099d461bda7287cd9043bfb99b0e424d + # via tokenizers +hyperframe==6.1.0 \ + --hash=sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5 \ + --hash=sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08 + # via h2 +idna==3.13 \ + --hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \ + --hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 + # via + # anyio + # email-validator + # httpx + # requests + # yarl +importlib-metadata==8.5.0 \ + --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ + --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 + # via litellm +jaraco-classes==3.4.0 \ + --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ + --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 + # via keyring +jaraco-context==6.1.2 \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 + # via keyring +jaraco-functools==4.4.0 \ + --hash=sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176 \ + --hash=sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb + # via keyring +jeepney==0.9.0 \ + --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \ + --hash=sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732 + # via + # keyring + # secretstorage +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # litellm + # mcp-agent-mail +jiter==0.14.0 \ + --hash=sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5 \ + --hash=sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c \ + --hash=sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531 \ + --hash=sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b \ + --hash=sha256:0fbad7aa06f87e8215d660fc6f05a9b07b58751a29967bbd9c81ff22d21dbe8c \ + --hash=sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce \ + --hash=sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588 \ + --hash=sha256:155dab67beac8d66cec9479c93ee2cbe7bfbc67509e5c2860e02ec2d9b0ecca1 \ + --hash=sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b \ + --hash=sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b \ + --hash=sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db \ + --hash=sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c \ + --hash=sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28 \ + --hash=sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2 \ + --hash=sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994 \ + --hash=sha256:2d45fc7ea86a46bd9b5bceb9e8d43e5d10a392378713fb32cf1ce851b4b0d1f8 \ + --hash=sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975 \ + --hash=sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674 \ + --hash=sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607 \ + --hash=sha256:32959d7285d1d0deb5a8c913349e476ad9271b384f3e54cca1931c4075f54c6e \ + --hash=sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d \ + --hash=sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92 \ + --hash=sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d \ + --hash=sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e \ + --hash=sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560 \ + --hash=sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2 \ + --hash=sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e \ + --hash=sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842 \ + --hash=sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016 \ + --hash=sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d \ + --hash=sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a \ + --hash=sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314 \ + --hash=sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c \ + --hash=sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844 \ + --hash=sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff \ + --hash=sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f \ + --hash=sha256:55bee2b6a2657434984d9144c20cf27ba3b6acd495539539953e447778515efd \ + --hash=sha256:59940ef6ac9f8b34c800838416f105f0503485fa8d71cae99f71d44a7285b01e \ + --hash=sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373 \ + --hash=sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa \ + --hash=sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129 \ + --hash=sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9 \ + --hash=sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9 \ + --hash=sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06 \ + --hash=sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea \ + --hash=sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a \ + --hash=sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9 \ + --hash=sha256:6ae66782ecffb1a266e1a07f5abbfc3832afdd260fc9b478982c3f8e01eba5fa \ + --hash=sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593 \ + --hash=sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140 \ + --hash=sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec \ + --hash=sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804 \ + --hash=sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc \ + --hash=sha256:758d19dae7ea4c4da3cbc463dc323d1660e7353144ef17509ff43beab6da5a47 \ + --hash=sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de \ + --hash=sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1 \ + --hash=sha256:78a4c677fe5689e0e129b39f5affe9210a500b6620ebb0386ebccf5922bee9a6 \ + --hash=sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310 \ + --hash=sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40 \ + --hash=sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e \ + --hash=sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2 \ + --hash=sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a \ + --hash=sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7 \ + --hash=sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa \ + --hash=sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00 \ + --hash=sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea \ + --hash=sha256:85581c4c3e4060fe3424cdfd7f3aa610f2dc5e9dde8b6863358eb68560018472 \ + --hash=sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f \ + --hash=sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746 \ + --hash=sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01 \ + --hash=sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f \ + --hash=sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220 \ + --hash=sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211 \ + --hash=sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9 \ + --hash=sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c \ + --hash=sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985 \ + --hash=sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8 \ + --hash=sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3 \ + --hash=sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94 \ + --hash=sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4 \ + --hash=sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342 \ + --hash=sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02 \ + --hash=sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9 \ + --hash=sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165 \ + --hash=sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb \ + --hash=sha256:c6279c63849444a4fe9b9abf82e5df0fc7d13dea07f53f084b362485bd1f2bbe \ + --hash=sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a \ + --hash=sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d \ + --hash=sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615 \ + --hash=sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928 \ + --hash=sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2 \ + --hash=sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98 \ + --hash=sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264 \ + --hash=sha256:e1765c3ef3ea31fe6e282376a16def1a96f5f11a0235055696c18d9d23ff30cb \ + --hash=sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f \ + --hash=sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577 \ + --hash=sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a \ + --hash=sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab \ + --hash=sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e \ + --hash=sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057 \ + --hash=sha256:f16b76d7d6aadbbaf7f79a76ff3a51dae14b7ebaaf9c1ba61607784ef51c537c \ + --hash=sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611 \ + --hash=sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850 \ + --hash=sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927 \ + --hash=sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9 \ + --hash=sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa \ + --hash=sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f \ + --hash=sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3 \ + --hash=sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10 + # via openai +jsonschema==4.23.0 \ + --hash=sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4 \ + --hash=sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566 + # via + # litellm + # mcp + # mcp-agent-mail +jsonschema-path==0.4.6 \ + --hash=sha256:451354b5311fa955c3144e6e4e255388c751c0121c5570ec5bb9291dd42d08c9 \ + --hash=sha256:c89eb635f4d497c9ac328eeff359c489755838806a7d033510a692e9576f5c4b + # via fastmcp +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +keyring==25.7.0 \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b + # via py-key-value-aio +litellm==1.83.14 \ + --hash=sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9 \ + --hash=sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb + # via mcp-agent-mail +markdown-it-py==4.0.0 \ + --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ + --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 + # via rich +markdown2==2.5.5 \ + --hash=sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664 \ + --hash=sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941 + # via mcp-agent-mail +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via jinja2 +mcp==1.27.0 \ + --hash=sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741 \ + --hash=sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83 + # via fastmcp +mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz \ + --hash=sha256:8ffe6d9ee8665e957a83a885e5f45d0ad2733f5a50a1e4ec4479e66ef625e35a + # via -r .github/requirements/mcp-agent-mail.in +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +more-itertools==11.0.2 \ + --hash=sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804 \ + --hash=sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4 + # via + # jaraco-classes + # jaraco-functools +multidict==6.7.1 \ + --hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \ + --hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \ + --hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \ + --hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \ + --hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \ + --hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \ + --hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \ + --hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \ + --hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \ + --hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \ + --hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \ + --hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \ + --hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \ + --hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \ + --hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \ + --hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \ + --hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \ + --hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \ + --hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \ + --hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \ + --hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \ + --hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \ + --hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \ + --hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \ + --hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \ + --hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \ + --hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \ + --hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \ + --hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \ + --hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \ + --hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \ + --hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \ + --hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \ + --hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \ + --hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \ + --hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \ + --hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \ + --hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \ + --hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \ + --hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \ + --hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \ + --hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \ + --hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \ + --hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \ + --hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \ + --hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \ + --hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \ + --hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \ + --hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \ + --hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \ + --hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \ + --hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \ + --hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \ + --hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \ + --hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \ + --hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \ + --hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \ + --hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \ + --hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \ + --hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \ + --hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \ + --hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \ + --hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \ + --hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \ + --hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \ + --hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \ + --hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \ + --hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \ + --hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \ + --hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \ + --hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \ + --hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \ + --hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \ + --hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \ + --hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \ + --hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \ + --hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \ + --hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \ + --hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \ + --hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \ + --hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \ + --hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \ + --hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \ + --hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \ + --hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \ + --hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \ + --hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \ + --hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \ + --hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \ + --hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \ + --hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \ + --hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \ + --hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \ + --hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \ + --hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \ + --hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \ + --hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \ + --hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \ + --hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \ + --hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \ + --hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \ + --hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \ + --hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \ + --hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \ + --hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \ + --hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \ + --hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \ + --hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \ + --hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \ + --hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \ + --hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \ + --hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \ + --hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \ + --hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \ + --hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \ + --hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \ + --hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \ + --hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \ + --hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \ + --hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \ + --hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \ + --hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \ + --hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \ + --hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \ + --hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \ + --hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \ + --hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \ + --hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \ + --hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \ + --hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \ + --hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \ + --hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \ + --hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \ + --hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \ + --hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \ + --hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \ + --hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \ + --hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \ + --hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \ + --hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \ + --hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \ + --hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 + # via + # aiohttp + # yarl +openai==2.24.0 \ + --hash=sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673 \ + --hash=sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94 + # via litellm +openapi-pydantic==0.5.1 \ + --hash=sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146 \ + --hash=sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d + # via fastmcp +orjson==3.11.8 \ + --hash=sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8 \ + --hash=sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34 \ + --hash=sha256:01928d0476b216ad2201823b0a74000440360cef4fed1912d297b8d84718f277 \ + --hash=sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d \ + --hash=sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25 \ + --hash=sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade \ + --hash=sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac \ + --hash=sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d \ + --hash=sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546 \ + --hash=sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d \ + --hash=sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f \ + --hash=sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f \ + --hash=sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06 \ + --hash=sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137 \ + --hash=sha256:3222adff1e1ff0dce93c16146b93063a7793de6c43d52309ae321234cdaf0f4d \ + --hash=sha256:3223665349bbfb68da234acd9846955b1a0808cbe5520ff634bf253a4407009b \ + --hash=sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6 \ + --hash=sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc \ + --hash=sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb \ + --hash=sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c \ + --hash=sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec \ + --hash=sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e \ + --hash=sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d \ + --hash=sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f \ + --hash=sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813 \ + --hash=sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6 \ + --hash=sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db \ + --hash=sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a \ + --hash=sha256:58fb9b17b4472c7b1dcf1a54583629e62e23779b2331052f09a9249edf81675b \ + --hash=sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c \ + --hash=sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c \ + --hash=sha256:61c9d357a59465736022d5d9ba06687afb7611dfb581a9d2129b77a6fcf78e59 \ + --hash=sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6 \ + --hash=sha256:6a4a639049c44d36a6d1ae0f4a94b271605c745aee5647fa8ffaabcdc01b69a6 \ + --hash=sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817 \ + --hash=sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054 \ + --hash=sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4 \ + --hash=sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53 \ + --hash=sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b \ + --hash=sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca \ + --hash=sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8 \ + --hash=sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f \ + --hash=sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e \ + --hash=sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5 \ + --hash=sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b \ + --hash=sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942 \ + --hash=sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd \ + --hash=sha256:93de06bc920854552493c81f1f729fab7213b7db4b8195355db5fda02c7d1363 \ + --hash=sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e \ + --hash=sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623 \ + --hash=sha256:97d823831105c01f6c8029faf297633dbeb30271892bd430e9c24ceae3734744 \ + --hash=sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6 \ + --hash=sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e \ + --hash=sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7 \ + --hash=sha256:b43dc2a391981d36c42fa57747a49dae793ef1d2e43898b197925b5534abd10a \ + --hash=sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8 \ + --hash=sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc \ + --hash=sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625 \ + --hash=sha256:c60c0423f15abb6cf78f56dff00168a1b582f7a1c23f114036e2bfc697814d5f \ + --hash=sha256:c98121237fea2f679480765abd566f7713185897f35c9e6c2add7e3a9900eb61 \ + --hash=sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf \ + --hash=sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600 \ + --hash=sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2 \ + --hash=sha256:e6693ff90018600c72fd18d3d22fa438be26076cd3c823da5f63f7bab28c11cb \ + --hash=sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506 \ + --hash=sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559 \ + --hash=sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4 \ + --hash=sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8 \ + --hash=sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f \ + --hash=sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8 \ + --hash=sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55 \ + --hash=sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858 \ + --hash=sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13 \ + --hash=sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6 + # via mcp-agent-mail +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via huggingface-hub +pathable==0.5.0 \ + --hash=sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6 \ + --hash=sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1 + # via jsonschema-path +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via mcp-agent-mail +pathvalidate==3.3.1 \ + --hash=sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f \ + --hash=sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177 + # via py-key-value-aio +pillow==12.2.0 \ + --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \ + --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \ + --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \ + --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \ + --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \ + --hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \ + --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \ + --hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \ + --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \ + --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \ + --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \ + --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \ + --hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \ + --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \ + --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \ + --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \ + --hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \ + --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \ + --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \ + --hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \ + --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \ + --hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \ + --hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \ + --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \ + --hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \ + --hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \ + --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \ + --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \ + --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \ + --hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \ + --hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \ + --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \ + --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \ + --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \ + --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \ + --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \ + --hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \ + --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \ + --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \ + --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \ + --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \ + --hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \ + --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \ + --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \ + --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \ + --hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \ + --hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \ + --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \ + --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \ + --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \ + --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \ + --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \ + --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \ + --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \ + --hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \ + --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \ + --hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \ + --hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \ + --hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \ + --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \ + --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \ + --hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \ + --hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \ + --hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \ + --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \ + --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \ + --hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \ + --hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \ + --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ + --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \ + --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \ + --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \ + --hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \ + --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \ + --hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \ + --hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \ + --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \ + --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \ + --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \ + --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \ + --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \ + --hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \ + --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \ + --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \ + --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \ + --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \ + --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \ + --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ + --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \ + --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \ + --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 + # via mcp-agent-mail +platformdirs==4.9.6 \ + --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ + --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 + # via fastmcp +propcache==0.4.1 \ + --hash=sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e \ + --hash=sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4 \ + --hash=sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be \ + --hash=sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3 \ + --hash=sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85 \ + --hash=sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b \ + --hash=sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367 \ + --hash=sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf \ + --hash=sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393 \ + --hash=sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888 \ + --hash=sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37 \ + --hash=sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8 \ + --hash=sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60 \ + --hash=sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1 \ + --hash=sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4 \ + --hash=sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717 \ + --hash=sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7 \ + --hash=sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc \ + --hash=sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe \ + --hash=sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb \ + --hash=sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75 \ + --hash=sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6 \ + --hash=sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e \ + --hash=sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff \ + --hash=sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566 \ + --hash=sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12 \ + --hash=sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367 \ + --hash=sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874 \ + --hash=sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf \ + --hash=sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566 \ + --hash=sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a \ + --hash=sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc \ + --hash=sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a \ + --hash=sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1 \ + --hash=sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6 \ + --hash=sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61 \ + --hash=sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726 \ + --hash=sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49 \ + --hash=sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44 \ + --hash=sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af \ + --hash=sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa \ + --hash=sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153 \ + --hash=sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc \ + --hash=sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5 \ + --hash=sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938 \ + --hash=sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf \ + --hash=sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925 \ + --hash=sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8 \ + --hash=sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c \ + --hash=sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85 \ + --hash=sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e \ + --hash=sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0 \ + --hash=sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1 \ + --hash=sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0 \ + --hash=sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992 \ + --hash=sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db \ + --hash=sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f \ + --hash=sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d \ + --hash=sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1 \ + --hash=sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e \ + --hash=sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900 \ + --hash=sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89 \ + --hash=sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a \ + --hash=sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b \ + --hash=sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f \ + --hash=sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f \ + --hash=sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1 \ + --hash=sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183 \ + --hash=sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66 \ + --hash=sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21 \ + --hash=sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db \ + --hash=sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded \ + --hash=sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb \ + --hash=sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19 \ + --hash=sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0 \ + --hash=sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165 \ + --hash=sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778 \ + --hash=sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455 \ + --hash=sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f \ + --hash=sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b \ + --hash=sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237 \ + --hash=sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81 \ + --hash=sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859 \ + --hash=sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c \ + --hash=sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835 \ + --hash=sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393 \ + --hash=sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5 \ + --hash=sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641 \ + --hash=sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144 \ + --hash=sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74 \ + --hash=sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db \ + --hash=sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac \ + --hash=sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403 \ + --hash=sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9 \ + --hash=sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f \ + --hash=sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311 \ + --hash=sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581 \ + --hash=sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36 \ + --hash=sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00 \ + --hash=sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a \ + --hash=sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f \ + --hash=sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2 \ + --hash=sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7 \ + --hash=sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239 \ + --hash=sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757 \ + --hash=sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72 \ + --hash=sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9 \ + --hash=sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4 \ + --hash=sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24 \ + --hash=sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207 \ + --hash=sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e \ + --hash=sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1 \ + --hash=sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d \ + --hash=sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37 \ + --hash=sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c \ + --hash=sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e \ + --hash=sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570 \ + --hash=sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af \ + --hash=sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f \ + --hash=sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88 \ + --hash=sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48 \ + --hash=sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781 + # via + # aiohttp + # yarl +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 + # via mcp-agent-mail +py-key-value-aio==0.2.8 \ + --hash=sha256:561565547ce8162128fd2bd0b9d70ce04a5f4586da8500cce79a54dfac78c46a \ + --hash=sha256:c0cfbb0bd4e962a3fa1a9fa6db9ba9df812899bd9312fa6368aaea7b26008b36 + # via fastmcp +py-key-value-shared==0.2.8 \ + --hash=sha256:703b4d3c61af124f0d528ba85995c3c8d78f8bd3d2b217377bd3278598070cc1 \ + --hash=sha256:aff1bbfd46d065b2d67897d298642e80e5349eae588c6d11b48452b46b8d46ba + # via py-key-value-aio +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d + # via + # fastapi + # fastmcp + # litellm + # mcp + # openai + # openapi-pydantic + # pydantic-settings + # sqlmodel +pydantic-core==2.41.5 \ + --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ + --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ + --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \ + --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ + --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ + --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ + --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ + --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ + --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ + --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ + --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ + --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ + --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ + --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ + --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ + --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ + --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ + --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ + --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ + --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ + --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ + --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ + --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ + --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ + --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \ + --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ + --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ + --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ + --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ + --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ + --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ + --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \ + --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ + --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ + --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ + --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ + --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ + --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ + --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ + --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ + --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ + --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ + --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ + --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ + --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ + --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ + --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ + --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ + --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ + --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ + --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ + --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ + --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ + --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ + --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ + --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ + --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \ + --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ + --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \ + --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ + --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ + --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ + --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ + --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \ + --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ + --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ + --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ + --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ + --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \ + --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ + --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \ + --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ + --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ + --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ + --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ + --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ + --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ + --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ + --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ + --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \ + --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ + --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ + --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ + --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ + --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ + --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ + --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ + --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ + --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ + --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ + --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ + --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ + --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ + --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ + --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ + --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ + --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ + --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ + --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ + --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ + --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ + --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ + --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ + --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ + --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \ + --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ + --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ + --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ + --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ + --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \ + --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ + --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ + --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ + --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ + --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \ + --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ + --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ + --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ + --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ + --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \ + --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 + # via pydantic +pydantic-settings==2.14.0 \ + --hash=sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d \ + --hash=sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e + # via mcp +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via rich +pyjwt==2.12.1 \ + --hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ + --hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b + # via mcp +pynacl==1.6.2 \ + --hash=sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c \ + --hash=sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574 \ + --hash=sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4 \ + --hash=sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 \ + --hash=sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b \ + --hash=sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590 \ + --hash=sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444 \ + --hash=sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634 \ + --hash=sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87 \ + --hash=sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa \ + --hash=sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594 \ + --hash=sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0 \ + --hash=sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e \ + --hash=sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c \ + --hash=sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0 \ + --hash=sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c \ + --hash=sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577 \ + --hash=sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145 \ + --hash=sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88 \ + --hash=sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14 \ + --hash=sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6 \ + --hash=sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465 \ + --hash=sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0 \ + --hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \ + --hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9 + # via mcp-agent-mail +pyperclip==1.11.0 \ + --hash=sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6 \ + --hash=sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273 + # via fastmcp +python-decouple==3.8 \ + --hash=sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f \ + --hash=sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66 + # via mcp-agent-mail +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via + # fastmcp + # litellm + # pydantic-settings + # uvicorn +python-multipart==0.0.27 \ + --hash=sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645 \ + --hash=sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602 + # via mcp +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # huggingface-hub + # jsonschema-path + # mcp-agent-mail + # uvicorn +redis==7.4.0 \ + --hash=sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad \ + --hash=sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec + # via mcp-agent-mail +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2026.4.4 \ + --hash=sha256:011bb48bffc1b46553ac704c975b3348717f4e4aa7a67522b51906f99da1820c \ + --hash=sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f \ + --hash=sha256:0540e5b733618a2f84e9cb3e812c8afa82e151ca8e19cf6c4e95c5a65198236f \ + --hash=sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62 \ + --hash=sha256:0709f22a56798457ae317bcce42aacee33c680068a8f14097430d9f9ba364bee \ + --hash=sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883 \ + --hash=sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13 \ + --hash=sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99 \ + --hash=sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a \ + --hash=sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0 \ + --hash=sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566 \ + --hash=sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9 \ + --hash=sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76 \ + --hash=sha256:1b9a00b83f3a40e09859c78920571dcb83293c8004079653dd22ec14bbfa98c7 \ + --hash=sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4 \ + --hash=sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717 \ + --hash=sha256:2895506ebe32cc63eeed8f80e6eae453171cfccccab35b70dc3129abec35a5b8 \ + --hash=sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17 \ + --hash=sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351 \ + --hash=sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d \ + --hash=sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb \ + --hash=sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7 \ + --hash=sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8 \ + --hash=sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86 \ + --hash=sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada \ + --hash=sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81 \ + --hash=sha256:33bfda9684646d323414df7abe5692c61d297dbb0530b28ec66442e768813c59 \ + --hash=sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453 \ + --hash=sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141 \ + --hash=sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031 \ + --hash=sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74 \ + --hash=sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244 \ + --hash=sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87 \ + --hash=sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f \ + --hash=sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465 \ + --hash=sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983 \ + --hash=sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff \ + --hash=sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0 \ + --hash=sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55 \ + --hash=sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752 \ + --hash=sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73 \ + --hash=sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe \ + --hash=sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95 \ + --hash=sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8 \ + --hash=sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb \ + --hash=sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45 \ + --hash=sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943 \ + --hash=sha256:6780f008ee81381c737634e75c24e5a6569cc883c4f8e37a37917ee79efcafd9 \ + --hash=sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520 \ + --hash=sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8 \ + --hash=sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1 \ + --hash=sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3 \ + --hash=sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1 \ + --hash=sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb \ + --hash=sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6 \ + --hash=sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f \ + --hash=sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be \ + --hash=sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4 \ + --hash=sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951 \ + --hash=sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27 \ + --hash=sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d \ + --hash=sha256:8512fcdb43f1bf18582698a478b5ab73f9c1667a5b7548761329ef410cd0a760 \ + --hash=sha256:867bddc63109a0276f5a31999e4c8e0eb7bbbad7d6166e28d969a2c1afeb97f9 \ + --hash=sha256:88e9b048345c613f253bea4645b2fe7e579782b82cac99b1daad81e29cc2ed8e \ + --hash=sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7 \ + --hash=sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735 \ + --hash=sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81 \ + --hash=sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3 \ + --hash=sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9 \ + --hash=sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790 \ + --hash=sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043 \ + --hash=sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59 \ + --hash=sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a \ + --hash=sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4 \ + --hash=sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f \ + --hash=sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f \ + --hash=sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427 \ + --hash=sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae \ + --hash=sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa \ + --hash=sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d \ + --hash=sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0 \ + --hash=sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc \ + --hash=sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863 \ + --hash=sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6 \ + --hash=sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54 \ + --hash=sha256:be061028481186ba62a0f4c5f1cc1e3d5ab8bce70c89236ebe01023883bc903b \ + --hash=sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52 \ + --hash=sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07 \ + --hash=sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b \ + --hash=sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b \ + --hash=sha256:cf9b1b2e692d4877880388934ac746c99552ce6bf40792a767fd42c8c99f136d \ + --hash=sha256:d2228c02b368d69b724c36e96d3d1da721561fb9cc7faa373d7bf65e07d75cb5 \ + --hash=sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf \ + --hash=sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b \ + --hash=sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359 \ + --hash=sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87 \ + --hash=sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca \ + --hash=sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa \ + --hash=sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423 \ + --hash=sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4 \ + --hash=sha256:e355be718caf838aa089870259cf1776dc2a4aa980514af9d02c59544d9a8b22 \ + --hash=sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80 \ + --hash=sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f \ + --hash=sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17 \ + --hash=sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f \ + --hash=sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e \ + --hash=sha256:ee9627de8587c1a22201cb16d0296ab92b4df5cdcb5349f4e9744d61db7c7c98 \ + --hash=sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4 \ + --hash=sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d \ + --hash=sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b \ + --hash=sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c \ + --hash=sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83 \ + --hash=sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b \ + --hash=sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e + # via tiktoken +requests==2.33.1 \ + --hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \ + --hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a + # via tiktoken +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via + # cyclopts + # fastmcp + # mcp-agent-mail + # rich-rst + # typer +rich-rst==1.3.2 \ + --hash=sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4 \ + --hash=sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a + # via cyclopts +rpds-py==0.30.0 \ + --hash=sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f \ + --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ + --hash=sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3 \ + --hash=sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7 \ + --hash=sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65 \ + --hash=sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4 \ + --hash=sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169 \ + --hash=sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf \ + --hash=sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4 \ + --hash=sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2 \ + --hash=sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c \ + --hash=sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4 \ + --hash=sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3 \ + --hash=sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6 \ + --hash=sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7 \ + --hash=sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89 \ + --hash=sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85 \ + --hash=sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6 \ + --hash=sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa \ + --hash=sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb \ + --hash=sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6 \ + --hash=sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87 \ + --hash=sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856 \ + --hash=sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4 \ + --hash=sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f \ + --hash=sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53 \ + --hash=sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229 \ + --hash=sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad \ + --hash=sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23 \ + --hash=sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db \ + --hash=sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038 \ + --hash=sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27 \ + --hash=sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00 \ + --hash=sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18 \ + --hash=sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083 \ + --hash=sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c \ + --hash=sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738 \ + --hash=sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898 \ + --hash=sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e \ + --hash=sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7 \ + --hash=sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08 \ + --hash=sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6 \ + --hash=sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551 \ + --hash=sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e \ + --hash=sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288 \ + --hash=sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df \ + --hash=sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0 \ + --hash=sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2 \ + --hash=sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05 \ + --hash=sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0 \ + --hash=sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464 \ + --hash=sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5 \ + --hash=sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404 \ + --hash=sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7 \ + --hash=sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139 \ + --hash=sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394 \ + --hash=sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb \ + --hash=sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15 \ + --hash=sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff \ + --hash=sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed \ + --hash=sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6 \ + --hash=sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e \ + --hash=sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95 \ + --hash=sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d \ + --hash=sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950 \ + --hash=sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3 \ + --hash=sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5 \ + --hash=sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97 \ + --hash=sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e \ + --hash=sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e \ + --hash=sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b \ + --hash=sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd \ + --hash=sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad \ + --hash=sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8 \ + --hash=sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425 \ + --hash=sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221 \ + --hash=sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d \ + --hash=sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825 \ + --hash=sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51 \ + --hash=sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e \ + --hash=sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f \ + --hash=sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8 \ + --hash=sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f \ + --hash=sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d \ + --hash=sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07 \ + --hash=sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877 \ + --hash=sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31 \ + --hash=sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58 \ + --hash=sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94 \ + --hash=sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28 \ + --hash=sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000 \ + --hash=sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1 \ + --hash=sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1 \ + --hash=sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7 \ + --hash=sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7 \ + --hash=sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40 \ + --hash=sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d \ + --hash=sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0 \ + --hash=sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84 \ + --hash=sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f \ + --hash=sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a \ + --hash=sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7 \ + --hash=sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419 \ + --hash=sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8 \ + --hash=sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a \ + --hash=sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9 \ + --hash=sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be \ + --hash=sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed \ + --hash=sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a \ + --hash=sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d \ + --hash=sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324 \ + --hash=sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f \ + --hash=sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2 \ + --hash=sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f \ + --hash=sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5 + # via + # jsonschema + # referencing +ruff==0.15.12 \ + --hash=sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b \ + --hash=sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33 \ + --hash=sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0 \ + --hash=sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002 \ + --hash=sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339 \ + --hash=sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e \ + --hash=sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847 \ + --hash=sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f \ + --hash=sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6 \ + --hash=sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d \ + --hash=sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20 \ + --hash=sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd \ + --hash=sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c \ + --hash=sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5 \ + --hash=sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6 \ + --hash=sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c \ + --hash=sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5 \ + --hash=sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5 + # via mcp-agent-mail +secretstorage==3.5.0 \ + --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \ + --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be + # via keyring +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +smmap==5.0.3 \ + --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \ + --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f + # via gitdb +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via openai +sqlalchemy==2.0.49 \ + --hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \ + --hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \ + --hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \ + --hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \ + --hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \ + --hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \ + --hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \ + --hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \ + --hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \ + --hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \ + --hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \ + --hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \ + --hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \ + --hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \ + --hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \ + --hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \ + --hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \ + --hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \ + --hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \ + --hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \ + --hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \ + --hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \ + --hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \ + --hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \ + --hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \ + --hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \ + --hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \ + --hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \ + --hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \ + --hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \ + --hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \ + --hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \ + --hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \ + --hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \ + --hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \ + --hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \ + --hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \ + --hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \ + --hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \ + --hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \ + --hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \ + --hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \ + --hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \ + --hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \ + --hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \ + --hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \ + --hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \ + --hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \ + --hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \ + --hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \ + --hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \ + --hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \ + --hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \ + --hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \ + --hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \ + --hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \ + --hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \ + --hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \ + --hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \ + --hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \ + --hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \ + --hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \ + --hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982 + # via + # mcp-agent-mail + # sqlmodel +sqlmodel==0.0.38 \ + --hash=sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649 \ + --hash=sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b + # via mcp-agent-mail +sse-starlette==3.4.1 \ + --hash=sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0 \ + --hash=sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555 + # via mcp +starlette==1.0.0 \ + --hash=sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149 \ + --hash=sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b + # via + # fastapi + # mcp + # sse-starlette +structlog==25.5.0 \ + --hash=sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98 \ + --hash=sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f + # via mcp-agent-mail +tenacity==9.1.4 \ + --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ + --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a + # via mcp-agent-mail +tiktoken==0.12.0 \ + --hash=sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa \ + --hash=sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e \ + --hash=sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb \ + --hash=sha256:09eb4eae62ae7e4c62364d9ec3a57c62eea707ac9a2b2c5d6bd05de6724ea179 \ + --hash=sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25 \ + --hash=sha256:15d875454bbaa3728be39880ddd11a5a2a9e548c29418b41e8fd8a767172b5ec \ + --hash=sha256:20cf97135c9a50de0b157879c3c4accbb29116bcf001283d26e073ff3b345946 \ + --hash=sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff \ + --hash=sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b \ + --hash=sha256:2cff3688ba3c639ebe816f8d58ffbbb0aa7433e23e08ab1cade5d175fc973fb3 \ + --hash=sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5 \ + --hash=sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3 \ + --hash=sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970 \ + --hash=sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def \ + --hash=sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded \ + --hash=sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be \ + --hash=sha256:4c9614597ac94bb294544345ad8cf30dac2129c05e2db8dc53e082f355857af7 \ + --hash=sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd \ + --hash=sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a \ + --hash=sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0 \ + --hash=sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0 \ + --hash=sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b \ + --hash=sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37 \ + --hash=sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134 \ + --hash=sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb \ + --hash=sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a \ + --hash=sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1 \ + --hash=sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3 \ + --hash=sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892 \ + --hash=sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3 \ + --hash=sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b \ + --hash=sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a \ + --hash=sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3 \ + --hash=sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160 \ + --hash=sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967 \ + --hash=sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646 \ + --hash=sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931 \ + --hash=sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a \ + --hash=sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16 \ + --hash=sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697 \ + --hash=sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8 \ + --hash=sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa \ + --hash=sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365 \ + --hash=sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e \ + --hash=sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030 \ + --hash=sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830 \ + --hash=sha256:d51d75a5bffbf26f86554d28e78bfb921eae998edc2675650fd04c7e1f0cdc1e \ + --hash=sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16 \ + --hash=sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88 \ + --hash=sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f \ + --hash=sha256:df37684ace87d10895acb44b7f447d4700349b12197a526da0d4a4149fde074c \ + --hash=sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63 \ + --hash=sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad \ + --hash=sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc \ + --hash=sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71 \ + --hash=sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27 \ + --hash=sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd + # via + # litellm + # mcp-agent-mail +tinycss2==1.5.1 \ + --hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \ + --hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957 + # via mcp-agent-mail +tokenizers==0.22.2 \ + --hash=sha256:143b999bdc46d10febb15cbffb4207ddd1f410e2c755857b5a0797961bbdc113 \ + --hash=sha256:1a62ba2c5faa2dd175aaeed7b15abf18d20266189fb3406c5d0550dd34dd5f37 \ + --hash=sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e \ + --hash=sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 \ + --hash=sha256:1e50f8554d504f617d9e9d6e4c2c2884a12b388a97c5c77f0bc6cf4cd032feee \ + --hash=sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7 \ + --hash=sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd \ + --hash=sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4 \ + --hash=sha256:319f659ee992222f04e58f84cbf407cfa66a65fe3a8de44e8ad2bc53e7d99012 \ + --hash=sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67 \ + --hash=sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a \ + --hash=sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5 \ + --hash=sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917 \ + --hash=sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c \ + --hash=sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195 \ + --hash=sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4 \ + --hash=sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a \ + --hash=sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc \ + --hash=sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92 \ + --hash=sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5 \ + --hash=sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48 \ + --hash=sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b \ + --hash=sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c \ + --hash=sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5 + # via litellm +tqdm==4.67.3 \ + --hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \ + --hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + # via + # huggingface-hub + # openai +typer==0.23.1 \ + --hash=sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134 \ + --hash=sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e + # via + # huggingface-hub + # mcp-agent-mail +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # aiosignal + # anyio + # exceptiongroup + # fastapi + # huggingface-hub + # mcp + # openai + # py-key-value-shared + # pydantic + # pydantic-core + # referencing + # sqlalchemy + # sqlmodel + # starlette + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # fastapi + # mcp + # pydantic + # pydantic-settings +urllib3==2.6.3 \ + --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ + --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + # via requests +uvicorn==0.46.0 \ + --hash=sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048 \ + --hash=sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d + # via + # mcp + # mcp-agent-mail +uvloop==0.22.1 \ + --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \ + --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ + --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \ + --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \ + --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \ + --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \ + --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ + --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ + --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \ + --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ + --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ + --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ + --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \ + --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \ + --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \ + --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \ + --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ + --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \ + --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \ + --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \ + --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \ + --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \ + --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ + --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \ + --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \ + --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ + --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ + --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ + --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \ + --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \ + --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \ + --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \ + --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ + --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \ + --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \ + --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ + --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \ + --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ + --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ + --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ + --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ + --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ + --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \ + --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \ + --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \ + --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \ + --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \ + --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \ + --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42 + # via uvicorn +watchfiles==1.1.1 \ + --hash=sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c \ + --hash=sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43 \ + --hash=sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510 \ + --hash=sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0 \ + --hash=sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2 \ + --hash=sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b \ + --hash=sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18 \ + --hash=sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219 \ + --hash=sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3 \ + --hash=sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4 \ + --hash=sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803 \ + --hash=sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94 \ + --hash=sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6 \ + --hash=sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce \ + --hash=sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099 \ + --hash=sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae \ + --hash=sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4 \ + --hash=sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43 \ + --hash=sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd \ + --hash=sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10 \ + --hash=sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374 \ + --hash=sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051 \ + --hash=sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d \ + --hash=sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34 \ + --hash=sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49 \ + --hash=sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7 \ + --hash=sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844 \ + --hash=sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77 \ + --hash=sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b \ + --hash=sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741 \ + --hash=sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e \ + --hash=sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33 \ + --hash=sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42 \ + --hash=sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab \ + --hash=sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc \ + --hash=sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5 \ + --hash=sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da \ + --hash=sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e \ + --hash=sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05 \ + --hash=sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a \ + --hash=sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d \ + --hash=sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701 \ + --hash=sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863 \ + --hash=sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2 \ + --hash=sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101 \ + --hash=sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02 \ + --hash=sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b \ + --hash=sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6 \ + --hash=sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb \ + --hash=sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620 \ + --hash=sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957 \ + --hash=sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6 \ + --hash=sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d \ + --hash=sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956 \ + --hash=sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef \ + --hash=sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261 \ + --hash=sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02 \ + --hash=sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af \ + --hash=sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9 \ + --hash=sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21 \ + --hash=sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336 \ + --hash=sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d \ + --hash=sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c \ + --hash=sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31 \ + --hash=sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81 \ + --hash=sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9 \ + --hash=sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff \ + --hash=sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2 \ + --hash=sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e \ + --hash=sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc \ + --hash=sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404 \ + --hash=sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01 \ + --hash=sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18 \ + --hash=sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3 \ + --hash=sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606 \ + --hash=sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04 \ + --hash=sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3 \ + --hash=sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14 \ + --hash=sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c \ + --hash=sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82 \ + --hash=sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610 \ + --hash=sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0 \ + --hash=sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150 \ + --hash=sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5 \ + --hash=sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c \ + --hash=sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a \ + --hash=sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b \ + --hash=sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d \ + --hash=sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70 \ + --hash=sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70 \ + --hash=sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f \ + --hash=sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24 \ + --hash=sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e \ + --hash=sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be \ + --hash=sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5 \ + --hash=sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e \ + --hash=sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f \ + --hash=sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88 \ + --hash=sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb \ + --hash=sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849 \ + --hash=sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d \ + --hash=sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c \ + --hash=sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44 \ + --hash=sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac \ + --hash=sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428 \ + --hash=sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b \ + --hash=sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5 \ + --hash=sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa \ + --hash=sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf + # via uvicorn +webencodings==0.5.1 \ + --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ + --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 + # via + # bleach + # tinycss2 +websockets==16.0 \ + --hash=sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c \ + --hash=sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a \ + --hash=sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe \ + --hash=sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e \ + --hash=sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec \ + --hash=sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1 \ + --hash=sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64 \ + --hash=sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3 \ + --hash=sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8 \ + --hash=sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206 \ + --hash=sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3 \ + --hash=sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156 \ + --hash=sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d \ + --hash=sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9 \ + --hash=sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad \ + --hash=sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2 \ + --hash=sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03 \ + --hash=sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8 \ + --hash=sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230 \ + --hash=sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8 \ + --hash=sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea \ + --hash=sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641 \ + --hash=sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957 \ + --hash=sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6 \ + --hash=sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6 \ + --hash=sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5 \ + --hash=sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f \ + --hash=sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00 \ + --hash=sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e \ + --hash=sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b \ + --hash=sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72 \ + --hash=sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39 \ + --hash=sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9 \ + --hash=sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79 \ + --hash=sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0 \ + --hash=sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac \ + --hash=sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35 \ + --hash=sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0 \ + --hash=sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5 \ + --hash=sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c \ + --hash=sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8 \ + --hash=sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1 \ + --hash=sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244 \ + --hash=sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3 \ + --hash=sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767 \ + --hash=sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a \ + --hash=sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d \ + --hash=sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd \ + --hash=sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e \ + --hash=sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944 \ + --hash=sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82 \ + --hash=sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d \ + --hash=sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4 \ + --hash=sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5 \ + --hash=sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904 \ + --hash=sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde \ + --hash=sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f \ + --hash=sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c \ + --hash=sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89 \ + --hash=sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da \ + --hash=sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4 + # via + # fastmcp + # uvicorn +yarl==1.23.0 \ + --hash=sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc \ + --hash=sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4 \ + --hash=sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85 \ + --hash=sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993 \ + --hash=sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222 \ + --hash=sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de \ + --hash=sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25 \ + --hash=sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e \ + --hash=sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2 \ + --hash=sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e \ + --hash=sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860 \ + --hash=sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957 \ + --hash=sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760 \ + --hash=sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52 \ + --hash=sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788 \ + --hash=sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912 \ + --hash=sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719 \ + --hash=sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035 \ + --hash=sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220 \ + --hash=sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412 \ + --hash=sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05 \ + --hash=sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41 \ + --hash=sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4 \ + --hash=sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4 \ + --hash=sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd \ + --hash=sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748 \ + --hash=sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a \ + --hash=sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4 \ + --hash=sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34 \ + --hash=sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069 \ + --hash=sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25 \ + --hash=sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2 \ + --hash=sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb \ + --hash=sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f \ + --hash=sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5 \ + --hash=sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8 \ + --hash=sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c \ + --hash=sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512 \ + --hash=sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6 \ + --hash=sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5 \ + --hash=sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9 \ + --hash=sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072 \ + --hash=sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5 \ + --hash=sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277 \ + --hash=sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a \ + --hash=sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6 \ + --hash=sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae \ + --hash=sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26 \ + --hash=sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2 \ + --hash=sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4 \ + --hash=sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70 \ + --hash=sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723 \ + --hash=sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c \ + --hash=sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9 \ + --hash=sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5 \ + --hash=sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e \ + --hash=sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c \ + --hash=sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4 \ + --hash=sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0 \ + --hash=sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2 \ + --hash=sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b \ + --hash=sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7 \ + --hash=sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750 \ + --hash=sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2 \ + --hash=sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474 \ + --hash=sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716 \ + --hash=sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7 \ + --hash=sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123 \ + --hash=sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007 \ + --hash=sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595 \ + --hash=sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe \ + --hash=sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea \ + --hash=sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598 \ + --hash=sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679 \ + --hash=sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8 \ + --hash=sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83 \ + --hash=sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6 \ + --hash=sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f \ + --hash=sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94 \ + --hash=sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51 \ + --hash=sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120 \ + --hash=sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039 \ + --hash=sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1 \ + --hash=sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05 \ + --hash=sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb \ + --hash=sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144 \ + --hash=sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa \ + --hash=sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a \ + --hash=sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99 \ + --hash=sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928 \ + --hash=sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d \ + --hash=sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3 \ + --hash=sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434 \ + --hash=sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86 \ + --hash=sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46 \ + --hash=sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319 \ + --hash=sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67 \ + --hash=sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c \ + --hash=sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169 \ + --hash=sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c \ + --hash=sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59 \ + --hash=sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107 \ + --hash=sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4 \ + --hash=sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a \ + --hash=sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb \ + --hash=sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f \ + --hash=sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769 \ + --hash=sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432 \ + --hash=sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090 \ + --hash=sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764 \ + --hash=sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d \ + --hash=sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4 \ + --hash=sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b \ + --hash=sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d \ + --hash=sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543 \ + --hash=sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24 \ + --hash=sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5 \ + --hash=sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b \ + --hash=sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d \ + --hash=sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b \ + --hash=sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6 \ + --hash=sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735 \ + --hash=sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e \ + --hash=sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28 \ + --hash=sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3 \ + --hash=sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401 \ + --hash=sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6 \ + --hash=sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d + # via aiohttp +zipp==3.23.1 \ + --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ + --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 + # via importlib-metadata diff --git a/.github/scripts/install-bd-archive.sh b/.github/scripts/install-bd-archive.sh new file mode 100755 index 0000000000..5025a26362 --- /dev/null +++ b/.github/scripts/install-bd-archive.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: install-bd-archive.sh VERSION [--cache] + +Downloads a bd release tarball, verifies its pinned SHA-256, and installs bd. +Use --cache on self-hosted runners to install under RUNNER_TOOL_CACHE/HOME +and add that bin directory to GITHUB_PATH. +USAGE +} + +version="${1:-}" +if [[ -z "$version" ]]; then + usage + exit 2 +fi +shift || true + +use_cache=false +while (($#)); do + case "$1" in + --cache) use_cache=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +version_no_v="${version#v}" +platform_tuple="${os}_${arch}" +expected_sha="" +case "${version}:${platform_tuple}" in + v1.0.3:linux_amd64) expected_sha="1ef5dca818d7e81574df9e9f9fc2a16ab711da09b0fa7b822ae162d9a81c8912" ;; + v1.0.3:linux_arm64) expected_sha="243a9c75012e794888fcafb957e7624b8fefdfef033d14cd03ebc9831c3bc12f" ;; + v1.0.3:darwin_amd64) expected_sha="6bd75ac056288a5e8bbb203750e95af5a441d5ad1d20ca5511e60cd6c813e54b" ;; + v1.0.3:darwin_arm64) expected_sha="fe6e4465751f46d9f3a670c3cf656714a171e44c8bc318fe19054f513b8306ed" ;; + v1.0.0:linux_amd64) expected_sha="7057db1e92428fcf5c08d5dc6b07ead57e588b262cba78b9a26893d55bd29fdb" ;; + v1.0.0:linux_arm64) expected_sha="9bb30413041e50dac945a0f8aa64011e4b345ebfd0a3f9b5fccd646c6dca61a7" ;; + v1.0.0:darwin_amd64) expected_sha="9a3d5bca07c9ce809c205ef9a20f73de6503ab3714655239ce306d862ceeb0d0" ;; + v1.0.0:darwin_arm64) expected_sha="b8763b428e6b68550eb2b2505483797794b49ae497a2e265ed3c60f0f0a0bcd2" ;; +esac + +github_release_asset_sha() { + local owner_repo="$1" + local tag="$2" + local asset="$3" + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to resolve GitHub release asset checksums" >&2 + exit 1 + fi + local auth_header=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl -fsSL "${auth_header[@]}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${owner_repo}/releases/tags/${tag}" \ + | jq -r --arg asset "$asset" '.assets[] | select(.name == $asset) | .digest // empty' \ + | sed 's/^sha256://' +} + +archive="beads_${version_no_v}_${platform_tuple}.tar.gz" +if [[ -z "$expected_sha" ]]; then + expected_sha="$(github_release_asset_sha "gastownhall/beads" "$version" "$archive")" + if [[ -z "$expected_sha" ]]; then + echo "No bd checksum found for ${version}/${platform_tuple}" >&2 + exit 1 + fi +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +install_binary() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + install -m 0755 "$src" "$dst" +} + +install_binary_with_sudo_fallback() { + local src="$1" + local dst="$2" + local dst_dir + dst_dir="$(dirname "$dst")" + mkdir -p "$dst_dir" + if [[ -w "$dst_dir" ]]; then + install_binary "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" "$dst" + else + echo "Cannot write $dst and sudo is unavailable" >&2 + exit 1 + fi +} + +if $use_cache; then + cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" + bin_dir="${cache_root}/gascity-bd/${version}/${platform_tuple}/bin" +else + bin_dir="${BD_INSTALL_BIN_DIR:-/usr/local/bin}" +fi + +target="${bin_dir}/bd" +if [[ -x "$target" ]]; then + echo "Reusing cached bd ${version} at ${target}" +else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL -o "${tmp}/${archive}" \ + "https://github.com/gastownhall/beads/releases/download/${version}/${archive}" + actual_sha="$(sha256_file "${tmp}/${archive}")" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "bd checksum mismatch for ${version}/${platform_tuple}" >&2 + echo "expected: $expected_sha" >&2 + echo "actual: $actual_sha" >&2 + exit 1 + fi + tar -xzf "${tmp}/${archive}" -C "$tmp" + src="${tmp}/bd" + if [[ ! -x "$src" ]]; then + src="${tmp}/beads_${version_no_v}_${platform_tuple}/bd" + fi + if $use_cache; then + install_binary "$src" "$target" + else + install_binary_with_sudo_fallback "$src" "$target" + fi +fi + +if $use_cache && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin_dir" >> "$GITHUB_PATH" +fi + +"$target" version diff --git a/.github/scripts/install-br-archive.sh b/.github/scripts/install-br-archive.sh new file mode 100755 index 0000000000..772b242d3e --- /dev/null +++ b/.github/scripts/install-br-archive.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: install-br-archive.sh VERSION [--cache] + +Downloads a br release tarball, verifies its pinned SHA-256, and installs br. +Use --cache on self-hosted runners to install under RUNNER_TOOL_CACHE/HOME +and add that bin directory to GITHUB_PATH. +USAGE +} + +version="${1:-}" +if [[ -z "$version" ]]; then + usage + exit 2 +fi +shift || true + +use_cache=false +while (($#)); do + case "$1" in + --cache) use_cache=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +version_no_v="${version#v}" +tag="v${version_no_v}" +platform_tuple="${os}_${arch}" +expected_sha="" +case "${tag}:${platform_tuple}" in + v0.1.20:linux_amd64) expected_sha="aefc2ef6b16c7b275f6890636c110540c7bc081e203a1e8a706a376207d1f9dd" ;; + v0.1.20:linux_arm64) expected_sha="20899316274b7ac40de477f3318a3d6391f7885c6cd1bec7ba10e828360207fb" ;; + v0.1.20:darwin_amd64) expected_sha="b53f109e3f288d23d2918bc9dcf7fa9997351d79bfab6be54ca18bc41d504d58" ;; + v0.1.20:darwin_arm64) expected_sha="705a13ab7c972bff97440656633210ca2c88cd49c1094a6007a98983d73fbb1d" ;; +esac + +github_release_asset_sha() { + local owner_repo="$1" + local release_tag="$2" + local asset="$3" + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to resolve GitHub release asset checksums" >&2 + exit 1 + fi + local auth_header=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl -fsSL "${auth_header[@]}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${owner_repo}/releases/tags/${release_tag}" \ + | jq -r --arg asset "$asset" '.assets[] | select(.name == $asset) | .digest // empty' \ + | sed 's/^sha256://' +} + +archive="br-v${version_no_v}-${platform_tuple}.tar.gz" +if [[ -z "$expected_sha" ]]; then + expected_sha="$(github_release_asset_sha "Dicklesworthstone/beads_rust" "$tag" "$archive")" + if [[ -z "$expected_sha" ]]; then + echo "No br checksum found for ${tag}/${platform_tuple}" >&2 + exit 1 + fi +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +install_binary() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + install -m 0755 "$src" "$dst" +} + +install_binary_with_sudo_fallback() { + local src="$1" + local dst="$2" + local dst_dir + dst_dir="$(dirname "$dst")" + mkdir -p "$dst_dir" + if [[ -w "$dst_dir" ]]; then + install_binary "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" "$dst" + else + echo "Cannot write $dst and sudo is unavailable" >&2 + exit 1 + fi +} + +if $use_cache; then + cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" + bin_dir="${cache_root}/gascity-br/${tag}/${platform_tuple}/bin" +else + bin_dir="${BR_INSTALL_BIN_DIR:-/usr/local/bin}" +fi + +target="${bin_dir}/br" +if [[ -x "$target" ]]; then + echo "Reusing cached br ${tag} at ${target}" +else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL -o "${tmp}/${archive}" \ + "https://github.com/Dicklesworthstone/beads_rust/releases/download/${tag}/${archive}" + actual_sha="$(sha256_file "${tmp}/${archive}")" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "br checksum mismatch for ${tag}/${platform_tuple}" >&2 + echo "expected: $expected_sha" >&2 + echo "actual: $actual_sha" >&2 + exit 1 + fi + tar -xzf "${tmp}/${archive}" -C "$tmp" + src="${tmp}/br" + if [[ ! -x "$src" ]]; then + src="$(find "$tmp" -type f -name br -perm -111 | head -n 1)" + fi + if [[ -z "${src:-}" || ! -x "$src" ]]; then + echo "br binary not found in ${archive}" >&2 + exit 1 + fi + if $use_cache; then + install_binary "$src" "$target" + else + install_binary_with_sudo_fallback "$src" "$target" + fi +fi + +if $use_cache && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin_dir" >> "$GITHUB_PATH" +fi + +"$target" --version diff --git a/.github/scripts/install-claude-native.sh b/.github/scripts/install-claude-native.sh new file mode 100755 index 0000000000..5b9a6c8498 --- /dev/null +++ b/.github/scripts/install-claude-native.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: install-claude-native.sh VERSION [--cache] + +Installs the native Claude Code binary after verifying its pinned SHA-256. +Use --cache on self-hosted runners to install under RUNNER_TOOL_CACHE/HOME +and add that bin directory to GITHUB_PATH. +USAGE +} + +version="${1:-}" +if [[ -z "$version" ]]; then + usage + exit 2 +fi +shift || true + +use_cache=false +while (($#)); do + case "$1" in + --cache) use_cache=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=x64 ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +platform="${os}-${arch}" +expected_sha="" +case "${version}:${platform}" in + 2.1.123:darwin-arm64) expected_sha="44597dff0f1c11e37c1954d4ac3965909be376e5961b558345723357253bcc90" ;; + 2.1.123:darwin-x64) expected_sha="ddea227d4c2b2602d650d2c5d5c812f7680701a1504bcaff81e42c165c583ef9" ;; + 2.1.123:linux-arm64) expected_sha="825c526035d1d75ff0bc1eebf18c887f98d07ea49ea80bd312ff416fe61a39b3" ;; + 2.1.123:linux-x64) expected_sha="5a78139b679a86a88a0ac5476c706a64c3105bf6a6d435ba10f3aa3fb635bdb2" ;; +esac + +if [[ -z "$expected_sha" ]]; then + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to resolve Claude Code checksums for ${version}/${platform}" >&2 + exit 1 + fi + manifest_url="https://downloads.claude.ai/claude-code-releases/${version}/manifest.json" + expected_sha="$(curl -fsSL "$manifest_url" | jq -r --arg platform "$platform" '.platforms[$platform].checksum // empty')" + if [[ -z "$expected_sha" ]]; then + echo "No Claude Code checksum found for ${version}/${platform}" >&2 + exit 1 + fi +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +install_binary() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + install -m 0755 "$src" "$dst" +} + +install_binary_with_sudo_fallback() { + local src="$1" + local dst="$2" + if [[ -w "$(dirname "$dst")" ]]; then + install_binary "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" "$dst" + else + echo "Cannot write $dst and sudo is unavailable" >&2 + exit 1 + fi +} + +if $use_cache; then + cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" + bin_dir="${cache_root}/gascity-claude/${version}/${platform}/bin" +else + bin_dir="${CLAUDE_INSTALL_BIN_DIR:-/usr/local/bin}" +fi + +target="${bin_dir}/claude" +if [[ -x "$target" ]]; then + echo "Reusing cached Claude Code ${version} at ${target}" +else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + binary="${tmp}/claude" + url="https://downloads.claude.ai/claude-code-releases/${version}/${platform}/claude" + curl -fsSL -o "$binary" "$url" + actual_sha="$(sha256_file "$binary")" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Claude Code checksum mismatch for ${version}/${platform}" >&2 + echo "expected: $expected_sha" >&2 + echo "actual: $actual_sha" >&2 + exit 1 + fi + + if $use_cache; then + install_binary "$binary" "$target" + else + install_binary_with_sudo_fallback "$binary" "$target" + fi +fi + +if $use_cache && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin_dir" >> "$GITHUB_PATH" +fi + +"$target" --version diff --git a/.github/scripts/install-dolt-archive.sh b/.github/scripts/install-dolt-archive.sh new file mode 100755 index 0000000000..305f8ea314 --- /dev/null +++ b/.github/scripts/install-dolt-archive.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: install-dolt-archive.sh VERSION [--cache] + +Downloads a Dolt release tarball, verifies its pinned SHA-256, and installs +dolt. Use --cache on self-hosted runners to install under RUNNER_TOOL_CACHE/HOME +and add that bin directory to GITHUB_PATH. +USAGE +} + +version="${1:-}" +if [[ -z "$version" ]]; then + usage + exit 2 +fi +shift || true + +use_cache=false +while (($#)); do + case "$1" in + --cache) use_cache=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +platform_tuple="${os}-${arch}" +expected_sha="" +case "${version}:${platform_tuple}" in + 1.86.6:linux-amd64) expected_sha="1f78bdc39edf4d4e731a53131b17d455fa0d1e2e872c0f5f8daaa44d07753a8b" ;; + 1.86.6:linux-arm64) expected_sha="1caa0aedc562ca63cfc24ee4b91287e5be7446aaeddc294f199f7515e5cfdc1f" ;; + 1.86.6:darwin-amd64) expected_sha="7ac44944c068c0bbb31ef91b032826f2e1aa0d5f5e4847e6c69bd31ea6d88dc5" ;; + 1.86.6:darwin-arm64) expected_sha="d27bb39ec5b86e425d06844e7f7e5495758adc41719a4fba99b842b89c8d68fc" ;; + 1.86.1:linux-amd64) expected_sha="37b4bd73b4c44fd1779115b35ab3e046a332ed99e563cf562882eb4fdb8bde86" ;; + 1.86.1:linux-arm64) expected_sha="5dc46c9db3cb2e8a3b5154ef972e502671520efdcdcdce0df644b67bab27d958" ;; + 1.86.1:darwin-amd64) expected_sha="563c9bae968e9d3dfa935eff36b06e91c16eed8b11d6a9c0d08e2b4629cdc458" ;; + 1.86.1:darwin-arm64) expected_sha="2e92b6aed60b2b02c4defc97fb48ca8b1c79d6994c645f690944c4c39a00d3a5" ;; + 1.85.0:linux-amd64) expected_sha="58e1462ddfbd59b2ccd707a12f70aa7597f1590745b546502049a03cb52e1aa2" ;; + 1.85.0:linux-arm64) expected_sha="f668c8e0d0276f684741ee66cd0dd18f2be8bf628a92982e8c7f20d1aef7b390" ;; + 1.85.0:darwin-amd64) expected_sha="7514c125cfb40f8a377e697a88535e21aa2e354f4bb62b7cabd6994604cb4af2" ;; + 1.85.0:darwin-arm64) expected_sha="67c5848ca13290722e8f49ec32cfa01140c4c64a3f55da3a5454aecbb59fc90d" ;; +esac + +github_release_asset_sha() { + local owner_repo="$1" + local tag="$2" + local asset="$3" + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to resolve GitHub release asset checksums" >&2 + exit 1 + fi + local auth_header=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl -fsSL "${auth_header[@]}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${owner_repo}/releases/tags/${tag}" \ + | jq -r --arg asset "$asset" '.assets[] | select(.name == $asset) | .digest // empty' \ + | sed 's/^sha256://' +} + +archive="dolt-${platform_tuple}.tar.gz" +if [[ -z "$expected_sha" ]]; then + expected_sha="$(github_release_asset_sha "dolthub/dolt" "v${version}" "$archive")" + if [[ -z "$expected_sha" ]]; then + echo "No Dolt checksum found for ${version}/${platform_tuple}" >&2 + exit 1 + fi +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +install_binary() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + install -m 0755 "$src" "$dst" +} + +install_binary_with_sudo_fallback() { + local src="$1" + local dst="$2" + local dst_dir + dst_dir="$(dirname "$dst")" + mkdir -p "$dst_dir" + if [[ -w "$dst_dir" ]]; then + install_binary "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" "$dst" + else + echo "Cannot write $dst and sudo is unavailable" >&2 + exit 1 + fi +} + +if $use_cache; then + cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" + bin_dir="${cache_root}/gascity-dolt/${version}/${platform_tuple}/bin" +else + bin_dir="${DOLT_INSTALL_BIN_DIR:-/usr/local/bin}" +fi + +target="${bin_dir}/dolt" +if [[ -x "$target" ]]; then + echo "Reusing cached Dolt ${version} at ${target}" +else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL -o "${tmp}/${archive}" \ + "https://github.com/dolthub/dolt/releases/download/v${version}/${archive}" + actual_sha="$(sha256_file "${tmp}/${archive}")" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Dolt checksum mismatch for ${version}/${platform_tuple}" >&2 + echo "expected: $expected_sha" >&2 + echo "actual: $actual_sha" >&2 + exit 1 + fi + tar -xzf "${tmp}/${archive}" -C "$tmp" + src="${tmp}/dolt-${platform_tuple}/bin/dolt" + if $use_cache; then + install_binary "$src" "$target" + else + install_binary_with_sudo_fallback "$src" "$target" + fi +fi + +if $use_cache && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin_dir" >> "$GITHUB_PATH" +fi + +"$target" version diff --git a/.github/scripts/install-trivy-archive.sh b/.github/scripts/install-trivy-archive.sh new file mode 100755 index 0000000000..3c8491b6c9 --- /dev/null +++ b/.github/scripts/install-trivy-archive.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: install-trivy-archive.sh VERSION [--cache] + +Downloads a Trivy release tarball, verifies its pinned SHA-256, and installs +trivy. Use --cache on self-hosted runners to install under RUNNER_TOOL_CACHE/HOME +and add that bin directory to GITHUB_PATH. +USAGE +} + +version="${1:-}" +if [[ -z "$version" ]]; then + usage + exit 2 +fi +shift || true + +use_cache=false +while (($#)); do + case "$1" in + --cache) use_cache=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +case "$(uname -s)" in + Darwin) os_asset=macOS ;; + Linux) os_asset=Linux ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch_asset=ARM64 ;; + x86_64|amd64) arch_asset=64bit ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +version_no_v="${version#v}" +tag="v${version_no_v}" +asset_platform="${os_asset}-${arch_asset}" +expected_sha="" +case "${tag}:${asset_platform}" in + v0.70.0:Linux-64bit) expected_sha="8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9" ;; + v0.70.0:Linux-ARM64) expected_sha="2f6bb988b553a1bbac6bdd1ce890f5e412439564e17522b88a4541b4f364fc8d" ;; + v0.70.0:macOS-64bit) expected_sha="52d531452b19e7593da29366007d02a810e1e0080d02f9cf6a1afb46c35aaa93" ;; + v0.70.0:macOS-ARM64) expected_sha="68e543c51dcc96e1c344053a4fde9660cf602c25565d9f09dc17dd41e13b838a" ;; +esac + +github_release_asset_sha() { + local owner_repo="$1" + local release_tag="$2" + local asset="$3" + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to resolve GitHub release asset checksums" >&2 + exit 1 + fi + local auth_header=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl -fsSL "${auth_header[@]}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${owner_repo}/releases/tags/${release_tag}" \ + | jq -r --arg asset "$asset" '.assets[] | select(.name == $asset) | .digest // empty' \ + | sed 's/^sha256://' +} + +archive="trivy_${version_no_v}_${asset_platform}.tar.gz" +if [[ -z "$expected_sha" ]]; then + expected_sha="$(github_release_asset_sha "aquasecurity/trivy" "$tag" "$archive")" + if [[ -z "$expected_sha" ]]; then + echo "No Trivy checksum found for ${tag}/${asset_platform}" >&2 + exit 1 + fi +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +install_binary() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + install -m 0755 "$src" "$dst" +} + +install_binary_with_sudo_fallback() { + local src="$1" + local dst="$2" + local dst_dir + dst_dir="$(dirname "$dst")" + mkdir -p "$dst_dir" + if [[ -w "$dst_dir" ]]; then + install_binary "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" "$dst" + else + echo "Cannot write $dst and sudo is unavailable" >&2 + exit 1 + fi +} + +if $use_cache; then + cache_root="${RUNNER_TOOL_CACHE:-$HOME/.local}" + bin_dir="${cache_root}/gascity-trivy/${tag}/${asset_platform}/bin" +else + bin_dir="${TRIVY_INSTALL_BIN_DIR:-/usr/local/bin}" +fi + +target="${bin_dir}/trivy" +if [[ -x "$target" ]]; then + echo "Reusing cached Trivy ${tag} at ${target}" +else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL -o "${tmp}/${archive}" \ + "https://github.com/aquasecurity/trivy/releases/download/${tag}/${archive}" + actual_sha="$(sha256_file "${tmp}/${archive}")" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Trivy checksum mismatch for ${tag}/${asset_platform}" >&2 + echo "expected: $expected_sha" >&2 + echo "actual: $actual_sha" >&2 + exit 1 + fi + tar -xzf "${tmp}/${archive}" -C "$tmp" trivy + install_target="${tmp}/trivy" + if [[ ! -x "$install_target" ]]; then + echo "trivy binary not found in ${archive}" >&2 + exit 1 + fi + if $use_cache; then + install_binary "$install_target" "$target" + else + install_binary_with_sudo_fallback "$install_target" "$target" + fi +fi + +if $use_cache && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin_dir" >> "$GITHUB_PATH" +fi + +"$target" --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f212193348..a306cceadc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,55 @@ name: CI on: workflow_call: + inputs: + force_blacksmith: + description: Force all jobs in the reusable CI graph onto Blacksmith runners. + required: false + type: boolean + default: false push: branches: [main] pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review permissions: contents: read +concurrency: + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + runner-policy: + name: Runner policy + runs-on: ${{ inputs.force_blacksmith && 'blacksmith-2vcpu-ubuntu-2404' || (github.event_name == 'pull_request' && contains(fromJSON('["julianknutsen","csells","sjarmak","quad341"]'), github.event.pull_request.user.login) && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest') }} + outputs: + use_blacksmith: ${{ steps.policy.outputs.use_blacksmith }} + reason: ${{ steps.policy.outputs.reason }} + runner_2vcpu: ${{ steps.policy.outputs.runner_2vcpu }} + runner_8vcpu: ${{ steps.policy.outputs.runner_8vcpu }} + runner_16vcpu: ${{ steps.policy.outputs.runner_16vcpu }} + runner_32vcpu: ${{ steps.policy.outputs.runner_32vcpu }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Select runner backend + id: policy + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + FORCE_BLACKSMITH: ${{ inputs.force_blacksmith }} + run: | + python3 .github/workflows/scripts/runner_policy.py + # Detect which paths changed to gate conditional jobs. changes: name: Detect changes - runs-on: ubuntu-latest + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} outputs: mail: ${{ steps.filter.outputs.mail }} docker: ${{ steps.filter.outputs.docker }} @@ -25,7 +62,7 @@ jobs: cmd_gc_process: ${{ steps.filter.outputs.cmd_gc_process }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 id: filter with: filters: | @@ -80,92 +117,177 @@ jobs: - 'internal/**' - 'examples/gastown/packs/**' - # Always runs: lint, fmt, vet, unit tests, docs, acceptance, coverage. - check: - name: Check - runs-on: ubuntu-latest + preflight-smoke: + name: Preflight / lint and smoke + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: - # Pinned dependency versions — keep in sync with deps.env. - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" - # Make TestGeneratedClientInSync fatal on missing oapi-codegen so the - # spec→client drift check can never silently skip in CI. - GC_REQUIRE_OAPI_CODEGEN: "1" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 - with: - go-version: "1.25.8" - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: ./.github/actions/setup-gascity-ubuntu with: - node-version: "22" - - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y tmux jq - - - name: Install dolt v${{ env.DOLT_VERSION }} - run: | - curl -fsSL https://github.com/dolthub/dolt/releases/download/v${{ env.DOLT_VERSION }}/install.sh | sudo bash - dolt version - - - name: Install released bd v${{ env.BD_VERSION }} - run: | - archive="beads_${BD_VERSION#v}_linux_amd64.tar.gz" - mkdir -p "$RUNNER_TEMP/beads" - curl -fsSL -o "$RUNNER_TEMP/$archive" \ - "https://github.com/gastownhall/beads/releases/download/${BD_VERSION}/${archive}" - tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP/beads" bd - sudo install -m 0755 "$RUNNER_TEMP/beads/bd" /usr/local/bin/bd - bd version - - - name: Install Claude CLI - run: npm install -g @anthropic-ai/claude-code - + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "false" - name: Install tools run: make install-tools - - name: Lint run: make lint - - name: Format run: make fmt-check - - name: Vet run: make vet + - name: Docs + run: make check-docs + - name: Smoke unit tests + run: make test + preflight-unit-cover: + name: Preflight / unit cover + needs: + - runner-policy + - preflight-smoke + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} + env: + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-gascity-ubuntu + with: + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "false" + - name: Install tools + run: make install-tools - name: Test run: make test-cover + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true - - name: Docs - run: make check-docs - + preflight-acceptance: + name: Preflight / acceptance A + needs: + - runner-policy + - preflight-smoke + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} + env: + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-gascity-ubuntu + with: + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "true" - name: Acceptance tests (Tier A) run: make test-acceptance + preflight-generated: + name: Preflight / generated artifacts + needs: + - runner-policy + - preflight-smoke + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} + env: + # Make TestGeneratedClientInSync fatal on missing oapi-codegen so the + # spec->client drift check can never silently skip in CI. + GC_REQUIRE_OAPI_CODEGEN: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" - name: Dashboard bundle drift check run: make dashboard-ci - - name: OpenAPI spec + client drift check run: make spec-ci - - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + # Historical fan-in name. During the Blacksmith proof, branch protection can + # move to `CI / required`; this job keeps the old name meaningful. + check: + name: Check + needs: + - runner-policy + - preflight-smoke + - preflight-unit-cover + - preflight-acceptance + - preflight-generated + if: ${{ always() }} + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - name: Summarize preflight result + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + failures = { + job: meta.get("result", "unknown") + for job, meta in sorted(needs.items()) + if meta.get("result") != "success" + } + summary_path = os.environ["GITHUB_STEP_SUMMARY"] + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("## CI Preflight\n\n") + handle.write("| Job | Result |\n| --- | --- |\n") + for job, meta in sorted(needs.items()): + handle.write(f"| {job} | {meta.get('result', 'unknown')} |\n") + if failures: + for job, result in failures.items(): + print(f"{job}: {result}", file=sys.stderr) + sys.exit(1) + PY + + release-config: + name: Release config + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check GoReleaser configuration + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: - files: coverage.txt - token: ${{ secrets.CODECOV_TOKEN }} - verbose: true + version: "~> v2" + args: check cmd-gc-process: - name: cmd/gc process suite - needs: changes + name: cmd/gc process / shards ${{ matrix.shard_group }} + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.cmd_gc_process == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - shard_group: 1-2 of 6 + shards: 1 2 + - shard_group: 3-4 of 6 + shards: 3 4 + - shard_group: 5-6 of 6 + shards: 5 6 env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-gascity-ubuntu @@ -176,33 +298,83 @@ jobs: - name: Install tools run: make install-tools - name: Run cmd/gc process suite - run: make test-cmd-gc-process + run: | + for shard in ${{ matrix.shards }}; do + make test-cmd-gc-process-shard CMD_GC_PROCESS_SHARD="$shard" CMD_GC_PROCESS_TOTAL=6 + done - # Runs always, but remains non-blocking while integration/provider paths are still stabilizing. integration-shards: name: Integration / ${{ matrix.shard_name }} - runs-on: ubuntu-latest - continue-on-error: true + needs: + - runner-policy + - preflight-smoke + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} timeout-minutes: ${{ matrix.timeout_minutes }} strategy: fail-fast: false matrix: include: - - shard_name: packages + - shard_name: packages-core timeout_minutes: 35 - command: make test-integration-packages - - shard_name: review-formulas + command: | + ./scripts/test-integration-shard packages-core-1-of-4 + ./scripts/test-integration-shard packages-core-2-of-4 + ./scripts/test-integration-shard packages-core-3-of-4 + ./scripts/test-integration-shard packages-core-4-of-4 + - shard_name: packages-cmd-gc timeout_minutes: 45 - command: make test-integration-review-formulas + command: | + ./scripts/test-integration-shard packages-cmd-gc-1-of-6 + ./scripts/test-integration-shard packages-cmd-gc-2-of-6 + ./scripts/test-integration-shard packages-cmd-gc-3-of-6 + ./scripts/test-integration-shard packages-cmd-gc-4-of-6 + ./scripts/test-integration-shard packages-cmd-gc-5-of-6 + ./scripts/test-integration-shard packages-cmd-gc-6-of-6 + - shard_name: packages-runtime-tmux + timeout_minutes: 30 + command: | + ./scripts/test-integration-shard packages-runtime-tmux-1-of-3 + ./scripts/test-integration-shard packages-runtime-tmux-2-of-3 + ./scripts/test-integration-shard packages-runtime-tmux-3-of-3 + - shard_name: review-formulas-basic + timeout_minutes: 30 + command: make test-integration-review-formulas-basic + - shard_name: review-formulas-retries + timeout_minutes: 30 + command: make test-integration-review-formulas-retries + - shard_name: review-formulas-recovery + timeout_minutes: 25 + command: make test-integration-review-formulas-recovery - shard_name: bdstore timeout_minutes: 15 command: make test-integration-bdstore - - shard_name: rest + - shard_name: rest-smoke + timeout_minutes: 25 + command: make test-integration-rest-smoke + - shard_name: rest-full-1-2-of-8 + timeout_minutes: 35 + command: | + ./scripts/test-integration-shard rest-full-1-of-8 + ./scripts/test-integration-shard rest-full-2-of-8 + - shard_name: rest-full-3-4-of-8 + timeout_minutes: 35 + command: | + ./scripts/test-integration-shard rest-full-3-of-8 + ./scripts/test-integration-shard rest-full-4-of-8 + - shard_name: rest-full-5-6-of-8 timeout_minutes: 35 - command: make test-integration-rest + command: | + ./scripts/test-integration-shard rest-full-5-of-8 + ./scripts/test-integration-shard rest-full-6-of-8 + - shard_name: rest-full-7-of-8 + timeout_minutes: 35 + command: ./scripts/test-integration-shard rest-full-7-of-8 + - shard_name: rest-full-8-of-8 + timeout_minutes: 35 + command: ./scripts/test-integration-shard rest-full-8-of-8 env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-gascity-ubuntu @@ -217,15 +389,18 @@ jobs: worker-core-claude: name: Worker core (Claude) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: claude/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-claude-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Prepare worker report dir @@ -249,15 +424,18 @@ jobs: worker-core-codex: name: Worker core (Codex) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: codex/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-codex-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Prepare worker report dir @@ -281,15 +459,18 @@ jobs: worker-core-gemini: name: Worker core (Gemini) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: gemini/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-gemini-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Prepare worker report dir @@ -314,12 +495,14 @@ jobs: worker-core-summary: name: Worker core summary needs: + - runner-policy - changes + - preflight-smoke - worker-core-claude - worker-core-codex - worker-core-gemini if: ${{ always() }} - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} env: WORKER_ROLLUP_DIR: /tmp/worker-core-summary-reports WORKER_ROLLUP_JSON: /tmp/worker-core-summary-reports/worker-core-summary.json @@ -378,9 +561,9 @@ jobs: run: | CHANGES_RESULT='${{ needs.changes.result }}' CHANGED='${{ needs.changes.outputs.worker }}' - CLAUDE_RESULT='${{ needs.worker-core-claude.result }}' - CODEX_RESULT='${{ needs.worker-core-codex.result }}' - GEMINI_RESULT='${{ needs.worker-core-gemini.result }}' + CLAUDE_RESULT='${{ needs['worker-core-claude'].result }}' + CODEX_RESULT='${{ needs['worker-core-codex'].result }}' + GEMINI_RESULT='${{ needs['worker-core-gemini'].result }}' CLAUDE_DOWNLOAD='${{ steps.download_worker_core_claude.outcome }}' CODEX_DOWNLOAD='${{ steps.download_worker_core_codex.outcome }}' GEMINI_DOWNLOAD='${{ steps.download_worker_core_gemini.outcome }}' @@ -421,15 +604,18 @@ jobs: worker-core-phase2-claude: name: Worker core phase 2 (Claude) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker_phase2 == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: claude/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-phase2-claude-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Install system dependencies @@ -457,15 +643,18 @@ jobs: worker-core-phase2-codex: name: Worker core phase 2 (Codex) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker_phase2 == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: codex/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-phase2-codex-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Install system dependencies @@ -493,15 +682,18 @@ jobs: worker-core-phase2-gemini: name: Worker core phase 2 (Gemini) - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.worker_phase2 == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} env: PROFILE: gemini/tmux-cli WORKER_REPORT_DIR: /tmp/worker-core-phase2-gemini-reports steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod - name: Install system dependencies @@ -530,12 +722,13 @@ jobs: worker-core-phase2-summary: name: Worker core phase 2 summary needs: + - runner-policy - changes - worker-core-phase2-claude - worker-core-phase2-codex - worker-core-phase2-gemini if: ${{ always() }} - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} env: WORKER_ROLLUP_DIR: /tmp/worker-core-phase2-summary-reports WORKER_ROLLUP_JSON: /tmp/worker-core-phase2-summary-reports/worker-core-phase2-summary.json @@ -594,9 +787,9 @@ jobs: run: | CHANGES_RESULT='${{ needs.changes.result }}' CHANGED='${{ needs.changes.outputs.worker_phase2 }}' - CLAUDE_RESULT='${{ needs.worker-core-phase2-claude.result }}' - CODEX_RESULT='${{ needs.worker-core-phase2-codex.result }}' - GEMINI_RESULT='${{ needs.worker-core-phase2-gemini.result }}' + CLAUDE_RESULT='${{ needs['worker-core-phase2-claude'].result }}' + CODEX_RESULT='${{ needs['worker-core-phase2-codex'].result }}' + GEMINI_RESULT='${{ needs['worker-core-phase2-gemini'].result }}' CLAUDE_DOWNLOAD='${{ steps.download_worker_core_phase2_claude.outcome }}' CODEX_DOWNLOAD='${{ steps.download_worker_core_phase2_codex.outcome }}' GEMINI_DOWNLOAD='${{ steps.download_worker_core_phase2_gemini.outcome }}' @@ -635,228 +828,29 @@ jobs: exit 1 fi - worker-inference-phase3-claude: - name: Worker inference phase 3 (Claude) - needs: changes - if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest - env: - PROFILE: claude/tmux-cli - WORKER_REPORT_DIR: /tmp/worker-inference-phase3-claude-reports - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Prepare worker report dir - run: mkdir -p "$WORKER_REPORT_DIR" - - name: Emit manual-only WorkerInference phase-3 report - run: python3 .github/workflows/scripts/worker_report_manual_only.py "$WORKER_REPORT_DIR" "worker-inference-phase3" - - name: WorkerInference phase-3 report summary - if: ${{ always() }} - run: python3 .github/workflows/scripts/worker_report_summary.py "$WORKER_REPORT_DIR" - - name: Upload WorkerInference phase-3 reports - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: worker-inference-phase3-claude-reports - path: ${{ env.WORKER_REPORT_DIR }}/*.json - if-no-files-found: error - - worker-inference-phase3-codex: - name: Worker inference phase 3 (Codex) - needs: changes - if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest - env: - PROFILE: codex/tmux-cli - WORKER_REPORT_DIR: /tmp/worker-inference-phase3-codex-reports - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Prepare worker report dir - run: mkdir -p "$WORKER_REPORT_DIR" - - name: Emit manual-only WorkerInference phase-3 report - run: python3 .github/workflows/scripts/worker_report_manual_only.py "$WORKER_REPORT_DIR" "worker-inference-phase3" - - name: WorkerInference phase-3 report summary - if: ${{ always() }} - run: python3 .github/workflows/scripts/worker_report_summary.py "$WORKER_REPORT_DIR" - - name: Upload WorkerInference phase-3 reports - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: worker-inference-phase3-codex-reports - path: ${{ env.WORKER_REPORT_DIR }}/*.json - if-no-files-found: error - - worker-inference-phase3-gemini: - name: Worker inference phase 3 (Gemini) - needs: changes - if: needs.changes.outputs.worker == 'true' - runs-on: ubuntu-latest - env: - PROFILE: gemini/tmux-cli - WORKER_REPORT_DIR: /tmp/worker-inference-phase3-gemini-reports - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Prepare worker report dir - run: mkdir -p "$WORKER_REPORT_DIR" - - name: Emit manual-only WorkerInference phase-3 report - run: python3 .github/workflows/scripts/worker_report_manual_only.py "$WORKER_REPORT_DIR" "worker-inference-phase3" - - name: WorkerInference phase-3 report summary - if: ${{ always() }} - run: python3 .github/workflows/scripts/worker_report_summary.py "$WORKER_REPORT_DIR" - - name: Upload WorkerInference phase-3 reports - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: worker-inference-phase3-gemini-reports - path: ${{ env.WORKER_REPORT_DIR }}/*.json - if-no-files-found: error - - worker-inference-phase3-summary: - name: Worker inference phase 3 summary - needs: - - changes - - worker-inference-phase3-claude - - worker-inference-phase3-codex - - worker-inference-phase3-gemini - if: ${{ always() }} - runs-on: ubuntu-latest - env: - WORKER_ROLLUP_DIR: /tmp/worker-inference-phase3-summary-reports - WORKER_ROLLUP_JSON: /tmp/worker-inference-phase3-summary-reports/worker-inference-phase3-summary.json - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Prepare worker rollup dir - if: ${{ needs.changes.outputs.worker == 'true' }} - run: mkdir -p "$WORKER_ROLLUP_DIR" - - name: Download WorkerInference phase-3 Claude reports - id: download_worker_inference_phase3_claude - if: ${{ needs.changes.outputs.worker == 'true' }} - continue-on-error: true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: worker-inference-phase3-claude-reports - path: ${{ env.WORKER_ROLLUP_DIR }}/claude - - name: Download WorkerInference phase-3 Codex reports - id: download_worker_inference_phase3_codex - if: ${{ needs.changes.outputs.worker == 'true' }} - continue-on-error: true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: worker-inference-phase3-codex-reports - path: ${{ env.WORKER_ROLLUP_DIR }}/codex - - name: Download WorkerInference phase-3 Gemini reports - id: download_worker_inference_phase3_gemini - if: ${{ needs.changes.outputs.worker == 'true' }} - continue-on-error: true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: worker-inference-phase3-gemini-reports - path: ${{ env.WORKER_ROLLUP_DIR }}/gemini - - name: WorkerInference phase-3 rollup summary - if: ${{ needs.changes.outputs.worker == 'true' }} - env: - CLAUDE_DOWNLOAD: ${{ steps.download_worker_inference_phase3_claude.outcome }} - CODEX_DOWNLOAD: ${{ steps.download_worker_inference_phase3_codex.outcome }} - GEMINI_DOWNLOAD: ${{ steps.download_worker_inference_phase3_gemini.outcome }} - run: | - python3 .github/workflows/scripts/worker_report_rollup.py \ - "$WORKER_ROLLUP_DIR" \ - --title "Worker inference phase 3 summary" \ - --require-reports \ - --expected-profile "claude/tmux-cli=$CLAUDE_DOWNLOAD" \ - --expected-profile "codex/tmux-cli=$CODEX_DOWNLOAD" \ - --expected-profile "gemini/tmux-cli=$GEMINI_DOWNLOAD" \ - --output "$WORKER_ROLLUP_JSON" - - name: Upload WorkerInference phase-3 rollup - if: ${{ always() && needs.changes.outputs.worker == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: worker-inference-phase3-summary-reports - path: ${{ env.WORKER_ROLLUP_JSON }} - if-no-files-found: error - - name: Assert worker-inference phase-3 matrix reported - run: | - CHANGES_RESULT='${{ needs.changes.result }}' - CHANGED='${{ needs.changes.outputs.worker }}' - CLAUDE_RESULT='${{ needs.worker-inference-phase3-claude.result }}' - CODEX_RESULT='${{ needs.worker-inference-phase3-codex.result }}' - GEMINI_RESULT='${{ needs.worker-inference-phase3-gemini.result }}' - CLAUDE_DOWNLOAD='${{ steps.download_worker_inference_phase3_claude.outcome }}' - CODEX_DOWNLOAD='${{ steps.download_worker_inference_phase3_codex.outcome }}' - GEMINI_DOWNLOAD='${{ steps.download_worker_inference_phase3_gemini.outcome }}' - if [ -f "$WORKER_ROLLUP_JSON" ]; then - ROLLUP_STATUS="$(python3 -c "import json, sys; print(json.load(open(sys.argv[1], encoding='utf-8')).get('summary', {}).get('status', 'unknown'))" "$WORKER_ROLLUP_JSON")" - else - ROLLUP_STATUS='missing' - fi - printf 'changes-result=%s\n' "$CHANGES_RESULT" - printf 'worker-changes=%s\n' "$CHANGED" - printf 'worker-inference-phase3-claude=%s\n' "$CLAUDE_RESULT" - printf 'worker-inference-phase3-codex=%s\n' "$CODEX_RESULT" - printf 'worker-inference-phase3-gemini=%s\n' "$GEMINI_RESULT" - printf 'download-worker-inference-phase3-claude=%s\n' "$CLAUDE_DOWNLOAD" - printf 'download-worker-inference-phase3-codex=%s\n' "$CODEX_DOWNLOAD" - printf 'download-worker-inference-phase3-gemini=%s\n' "$GEMINI_DOWNLOAD" - printf 'worker-inference-phase3-rollup=%s\n' "$ROLLUP_STATUS" - if [ "$CHANGES_RESULT" != "success" ]; then - echo "changes job did not complete successfully" >&2 - exit 1 - fi - if [ "$CHANGED" != "true" ]; then - echo "No phase-3 worker changes detected; passing summary without fan-in." - exit 0 - fi - if [ "$CLAUDE_DOWNLOAD" != "success" ] || [ "$CODEX_DOWNLOAD" != "success" ] || [ "$GEMINI_DOWNLOAD" != "success" ]; then - echo "worker-inference phase-3 summary is missing one or more expected profile artifacts" >&2 - exit 1 - fi - if [ "$ROLLUP_STATUS" != "pass" ] && [ "$ROLLUP_STATUS" != "unsupported" ]; then - echo "worker-inference phase-3 rollup reported an unexpected status" >&2 - exit 1 - fi - if [ "$ROLLUP_STATUS" = "unsupported" ]; then - echo "worker-inference phase-3 is catalog-only until executable inference scenarios land." - fi - if [ "$CLAUDE_RESULT" != "success" ] || [ "$CODEX_RESULT" != "success" ] || [ "$GEMINI_RESULT" != "success" ]; then - echo "worker-inference phase-3 matrix failed" >&2 - exit 1 - fi - # Runs when pack-related files change — full gastown integration suite. pack-gate: name: Pack compatibility gate - needs: [changes, check] + needs: + - runner-policy + - changes + - check if: needs.changes.outputs.packs == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 - with: - go-version: "1.25.8" - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: ./.github/actions/setup-gascity-ubuntu with: - node-version: "22" - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y tmux jq - - name: Install dolt - run: | - curl -fsSL https://github.com/dolthub/dolt/releases/download/v${{ env.DOLT_VERSION }}/install.sh | sudo bash - - name: Install released bd v${{ env.BD_VERSION }} - run: | - archive="beads_${BD_VERSION#v}_linux_amd64.tar.gz" - mkdir -p "$RUNNER_TEMP/beads" - curl -fsSL -o "$RUNNER_TEMP/$archive" \ - "https://github.com/gastownhall/beads/releases/download/${BD_VERSION}/${archive}" - tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP/beads" bd - sudo install -m 0755 "$RUNNER_TEMP/beads/bd" /usr/local/bin/bd - - name: Install Claude CLI - run: npm install -g @anthropic-ai/claude-code + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "true" - name: Install tools run: make install-tools - name: Pack compatibility tests run: make test-acceptance env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" # Dashboard SPA typecheck + tests + build. Runs on every push/PR # so TS drift against the spec (e.g. a query param tightening from @@ -866,14 +860,20 @@ jobs: # load-bearing discipline step. dashboard: name: Dashboard SPA - runs-on: ubuntu-latest + needs: + - runner-policy + - preflight-smoke + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" - name: Install SPA dependencies - run: npm install --silent + run: npm ci --silent working-directory: cmd/gc/dashboard/web - name: Verify generated TS schema is in sync run: | @@ -896,14 +896,17 @@ jobs: # Runs when mail-related source paths change. mcp-mail: name: MCP mail conformance - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.mail == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_8vcpu }} continue-on-error: true # upstream mcp_agent_mail API may drift steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -915,7 +918,7 @@ jobs: python-version: '3.12' - name: Install mcp_agent_mail - run: pip install 'mcp-agent-mail==0.1.0' + run: python -m pip install --require-hashes -r .github/requirements/mcp-agent-mail.txt - name: MCP mail conformance test run: make test-mcp-mail @@ -923,13 +926,16 @@ jobs: # Runs when session/Docker-related source paths change. docker-session: name: Docker session - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.docker == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -949,13 +955,16 @@ jobs: # Requires K8s CI infrastructure — no-op until secrets are configured. k8s-session: name: K8s session - needs: changes + needs: + - runner-policy + - changes + - preflight-smoke if: needs.changes.outputs.k8s == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_8vcpu }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -967,3 +976,117 @@ jobs: env: GC_K8S_AVAILABLE: ${{ secrets.GC_K8S_AVAILABLE }} run: make test-k8s + + ci-preflight: + name: CI / preflight + needs: + - runner-policy + - check + - release-config + - dashboard + if: ${{ always() }} + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - name: Summarize preflight gates + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + failed = { + job: meta.get("result", "unknown") + for job, meta in sorted(needs.items()) + if meta.get("result") != "success" + } + if failed: + for job, result in failed.items(): + print(f"{job}: {result}", file=sys.stderr) + sys.exit(1) + PY + + ci-integration: + name: CI / integration + needs: + - runner-policy + - integration-shards + if: ${{ always() }} + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - name: Summarize integration gates + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + failed = { + job: meta.get("result", "unknown") + for job, meta in sorted(needs.items()) + if meta.get("result") != "success" + } + if failed: + for job, result in failed.items(): + print(f"{job}: {result}", file=sys.stderr) + sys.exit(1) + PY + + ci-required: + name: CI / required + needs: + - runner-policy + - changes + - ci-preflight + - ci-integration + - cmd-gc-process + - worker-core-summary + - worker-core-phase2-summary + - pack-gate + - docker-session + - k8s-session + if: ${{ always() }} + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - name: Summarize required gates + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + allow_skipped = { + "cmd-gc-process", + "pack-gate", + "docker-session", + "k8s-session", + } + failed = {} + for job, meta in sorted(needs.items()): + result = meta.get("result", "unknown") + if result == "success": + continue + if result == "skipped" and job in allow_skipped: + continue + failed[job] = result + + summary_path = os.environ["GITHUB_STEP_SUMMARY"] + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("## CI Required\n\n") + handle.write("| Job | Result |\n| --- | --- |\n") + for job, meta in sorted(needs.items()): + handle.write(f"| {job} | {meta.get('result', 'unknown')} |\n") + + if failed: + for job, result in failed.items(): + print(f"{job}: {result}", file=sys.stderr) + sys.exit(1) + PY diff --git a/.github/workflows/close-stale-needs.yml b/.github/workflows/close-stale-needs.yml index 35451f7cc9..44c4e4235b 100644 --- a/.github/workflows/close-stale-needs.yml +++ b/.github/workflows/close-stale-needs.yml @@ -5,6 +5,8 @@ on: - cron: '37 9 * * *' workflow_dispatch: +permissions: {} + jobs: close-needs-info: runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..e4f03f1990 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: + - opened + - reopened + - synchronize + - ready_for_review + schedule: + - cron: "24 4 * * 1" + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + runner-policy: + name: Runner policy + runs-on: ${{ github.event_name == 'pull_request' && contains(fromJSON('["julianknutsen","csells","sjarmak","quad341"]'), github.event.pull_request.user.login) && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + outputs: + use_blacksmith: ${{ steps.policy.outputs.use_blacksmith }} + reason: ${{ steps.policy.outputs.reason }} + runner_2vcpu: ${{ steps.policy.outputs.runner_2vcpu }} + runner_8vcpu: ${{ steps.policy.outputs.runner_8vcpu }} + runner_16vcpu: ${{ steps.policy.outputs.runner_16vcpu }} + runner_32vcpu: ${{ steps.policy.outputs.runner_32vcpu }} + runner_macos: ${{ steps.policy.outputs.runner_macos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Select runner backend + id: policy + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + python3 .github/workflows/scripts/runner_policy.py + + analyze: + name: Analyze (${{ matrix.language }}) + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Autobuild + if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml new file mode 100644 index 0000000000..f6beb097a3 --- /dev/null +++ b/.github/workflows/container-scan.yml @@ -0,0 +1,300 @@ +name: Container Scan + +on: + push: + branches: [main] + paths: + - ".dockerignore" + - ".trivyignore.yaml" + - ".trivyignore-config" + - ".github/requirements/mcp-agent-mail.in" + - ".github/requirements/mcp-agent-mail.txt" + - ".github/scripts/install-*.sh" + - ".github/workflows/container-scan.yml" + - "contrib/beads-scripts/gc-beads-br" + - "contrib/events-scripts/gc-events-k8s" + - "contrib/k8s/**" + - "contrib/mail-scripts/gc-mail-mcp-agent-mail" + - "contrib/session-scripts/gc-controller-k8s" + - "deps.env" + - "go.mod" + - "go.sum" + pull_request: + branches: [main] + types: + - opened + - reopened + - synchronize + - ready_for_review + paths: + - ".dockerignore" + - ".trivyignore.yaml" + - ".trivyignore-config" + - ".github/requirements/mcp-agent-mail.in" + - ".github/requirements/mcp-agent-mail.txt" + - ".github/scripts/install-*.sh" + - ".github/workflows/container-scan.yml" + - "contrib/beads-scripts/gc-beads-br" + - "contrib/events-scripts/gc-events-k8s" + - "contrib/k8s/**" + - "contrib/mail-scripts/gc-mail-mcp-agent-mail" + - "contrib/session-scripts/gc-controller-k8s" + - "deps.env" + - "go.mod" + - "go.sum" + schedule: + - cron: "43 6 * * 3" + workflow_dispatch: + +permissions: + contents: read + +env: + TRIVY_VERSION: "v0.70.0" + +jobs: + runner-policy: + name: Runner policy + runs-on: ${{ github.event_name == 'pull_request' && contains(fromJSON('["julianknutsen","csells","sjarmak","quad341"]'), github.event.pull_request.user.login) && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + outputs: + use_blacksmith: ${{ steps.policy.outputs.use_blacksmith }} + reason: ${{ steps.policy.outputs.reason }} + runner_2vcpu: ${{ steps.policy.outputs.runner_2vcpu }} + runner_8vcpu: ${{ steps.policy.outputs.runner_8vcpu }} + runner_16vcpu: ${{ steps.policy.outputs.runner_16vcpu }} + runner_32vcpu: ${{ steps.policy.outputs.runner_32vcpu }} + runner_macos: ${{ steps.policy.outputs.runner_macos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Select runner backend + id: policy + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + python3 .github/workflows/scripts/runner_policy.py + + dockerfile-config: + name: Dockerfile config + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_8vcpu }} + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install Trivy + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + bin_dir="${RUNNER_TEMP}/gascity-trivy-bin" + TRIVY_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-trivy-archive.sh "${TRIVY_VERSION}" + echo "$bin_dir" >> "$GITHUB_PATH" + + - name: Generate Dockerfile and manifest SARIF + run: | + mkdir -p trivy-results + trivy config \ + --severity HIGH,CRITICAL \ + --ignorefile .trivyignore-config \ + --format sarif \ + --output trivy-results/dockerfile-config.sarif \ + contrib/k8s + + - name: Upload Dockerfile SARIF + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: trivy-results/dockerfile-config.sarif + + - name: Upload Dockerfile scan artifact + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: trivy-dockerfile-config + path: trivy-results/dockerfile-config.sarif + retention-days: 5 + + - name: Summarize Dockerfile and manifest findings + run: | + trivy config \ + --severity HIGH,CRITICAL \ + --ignorefile .trivyignore-config \ + --format table \ + contrib/k8s + + - name: Enforce Dockerfile and manifest policy + run: | + trivy config \ + --severity HIGH,CRITICAL \ + --ignorefile .trivyignore-config \ + --exit-code 1 \ + --format table \ + contrib/k8s + + image-vulnerabilities: + name: Image vulnerabilities + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} + timeout-minutes: 45 + permissions: + contents: read + security-events: write + env: + IMAGE_TAG: ${{ github.sha }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Install Trivy + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + bin_dir="${RUNNER_TEMP}/gascity-trivy-bin" + TRIVY_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-trivy-archive.sh "${TRIVY_VERSION}" + echo "$bin_dir" >> "$GITHUB_PATH" + + - name: Prepare image build inputs + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + . ./deps.env + bin_dir="${RUNNER_TEMP}/gascity-container-scan-bin" + mkdir -p "$bin_dir" + BD_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-bd-archive.sh "$BD_VERSION" + BR_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-br-archive.sh "$BR_VERSION" + go build -o gc ./cmd/gc + cp -f "$bin_dir/bd" bd + cp -f "$bin_dir/br" br + + - name: Build local images + run: | + set -euo pipefail + . ./deps.env + docker build \ + -f contrib/k8s/Dockerfile.base \ + --build-arg DOLT_VERSION="$DOLT_VERSION" \ + -t "gc-agent-base:${IMAGE_TAG}" \ + . + docker tag "gc-agent-base:${IMAGE_TAG}" gc-agent-base:latest + + docker build \ + -f contrib/k8s/Dockerfile.agent \ + --build-arg BASE_IMAGE=gc-agent-base:latest \ + -t "gc-agent:${IMAGE_TAG}" \ + . + docker tag "gc-agent:${IMAGE_TAG}" gc-agent:latest + + docker build \ + -f contrib/k8s/Dockerfile.controller \ + --build-arg BASE=gc-agent:latest \ + -t "gc-controller:${IMAGE_TAG}" \ + . + + docker build \ + -f contrib/k8s/Dockerfile.mail \ + -t "gc-mcp-mail:${IMAGE_TAG}" \ + . + + - name: Generate image SARIF and SBOMs + run: | + set -euo pipefail + mkdir -p trivy-results + images=( + "gc-agent-base:${IMAGE_TAG}" + "gc-agent:${IMAGE_TAG}" + "gc-controller:${IMAGE_TAG}" + "gc-mcp-mail:${IMAGE_TAG}" + ) + for image in "${images[@]}"; do + name="${image%%:*}" + trivy image \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --ignore-unfixed \ + --ignorefile .trivyignore.yaml \ + --timeout 15m \ + --format sarif \ + --output "trivy-results/${name}.sarif" \ + "$image" + trivy image \ + --scanners vuln \ + --ignorefile .trivyignore.yaml \ + --timeout 15m \ + --format cyclonedx \ + --output "trivy-results/${name}.cdx.json" \ + "$image" + done + + - name: Enforce image vulnerability policy + run: | + set -euo pipefail + images=( + "gc-agent-base:${IMAGE_TAG}" + "gc-agent:${IMAGE_TAG}" + "gc-controller:${IMAGE_TAG}" + "gc-mcp-mail:${IMAGE_TAG}" + ) + for image in "${images[@]}"; do + trivy image \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --ignore-unfixed \ + --ignorefile .trivyignore.yaml \ + --exit-code 1 \ + --timeout 15m \ + --format table \ + "$image" + done + + - name: Upload base image SARIF + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: trivy-results/gc-agent-base.sarif + category: trivy-image/gc-agent-base + + - name: Upload agent image SARIF + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: trivy-results/gc-agent.sarif + category: trivy-image/gc-agent + + - name: Upload controller image SARIF + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: trivy-results/gc-controller.sarif + category: trivy-image/gc-controller + + - name: Upload MCP mail image SARIF + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: trivy-results/gc-mcp-mail.sarif + category: trivy-image/gc-mcp-mail + + - name: Upload image scan artifacts + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: trivy-image-results + path: trivy-results/ + retention-days: 5 diff --git a/.github/workflows/dispatch-labeled-pr-suite.yml b/.github/workflows/dispatch-labeled-pr-suite.yml new file mode 100644 index 0000000000..76afbc2459 --- /dev/null +++ b/.github/workflows/dispatch-labeled-pr-suite.yml @@ -0,0 +1,105 @@ +name: Dispatch labeled PR suite + +on: + pull_request_target: + types: + - labeled + +permissions: + actions: write + contents: read + pull-requests: read + +jobs: + dispatch-suite: + name: Dispatch requested suite + if: >- + github.event.label.name == 'needs-mac' || + github.event.label.name == 'needs-review-formulas' + runs-on: ubuntu-latest + steps: + # This workflow never checks out or runs pull request code. It reads the + # trusted base allowlist, then dispatches a dedicated workflow with the + # PR head repository and SHA as explicit inputs. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Dispatch suite for trusted PR author + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + LABEL_NAME: ${{ github.event.label.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + python3 - <<'PY' + import json + import os + import urllib.parse + import urllib.request + from pathlib import Path + + label = os.environ["LABEL_NAME"] + draft = os.environ.get("PR_DRAFT", "").lower() == "true" + author = os.environ.get("PR_AUTHOR", "").strip() + association = os.environ.get("PR_ASSOCIATION", "").strip().upper() + + if draft: + print("PR is draft; not dispatching a label-requested suite") + raise SystemExit(0) + + allowlist = set() + for raw_line in Path(".github/blacksmith-allowlist.txt").read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if line: + allowlist.add(line.lower()) + + trusted = association in {"OWNER", "MEMBER", "COLLABORATOR"} or author.lower() in allowlist + if not trusted: + print(f"PR author {author or ''} is not trusted for label-dispatched suites") + raise SystemExit(0) + + workflows = { + "needs-mac": ("mac-regression.yml", {"suite": "needs-mac"}), + "needs-review-formulas": ("review-formulas.yml", {}), + } + workflow, extra_inputs = workflows[label] + inputs = { + **extra_inputs, + "pr_number": os.environ["PR_NUMBER"], + "head_repo": os.environ["PR_HEAD_REPO"], + "head_ref": os.environ["PR_HEAD_REF"], + "head_sha": os.environ["PR_HEAD_SHA"], + } + payload = json.dumps({ + "ref": os.environ["BASE_REF"], + "inputs": inputs, + }).encode("utf-8") + + workflow_id = urllib.parse.quote(workflow, safe="") + url = f"https://api.github.com/repos/{os.environ['REPOSITORY']}/actions/workflows/{workflow_id}/dispatches" + request = urllib.request.Request( + url, + data=payload, + method="POST", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {os.environ['GH_TOKEN']}", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2026-03-10", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 204: + raise RuntimeError(f"unexpected dispatch status {response.status}") + + print(f"Dispatched {workflow} for PR #{inputs['pr_number']} at {inputs['head_repo']}@{inputs['head_sha']}") + PY diff --git a/.github/workflows/mac-regression.yml b/.github/workflows/mac-regression.yml index 954c23fdc6..5bcdc8b84a 100644 --- a/.github/workflows/mac-regression.yml +++ b/.github/workflows/mac-regression.yml @@ -10,7 +10,24 @@ on: options: - smoke - full + - needs-mac default: smoke + pr_number: + description: Pull request number for label-dispatched runs + required: false + type: string + head_repo: + description: Pull request head repository for label-dispatched runs + required: false + type: string + head_sha: + description: Pull request head SHA for label-dispatched runs + required: false + type: string + head_ref: + description: Pull request head ref for label-dispatched runs + required: false + type: string schedule: - cron: "17 3 * * *" pull_request: @@ -20,18 +37,17 @@ on: - reopened - synchronize - ready_for_review - - labeled permissions: contents: read concurrency: - group: mac-regression-${{ github.event.pull_request.number || github.ref || github.run_id }} + group: mac-regression-${{ inputs.pr_number || github.event.pull_request.number || github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name != 'schedule' }} env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" # Trigger gate re-used by every job below via `if:`. # We want each job to run when EITHER: @@ -42,10 +58,32 @@ env: # expression; keep them in sync. jobs: + runner-policy: + name: Runner policy + runs-on: ${{ github.event_name == 'pull_request' && contains(fromJSON('["julianknutsen","csells","sjarmak","quad341"]'), github.event.pull_request.user.login) && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + outputs: + use_blacksmith: ${{ steps.policy.outputs.use_blacksmith }} + reason: ${{ steps.policy.outputs.reason }} + runner_2vcpu: ${{ steps.policy.outputs.runner_2vcpu }} + runner_8vcpu: ${{ steps.policy.outputs.runner_8vcpu }} + runner_16vcpu: ${{ steps.policy.outputs.runner_16vcpu }} + runner_32vcpu: ${{ steps.policy.outputs.runner_32vcpu }} + runner_macos: ${{ steps.policy.outputs.runner_macos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Select runner backend + id: policy + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + python3 .github/workflows/scripts/runner_policy.py + # Fast quality gates that Linux runs on every PR. Keep these cheap so a # Mac-parity loop stays interactive. mac-quality: name: Mac / quality (lint, fmt, vet, docs) + needs: runner-policy if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || @@ -55,14 +93,14 @@ jobs: !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -82,6 +120,7 @@ jobs: # Unit tests — the suite Mac already ran as "smoke". mac-unit: name: Mac / make test + needs: runner-policy if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || @@ -91,14 +130,14 @@ jobs: !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 25 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -110,6 +149,7 @@ jobs: # Tier A acceptance — smoke-level gate on every PR. mac-acceptance: name: Mac / acceptance (Tier A) + needs: runner-policy if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || @@ -119,14 +159,14 @@ jobs: !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 25 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -146,26 +186,30 @@ jobs: # job's result still reflects the actual outcome for the summary. mac-cover: name: Mac / test-cover - # Heavy job: schedule/full-dispatch/PR(needs-mac). Smoke dispatch skips. + needs: runner-policy + # Heavy job: schedule/full-dispatch/needs-mac-dispatch/PR(needs-mac). Smoke dispatch skips. if: >- github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.suite == 'full') || + ( + github.event_name == 'workflow_dispatch' && + (inputs.suite == 'full' || inputs.suite == 'needs-mac') + ) || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 25 outputs: outcome: ${{ steps.cover.outcome }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -185,46 +229,72 @@ jobs: path: coverage.txt if-no-files-found: ignore - # Integration shards. Linux runs these with continue-on-error today while - # stabilizing; we mirror that until Mac parity is proven. These three - # shards run on `needs-mac` label, nightly, or manual dispatch. The - # long-running review-formulas shard lives in a separate job below so - # it can gate on nightly / full-dispatch only. - # Integration shards. Linux runs these with continue-on-error today - # while stabilizing; we mirror that until Mac parity is proven. Split - # into discrete jobs (rather than a matrix) so each shard publishes - # its own `outputs.outcome` — matrix-job outputs are last-writer-wins - # and would mask a per-shard failure in the summary row. + # Integration shards. Packages and REST are matrix jobs whose aggregate + # result now gates the summary directly. The long-running review-formulas + # shard stays separate so it can gate on nightly / full-dispatch only. mac-integration-packages: - name: Mac / integration (packages) + name: Mac / integration packages / ${{ matrix.shard_name }} + needs: + - runner-policy + - mac-quality + - mac-unit + - mac-acceptance if: >- github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.suite == 'full') || + ( + github.event_name == 'workflow_dispatch' && + (inputs.suite == 'full' || inputs.suite == 'needs-mac') + ) || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium - timeout-minutes: 60 - outputs: - outcome: ${{ steps.shard.outcome }} + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - shard_name: core + timeout_minutes: 60 + command: | + ./scripts/test-integration-shard packages-core-1-of-4 + ./scripts/test-integration-shard packages-core-2-of-4 + ./scripts/test-integration-shard packages-core-3-of-4 + ./scripts/test-integration-shard packages-core-4-of-4 + - shard_name: cmd-gc + timeout_minutes: 75 + command: | + ./scripts/test-integration-shard packages-cmd-gc-1-of-6 + ./scripts/test-integration-shard packages-cmd-gc-2-of-6 + ./scripts/test-integration-shard packages-cmd-gc-3-of-6 + ./scripts/test-integration-shard packages-cmd-gc-4-of-6 + ./scripts/test-integration-shard packages-cmd-gc-5-of-6 + ./scripts/test-integration-shard packages-cmd-gc-6-of-6 + - shard_name: tmux + timeout_minutes: 45 + command: | + ./scripts/test-integration-shard packages-runtime-tmux-1-of-3 + ./scripts/test-integration-shard packages-runtime-tmux-2-of-3 + ./scripts/test-integration-shard packages-runtime-tmux-3-of-3 env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -234,39 +304,47 @@ jobs: run: make install-tools - name: Run integration shard id: shard - continue-on-error: true - run: make test-integration-packages + run: ${{ matrix.command }} mac-integration-bdstore: name: Mac / integration (bdstore) + needs: + - runner-policy + - mac-quality + - mac-unit + - mac-acceptance if: >- github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.suite == 'full') || + ( + github.event_name == 'workflow_dispatch' && + (inputs.suite == 'full' || inputs.suite == 'needs-mac') + ) || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 60 outputs: outcome: ${{ steps.shard.outcome }} env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -280,35 +358,70 @@ jobs: run: make test-integration-bdstore mac-integration-rest: - name: Mac / integration (rest) + name: Mac / integration rest / ${{ matrix.shard_name }} + needs: + - runner-policy + - mac-quality + - mac-unit + - mac-acceptance if: >- github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.suite == 'full') || + ( + github.event_name == 'workflow_dispatch' && + (inputs.suite == 'full' || inputs.suite == 'needs-mac') + ) || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'needs-mac') ) - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium - timeout-minutes: 60 - outputs: - outcome: ${{ steps.shard.outcome }} + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - shard_name: smoke + timeout_minutes: 45 + command: make test-integration-rest-smoke + - shard_name: full-1-2-of-8 + timeout_minutes: 45 + command: | + ./scripts/test-integration-shard rest-full-1-of-8 + ./scripts/test-integration-shard rest-full-2-of-8 + - shard_name: full-3-4-of-8 + timeout_minutes: 45 + command: | + ./scripts/test-integration-shard rest-full-3-of-8 + ./scripts/test-integration-shard rest-full-4-of-8 + - shard_name: full-5-6-of-8 + timeout_minutes: 45 + command: | + ./scripts/test-integration-shard rest-full-5-of-8 + ./scripts/test-integration-shard rest-full-6-of-8 + - shard_name: full-7-of-8 + timeout_minutes: 45 + command: ./scripts/test-integration-shard rest-full-7-of-8 + - shard_name: full-8-of-8 + timeout_minutes: 45 + command: ./scripts/test-integration-shard rest-full-8-of-8 env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -318,34 +431,39 @@ jobs: run: make install-tools - name: Run integration shard id: shard - continue-on-error: true - run: make test-integration-rest + run: ${{ matrix.command }} # Long-running review-formulas shard — nightly / full dispatch only. mac-integration-review-formulas: name: Mac / integration (review-formulas) + needs: + - runner-policy + - mac-quality + - mac-unit + - mac-acceptance if: >- github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.suite == 'full') - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: ${{ needs.runner-policy.outputs.runner_macos }} timeout-minutes: 90 outputs: outcome: ${{ steps.shard.outcome }} env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-macos with: dolt-version: ${{ env.DOLT_VERSION }} @@ -361,10 +479,9 @@ jobs: # Aggregate summary so a single check reports Mac parity status on the # PR. Gated on the same trigger set as the parity jobs so it doesn't # post a misleading green check on PRs that never ran Mac at all. The - # best-effort jobs (cover, integration, review-formulas) keep their - # failures visible here via job outputs that capture the real - # step outcome — needs..result masks it as success because the - # failing steps are continue-on-error. + # best-effort jobs keep their failures visible here via job outputs that + # capture the real step outcome — needs..result masks it as success + # because the failing steps are continue-on-error. mac-regression-summary: name: Mac regression summary if: >- @@ -379,6 +496,7 @@ jobs: ) ) needs: + - runner-policy - mac-quality - mac-unit - mac-acceptance @@ -387,20 +505,18 @@ jobs: - mac-integration-bdstore - mac-integration-rest - mac-integration-review-formulas - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} steps: - name: Summarize env: QUALITY: ${{ needs.mac-quality.result }} UNIT: ${{ needs.mac-unit.result }} ACCEPTANCE: ${{ needs.mac-acceptance.result }} - # Best-effort jobs: use outputs.outcome (not needs.*.result) - # because their test step is continue-on-error, which forces - # needs.*.result to "success" even on failure. + # Best-effort jobs: use outputs.outcome (not needs.*.result). COVER: ${{ needs.mac-cover.outputs.outcome || needs.mac-cover.result }} - INT_PACKAGES: ${{ needs.mac-integration-packages.outputs.outcome || needs.mac-integration-packages.result }} + INT_PACKAGES: ${{ needs.mac-integration-packages.result }} INT_BDSTORE: ${{ needs.mac-integration-bdstore.outputs.outcome || needs.mac-integration-bdstore.result }} - INT_REST: ${{ needs.mac-integration-rest.outputs.outcome || needs.mac-integration-rest.result }} + INT_REST: ${{ needs.mac-integration-rest.result }} REVIEW_FORMULAS: ${{ needs.mac-integration-review-formulas.outputs.outcome || needs.mac-integration-review-formulas.result }} run: | cat >>"$GITHUB_STEP_SUMMARY" <&2; exit 1; } - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "ANTHROPIC_DEFAULT_HAIKU_MODEL resolved empty" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "ANTHROPIC_DEFAULT_SONNET_MODEL resolved empty" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "ANTHROPIC_DEFAULT_OPUS_MODEL resolved empty" >&2; exit 1; } @@ -60,9 +48,6 @@ jobs: printf 'ANTHROPIC_DEFAULT_OPUS_MODEL=%s\n' "$ANTHROPIC_DEFAULT_OPUS_MODEL" printf 'CLAUDE_CODE_SUBAGENT_MODEL=%s\n' "$CLAUDE_CODE_SUBAGENT_MODEL" - - name: Install Claude CLI - run: npm install -g @anthropic-ai/claude-code - - name: Tier B acceptance tests run: make test-acceptance-b @@ -71,20 +56,17 @@ jobs: mac-inference: name: Mac / Tier B+C inference tests - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium + runs-on: macos-15 timeout-minutes: 180 env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_API_KEY: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_API_KEY: "" + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: @@ -96,10 +78,9 @@ jobs: bd-version: ${{ env.BD_VERSION }} install-claude-cli: "true" - - name: Validate Synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_API_KEY" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "ANTHROPIC_DEFAULT_HAIKU_MODEL resolved empty" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "ANTHROPIC_DEFAULT_SONNET_MODEL resolved empty" >&2; exit 1; } test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "ANTHROPIC_DEFAULT_OPUS_MODEL resolved empty" >&2; exit 1; } @@ -119,12 +100,13 @@ jobs: WORKER_REPORT_DIR: ${{ github.workspace }}/.nightly-tmp/worker-inference-claude-reports DOLT_VERSION: "1.85.0" BD_COMMIT: "9d9d0e53c2330bd081bef350883f56c2557eb78b" - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: @@ -134,30 +116,29 @@ jobs: repository: gastownhall/beads ref: ${{ env.BD_COMMIT }} path: .beads-src - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version: "1.25.8" + go-version: "1.25.9" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y tmux jq - name: Install dolt - run: | - curl -fsSL https://github.com/dolthub/dolt/releases/download/v${{ env.DOLT_VERSION }}/install.sh | sudo bash + run: .github/scripts/install-dolt-archive.sh "${{ env.DOLT_VERSION }}" - name: Build bd working-directory: .beads-src run: | mkdir -p "$GITHUB_WORKSPACE/.bd-release" GOBIN="$GITHUB_WORKSPACE/.bd-release" go install ./cmd/bd sudo install -m 0755 "$GITHUB_WORKSPACE/.bd-release/bd" /usr/local/bin/bd - - name: Validate Synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL GitHub variable" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } printf 'ANTHROPIC_BASE_URL=%s\n' "$ANTHROPIC_BASE_URL" printf 'ANTHROPIC_DEFAULT_HAIKU_MODEL=%s\n' "$ANTHROPIC_DEFAULT_HAIKU_MODEL" printf 'ANTHROPIC_DEFAULT_SONNET_MODEL=%s\n' "$ANTHROPIC_DEFAULT_SONNET_MODEL" @@ -170,7 +151,7 @@ jobs: - name: WorkerInference tests id: worker_inference_tests run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-inference PROFILE="$PROFILE" - - name: Emit synthetic failure report + - name: Emit worker inference failure report if: ${{ always() && steps.worker_inference_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-inference" - name: WorkerInference report summary @@ -201,17 +182,16 @@ jobs: repository: gastownhall/beads ref: ${{ env.BD_COMMIT }} path: .beads-src - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version: "1.25.8" + go-version: "1.25.9" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y tmux jq - name: Install dolt - run: | - curl -fsSL https://github.com/dolthub/dolt/releases/download/v${{ env.DOLT_VERSION }}/install.sh | sudo bash + run: .github/scripts/install-dolt-archive.sh "${{ env.DOLT_VERSION }}" - name: Build bd working-directory: .beads-src run: | @@ -225,7 +205,7 @@ jobs: - name: WorkerInference tests id: worker_inference_tests run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-inference PROFILE="$PROFILE" - - name: Emit synthetic failure report + - name: Emit worker inference failure report if: ${{ always() && steps.worker_inference_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-inference" - name: WorkerInference report summary @@ -259,17 +239,16 @@ jobs: repository: gastownhall/beads ref: ${{ env.BD_COMMIT }} path: .beads-src - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version: "1.25.8" + go-version: "1.25.9" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y tmux jq - name: Install dolt - run: | - curl -fsSL https://github.com/dolthub/dolt/releases/download/v${{ env.DOLT_VERSION }}/install.sh | sudo bash + run: .github/scripts/install-dolt-archive.sh "${{ env.DOLT_VERSION }}" - name: Build bd working-directory: .beads-src run: | @@ -283,7 +262,7 @@ jobs: - name: WorkerInference tests id: worker_inference_tests run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-inference PROFILE="$PROFILE" - - name: Emit synthetic failure report + - name: Emit worker inference failure report if: ${{ always() && steps.worker_inference_tests.outcome != 'success' }} run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-inference" - name: WorkerInference report summary diff --git a/.github/workflows/ollama-acceptance-c.yml b/.github/workflows/ollama-acceptance-c.yml new file mode 100644 index 0000000000..cb13ce1c26 --- /dev/null +++ b/.github/workflows/ollama-acceptance-c.yml @@ -0,0 +1,74 @@ +name: Ollama Acceptance C + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + DOLT_VERSION: "1.86.1" + BD_VERSION: "v1.0.0" + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_API_KEY: "" + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL }} + CLAUDE_CODE_EFFORT_LEVEL: auto + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" + +jobs: + acceptance-c: + name: Acceptance C via Ollama Cloud + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 120 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-gascity-ubuntu + with: + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "true" + - name: Validate Ollama Claude configuration + run: | + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "ANTHROPIC_DEFAULT_HAIKU_MODEL resolved empty" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "ANTHROPIC_DEFAULT_SONNET_MODEL resolved empty" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "ANTHROPIC_DEFAULT_OPUS_MODEL resolved empty" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "CLAUDE_CODE_SUBAGENT_MODEL resolved empty" >&2; exit 1; } + - name: Verify 6 concurrent Claude harness requests + run: | + set -euo pipefail + pids=() + for i in 1 2 3 4 5 6; do + claude --model "$CLAUDE_CODE_SUBAGENT_MODEL" --print "Reply exactly ok-$i" >"$RUNNER_TEMP/ollama-concurrency-$i.out" 2>"$RUNNER_TEMP/ollama-concurrency-$i.err" & + pids+=("$!") + done + failed=0 + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + failed=1 + fi + done + if [ "$failed" -ne 0 ]; then + for i in 1 2 3 4 5 6; do + echo "=== request $i stderr ===" >&2 + cat "$RUNNER_TEMP/ollama-concurrency-$i.err" >&2 || true + done + exit 1 + fi + for i in 1 2 3 4 5 6; do + if ! grep -Fxq "ok-$i" "$RUNNER_TEMP/ollama-concurrency-$i.out"; then + echo "request $i did not return ok-$i" >&2 + echo "=== request $i stdout ===" >&2 + cat "$RUNNER_TEMP/ollama-concurrency-$i.out" >&2 || true + echo "=== request $i stderr ===" >&2 + cat "$RUNNER_TEMP/ollama-concurrency-$i.err" >&2 || true + exit 1 + fi + done + - name: Run make test-acceptance-c + run: make test-acceptance-c diff --git a/.github/workflows/rc-gate.yml b/.github/workflows/rc-gate.yml index a797a6dfac..4262c44a40 100644 --- a/.github/workflows/rc-gate.yml +++ b/.github/workflows/rc-gate.yml @@ -7,8 +7,8 @@ permissions: contents: read env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" jobs: # Reuse the shared CI graph so RC inherits new parity checks automatically. @@ -17,12 +17,46 @@ jobs: permissions: contents: read uses: ./.github/workflows/ci.yml + with: + force_blacksmith: true secrets: inherit - ubuntu_make_test: - name: ubuntu / make test - runs-on: ubuntu-latest - timeout-minutes: 45 + ubuntu_fast_tests: + name: ubuntu / fast tests / ${{ matrix.label }} + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - label: fsys-darwin-compile + timeout_minutes: 10 + command: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + GOOS=darwin GOARCH=arm64 go test -c -o "$tmp/fsys.test" ./internal/fsys + - label: unit-core + timeout_minutes: 20 + command: | + GC_FAST_UNIT=1 go test -timeout 8m $(go list ./... | grep -v '^github.com/gastownhall/gascity/cmd/gc$') + - label: cmd-gc-1-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 1 6 + - label: cmd-gc-2-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 2 6 + - label: cmd-gc-3-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 3 6 + - label: cmd-gc-4-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 4 6 + - label: cmd-gc-5-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 5 6 + - label: cmd-gc-6-of-6 + timeout_minutes: 20 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 6 6 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-gascity-ubuntu @@ -30,12 +64,12 @@ jobs: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} install-claude-cli: "false" - - name: Run make test - run: make test + - name: Run fast test shard + run: ${{ matrix.command }} ubuntu_make_check_docs: name: ubuntu / make check-docs - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -47,17 +81,51 @@ jobs: - name: Run make check-docs run: make check-docs - ubuntu_make_test_acceptance: - name: ubuntu / make test-acceptance - runs-on: ubuntu-latest - timeout-minutes: 30 + ubuntu_acceptance_a: + name: ubuntu / acceptance A / ${{ matrix.label }} + needs: ci_parity + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + max-parallel: 2 + matrix: + include: + - label: root-1-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 1 8 + - label: root-2-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 2 8 + - label: root-3-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 3 8 + - label: root-4-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 4 8 + - label: root-5-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 5 8 + - label: root-6-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 6 8 + - label: root-7-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 7 8 + - label: root-8-of-8 + timeout_minutes: 15 + command: GO_TEST_TAGS=acceptance_a GO_TEST_TIMEOUT=8m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance 8 8 + - label: helpers + timeout_minutes: 10 + command: go test -tags acceptance_a -timeout 8m ./test/acceptance/helpers env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: @@ -67,20 +135,24 @@ jobs: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} install-claude-cli: "true" - - name: Validate synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run make test-acceptance - run: make test-acceptance + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL GitHub variable" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } + - name: Run acceptance A shard + run: ${{ matrix.command }} - ubuntu_make_test_acceptance_b: - name: ubuntu / make test-acceptance-b - runs-on: ubuntu-latest - timeout-minutes: 45 + ubuntu_acceptance_b: + name: ubuntu / acceptance B / ${{ matrix.shard_index }} of 3 + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard_index: [1, 2, 3] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-gascity-ubuntu @@ -88,79 +160,27 @@ jobs: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} install-claude-cli: "false" - - name: Run make test-acceptance-b - run: make test-acceptance-b - - ubuntu_make_test_acceptance_c: - name: ubuntu / make test-acceptance-c - runs-on: ubuntu-latest - timeout-minutes: 120 - env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_EFFORT_LEVEL: auto - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: ./.github/actions/setup-gascity-ubuntu - with: - dolt-version: ${{ env.DOLT_VERSION }} - bd-version: ${{ env.BD_VERSION }} - - name: Validate synthetic Claude configuration - run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run make test-acceptance-c - run: make test-acceptance-c + - name: Run acceptance B shard + run: GO_TEST_TAGS=acceptance_b GO_TEST_TIMEOUT=10m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance/tier_b ${{ matrix.shard_index }} 3 - ubuntu_test_integration_packages: - name: ubuntu / integration packages - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_EFFORT_LEVEL: auto - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: ./.github/actions/setup-gascity-ubuntu - with: - dolt-version: ${{ env.DOLT_VERSION }} - bd-version: ${{ env.BD_VERSION }} - install-claude-cli: "true" - - name: Validate synthetic Claude configuration - run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run integration packages shard - run: make test-integration-packages - - ubuntu_test_integration_review_formulas: - name: ubuntu / integration review-formulas - runs-on: ubuntu-latest + ubuntu_acceptance_c: + name: ubuntu / acceptance C / ${{ matrix.shard_index }} of 5 + needs: ubuntu_acceptance_a + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 60 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + shard_index: [1, 2, 3, 4, 5] env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: @@ -169,28 +189,121 @@ jobs: with: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} - install-claude-cli: "true" - - name: Validate synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run integration review-formulas shard - run: make test-integration-review-formulas + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL GitHub variable" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } + - name: Run acceptance C shard + run: GO_TEST_TAGS=acceptance_c GO_TEST_TIMEOUT=45m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance/tier_c ${{ matrix.shard_index }} 5 - ubuntu_test_integration_bdstore: - name: ubuntu / integration bdstore - runs-on: ubuntu-latest - timeout-minutes: 20 + ubuntu_integration_shards: + name: ubuntu / integration / ${{ matrix.shard_name }} + needs: ubuntu_acceptance_c + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + - shard_name: packages-core-1-of-4 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-core-1-of-4 + - shard_name: packages-core-2-of-4 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-core-2-of-4 + - shard_name: packages-core-3-of-4 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-core-3-of-4 + - shard_name: packages-core-4-of-4 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-core-4-of-4 + - shard_name: packages-cmd-gc-1-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-1-of-6 + - shard_name: packages-cmd-gc-2-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-2-of-6 + - shard_name: packages-cmd-gc-3-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-3-of-6 + - shard_name: packages-cmd-gc-4-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-4-of-6 + - shard_name: packages-cmd-gc-5-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-5-of-6 + - shard_name: packages-cmd-gc-6-of-6 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-cmd-gc-6-of-6 + - shard_name: packages-runtime-tmux-1-of-3 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-runtime-tmux-1-of-3 + - shard_name: packages-runtime-tmux-2-of-3 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-runtime-tmux-2-of-3 + - shard_name: packages-runtime-tmux-3-of-3 + timeout_minutes: 20 + command: ./scripts/test-integration-shard packages-runtime-tmux-3-of-3 + - shard_name: review-formulas-basic-1-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard review-formulas-basic-1-of-2 + - shard_name: review-formulas-basic-2-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard review-formulas-basic-2-of-2 + - shard_name: review-formulas-retries-1-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard review-formulas-retries-1-of-2 + - shard_name: review-formulas-retries-2-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard review-formulas-retries-2-of-2 + - shard_name: review-formulas-recovery + timeout_minutes: 25 + command: make test-integration-review-formulas-recovery + - shard_name: bdstore + timeout_minutes: 15 + command: make test-integration-bdstore + - shard_name: rest-smoke-1-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-smoke-1-of-2 + - shard_name: rest-smoke-2-of-2 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-smoke-2-of-2 + - shard_name: rest-full-1-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-1-of-8 + - shard_name: rest-full-2-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-2-of-8 + - shard_name: rest-full-3-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-3-of-8 + - shard_name: rest-full-4-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-4-of-8 + - shard_name: rest-full-5-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-5-of-8 + - shard_name: rest-full-6-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-6-of-8 + - shard_name: rest-full-7-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-7-of-8 + - shard_name: rest-full-8-of-8 + timeout_minutes: 20 + command: ./scripts/test-integration-shard rest-full-8-of-8 env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: @@ -200,57 +313,34 @@ jobs: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} install-claude-cli: "true" - - name: Validate synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run integration bdstore shard - run: make test-integration-bdstore + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL GitHub variable" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } + - name: Run integration shard + run: ${{ matrix.command }} - ubuntu_test_integration_rest: - name: ubuntu / integration rest - runs-on: ubuntu-latest - timeout-minutes: 45 + ubuntu_tutorial: + name: ubuntu / tutorial goldens / ${{ matrix.shard_index }} of 6 + needs: ubuntu_integration_shards + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 110 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + shard_index: [1, 2, 3, 4, 5, 6] env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_EFFORT_LEVEL: auto - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: ./.github/actions/setup-gascity-ubuntu - with: - dolt-version: ${{ env.DOLT_VERSION }} - bd-version: ${{ env.BD_VERSION }} - install-claude-cli: "true" - - name: Validate synthetic Claude configuration - run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run integration rest shard - run: make test-integration-rest - - ubuntu_make_test_tutorial: - name: ubuntu / make test-tutorial - runs-on: ubuntu-latest - timeout-minutes: 180 - env: - ANTHROPIC_BASE_URL: https://api.synthetic.new/anthropic - ANTHROPIC_AUTH_TOKEN: ${{ secrets.SYNTHETIC_API_KEY }} - ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} - CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL }} + ANTHROPIC_BASE_URL: https://ollama.com + ANTHROPIC_AUTH_TOKEN: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} + CLAUDE_CODE_SUBAGENT_MODEL: ${{ vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL || vars.GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL }} CLAUDE_CODE_EFFORT_LEVEL: auto CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" GC_TUTORIAL_GOLDENS_USE_CLAUDE_FOR_CODEX: "1" @@ -260,19 +350,19 @@ jobs: with: dolt-version: ${{ env.DOLT_VERSION }} bd-version: ${{ env.BD_VERSION }} - - name: Validate synthetic Claude configuration + - name: Validate Ollama Claude configuration run: | - test -n "$ANTHROPIC_AUTH_TOKEN" || { echo "Missing SYNTHETIC_API_KEY GitHub secret" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_HAIKU_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SONNET_MODEL GitHub variable" >&2; exit 1; } - test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_OPUS_MODEL GitHub variable" >&2; exit 1; } - test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_MODEL or GC_WORKER_INFERENCE_CLAUDE_SYNTHETIC_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } - - name: Run make test-tutorial - run: make test-tutorial + test -n "$OLLAMA_API_KEY" || { echo "Missing OLLAMA_API_KEY GitHub secret" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_HAIKU_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_HAIKU_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_SONNET_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SONNET_MODEL GitHub variable" >&2; exit 1; } + test -n "$ANTHROPIC_DEFAULT_OPUS_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_OPUS_MODEL GitHub variable" >&2; exit 1; } + test -n "$CLAUDE_CODE_SUBAGENT_MODEL" || { echo "Missing GC_WORKER_INFERENCE_CLAUDE_OLLAMA_MODEL or GC_WORKER_INFERENCE_CLAUDE_OLLAMA_SUBAGENT_MODEL GitHub variable" >&2; exit 1; } + - name: Run tutorial golden shard + run: GO_TEST_TAGS=acceptance_c GO_TEST_TIMEOUT=90m GO_TEST_COUNT=1 ./scripts/test-go-test-shard ./test/acceptance/tutorial_goldens ${{ matrix.shard_index }} 6 ubuntu_goreleaser_snapshot: name: ubuntu / goreleaser snapshot - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -284,7 +374,7 @@ jobs: bd-version: ${{ env.BD_VERSION }} install-claude-cli: "false" - name: Run GoReleaser snapshot - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: version: "~> v2" args: release --snapshot --clean @@ -295,53 +385,67 @@ jobs: path: dist/** if-no-files-found: error - macos_make_test: - name: macOS / make test - runs-on: - - self-hosted - - macOS - - ARM64 - - macstadium - timeout-minutes: 45 + macos_fast_tests: + name: macOS / fast tests / ${{ matrix.label }} + runs-on: blacksmith-12vcpu-macos-15 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - label: fsys-darwin-compile + timeout_minutes: 10 + command: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + GOOS=darwin GOARCH=arm64 go test -c -o "$tmp/fsys.test" ./internal/fsys + - label: unit-core + timeout_minutes: 30 + command: | + GC_FAST_UNIT=1 go test -timeout 20m $(go list ./... | grep -v '^github.com/gastownhall/gascity/cmd/gc$') + - label: cmd-gc-1-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 1 6 + - label: cmd-gc-2-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 2 6 + - label: cmd-gc-3-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 3 6 + - label: cmd-gc-4-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 4 6 + - label: cmd-gc-5-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 5 6 + - label: cmd-gc-6-of-6 + timeout_minutes: 30 + command: GC_FAST_UNIT=1 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 6 6 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: ./.github/actions/setup-gascity-macos with: - # The mac runner still needs Go for `make test`, but not for building bd. - cache: false - go-version: "1.25.8" - - name: Install released bd - run: | - BD_MAC_RELEASE_TARBALL="beads_${BD_VERSION#v}_darwin_arm64.tar.gz" - mkdir -p "$HOME/.local/bin" - mkdir -p "$RUNNER_TEMP/beads" - curl -fsSL -o "$RUNNER_TEMP/$BD_MAC_RELEASE_TARBALL" \ - "https://github.com/gastownhall/beads/releases/download/${BD_VERSION}/${BD_MAC_RELEASE_TARBALL}" - tar -xzf "$RUNNER_TEMP/$BD_MAC_RELEASE_TARBALL" -C "$RUNNER_TEMP/beads" --strip-components=1 - install -m 0755 "$RUNNER_TEMP/beads/bd" "$HOME/.local/bin/bd" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - "$HOME/.local/bin/bd" version - - name: Run make test - run: make test + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "false" + - name: Run macOS fast test shard + run: ${{ matrix.command }} rc_summary: name: RC summary if: ${{ always() }} - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 needs: - ci_parity - - ubuntu_make_test + - ubuntu_fast_tests - ubuntu_make_check_docs - - ubuntu_make_test_acceptance - - ubuntu_make_test_acceptance_b - - ubuntu_make_test_acceptance_c - - ubuntu_test_integration_packages - - ubuntu_test_integration_review_formulas - - ubuntu_test_integration_bdstore - - ubuntu_test_integration_rest - - ubuntu_make_test_tutorial + - ubuntu_acceptance_a + - ubuntu_acceptance_b + - ubuntu_acceptance_c + - ubuntu_integration_shards + - ubuntu_tutorial - ubuntu_goreleaser_snapshot - - macos_make_test + - macos_fast_tests env: NEEDS_JSON: ${{ toJSON(needs) }} steps: @@ -352,27 +456,27 @@ jobs: import os import sys - needs = json.loads(os.environ['NEEDS_JSON']) - summary_path = os.environ['GITHUB_STEP_SUMMARY'] + needs = json.loads(os.environ["NEEDS_JSON"]) + summary_path = os.environ["GITHUB_STEP_SUMMARY"] lines = [ - '## RC Gate', - '', - 'This workflow is the manual pre-RC gate. It calls the reusable CI workflow plus RC-only release jobs for the dispatched ref.', - '', - 'The `ci_parity` entry is the aggregate result of the shared CI workflow. Inspect the nested CI jobs in this run for per-check detail.', - '', - 'Jobs that show `skipped` were intentionally gated off by the same conditional logic used in CI.', - '', - '| Job | Result |', - '| --- | --- |', + "## RC Gate", + "", + "This workflow is the manual pre-RC gate. It calls the reusable CI workflow plus RC-only release jobs for the dispatched ref.", + "", + "The `ci_parity` entry is the aggregate result of the shared CI workflow. Inspect the nested CI jobs in this run for per-check detail.", + "", + "Jobs that show `skipped` were intentionally gated off by the same conditional logic used in CI.", + "", + "| Job | Result |", + "| --- | --- |", ] fail = False for job_id, meta in needs.items(): - result = meta.get('result', 'unknown') - lines.append(f'| {job_id} | {result} |') - if result not in {'success', 'skipped'}: + result = meta.get("result", "unknown") + lines.append(f"| {job_id} | {result} |") + if result not in {"success", "skipped"}: fail = True - with open(summary_path, 'a', encoding='utf-8') as handle: - handle.write('\\n'.join(lines) + '\\n') + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") sys.exit(1 if fail else 0) PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0c73e93e3..c992d6341a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,20 +12,21 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false -permissions: - contents: write +permissions: {} jobs: release: name: Release if: ${{ github.repository == 'gastownhall/gascity' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -42,7 +43,7 @@ jobs: run: make check-version-tag - name: Run GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: version: "~> v2" args: > @@ -52,14 +53,66 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + attest-release: + name: Attest release + if: ${{ github.repository == 'gastownhall/gascity' && startsWith(github.ref, 'refs/tags/v') }} + needs: release + runs-on: ubuntu-latest + permissions: + attestations: write + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Resolve release asset paths + id: assets + run: | + version="${GITHUB_REF_NAME#v}" + mkdir -p dist + echo "checksums=dist/gascity_${version}_checksums.txt" >> "$GITHUB_OUTPUT" + echo "sbom=dist/gascity-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_OUTPUT" + + - name: Download release checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version="${GITHUB_REF_NAME#v}" + gh release download "${GITHUB_REF_NAME}" --pattern "gascity_${version}_checksums.txt" --dir dist + + - name: Generate release SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + path: . + format: spdx-json + output-file: ${{ steps.assets.outputs.sbom }} + upload-artifact: false + upload-release-assets: false + + - name: Upload release SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${GITHUB_REF_NAME}" "${{ steps.assets.outputs.sbom }}" --clobber + + - name: Attest release artifacts + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 + with: + subject-checksums: ${{ steps.assets.outputs.checksums }} + + - name: Attest release SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 + with: + subject-checksums: ${{ steps.assets.outputs.checksums }} + sbom-path: ${{ steps.assets.outputs.sbom }} + update-homebrew-formula: name: Update Homebrew formula if: ${{ github.repository == 'gastownhall/gascity' && startsWith(github.ref, 'refs/tags/v') }} - needs: release + needs: [release, attest-release] runs-on: ubuntu-latest - env: - HAS_HOMEBREW_APP: ${{ secrets.HOMEBREW_TAP_APP_ID != '' && secrets.HOMEBREW_TAP_APP_PRIVATE_KEY != '' }} - HAS_HOMEBREW_PAT: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -68,10 +121,19 @@ jobs: id: version run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - name: Verify Homebrew tap app credentials + env: + HOMEBREW_TAP_APP_ID: ${{ secrets.HOMEBREW_TAP_APP_ID }} + HOMEBREW_TAP_APP_PRIVATE_KEY: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} + run: | + if [ -z "$HOMEBREW_TAP_APP_ID" ] || [ -z "$HOMEBREW_TAP_APP_PRIVATE_KEY" ]; then + echo "ERROR: HOMEBREW_TAP_APP_ID and HOMEBREW_TAP_APP_PRIVATE_KEY are required for tap publishing." >&2 + exit 1 + fi + - name: Mint Homebrew tap token id: homebrew-token - if: ${{ env.HAS_HOMEBREW_APP == 'true' }} - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 with: app-id: ${{ secrets.HOMEBREW_TAP_APP_ID }} private-key: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} @@ -80,10 +142,9 @@ jobs: permission-contents: write - name: Generate and push Homebrew formula - if: ${{ env.HAS_HOMEBREW_APP == 'true' || env.HAS_HOMEBREW_PAT == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_TOKEN: ${{ steps.homebrew-token.outputs.token || secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ steps.homebrew-token.outputs.token }} run: | version="${{ steps.version.outputs.version }}" tag="v${version}" @@ -180,7 +241,3 @@ jobs: git add Formula/gascity.rb git commit -m "gascity ${version}" || echo "No changes to commit" git push - - - name: Skip Homebrew formula update - if: ${{ env.HAS_HOMEBREW_APP != 'true' && env.HAS_HOMEBREW_PAT != 'true' }} - run: echo "No Homebrew tap credential configured; skipping tap update." diff --git a/.github/workflows/remove-needs-info.yml b/.github/workflows/remove-needs-info.yml index 9d6654001a..58233e7781 100644 --- a/.github/workflows/remove-needs-info.yml +++ b/.github/workflows/remove-needs-info.yml @@ -6,7 +6,11 @@ on: pull_request_target: types: [synchronize] +permissions: {} + jobs: + # pull_request_target is safe here because this job never checks out or runs + # pull request code; it only removes labels from the issue/PR metadata. remove-label: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/remove-needs-triage.yml b/.github/workflows/remove-needs-triage.yml index f76044e017..189c61ae09 100644 --- a/.github/workflows/remove-needs-triage.yml +++ b/.github/workflows/remove-needs-triage.yml @@ -6,7 +6,11 @@ on: pull_request_target: types: [labeled] +permissions: {} + jobs: + # pull_request_target is safe here because this job never checks out or runs + # pull request code; it only removes labels from the issue/PR metadata. remove-triage-label: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/review-formulas.yml b/.github/workflows/review-formulas.yml index 4969a6bd25..e5445aaca8 100644 --- a/.github/workflows/review-formulas.yml +++ b/.github/workflows/review-formulas.yml @@ -11,34 +11,72 @@ on: - reopened - synchronize - ready_for_review - - labeled workflow_dispatch: + inputs: + pr_number: + description: Pull request number for label-dispatched runs + required: false + type: string + head_repo: + description: Pull request head repository for label-dispatched runs + required: false + type: string + head_sha: + description: Pull request head SHA for label-dispatched runs + required: false + type: string + head_ref: + description: Pull request head ref for label-dispatched runs + required: false + type: string permissions: contents: read concurrency: - group: review-formulas-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + group: review-formulas-${{ github.event_name }}-${{ inputs.pr_number || github.event.pull_request.number || github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - DOLT_VERSION: "1.86.1" - BD_VERSION: "v1.0.0" + DOLT_VERSION: "1.86.6" + BD_VERSION: "v1.0.3" jobs: + runner-policy: + name: Runner policy + runs-on: ${{ github.event_name == 'pull_request' && contains(fromJSON('["julianknutsen","csells","sjarmak","quad341"]'), github.event.pull_request.user.login) && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + outputs: + use_blacksmith: ${{ steps.policy.outputs.use_blacksmith }} + reason: ${{ steps.policy.outputs.reason }} + runner_2vcpu: ${{ steps.policy.outputs.runner_2vcpu }} + runner_8vcpu: ${{ steps.policy.outputs.runner_8vcpu }} + runner_16vcpu: ${{ steps.policy.outputs.runner_16vcpu }} + runner_32vcpu: ${{ steps.policy.outputs.runner_32vcpu }} + runner_macos: ${{ steps.policy.outputs.runner_macos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Select runner backend + id: policy + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + python3 .github/workflows/scripts/runner_policy.py + gate: name: review-formulas routing - if: >- - github.event_name != 'pull_request' || - github.event.action != 'labeled' || - github.event.label.name == 'needs-review-formulas' - runs-on: ubuntu-latest + needs: runner-policy + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} outputs: run_shard: ${{ steps.gate.outputs.run_shard }} reason: ${{ steps.gate.outputs.reason }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 id: filter with: filters: | @@ -62,8 +100,6 @@ jobs: id: gate env: EVENT_NAME: ${{ github.event_name }} - EVENT_ACTION: ${{ github.event.action }} - LABELED_NAME: ${{ github.event.label.name }} PR_DRAFT: ${{ github.event.pull_request.draft }} PATH_HIT: ${{ steps.filter.outputs.review_formulas }} NEEDS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-review-formulas') }} @@ -78,9 +114,7 @@ jobs: run_shard=true reason="push to main safety net" elif [[ "$PR_DRAFT" != "true" ]]; then - if [[ "$EVENT_ACTION" == "labeled" && "$LABELED_NAME" != "needs-review-formulas" ]]; then - reason="ignored unrelated label event" - elif [[ "$PATH_HIT" == "true" || "$NEEDS_LABEL" == "true" ]]; then + if [[ "$PATH_HIT" == "true" || "$NEEDS_LABEL" == "true" ]]; then run_shard=true reason="pull request path/label match" else @@ -100,25 +134,31 @@ jobs: review-formulas-shard: name: Integration / review-formulas (${{ matrix.label }}) - needs: gate + needs: + - runner-policy + - gate if: needs.gate.outputs.run_shard == 'true' - runs-on: ubuntu-latest + runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }} timeout-minutes: 30 strategy: fail-fast: false matrix: include: - - shard: review-formulas-basic - label: basic + - label: basic + command: make test-integration-review-formulas-basic-cover coverprofile: coverage.integration-review-formulas-basic.txt - - shard: review-formulas-retries - label: retries + - label: retries + command: make test-integration-review-formulas-retries-cover coverprofile: coverage.integration-review-formulas-retries.txt - - shard: review-formulas-recovery - label: recovery + - label: recovery + command: make test-integration-review-formulas-recovery-cover coverprofile: coverage.integration-review-formulas-recovery.txt steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.head_repo || github.repository }} + ref: ${{ inputs.head_sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-gascity-ubuntu with: dolt-version: ${{ env.DOLT_VERSION }} @@ -128,7 +168,7 @@ jobs: run: make install-tools - name: Run review-formulas shard id: shard - run: GO_TEST_COVERPROFILE=${{ matrix.coverprofile }} ./scripts/test-integration-shard ${{ matrix.shard }} + run: ${{ matrix.command }} - name: Inspect shard coverage profile if: steps.shard.outcome == 'success' run: | @@ -145,7 +185,7 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository ) - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: ${{ matrix.coverprofile }} flags: integration-review-formulas @@ -159,16 +199,11 @@ jobs: # explicit while the shards run in parallel underneath it. name: Integration / review-formulas needs: + - runner-policy - gate - review-formulas-shard - if: >- - always() && - ( - github.event_name != 'pull_request' || - github.event.action != 'labeled' || - github.event.label.name == 'needs-review-formulas' - ) - runs-on: ubuntu-latest + if: always() + runs-on: ${{ needs.runner-policy.outputs.runner_2vcpu }} steps: - name: Finalize review-formulas result env: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000000..6205100e95 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,48 @@ +name: OpenSSF Scorecard + +on: + workflow_dispatch: + schedule: + - cron: "37 5 * * *" + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + continue-on-error: true + permissions: + contents: read + security-events: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + continue-on-error: true + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF results + if: ${{ hashFiles('scorecard.sarif') != '' }} + continue-on-error: true + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: scorecard.sarif + + - name: Upload SARIF artifact + if: ${{ hashFiles('scorecard.sarif') != '' }} + continue-on-error: true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: openssf-scorecard-sarif + path: scorecard.sarif + retention-days: 5 diff --git a/.github/workflows/scripts/runner_policy.py b/.github/workflows/scripts/runner_policy.py new file mode 100644 index 0000000000..0afaaaec2d --- /dev/null +++ b/.github/workflows/scripts/runner_policy.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Select GitHub Actions runners for Gas City workflows.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +ALLOWLIST_PATH = Path(".github/blacksmith-allowlist.txt") + +BLACKSMITH_RUNNERS = { + "runner_2vcpu": "blacksmith-2vcpu-ubuntu-2404", + "runner_8vcpu": "blacksmith-8vcpu-ubuntu-2404", + "runner_16vcpu": "blacksmith-16vcpu-ubuntu-2404", + "runner_32vcpu": "blacksmith-32vcpu-ubuntu-2404", + "runner_macos": "blacksmith-12vcpu-macos-15", +} + +GITHUB_RUNNERS = { + "runner_2vcpu": "ubuntu-latest", + "runner_8vcpu": "ubuntu-latest", + "runner_16vcpu": "ubuntu-latest", + "runner_32vcpu": "ubuntu-latest", + "runner_macos": "macos-15", +} + + +def load_allowlist(path: Path = ALLOWLIST_PATH) -> set[str]: + """Load the Blacksmith pull request author allowlist.""" + allowlist: set[str] = set() + if not path.exists(): + return allowlist + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if line: + allowlist.add(line.lower()) + return allowlist + + +def select_runners( + event_name: str, + author: str, + allowlist: set[str], + *, + force_blacksmith: bool = False, +) -> tuple[bool, str, dict[str, str]]: + """Return whether to use Blacksmith, the reason, and runner labels.""" + normalized_event = event_name.strip() + normalized_author = author.strip() + if force_blacksmith: + return True, "Blacksmith forced by workflow input", BLACKSMITH_RUNNERS + if normalized_event == "pull_request" and normalized_author.lower() in allowlist: + return True, "pull request author is in .github/blacksmith-allowlist.txt", BLACKSMITH_RUNNERS + if normalized_event != "pull_request": + return ( + False, + f"Blacksmith is limited to approved pull requests; using GitHub-hosted runners for {normalized_event or ''}", + GITHUB_RUNNERS, + ) + return ( + False, + f"author {normalized_author or ''} is not on the Blacksmith allowlist; using GitHub-hosted runners", + GITHUB_RUNNERS, + ) + + +def append_outputs(use_blacksmith: bool, reason: str, runners: dict[str, str]) -> None: + """Append selected policy fields to GITHUB_OUTPUT.""" + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"use_blacksmith={str(use_blacksmith).lower()}\n") + output.write(f"reason={reason}\n") + for name, runner in runners.items(): + output.write(f"{name}={runner}\n") + + +def append_summary(use_blacksmith: bool, reason: str, event_name: str, author: str) -> None: + """Append a human-readable runner policy summary.""" + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + backend = "Blacksmith" if use_blacksmith else "GitHub-hosted" + with open(summary_path, "a", encoding="utf-8") as summary: + summary.write("## Runner policy\n\n") + summary.write(f"- backend: `{backend}`\n") + summary.write(f"- use_blacksmith: `{str(use_blacksmith).lower()}`\n") + summary.write(f"- reason: {reason}\n") + if event_name == "pull_request": + summary.write(f"- author: `{author or ''}`\n") + + +def main() -> None: + event_name = os.environ["EVENT_NAME"] + author = os.environ.get("PR_AUTHOR", "").strip() + force_blacksmith = os.environ.get("FORCE_BLACKSMITH", "").strip().lower() == "true" + use_blacksmith, reason, runners = select_runners( + event_name, + author, + load_allowlist(), + force_blacksmith=force_blacksmith, + ) + append_outputs(use_blacksmith, reason, runners) + append_summary(use_blacksmith, reason, event_name, author) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/test_rc_gate_policy.py b/.github/workflows/scripts/test_rc_gate_policy.py new file mode 100644 index 0000000000..c64a87b11e --- /dev/null +++ b/.github/workflows/scripts/test_rc_gate_policy.py @@ -0,0 +1,42 @@ +from pathlib import Path +import unittest + + +WORKFLOW = Path(__file__).resolve().parents[1] / "rc-gate.yml" + + +def _job_block(workflow: str, job_name: str) -> str: + marker = f" {job_name}:\n" + start = workflow.index(marker) + lines = workflow[start:].splitlines(keepends=True) + block = [lines[0]] + for line in lines[1:]: + if line.startswith(" ") and not line.startswith(" ") and line.strip().endswith(":"): + break + block.append(line) + return "".join(block) + + +class RCGatePolicyTests(unittest.TestCase): + def test_real_inference_jobs_are_throttled_after_ci_parity(self) -> None: + workflow = WORKFLOW.read_text() + + acceptance_a = _job_block(workflow, "ubuntu_acceptance_a") + self.assertIn("needs: ci_parity", acceptance_a) + self.assertIn("max-parallel: 2", acceptance_a) + + acceptance_c = _job_block(workflow, "ubuntu_acceptance_c") + self.assertIn("needs: ubuntu_acceptance_a", acceptance_c) + self.assertIn("max-parallel: 1", acceptance_c) + + integration = _job_block(workflow, "ubuntu_integration_shards") + self.assertIn("needs: ubuntu_acceptance_c", integration) + self.assertIn("max-parallel: 4", integration) + + tutorial = _job_block(workflow, "ubuntu_tutorial") + self.assertIn("needs: ubuntu_integration_shards", tutorial) + self.assertIn("max-parallel: 1", tutorial) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/scripts/test_runner_policy.py b/.github/workflows/scripts/test_runner_policy.py new file mode 100644 index 0000000000..4499179195 --- /dev/null +++ b/.github/workflows/scripts/test_runner_policy.py @@ -0,0 +1,73 @@ +import tempfile +import unittest +from pathlib import Path + +import runner_policy + + +class RunnerPolicyTests(unittest.TestCase): + def test_load_allowlist_ignores_comments_and_case_normalizes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "allowlist.txt" + path.write_text( + "julianknutsen\n" + " Csells # maintainer\n" + "\n" + "# comment\n", + encoding="utf-8", + ) + + self.assertEqual(runner_policy.load_allowlist(path), {"julianknutsen", "csells"}) + + def test_pull_request_from_allowlisted_author_uses_blacksmith(self) -> None: + use_blacksmith, reason, runners = runner_policy.select_runners( + "pull_request", + "Quad341", + {"quad341"}, + ) + + self.assertTrue(use_blacksmith) + self.assertIn("allowlist", reason) + self.assertEqual(runners["runner_32vcpu"], "blacksmith-32vcpu-ubuntu-2404") + self.assertEqual(runners["runner_macos"], "blacksmith-12vcpu-macos-15") + + def test_push_uses_github_even_for_allowlisted_author(self) -> None: + use_blacksmith, reason, runners = runner_policy.select_runners( + "push", + "julianknutsen", + {"julianknutsen"}, + force_blacksmith=False, + ) + + self.assertFalse(use_blacksmith) + self.assertIn("approved pull requests", reason) + self.assertEqual(runners["runner_32vcpu"], "ubuntu-latest") + + def test_forced_workflow_call_uses_blacksmith(self) -> None: + use_blacksmith, reason, runners = runner_policy.select_runners( + "workflow_call", + "", + set(), + force_blacksmith=True, + ) + + self.assertTrue(use_blacksmith) + self.assertIn("forced", reason) + self.assertEqual(runners["runner_16vcpu"], "blacksmith-16vcpu-ubuntu-2404") + self.assertEqual(runners["runner_macos"], "blacksmith-12vcpu-macos-15") + + def test_unlisted_pull_request_author_uses_github(self) -> None: + use_blacksmith, reason, runners = runner_policy.select_runners( + "pull_request", + "external-contributor", + {"julianknutsen"}, + force_blacksmith=False, + ) + + self.assertFalse(use_blacksmith) + self.assertIn("not on the Blacksmith allowlist", reason) + self.assertEqual(runners["runner_macos"], "macos-15") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/scripts/test_worker_report_artifacts.py b/.github/workflows/scripts/test_worker_report_artifacts.py index f4897ed781..b67bdae630 100644 --- a/.github/workflows/scripts/test_worker_report_artifacts.py +++ b/.github/workflows/scripts/test_worker_report_artifacts.py @@ -1,6 +1,5 @@ import json import os -import subprocess import sys import tempfile import unittest @@ -127,43 +126,6 @@ def test_summary_prints_top_evidence_and_hooks(self) -> None: self.assertIn("planned hooks", content) self.assertIn("transcript_path=/tmp/transcript.jsonl", content) - def test_manual_only_script_emits_unsupported_phase3_report(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - report_dir = Path(tmp) / "reports" - env = os.environ.copy() - env["PROFILE"] = "codex/tmux-cli" - - subprocess.run( - [ - sys.executable, - str(SCRIPT_DIR / "worker_report_manual_only.py"), - str(report_dir), - "worker-inference-phase3", - ], - check=True, - env=env, - ) - - reports = sorted(report_dir.glob("*.json")) - self.assertEqual(len(reports), 1) - payload = json.loads(reports[0].read_text(encoding="utf-8")) - self.assertEqual(payload["summary"]["status"], "unsupported") - self.assertEqual(payload["metadata"]["profile_filter"], "codex/tmux-cli") - self.assertTrue(payload["metadata"]["manual_only"]) - self.assertEqual(payload["summary"]["unsupported"], 6) - self.assertEqual( - [result["requirement"] for result in payload["results"]], - [ - "WI-START-001", - "WI-TOOL-001", - "WI-MTURN-001", - "WI-CONT-001", - "WI-RESET-001", - "WI-INT-001", - ], - ) - self.assertTrue(all(result["status"] == "unsupported" for result in payload["results"])) - def test_rollup_builds_baseline_delta_and_hooks(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/.github/workflows/scripts/worker_report_manual_only.py b/.github/workflows/scripts/worker_report_manual_only.py deleted file mode 100644 index b45b688d7b..0000000000 --- a/.github/workflows/scripts/worker_report_manual_only.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import sys - - -SCHEMA_VERSION = "gc.worker.conformance.v1" - -CATALOG_BY_SUITE = { - "worker-inference-phase3": [ - "WI-START-001", - "WI-TOOL-001", - "WI-MTURN-001", - "WI-CONT-001", - "WI-RESET-001", - "WI-INT-001", - ], -} - - -def main() -> int: - if len(sys.argv) != 3: - print( - "usage: worker_report_manual_only.py ", - file=sys.stderr, - ) - return 2 - - report_dir = sys.argv[1] - suite = sys.argv[2].strip() - requirements = CATALOG_BY_SUITE.get(suite) - if not requirements: - print(f"unsupported manual-only suite: {suite!r}", file=sys.stderr) - return 2 - - profile = os.environ.get("PROFILE", "").strip() or "all-profiles" - os.makedirs(report_dir, exist_ok=True) - - results = [ - { - "profile": profile, - "requirement": requirement, - "status": "unsupported", - "detail": "manual-only live inference is disabled in PR CI", - } - for requirement in requirements - ] - payload = { - "schema_version": SCHEMA_VERSION, - "run_id": f"{sanitize(suite)}-{sanitize(profile)}-manual-only", - "suite": suite, - "metadata": { - "profile_filter": profile, - "suite": suite, - "manual_only": True, - "synthetic": "true", - }, - "summary": { - "status": "unsupported", - "total": len(results), - "passed": 0, - "failed": 0, - "unsupported": len(results), - "environment_errors": 0, - "provider_incidents": 0, - "flaky_live": 0, - "not_certifiable_live": 0, - "suite_failed": False, - "profiles": 1, - "requirements": len(results), - "failing_requirements": [], - "top_evidence": [], - }, - "results": results, - } - out_path = os.path.join( - report_dir, - f"{sanitize(suite)}-{sanitize(profile)}-manual-only.json", - ) - with open(out_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2) - handle.write("\n") - return 0 - - -def sanitize(value: str) -> str: - value = value.strip().lower() - if not value: - return "unknown" - out = [] - last_dash = False - for ch in value: - if ch.isalnum(): - out.append(ch) - last_dash = False - elif not last_dash: - out.append("-") - last_dash = True - return "".join(out).strip("-") or "unknown" - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/triage-label.yml b/.github/workflows/triage-label.yml index fbfcada2ec..99c8807ffb 100644 --- a/.github/workflows/triage-label.yml +++ b/.github/workflows/triage-label.yml @@ -2,11 +2,15 @@ name: Auto-label new issues and PRs on: issues: - types: [opened] + types: [opened, reopened] pull_request_target: - types: [opened] + types: [opened, reopened, ready_for_review] + +permissions: {} jobs: + # pull_request_target is safe here because this job never checks out or runs + # pull request code; it only labels the issue/PR from event metadata. add-triage-label: runs-on: ubuntu-latest permissions: @@ -17,7 +21,18 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | - const number = context.issue?.number || context.payload.pull_request?.number; + const pullRequest = context.payload.pull_request; + if (pullRequest?.draft) { + console.log(`Skipping draft PR #${pullRequest.number}`); + return; + } + + const number = context.issue?.number || pullRequest?.number; + if (!number) { + core.setFailed('Unable to determine issue or PR number'); + return; + } + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.gitignore b/.gitignore index 37729dab70..03f66072b7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ reports/ # Developer scratch — diff dumps and ad-hoc patches created during review tmp_*.diff tmp_*.patch +issues.jsonl diff --git a/.goreleaser.yml b/.goreleaser.yml index 96b259b454..da326bd1eb 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -15,7 +15,13 @@ builds: - arm64 archives: - - formats: [tar.gz] + - id: gc-archive + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + algorithm: sha256 release: prerelease: auto diff --git a/.trivyignore-config b/.trivyignore-config new file mode 100644 index 0000000000..a6414b1e12 --- /dev/null +++ b/.trivyignore-config @@ -0,0 +1,4 @@ +# Gas City controller implements the Kubernetes session protocol by execing +# into namespace-local agent pods. Keep this exception narrow: do not add +# wildcard pod verbs or cluster-wide roles. +KSV-0053 diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000000..eb380d3426 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,61 @@ +vulnerabilities: + - id: CVE-2026-41602 + paths: + - "usr/local/bin/dolt" + expired_at: 2026-06-07 + statement: Latest Dolt 1.88.0 still embeds github.com/apache/thrift v0.13.1; remove after a Dolt release includes thrift 0.23.0 or later. + - id: CVE-2026-34986 + paths: + - "usr/local/bin/bd" + expired_at: 2026-05-29 + statement: Latest bd v1.0.3 still embeds go-jose v4.1.3; remove after a beads release includes go-jose v4.1.4 or later. + - id: CVE-2026-41602 + paths: + - "usr/local/bin/bd" + expired_at: 2026-06-07 + statement: Latest bd v1.0.3 still embeds github.com/apache/thrift v0.19.0; remove after a beads release includes thrift 0.23.0 or later. + - id: CVE-2026-27962 + paths: + - "usr/local/lib/python3.12/site-packages/authlib-1.5.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 still requires authlib <1.6; remove after upstream accepts Authlib 1.6.9 or later. + - id: CVE-2025-59420 + paths: + - "usr/local/lib/python3.12/site-packages/authlib-1.5.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 still requires authlib <1.6; remove after upstream accepts Authlib 1.6.4 or later. + - id: CVE-2025-61920 + paths: + - "usr/local/lib/python3.12/site-packages/authlib-1.5.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 still requires authlib <1.6; remove after upstream accepts Authlib 1.6.5 or later. + - id: CVE-2026-28490 + paths: + - "usr/local/lib/python3.12/site-packages/authlib-1.5.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 still requires authlib <1.6; remove after upstream accepts Authlib 1.6.9 or later. + - id: CVE-2026-28498 + paths: + - "usr/local/lib/python3.12/site-packages/authlib-1.5.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 still requires authlib <1.6; remove after upstream accepts Authlib 1.6.9 or later. + - id: CVE-2026-32871 + paths: + - "usr/local/lib/python3.12/site-packages/fastmcp-2.13.0.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 Authlib constraint keeps FastMCP on 2.13.0.2; remove after upstream accepts FastMCP 3.2.0 or later. + - id: CVE-2025-69196 + paths: + - "usr/local/lib/python3.12/site-packages/fastmcp-2.13.0.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 Authlib constraint keeps FastMCP on 2.13.0.2; remove after upstream accepts FastMCP 2.14.2 or later. + - id: CVE-2026-27124 + paths: + - "usr/local/lib/python3.12/site-packages/fastmcp-2.13.0.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 Authlib constraint keeps FastMCP on 2.13.0.2; remove after upstream accepts FastMCP 3.2.0 or later. + - id: GHSA-rcfx-77hg-w2wv + paths: + - "usr/local/lib/python3.12/site-packages/fastmcp-2.13.0.2.dist-info/METADATA" + expired_at: 2026-05-13 + statement: mcp-agent-mail v0.3.2 Authlib constraint keeps FastMCP on 2.13.0.2; remove after upstream accepts FastMCP 2.14.0 or later. diff --git a/AGENTS.md b/AGENTS.md index dccabf9b92..acb5aa2a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,13 @@ mechanism is provably composable from the primitives. **Five primitives (Layer 0-1):** -1. **Agent Protocol** — start/stop/prompt/observe agents regardless of - provider. Identity, pools, sandboxes, resume, crash adoption. +1. **Session** — start/stop/prompt/observe sessions regardless of + provider. Identity (via `agent.SessionNameFor`), pools, sandboxes, + resume, crash adoption. Lifecycle is a bead-backed projection + (`internal/session/lifecycle_projection.go`). Runtime providers + (tmux, subprocess, exec, k8s, fake) plus routing layers (acp, + auto, hybrid) live under `internal/runtime/` and plug in behind + the Session surface. 2. **Task Store (Beads)** — CRUD + Hook + Dependencies + Labels + Query over work units. Everything is a bead: tasks, mail, molecules, convoys. 3. **Event Bus** — append-only pub/sub log of all system activity. Two @@ -55,13 +60,16 @@ mechanism is provably composable from the primitives. **Four derived mechanisms (Layer 2-4):** 6. **Messaging** — Mail = `TaskStore.Create(bead{type:"message"})`. - Nudge = `AgentProtocol.SendPrompt()`. No new primitive needed. + Nudge = a session-layer operation implemented via + `runtime.Provider.Nudge()` (and exposed through + `worker.Handle.Nudge()` at the worker boundary). No new + primitive needed. 7. **Formulas & Molecules** — Formula = TOML parsed by Config. Molecule = root bead + child step beads in Task Store. Wisps = ephemeral molecules. Orders = formulas with gate conditions on Event Bus. 8. **Dispatch (Sling)** — composed: find/spawn agent → select formula → create molecule → hook to agent → nudge → create convoy → log event. -9. **Health Patrol** — ping agents (Agent Protocol), compare thresholds +9. **Health Patrol** — probe sessions (Session), compare thresholds (Config), publish stalls (Event Bus), restart with backoff. ### Layering invariants @@ -79,14 +87,14 @@ mechanism is provably composable from the primitives. Capabilities activate progressively via config presence. | Level | Adds | -|-------|-------------------------| -| 0-1 | Agent + tasks | +| ----- | ----------------------- | +| 0-1 | Session + tasks | | 2 | Task loop | | 3 | Multiple agents + pool | | 4 | Messaging | | 5 | Formulas & molecules | | 6 | Health monitoring | -| 7 | Orders | +| 7 | Orders | | 8 | Full orchestration | ## Architecture docs @@ -108,10 +116,11 @@ Load-bearing invariants enforced by CI (violating any fails the build; full rationale is in the architecture docs): - **Object model at the center.** `internal/{beads, mail, convoy, - formula, agent, events, session, sling, ...}` is the canonical + formula, events, session, worker, sling, ...}` is the canonical domain. The CLI (`cmd/gc/`) and the HTTP+SSE API (`internal/api/`) are projections over it. Neither re-implements - domain logic. + domain logic. `internal/agent/` is a small helper package + (session-name utilities, startup hints) — not a primitive. - **Typed wire.** No hand-written JSON on any HTTP or SSE wire path; no `map[string]any` or `json.RawMessage` on wire types (documented exceptions live in the API control-plane doc). All @@ -124,6 +133,36 @@ build; full rationale is in the architecture docs): capture the semantics. Enforced by `TestEveryKnownEventTypeHasRegisteredPayload`. +## Active migrations + +These migrations are in flight. New code on affected paths must take +the canonical route, not the legacy route. + +- **Worker boundary (started `12a0a848` on Apr 17 2026, in progress).** + `internal/worker/handle.go` is the canonical boundary for session + creation and lifecycle operations. Production `cmd/gc/*.go` files + must route through `worker.Handle` — enforced by + `TestGCNonTestFilesStayOnWorkerBoundary` in + `cmd/gc/worker_boundary_import_test.go`, which forbids non-test + files from importing `session.NewManager(`, `worker.SessionHandle`, + `sessionlog`, and similar bypass paths in `cmd/gc`. The remaining + manager-construction/direct-create bypasses are split by category: + `internal/api/session_manager.go` constructs `session.Manager` values + for API handlers, and `internal/api/session_resolution.go` still calls + `mgr.CreateAliasedNamedWithTransportAndMetadata(...)` directly. This + list is not a sessionlog read-site inventory; stream and transcript + readers in `internal/api/` and `internal/session/` still read + session logs directly. Package-internal helpers in `internal/session/` + may construct and use `session.Manager`; tests may construct it + directly. Do not add new non-test direct `session.Manager.Create*` call + sites outside the worker boundary. +- **Session-first (completed `dd90ac0a` on Mar 8 2026).** The former + Agent Protocol primitive was removed; responsibilities moved to + `internal/session/` (lifecycle) and `internal/runtime/` (providers). + `internal/agent/` is now a helper package with session-name utilities + and startup hints — not a primitive. Do not reconstruct the + `Agent` / `Handle` interfaces. + ## Design decisions (settled) These decisions are final. Do not revisit them. @@ -155,7 +194,7 @@ These decisions are final. Do not revisit them. - **Zero Framework Cognition (ZFC)** — Go handles transport, not reasoning. If a line of Go contains a judgment call, it's a violation. **The ZFC test:** does any line of Go contain a judgment call? An `if stuck then - restart` is framework intelligence. Move the decision to the prompt. +restart` is framework intelligence. Move the decision to the prompt. - **Bitter Lesson** — every primitive must become MORE useful as models improve, not less. Don't build heuristics or decision trees. - **GUPP** — "If you find work on your hook, YOU RUN IT." No confirmation, @@ -211,13 +250,20 @@ Lesson test — it becomes LESS useful as models improve. definitions; the apply functions and pool deep-copy must be checked manually. -- `TESTING.md` — testing philosophy and tier boundaries. Read before writing any test. +- `TESTING.md` — testing philosophy, tier boundaries, and sharded local + runners. Read before writing any test. For broad local sweeps, prefer the + documented shard targets (`make test-fast-parallel`, + `make test-cmd-gc-process-parallel`, `make test-integration-shards-parallel`, + `make test-local-full-parallel`) over raw `go test`. ## Code quality gates Before considering any task complete: -- `go test ./...` passes +- Fast unit baseline passes (`make test`, or `make test-fast-parallel` on + machines where sharding is useful) +- Broader process/integration coverage uses the sharded targets documented in + `TESTING.md` instead of one monolithic `go test ./...` sweep - `go vet ./...` clean - `.githooks/pre-commit` is active locally (`git config core.hooksPath` prints `.githooks`) and has run for the staged change @@ -231,62 +277,6 @@ Before considering any task complete: - No premature abstractions - Tests cover happy path AND edge cases -## Architecture Best Practices - -These apply to all code in this project — frontend and server: - -- **TDD (Test-Driven Development)** - write the tests first; the implementation - code isn't done until the tests pass. -- **Consider First Principles** to assess your current architecture against the - one you'd use if you started over from scratch. -- **Leverage Types** using statically typed languages (TypeScript, Rust, etc) so - that we can leverage the power of the compiler as guardrails and immediate - feedback on our code at build-time instead of waiting until run-time. -- **DRY (Don't Repeat Yourself)** – eliminate duplicated logic by extracting - shared utilities and modules. -- **Separation of Concerns** – each module should handle one distinct - responsibility. -- **Single Responsibility Principle (SRP)** – every class/module/function/file - should have exactly one reason to change. -- **Clear Abstractions & Contracts** – expose intent through small, stable - interfaces and hide implementation details. -- **Low Coupling, High Cohesion** – keep modules self-contained, minimize - cross-dependencies. -- **Scalability & Statelessness** – design components to scale horizontally and - prefer stateless services when possible. -- **Observability & Testability** – build in logging, metrics, tracing, and - ensure components can be unit/integration tested. -- **KISS (Keep It Simple, Sir)** - keep solutions as simple as possible. -- **YAGNI (You're Not Gonna Need It)** – avoid speculative complexity or - over-engineering. -- **Don't Swallow Errors** by catching exceptions, silently filling in required - but missing values, masking deserialization with nulls or empty lists, or - ignoring timeouts when something hangs. All of those are errors (client-side - and server-side) and must be tracked in a centralized log so it can be used to - improve the app over time. Also, inform the user as appropriate so that they - can take necessary action. -- **No Placeholder Code** - we're building production code here, not toys. -- **No Comments for Removed Functionality** - the source is not the place to - keep history of what's changed; it's the place to implement the current - requirements only. -- **Layered Architecture** - organize code into clear tiers where each layer - depends only on the one(s) below it, keeping logic cleanly separated. -- **Use Non-Nullable Variables** when possible; use nullability only when - there is NO other possiblity. -- **Use Async Notifications** when possible over inefficient polling. -- **Eliminate Race Conditions** that might cause dropped or corrupted data -- **Write for Maintainability** so that the code is clear and readable and easy - to maintain by future developers. -- **Arrange Project Idiomatically** for the language and framework being used, - including recommended lints, static analysis tools, folder structure and - gitignore entries. -- **Keep Serialization/Deserialization At The Edges** to make full use of - type-safe objects in the app itself and to centralize error handling for - type-system translation. Do NOT allow untyped data with known shapes to flow - through the system and subvert the type system. -- **Prefer Well-Known, High Quality OSS Libraries** instead of hand-rolling your - own behavior to get more robust, better maintained and better tested results. - ## Non-Interactive Shell Commands **ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. @@ -362,3 +352,63 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + +## Architecture Best Practices + +These apply to all code in this project — frontend and server: + +- **TDD (Test-Driven Development)** - write the tests first; the implementation + code isn't done until the tests pass. +- **Consider First Principles** to assess your current architecture against the + one you'd use if you started over from scratch. +- **Leverage Types** using statically typed languages (TypeScript, Rust, etc) so + that we can leverage the power of the compiler as guardrails and immediate + feedback on our code at build-time instead of waiting until run-time. +- **DRY (Don't Repeat Yourself)** – eliminate duplicated logic by extracting + shared utilities and modules. +- **Separation of Concerns** – each module should handle one distinct + responsibility. +- **Single Responsibility Principle (SRP)** – every class/module/function/file + should have exactly one reason to change. +- **Clear Abstractions & Contracts** – expose intent through small, stable + interfaces and hide implementation details. +- **Low Coupling, High Cohesion** – keep modules self-contained, minimize + cross-dependencies. +- **Scalability & Statelessness** – design components to scale horizontally and + prefer stateless services when possible. +- **Observability & Testability** – build in logging, metrics, tracing, and + ensure components can be unit/integration tested. +- **KISS (Keep It Simple, Sir)** - keep solutions as simple as possible. +- **YAGNI (You're Not Gonna Need It)** – avoid speculative complexity or + over-engineering. +- **Don't Swallow Errors** by catching exceptions, silently filling in required + but missing values, masking deserialization with nulls or empty lists, or + ignoring timeouts when something hangs. All of those are errors (client-side + and server-side) and must be tracked in a centralized log so it can be used to + improve the app over time. Also, inform the user as appropriate so that they + can take necessary action. +- **No Placeholder Code** - we're building production code here, not toys. +- **No Comments for Removed Functionality** - the source is not the place to + keep history of what's changed; it's the place to implement the current + requirements only. +- **Layered Architecture** - organize code into clear tiers where each layer + depends only on the one(s) below it, keeping logic cleanly separated. +- **Use Non-Nullable Variables** when possible; use nullability only when + there is NO other possiblity. +- **Use Async Notifications** when possible over inefficient polling. +- **Eliminate Race Conditions** that might cause dropped or corrupted data +- **Write for Maintainability** so that the code is clear and readable and easy + to maintain by future developers. +- **Arrange Project Idiomatically** for the language and framework being used, + including recommended lints, static analysis tools, folder structure and + gitignore entries. +- **Keep Serialization/Deserialization At The Edges** to make full use of + type-safe objects in the app itself and to centralize error handling for + type-system translation. Do NOT allow untyped data with known shapes to flow + through the system and subvert the type system. +- **Prefer Well-Known, High Quality OSS Libraries** instead of hand-rolling your + own behavior to get more robust, better maintained and better tested results. +- **Treat Static Warnings And Info As Errors To Be Fixed**. The whole point of + static checking (linting, compilers, etc) is that they surface issues at + build-time so that they can be fixed now instead of lead to errors at runtime. + Take advantage of that feedback to fix those errors! diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd259bd43..f5301f507c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The built-in `control-dispatcher` trace now defaults to + `${GC_CITY_RUNTIME_DIR}/control-dispatcher-trace.log` (falling back to + `${GC_CITY}/.gc/runtime/control-dispatcher-trace.log`) instead of writing at + city root. This keeps workflow-trace appends inside the controller's + watcher-excluded runtime subtree, avoiding continuous `config-changed` + reconciliations. After upgrading, operators tailing the default trace should + switch to `.gc/runtime/control-dispatcher-trace.log`; the old + `${GC_CITY}/control-dispatcher-trace.log` file becomes stale and can be + removed. After upgrading, restart or recycle existing `control-dispatcher` + sessions so they pick up the new trace path; otherwise they keep their + previous trace target and can continue retriggering reconciles. Validation + currently covers watcher exclusion, dispatcher warning routing, and the + graph-workflow integration shard; there is not yet a dedicated patrol-cadence + stress test. +- `proxy_process` services now receive a `GC_SERVICE_URL_PREFIX` that the + supervisor's public listener actually routes. Previously the prefix was + the per-city-relative `/svc/`, so any service that composed + `CallbackURL = $GC_API_BASE_URL + $GC_SERVICE_URL_PREFIX` (the documented + shape for adapter self-registration) would 404 on inbound calls. The + prefix is now the full `/v0/city//svc/` path. The + per-city router contract (`config.Service.MountPathOrDefault`) is + unchanged. +- `gc session reset` now documents its named-session circuit-breaker behavior: + when the target is a named session, reset clears a tripped respawn breaker + before requesting a fresh restart. + ### Changed +- `[[orders.overrides]]` rig matching is stricter and clearer. A rigless + override (`rig` unset) still matches **only** city-level orders; if the + named order exists only as per-rig instances, the error now names every + matching rig so it's obvious what to type. `rig = "*"` is a new wildcard + that targets every instance of the named order (city-level + per-rig). + The literal `"*"` is reserved and rejected as a real rig name by config + validation. - Managed Dolt config now emits listener backlog and connection-timeout keys. Existing managed cities may see a `dolt-config` doctor warning until `gc dolt restart` or the next managed server start regenerates `dolt-config.yaml`. +- In bead-backed pool reconciliation, `scale_check` output is now documented + and enforced as additive new-session demand. Assigned work is resumed + separately; custom checks that previously returned total desired sessions + should return only new unassigned demand. +- Session bead reconciliation now stops suspended and orphaned runtimes before + closing their beads; resuming one of those sessions starts a fresh lifecycle + instead of continuing the previous runtime process. +- `gc hook --inject` is now silent legacy compatibility for already-installed + Stop/session-end hooks. Fresh managed hook configs no longer install it; + routed work pickup should happen through the SessionStart claim protocol or + an explicit non-inject `gc hook` call. +- The built-in Claude provider's `model = "opus"` option now emits + `claude-opus-4-7`. Cities that rely on the `opus` alias should expect the + new model target after upgrading. + +### Fixed + +- Linux systemd supervisor service restarts now preserve managed tmux sessions + for re-adoption. Linux users should rerun `gc supervisor install` after + upgrading so the user unit is regenerated with `KillMode=process` and the + preserve-on-signal environment. If the currently active Linux supervisor + predates the preserve-on-signal environment, `gc supervisor install` now + refuses the warm refresh before sending a signal and tells operators to stop + or drain agents intentionally with `gc supervisor stop --wait`, then rerun the + install. Once the active supervisor already supports preserve mode, Linux warm + refresh sends the main supervisor PID `SIGTERM` first so preserve-mode + shutdown can close workspace services and flush traces, with a bounded + `SIGKILL` fallback if the process does not exit. The Linux refresh also stops + orphan-prone workspace service process groups owned by registered cities + before starting the replacement supervisor; supervisor startup repeats the + same owned-service cleanup after crashes. Service-managed `SIGTERM` preserves + sessions for re-adoption, while `SIGINT` remains a destructive escalation + path. Preserve mode intentionally leaves the beads provider running so + preserved sessions can keep using the store; the bundled managed-Dolt start + path is idempotent when it finds an already-running server, but custom exec + providers must make `start` reattach or no-op safely after preserve-mode + restarts. macOS launchd upgrades still use launchd unload/load rather than the + Linux main-PID refresh path; macOS supervisor startup now warns that automatic + orphaned workspace-service cleanup is Linux-only, lists the registered + `GC_SERVICE_STATE_ROOT` roots to inspect, and tells operators to stop stale + workspace-service processes before restarting affected cities after + non-graceful exits. ## [1.0.0] - 2026-04-21 diff --git a/Makefile b/Makefile index 60cdcf50c5..aede12aa1c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ GOLANGCI_LINT_VERSION := 2.9.0 +BUILDX_VERSION := 0.21.2 # Detect OS and arch for binary download. GOOS := $(shell go env GOOS) @@ -20,7 +21,7 @@ LDFLAGS := -X main.version=$(VERSION) \ -X main.commit=$(COMMIT) \ -X main.date=$(BUILD_TIME) -.PHONY: build check check-all check-bd check-docker check-docs check-dolt check-version-tag lint fmt-check fmt vet test test-fsys-darwin-compile test-cmd-gc-process test-worker-core test-worker-core-phase2 test-worker-core-phase2-real-transport test-worker-inference-phase3 test-acceptance test-acceptance-b test-acceptance-c test-acceptance-all test-tutorial-goldens test-tutorial-regression test-tutorial test-integration test-integration-shards test-integration-shards-cover test-integration-packages test-integration-packages-cover test-integration-review-formulas test-integration-review-formulas-cover test-integration-review-formulas-basic test-integration-review-formulas-basic-cover test-integration-review-formulas-retries test-integration-review-formulas-retries-cover test-integration-review-formulas-recovery test-integration-review-formulas-recovery-cover test-integration-bdstore test-integration-bdstore-cover test-integration-rest test-integration-rest-cover test-integration-rest-smoke test-integration-rest-smoke-cover test-integration-rest-full test-integration-rest-full-cover test-mcp-mail test-docker test-k8s test-cover cover install install-tools install-buildx setup clean generate check-schema docker-base docker-agent docker-controller docs-dev dashboard-smoke +.PHONY: build check check-all check-bd check-docker check-docs check-dolt check-version-tag lint fmt-check fmt vet test test-fast-parallel test-fsys-darwin-compile test-cmd-gc-process test-cmd-gc-process-shard test-cmd-gc-process-parallel test-worker-core test-worker-core-phase2 test-worker-core-phase2-real-transport setup-worker-inference test-worker-inference test-worker-inference-phase3 test-acceptance test-acceptance-b test-acceptance-c test-acceptance-all test-tutorial-goldens test-tutorial-regression test-tutorial test-integration test-integration-shards test-integration-shards-parallel test-integration-shards-cover test-integration-packages test-integration-packages-cover test-integration-review-formulas test-integration-review-formulas-cover test-integration-review-formulas-basic test-integration-review-formulas-basic-cover test-integration-review-formulas-retries test-integration-review-formulas-retries-cover test-integration-review-formulas-recovery test-integration-review-formulas-recovery-cover test-integration-bdstore test-integration-bdstore-cover test-integration-rest test-integration-rest-cover test-integration-rest-smoke test-integration-rest-smoke-cover test-integration-rest-full test-integration-rest-full-cover test-local-full-parallel test-mcp-mail test-docker test-k8s test-cover cover install install-tools install-buildx setup clean generate check-schema docker-base docker-agent docker-controller docs-dev dashboard-smoke ## build: compile gc binary with version metadata build: @@ -159,14 +160,34 @@ TEST_ENV = env -i \ GOINSECURE="$${GOINSECURE-}" \ GOVCS="$${GOVCS-}" \ GOWORK="$${GOWORK-}" \ + ANTHROPIC_BASE_URL="$${ANTHROPIC_BASE_URL-}" \ + ANTHROPIC_API_KEY="$${ANTHROPIC_API_KEY-}" \ + ANTHROPIC_AUTH_TOKEN="$${ANTHROPIC_AUTH_TOKEN-}" \ + ANTHROPIC_DEFAULT_HAIKU_MODEL="$${ANTHROPIC_DEFAULT_HAIKU_MODEL-}" \ + ANTHROPIC_DEFAULT_SONNET_MODEL="$${ANTHROPIC_DEFAULT_SONNET_MODEL-}" \ + ANTHROPIC_DEFAULT_OPUS_MODEL="$${ANTHROPIC_DEFAULT_OPUS_MODEL-}" \ + CLAUDE_CODE_SUBAGENT_MODEL="$${CLAUDE_CODE_SUBAGENT_MODEL-}" \ + CLAUDE_CODE_EFFORT_LEVEL="$${CLAUDE_CODE_EFFORT_LEVEL-}" \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="$${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC-}" \ + OLLAMA_API_KEY="$${OLLAMA_API_KEY-}" \ $(EXTRA_TEST_ENV) ## test: run fast unit tests (skip integration-tagged and GC_FAST_UNIT-gated process tests) ## The skipped cmd/gc process-backed scenarios remain covered by ## `make test-cmd-gc-process` locally and the CI `cmd/gc process suite` job. +## Bound package parallelism so subprocess-heavy packages do not starve each +## other into false 5s probe/condition timeouts. Use -count=1 so pre-commit +## reports actual test results instead of hanging after PASS while Go computes +## cache input hashes over local working files. ## Wrapped in $(TEST_ENV) — see comment above for why. test: test-fsys-darwin-compile - $(TEST_ENV) GC_FAST_UNIT=1 go test ./... + $(TEST_ENV) GC_FAST_UNIT=1 scripts/go-test-observable test -- -p=4 -count=1 ./... + +LOCAL_TEST_JOBS ?= $(shell nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 8) + +## test-fast-parallel: run the default fast suite with cmd/gc sharded locally +test-fast-parallel: + LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) CMD_GC_PROCESS_TOTAL=$(CMD_GC_PROCESS_TOTAL) ./scripts/test-local-parallel fast ## test-fsys-darwin-compile: cross-compile internal/fsys for macOS so ## unix.Stat_t field-type regressions fail in the default fast test path. @@ -178,7 +199,16 @@ test-fsys-darwin-compile: ## test-cmd-gc-process: run the full non-short cmd/gc suite, including the ## process-backed lifecycle coverage routed out of the default fast loop test-cmd-gc-process: - $(TEST_ENV) GC_FAST_UNIT=0 go test -count=1 -timeout 20m ./cmd/gc + $(TEST_ENV) GC_FAST_UNIT=0 scripts/go-test-observable test-cmd-gc-process -- -timeout 25m ./cmd/gc + +CMD_GC_PROCESS_SHARD ?= 1 +CMD_GC_PROCESS_TOTAL ?= 6 +test-cmd-gc-process-shard: + $(TEST_ENV) GC_FAST_UNIT=0 GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc $(CMD_GC_PROCESS_SHARD) $(CMD_GC_PROCESS_TOTAL) + +## test-cmd-gc-process-parallel: run all cmd/gc process shards concurrently +test-cmd-gc-process-parallel: + LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) CMD_GC_PROCESS_TOTAL=$(CMD_GC_PROCESS_TOTAL) ./scripts/test-local-parallel cmd-gc-process ## test-worker-core: run deterministic worker transcript and continuation conformance test-worker-core: @@ -194,9 +224,18 @@ test-worker-core-phase2: test-worker-core-phase2-real-transport: $(TEST_ENV) PROFILE="$${PROFILE-}" GC_WORKER_REPORT_DIR="$${GC_WORKER_REPORT_DIR-}" go test -count=1 -tags integration ./cmd/gc -run '^TestPhase2WorkerCoreRealTransportProof$$' -## test-worker-inference-phase3: run the live worker inference conformance package -test-worker-inference-phase3: - $(TEST_ENV) PROFILE="$${PROFILE-}" GC_WORKER_REPORT_DIR="$${GC_WORKER_REPORT_DIR-}" go test -count=1 -tags acceptance_c -timeout 45m -v ./test/acceptance/worker_inference +WORKER_INFERENCE_PROFILE := $(if $(PROFILE),$(PROFILE),claude/tmux-cli) + +## setup-worker-inference: install the provider CLI for PROFILE (default claude/tmux-cli) +setup-worker-inference: + python3 scripts/worker_inference_setup.py install --profile "$(WORKER_INFERENCE_PROFILE)" + +## test-worker-inference: run the live worker inference conformance package +test-worker-inference: + $(TEST_ENV) PROFILE="$(WORKER_INFERENCE_PROFILE)" GC_WORKER_REPORT_DIR="$(GC_WORKER_REPORT_DIR)" go test -count=1 -tags acceptance_c -timeout 45m -v ./test/acceptance/worker_inference + +## test-worker-inference-phase3: alias for the live worker inference conformance package +test-worker-inference-phase3: test-worker-inference ## test-acceptance: run acceptance tests (Tier A — fast, <5 min, every PR). ## ACCEPTANCE_TIMEOUT overrides the go-test timeout (defaults to 5m on @@ -228,11 +267,19 @@ test-integration-huma: ## test-integration-shards: run the CI integration shards sequentially test-integration-shards: test-integration-packages test-integration-review-formulas test-integration-bdstore test-integration-rest-smoke test-integration-rest-full +## test-integration-shards-parallel: run the CI integration shards concurrently +test-integration-shards-parallel: + LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) ./scripts/test-local-parallel integration + +## test-local-full-parallel: run fast unit, cmd/gc process, and integration shards concurrently +test-local-full-parallel: + LOCAL_TEST_JOBS=$(LOCAL_TEST_JOBS) CMD_GC_PROCESS_TOTAL=$(CMD_GC_PROCESS_TOTAL) ./scripts/test-local-parallel full + ## test-integration-shards-cover: run the CI integration coverage shards sequentially test-integration-shards-cover: test-integration-packages-cover test-integration-review-formulas-cover test-integration-bdstore-cover test-integration-rest-smoke-cover test-integration-rest-full-cover ## test-integration-packages: run all integration-tagged packages except ./test/integration -## This shard is also the required non-short CI path for the slow cmd/gc process suite. +## cmd/gc package shards default to GC_FAST_UNIT=1; use test-cmd-gc-process for the slow process suite. test-integration-packages: ./scripts/test-integration-shard packages @@ -368,8 +415,7 @@ install-tools: $(GOLANGCI_LINT) install-oapi-codegen $(GOLANGCI_LINT): @echo "Installing golangci-lint v$(GOLANGCI_LINT_VERSION)..." - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | \ - sh -s -- -b $(BIN_DIR) v$(GOLANGCI_LINT_VERSION) + GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v$(GOLANGCI_LINT_VERSION) ## install-oapi-codegen: install pinned oapi-codegen so the spec→client drift ## test (TestGeneratedClientInSync) can regenerate client_gen.go without skipping. @@ -383,10 +429,23 @@ install-oapi-codegen: ## install-buildx: install docker buildx plugin install-buildx: @mkdir -p $(HOME)/.docker/cli-plugins - curl -sSfL "https://github.com/docker/buildx/releases/download/v0.21.2/buildx-v0.21.2.$$(go env GOOS)-$$(go env GOARCH)" \ - -o $(HOME)/.docker/cli-plugins/docker-buildx - chmod +x $(HOME)/.docker/cli-plugins/docker-buildx - @echo "Installed docker-buildx v0.21.2" + @case "$(GOOS)-$(GOARCH)" in \ + linux-amd64|linux-arm64) ;; \ + *) echo "Unsupported docker-buildx platform: $(GOOS)-$(GOARCH)" >&2; exit 1 ;; \ + esac; \ + tmp="$$(mktemp)"; \ + checksums="$$(mktemp)"; \ + trap 'rm -f "$$tmp" "$$checksums"' EXIT; \ + curl -sSfL "https://github.com/docker/buildx/releases/download/v$(BUILDX_VERSION)/checksums.txt" \ + -o "$$checksums"; \ + asset="buildx-v$(BUILDX_VERSION).$(GOOS)-$(GOARCH)"; \ + expected_sha="$$(awk -v asset="*$$asset" '$$2 == asset {print $$1}' "$$checksums")"; \ + if [ -z "$$expected_sha" ]; then echo "Missing checksum for $$asset" >&2; exit 1; fi; \ + curl -sSfL "https://github.com/docker/buildx/releases/download/v$(BUILDX_VERSION)/buildx-v$(BUILDX_VERSION).$(GOOS)-$(GOARCH)" \ + -o "$$tmp"; \ + echo "$$expected_sha $$tmp" | sha256sum -c -; \ + install -m 0755 "$$tmp" $(HOME)/.docker/cli-plugins/docker-buildx + @echo "Installed docker-buildx v$(BUILDX_VERSION)" ## test-mcp-mail: run mcp_agent_mail live conformance test (auto-starts server) test-mcp-mail: @@ -411,7 +470,7 @@ docs-dev: ## dashboard-build: regenerate SPA types + compile the dist bundle dashboard-build: - cd cmd/gc/dashboard/web && npm install --silent && npm run gen && npm run build + cd cmd/gc/dashboard/web && npm ci --silent && npm run gen && npm run build ## dashboard-dev: Vite dev server (HMR) for SPA iteration dashboard-dev: diff --git a/README.md b/README.md index d1ad080b17..3fae5af04d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Gas City requires the following tools on your system. `gc init` and | jq | Always | — | `brew install jq` | `apt install jq` | | pgrep | Always | — | (included in macOS) | `apt install procps` | | lsof | Always | — | (included in macOS) | `apt install lsof` | -| dolt | Beads provider `bd` | 1.86.1 | `brew install dolt` | [releases](https://github.com/dolthub/dolt/releases) | +| dolt | Beads provider `bd` | 1.86.2 or newer | `brew install dolt` | [releases](https://github.com/dolthub/dolt/releases) | | bd | Beads provider `bd` | 1.0.0 | [releases](https://github.com/gastownhall/beads/releases) | [releases](https://github.com/gastownhall/beads/releases) | | flock | Beads provider `bd` | — | `brew install flock` | `apt install util-linux` | | claude / codex / gemini | Per provider | — | See provider docs | See provider docs | @@ -45,6 +45,11 @@ The `bd` (beads) provider is the default. To use a file-based store instead (no dolt/bd/flock needed), set `GC_BEADS=file` or add `[beads] provider = "file"` to your `city.toml`. +Managed Dolt checks require a final Dolt 1.86.2 or newer. Earlier and +pre-release builds can miss the upstream GC/writer deadlock fix in +dolthub/dolt commit `ccf7bde206`, which can hang `dolt_backup sync` under +heavy write load. + Install from Homebrew: ```bash diff --git a/RELEASING.md b/RELEASING.md index b70ae40a89..19e60dff14 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -46,7 +46,8 @@ Version numbers live **only** in the git tag — there is no `Version` constant 1. **Reject `replace` directives in `go.mod`** — they break `go install ...@latest` and bottle builds in homebrew-core. 2. **`make check-version-tag`** — asserts the tag is a clean `vMAJOR.MINOR.PATCH` with no pre-release suffix. RC/beta tags will fail the release. Pre-release tags should be cut on a dedicated branch or not trigger this workflow. 3. **GoReleaser** — builds binaries for linux/darwin × amd64/arm64 and creates the GitHub Release with grouped changelog (`feat:` → Features, `fix:` → Bug Fixes, others → Others). -4. **Homebrew tap update** — downloads the published checksums and writes an asset-based formula to `gastownhall/homebrew-gascity`. +4. **Release attestations** — downloads the published checksum manifest, uploads an SPDX SBOM asset, and creates GitHub artifact attestations for the release archives. +5. **Homebrew tap update** — downloads the published checksums and writes an asset-based formula to `gastownhall/homebrew-gascity`. Forks skip publish/announce steps automatically via the `--skip=publish --skip=announce` flag (the workflow checks `github.repository != 'gastownhall/gascity'`). @@ -55,11 +56,12 @@ Forks skip publish/announce steps automatically via the `--skip=publish --skip=a ```bash make check-version-tag # no-op unless HEAD is a release tag grep '^replace' go.mod # should print nothing +goreleaser check # also enforced by CI ``` ## Homebrew tap (`gastownhall/gascity`) -The release workflow automatically overwrites `Formula/gascity.rb` in the `gastownhall/homebrew-gascity` repo on every tag push. It prefers the GitHub App credentials `HOMEBREW_TAP_APP_ID` and `HOMEBREW_TAP_APP_PRIVATE_KEY`, and falls back to the legacy `HOMEBREW_TAP_TOKEN` while the app rollout is in progress. +The release workflow automatically overwrites `Formula/gascity.rb` in the `gastownhall/homebrew-gascity` repo on every tag push. Publishing is GitHub App only: `HOMEBREW_TAP_APP_ID` and `HOMEBREW_TAP_APP_PRIVATE_KEY` must be configured in repository secrets for an app installed on `gastownhall/homebrew-gascity` with contents write. The tap formula installs prebuilt release assets, so users do not need Go or a source build: @@ -93,6 +95,7 @@ Manual `brew bump-formula-pr` is refused for autobump formulae. If the bot stall | `CHANGELOG.md` | `[Unreleased]` → `[X.Y.Z] - DATE` | `scripts/bump-version.sh` | | Git tag `vX.Y.Z` | Created and pushed | `scripts/bump-version.sh` | | GitHub Release page | Created with binaries + grouped changelog | GoReleaser in `release.yml` | +| Release SBOM + attestations | SPDX SBOM uploaded and release archives attested | `attest-release` in `release.yml` | | `gastownhall/homebrew-gascity/Formula/gascity.rb` | asset URLs + `sha256` updated | `update-homebrew-formula` in `release.yml` | ## Troubleshooting @@ -111,7 +114,7 @@ Check `.github/workflows/release.yml` still matches `tags: v*`. Verify the tag w ### Tap formula not updated -Check the Homebrew tap credential in repo secrets. Preferred: `HOMEBREW_TAP_APP_ID` and `HOMEBREW_TAP_APP_PRIVATE_KEY` for a GitHub App installed on `gastownhall/homebrew-gascity` with contents write. Legacy fallback: `HOMEBREW_TAP_TOKEN` with contents write on the tap. The workflow logs will show the exact error. +Check the Homebrew tap GitHub App credentials in repo secrets: `HOMEBREW_TAP_APP_ID` and `HOMEBREW_TAP_APP_PRIVATE_KEY`. The app must be installed on `gastownhall/homebrew-gascity` with contents write. The workflow intentionally fails instead of falling back to a long-lived token. ### Homebrew shows old version after a release diff --git a/SECURITY.md b/SECURITY.md index e9e1db0c36..919ee2b3ff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,37 +2,60 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in Gas City, please report it responsibly: +Please report suspected vulnerabilities through GitHub private vulnerability +reporting: -1. **Do not** open a public issue for security vulnerabilities -2. Email the maintainers directly with details -3. Include steps to reproduce the vulnerability -4. Allow reasonable time for a fix before public disclosure +https://github.com/gastownhall/gascity/security/advisories/new -## Scope +Do not open a public issue, public discussion, or public pull request for a +security vulnerability before the maintainers have had time to investigate and +release a fix. + +Include as much of the following as you can: -Gas City is experimental software focused on multi-agent coordination. Security considerations include: +- Affected version, commit, or release asset. +- Reproduction steps or proof-of-concept details. +- Expected and observed impact. +- Relevant logs, terminal output, or screenshots with secrets removed. +- Whether the issue is already being exploited or publicly discussed. -- **Agent isolation**: Agents run in separate tmux sessions but share filesystem access -- **Git operations**: Agents can push to configured remotes -- **Shell execution**: Agents execute shell commands as the running user -- **Beads data**: Work tracking data is stored in `.gc/` directories +Maintainers will acknowledge a valid private report within three business days +when possible, triage severity, and coordinate disclosure through the GitHub +security advisory. If a fix is needed, it will be released before public +disclosure unless there is an active exploitation risk that requires faster +notice. -## Best Practices +## Supported Versions -When using Gas City: +Security fixes target the current stable major release unless a separate support +window is announced in release notes. -- Run in isolated environments for untrusted code -- Review agent output before pushing to production branches -- Use appropriate git remote permissions -- Monitor agent activity via `gc session attach` and logs +| Version | Supported | +| ------- | --------- | +| 1.x | Yes | +| < 1.0 | No | -## Supported Versions +## Scope + +Gas City coordinates local and remote agent workflows. Security reports are in +scope when they affect confidentiality, integrity, or availability in normal +supported use, including: + +- Agent isolation, workspace boundaries, and command execution. +- Git operations, release workflows, and repository publishing paths. +- Secrets handling, logs, generated artifacts, and configuration files. +- Beads data in `.gc/` directories when used through Gas City. + +Expected behavior in trusted local development environments, documented +administrative actions, and vulnerabilities in third-party tools should be +reported to the relevant upstream project unless Gas City creates a new or +materially worse exposure. -| Version | Supported | -| ------- | ------------------ | -| 0.1.x | :white_check_mark: | +## Release Integrity -## Updates +Release archives are published through GitHub Releases with SHA-256 checksums, +SBOM assets, and GitHub artifact attestations generated by GitHub Actions. +Homebrew formulas install release archives by checksum. -Security updates will be released as patch versions when applicable. +Direct-download users should verify checksums and attestations before installing +or upgrading. See the installation guide for the current commands. diff --git a/TESTING.md b/TESTING.md index bab2f2f5f9..ac76242594 100644 --- a/TESTING.md +++ b/TESTING.md @@ -37,13 +37,71 @@ scripts, and the large `gc-beads-bd` provider suite are routed out of the default path so local `make check` and CI `Check` stay focused on quick feedback. If you need that full `cmd/gc` scenario coverage locally, run `make test-cmd-gc-process`. In CI, the required non-short path is the -`test-integration-packages` shard. If you need the heavier package +dedicated Linux `cmd/gc process` job. The generic integration package +shards keep `GC_FAST_UNIT=1` for `cmd/gc` unless explicitly overridden, +so they exercise the fast package sweep without duplicating the slow +process-backed suite. If you need the heavier package coverage sweep locally, use `make test-integration-packages-cover` or `make test-integration-shards-cover`. As a result, `coverage.txt` is the fast unit-only baseline; the integration contribution comes from the shard-specific `coverage.integration-*.txt` profiles and their matching Codecov flags. +#### Sharded local runners + +For broad local runs, prefer the repo's sharded wrappers over raw `go test` +commands. They use the same buckets as CI, run under a scrubbed environment, +and split single-package bottlenecks such as `cmd/gc` across multiple +processes. + +Use these as the default entry points: + +```bash +# Fast unit baseline, with cmd/gc split into shards. +make test-fast-parallel + +# Full process-backed cmd/gc suite, sharded. +make test-cmd-gc-process-parallel + +# CI integration buckets, sharded. +make test-integration-shards-parallel + +# Fast + process-backed cmd/gc + integration shards. +make test-local-full-parallel +``` + +On large local machines, tune parallelism explicitly: + +```bash +LOCAL_TEST_JOBS=48 CMD_GC_PROCESS_TOTAL=12 make test-local-full-parallel +``` + +For one package, shard top-level Go tests directly: + +```bash +GO_TEST_COUNT=1 GO_TEST_TIMEOUT=20m ./scripts/test-go-test-shard ./cmd/gc 1 6 +GO_TEST_TAGS=acceptance_b GO_TEST_TIMEOUT=10m ./scripts/test-go-test-shard ./test/acceptance/tier_b 2 3 +``` + +For integration buckets, use the named shard runner: + +```bash +./scripts/test-integration-shard packages-cmd-gc-3-of-6 +./scripts/test-integration-shard review-formulas-retries-1-of-2 +./scripts/test-integration-shard rest-full-4-of-8 +``` + +To force the process-backed `cmd/gc` tests through the package shard for +diagnostics, override the default explicitly: + +```bash +GC_FAST_UNIT=0 ./scripts/test-integration-shard packages-cmd-gc-3-of-6 +``` + +Raw `go test` is still appropriate for a focused package or a single failing +test. Do not use it as the default for full local sweeps when a sharded target +exists. + ### 2. Testscript (`.txtar` files in `cmd/gc/testdata/`) Test what the USER sees. Run the real `gc` binary, assert on stdout/stderr. @@ -110,6 +168,68 @@ listener bootstrap, socket paths — wires end-to-end through a real binary. Run with `make test-integration-huma` or `go test -tags integration -run TestHumaBinary ./test/integration/`. +**Supervisor API contract tests** (`test/integration/gc_live_contract_test.go` +and focused cases in `test/integration/huma_binary_test.go`): build the real +`gc` binary, start `gc supervisor run` against an isolated `GC_HOME` and +runtime dir, then exercise the HTTP API as a client would. These tests are +not handler unit tests and are not CLI tutorial tests; they prove that the +published API contract survives the full control plane: Huma registration, +OpenAPI generation, supervisor routing, city lifecycle, event publication, +storage providers, and asynchronous request completion. + +The live API contract test has a few load-bearing rules: + +- Validate responses against the supervisor's live `/openapi.json`. If the + server says a route returns a schema, the integration test should prove the + real response matches that schema. +- Exercise API mutations through HTTP only. Set `X-GC-Request` for mutating + calls and observe durable results through API reads or events, not by + reaching into internal Go state. +- Treat asynchronous operations as two-step contracts: the HTTP call returns + quickly with `202 Accepted` and a `request_id`, then a `request.result.*` + or `request.failed` event appears. Focused Huma binary tests should use + `/v0/events/stream` for the critical async paths; broader coverage may poll + event-list endpoints when the thing being tested is the API surface rather + than SSE framing. +- Prefer self-provisioned fixtures. The test should create its own city, rig, + provider/agent/session, beads, mail, formulas, convoys, and order-history + fixtures where practical, then clean them up through the API. +- Keep the test hermetic. It must not depend on the developer's machine-wide + supervisor, personal `~/.gc`, default tmux server, or a pre-existing city. + Use isolated `GC_HOME`, runtime dir, ports, and process cleanup. +- Lock compatibility surfaces explicitly. If generated clients rely on an + operation ID, method, path template, status code, or response schema, add an + assertion for that contract rather than relying only on incidental behavior. +- Keep generated-read sweeps read-only. A sweep over OpenAPI GET routes is + useful for schema and routing drift, but any GET route with unbound identity + parameters still needs an explicit fixture-backed test. + +Use supervisor API contract tests for externally visible behavior that only +exists when the real supervisor process is running: async city/session request +results, event streams, OpenAPI/response agreement, cross-route lifecycle +coherence, and end-to-end provider wiring. Do not put low-level edge cases +here. Corrupt files, exact parser failures, request validation branches, and +single handler error cases belong in unit tests next to the implementation. + +#### Live worker inference tests (`//go:build acceptance_c`) + +`test/acceptance/worker_inference` runs live Claude/Codex/Gemini/OpenCode CLI +sessions through tmux and requires local or CI-provided provider auth. It is +not part of PR CI. Run it deliberately when validating provider behavior: + +```bash +make setup-worker-inference PROFILE=claude/tmux-cli +make test-worker-inference PROFILE=claude/tmux-cli +``` + +Supported profiles are `claude/tmux-cli`, `codex/tmux-cli`, +`gemini/tmux-cli`, and `opencode/tmux-cli`. OpenCode live tests use Gemini via +`--model google/gemini-2.5-flash` by default; set +`GC_WORKER_INFERENCE_OPENCODE_MODEL` to override it and provide +`GOOGLE_GENERATIVE_AI_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` for auth. +Nightly CI runs the configured profile matrix with its credentials and uploads +worker report artifacts. + ### 4. Documentation sync tests (`test/docsync`) These tests keep the public docs surface honest. diff --git a/cmd/gc/agent_build_params.go b/cmd/gc/agent_build_params.go index 3c9a7f5315..c7787314e5 100644 --- a/cmd/gc/agent_build_params.go +++ b/cmd/gc/agent_build_params.go @@ -44,6 +44,11 @@ type agentBuildParams struct { // desired-state build so per-agent resolution does not rescan the store. sessionBeads *sessionBeadSnapshot + // assignedWorkBeads is the actionable assigned-work snapshot for this + // build. Pool new-tier materialization uses it to avoid treating sessions + // that already own work as available generic capacity. + assignedWorkBeads []beads.Bead + // beadNames caches qualifiedName → session_name mappings resolved // during this build cycle. Populated lazily by resolveSessionName. beadNames map[string]string diff --git a/cmd/gc/agent_env_path.go b/cmd/gc/agent_env_path.go new file mode 100644 index 0000000000..072c72cdac --- /dev/null +++ b/cmd/gc/agent_env_path.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +// prependGCBinDirToPATH ensures that the directory containing the gc binary +// is the first entry in env["PATH"]. If env["PATH"] is unset, falls back to +// the calling process's PATH as the base. +// +// This protects spawned agents (which may write `gc` in shell prompts) from +// PATH collisions with unrelated binaries — notably Homebrew's `graphviz` +// package, which ships /opt/homebrew/bin/gc and breaks bare `gc` invocations +// for any agent whose PATH happens to put /opt/homebrew/bin first. +// +// gcBin is the absolute path to the gc binary (typically the value the caller +// also writes to env["GC_BIN"]). If empty or has no directory component, the +// function is a no-op. +func prependGCBinDirToPATH(env map[string]string, gcBin string) { + if gcBin == "" { + return + } + dir := filepath.Dir(gcBin) + if dir == "" || dir == "." { + return + } + sep := string(os.PathListSeparator) + base, ok := env["PATH"] + if !ok { + base = os.Getenv("PATH") + } + if base == "" { + env["PATH"] = dir + return + } + + parts := strings.Split(base, sep) + entries := []string{dir} + for _, p := range parts { + if p == dir { + continue + } + entries = append(entries, p) + } + env["PATH"] = strings.Join(entries, sep) +} diff --git a/cmd/gc/agent_env_path_test.go b/cmd/gc/agent_env_path_test.go new file mode 100644 index 0000000000..3b15db3f53 --- /dev/null +++ b/cmd/gc/agent_env_path_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrependGCBinDirToPATH_NoGCBin_NoOp(t *testing.T) { + env := map[string]string{"PATH": "/usr/bin:/bin"} + prependGCBinDirToPATH(env, "") + if env["PATH"] != "/usr/bin:/bin" { + t.Fatalf("PATH should be unchanged when GC_BIN empty, got %q", env["PATH"]) + } +} + +func TestPrependGCBinDirToPATH_AddsToExistingPATH(t *testing.T) { + env := map[string]string{"PATH": "/usr/bin:/bin"} + prependGCBinDirToPATH(env, "/Users/jbb/go/bin/gc") + want := "/Users/jbb/go/bin" + string(os.PathListSeparator) + "/usr/bin:/bin" + if env["PATH"] != want { + t.Fatalf("PATH=%q, want %q", env["PATH"], want) + } +} + +func TestPrependGCBinDirToPATH_FallsBackToOSPATH(t *testing.T) { + env := map[string]string{} + t.Setenv("PATH", "/usr/bin:/bin") + prependGCBinDirToPATH(env, "/opt/gc/bin/gc") + want := "/opt/gc/bin" + string(os.PathListSeparator) + "/usr/bin:/bin" + if env["PATH"] != want { + t.Fatalf("PATH=%q, want %q", env["PATH"], want) + } +} + +func TestPrependGCBinDirToPATH_ExplicitEmptyPATHUsesOnlyGCBinDir(t *testing.T) { + dir := "/opt/gc/bin" + env := map[string]string{"PATH": ""} + prependGCBinDirToPATH(env, filepath.Join(dir, "gc")) + if env["PATH"] != dir { + t.Fatalf("PATH=%q, want only gc bin dir %q", env["PATH"], dir) + } +} + +func TestPrependGCBinDirToPATH_UnsetPATHWithEmptyOSPATHUsesOnlyGCBinDir(t *testing.T) { + dir := "/opt/gc/bin" + env := map[string]string{} + t.Setenv("PATH", "") + prependGCBinDirToPATH(env, filepath.Join(dir, "gc")) + if env["PATH"] != dir { + t.Fatalf("PATH=%q, want only gc bin dir %q", env["PATH"], dir) + } +} + +func TestPrependGCBinDirToPATH_AlreadyFirst_NoDuplicate(t *testing.T) { + dir := "/Users/jbb/go/bin" + env := map[string]string{"PATH": dir + string(os.PathListSeparator) + "/usr/bin"} + prependGCBinDirToPATH(env, filepath.Join(dir, "gc")) + parts := strings.Split(env["PATH"], string(os.PathListSeparator)) + if parts[0] != dir { + t.Fatalf("first PATH entry %q, want %q", parts[0], dir) + } + count := 0 + for _, p := range parts { + if p == dir { + count++ + } + } + if count != 1 { + t.Fatalf("dir %q should appear exactly once, found %d times in %q", dir, count, env["PATH"]) + } +} + +func TestPrependGCBinDirToPATH_PresentNotFirst_MovesToFront(t *testing.T) { + dir := "/Users/jbb/go/bin" + env := map[string]string{"PATH": "/opt/homebrew/bin" + string(os.PathListSeparator) + dir + string(os.PathListSeparator) + "/usr/bin"} + prependGCBinDirToPATH(env, filepath.Join(dir, "gc")) + parts := strings.Split(env["PATH"], string(os.PathListSeparator)) + if parts[0] != dir { + t.Fatalf("first PATH entry %q, want %q (full PATH=%q)", parts[0], dir, env["PATH"]) + } + count := 0 + for _, p := range parts { + if p == dir { + count++ + } + } + if count != 1 { + t.Fatalf("dir %q should appear exactly once, found %d times in %q", dir, count, env["PATH"]) + } +} + +func TestPrependGCBinDirToPATH_PreservesLeadingEmptyEntry(t *testing.T) { + dir := "/Users/jbb/go/bin" + sep := string(os.PathListSeparator) + env := map[string]string{"PATH": sep + "/usr/bin"} + prependGCBinDirToPATH(env, filepath.Join(dir, "gc")) + want := dir + sep + sep + "/usr/bin" + if env["PATH"] != want { + t.Fatalf("PATH=%q, want %q", env["PATH"], want) + } +} + +func TestPrependGCBinDirToPATH_EmptyDir_NoOp(t *testing.T) { + // edge: GC_BIN is just "gc" with no directory part — skip prepend. + env := map[string]string{"PATH": "/usr/bin"} + prependGCBinDirToPATH(env, "gc") + if env["PATH"] != "/usr/bin" { + t.Fatalf("PATH should be unchanged when GC_BIN has no dir, got %q", env["PATH"]) + } +} diff --git a/cmd/gc/api_state.go b/cmd/gc/api_state.go index db26edae92..06e03a32a6 100644 --- a/cmd/gc/api_state.go +++ b/cmd/gc/api_state.go @@ -25,6 +25,7 @@ import ( "github.com/gastownhall/gascity/internal/mail" "github.com/gastownhall/gascity/internal/orders" "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/workspacesvc" ) @@ -51,8 +52,17 @@ type controllerState struct { services workspacesvc.Registry extmsgSvc *extmsg.Services adapterReg *extmsg.AdapterRegistry + updateMu sync.Mutex // serializes rebuild+swap so stale reloads cannot overtake newer mutations + + // True after an API config mutation refreshes controller state ahead of the + // runtime reload loop. Runtime reloads from older revisions are ignored + // until the loop observes and applies the same or a newer on-disk config. + configMutationPending atomic.Bool + pendingConfigRev string } +var controllerStateInitRigDirIfReady = initDirIfReady + type configMutationSnapshot struct { cityPath string files map[string][]byte @@ -179,7 +189,7 @@ func (cs *controllerState) buildStores(cfg *config.City) map[string]beads.Store if sharedLegacyFileStore != nil && scopeProvider == "file" && !scopeUsesFileStoreContract(scopeRoot) { store = sharedLegacyFileStore } else { - store = cs.openRigStore(scopeProvider, rig.Name, scopeRoot, rig.EffectivePrefix()) + store = cs.openRigStore(scopeProvider, rig.Name, scopeRoot, rig.EffectivePrefix(), cfg) } stores[rig.Name] = wrapWithCachingStore(cs.cacheCtx, store, cs.eventProv) } @@ -187,7 +197,7 @@ func (cs *controllerState) buildStores(cfg *config.City) map[string]beads.Store } // openRigStore creates a bead store for a rig path using the given provider. -func (cs *controllerState) openRigStore(provider, rigName, rigPath, prefix string) beads.Store { +func (cs *controllerState) openRigStore(provider, rigName, rigPath, prefix string, cfg *config.City) beads.Store { scopeRoot := resolveStoreScopeRoot(cs.cityPath, rigPath) if strings.HasPrefix(provider, "exec:") { s := beadsexec.NewStore(strings.TrimPrefix(provider, "exec:")) @@ -207,7 +217,7 @@ func (cs *controllerState) openRigStore(provider, rigName, rigPath, prefix strin } return store default: // "bd" or unrecognized - return bdStoreForRig(scopeRoot, cs.cityPath, cs.cfg) + return bdStoreForRig(scopeRoot, cs.cityPath, cfg, prefix) } } @@ -250,6 +260,29 @@ func (cs *controllerState) applyBeadEventToStores(evt events.Event) { return } cs.mu.RLock() + stores := cs.beadEventStoresLocked(evt) + cs.mu.RUnlock() + + for _, store := range stores { + if cached, ok := store.(*beads.CachingStore); ok { + cached.ApplyEvent(evt.Type, evt.Payload) + } + } + if evt.Actor != "cache-reconcile" { + cs.Poke() + } +} + +func (cs *controllerState) beadEventStoresLocked(evt events.Event) []beads.Store { + if id := beadEventID(evt); id != "" && cs.cfg != nil { + if store, known := cs.beadEventConfiguredStoreLocked(id); known { + if store == nil { + return nil + } + return []beads.Store{store} + } + } + stores := make([]beads.Store, 0, len(cs.beadStores)+1) for _, s := range cs.beadStores { stores = append(stores, s) @@ -257,21 +290,47 @@ func (cs *controllerState) applyBeadEventToStores(evt events.Event) { if cs.cityBeadStore != nil { stores = append(stores, cs.cityBeadStore) } - cs.mu.RUnlock() + return stores +} - for _, store := range stores { - if cached, ok := store.(*beads.CachingStore); ok { - cached.ApplyEvent(evt.Type, evt.Payload) +func (cs *controllerState) beadEventConfiguredStoreLocked(id string) (beads.Store, bool) { + var matchedStore beads.Store + matchedLen := -1 + match := func(prefix string, store beads.Store) { + if prefix == "" || !strings.HasPrefix(id, prefix+"-") { + return + } + if len(prefix) > matchedLen { + matchedLen = len(prefix) + matchedStore = store } } - if evt.Actor != "cache-reconcile" { - cs.Poke() + match(config.EffectiveHQPrefix(cs.cfg), cs.cityBeadStore) + for _, rig := range cs.cfg.Rigs { + match(rig.EffectivePrefix(), cs.beadStores[rig.Name]) + } + return matchedStore, matchedLen >= 0 +} + +func beadEventID(evt events.Event) string { + id := strings.TrimSpace(evt.Subject) + if id == "" { + var payload struct { + ID string `json:"id"` + } + if err := json.Unmarshal(evt.Payload, &payload); err == nil { + id = strings.TrimSpace(payload.ID) + } } + return id } // update replaces the config, session provider, and reopens stores. // Stores are built outside the lock to avoid blocking readers during I/O. func (cs *controllerState) update(cfg *config.City, sp runtime.Provider) { + cs.updateMu.Lock() + defer cs.updateMu.Unlock() + // Build new stores outside the lock (may do file I/O / subprocess spawns). stores := cs.buildStores(cfg) // Reopen city-level store for session beads and mail. @@ -304,6 +363,197 @@ func (cs *controllerState) update(cfg *config.City, sp runtime.Provider) { cs.mu.Unlock() } +func (cs *controllerState) updateFromRuntime(cfg *config.City, sp runtime.Provider, revision string) { + if cs.configMutationPending.Load() { + matchesPending, stale := cs.runtimeUpdateStatusForPendingMutation(revision) + if stale { + return + } + if matchesPending { + if cs.runtimeUpdateDropsPendingRigs(cfg) { + return + } + if cs.runtimeUpdateCanReuseCurrentStores(cfg) { + cs.updateConfigAndProviderOnly(cfg, sp) + cs.clearConfigMutationPending() + return + } + } + } else if cs.runtimeUpdateRevisionIsStale(revision) { + return + } + if cs.runtimeUpdateCanReuseCurrentStores(cfg) { + cs.updateConfigAndProviderOnly(cfg, sp) + cs.clearConfigMutationPending() + return + } + cs.update(cfg, sp) + cs.clearConfigMutationPending() +} + +func (cs *controllerState) updateConfigAndProviderOnly(cfg *config.City, sp runtime.Provider) { + cs.updateMu.Lock() + defer cs.updateMu.Unlock() + + cs.mu.Lock() + cs.cfg = cfg + cs.sp = sp + cs.mu.Unlock() +} + +func (cs *controllerState) runtimeUpdateCanReuseCurrentStores(next *config.City) bool { + cs.mu.RLock() + current := cs.cfg + cityStore := cs.cityBeadStore + stores := make(map[string]beads.Store, len(cs.beadStores)) + for name, store := range cs.beadStores { + stores[name] = store + } + cs.mu.RUnlock() + + if cityStore == nil || !sameStoreTopology(cs.cityPath, current, next) { + return false + } + for _, rig := range next.Rigs { + if strings.TrimSpace(rig.Path) == "" { + continue + } + if stores[rig.Name] == nil { + return false + } + } + return true +} + +func (cs *controllerState) runtimeUpdateDropsPendingRigs(next *config.City) bool { + cs.mu.RLock() + current := cs.cfg + cs.mu.RUnlock() + return configDropsBoundRigs(current, next) +} + +func (cs *controllerState) runtimeUpdateStatusForPendingMutation(revision string) (matchesPending, stale bool) { + pendingRev := cs.pendingConfigRevision() + if pendingRev == "" { + return false, true + } + if revision == "" { + return false, true + } + if revision == pendingRev { + return true, false + } + currentRev, err := cs.currentConfigRevision() + if err != nil || currentRev != revision { + return false, true + } + return false, false +} + +func (cs *controllerState) runtimeUpdateRevisionIsStale(revision string) bool { + if revision == "" { + return false + } + currentRev, err := cs.currentConfigRevision() + return err != nil || currentRev != revision +} + +func (cs *controllerState) pendingConfigRevision() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + return cs.pendingConfigRev +} + +func (cs *controllerState) currentConfigRevision() (string, error) { + if cs.cityPath == "" { + return "", nil + } + _, revision, err := cs.loadCurrentConfigSnapshot() + if err != nil { + return "", fmt.Errorf("loading current city config: %w", err) + } + return revision, nil +} + +func (cs *controllerState) markConfigMutationPending(revision string) { + cs.mu.Lock() + cs.pendingConfigRev = revision + cs.mu.Unlock() + cs.configMutationPending.Store(true) +} + +func (cs *controllerState) clearConfigMutationPending() { + cs.mu.Lock() + cs.pendingConfigRev = "" + cs.mu.Unlock() + cs.configMutationPending.Store(false) +} + +type storeTopologyRig struct { + path string + prefix string +} + +func sameStoreTopology(cityPath string, current, next *config.City) bool { + if current == nil || next == nil { + return false + } + if strings.TrimSpace(current.Beads.Provider) != strings.TrimSpace(next.Beads.Provider) { + return false + } + if strings.TrimSpace(current.Mail.Provider) != strings.TrimSpace(next.Mail.Provider) { + return false + } + if config.EffectiveHQPrefix(current) != config.EffectiveHQPrefix(next) { + return false + } + currentRigs := storeTopologyRigs(cityPath, current.Rigs) + nextRigs := storeTopologyRigs(cityPath, next.Rigs) + if len(currentRigs) != len(nextRigs) { + return false + } + for name, currentRig := range currentRigs { + if nextRig, ok := nextRigs[name]; !ok || nextRig != currentRig { + return false + } + } + return true +} + +func storeTopologyRigs(cityPath string, rigs []config.Rig) map[string]storeTopologyRig { + result := make(map[string]storeTopologyRig, len(rigs)) + for _, rig := range rigs { + path := strings.TrimSpace(rig.Path) + if path != "" { + path = resolveStoreScopeRoot(cityPath, path) + } + result[rig.Name] = storeTopologyRig{ + path: path, + prefix: rig.EffectivePrefix(), + } + } + return result +} + +func configDropsBoundRigs(current, next *config.City) bool { + if current == nil || next == nil { + return false + } + nextRigPaths := make(map[string]string, len(next.Rigs)) + for _, rig := range next.Rigs { + nextRigPaths[rig.Name] = strings.TrimSpace(rig.Path) + } + for _, rig := range current.Rigs { + if strings.TrimSpace(rig.Path) == "" { + continue + } + if nextRigPaths[rig.Name] == "" { + return true + } + } + return false +} + // --- api.State implementation --- // Config returns the current city config snapshot. @@ -530,6 +780,15 @@ func (cs *controllerState) CreateAgent(a config.Agent) error { }) } +// WaitForAgentVisibility blocks until findAgent in the controller's hot-reloaded +// config snapshot resolves the given qualified agent name. CreateAgent already +// refreshes cs.cfg from disk, so the first check normally succeeds; the wait +// preserves the HTTP contract that a successful POST /agents response can be +// followed immediately by POST /sling against the same target. +func (cs *controllerState) WaitForAgentVisibility(ctx context.Context, qualifiedName string) error { + return api.WaitForAgentVisibilityIn(ctx, cs.Config, qualifiedName) +} + // UpdateAgent partially updates an existing agent definition in city.toml. func (cs *controllerState) UpdateAgent(name string, patch api.AgentUpdate) error { return cs.mutateAndPoke(func() error { @@ -550,11 +809,39 @@ func (cs *controllerState) DeleteAgent(name string) error { // CreateRig adds a new rig to city.toml. func (cs *controllerState) CreateRig(r config.Rig) error { + if err := cs.initializeRigStoreForCreate(r); err != nil { + return err + } return cs.mutateAndPoke(func() error { return cs.editor.CreateRig(r) }) } +func (cs *controllerState) initializeRigStoreForCreate(r config.Rig) error { + cityPath := strings.TrimSpace(cs.cityPath) + rigPath := strings.TrimSpace(r.Path) + if cityPath == "" || rigPath == "" { + return nil + } + + cs.mu.RLock() + cfg := cs.cfg + cs.mu.RUnlock() + if cfg != nil { + for _, existing := range cfg.Rigs { + if existing.Name == r.Name { + return fmt.Errorf("%w: rig %q", configedit.ErrAlreadyExists, r.Name) + } + } + } + + scopeRoot := resolveStoreScopeRoot(cityPath, rigPath) + if _, err := controllerStateInitRigDirIfReady(cityPath, scopeRoot, r.EffectivePrefix()); err != nil { + return fmt.Errorf("initializing rig %q beads: %w", r.Name, err) + } + return nil +} + // UpdateRig partially updates a rig in city.toml. func (cs *controllerState) UpdateRig(name string, patch api.RigUpdate) error { return cs.mutateAndPoke(func() error { @@ -739,7 +1026,8 @@ func (cs *controllerState) mutateAndPoke(mutate func() error) error { if err := mutate(); err != nil { return err } - if err := cs.refreshConfigSnapshot(); err != nil { + revision, err := cs.refreshConfigSnapshot() + if err != nil { if snapshot != nil { if restoreErr := snapshot.restore(); restoreErr != nil { restoreFailure := fmt.Errorf("restoring previous city config: %w", restoreErr) @@ -748,6 +1036,7 @@ func (cs *controllerState) mutateAndPoke(mutate func() error) error { } return fmt.Errorf("refreshing updated city config: %w", err) } + cs.markConfigMutationPending(revision) if cs.configDirty != nil { cs.configDirty.Store(true) } @@ -755,24 +1044,35 @@ func (cs *controllerState) mutateAndPoke(mutate func() error) error { return nil } -func (cs *controllerState) refreshConfigSnapshot() error { +func (cs *controllerState) refreshConfigSnapshot() (string, error) { if cs.cityPath == "" || cs.cfg == nil { - return nil + return "", nil } - tomlPath := filepath.Join(cs.cityPath, "city.toml") - nextCfg, _, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath, extraConfigFiles...) + nextCfg, revision, err := cs.loadCurrentConfigSnapshot() if err != nil { - return fmt.Errorf("loading updated city config: %w", err) + return "", fmt.Errorf("loading updated city config: %w", err) + } + if revision == "" { + return "", errors.New("computed empty config revision") } - applyFeatureFlags(nextCfg) - applyRuntimeCityIdentity(nextCfg, cs.cityName) cs.mu.RLock() sp := cs.sp cs.mu.RUnlock() cs.update(nextCfg, sp) - return nil + return revision, nil +} + +func (cs *controllerState) loadCurrentConfigSnapshot() (*config.City, string, error) { + nextCfg, prov, err := loadCityConfigWithBuiltinPacks(cs.cityPath, extraConfigFiles...) + if err != nil { + return nil, "", err + } + applyFeatureFlags(nextCfg) + applyRuntimeCityIdentity(nextCfg, cs.cityName) + revision := config.Revision(fsys.OSFS{}, prov, nextCfg, cs.cityPath) + return nextCfg, revision, nil } // Poke signals the controller to trigger an immediate reconciler tick. @@ -787,6 +1087,45 @@ func (cs *controllerState) Poke() { } } +// WaitForSessionCommandable waits until the controller has reconciled an async +// session create into a lifecycle state that can accept normal commands. +func (cs *controllerState) WaitForSessionCommandable(ctx context.Context, sessionID string) (session.Info, error) { + store := cs.CityBeadStore() + if store == nil { + return session.Info{}, errors.New("city bead store is unavailable") + } + catalog, err := workerSessionCatalogWithConfig(cs.CityPath(), store, cs.SessionProvider(), cs.Config()) + if err != nil { + return session.Info{}, err + } + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + info, err := catalog.Get(sessionID) + if err != nil { + return session.Info{}, err + } + if info.Closed { + return session.Info{}, fmt.Errorf("session is closed: %s", sessionID) + } + switch info.State { + case session.StateActive, session.StateAwake, session.StateAsleep, session.StateSuspended, session.StateQuarantined: + return info, nil + case session.StateCreating, "": + default: + return session.Info{}, fmt.Errorf("session %s reached non-commandable state %q", sessionID, info.State) + } + + select { + case <-ctx.Done(): + return session.Info{}, fmt.Errorf("session %s did not become commandable: %w", sessionID, ctx.Err()) + case <-ticker.C: + } + } +} + // ServiceRegistry returns the workspace service registry. func (cs *controllerState) ServiceRegistry() workspacesvc.Registry { cs.mu.RLock() diff --git a/cmd/gc/api_state_test.go b/cmd/gc/api_state_test.go index f78d033a58..231f938293 100644 --- a/cmd/gc/api_state_test.go +++ b/cmd/gc/api_state_test.go @@ -3,12 +3,15 @@ package main import ( "context" "encoding/json" + "errors" + "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" + "time" "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/beads" @@ -136,6 +139,441 @@ func TestControllerStateUpdate(t *testing.T) { } } +func TestControllerStateRuntimeUpdateDoesNotDropPendingMutationRigs(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"city1\"\n\n[beads]\nprovider = \"file\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{Name: "alpha", Path: t.TempDir()}}, + } + stale := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + } + + cs := newControllerState(context.Background(), current, runtime.NewFake(), events.NewFake(), "city1", cityDir) + cs.markConfigMutationPending("current-rev") + + cs.updateFromRuntime(stale, runtime.NewFake(), "stale-rev") + + if got := cs.Config(); got != current { + t.Fatalf("Config() = %+v, want pending mutation config with rig alpha", got) + } + if !cs.configMutationPending.Load() { + t.Fatal("pending mutation marker cleared by stale runtime update") + } + + cs.updateFromRuntime(current, runtime.NewFake(), "current-rev") + + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker not cleared after matching runtime update") + } +} + +func TestControllerStateRuntimeUpdateDoesNotDropPendingMutationAgents(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"city1\"\n\n[beads]\nprovider = \"file\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + rigDir := t.TempDir() + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{ + {Name: "worker", Dir: "alpha", Provider: "bash"}, + {Name: "helper", Dir: "alpha", Provider: "bash"}, + }, + } + stale := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{{Name: "worker", Dir: "alpha", Provider: "bash"}}, + } + + cs := newControllerState(context.Background(), current, runtime.NewFake(), events.NewFake(), "city1", cityDir) + cs.markConfigMutationPending("current-rev") + + cs.updateFromRuntime(stale, runtime.NewFake(), "stale-rev") + + if got := cs.Config(); got != current { + t.Fatalf("Config() = %+v, want pending mutation config with helper agent", got) + } + if !cs.configMutationPending.Load() { + t.Fatal("pending mutation marker cleared by stale runtime update") + } + + cs.updateFromRuntime(current, runtime.NewFake(), "current-rev") + + if got := cs.Config(); got != current { + t.Fatalf("Config() = %+v, want matching runtime config applied", got) + } + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker not cleared after matching runtime update") + } +} + +func TestControllerStateCreatedAgentVisibleAfterStaleRuntimeInterleaving(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "alpha") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatalf("mkdir rig: %v", err) + } + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Beads: config.BeadsConfig{Provider: "file"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{{Name: "worker", Dir: "alpha", Provider: "bash"}}, + } + content, err := current.Marshal() + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), content, 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + + cs := newControllerState(context.Background(), current, runtime.NewFake(), events.NewFake(), "city1", cityDir) + if err := cs.CreateAgent(config.Agent{Name: "helper", Dir: "alpha", Provider: "bash"}); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + pendingRev := cs.pendingConfigRevision() + if pendingRev == "" { + t.Fatal("CreateAgent did not mark a pending config revision") + } + + stale := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Beads: config.BeadsConfig{Provider: "file"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{{Name: "worker", Dir: "alpha", Provider: "bash"}}, + } + cs.updateFromRuntime(stale, runtime.NewFake(), pendingRev) + if got := cs.Config(); configHasAgent(got, "alpha/helper") { + t.Fatalf("stale runtime update did not hide alpha/helper; agents = %+v", got.Agents) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + waitErr := make(chan error, 1) + go func() { + waitErr <- cs.WaitForAgentVisibility(ctx, "alpha/helper") + }() + + select { + case err := <-waitErr: + t.Fatalf("WaitForAgentVisibility returned before fresh runtime update: %v", err) + case <-time.After(100 * time.Millisecond): + } + + fresh, freshRev, err := cs.loadCurrentConfigSnapshot() + if err != nil { + t.Fatalf("load fresh config snapshot: %v", err) + } + cs.updateFromRuntime(fresh, runtime.NewFake(), freshRev) + + if err := <-waitErr; err != nil { + t.Fatalf("WaitForAgentVisibility after stale runtime update: %v", err) + } + got := cs.Config() + if !configHasAgent(got, "alpha/helper") { + t.Fatalf("agents after stale runtime update = %+v, want alpha/helper still visible", got.Agents) + } +} + +func configHasAgent(cfg *config.City, qualifiedName string) bool { + if cfg == nil { + return false + } + for _, agent := range cfg.Agents { + if agent.QualifiedName() == qualifiedName { + return true + } + } + return false +} + +func TestControllerStateRuntimeUpdateIgnoresEmptyRevisionDuringPendingMutation(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"city1\"\n\n[beads]\nprovider = \"file\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + rigDir := t.TempDir() + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{ + {Name: "worker", Dir: "alpha", Provider: "bash"}, + {Name: "helper", Dir: "alpha", Provider: "bash"}, + }, + } + stale := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{Name: "alpha", Path: rigDir}}, + Agents: []config.Agent{{Name: "worker", Dir: "alpha", Provider: "bash"}}, + } + + cs := newControllerState(context.Background(), current, runtime.NewFake(), events.NewFake(), "city1", cityDir) + cs.markConfigMutationPending("current-rev") + + cs.updateFromRuntime(stale, runtime.NewFake(), "") + + if got := cs.Config(); got != current { + t.Fatalf("Config() = %+v, want pending mutation config with helper agent", got) + } + if !cs.configMutationPending.Load() { + t.Fatal("pending mutation marker cleared by empty-revision runtime update") + } +} + +func TestControllerStateRuntimeUpdateAcceptsBuiltinAwareRevision(t *testing.T) { + configureTestDoltIdentityEnv(t) + t.Setenv("GC_BEADS", "") + + cityDir := shortSocketTempDir(t, "gc-state-runtime-builtin-") + cleanupManagedDoltTestCity(t, cityDir) + tomlPath := filepath.Join(cityDir, "city.toml") + if err := os.WriteFile(tomlPath, []byte("[workspace]\nname = \"test\"\n"), 0o644); err != nil { + t.Fatalf("write initial city.toml: %v", err) + } + + initial, err := tryReloadConfig(tomlPath, "test", cityDir) + if err != nil { + t.Fatalf("initial tryReloadConfig: %v", err) + } + applyRuntimeCityIdentity(initial.Cfg, "test") + cs := newControllerState(context.Background(), initial.Cfg, runtime.NewFake(), events.NewFake(), "test", cityDir) + + rigDir := t.TempDir() + updatedToml := fmt.Sprintf("[workspace]\nname = \"test\"\n\n[[rigs]]\nname = \"alpha\"\npath = %q\n", rigDir) + if err := os.WriteFile(tomlPath, []byte(updatedToml), 0o644); err != nil { + t.Fatalf("write updated city.toml: %v", err) + } + reloaded, err := tryReloadConfig(tomlPath, "test", cityDir) + if err != nil { + t.Fatalf("reloaded tryReloadConfig: %v", err) + } + applyRuntimeCityIdentity(reloaded.Cfg, "test") + + cs.updateFromRuntime(reloaded.Cfg, runtime.NewFake(), reloaded.Revision) + + if got := cs.Config().Rigs; len(got) != 1 || got[0].Name != "alpha" { + t.Fatalf("runtime update was not accepted; rigs = %#v", got) + } + requireControllerStateOrder(t, cs, "gate-sweep") +} + +func TestControllerStateMutationRefreshKeepsBuiltinOrdersAndClearsPending(t *testing.T) { + configureTestDoltIdentityEnv(t) + t.Setenv("GC_BEADS", "") + + cityDir := shortSocketTempDir(t, "gc-state-mutation-builtin-") + cleanupManagedDoltTestCity(t, cityDir) + tomlPath := filepath.Join(cityDir, "city.toml") + if err := os.WriteFile(tomlPath, []byte("[workspace]\nname = \"test\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + + initial, err := tryReloadConfig(tomlPath, "test", cityDir) + if err != nil { + t.Fatalf("tryReloadConfig: %v", err) + } + applyRuntimeCityIdentity(initial.Cfg, "test") + cs := newControllerState(context.Background(), initial.Cfg, runtime.NewFake(), events.NewFake(), "test", cityDir) + + if err := cs.EnableOrder("gate-sweep", ""); err != nil { + t.Fatalf("EnableOrder: %v", err) + } + requireControllerStateOrder(t, cs, "gate-sweep") + if !cs.configMutationPending.Load() { + t.Fatal("pending mutation marker was not set") + } + + reloaded, err := tryReloadConfig(tomlPath, "test", cityDir) + if err != nil { + t.Fatalf("tryReloadConfig after mutation: %v", err) + } + applyRuntimeCityIdentity(reloaded.Cfg, "test") + cs.updateFromRuntime(reloaded.Cfg, runtime.NewFake(), reloaded.Revision) + + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker was not cleared by matching runtime update") + } + requireControllerStateOrder(t, cs, "gate-sweep") +} + +func requireControllerStateOrder(t *testing.T, cs *controllerState, want string) { + t.Helper() + + for _, order := range cs.Orders() { + if order.Name == want { + return + } + } + t.Fatalf("Orders() missing %q", want) +} + +func TestControllerStateRuntimeUpdateAfterMutationPreservesCurrentStores(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "alpha") + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + rigStore := beads.NewMemStore() + cityStore := beads.NewMemStore() + cs := &controllerState{ + cfg: current, + sp: runtime.NewFake(), + beadStores: map[string]beads.Store{"alpha": rigStore}, + cityBeadStore: cityStore, + cityName: "city1", + cityPath: cityDir, + } + cs.markConfigMutationPending("next-rev") + + next := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + cs.updateFromRuntime(next, runtime.NewFake(), "next-rev") + + if got := cs.BeadStore("alpha"); got != rigStore { + t.Fatalf("BeadStore(alpha) = %T %p, want original store %T %p", got, got, rigStore, rigStore) + } + if got := cs.CityBeadStore(); got != cityStore { + t.Fatalf("CityBeadStore() = %T %p, want original store %T %p", got, got, cityStore, cityStore) + } + if cs.Config() != next { + t.Fatal("Config() was not advanced to runtime snapshot") + } + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker not cleared after matching runtime update") + } +} + +func TestControllerStateRuntimeUpdatePreservesCurrentStoresWithoutPendingMutation(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "alpha") + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + rigStore := beads.NewMemStore() + cityStore := beads.NewMemStore() + cs := &controllerState{ + cfg: current, + sp: runtime.NewFake(), + beadStores: map[string]beads.Store{"alpha": rigStore}, + cityBeadStore: cityStore, + cityName: "city1", + cityPath: cityDir, + } + + next := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + nextProvider := runtime.NewFake() + cs.updateFromRuntime(next, nextProvider, "") + + if got := cs.BeadStore("alpha"); got != rigStore { + t.Fatalf("BeadStore(alpha) = %T %p, want original store %T %p", got, got, rigStore, rigStore) + } + if got := cs.CityBeadStore(); got != cityStore { + t.Fatalf("CityBeadStore() = %T %p, want original store %T %p", got, got, cityStore, cityStore) + } + if cs.Config() != next { + t.Fatal("Config() was not advanced to runtime snapshot") + } + if cs.SessionProvider() != nextProvider { + t.Fatal("SessionProvider() was not advanced to runtime provider") + } +} + +func TestControllerStateRuntimeUpdateIgnoresStaleRevisionWithoutPendingMutation(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "alpha") + cityToml := fmt.Sprintf(`[workspace] +name = "city1" + +[beads] +provider = "file" + +[[rigs]] +name = "alpha" +path = %q +prefix = "al" + +[[agent]] +name = "worker" +dir = "alpha" +provider = "bash" +`, rigDir) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + current := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Beads: config.BeadsConfig{Provider: "file"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + Agents: []config.Agent{{Name: "worker", Dir: "alpha", Provider: "bash"}}, + } + stale := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Beads: config.BeadsConfig{Provider: "file"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + originalProvider := runtime.NewFake() + cs := newControllerState(context.Background(), current, originalProvider, events.NewFake(), "city1", cityDir) + + cs.updateFromRuntime(stale, runtime.NewFake(), "stale-rev") + + if got := cs.Config(); got != current { + t.Fatalf("Config() = %+v, want current config with worker agent", got) + } + if cs.SessionProvider() != originalProvider { + t.Fatal("SessionProvider() advanced for stale runtime update") + } + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker set by stale runtime update") + } +} + func TestControllerStateCreateRigPokesReconciler(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -167,6 +605,46 @@ func TestControllerStateCreateRigPokesReconciler(t *testing.T) { } } +func TestControllerStateCreateRigInitializesStoreBeforePublishing(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"city1\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + if err := ensureScopedFileStoreLayout(cityDir); err != nil { + t.Fatalf("enable scoped file store layout: %v", err) + } + if err := ensurePersistedScopeLocalFileStore(cityDir); err != nil { + t.Fatalf("init city store: %v", err) + } + + cfg := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + } + cs := newControllerState(context.Background(), cfg, runtime.NewFake(), events.NewFake(), "city1", cityDir) + + rigDir := filepath.Join(cityDir, "alpha") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatalf("mkdir rig: %v", err) + } + if err := cs.CreateRig(config.Rig{Name: "alpha", Path: rigDir, Prefix: "al"}); err != nil { + t.Fatalf("CreateRig: %v", err) + } + + store := cs.BeadStore("alpha") + if store == nil { + t.Fatal("BeadStore(alpha) = nil") + } + created, err := store.Create(beads.Bead{Title: "first rig bead", Type: "task"}) + if err != nil { + t.Fatalf("newly published rig store Create: %v", err) + } + if _, err := store.Get(created.ID); err != nil { + t.Fatalf("newly published rig store Get(%q): %v", created.ID, err) + } +} + func TestControllerStateMutationRollsBackWhenRefreshFails(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -308,6 +786,133 @@ func TestControllerStateAppliesCacheReconcileBeadEventsToStores(t *testing.T) { } } +func TestControllerStateBeadEventsRespectStorePrefixes(t *testing.T) { + cityBacking := beads.NewMemStore() + rigBacking := beads.NewMemStore() + cityCache := beads.NewCachingStoreForTestWithPrefix(cityBacking, "mc", nil) + rigCache := beads.NewCachingStoreForTestWithPrefix(rigBacking, "ga", nil) + for name, cache := range map[string]*beads.CachingStore{ + "city": cityCache, + "rig": rigCache, + } { + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime(%s): %v", name, err) + } + } + + payload, err := json.Marshal(beads.Bead{ + ID: "mc-source", + Title: "city source", + Status: "open", + }) + if err != nil { + t.Fatalf("marshal city bead: %v", err) + } + cs := &controllerState{ + cityBeadStore: cityCache, + beadStores: map[string]beads.Store{"gascity": rigCache}, + pokeCh: make(chan struct{}, 1), + } + + cs.applyBeadEventToStores(events.Event{ + Type: events.BeadCreated, + Actor: "bd-hook", + Subject: "mc-source", + Payload: payload, + }) + + cityItems, err := cityCache.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List city cache: %v", err) + } + if len(cityItems) != 1 || cityItems[0].ID != "mc-source" { + t.Fatalf("city cache items = %+v, want mc-source", cityItems) + } + rigItems, err := rigCache.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List rig cache: %v", err) + } + if len(rigItems) != 0 { + t.Fatalf("rig cache items = %+v, want no city bead", rigItems) + } + + payload, err = json.Marshal(beads.Bead{ + ID: "ga-rig", + Title: "rig work", + Status: "open", + }) + if err != nil { + t.Fatalf("marshal rig bead: %v", err) + } + + cs.applyBeadEventToStores(events.Event{ + Type: events.BeadCreated, + Actor: "bd-hook", + Subject: "ga-rig", + Payload: payload, + }) + + cityItems, err = cityCache.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List city cache after rig event: %v", err) + } + if len(cityItems) != 1 || cityItems[0].ID != "mc-source" { + t.Fatalf("city cache items after rig event = %+v, want only mc-source", cityItems) + } + rigItems, err = rigCache.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List rig cache after rig event: %v", err) + } + if len(rigItems) != 1 || rigItems[0].ID != "ga-rig" { + t.Fatalf("rig cache items after rig event = %+v, want ga-rig", rigItems) + } +} + +func TestControllerStateBeadEventsUseScopePrefixWhenConfiguredPrefixDrifts(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "rigs", "repo") + if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigDir, ".beads", "config.yaml"), []byte("issue_prefix: repo\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.City{Rigs: []config.Rig{{Name: "repo", Path: "rigs/repo", Prefix: "ga"}}} + bdStore := bdStoreForRig(rigDir, cityDir, cfg, cfg.Rigs[0].EffectivePrefix()) + rigCache := beads.NewCachingStoreForTestWithPrefix(beads.NewMemStore(), bdStore.IDPrefix(), nil) + if err := rigCache.Prime(context.Background()); err != nil { + t.Fatalf("Prime rig cache: %v", err) + } + + payload, err := json.Marshal(beads.Bead{ + ID: "repo-owned", + Title: "rig-owned work", + Status: "open", + }) + if err != nil { + t.Fatalf("marshal rig bead: %v", err) + } + cs := &controllerState{ + beadStores: map[string]beads.Store{"repo": rigCache}, + pokeCh: make(chan struct{}, 1), + } + + cs.applyBeadEventToStores(events.Event{ + Type: events.BeadCreated, + Actor: "bd-hook", + Subject: "repo-owned", + Payload: payload, + }) + + rigItems, err := rigCache.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List rig cache: %v", err) + } + if len(rigItems) != 1 || rigItems[0].ID != "repo-owned" { + t.Fatalf("rig cache items = %+v, want repo-owned", rigItems) + } +} + func TestControllerStateBuildStoresUsesScopeLocalFileStores(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -365,6 +970,80 @@ func TestControllerStateBuildStoresUsesScopeLocalFileStores(t *testing.T) { } } +func TestControllerStateAppliesBeadEventsOnlyToOwningCache(t *testing.T) { + cityBacking := beads.NewMemStore() + rigBacking := beads.NewMemStore() + cityStore := beads.NewCachingStoreForTest(cityBacking, nil) + rigStore := beads.NewCachingStoreForTest(rigBacking, nil) + if err := cityStore.Prime(context.Background()); err != nil { + t.Fatalf("city Prime: %v", err) + } + if err := rigStore.Prime(context.Background()); err != nil { + t.Fatalf("rig Prime: %v", err) + } + + cs := &controllerState{ + cfg: &config.City{ + Workspace: config.Workspace{Name: "test-city", Prefix: "ct"}, + Rigs: []config.Rig{{Name: "rig1", Prefix: "rw"}}, + }, + cityName: "test-city", + cityBeadStore: cityStore, + beadStores: map[string]beads.Store{"rig1": rigStore}, + } + + cs.applyBeadEventToStores(events.Event{ + Type: events.BeadCreated, + Subject: "rw-1", + Payload: json.RawMessage(`{"id":"rw-1","title":"rig bead","status":"open","issue_type":"task","created_at":"2026-04-26T21:37:46Z"}`), + }) + + if _, err := cityStore.Get("rw-1"); !errors.Is(err, beads.ErrNotFound) { + t.Fatalf("city cache Get(rw-1) error = %v, want ErrNotFound", err) + } + if got, err := rigStore.Get("rw-1"); err != nil { + t.Fatalf("rig cache Get(rw-1): %v", err) + } else if got.Title != "rig bead" { + t.Fatalf("rig cache title = %q, want rig bead", got.Title) + } +} + +func TestControllerStateAppliesHyphenatedPrefixEventsOnlyToOwningCache(t *testing.T) { + cityStore := beads.NewCachingStoreForTest(beads.NewMemStore(), nil) + rigStore := beads.NewCachingStoreForTest(beads.NewMemStore(), nil) + if err := cityStore.Prime(context.Background()); err != nil { + t.Fatalf("city Prime: %v", err) + } + if err := rigStore.Prime(context.Background()); err != nil { + t.Fatalf("rig Prime: %v", err) + } + + cs := &controllerState{ + cfg: &config.City{ + Workspace: config.Workspace{Name: "test-city", Prefix: "mlcm"}, + Rigs: []config.Rig{{Name: "rig1", Prefix: "mc-mogbzvrs"}}, + }, + cityName: "test-city", + cityBeadStore: cityStore, + beadStores: map[string]beads.Store{"rig1": rigStore}, + } + + cs.applyBeadEventToStores(events.Event{ + Type: events.BeadCreated, + Subject: "mc-mogbzvrs-hiv.1", + Payload: json.RawMessage(`{"id":"mc-mogbzvrs-hiv.1","title":"rig bead","status":"open","issue_type":"task","created_at":"2026-04-26T21:37:46Z"}`), + }) + + if _, err := cityStore.Get("mc-mogbzvrs-hiv.1"); !errors.Is(err, beads.ErrNotFound) { + t.Fatalf("city cache Get(hyphenated rig bead) error = %v, want ErrNotFound", err) + } + if got, err := rigStore.Get("mc-mogbzvrs-hiv.1"); err != nil { + t.Fatalf("rig cache Get(hyphenated rig bead): %v", err) + } else if got.Title != "rig bead" { + t.Fatalf("rig cache title = %q, want rig bead", got.Title) + } +} + func TestControllerStateBuildStoresFileStoresUseLockFiles(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -582,7 +1261,7 @@ func TestControllerStateOpenRigStoreFileOpenErrorDoesNotFallbackToBd(t *testing. } cs := &controllerState{cityPath: cityDir} - store := cs.openRigStore("file", "rig1", rigDir, "rg") + store := cs.openRigStore("file", "rig1", rigDir, "rg", nil) if _, ok := store.(*beads.BdStore); ok { t.Fatalf("openRigStore returned %T, want file-open failure instead of bd fallback", store) } @@ -1320,6 +1999,62 @@ func TestBuildStores_ExecProviderSetsPerRigEnv(t *testing.T) { } } +func TestBuildStoresBdProviderUsesPassedConfigForRigEnv(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "alpha") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + + capturePath := filepath.Join(t.TempDir(), "bd.env") + binDir := t.TempDir() + fakeBD := filepath.Join(binDir, "bd") + script := "#!/bin/sh\n" + + "printf 'GC_RIG=%s\\nGC_RIG_ROOT=%s\\nBEADS_DIR=%s\\n' \"${GC_RIG:-}\" \"${GC_RIG_ROOT:-}\" \"${BEADS_DIR:-}\" > \"$BD_ENV_CAPTURE\"\n" + + "printf '[]\\n'\n" + if err := os.WriteFile(fakeBD, []byte(script), 0o755); err != nil { + t.Fatalf("write fake bd: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BD_ENV_CAPTURE", capturePath) + t.Setenv("GC_BEADS", "bd") + + staleCfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + nextCfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + cs := &controllerState{ + cfg: staleCfg, + cityName: "test-city", + cityPath: cityDir, + } + + stores := cs.buildStores(nextCfg) + if stores["alpha"] == nil { + t.Fatal("buildStores did not create alpha store") + } + + data, err := os.ReadFile(capturePath) + if err != nil { + t.Fatalf("read captured bd env: %v", err) + } + env := string(data) + if !strings.Contains(env, "GC_RIG=alpha\n") { + t.Fatalf("captured env missing GC_RIG=alpha; got:\n%s", env) + } + if !strings.Contains(env, "GC_RIG_ROOT="+rigDir+"\n") { + t.Fatalf("captured env missing rig root %q; got:\n%s", rigDir, env) + } + if !strings.Contains(env, "BEADS_DIR="+filepath.Join(rigDir, ".beads")+"\n") { + t.Fatalf("captured env missing rig BEADS_DIR; got:\n%s", env) + } +} + // Verify controllerState satisfies the api.State interface at compile time. // This uses a blank import check, not an explicit runtime assertion. var _ interface { diff --git a/cmd/gc/assigned_work_scope.go b/cmd/gc/assigned_work_scope.go new file mode 100644 index 0000000000..dee5a2aa4b --- /dev/null +++ b/cmd/gc/assigned_work_scope.go @@ -0,0 +1,156 @@ +package main + +import ( + "strings" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" +) + +func assignedWorkStoreRefForAgent(cityPath string, cfg *config.City, agentCfg *config.Agent) string { + if cfg == nil || agentCfg == nil { + return "" + } + return configuredRigName(cityPath, agentCfg, cfg.Rigs) +} + +func assignedWorkIndexReachableFromAgent(cityPath string, cfg *config.City, agentCfg *config.Agent, storeRefs []string, index int) bool { + if len(storeRefs) == 0 { + return true + } + if index < 0 || index >= len(storeRefs) { + return false + } + return storeRefs[index] == assignedWorkStoreRefForAgent(cityPath, cfg, agentCfg) +} + +func filterAssignedWorkBeadsForPoolDemand( + cfg *config.City, + cityPath string, + sessionBeads []beads.Bead, + assignedWorkBeads []beads.Bead, + assignedWorkStoreRefs []string, +) []beads.Bead { + if len(assignedWorkBeads) == 0 || len(assignedWorkStoreRefs) == 0 { + return assignedWorkBeads + } + if cfg == nil { + return assignedWorkBeads + } + assigneeToSessionBeadID := make(map[string]string) + sessionBeadTemplate := make(map[string]string) + for _, sb := range sessionBeads { + if sb.Status == "closed" { + continue + } + template := normalizedSessionTemplate(sb, cfg) + if template == "" { + template = strings.TrimSpace(sb.Metadata["template"]) + } + if template != "" { + sessionBeadTemplate[sb.ID] = template + } + assigneeToSessionBeadID[sb.ID] = sb.ID + if sessionName := strings.TrimSpace(sb.Metadata["session_name"]); sessionName != "" { + assigneeToSessionBeadID[sessionName] = sb.ID + } + if identity := strings.TrimSpace(sb.Metadata["configured_named_identity"]); identity != "" { + assigneeToSessionBeadID[identity] = sb.ID + } + } + filtered := make([]beads.Bead, 0, len(assignedWorkBeads)) + for i, wb := range assignedWorkBeads { + template := strings.TrimSpace(wb.Metadata["gc.routed_to"]) + if template == "" { + if sessionBeadID := assigneeToSessionBeadID[strings.TrimSpace(wb.Assignee)]; sessionBeadID != "" { + template = sessionBeadTemplate[sessionBeadID] + if template == "" && len(cfg.Agents) == 1 { + template = cfg.Agents[0].QualifiedName() + } + } + } + if template == "" { + continue + } + agentCfg := findAgentByTemplate(cfg, template) + if agentCfg == nil { + continue + } + if assignedWorkIndexReachableFromAgent(cityPath, cfg, agentCfg, assignedWorkStoreRefs, i) { + filtered = append(filtered, wb) + } + } + return filtered +} + +func filterAssignedWorkBeadsForSessionWake( + cfg *config.City, + cityPath string, + sessionBeads []beads.Bead, + assignedWorkBeads []beads.Bead, + assignedWorkStoreRefs []string, +) []beads.Bead { + if len(assignedWorkBeads) == 0 || len(assignedWorkStoreRefs) == 0 { + return assignedWorkBeads + } + if cfg == nil { + return assignedWorkBeads + } + reachableRefsByAssignee := make(map[string]map[string]struct{}) + add := func(identifier, storeRef string) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return + } + refs := reachableRefsByAssignee[identifier] + if refs == nil { + refs = make(map[string]struct{}) + reachableRefsByAssignee[identifier] = refs + } + refs[storeRef] = struct{}{} + } + + for i := range cfg.NamedSessions { + identity := cfg.NamedSessions[i].QualifiedName() + spec, ok := findNamedSessionSpec(cfg, "", identity) + if !ok { + continue + } + add(identity, assignedWorkStoreRefForAgent(cityPath, cfg, spec.Agent)) + } + for _, sb := range sessionBeads { + if sb.Status == "closed" { + continue + } + template := normalizedSessionTemplate(sb, cfg) + if template == "" { + template = strings.TrimSpace(sb.Metadata["template"]) + } + agentCfg := findAgentByTemplate(cfg, template) + if agentCfg == nil { + continue + } + storeRef := assignedWorkStoreRefForAgent(cityPath, cfg, agentCfg) + add(sb.ID, storeRef) + add(sb.Metadata["session_name"], storeRef) + add(sb.Metadata["configured_named_identity"], storeRef) + add(template, storeRef) + } + + filtered := make([]beads.Bead, 0, len(assignedWorkBeads)) + for i, wb := range assignedWorkBeads { + if i >= len(assignedWorkStoreRefs) { + continue + } + assignee := strings.TrimSpace(wb.Assignee) + if assignee == "" { + continue + } + if refs := reachableRefsByAssignee[assignee]; refs != nil { + if _, ok := refs[assignedWorkStoreRefs[i]]; ok { + filtered = append(filtered, wb) + } + } + } + return filtered +} diff --git a/cmd/gc/assigned_work_scope_test.go b/cmd/gc/assigned_work_scope_test.go new file mode 100644 index 0000000000..9c57fc24b9 --- /dev/null +++ b/cmd/gc/assigned_work_scope_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" +) + +func TestFilterAssignedWorkBeadsForSessionWakeKeepsOnlyReachableAssigneeSources(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + cfg := &config.City{ + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "riga", + }}, + NamedSessions: []config.NamedSession{{ + Template: "worker", + Dir: "riga", + Mode: "on_demand", + }}, + } + sessions := []beads.Bead{{ + ID: "session-1", + Status: "open", + Type: sessionBeadType, + Metadata: map[string]string{ + "template": "riga/worker", + "session_name": "worker-session", + "configured_named_identity": "riga/worker", + }, + }} + work := []beads.Bead{ + {ID: "city-named", Status: "open", Assignee: "riga/worker"}, + {ID: "rig-named", Status: "open", Assignee: "riga/worker"}, + {ID: "city-session", Status: "in_progress", Assignee: "session-1"}, + {ID: "rig-session", Status: "in_progress", Assignee: "session-1"}, + } + storeRefs := []string{"", "riga", "", "riga"} + + got := filterAssignedWorkBeadsForSessionWake(cfg, cityPath, sessions, work, storeRefs) + + if len(got) != 2 { + t.Fatalf("filtered work length = %d, want 2: %#v", len(got), got) + } + if got[0].ID != "rig-named" || got[1].ID != "rig-session" { + t.Fatalf("filtered work IDs = [%s %s], want [rig-named rig-session]", got[0].ID, got[1].ID) + } +} + +func TestFilterAssignedWorkBeadsForPoolDemandKeepsDirectAssigneeAfterTemplateFallback(t *testing.T) { + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "worker", + }}, + } + sessions := []beads.Bead{{ + ID: "session-1", + Status: "open", + Type: sessionBeadType, + Metadata: map[string]string{ + "template": "worker", + "session_name": "worker-session", + }, + }} + work := []beads.Bead{{ + ID: "direct-assigned", + Status: "in_progress", + Assignee: "session-1", + Metadata: map[string]string{}, + }} + + got := filterAssignedWorkBeadsForPoolDemand(cfg, "", sessions, work, []string{""}) + + if len(got) != 1 || got[0].ID != "direct-assigned" { + t.Fatalf("filtered work = %#v, want direct-assigned work preserved through template fallback", got) + } +} + +func TestFilterAssignedWorkBeadsForPoolDemandDropsDirectAssigneeFromUnreachableStore(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + cfg := &config.City{ + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "worker", + }}, + } + sessions := []beads.Bead{{ + ID: "session-1", + Status: "open", + Type: sessionBeadType, + Metadata: map[string]string{ + "template": "worker", + "session_name": "worker-session", + }, + }} + work := []beads.Bead{{ + ID: "rig-direct-assigned", + Status: "in_progress", + Assignee: "session-1", + Metadata: map[string]string{}, + }} + + got := filterAssignedWorkBeadsForPoolDemand(cfg, cityPath, sessions, work, []string{"riga"}) + + if len(got) != 0 { + t.Fatalf("filtered work = %#v, want unreachable rig-store direct assignment dropped", got) + } +} + +func TestSessionHasOpenAssignedWorkUsesOnlyReachableStore(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + cfg := &config.City{ + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "riga", + }}, + } + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + session := beads.Bead{ + ID: "session-1", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "template": "riga/worker", + "session_name": "worker-session", + }, + } + if _, err := cityStore.Create(beads.Bead{ + ID: "city-work", + Type: "task", + Status: "open", + Assignee: session.ID, + }); err != nil { + t.Fatalf("Create city work: %v", err) + } + + has, err := sessionHasOpenAssignedWorkForReachableStore(cityPath, cfg, cityStore, map[string]beads.Store{"riga": rigStore}, session) + if err != nil { + t.Fatalf("sessionHasOpenAssignedWorkForReachableStore: %v", err) + } + if has { + t.Fatal("city-store assigned work should not count for a rig-scoped session") + } + + if _, err := rigStore.Create(beads.Bead{ + ID: "rig-work", + Type: "task", + Status: "open", + Assignee: session.ID, + }); err != nil { + t.Fatalf("Create rig work: %v", err) + } + has, err = sessionHasOpenAssignedWorkForReachableStore(cityPath, cfg, cityStore, map[string]beads.Store{"riga": rigStore}, session) + if err != nil { + t.Fatalf("sessionHasOpenAssignedWorkForReachableStore: %v", err) + } + if !has { + t.Fatal("rig-store assigned work should count for a rig-scoped session") + } +} diff --git a/cmd/gc/bd_env.go b/cmd/gc/bd_env.go index 5136317722..823b9b0f68 100644 --- a/cmd/gc/bd_env.go +++ b/cmd/gc/bd_env.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -13,6 +14,7 @@ import ( "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/doltauth" + "github.com/gastownhall/gascity/internal/execenv" "github.com/gastownhall/gascity/internal/fsys" ) @@ -29,17 +31,99 @@ func bdCommandRunnerForCity(cityPath string) beads.CommandRunner { } func bdStoreForCity(dir, cityPath string) *beads.BdStore { - return beads.NewBdStore(dir, bdCommandRunnerForCity(cityPath)) + cfg, err := loadCityConfig(cityPath, io.Discard) + if err != nil { + cfg = nil + } + return beads.NewBdStoreWithPrefix(dir, bdCommandRunnerForCity(cityPath), issuePrefixForScope(dir, cityPath, cfg)) } // bdStoreForRig opens a bead store at rigDir using rig-level Dolt config // when available, falling back to city-level config. Use this when the rig // may have its own Dolt server (e.g., shared from another city). -func bdStoreForRig(rigDir, cityPath string, cfg *config.City) *beads.BdStore { - return beads.NewBdStore(rigDir, bdCommandRunnerWithManagedRetry(cityPath, func(_ string) map[string]string { +func bdStoreForRig(rigDir, cityPath string, cfg *config.City, knownPrefix ...string) *beads.BdStore { + prefix := issuePrefixForScope(rigDir, cityPath, cfg) + if prefix == "" { + for _, candidate := range knownPrefix { + if strings.TrimSpace(candidate) != "" { + prefix = candidate + break + } + } + } + return beads.NewBdStoreWithPrefix(rigDir, bdCommandRunnerForRig(cityPath, cfg, rigDir), prefix) +} + +func controlBdStoreForCity(dir, cityPath string, cfg *config.City) *beads.BdStore { + return beads.NewBdStoreWithPrefix(dir, controlBdCommandRunnerForCity(cityPath), issuePrefixForScope(dir, cityPath, cfg)) +} + +func controlBdStoreForRig(rigDir, cityPath string, cfg *config.City, knownPrefix ...string) *beads.BdStore { + prefix := issuePrefixForScope(rigDir, cityPath, cfg) + if prefix == "" { + for _, candidate := range knownPrefix { + if strings.TrimSpace(candidate) != "" { + prefix = candidate + break + } + } + } + return beads.NewBdStoreWithPrefix(rigDir, controlBdCommandRunnerForRig(cityPath, cfg, rigDir), prefix) +} + +func controlBdCommandRunnerForCity(cityPath string) beads.CommandRunner { + return bdCommandRunnerWithManagedRetry(cityPath, func(dir string) map[string]string { + env := bdRuntimeEnv(cityPath) + env["BEADS_DIR"] = filepath.Join(dir, ".beads") + applyControlBdEnv(env) + return env + }) +} + +func controlBdCommandRunnerForRig(cityPath string, cfg *config.City, rigDir string) beads.CommandRunner { + return bdCommandRunnerWithManagedRetry(cityPath, func(_ string) map[string]string { env := bdRuntimeEnvForRig(cityPath, cfg, rigDir) + applyControlBdEnv(env) return env - })) + }) +} + +func applyControlBdEnv(env map[string]string) { + env["BD_EXPORT_AUTO"] = "false" +} + +func issuePrefixForScope(scopeRoot, cityPath string, cfg *config.City) string { + if prefix := readScopeIssuePrefix(scopeRoot); prefix != "" { + return prefix + } + if cfg == nil { + return "" + } + scopeRoot = filepath.Clean(scopeRoot) + if filepath.Clean(cityPath) == scopeRoot { + return config.EffectiveHQPrefix(cfg) + } + for i := range cfg.Rigs { + rigPath := resolveStoreScopeRoot(cityPath, cfg.Rigs[i].Path) + if filepath.Clean(rigPath) == scopeRoot { + return cfg.Rigs[i].EffectivePrefix() + } + } + return "" +} + +func readScopeIssuePrefix(scopeRoot string) string { + prefix, ok, err := contract.ReadIssuePrefix(fsys.OSFS{}, filepath.Join(scopeRoot, ".beads", "config.yaml")) + if err != nil || !ok { + return "" + } + return prefix +} + +func bdCommandRunnerForRig(cityPath string, cfg *config.City, rigDir string) beads.CommandRunner { + return bdCommandRunnerWithManagedRetry(cityPath, func(_ string) map[string]string { + return bdRuntimeEnvForRig(cityPath, cfg, rigDir) + }) } func canonicalScopeDoltTarget(cityPath, scopeRoot string) (contract.DoltConnectionTarget, bool, error) { @@ -152,7 +236,7 @@ var beadsExecCommandRunnerWithEnv = beads.ExecCommandRunnerWithEnv var recoverManagedBDCommand = func(cityPath string) error { script := gcBeadsBdScriptPath(cityPath) - overrides := citylayout.CityRuntimeEnvMap(cityPath) + overrides := cityRuntimeEnvMapForCity(cityPath) setProjectedDoltEnvEmpty(overrides) environ := mergeRuntimeEnv(os.Environ(), overrides) environ = append(environ, providerLifecycleDoltPathEnv(cityPath)...) @@ -426,29 +510,12 @@ func bdRuntimeEnv(cityPath string) map[string]string { } func cityRuntimeEnvMapForCity(cityPath string) map[string]string { - env := citylayout.CityRuntimeEnvMap(cityPath) - if runtimeDir := trustedAmbientCityRuntimeDir(cityPath); runtimeDir != "" { - env["GC_CITY_RUNTIME_DIR"] = runtimeDir - } - return env -} - -func trustedAmbientCityRuntimeDir(cityPath string) string { - runtimeDir := strings.TrimSpace(os.Getenv("GC_CITY_RUNTIME_DIR")) - if runtimeDir == "" { - return "" - } - for _, key := range []string{"GC_CITY_PATH", "GC_CITY"} { - if samePath(strings.TrimSpace(os.Getenv(key)), cityPath) { - return normalizePathForCompare(runtimeDir) - } - } - return "" + return citylayout.CityRuntimeEnvMapForRuntimeDir(cityPath, citylayout.TrustedAmbientCityRuntimeDir(cityPath)) } func cityRuntimeProcessEnv(cityPath string) []string { cityPath = normalizePathForCompare(cityPath) - overrides := citylayout.CityRuntimeEnvMap(cityPath) + overrides := cityRuntimeEnvMapForCity(cityPath) if cityUsesBdStoreContract(cityPath) { source := map[string]string{"BEADS_DOLT_AUTO_START": "0"} if err := applyResolvedCityDoltEnv(source, cityPath, false); err != nil { @@ -516,7 +583,7 @@ func cityForStoreDir(dir string) string { } func overlayEnvEntries(environ []string, overrides map[string]string) []string { - out := append([]string(nil), environ...) + out := execenv.FilterInherited(environ) if len(overrides) == 0 { return out } @@ -569,7 +636,7 @@ func mergeRuntimeEnv(environ []string, overrides map[string]string) []string { } } sort.Strings(keys) - out := append([]string(nil), environ...) + out := execenv.FilterInherited(environ) for _, key := range keys { out = removeEnvKey(out, key) } diff --git a/cmd/gc/bd_env_test.go b/cmd/gc/bd_env_test.go index 40da22e269..6ce644f0ae 100644 --- a/cmd/gc/bd_env_test.go +++ b/cmd/gc/bd_env_test.go @@ -32,6 +32,75 @@ func TestCityRuntimeProcessEnvStripsAmbientGCDolt(t *testing.T) { } } +func TestBdStoreForCityResolvesIDPrefixFromScopeConfig(t *testing.T) { + cityDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace] +name = "Metro City" +prefix = "mc" +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityDir, ".beads", "config.yaml"), []byte("issue_prefix: hq\n"), 0o644); err != nil { + t.Fatal(err) + } + + store := bdStoreForCity(cityDir, cityDir) + if got := store.IDPrefix(); got != "hq" { + t.Fatalf("IDPrefix() = %q, want hq", got) + } +} + +func TestBdStoreForRigResolvesIDPrefixFromScopeConfig(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "rigs", "repo") + if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigDir, ".beads", "config.yaml"), []byte("issue_prefix: repo\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.City{Rigs: []config.Rig{{Name: "repo", Path: "rigs/repo", Prefix: "ga"}}} + + store := bdStoreForRig(rigDir, cityDir, cfg) + if got := store.IDPrefix(); got != "repo" { + t.Fatalf("IDPrefix() = %q, want repo", got) + } +} + +func TestBdStoreForRigPrefersScopeConfigOverKnownPrefix(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "rigs", "repo") + if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigDir, ".beads", "config.yaml"), []byte("issue_prefix: repo\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.City{Rigs: []config.Rig{{Name: "repo", Path: "rigs/repo", Prefix: "ga"}}} + + store := bdStoreForRig(rigDir, cityDir, cfg, "stale") + if got := store.IDPrefix(); got != "repo" { + t.Fatalf("IDPrefix() = %q, want repo", got) + } +} + +func TestBdStoreForRigFallsBackToConfiguredEffectivePrefix(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "rigs", "repo") + if err := os.MkdirAll(rigDir, 0o700); err != nil { + t.Fatal(err) + } + cfg := &config.City{Rigs: []config.Rig{{Name: "repo", Path: "rigs/repo", Prefix: "ga"}}} + + store := bdStoreForRig(rigDir, cityDir, cfg) + if got := store.IDPrefix(); got != "ga" { + t.Fatalf("IDPrefix() = %q, want ga", got) + } +} + func TestBdRuntimeEnvIncludesDoltHost(t *testing.T) { t.Setenv("GC_BEADS", "bd") t.Setenv("GC_DOLT_HOST", "mini2.hippo-tilapia.ts.net") diff --git a/cmd/gc/bd_testscript_test.go b/cmd/gc/bd_testscript_test.go index 56d6dd13d0..91c6919331 100644 --- a/cmd/gc/bd_testscript_test.go +++ b/cmd/gc/bd_testscript_test.go @@ -63,6 +63,12 @@ func bdTestCmd() { code = doBdShow(store, rest) case "ready": code = doBdReady(store, rest) + case "init", "config", "migrate": + // No-op stubs used by gc-beads-bd.sh during finalize. The + // file-backed store does not need schema seeding, so accept + // these and exit 0 to keep finalize green for tests that + // exercise the real localInitializer + finalizeInit path. + code = 0 default: fmt.Fprintf(os.Stderr, "bd: unknown subcommand %q\n", subcmd) code = 1 diff --git a/cmd/gc/beads_provider_lifecycle.go b/cmd/gc/beads_provider_lifecycle.go index f7ce2f6284..fd1a72f61d 100644 --- a/cmd/gc/beads_provider_lifecycle.go +++ b/cmd/gc/beads_provider_lifecycle.go @@ -30,6 +30,26 @@ import ( // break supervisor multi-tenancy where multiple cities share one process). var cityDoltConfigs sync.Map // cityPath → config.DoltConfig +// providerOpSemaphores limits concurrent provider operations per city. +// When dolt goes down, health checks and recovery attempts from multiple +// callers can pile up. Without backpressure, all queued operations fire +// simultaneously when dolt restarts, causing a thundering herd that +// hammers the server back down. Each semaphore allows at most 1 +// concurrent provider operation per city (serialize lifecycle ops). +var providerOpSemaphores sync.Map // cityPath → chan struct{} + +func cityDoltConfigHasLifecycleFields(cfg config.DoltConfig) bool { + return cfg.Host != "" || cfg.Port != 0 || cfg.ArchiveLevel != nil +} + +func registerCityDoltConfig(cityPath string, cfg config.DoltConfig) { + cityDoltConfigs.Store(normalizePathForCompare(cityPath), cfg) +} + +func clearCityDoltConfig(cityPath string) { + cityDoltConfigs.Delete(normalizePathForCompare(cityPath)) +} + var resolveProviderLifecycleGCBinary = func() string { if isTestBinary() { return "" @@ -47,6 +67,7 @@ var ( initDirIfReadyEnsureBeadsProvider = ensureBeadsProvider initDirIfReadyInitAndHookDir = initAndHookDir initDirIfReadyRetryDelay = time.Second + initAndHookDirWaitForScopeReady = waitForBeadsScopeReadyAfterRecovery ) const initDirIfReadyRetryLimit = 2 @@ -58,7 +79,9 @@ func isRetryableManagedDoltLifecycleError(err error) bool { msg := strings.ToLower(err.Error()) return strings.Contains(msg, "dolt server exited during startup") || strings.Contains(msg, "did not become query-ready") || - strings.Contains(msg, "signal: terminated") + strings.Contains(msg, "signal: terminated") || + strings.Contains(msg, "table not found: issues") || + strings.Contains(msg, "table not found: config") } // ── Consolidated lifecycle operations ──────────────────────────────────── @@ -91,10 +114,10 @@ func startBeadsLifecycle(cityPath, _ string, cfg *config.City, _ io.Writer) erro // registration point — supervisor, standalone, and reload all flow // through here. Always write (or clear) to handle config reload: // removing [dolt] after a reload must not leave stale entries. - if cfg.Dolt.Host != "" || cfg.Dolt.Port != 0 { - cityDoltConfigs.Store(cityPath, cfg.Dolt) + if cityDoltConfigHasLifecycleFields(cfg.Dolt) { + registerCityDoltConfig(cityPath, cfg.Dolt) } else { - cityDoltConfigs.Delete(cityPath) + clearCityDoltConfig(cityPath) } // Skip local Dolt startup only when canonical or compatibility topology // says the city endpoint is external. Managed-local cities may not have a @@ -218,6 +241,7 @@ func desiredScopeDoltConfigStateForInit(cityPath, dir, prefix string) (contract. if strings.TrimSpace(dir) == "" || strings.TrimSpace(prefix) == "" { return contract.ConfigState{}, false, nil } + cityPath = normalizePathForCompare(cityPath) cityDolt := config.DoltConfig{} if cfg, err := loadCityConfig(cityPath, io.Discard); err == nil { resolveRigPaths(cityPath, cfg.Rigs) @@ -319,7 +343,8 @@ func defaultScopeDoltDatabase(cityPath, dir, prefix string) string { } func isReservedManagedDoltDatabase(name string) bool { - return strings.EqualFold(strings.TrimSpace(name), managedDoltProbeDatabase) + _, ok := managedDoltSystemDatabases[strings.ToLower(strings.TrimSpace(name))] + return ok } func canonicalScopeDoltDatabase(cityPath, dir, prefix string) string { @@ -363,6 +388,14 @@ func initAndHookDir(cityPath, dir, prefix string) error { if err := normalizeCanonicalBdScopeFilesForInit(cityPath, dir, prefix, doltDatabase); err != nil { return err } + if cityUsesBdStoreContract(cityPath) && currentManagedDoltPort(cityPath) != "" { + if err := syncManagedDoltPortMirrors(cityPath); err != nil { + return fmt.Errorf("sync managed dolt port mirrors after init: %w", err) + } + if err := initAndHookDirWaitForScopeReady(dir, cityPath, time.Now().Add(10*time.Second)); err != nil { + return fmt.Errorf("waiting for initialized bead scope readiness: %w", err) + } + } // Non-fatal: hooks are convenience (event forwarding), not critical. if err := installBeadHooks(dir); err != nil { return fmt.Errorf("install hooks at %s: %w", dir, err) @@ -370,6 +403,13 @@ func initAndHookDir(cityPath, dir, prefix string) error { return nil } +func shouldRetryExecBdInit(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "bd schema not visible") +} + // resolveRigPaths resolves relative rig paths to absolute (relative to // cityPath). Mutates rigs in place. Must be called after loading city config // and before any access to rigs[i].Path for filesystem operations. Required @@ -395,12 +435,20 @@ func resolveRigPaths(cityPath string, rigs []config.Rig) { // ensureBeadsProvider starts the bead store's backing service if needed. // For exec providers, fires "start". For file providers, always available. +// Acquires a per-city semaphore to prevent concurrent start operations +// from causing spawn storms. func ensureBeadsProvider(cityPath string) error { if cityUsesBdStoreContract(cityPath) && strings.TrimSpace(os.Getenv("GC_DOLT")) == "skip" { return nil } provider := beadsProvider(cityPath) if strings.HasPrefix(provider, "exec:") { + release, err := acquireProviderSemaphoreForOp(cityPath, "start") + if err != nil { + return err + } + defer release() + script := strings.TrimPrefix(provider, "exec:") managedBDProvider := samePath(script, gcBeadsBdScriptPath(cityPath)) if err := runProviderOpWithEnv(script, providerLifecycleProcessEnv(cityPath, provider), "start"); err != nil { @@ -450,6 +498,13 @@ func shutdownBeadsProvider(cityPath string) error { // initBeadsForDir initializes bead store infrastructure in a directory. // Idempotent — skips if already initialized. Callers should use // initAndHookDir instead to ensure hooks are installed afterward. +// +// Every load-bearing exec path that invokes bd init locally ensures +// BEADS_DIR=/.beads. bd init creates a .git/ as a side effect when +// BEADS_DIR is unset (upstream gastownhall/beads cmd/bd/init.go), so generic +// exec providers get the scope's bead directory in the subprocess env and +// providers that run bd init elsewhere (for example gc-beads-k8s inside the +// pod) must set it in their own wrapper before invoking bd init. func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { if cityUsesBdStoreContract(cityPath) && os.Getenv("GC_DOLT") == "skip" { if err := seedDeferredManagedBeadsErr(cityPath, dir, prefix, doltDatabase); err != nil { @@ -469,7 +524,9 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { script := strings.TrimPrefix(provider, "exec:") if execProviderUsesCanonicalBdScopeFiles(provider) && !execProviderNeedsScopedDoltInit(provider) { baseEnv := providerLifecycleProcessEnv(cityPath, provider) - overrides := map[string]string{} + overrides := map[string]string{ + "BEADS_DIR": filepath.Join(dir, ".beads"), + } canonicalDoltDatabase := strings.TrimSpace(doltDatabase) if canonicalDoltDatabase == "" { canonicalDoltDatabase = canonicalScopeDoltDatabase(cityPath, dir, prefix) @@ -483,13 +540,50 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { return err } } - if err := runProviderOpWithEnv(script, overlayEnvEntries(baseEnv, overrides), args...); err != nil { + env := overlayEnvEntries(baseEnv, overrides) + if err := runProviderOpWithEnv(script, env, args...); err != nil { + if shouldRetryExecBdInit(err) { + for attempt := 0; attempt < 3; attempt++ { + time.Sleep(time.Second) + retryErr := runProviderOpWithEnv(script, env, args...) + if retryErr == nil { + return finalizeCanonicalBdScopeInit(cityPath, dir, prefix, canonicalDoltDatabase) + } + if !shouldRetryExecBdInit(retryErr) { + return retryErr + } + err = retryErr + } + } return err } return finalizeCanonicalBdScopeInit(cityPath, dir, prefix, canonicalDoltDatabase) } if !execProviderNeedsScopedDoltInit(provider) { - return runProviderOp(script, cityPath, args...) + baseEnv := cityRuntimeProcessEnv(cityPath) + if strings.TrimSpace(cityPath) == "" { + baseEnv = os.Environ() + } + env := overlayEnvEntries(baseEnv, map[string]string{ + "BEADS_DIR": filepath.Join(dir, ".beads"), + }) + if err := runProviderOpWithEnv(script, env, args...); err != nil { + if shouldRetryExecBdInit(err) { + for attempt := 0; attempt < 3; attempt++ { + time.Sleep(time.Second) + retryErr := runProviderOpWithEnv(script, env, args...) + if retryErr == nil { + return nil + } + if !shouldRetryExecBdInit(retryErr) { + return retryErr + } + err = retryErr + } + } + return err + } + return nil } target, err := resolveConfiguredExecStoreTarget(cityPath, dir) if err != nil { @@ -550,6 +644,7 @@ func forcedScopeDoltConfigStateForInit(cityPath, dir, prefix string) (contract.C if strings.TrimSpace(dir) == "" || strings.TrimSpace(prefix) == "" { return contract.ConfigState{}, false, nil } + cityPath = normalizePathForCompare(cityPath) cityDolt := config.DoltConfig{} if cfg, err := loadCityConfig(cityPath, io.Discard); err == nil { resolveRigPaths(cityPath, cfg.Rigs) @@ -590,12 +685,21 @@ func initFileStoreForDir(cityPath, dir string) error { // For exec providers, fires the "health" operation. For bd (dolt), runs // a three-layer health check and attempts recovery on failure. For file // provider, always healthy (no-op). +// +// Acquires a per-city semaphore to prevent concurrent health/recovery +// operations from causing a thundering herd when dolt bounces. func healthBeadsProvider(cityPath string) error { if cityUsesBdStoreContract(cityPath) && strings.TrimSpace(os.Getenv("GC_DOLT")) == "skip" { return nil } provider := beadsProvider(cityPath) if strings.HasPrefix(provider, "exec:") { + release, err := acquireProviderSemaphoreForOp(cityPath, "health") + if err != nil { + return err + } + defer release() + script := strings.TrimPrefix(provider, "exec:") providerEnv := providerLifecycleProcessEnv(cityPath, provider) if err := runProviderOpWithEnv(script, providerEnv, "health"); err != nil { @@ -717,6 +821,7 @@ func configuredCityDoltTarget(cityPath string) (string, string, bool) { } func resolveConfiguredCityDoltTarget(cityPath string) (string, string, bool, bool) { + cityPath = normalizePathForCompare(cityPath) resolved, err := contract.ResolveScopeConfigState(fsys.OSFS{}, cityPath, cityPath, "") if err != nil { var invalid *contract.InvalidCanonicalConfigError @@ -888,6 +993,10 @@ func validateManagedDoltDatabaseName(path, doltDatabase string) (string, error) return doltDatabase, nil } +func isLegacyManagedDoltProbeDatabase(name string) bool { + return strings.EqualFold(strings.TrimSpace(name), managedDoltProbeDatabase) +} + func ensureCanonicalScopeMetadata(fs fsys.FS, scopeRoot, doltDatabase string, preserveExisting bool) error { path := filepath.Join(scopeRoot, ".beads", "metadata.json") preserveReservedExisting := false @@ -898,9 +1007,10 @@ func ensureCanonicalScopeMetadata(fs fsys.FS, scopeRoot, doltDatabase string, pr doltDatabase = strings.TrimSpace(existing) if isReservedManagedDoltDatabase(doltDatabase) { // New init paths reject this reserved name, but existing metadata - // may predate the reservation. Preserve it during startup - // normalization so operators can migrate the scope deliberately. - preserveReservedExisting = true + // may use the legacy probe database as its real bead store. + // Preserve only that one migration case; Dolt system databases + // are unsafe bead-store targets even when already pinned. + preserveReservedExisting = isLegacyManagedDoltProbeDatabase(doltDatabase) } } } @@ -1300,6 +1410,7 @@ func providerLifecycleProcessEnv(cityPath, provider string) []string { "GC_DOLT_PID_FILE", "GC_DOLT_LOCK_FILE", "GC_DOLT_CONFIG_FILE", + "GC_DOLT_ARCHIVE_LEVEL", } { env = removeEnvKey(env, key) } @@ -1308,9 +1419,52 @@ func providerLifecycleProcessEnv(cityPath, provider string) []string { env = removeEnvKey(env, "GC_BIN") env = append(env, "GC_BIN="+gcBin) } + // Propagate archive_level from city config so the managed dolt + // server inherits it without shell-script changes. + if v, ok := cityDoltConfigs.Load(cityPath); ok { + dc, _ := v.(config.DoltConfig) + if dc.ArchiveLevel != nil { + env = append(env, fmt.Sprintf("GC_DOLT_ARCHIVE_LEVEL=%d", *dc.ArchiveLevel)) + } + } return env } +// acquireProviderSemaphore returns a per-city semaphore channel and waits +// until a slot is available or ctx is canceled. Call the returned function to +// release. Semaphore entries intentionally live for the process lifetime: +// deleting an entry while a lifecycle operation is still running would allow a +// second channel for the same city and break serialization. The map is bounded +// by city roots seen by this controller process. +// This serializes lifecycle operations per city to prevent thundering herd +// when dolt bounces: without this, concurrent health checks all trigger +// recovery simultaneously, spawning a storm of processes that overwhelm +// dolt on restart. +func acquireProviderSemaphore(ctx context.Context, cityPath string) (func(), error) { + cityPath = normalizePathForCompare(cityPath) + v, _ := providerOpSemaphores.LoadOrStore(cityPath, make(chan struct{}, 1)) + sem := v.(chan struct{}) + select { + case sem <- struct{}{}: + return func() { <-sem }, nil + case <-ctx.Done(): + return nil, fmt.Errorf("waiting for provider lifecycle slot for %q: %w", cityPath, ctx.Err()) + } +} + +func acquireProviderSemaphoreForOp(cityPath, op string) (func(), error) { + ctx, cancel := context.WithTimeout(context.Background(), providerOpTimeout(op)) + release, err := acquireProviderSemaphore(ctx, cityPath) + if err != nil { + cancel() + return nil, err + } + return func() { + release() + cancel() + }, nil +} + // providerOpTimeout returns the context timeout for a given lifecycle // operation. The "start" and "recover" operations get a longer timeout // because dolt server startup can take 30+ seconds for large data dirs. diff --git a/cmd/gc/beads_provider_lifecycle_test.go b/cmd/gc/beads_provider_lifecycle_test.go index 64ee4754dc..ec8ce9638b 100644 --- a/cmd/gc/beads_provider_lifecycle_test.go +++ b/cmd/gc/beads_provider_lifecycle_test.go @@ -41,6 +41,12 @@ func freeLoopbackPort(t *testing.T) string { return strconv.Itoa(addr.Port) } +func setScopedBeadsProviderForTest(t *testing.T, scopeRoot, provider string) { + t.Helper() + t.Setenv("GC_BEADS", provider) + t.Setenv("GC_BEADS_SCOPE_ROOT", scopeRoot) +} + // TestEnsureBeadsProvider_file verifies that file provider is a no-op. func TestEnsureBeadsProvider_file(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -52,9 +58,10 @@ func TestEnsureBeadsProvider_file(t *testing.T) { // TestEnsureBeadsProvider_exec calls script with ensure-ready, exit 2 = no-op. func TestEnsureBeadsProvider_exec(t *testing.T) { + dir := t.TempDir() script := writeTestScript(t, "ensure-ready", 2, "") - t.Setenv("GC_BEADS", "exec:"+script) - if err := ensureBeadsProvider(t.TempDir()); err != nil { + setScopedBeadsProviderForTest(t, dir, "exec:"+script) + if err := ensureBeadsProvider(dir); err != nil { t.Fatalf("expected nil for exit 2, got %v", err) } } @@ -159,7 +166,43 @@ func TestProviderLifecycleProcessEnvProjectsResolvedGCBin(t *testing.T) { } } -func TestGcBeadsBdReadOnlyFallbackDoesNotDropProbeDatabase(t *testing.T) { +func TestProviderLifecycleProcessEnvPropagatesArchiveLevel(t *testing.T) { + cityPath := t.TempDir() + normPath := normalizePathForCompare(cityPath) + + level := 1 + cityDoltConfigs.Store(normPath, config.DoltConfig{ArchiveLevel: &level}) + t.Cleanup(func() { cityDoltConfigs.Delete(normPath) }) + + envEntries := providerLifecycleProcessEnv(cityPath, "exec:"+gcBeadsBdScriptPath(cityPath)) + env := map[string]string{} + for _, entry := range envEntries { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[key] = value + } + } + if got := env["GC_DOLT_ARCHIVE_LEVEL"]; got != "1" { + t.Fatalf("GC_DOLT_ARCHIVE_LEVEL = %q, want %q", got, "1") + } +} + +func TestProviderLifecycleProcessEnvOmitsArchiveLevelWhenNil(t *testing.T) { + cityPath := t.TempDir() + normPath := normalizePathForCompare(cityPath) + + cityDoltConfigs.Store(normPath, config.DoltConfig{}) + t.Cleanup(func() { cityDoltConfigs.Delete(normPath) }) + + envEntries := providerLifecycleProcessEnv(cityPath, "exec:"+gcBeadsBdScriptPath(cityPath)) + for _, entry := range envEntries { + if strings.HasPrefix(entry, "GC_DOLT_ARCHIVE_LEVEL=") { + t.Fatalf("GC_DOLT_ARCHIVE_LEVEL should not be set when ArchiveLevel is nil, got %q", entry) + } + } +} + +func TestGcBeadsBdReadOnlyFallbackDoesNotTargetLegacyProbeDatabase(t *testing.T) { cityPath := t.TempDir() if err := MaterializeBuiltinPacks(cityPath); err != nil { t.Fatalf("MaterializeBuiltinPacks: %v", err) @@ -170,14 +213,55 @@ func TestGcBeadsBdReadOnlyFallbackDoesNotDropProbeDatabase(t *testing.T) { } script := string(scriptData) assertNoManagedDoltProbeDrop(t, "gc-beads-bd read-only fallback", script) - if !strings.Contains(script, "CREATE TABLE IF NOT EXISTS __gc_probe.__probe") { - t.Fatalf("gc-beads-bd read-only fallback missing qualified persistent probe table") + assertNoManagedDoltProbeLegacyTarget(t, "gc-beads-bd read-only fallback", script) + for _, want := range []string{"SHOW DATABASES", managedDoltProbeTable, "performance_schema", "sys"} { + if !strings.Contains(script, want) { + t.Fatalf("gc-beads-bd read-only fallback missing %q", want) + } + } +} + +func TestGcBeadsBdShellFallbackSanitizesArchiveLevel(t *testing.T) { + cityPath := t.TempDir() + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + scriptData, err := os.ReadFile(gcBeadsBdScriptPath(cityPath)) + if err != nil { + t.Fatalf("ReadFile(gc-beads-bd): %v", err) + } + script := string(scriptData) + for _, forbidden := range []string{ + `--archive-level "${GC_DOLT_ARCHIVE_LEVEL:-0}"`, + "archive_level: ${GC_DOLT_ARCHIVE_LEVEL:-0}", + } { + if strings.Contains(script, forbidden) { + t.Fatalf("gc-beads-bd shell fallback uses unsanitized archive level pattern %q", forbidden) + } + } + for _, want := range []string{ + "archive_level=${GC_DOLT_ARCHIVE_LEVEL:-0}", + "*[!0-9]*", + "--archive-level \"$archive_level\"", + "archive_level: $archive_level", + } { + if !strings.Contains(script, want) { + t.Fatalf("gc-beads-bd shell fallback missing sanitized archive level pattern %q", want) + } } - assertManagedDoltProbeWrites(t, "gc-beads-bd read-only fallback", script) } func TestGcBeadsBdInitRejectsManagedProbeDatabaseName(t *testing.T) { - for _, dbName := range []string{managedDoltProbeDatabase, strings.ToUpper(managedDoltProbeDatabase), " " + managedDoltProbeDatabase + " "} { + for _, dbName := range []string{ + managedDoltProbeDatabase, + strings.ToUpper(managedDoltProbeDatabase), + " " + managedDoltProbeDatabase + " ", + "information_schema", + "mysql", + "dolt_cluster", + "performance_schema", + "sys", + } { t.Run(dbName, func(t *testing.T) { cityPath := t.TempDir() scopePath := filepath.Join(cityPath, "rigs", "frontend") @@ -203,14 +287,25 @@ func TestGcBeadsBdInitRejectsManagedProbeDatabaseName(t *testing.T) { } } -func TestEnsureCanonicalScopeMetadataRejectsManagedProbeDatabase(t *testing.T) { - scopePath := t.TempDir() - err := ensureCanonicalScopeMetadataForInit(fsys.OSFS{}, scopePath, managedDoltProbeDatabase) - if err == nil { - t.Fatalf("ensureCanonicalScopeMetadataForInit unexpectedly accepted %s", managedDoltProbeDatabase) - } - if !strings.Contains(err.Error(), "reserved pinned dolt_database") || !strings.Contains(err.Error(), "choose a different dolt_database") { - t.Fatalf("ensureCanonicalScopeMetadataForInit error = %v, want reserved database remediation", err) +func TestEnsureCanonicalScopeMetadataRejectsManagedSystemDatabases(t *testing.T) { + for _, dbName := range []string{ + managedDoltProbeDatabase, + "information_schema", + "mysql", + "dolt_cluster", + "performance_schema", + "sys", + } { + t.Run(dbName, func(t *testing.T) { + scopePath := t.TempDir() + err := ensureCanonicalScopeMetadataForInit(fsys.OSFS{}, scopePath, dbName) + if err == nil { + t.Fatalf("ensureCanonicalScopeMetadataForInit unexpectedly accepted %s", dbName) + } + if !strings.Contains(err.Error(), "reserved pinned dolt_database") || !strings.Contains(err.Error(), "choose a different dolt_database") { + t.Fatalf("ensureCanonicalScopeMetadataForInit error = %v, want reserved database remediation", err) + } + }) } } @@ -245,6 +340,34 @@ func TestNormalizeCanonicalBdScopeFilesPreservesExistingManagedProbeDatabase(t * } } +func TestNormalizeCanonicalBdScopeFilesRejectsExistingManagedSystemDatabase(t *testing.T) { + cityPath := t.TempDir() + metadataPath := filepath.Join(cityPath, ".beads", "metadata.json") + if err := os.MkdirAll(filepath.Dir(metadataPath), 0o700); err != nil { + t.Fatal(err) + } + if _, err := contract.EnsureCanonicalMetadata(fsys.OSFS{}, metadataPath, contract.MetadataState{ + Database: "dolt", + Backend: "dolt", + DoltMode: "server", + DoltDatabase: "mysql", + }); err != nil { + t.Fatalf("EnsureCanonicalMetadata: %v", err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "config.yaml"), []byte("issue_prefix: hq\nissue-prefix: hq\ndolt.auto-start: true\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.City{Workspace: config.Workspace{Name: "dogfood-city"}} + err := normalizeCanonicalBdScopeFiles(cityPath, cfg) + if err == nil { + t.Fatal("normalizeCanonicalBdScopeFiles() error = nil, want reserved metadata rejection") + } + if !strings.Contains(err.Error(), "reserved pinned dolt_database") || !strings.Contains(err.Error(), "mysql") { + t.Fatalf("normalizeCanonicalBdScopeFiles() error = %v, want mysql reserved metadata rejection", err) + } +} + func TestNormalizeCanonicalBdScopeFilesForInitPreservesExistingManagedProbeDatabase(t *testing.T) { cityPath := t.TempDir() metadataPath := filepath.Join(cityPath, ".beads", "metadata.json") @@ -276,6 +399,224 @@ func TestNormalizeCanonicalBdScopeFilesForInitPreservesExistingManagedProbeDatab } } +func TestGcBeadsBdReadOnlyFallbackNoUserDatabaseIsDiagnostic(t *testing.T) { + cityPath := t.TempDir() + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + scriptData, err := os.ReadFile(gcBeadsBdScriptPath(cityPath)) + if err != nil { + t.Fatalf("ReadFile(gc-beads-bd): %v", err) + } + prelude, _, ok := strings.Cut(string(scriptData), "# --- Main ---") + if !ok { + t.Fatal("gc-beads-bd script missing main marker") + } + + binDir := t.TempDir() + invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") + if err := os.WriteFile(filepath.Join(binDir, "dolt"), []byte(`#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$INVOCATION_FILE" +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ninformation_schema\nmysql\ndolt_cluster\nperformance_schema\nsys\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) + echo "unexpected write probe without a user database" >&2 + exit 2 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac +`), 0o755); err != nil { + t.Fatalf("WriteFile(dolt): %v", err) + } + + harness := filepath.Join(t.TempDir(), "read-only-fallback.sh") + body := prelude + ` +GC_BIN="" +GC_DOLT_HOST="" +DOLT_PORT=3311 +DOLT_USER=root +set +e +check_read_only +status=$? +set -e +printf 'status=%s\n' "$status" +` + if err := os.WriteFile(harness, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("sh", harness) + cmd.Env = append(sanitizedBaseEnv( + "INVOCATION_FILE="+invocationFile, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ), "GC_BIN=") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("check_read_only harness failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "status=2") { + t.Fatalf("check_read_only output = %s, want diagnostic status 2", out) + } + if !strings.Contains(string(out), "no user database") { + t.Fatalf("check_read_only output = %s, want no-user-database diagnostic", out) + } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + if strings.Contains(string(invocation), "CREATE TABLE IF NOT EXISTS") { + t.Fatalf("check_read_only ran write probe without user database:\n%s", invocation) + } +} + +func TestGcBeadsBdHealthNoUserDatabaseWarnsAndContinues(t *testing.T) { + cityPath := t.TempDir() + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + scriptData, err := os.ReadFile(gcBeadsBdScriptPath(cityPath)) + if err != nil { + t.Fatalf("ReadFile(gc-beads-bd): %v", err) + } + prelude, _, ok := strings.Cut(string(scriptData), "# --- Main ---") + if !ok { + t.Fatal("gc-beads-bd script missing main marker") + } + + binDir := t.TempDir() + invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") + if err := os.WriteFile(filepath.Join(binDir, "dolt"), []byte(`#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$INVOCATION_FILE" +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ninformation_schema\nmysql\ndolt_cluster\nperformance_schema\nsys\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*) + echo "unexpected write probe without a user database" >&2 + exit 2 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac +`), 0o755); err != nil { + t.Fatalf("WriteFile(dolt): %v", err) + } + + harness := filepath.Join(t.TempDir(), "health-fallback.sh") + body := prelude + ` +GC_BIN="" +GC_DOLT_HOST="" +DOLT_PORT=3311 +DOLT_USER=root +tcp_check() { return 0; } +do_query_probe() { return 0; } +get_connection_count() { return 1; } +set +e +op_health +status=$? +set -e +printf 'status=%s\n' "$status" +` + if err := os.WriteFile(harness, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("sh", harness) + cmd.Env = append(sanitizedBaseEnv( + "INVOCATION_FILE="+invocationFile, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ), "GC_BIN=") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("op_health harness failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "status=0") { + t.Fatalf("op_health output = %s, want success status", out) + } + if !strings.Contains(string(out), "warning: dolt read-only probe inconclusive") { + t.Fatalf("op_health output = %s, want warning", out) + } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + if strings.Contains(string(invocation), "CREATE TABLE IF NOT EXISTS") { + t.Fatalf("op_health ran write probe without user database:\n%s", invocation) + } +} + +func TestGcBeadsBdReadOnlyHelperErrorIsDiagnostic(t *testing.T) { + cityPath := t.TempDir() + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + scriptData, err := os.ReadFile(gcBeadsBdScriptPath(cityPath)) + if err != nil { + t.Fatalf("ReadFile(gc-beads-bd): %v", err) + } + prelude, _, ok := strings.Cut(string(scriptData), "# --- Main ---") + if !ok { + t.Fatal("gc-beads-bd script missing main marker") + } + + gcBin := filepath.Join(t.TempDir(), "gc") + if err := os.WriteFile(gcBin, []byte(`#!/bin/sh +set -eu +case "$1 $2" in + "dolt-state read-only-check") + echo "gc dolt-state read-only-check: no user database available for managed Dolt read-only probe" >&2 + exit 1 + ;; + *) + echo "unexpected gc command: $*" >&2 + exit 66 + ;; +esac +`), 0o755); err != nil { + t.Fatalf("WriteFile(gc): %v", err) + } + + harness := filepath.Join(t.TempDir(), "read-only-helper.sh") + body := prelude + fmt.Sprintf(` +GC_BIN=%q +GC_DOLT_HOST="" +DOLT_PORT=3311 +DOLT_USER=root +set +e +check_read_only +status=$? +set -e +printf 'status=%%s\n' "$status" +`, gcBin) + if err := os.WriteFile(harness, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("sh", harness) + cmd.Env = sanitizedBaseEnv("PATH=" + os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("check_read_only harness failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "status=2") { + t.Fatalf("check_read_only output = %s, want diagnostic status 2", out) + } + if !strings.Contains(string(out), "no user database") { + t.Fatalf("check_read_only output = %s, want helper diagnostic", out) + } +} + func TestGcBeadsBdCleanupStaleLocksBoundsLsof(t *testing.T) { cityPath := t.TempDir() if err := MaterializeBuiltinPacks(cityPath); err != nil { @@ -373,7 +714,7 @@ exit 0 if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { t.Fatal(err) } - t.Setenv("GC_BEADS", "exec:"+script) + setScopedBeadsProviderForTest(t, cityPath, "exec:"+script) if err := ensureBeadsProvider(cityPath); err != nil { t.Fatalf("ensureBeadsProvider: %v", err) @@ -508,9 +849,9 @@ dolt_port = "4406" func TestManagedDoltLifecycleOwnedReportsInvalidCityConfigForFileCity(t *testing.T) { t.Setenv("GC_BEADS", "file") - t.Setenv("GC_BEADS_SCOPE_ROOT", "") cityPath := t.TempDir() + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname =\n"), 0o644); err != nil { t.Fatal(err) } @@ -531,7 +872,7 @@ func TestEnsureBeadsProvider_bd_skip(t *testing.T) { t.Fatal(err) } MaterializeBuiltinPacks(dir) //nolint:errcheck - t.Setenv("GC_BEADS", "bd") + setScopedBeadsProviderForTest(t, dir, "bd") t.Setenv("GC_DOLT", "skip") if err := ensureBeadsProvider(dir); err != nil { t.Fatalf("expected nil, got %v", err) @@ -577,7 +918,7 @@ func TestEnsureBeadsProvider_bdAcceptsHealthyServerAfterStartError(t *testing.T) t.Fatal(err) } - t.Setenv("GC_BEADS", "bd") + setScopedBeadsProviderForTest(t, dir, "bd") if err := ensureBeadsProvider(dir); err != nil { t.Fatalf("ensureBeadsProvider = %v, want nil", err) @@ -619,7 +960,7 @@ func TestEnsureBeadsProvider_execDoesNotMaskStartErrorWithHealth(t *testing.T) { t.Fatal(err) } - t.Setenv("GC_BEADS", "exec:"+script) + setScopedBeadsProviderForTest(t, dir, "exec:"+script) err := ensureBeadsProvider(dir) if err == nil { @@ -676,6 +1017,7 @@ func TestEnsureBeadsProvider_execDoesNotReclassifyProviderAfterStart(t *testing. if err := os.Setenv("GC_BEADS", "exec:"+script); err != nil { t.Fatalf("set GC_BEADS: %v", err) } + t.Setenv("GC_BEADS_SCOPE_ROOT", dir) t.Cleanup(func() { if hadProvider { _ = os.Setenv("GC_BEADS", originalProvider) @@ -737,9 +1079,10 @@ func TestShutdownBeadsProvider_file(t *testing.T) { // TestShutdownBeadsProvider_exec calls script with shutdown, exit 2 = no-op. func TestShutdownBeadsProvider_exec(t *testing.T) { + dir := t.TempDir() script := writeTestScript(t, "shutdown", 2, "") - t.Setenv("GC_BEADS", "exec:"+script) - if err := shutdownBeadsProvider(t.TempDir()); err != nil { + setScopedBeadsProviderForTest(t, dir, "exec:"+script) + if err := shutdownBeadsProvider(dir); err != nil { t.Fatalf("expected nil for exit 2, got %v", err) } } @@ -752,6 +1095,7 @@ func TestShutdownBeadsProvider_bd_skip(t *testing.T) { } MaterializeBuiltinPacks(dir) //nolint:errcheck t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", dir) t.Setenv("GC_DOLT", "skip") if err := shutdownBeadsProvider(dir); err != nil { t.Fatalf("expected nil, got %v", err) @@ -1970,6 +2314,7 @@ func TestInitBeadsForDir_file(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_DOLT", "skip") cityDir := t.TempDir() + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "test", "test"); err != nil { t.Fatalf("expected nil, got %v", err) } @@ -1996,6 +2341,7 @@ func TestInitBeadsForDir_fileScopedRigCreatesStore(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_DOLT", "skip") cityDir := t.TempDir() + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) rigDir := filepath.Join(t.TempDir(), "rig1") if err := os.MkdirAll(rigDir, 0o755); err != nil { t.Fatal(err) @@ -2022,6 +2368,7 @@ func TestInitBeadsForDir_fileLegacyRigPreservesSharedCityStore(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_DOLT", "skip") cityDir := t.TempDir() + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) rigDir := filepath.Join(t.TempDir(), "rig1") if err := os.MkdirAll(rigDir, 0o755); err != nil { t.Fatal(err) @@ -2056,6 +2403,7 @@ func TestInitBeadsForDir_exec(t *testing.T) { writeMinimalCityToml(t, cityDir) script := writeTestScript(t, "init", 2, "") t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "prefix", "prefix"); err != nil { t.Fatalf("expected nil for exit 2, got %v", err) } @@ -2072,6 +2420,7 @@ func TestInitBeadsForDir_execPassesCanonicalDoltDatabase(t *testing.T) { } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "gc", "gascity"); err != nil { t.Fatalf("expected nil, got %v", err) } @@ -2086,25 +2435,79 @@ func TestInitBeadsForDir_execPassesCanonicalDoltDatabase(t *testing.T) { } } -func TestRunProviderOpStripsAmbientGCDoltSkip(t *testing.T) { - cityDir := t.TempDir() - writeMinimalCityToml(t, cityDir) +// TestInitBeadsForDirExecSetsBEADSDIR exercises the controller-side exec paths +// that invoke bd init directly and asserts BEADS_DIR=/.beads is present in +// the subprocess env. The k8s scoped path sets BEADS_DIR inside the provider +// script itself; that behavior is covered by internal/runtime/k8s tests. +// Regression for #399. +func TestInitBeadsForDirExecSetsBEADSDIR(t *testing.T) { + for _, tc := range []struct { + name string + scriptBase string + // cityToml uses dolt/rig config appropriate for the exec branch. + cityToml func(rigRel string) string + }{ + { + name: "gc-beads-bd canonical", + scriptBase: "gc-beads-bd", + cityToml: func(rigRel string) string { + return "[workspace]\nname = \"demo\"\n\n[[rigs]]\nname = \"r\"\npath = \"" + rigRel + "\"\nprefix = \"rg\"\n" + }, + }, + { + name: "generic legacy exec", + scriptBase: "record-env", + cityToml: func(rigRel string) string { + return "[workspace]\nname = \"demo\"\n\n[[rigs]]\nname = \"r\"\npath = \"" + rigRel + "\"\nprefix = \"rg\"\n" + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "r") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(tc.cityToml("r")), 0o644); err != nil { + t.Fatal(err) + } + logFile := filepath.Join(t.TempDir(), "env.log") + script := filepath.Join(t.TempDir(), tc.scriptBase) + content := fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = init ]; then printf '%%s\\n' \"${BEADS_DIR:-}\" > %q; fi\nexit 0\n", logFile) + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("GC_BEADS", "exec:"+script) + if err := initBeadsForDir(cityDir, rigDir, "rg", "rg-db"); err != nil { + t.Fatalf("initBeadsForDir: %v", err) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read env log: %v", err) + } + want := filepath.Join(rigDir, ".beads") + if got := strings.TrimSpace(string(data)); got != want { + t.Fatalf("BEADS_DIR = %q, want %q (bd init without BEADS_DIR creates .git as a side effect)", got, want) + } + }) + } +} + +func TestInitBeadsForDirExecWithoutCityPathPreservesAmbientEnv(t *testing.T) { + rigDir := t.TempDir() logFile := filepath.Join(t.TempDir(), "env.log") - script := filepath.Join(t.TempDir(), "record-env.sh") - content := fmt.Sprintf(`#!/bin/sh -printf '%%s|%%s|%%s|%%s -' "${GC_DOLT:-}" "${GC_DOLT_HOST:-}" "${GC_DOLT_PORT:-}" "${GC_CITY_PATH:-}" > %q -exit 0 -`, logFile) + script := filepath.Join(t.TempDir(), "record-env") + content := fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = init ]; then printf '%%s|%%s\\n' \"${GC_DOLT_HOST:-}\" \"${BEADS_DIR:-}\" > %q; fi\nexit 0\n", logFile) if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatal(err) } - t.Setenv("GC_BEADS", "bd") - t.Setenv("GC_DOLT", "skip") - - if err := runProviderOp(script, cityDir, "init", cityDir, "gc", "hq"); err != nil { - t.Fatalf("runProviderOp: %v", err) + t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_DOLT_HOST", "ambient-dolt") + if err := initBeadsForDir("", rigDir, "rg", ""); err != nil { + t.Fatalf("initBeadsForDir: %v", err) } data, err := os.ReadFile(logFile) @@ -2112,25 +2515,119 @@ exit 0 t.Fatalf("read env log: %v", err) } parts := strings.Split(strings.TrimSpace(string(data)), "|") - if len(parts) != 4 { - t.Fatalf("captured env = %q, want 4 fields", strings.TrimSpace(string(data))) + if len(parts) != 2 { + t.Fatalf("env log = %q, want host|beads_dir", string(data)) } - if parts[0] != "" { - t.Fatalf("GC_DOLT leaked into provider env: %q", parts[0]) + if got := parts[0]; got != "ambient-dolt" { + t.Fatalf("GC_DOLT_HOST = %q, want ambient-dolt", got) } - if parts[3] != cityDir { - t.Fatalf("GC_CITY_PATH = %q, want %q", parts[3], cityDir) + if got, want := parts[1], filepath.Join(rigDir, ".beads"); got != want { + t.Fatalf("BEADS_DIR = %q, want %q", got, want) } } -func TestInitBeadsForDirExecGcBeadsBdPreservesCityRuntimeEnv(t *testing.T) { - cityDir := t.TempDir() - writeMinimalCityToml(t, cityDir) - logFile := filepath.Join(t.TempDir(), "env.log") - script := filepath.Join(t.TempDir(), "gc-beads-bd") - content := fmt.Sprintf(`#!/bin/sh +func TestInitBeadsForDirExecPreventsStrayGitInit(t *testing.T) { + script := filepath.Join(t.TempDir(), "bd-like-provider.sh") + content := `#!/bin/sh set -eu -case "$1" in +op="$1" +shift +case "$op" in + init) + dir="$1" + mkdir -p "$dir/.beads" + if [ -z "${BEADS_DIR:-}" ]; then + mkdir -p "$dir/.git" + fi + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + + rawDir := t.TempDir() + rawCmd := exec.Command(script, "init", rawDir, "raw") + rawCmd.Env = sanitizedBaseEnv() + rawOut, err := rawCmd.CombinedOutput() + if err != nil { + t.Fatalf("direct provider init failed: %v\n%s", err, rawOut) + } + if _, err := os.Stat(filepath.Join(rawDir, ".beads")); err != nil { + t.Fatalf("direct provider init did not create .beads: %v", err) + } + if _, err := os.Stat(filepath.Join(rawDir, ".git")); err != nil { + t.Fatalf("direct provider init did not emulate stray .git creation: %v", err) + } + + cityDir := t.TempDir() + writeMinimalCityToml(t, cityDir) + rigDir := filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("GC_BEADS", "exec:"+script) + if err := initBeadsForDir(cityDir, rigDir, "fe", "frontend-db"); err != nil { + t.Fatalf("initBeadsForDir: %v", err) + } + if _, err := os.Stat(filepath.Join(rigDir, ".beads")); err != nil { + t.Fatalf("initBeadsForDir did not create .beads: %v", err) + } + if _, err := os.Stat(filepath.Join(rigDir, ".git")); !os.IsNotExist(err) { + t.Fatalf("initBeadsForDir should prevent stray .git creation, stat err = %v", err) + } +} + +func TestRunProviderOpStripsAmbientGCDoltSkip(t *testing.T) { + cityDir := t.TempDir() + writeMinimalCityToml(t, cityDir) + logFile := filepath.Join(t.TempDir(), "env.log") + script := filepath.Join(t.TempDir(), "record-env.sh") + content := fmt.Sprintf(`#!/bin/sh +printf '%%s|%%s|%%s|%%s +' "${GC_DOLT:-}" "${GC_DOLT_HOST:-}" "${GC_DOLT_PORT:-}" "${GC_CITY_PATH:-}" > %q +exit 0 +`, logFile) + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) + t.Setenv("GC_DOLT", "skip") + + if err := runProviderOp(script, cityDir, "init", cityDir, "gc", "hq"); err != nil { + t.Fatalf("runProviderOp: %v", err) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read env log: %v", err) + } + parts := strings.Split(strings.TrimSpace(string(data)), "|") + if len(parts) != 4 { + t.Fatalf("captured env = %q, want 4 fields", strings.TrimSpace(string(data))) + } + if parts[0] != "" { + t.Fatalf("GC_DOLT leaked into provider env: %q", parts[0]) + } + if parts[3] != cityDir { + t.Fatalf("GC_CITY_PATH = %q, want %q", parts[3], cityDir) + } +} + +func TestInitBeadsForDirExecGcBeadsBdPreservesCityRuntimeEnv(t *testing.T) { + cityDir := t.TempDir() + writeMinimalCityToml(t, cityDir) + logFile := filepath.Join(t.TempDir(), "env.log") + script := filepath.Join(t.TempDir(), "gc-beads-bd") + content := fmt.Sprintf(`#!/bin/sh +set -eu +case "$1" in init) printf '%%s|%%s|%%s|%%s ' "${GC_CITY_PATH:-}" "${GC_CITY_RUNTIME_DIR:-}" "${GC_PACK_STATE_DIR:-}" "${GC_DOLT_DATA_DIR:-}" > %q @@ -2146,6 +2643,7 @@ esac } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) t.Setenv("GC_CITY_PATH", "/wrong-city") t.Setenv("GC_CITY_RUNTIME_DIR", "/wrong-runtime") t.Setenv("GC_PACK_STATE_DIR", "/wrong-pack") @@ -2222,6 +2720,7 @@ esac } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "gc", "hq"); err != nil { t.Fatalf("initBeadsForDir: %v", err) @@ -2304,6 +2803,7 @@ exit 0 } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, rigDir, "fe", "frontend-db"); err != nil { t.Fatalf("initBeadsForDir: %v", err) } @@ -2338,6 +2838,7 @@ func TestInitBeadsForDir_execOmitsCanonicalDoltDatabaseWhenUnknown(t *testing.T) } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "gc", ""); err != nil { t.Fatalf("expected nil, got %v", err) } @@ -2381,6 +2882,7 @@ esac } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) if err := initBeadsForDir(cityDir, cityDir, "gc", ""); err != nil { t.Fatalf("initBeadsForDir: %v", err) } @@ -2403,6 +2905,7 @@ func TestInitBeadsForDir_bd_skip(t *testing.T) { } MaterializeBuiltinPacks(dir) //nolint:errcheck t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", dir) t.Setenv("GC_DOLT", "skip") if err := initBeadsForDir(dir, dir, "test", "test"); err != nil { t.Fatalf("expected nil, got %v", err) @@ -2448,7 +2951,9 @@ esac t.Fatal(err) } + configureTestDoltIdentityEnv(t) t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) if err := initBeadsForDir(cityDir, cityDir, "gc", "hq"); err != nil { t.Fatalf("initBeadsForDir: %v", err) @@ -2502,7 +3007,9 @@ esac t.Fatal(err) } + configureTestDoltIdentityEnv(t) t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) t.Setenv("GC_CITY_PATH", "/wrong-city") t.Setenv("GC_CITY_RUNTIME_DIR", "/wrong-runtime") @@ -2618,6 +3125,7 @@ func TestStartBeadsLifecycleDoesNotMutateProcessDoltEnv(t *testing.T) { _ = os.Unsetenv("BEADS_DOLT_SERVER_HOST") cityPath := t.TempDir() + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) } @@ -2729,6 +3237,133 @@ func TestGcBeadsBdStartUsesRootBeadsDataDir(t *testing.T) { } } +func TestGcBeadsBdStartRetriesAutoPortBindConflict(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"demo\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + stateDir := t.TempDir() + attemptsFile := filepath.Join(stateDir, "attempts") + portsFile := filepath.Join(stateDir, "ports") + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := fmt.Sprintf(`#!/bin/sh +set -eu +attempts_file=%q +ports_file=%q +cmd="${1:-}" +case "$cmd" in + config) + exit 0 + ;; + --host) + count=0 + if [ -f "$attempts_file" ]; then + count=$(cat "$attempts_file") + fi + [ "$count" -ge 2 ] + ;; + sql-server) + config_file="" + while [ "$#" -gt 0 ]; do + case "$1" in + --config) + shift + config_file="$1" + ;; + esac + shift || true + done + port=$(awk '/^[[:space:]]*port:/{print $2; exit}' "$config_file") + printf '%%s\n' "$port" >> "$ports_file" + count=0 + if [ -f "$attempts_file" ]; then + count=$(cat "$attempts_file") + fi + count=$((count + 1)) + printf '%%s\n' "$count" > "$attempts_file" + if [ "$count" -eq 1 ]; then + echo "Starting server with Config HP=\"0.0.0.0:${port}\"|T=\"300000\"|R=\"false\"|L=\"warning\"" + echo "listen tcp 0.0.0.0:${port}: bind: address already in use" + exit 1 + fi + sleep 60 + exit 0 + ;; + *) + exit 0 + ;; +esac +`, attemptsFile, portsFile) + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + fakeNC := filepath.Join(binDir, "nc") + fakeNCScript := fmt.Sprintf(`#!/bin/sh +attempts_file=%q +count=0 +if [ -f "$attempts_file" ]; then + count=$(cat "$attempts_file") +fi +if [ "$count" -ge 2 ]; then + exit 0 +fi +exit 1 +`, attemptsFile) + if err := os.WriteFile(fakeNC, []byte(fakeNCScript), 0o755); err != nil { + t.Fatal(err) + } + + scriptEnv := sanitizedBaseEnv( + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + ) + t.Cleanup(func() { + cmd := exec.Command(script, "stop") + cmd.Env = scriptEnv + _ = cmd.Run() + }) + + cmd := exec.Command(script, "start") + cmd.Env = scriptEnv + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("start: %v\n%s", err, out) + } + + data, err := os.ReadFile(portsFile) + if err != nil { + t.Fatalf("read attempted ports: %v", err) + } + ports := strings.Fields(string(data)) + if len(ports) != 2 { + t.Fatalf("attempted ports = %v, want two startup attempts", ports) + } + if ports[0] == ports[1] { + t.Fatalf("retry reused busy port %s", ports[0]) + } + + state, err := readDoltRuntimeStateFile(providerManagedDoltStatePath(cityPath)) + if err != nil { + t.Fatalf("read provider state: %v", err) + } + if got := strconv.Itoa(state.Port); got != ports[1] { + t.Fatalf("provider state port = %q, want retry port %q", got, ports[1]) + } +} + func TestGcBeadsBdInitRetriesRootStoreVerification(t *testing.T) { cityPath := t.TempDir() writeMinimalCityToml(t, cityPath) @@ -2784,7 +3419,9 @@ esac t.Fatal(err) } + configureTestDoltIdentityEnv(t) t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) if err := initBeadsForDir(cityPath, cityPath, "mc", "mc"); err != nil { @@ -2856,6 +3493,7 @@ dolt.user: city-user captureFile := filepath.Join(t.TempDir(), "init-env-city") script := writeGcBeadsBdInitEnvCaptureScript(t, captureFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT_HOST", "ambient.invalid") t.Setenv("GC_DOLT_PORT", "9999") t.Setenv("GC_PACK_STATE_DIR", "/wrong/.gc/runtime/packs/dolt") @@ -2909,6 +3547,7 @@ dolt.user: rig-user captureFile := filepath.Join(t.TempDir(), "init-env-rig") script := writeGcBeadsBdInitEnvCaptureScript(t, captureFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT_HOST", "ambient.invalid") t.Setenv("GC_DOLT_PORT", "9999") t.Setenv("GC_PACK_STATE_DIR", "/wrong/.gc/runtime/packs/dolt") @@ -2981,6 +3620,7 @@ dolt.user: city-user captureFile := filepath.Join(t.TempDir(), "init-env-inherited-rig") script := writeGcBeadsBdInitEnvCaptureScript(t, captureFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT_HOST", "ambient.invalid") t.Setenv("GC_DOLT_PORT", "9999") if err := initAndHookDir(cityPath, rigPath, "fe"); err != nil { @@ -3037,6 +3677,7 @@ esac t.Fatal(err) } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT_HOST", "ambient.invalid") t.Setenv("GC_DOLT_PORT", "9999") t.Setenv("GC_PACK_STATE_DIR", "/wrong/.gc/runtime/packs/dolt") @@ -3227,6 +3868,7 @@ esac t.Fatal(err) } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_PACK_STATE_DIR", "/wrong/.gc/runtime/packs/dolt") if err := ensureBeadsProvider(cityPath); err != nil { t.Fatalf("ensureBeadsProvider: %v", err) @@ -3259,6 +3901,7 @@ dolt.port: 3307 t.Fatal(err) } t.Setenv("GC_BEADS", "exec:"+writeGcBeadsBdInitEnvCaptureScript(t, filepath.Join(t.TempDir(), "should-not-run"))) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := initAndHookDir(cityPath, cityPath, "gc"); err == nil || !strings.Contains(err.Error(), "invalid canonical city endpoint state") { t.Fatalf("initAndHookDir() error = %v, want invalid canonical city endpoint state", err) } @@ -3300,6 +3943,7 @@ esac } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := initAndHookDir(cityPath, cityPath, "gc"); err != nil { t.Fatalf("initAndHookDir: %v", err) } @@ -3430,10 +4074,10 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -3596,7 +4240,9 @@ esac t.Fatal(err) } + configureTestDoltIdentityEnv(t) t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator))) t.Setenv("GC_DOLT_HOST", "rig-db.example.com") t.Setenv("GC_DOLT_PORT", "3307") @@ -3612,14 +4258,15 @@ esac "3307", filepath.Join(rigDir, ".beads"), }, "|") - for _, name := range []string{"config.env", "migrate.env"} { - data, err := os.ReadFile(filepath.Join(captureDir, name)) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - if got := strings.TrimSpace(string(data)); got != wantPinned { - t.Fatalf("%s = %q, want %q", name, got, wantPinned) - } + data, err := os.ReadFile(filepath.Join(captureDir, "migrate.env")) + if err != nil { + t.Fatalf("read migrate.env: %v", err) + } + if got := strings.TrimSpace(string(data)); got != wantPinned { + t.Fatalf("migrate.env = %q, want %q", got, wantPinned) + } + if _, err := os.Stat(filepath.Join(captureDir, "config.env")); !os.IsNotExist(err) { + t.Fatalf("config.env exists after init; err=%v", err) } listData, err := os.ReadFile(filepath.Join(captureDir, "list.env")) if err != nil { @@ -3703,11 +4350,11 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+currentGCBinaryForTests(t), "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -3833,11 +4480,11 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+fakeGC, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -3922,11 +4569,11 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+currentGCBinaryForTests(t), "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -3986,10 +4633,10 @@ exit 0 } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -4112,31 +4759,32 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", "gascity") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+fakeGC, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) } - for _, name := range []string{"config-db.log", "migrate-db.log"} { - data, err := os.ReadFile(filepath.Join(captureDir, name)) - if err != nil { - t.Fatalf("ReadFile(%s): %v", name, err) - } - lines := strings.Fields(string(data)) - if len(lines) == 0 { - t.Fatalf("%s empty", name) - } - for _, line := range lines { - if line != "gascity" { - t.Fatalf("%s line = %q, want gascity", name, line) - } + data, err := os.ReadFile(filepath.Join(captureDir, "migrate-db.log")) + if err != nil { + t.Fatalf("ReadFile(migrate-db.log): %v", err) + } + lines := strings.Fields(string(data)) + if len(lines) == 0 { + t.Fatal("migrate-db.log empty") + } + for _, line := range lines { + if line != "gascity" { + t.Fatalf("migrate-db.log line = %q, want gascity", line) } } + if _, err := os.Stat(filepath.Join(captureDir, "config-db.log")); !os.IsNotExist(err) { + t.Fatalf("config-db.log exists after init; err=%v", err) + } metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) if err != nil { t.Fatalf("read metadata: %v", err) @@ -4256,113 +4904,691 @@ esac } cmd := exec.Command(script, "init", cityPath, "gc", strings.ToUpper(managedDoltProbeDatabase)) - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+fakeGC, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) + } + + data, err := os.ReadFile(filepath.Join(captureDir, "migrate-db.log")) + if err != nil { + t.Fatalf("ReadFile(migrate-db.log): %v", err) + } + lines := strings.Fields(string(data)) + if len(lines) == 0 { + t.Fatal("migrate-db.log empty") + } + for _, line := range lines { + if line != strings.ToUpper(managedDoltProbeDatabase) { + t.Fatalf("migrate-db.log line = %q, want %s", line, strings.ToUpper(managedDoltProbeDatabase)) + } + } + if _, err := os.Stat(filepath.Join(captureDir, "config-db.log")); !os.IsNotExist(err) { + t.Fatalf("config-db.log exists after init; err=%v", err) + } + + metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + metaText := string(metaData) + if !strings.Contains(metaText, `"dolt_database": "`+strings.ToUpper(managedDoltProbeDatabase)+`"`) || !strings.Contains(metaText, `"project_id": "backfilled-project-id"`) { + t.Fatalf("metadata = %s", metaText) + } +} + +func TestEnforceCanonicalScopeMetadataForInitRepairsWrongDoltDatabaseFromExplicitCanonicalIdentity(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"wrong-db","dolt_host":"127.0.0.1","dolt_user":"legacy","dolt_password":"secret","dolt_server_host":"legacy.example.com","dolt_server_port":"3307","dolt_server_user":"legacy-user","dolt_port":"4406"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { + t.Fatalf("enforceCanonicalScopeMetadataForInit: %v", err) + } + + metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + metaText := string(metaData) + if !strings.Contains(metaText, `"dolt_database": "gascity"`) { + t.Fatalf("metadata should be repaired to canonical database:\n%s", metaText) + } + for _, forbidden := range []string{"dolt_host", "dolt_user", "dolt_password", "dolt_server_host", "dolt_server_port", "dolt_server_user", "dolt_port"} { + if strings.Contains(metaText, forbidden) { + t.Fatalf("metadata should scrub deprecated field %s:\n%s", forbidden, metaText) + } + } +} + +func TestEnforceCanonicalScopeMetadataForInitScrubsDeprecatedMetadataEndpointAuthFields(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"legacy","backend":"legacy","dolt_mode":"embedded","dolt_database":"wrong-db","custom":"keep","dolt_host":"127.0.0.1","dolt_user":"legacy-user","dolt_password":"legacy-pass","dolt_server_host":"legacy.example.com","dolt_server_port":"3307","dolt_server_user":"legacy-server-user","dolt_port":"4406"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { + t.Fatalf("enforceCanonicalScopeMetadataForInit: %v", err) + } + if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { + t.Fatalf("second enforceCanonicalScopeMetadataForInit: %v", err) + } + + metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + var meta map[string]any + if err := json.Unmarshal(metaData, &meta); err != nil { + t.Fatalf("unmarshal metadata: %v", err) + } + for _, forbidden := range []string{"dolt_host", "dolt_user", "dolt_password", "dolt_server_host", "dolt_server_port", "dolt_server_user", "dolt_port"} { + if _, ok := meta[forbidden]; ok { + t.Fatalf("metadata should scrub %s: %s", forbidden, string(metaData)) + } + } + for key, want := range map[string]string{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "server", + "dolt_database": "gascity", + "custom": "keep", + } { + if got := strings.TrimSpace(fmt.Sprint(meta[key])); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } +} + +func TestGcBeadsBdInitPreservesMetadataIdentityWhenCanonicalUnknownAndDatabaseMustBeCreated(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"gascity"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + sqlLog := filepath.Join(t.TempDir(), "dolt-sql.log") + createdFile := filepath.Join(t.TempDir(), "created-gascity") + + fakeBd := filepath.Join(binDir, "bd") + fakeBdScript := `#!/bin/sh +set -eu +case "${1:-}" in + list|config|migrate) + exit 0 + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { + t.Fatal(err) + } + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := `#!/bin/sh +set -eu +query="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-q" ]; then + query="$arg" + break + fi + prev="$arg" +done +printf '%s\n' "$query" >> "` + sqlLog + `" +case "$query" in + 'USE ` + "`gascity`" + `') + if [ -f "` + createdFile + `" ]; then + exit 0 + fi + exit 1 + ;; + 'CREATE DATABASE IF NOT EXISTS ` + "`gascity`" + `') + : > "` + createdFile + `" + exit 0 + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(script, "init", cityPath, "gc") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + )...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) + } + + metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + if got := string(metaData); !strings.Contains(got, `"dolt_database":"gascity"`) && !strings.Contains(got, `"dolt_database": "gascity"`) { + t.Fatalf("metadata should preserve existing database identity:\n%s", got) + } + + sqlData, err := os.ReadFile(sqlLog) + if err != nil { + t.Fatalf("read sql log: %v", err) + } + sqlText := string(sqlData) + if !strings.Contains(sqlText, "CREATE DATABASE IF NOT EXISTS `gascity`") { + t.Fatalf("expected canonical database creation, got:\n%s", sqlText) + } + if strings.Contains(sqlText, "CREATE DATABASE IF NOT EXISTS `gc`") { + t.Fatalf("should not create prefix database when preserving metadata identity:\n%s", sqlText) + } +} + +// TestGcBeadsBdInitFastPathRepairsRuntimeConfigDirectly guards the fix for +// bd v1.0.3 rejecting DB-backed config writes during the managed fast path +// after the schema already exists. In that state, the script should repair +// issue_prefix and types.custom directly without falling back to bd init. +func TestGcBeadsBdInitFastPathRepairsRuntimeConfigDirectly(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + // Seed metadata.json, simulating seedDeferredManagedBeadsBeforeProviderReadiness + // writing it before Dolt starts (the trigger for the fast path on a fresh city). + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), + []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + initArgsFile := filepath.Join(t.TempDir(), "unexpected-bd-init-args") + sqlLogFile := filepath.Join(t.TempDir(), "dolt-sql-args") + fakeBd := filepath.Join(binDir, "bd") + fakeBdScript := fmt.Sprintf(`#!/bin/sh +set -eu +cmd="${1:-}" +case "$cmd" in + config) + sub="${2:-}" + key="${3:-}" + if [ "$sub" = "set" ] && { [ "$key" = "issue_prefix" ] || [ "$key" = "types.custom" ]; }; then + echo "$key must not be set through bd config set" >&2 + exit 2 + fi + exit 0 + ;; + init) + printf '%%s\n' "$@" > %q + echo "bd init fallback should not run" >&2 + exit 2 + ;; + migrate|list) + exit 0 + ;; + *) + exit 0 + ;; +esac +`, initArgsFile) + if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { + t.Fatal(err) + } + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" >> %q\nexit 0\n", sqlLogFile) + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(script, "init", cityPath, "gc", "hq") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + )...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) + } + + if data, err := os.ReadFile(initArgsFile); err == nil { + t.Fatalf("bd init fallback unexpectedly ran with argv:\n%s", data) + } else if !os.IsNotExist(err) { + t.Fatalf("stat bd init argv: %v", err) + } + + sqlData, err := os.ReadFile(sqlLogFile) + if err != nil { + t.Fatalf("read dolt SQL log: %v", err) + } + sqlText := string(sqlData) + for _, want := range []string{ + "SELECT 1 FROM config LIMIT 1", + "USE `hq`", + "VALUES ('issue_prefix', 'gc') ON DUPLICATE KEY UPDATE", + "VALUES ('types.custom', 'molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence') ON DUPLICATE KEY UPDATE", + } { + if !strings.Contains(sqlText, want) { + t.Fatalf("dolt SQL log missing %q:\n%s", want, sqlText) + } + } +} + +func TestGcBeadsBdInitMetadataOnlyFallsThroughToForcedBdInitWithPinnedDatabaseWhenSchemaMissing(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), + []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + initArgsFile := filepath.Join(t.TempDir(), "bd-init-args") + initCountFile := filepath.Join(t.TempDir(), "bd-init-count") + sqlLogFile := filepath.Join(t.TempDir(), "dolt-sql-args") + fakeBd := filepath.Join(binDir, "bd") + fakeBdScript := fmt.Sprintf(`#!/bin/sh +set -eu +cmd="${1:-}" +case "$cmd" in + init) + has_force=false + for arg in "$@"; do + if [ "$arg" = "--force" ]; then + has_force=true + fi + done + if [ "$has_force" != "true" ]; then + echo "bd init fallback must force reinitialize existing workspace" >&2 + exit 2 + fi + printf '1\n' > %q + printf '%%s\n' "$@" > %q + exit 0 + ;; + config|migrate|list) + exit 0 + ;; + *) + exit 0 + ;; +esac +`, initCountFile, initArgsFile) + if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { + t.Fatal(err) + } + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := fmt.Sprintf(`#!/bin/sh +set -eu +query="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-q" ]; then + query="$arg" + break + fi + prev="$arg" +done +printf '%%s\n' "$query" >> %q +case "$query" in + 'USE `+"`hq`"+`; SELECT 1 FROM config LIMIT 1') + if [ ! -f %q ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''types.custom'\'', '\''molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''issue_prefix'\'', '\''gc'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + exit 0 + ;; + *) + exit 0 + ;; +esac +`, sqlLogFile, initCountFile) + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(script, "init", cityPath, "gc", "hq") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + )...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) + } + + data, err := os.ReadFile(initArgsFile) + if err != nil { + t.Fatalf("expected bd init fallback to run: %v", err) + } + got := string(data) + for _, want := range []string{"--force", "--server", "-p", "gc", "--database", "hq", cityPath} { + if !strings.Contains(got, want) { + t.Fatalf("bd init argv missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "-p hq") { + t.Fatalf("bd init should keep visible prefix gc while pinning database hq:\n%s", got) + } +} + +func TestGcBeadsBdInitWaitsForSchemaVisibilityBeforeRuntimeRepair(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), + []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + probeCountFile := filepath.Join(t.TempDir(), "schema-probe-count") + fakeBd := filepath.Join(binDir, "bd") + fakeBdScript := `#!/bin/sh +set -eu +case "${1:-}" in + init|config|migrate|list) + exit 0 + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { + t.Fatal(err) + } + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := fmt.Sprintf(`#!/bin/sh +set -eu +query="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-q" ]; then + query="$arg" + break + fi + prev="$arg" +done +case "$query" in + 'USE `+"`hq`"+`; SELECT 1 FROM config LIMIT 1') + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + count=$((count + 1)) + printf '%%s\n' "$count" > %q + if [ "$count" -lt 3 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''types.custom'\'', '\''molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + if [ ! -f %q ] || [ "$(cat %q)" -lt 3 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''issue_prefix'\'', '\''gc'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + if [ ! -f %q ] || [ "$(cat %q)" -lt 3 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + *) + exit 0 + ;; +esac +`, probeCountFile, probeCountFile, probeCountFile, probeCountFile, probeCountFile, probeCountFile, probeCountFile) + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(script, "init", cityPath, "gc", "hq") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + )...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) + } + + data, err := os.ReadFile(probeCountFile) + if err != nil { + t.Fatalf("read schema probe count: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "3" { + t.Fatalf("schema probe count = %q, want 3", got) + } +} + +func TestGcBeadsBdInitRetriesPlainInitWhenSchemaStillMissingAfterSuccess(t *testing.T) { + cityPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + + if err := MaterializeBuiltinPacks(cityPath); err != nil { + t.Fatalf("MaterializeBuiltinPacks: %v", err) + } + script := gcBeadsBdScriptPath(cityPath) + + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + initCountFile := filepath.Join(t.TempDir(), "bd-init-count") + initArgsFile := filepath.Join(t.TempDir(), "bd-init-args") + fakeBd := filepath.Join(binDir, "bd") + fakeBdScript := fmt.Sprintf(`#!/bin/sh +set -eu +case "${1:-}" in + init) + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + count=$((count + 1)) + printf '%%s\n' "$count" > %q + printf '%%s\n' "$*" >> %q + exit 0 + ;; + config|migrate|list) + exit 0 + ;; + *) + exit 0 + ;; +esac +`, initCountFile, initCountFile, initCountFile, initArgsFile) + if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { + t.Fatal(err) + } + + fakeDolt := filepath.Join(binDir, "dolt") + fakeDoltScript := fmt.Sprintf(`#!/bin/sh +set -eu +query="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-q" ]; then + query="$arg" + break + fi + prev="$arg" +done +case "$query" in + 'USE `+"`hq`"+`') + exit 0 + ;; + 'CREATE DATABASE IF NOT EXISTS `+"`hq`"+`') + exit 0 + ;; + 'DROP DATABASE IF EXISTS `+"`beads_gc`"+`') + exit 0 + ;; + 'USE `+"`hq`"+`; SELECT 1 FROM config LIMIT 1') + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + if [ "$count" -lt 2 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''types.custom'\'', '\''molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + if [ "$count" -lt 2 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''issue_prefix'\'', '\''gc'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + if [ "$count" -lt 2 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 + ;; + *) + exit 0 + ;; +esac +`, initCountFile, initCountFile, initCountFile, initCountFile, initCountFile, initCountFile) + if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(script, "init", cityPath, "gc", "hq") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), + "GC_CITY_PATH="+cityPath, + "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) } - for _, name := range []string{"config-db.log", "migrate-db.log"} { - data, err := os.ReadFile(filepath.Join(captureDir, name)) - if err != nil { - t.Fatalf("ReadFile(%s): %v", name, err) - } - lines := strings.Fields(string(data)) - if len(lines) == 0 { - t.Fatalf("%s empty", name) - } - for _, line := range lines { - if line != strings.ToUpper(managedDoltProbeDatabase) { - t.Fatalf("%s line = %q, want %s", name, line, strings.ToUpper(managedDoltProbeDatabase)) - } - } - } - - metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - metaText := string(metaData) - if !strings.Contains(metaText, `"dolt_database": "`+strings.ToUpper(managedDoltProbeDatabase)+`"`) || !strings.Contains(metaText, `"project_id": "backfilled-project-id"`) { - t.Fatalf("metadata = %s", metaText) - } -} - -func TestEnforceCanonicalScopeMetadataForInitRepairsWrongDoltDatabaseFromExplicitCanonicalIdentity(t *testing.T) { - cityPath := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"wrong-db","dolt_host":"127.0.0.1","dolt_user":"legacy","dolt_password":"secret","dolt_server_host":"legacy.example.com","dolt_server_port":"3307","dolt_server_user":"legacy-user","dolt_port":"4406"}`), 0o644); err != nil { - t.Fatal(err) - } - - if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { - t.Fatalf("enforceCanonicalScopeMetadataForInit: %v", err) - } - - metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + countData, err := os.ReadFile(initCountFile) if err != nil { - t.Fatalf("read metadata: %v", err) - } - metaText := string(metaData) - if !strings.Contains(metaText, `"dolt_database": "gascity"`) { - t.Fatalf("metadata should be repaired to canonical database:\n%s", metaText) - } - for _, forbidden := range []string{"dolt_host", "dolt_user", "dolt_password", "dolt_server_host", "dolt_server_port", "dolt_server_user", "dolt_port"} { - if strings.Contains(metaText, forbidden) { - t.Fatalf("metadata should scrub deprecated field %s:\n%s", forbidden, metaText) - } - } -} - -func TestEnforceCanonicalScopeMetadataForInitScrubsDeprecatedMetadataEndpointAuthFields(t *testing.T) { - cityPath := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"legacy","backend":"legacy","dolt_mode":"embedded","dolt_database":"wrong-db","custom":"keep","dolt_host":"127.0.0.1","dolt_user":"legacy-user","dolt_password":"legacy-pass","dolt_server_host":"legacy.example.com","dolt_server_port":"3307","dolt_server_user":"legacy-server-user","dolt_port":"4406"}`), 0o644); err != nil { - t.Fatal(err) - } - - if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { - t.Fatalf("enforceCanonicalScopeMetadataForInit: %v", err) + t.Fatalf("read init count: %v", err) } - if err := enforceCanonicalScopeMetadataForInit(fsys.OSFS{}, cityPath, "gascity"); err != nil { - t.Fatalf("second enforceCanonicalScopeMetadataForInit: %v", err) + if got := strings.TrimSpace(string(countData)); got != "2" { + t.Fatalf("bd init count = %q, want 2", got) } - metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) + argsData, err := os.ReadFile(initArgsFile) if err != nil { - t.Fatalf("read metadata: %v", err) - } - var meta map[string]any - if err := json.Unmarshal(metaData, &meta); err != nil { - t.Fatalf("unmarshal metadata: %v", err) + t.Fatalf("read init args: %v", err) } - for _, forbidden := range []string{"dolt_host", "dolt_user", "dolt_password", "dolt_server_host", "dolt_server_port", "dolt_server_user", "dolt_port"} { - if _, ok := meta[forbidden]; ok { - t.Fatalf("metadata should scrub %s: %s", forbidden, string(metaData)) + gotArgs := string(argsData) + for _, want := range []string{"init --quiet --server -p gc --database hq"} { + if !strings.Contains(gotArgs, want) { + t.Fatalf("bd init retry args missing %q:\n%s", want, gotArgs) } } - for key, want := range map[string]string{ - "database": "dolt", - "backend": "dolt", - "dolt_mode": "server", - "dolt_database": "gascity", - "custom": "keep", - } { - if got := strings.TrimSpace(fmt.Sprint(meta[key])); got != want { - t.Fatalf("%s = %q, want %q", key, got, want) - } + if strings.Contains(gotArgs, "--force") { + t.Fatalf("post-init schema retry should rerun plain init, got:\n%s", gotArgs) } } -func TestGcBeadsBdInitPreservesMetadataIdentityWhenCanonicalUnknownAndDatabaseMustBeCreated(t *testing.T) { +func TestGcBeadsBdInitDropsMetadataBeforeRetryingInitAfterForcedFallback(t *testing.T) { cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) @@ -4370,7 +5596,8 @@ func TestGcBeadsBdInitPreservesMetadataIdentityWhenCanonicalUnknownAndDatabaseMu if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"gascity"}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), + []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"hq"}`), 0o644); err != nil { t.Fatal(err) } @@ -4384,27 +5611,42 @@ func TestGcBeadsBdInitPreservesMetadataIdentityWhenCanonicalUnknownAndDatabaseMu t.Fatal(err) } - sqlLog := filepath.Join(t.TempDir(), "dolt-sql.log") - createdFile := filepath.Join(t.TempDir(), "created-gascity") - + initCountFile := filepath.Join(t.TempDir(), "bd-init-count") + initArgsFile := filepath.Join(t.TempDir(), "bd-init-args") + initStateFile := filepath.Join(t.TempDir(), "bd-init-state") fakeBd := filepath.Join(binDir, "bd") - fakeBdScript := `#!/bin/sh + fakeBdScript := fmt.Sprintf(`#!/bin/sh set -eu case "${1:-}" in - list|config|migrate) + init) + count=0 + if [ -f %q ]; then + count=$(cat %q) + fi + count=$((count + 1)) + printf '%%s\n' "$count" > %q + if [ -f "$BEADS_DIR/metadata.json" ]; then + printf 'metadata=yes args=%%s\n' "$*" >> %q + else + printf 'metadata=no args=%%s\n' "$*" >> %q + fi + printf '%%s\n' "$*" >> %q + exit 0 + ;; + config|migrate|list) exit 0 ;; *) exit 0 ;; esac -` +`, initCountFile, initCountFile, initCountFile, initStateFile, initStateFile, initArgsFile) if err := os.WriteFile(fakeBd, []byte(fakeBdScript), 0o755); err != nil { t.Fatal(err) } fakeDolt := filepath.Join(binDir, "dolt") - fakeDoltScript := `#!/bin/sh + fakeDoltScript := fmt.Sprintf(`#!/bin/sh set -eu query="" prev="" @@ -4415,55 +5657,64 @@ for arg in "$@"; do fi prev="$arg" done -printf '%s\n' "$query" >> "` + sqlLog + `" case "$query" in - 'USE ` + "`gascity`" + `') - if [ -f "` + createdFile + `" ]; then - exit 0 + 'USE `+"`hq`"+`') + exit 0 + ;; + 'CREATE DATABASE IF NOT EXISTS `+"`hq`"+`') + exit 0 + ;; + 'DROP DATABASE IF EXISTS `+"`beads_gc`"+`') + exit 0 + ;; + 'USE `+"`hq`"+`; SELECT 1 FROM config LIMIT 1') + count=0 + if [ -f %q ]; then + count=$(cat %q) fi - exit 1 + if [ "$count" -lt 2 ]; then + echo "table not found: config" >&2 + exit 1 + fi + exit 0 ;; - 'CREATE DATABASE IF NOT EXISTS ` + "`gascity`" + `') - : > "` + createdFile + `" + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''types.custom'\'', '\''molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') + exit 0 + ;; + 'USE `+"`hq`"+`; INSERT INTO config (`+"`key`"+`, value) VALUES ('\''issue_prefix'\'', '\''gc'\'') ON DUPLICATE KEY UPDATE value = VALUES(value)') exit 0 ;; *) exit 0 ;; esac -` +`, initCountFile, initCountFile) if err := os.WriteFile(fakeDolt, []byte(fakeDoltScript), 0o755); err != nil { t.Fatal(err) } - cmd := exec.Command(script, "init", cityPath, "gc") - cmd.Env = sanitizedBaseEnv( + cmd := exec.Command(script, "init", cityPath, "gc", "hq") + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) } - metaData, err := os.ReadFile(filepath.Join(cityPath, ".beads", "metadata.json")) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - if got := string(metaData); !strings.Contains(got, `"dolt_database":"gascity"`) && !strings.Contains(got, `"dolt_database": "gascity"`) { - t.Fatalf("metadata should preserve existing database identity:\n%s", got) - } - - sqlData, err := os.ReadFile(sqlLog) + stateData, err := os.ReadFile(initStateFile) if err != nil { - t.Fatalf("read sql log: %v", err) - } - sqlText := string(sqlData) - if !strings.Contains(sqlText, "CREATE DATABASE IF NOT EXISTS `gascity`") { - t.Fatalf("expected canonical database creation, got:\n%s", sqlText) + t.Fatalf("read init state: %v", err) } - if strings.Contains(sqlText, "CREATE DATABASE IF NOT EXISTS `gc`") { - t.Fatalf("should not create prefix database when preserving metadata identity:\n%s", sqlText) + gotState := string(stateData) + for _, want := range []string{ + "metadata=yes args=init --force --quiet --server -p gc --database hq", + "metadata=no args=init --quiet --server -p gc --database hq", + } { + if !strings.Contains(gotState, want) { + t.Fatalf("init state missing %q:\n%s", want, gotState) + } } } @@ -4696,6 +5947,7 @@ dolt.auto-start: false func TestValidateCanonicalCompatDoltDriftRejectsCityMismatch(t *testing.T) { cityPath := t.TempDir() t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil { t.Fatal(err) } @@ -4790,6 +6042,8 @@ esac if err := os.WriteFile(fakeDolt, []byte(fakeScript), 0o755); err != nil { t.Fatal(err) } + invocationFile := filepath.Join(t.TempDir(), "gc-invocations.log") + fakeGC := writeFakeManagedConfigWriterGC(t, binDir, invocationFile) compatPort := reserveRandomTCPPort(t) compatListener := startTCPListenerProcess(t, compatPort) @@ -4810,6 +6064,7 @@ esac env := sanitizedBaseEnv( "GC_CITY_PATH="+cityPath, + "GC_BIN="+fakeGC, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), ) runStart := func() { @@ -4912,7 +6167,7 @@ data_dir: "$data_dir" behavior: auto_gc_behavior: enable: true - archive_level: 1 + archive_level: 0 EOF ;; "dolt-state allocate-port") @@ -5167,7 +6422,7 @@ data_dir: "$data_dir" behavior: auto_gc_behavior: enable: true - archive_level: 1 + archive_level: 0 EOF printf '12345\n' > "$pid_file" printf '{"running":true,"pid":12345,"port":%%s,"data_dir":"%%s","started_at":"2026-04-14T00:00:00Z"}\n' "$port" "$data_dir" > "$state_file" @@ -6408,7 +7663,7 @@ func TestManagedDoltConfigGoWriterMatchesShellFallbackSemantics(t *testing.T) { t.Fatal(err) } goConfigPath := filepath.Join(t.TempDir(), "go", "dolt-config.yaml") - if err := writeManagedDoltConfigFile(goConfigPath, "0.0.0.0", "3311", filepath.Join(cityPath, ".beads", "dolt"), "info"); err != nil { + if err := writeManagedDoltConfigFile(goConfigPath, "0.0.0.0", "3311", filepath.Join(cityPath, ".beads", "dolt"), "info", 0); err != nil { t.Fatalf("writeManagedDoltConfigFile: %v", err) } @@ -6623,13 +7878,15 @@ INNERPY exit 0 ;; esac -` + ` if err := os.WriteFile(fakeDolt, []byte(fakeScript), 0o755); err != nil { t.Fatal(err) } + gcBin := currentGCBinaryForTests(t) env := sanitizedBaseEnv( "GC_CITY_PATH="+cityPath, + "GC_BIN="+gcBin, "GC_DOLT_PORT="+port, "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), ) @@ -6772,7 +8029,7 @@ INNERPY exit 0 ;; esac -`, countFile, filepath.Join(cityPath, ".beads", "dolt"), deletedMarkerFile) + `, countFile, filepath.Join(cityPath, ".beads", "dolt"), deletedMarkerFile) if err := os.WriteFile(fakeDolt, []byte(fakeScript), 0o755); err != nil { t.Fatal(err) } @@ -6949,10 +8206,6 @@ esac } initialStartCount := readDoltStartCountForTest(t, countFile) - realNC, err := exec.LookPath("nc") - if err != nil { - t.Skip("nc not installed") - } shimDir := filepath.Join(t.TempDir(), "shim") if err := os.MkdirAll(shimDir, 0o755); err != nil { t.Fatal(err) @@ -6962,13 +8215,12 @@ esac shim := fmt.Sprintf(`#!/bin/sh set -eu probe_file=%q -real_nc=%q if [ ! -f "$probe_file" ]; then : > "$probe_file" exit 1 fi -exec "$real_nc" "$@" -`, probeFile, realNC) +exit 0 +`, probeFile) if err := os.WriteFile(shimPath, []byte(shim), 0o755); err != nil { t.Fatal(err) } @@ -7003,6 +8255,7 @@ func TestValidateCanonicalCompatDoltDriftRejectsInheritedRigCompatOverrideWithRe rigRel := "frontend" rigPath := filepath.Join(cityPath, rigRel) t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) for _, dir := range []string{cityPath, rigPath} { if err := os.MkdirAll(filepath.Join(dir, ".beads"), 0o700); err != nil { t.Fatal(err) @@ -7056,6 +8309,7 @@ func TestValidateCanonicalCompatDoltDriftRejectsInheritedRigCompatOverride(t *te cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "frontend") t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) for _, dir := range []string{cityPath, rigPath} { if err := os.MkdirAll(filepath.Join(dir, ".beads"), 0o700); err != nil { t.Fatal(err) @@ -7116,6 +8370,7 @@ dolt.port: 3307 } t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) cfg := &config.City{ Workspace: config.Workspace{Name: "test-city"}, Dolt: config.DoltConfig{Host: "compat-db.example.com", Port: 4406}, @@ -7264,6 +8519,7 @@ dolt.auto-start: false } t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} if err := startBeadsLifecycle(cityPath, "test-city", cfg, io.Discard); err == nil || !strings.Contains(err.Error(), "invalid canonical city endpoint state") { t.Fatalf("startBeadsLifecycle() error = %v, want invalid canonical city endpoint state", err) @@ -7295,6 +8551,7 @@ dolt.auto-start: false } t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) cfg := &config.City{ Workspace: config.Workspace{Name: "test-city"}, Rigs: []config.Rig{{Name: "frontend", Path: rigPath, Prefix: "fe"}}, @@ -7307,6 +8564,7 @@ dolt.auto-start: false func TestStartBeadsLifecycleRegistersDoltConfig(t *testing.T) { cityPath := t.TempDir() t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT", "skip") t.Setenv("GC_DOLT_HOST", "") _ = os.Unsetenv("GC_DOLT_HOST") @@ -7329,6 +8587,40 @@ func TestStartBeadsLifecycleRegistersDoltConfig(t *testing.T) { } } +func TestStartBeadsLifecycleRegistersArchiveLevelOnlyDoltConfig(t *testing.T) { + realCity := t.TempDir() + aliasRoot := t.TempDir() + aliasCity := filepath.Join(aliasRoot, "city-link") + if err := os.Symlink(realCity, aliasCity); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", aliasCity) + t.Setenv("GC_DOLT", "skip") + + archiveLevel := 1 + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Dolt: config.DoltConfig{ArchiveLevel: &archiveLevel}, + } + if err := startBeadsLifecycle(aliasCity, "test-city", cfg, io.Discard); err != nil { + t.Fatalf("startBeadsLifecycle: %v", err) + } + t.Cleanup(func() { cityDoltConfigs.Delete(normalizePathForCompare(realCity)) }) + + envEntries := providerLifecycleProcessEnv(realCity, "exec:"+gcBeadsBdScriptPath(realCity)) + env := map[string]string{} + for _, entry := range envEntries { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[key] = value + } + } + if got := env["GC_DOLT_ARCHIVE_LEVEL"]; got != "1" { + t.Fatalf("GC_DOLT_ARCHIVE_LEVEL = %q, want 1", got) + } +} + func TestStartBeadsLifecycleManagedDeferredDoesNotRequireRuntimeState(t *testing.T) { cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "rig") @@ -7373,6 +8665,7 @@ func TestStartBeadsLifecycleManagedDeferredDoesNotRequireRuntimeState(t *testing } t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) cfg := &config.City{ Workspace: config.Workspace{Name: "test-city"}, Rigs: []config.Rig{{Name: "rig", Path: rigPath, Prefix: "rg"}}, @@ -7422,6 +8715,7 @@ dolt.port: "4406" } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) // Defensively ensure the call log does not pre-exist. t.TempDir() // provides a fresh directory, but other test-global resolution paths @@ -7467,6 +8761,7 @@ dolt.port: "4406" } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) if err := shutdownBeadsProvider(cityPath); err != nil { t.Fatalf("shutdownBeadsProvider() error = %v", err) } @@ -7498,6 +8793,7 @@ func TestStartBeadsLifecycleSkipsProviderForExternalHost(t *testing.T) { } t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) t.Setenv("GC_DOLT_HOST", "operator-override.example.com") t.Setenv("GC_DOLT_PORT", "5511") t.Setenv("BEADS_DOLT_SERVER_HOST", "operator-override.example.com") @@ -7704,11 +9000,11 @@ esac } cmd := exec.Command(script, "init", rigPath, "fe", "fe") - cmd.Env = sanitizedBaseEnv( + cmd.Env = sanitizedBaseEnv(append(gcBeadsBdTestHomeEnv(t), "GC_CITY_PATH="+cityPath, "GC_BIN="+currentGCBinaryForTests(t), "PATH="+strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)), - ) + )...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("gc-beads-bd init failed: %v\n%s", err, out) @@ -7867,3 +9163,186 @@ func TestGcBeadsBdStartFallsBackToShellManagedConfigWriterWhenGCBinUnset(t *test t.Fatalf("provider state port = %d, want non-zero", state.Port) } } + +func TestAcquireProviderSemaphore_SerializesConcurrentOps(t *testing.T) { + t.Parallel() + cityPath := t.TempDir() + + // First acquire succeeds immediately. + release1, err := acquireProviderSemaphore(context.Background(), cityPath) + if err != nil { + t.Fatalf("acquireProviderSemaphore first: %v", err) + } + + // Second acquire should block. + acquired := make(chan struct{}) + go func() { + release2, err := acquireProviderSemaphore(context.Background(), cityPath) + if err != nil { + return + } + close(acquired) + release2() + }() + + select { + case <-acquired: + t.Fatal("second acquire succeeded while first still held") + case <-time.After(50 * time.Millisecond): + // Expected — still blocked. + } + + // Release first — second should unblock. + release1() + + select { + case <-acquired: + // Expected. + case <-time.After(2 * time.Second): + t.Fatal("second acquire did not unblock after release") + } +} + +func TestAcquireProviderSemaphore_IndependentCities(t *testing.T) { + t.Parallel() + city1 := t.TempDir() + city2 := t.TempDir() + + release1, err := acquireProviderSemaphore(context.Background(), city1) + if err != nil { + t.Fatalf("acquireProviderSemaphore city1: %v", err) + } + defer release1() + + // Different city should not block. + acquired := make(chan struct{}) + go func() { + release2, err := acquireProviderSemaphore(context.Background(), city2) + if err != nil { + return + } + close(acquired) + release2() + }() + + select { + case <-acquired: + // Expected — different cities are independent. + case <-time.After(2 * time.Second): + t.Fatal("acquire for different city blocked unexpectedly") + } +} + +func TestAcquireProviderSemaphoreHonorsContextDeadline(t *testing.T) { + t.Parallel() + cityPath := t.TempDir() + + release1, err := acquireProviderSemaphore(context.Background(), cityPath) + if err != nil { + t.Fatalf("acquireProviderSemaphore first: %v", err) + } + defer release1() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + release2, err := acquireProviderSemaphore(ctx, cityPath) + if err == nil { + release2() + t.Fatal("second acquire succeeded while first still held") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("acquireProviderSemaphore error = %v, want context deadline", err) + } +} + +func TestEnsureBeadsProviderSerializesConcurrentExecStarts(t *testing.T) { + cityPath := t.TempDir() + script := filepath.Join(cityPath, "provider.sh") + lockDir := filepath.Join(cityPath, "provider.lock") + callLog := filepath.Join(cityPath, "provider.log") + scriptBody := fmt.Sprintf(`#!/bin/sh +set -eu +if [ "$1" = "start" ]; then + if ! mkdir %q 2>/dev/null; then + echo "overlap" >&2 + exit 1 + fi + echo "start" >> %q + sleep 0.1 + rmdir %q + exit 0 +fi +exit 2 +`, lockDir, callLog, lockDir) + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) + + errs := make(chan error, 2) + for range 2 { + go func() { + errs <- ensureBeadsProvider(cityPath) + }() + } + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("ensureBeadsProvider: %v", err) + } + } + + data, err := os.ReadFile(callLog) + if err != nil { + t.Fatalf("read call log: %v", err) + } + if got := strings.Count(string(data), "start"); got != 2 { + t.Fatalf("start call count = %d, want 2; log:\n%s", got, data) + } +} + +func TestHealthBeadsProviderSerializesConcurrentExecHealthChecks(t *testing.T) { + cityPath := t.TempDir() + script := filepath.Join(cityPath, "provider.sh") + lockDir := filepath.Join(cityPath, "provider.lock") + callLog := filepath.Join(cityPath, "provider.log") + scriptBody := fmt.Sprintf(`#!/bin/sh +set -eu +if [ "$1" = "health" ]; then + if ! mkdir %q 2>/dev/null; then + echo "overlap" >&2 + exit 1 + fi + echo "health" >> %q + sleep 0.1 + rmdir %q + exit 0 +fi +exit 2 +`, lockDir, callLog, lockDir) + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) + + errs := make(chan error, 2) + for range 2 { + go func() { + errs <- healthBeadsProvider(cityPath) + }() + } + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("healthBeadsProvider: %v", err) + } + } + + data, err := os.ReadFile(callLog) + if err != nil { + t.Fatalf("read call log: %v", err) + } + if got := strings.Count(string(data), "health"); got != 2 { + t.Fatalf("health call count = %d, want 2; log:\n%s", got, data) + } +} diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index 721f29ddf4..6051e5b0c8 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -5,6 +5,7 @@ import ( "io" "log" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -23,36 +24,61 @@ import ( // can pass ScaleCheckCounts to ComputePoolDesiredStates without re-running // scale_check commands. type DesiredStateResult struct { - State map[string]TemplateParams - BaseState map[string]TemplateParams - ScaleCheckCounts map[string]int // nil when store is nil or scale_check not run - PoolDesiredCounts map[string]int // runtime-owned demand snapshot; reused on stable patrol ticks when still fresh - WorkSet map[string]bool - AssignedWorkBeads []beads.Bead // actionable assigned work: in_progress or ready+assigned + State map[string]TemplateParams + BaseState map[string]TemplateParams + ScaleCheckCounts map[string]int // nil when store is nil or scale_check not run + // ScaleCheckPartialTemplates records all templates whose bead-backed demand + // probe failed. PoolScaleCheckPartialTemplates drives generic pool retention; + // NamedScaleCheckPartialTemplates only protects configured named sessions. + ScaleCheckPartialTemplates map[string]bool + PoolScaleCheckPartialTemplates map[string]bool + NamedScaleCheckPartialTemplates map[string]bool + PoolDesiredCounts map[string]int // runtime-owned demand snapshot; reused on stable patrol ticks when still fresh + WorkSet map[string]bool + AssignedWorkBeads []beads.Bead // actionable assigned work, plus stranded pool work that needs release // AssignedWorkStores is aligned by index with AssignedWorkBeads, so later // mutation paths update rig-owned work in the right store even when // independent stores produce overlapping bead IDs. AssignedWorkStores []beads.Store + // AssignedWorkStoreRefs is aligned by index with AssignedWorkBeads. + // The empty string means city store; non-empty values are rig names. + // Consumers that decide whether a specific agent should run must use + // this scope before treating a bead as reachable work for that agent. + AssignedWorkStoreRefs []string // NamedSessionDemand records which named-session identities have active - // demand — either direct assignee demand (Assignee == identity) or - // work_query-detected ready work. The reconciler merges this into - // poolDesired so that on-demand named sessions remain config-eligible - // even when no gc.routed_to metadata exists for the template. + // direct assignee demand (Assignee == identity). The reconciler merges this + // into poolDesired so that on-demand named sessions remain config-eligible. NamedSessionDemand map[string]bool - // StoreQueryPartial is true when one or more bead store queries failed - // during assigned-work snapshot collection. When set, the reconciler must NOT - // drain sessions based on the (incomplete) desired state — a transient - // store failure would cause running sessions to be falsely orphaned - // and interrupted via Ctrl-C. + // StoreQueryPartial is true when one or more bead store work queries + // failed. When set, the reconciler must NOT drain sessions based on the + // incomplete desired state — a transient failure would cause running + // sessions to be falsely orphaned and interrupted via Ctrl-C. StoreQueryPartial bool - BeaconTime time.Time + // SessionQueryPartial is true when session-bead snapshot loading failed. + // Orphan-release and drain decisions must treat this like an incomplete + // work snapshot because missing live session beads make assigned work look + // orphaned. + SessionQueryPartial bool + BeaconTime time.Time +} + +func (r DesiredStateResult) snapshotQueryPartial() bool { + return r.StoreQueryPartial || r.SessionQueryPartial } type poolEvalWork struct { - agentIdx int - sp scaleParams - poolDir string - env map[string]string + agentIdx int + sp scaleParams + poolDir string + env map[string]string + newDemand bool +} + +type defaultScaleCheckTarget struct { + template string + storeKey string + store beads.Store + err error } func evaluatePendingPools( @@ -60,7 +86,7 @@ func evaluatePendingPools( pendingPools []poolEvalWork, stderr io.Writer, trace *sessionReconcilerTraceCycle, -) []int { +) ([]int, []bool) { type poolEvalResult struct { desired int err error @@ -80,12 +106,19 @@ func evaluatePendingPools( template := cfg.Agents[pw.agentIdx].QualifiedName() agentName := cfg.Agents[pw.agentIdx].Name agentIndex := pw.agentIdx - go func(idx int, template, agentName string, agentIndex int, sp scaleParams, dir string) { + newDemand := pw.newDemand + go func(idx int, template, agentName string, agentIndex int, sp scaleParams, dir string, newDemand bool) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() started := time.Now() - d, err := evaluatePool(agentName, sp, dir, probeEnv, shellScaleCheck) + var d int + var err error + if newDemand { + d, err = evaluatePoolNewDemand(agentName, sp, dir, probeEnv, shellScaleCheck) + } else { + d, err = evaluatePool(agentName, sp, dir, probeEnv, shellScaleCheck) + } evalResults[idx] = poolEvalResult{desired: d, err: err} if trace != nil { outcome := "success" @@ -102,36 +135,48 @@ func evaluatePendingPools( "agent_index": agentIndex, }, "") } - }(j, template, agentName, agentIndex, sp, pw.poolDir) + }(j, template, agentName, agentIndex, sp, pw.poolDir, newDemand) } wg.Wait() counts := make([]int, len(pendingPools)) + partials := make([]bool, len(pendingPools)) for j, pw := range pendingPools { pr := evalResults[j] if pr.err != nil { - fmt.Fprintf(stderr, "buildDesiredState: %v (using min=%d)\n", pr.err, pw.sp.Min) //nolint:errcheck + partials[j] = true + if pw.newDemand { + fmt.Fprintf(stderr, "buildDesiredState: %v (using new demand=0)\n", pr.err) //nolint:errcheck + } else { + fmt.Fprintf(stderr, "buildDesiredState: %v (using min=%d)\n", pr.err, pw.sp.Min) //nolint:errcheck + } } counts[j] = pr.desired } - return counts + return counts, partials } -// evaluatePendingPoolsMap is like evaluatePendingPools but returns a map -// from agent qualified name → desired count. Used to feed scale_check -// results into ComputePoolDesiredStates. +// evaluatePendingPoolsMap is like evaluatePendingPools but returns a map from +// agent qualified name to scale_check count. In bead-backed reconciliation the +// count is additive new demand; legacy no-store callers still use desired +// counts. func evaluatePendingPoolsMap( cfg *config.City, pendingPools []poolEvalWork, stderr io.Writer, trace *sessionReconcilerTraceCycle, -) map[string]int { - counts := evaluatePendingPools(cfg, pendingPools, stderr, trace) +) (map[string]int, map[string]bool) { + counts, partials := evaluatePendingPools(cfg, pendingPools, stderr, trace) m := make(map[string]int, len(counts)) + var partialTemplates map[string]bool for j, pw := range pendingPools { - m[cfg.Agents[pw.agentIdx].QualifiedName()] = counts[j] + template := cfg.Agents[pw.agentIdx].QualifiedName() + m[template] = counts[j] + if partials[j] { + partialTemplates = markScaleCheckPartialTemplate(partialTemplates, template) + } } - return m + return m, partialTemplates } // buildDesiredState computes the desired session state from config, @@ -147,6 +192,9 @@ func evaluatePendingPoolsMap( // ACP route registration, and session bead auto-creation. These are safe // to repeat because hooks are installed to stable filesystem paths, // ACP routing is idempotent, and bead creation is deduplicated by template. +// Rig-scoped agents with an implicit default scale_check require rigStores; +// when rigStores is missing, they report zero new demand plus a diagnostic +// rather than counting work from the wrong store. func buildDesiredState( cityName, cityPath string, beaconTime time.Time, @@ -156,14 +204,18 @@ func buildDesiredState( stderr io.Writer, ) DesiredStateResult { var sessionBeads *sessionBeadSnapshot + var sessionQueryPartial bool if store != nil { var err error sessionBeads, err = loadSessionBeadSnapshot(store) if err != nil { fmt.Fprintf(stderr, "buildDesiredState: listing session beads: %v\n", err) //nolint:errcheck + sessionQueryPartial = true } } - return buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, store, nil, sessionBeads, nil, stderr) + result := buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, store, nil, sessionBeads, nil, stderr) + result.SessionQueryPartial = result.SessionQueryPartial || sessionQueryPartial + return result } func buildDesiredStateWithSessionBeads( @@ -189,6 +241,8 @@ func buildDesiredStateWithSessionBeads( desired := make(map[string]TemplateParams) var pendingPools []poolEvalWork + var defaultScaleTargets []defaultScaleCheckTarget + var defaultNamedScaleTargets []defaultScaleCheckTarget for i := range cfg.Agents { if cfg.Agents[i].Suspended { @@ -216,10 +270,16 @@ func buildDesiredStateWithSessionBeads( continue } // Named-session materialization is handled in the named-session pass, - // but generic scale_check/min demand for the backing template still - // creates ephemeral capacity through the pool pipeline. + // but explicit scale_check/min demand for the backing template still + // creates ephemeral capacity through the pool pipeline. The implicit + // routed-work scale_check feeds named demand separately so it does + // not create a parallel generic worker for the same backing template. poolDir := agentCommandDir(cityPath, &cfg.Agents[i], cfg.Rigs) - pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir}) + if store != nil && strings.TrimSpace(cfg.Agents[i].ScaleCheck) == "" { + defaultNamedScaleTargets = append(defaultNamedScaleTargets, defaultScaleCheckTargetForAgent(cityPath, cfg, &cfg.Agents[i], store, rigStores)) + continue + } + pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir, newDemand: store != nil}) continue } @@ -227,25 +287,31 @@ func buildDesiredStateWithSessionBeads( if rigName != "" && suspendedRigPaths[filepath.Clean(rigRootForName(rigName, cfg.Rigs))] { continue } - // Pool agent: collect scale-check inputs. Legacy no-store mode uses - // them directly; bead-backed mode falls back to them when work-bead - // listing fails so transient store errors do not collapse demand to 0. + // Pool agent: collect scale_check inputs. Legacy no-store mode uses + // them as desired counts; bead-backed mode uses them as authoritative + // new unassigned demand while assigned work drives resume requests. poolDir := agentCommandDir(cityPath, &cfg.Agents[i], cfg.Rigs) - pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir, env: controllerQueryRuntimeEnv(cityPath, cfg, &cfg.Agents[i])}) + if store != nil && strings.TrimSpace(cfg.Agents[i].ScaleCheck) == "" { + defaultScaleTargets = append(defaultScaleTargets, defaultScaleCheckTargetForAgent(cityPath, cfg, &cfg.Agents[i], store, rigStores)) + continue + } + pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir, env: controllerQueryRuntimeEnv(cityPath, cfg, &cfg.Agents[i]), newDemand: store != nil}) } - // scale_check runs in parallel for all pool agents — the authoritative - // demand signal for new sessions. Computed once, returned in result. - scaleCheckCounts := evaluatePendingPoolsMap(cfg, pendingPools, stderr, trace) - // Collect work beads with assignees — used for both pool demand and // named session on_demand wake. Hoisted out of the store block so // the named session section can also use it. var assignedWorkBeads []beads.Bead var assignedWorkStores []beads.Store + var assignedWorkStoreRefs []string var storePartial bool + var scaleCheckCounts map[string]int + var poolScaleCheckPartialTemplates map[string]bool + var namedScaleCheckPartialTemplates map[string]bool + var scaleCheckPartialTemplates map[string]bool + var namedDefaultDemand map[string]bool if store != nil { - assignedWorkBeads, assignedWorkStores, storePartial = collectAssignedWorkBeadsWithStores(cfg, store, rigStores, suspendedRigPaths) + assignedWorkBeads, assignedWorkStores, assignedWorkStoreRefs, storePartial = collectAssignedWorkBeadsWithStores(cfg, store, rigStores, suspendedRigPaths, sessionBeads) if storePartial { fmt.Fprintf(stderr, "assignedWorkBeads: PARTIAL — store query failed, drain decisions suppressed\n") //nolint:errcheck } @@ -257,7 +323,34 @@ func buildDesiredStateWithSessionBeads( } else { fmt.Fprintf(stderr, "assignedWorkBeads: 0 beads (rigStores=%d)\n", len(rigStores)) //nolint:errcheck } - poolDesiredStates := ComputePoolDesiredStatesTraced(cfg, assignedWorkBeads, sessionBeads.Open(), scaleCheckCounts, trace) + scaleCheckCounts, poolScaleCheckPartialTemplates = evaluatePendingPoolsMap(cfg, pendingPools, stderr, trace) + if len(defaultScaleTargets) > 0 { + defaultCounts, partialTemplates, errs := defaultScaleCheckCounts(defaultScaleTargets) + for _, err := range errs { + fmt.Fprintf(stderr, "buildDesiredState: %v (using new demand=0)\n", err) //nolint:errcheck + } + poolScaleCheckPartialTemplates = mergeScaleCheckPartialTemplates(poolScaleCheckPartialTemplates, partialTemplates) + for template, count := range defaultCounts { + scaleCheckCounts[template] = count + } + } + if len(defaultNamedScaleTargets) > 0 { + var namedErrs []error + var partialTemplates map[string]bool + namedDefaultDemand, partialTemplates, namedErrs = defaultNamedSessionDemand(defaultNamedScaleTargets, cfg, cityName) + for _, err := range namedErrs { + fmt.Fprintf(stderr, "buildDesiredState: %v (using named demand=false)\n", err) //nolint:errcheck + } + namedScaleCheckPartialTemplates = mergeScaleCheckPartialTemplates(namedScaleCheckPartialTemplates, partialTemplates) + } + scaleCheckPartialTemplates = mergeScaleCheckPartialTemplates(scaleCheckPartialTemplates, poolScaleCheckPartialTemplates) + scaleCheckPartialTemplates = mergeScaleCheckPartialTemplates(scaleCheckPartialTemplates, namedScaleCheckPartialTemplates) + if len(scaleCheckPartialTemplates) > 0 { + fmt.Fprintf(stderr, "scaleCheck: PARTIAL — scale_check failed for %s, retaining affected sessions\n", strings.Join(sortedBoolMapKeys(scaleCheckPartialTemplates), ",")) //nolint:errcheck + } + poolWorkBeads := filterAssignedWorkBeadsForPoolDemand(cfg, cityPath, sessionBeads.Open(), assignedWorkBeads, assignedWorkStoreRefs) + bp.assignedWorkBeads = poolWorkBeads + poolDesiredStates := ComputePoolDesiredStatesTraced(cfg, poolWorkBeads, sessionBeads.Open(), scaleCheckCounts, trace) for _, poolState := range poolDesiredStates { cfgAgent := findAgentByTemplate(cfg, poolState.Template) if cfgAgent == nil { @@ -271,6 +364,7 @@ func buildDesiredStateWithSessionBeads( } } else { // No store — use scale_check counts directly. + scaleCheckCounts, _ = evaluatePendingPoolsMap(cfg, pendingPools, stderr, trace) for _, pw := range pendingPools { desiredCount := scaleCheckCounts[cfg.Agents[pw.agentIdx].QualifiedName()] for slot := 1; slot <= desiredCount; slot++ { @@ -295,9 +389,9 @@ func buildDesiredStateWithSessionBeads( } // Named sessions: materialize session beads for configured [[named_session]] - // entries. "always" mode sessions are unconditionally materialized; "on_demand" - // sessions are materialized only when they already have a canonical bead or - // when their work query returns ready work. + // entries. "always" mode sessions are unconditionally materialized; + // "on_demand" sessions are materialized only when they already have a + // canonical bead or direct assigned work. namedSpecs := make(map[string]namedSessionSpec) for i := range cfg.NamedSessions { identity := cfg.NamedSessions[i].QualifiedName() @@ -311,54 +405,37 @@ func buildDesiredStateWithSessionBeads( namedSpecs[identity] = spec } namedWorkReady := make(map[string]bool, len(namedSpecs)) + for identity := range namedDefaultDemand { + if _, ok := namedSpecs[identity]; ok { + namedWorkReady[identity] = true + } + } // Check assigned work beads: if any work bead's Assignee matches a named // session's identity, that session has direct demand. // // Raw gc.routed_to metadata is intentionally NOT treated as direct named - // demand here. Routed metadata feeds the named agent's work_query, and the - // on-demand session only materializes from that path once the work is - // actually actionable. This keeps blocked or merely routed work from - // waking/materializing the named session prematurely. - for identity := range namedSpecs { - for _, wb := range assignedWorkBeads { + // demand here. The controller only uses assignment/readiness state; routed + // metadata is consumed by the agent-side gc hook path. + for identity, spec := range namedSpecs { + for i, wb := range assignedWorkBeads { if wb.Status != "open" && wb.Status != "in_progress" { continue } assignee := strings.TrimSpace(wb.Assignee) - if assignee == identity { - fmt.Fprintf(stderr, "namedWorkReady: %s matched by bead %s (assignee=%s status=%s)\n", identity, wb.ID, assignee, wb.Status) //nolint:errcheck - namedWorkReady[identity] = true - break + if assignee != identity { + continue + } + if !assignedWorkIndexReachableFromAgent(cityPath, cfg, spec.Agent, assignedWorkStoreRefs, i) { + continue } + fmt.Fprintf(stderr, "namedWorkReady: %s matched by bead %s (assignee=%s status=%s)\n", identity, wb.ID, assignee, wb.Status) //nolint:errcheck + namedWorkReady[identity] = true + break } } if len(assignedWorkBeads) > 0 { fmt.Fprintf(stderr, "namedWorkReady: %d assigned beads, %d named specs, ready=%v\n", len(assignedWorkBeads), len(namedSpecs), namedWorkReady) //nolint:errcheck } - for identity, spec := range namedSpecs { - if spec.Mode == "always" || namedWorkReady[identity] || !namedSessionAllowsControllerWorkQuery(cityPath, cfg, spec) { - continue - } - // Controller-side work_query demand stays intentionally narrow. - // Generic city-scoped named sessions materialize from direct continuity - // (canonical bead or explicit assignee demand), while rig-scoped named - // sessions still probe here so the controller validates rig-local query - // env such as scoped Dolt credentials. - wq := spec.Agent.EffectiveWorkQuery() - if wq == "" { - continue - } - wq = expandAgentCommandTemplate(cityPath, cityName, spec.Agent, cfg.Rigs, "work_query", wq, stderr) - dir := agentCommandDir(cityPath, spec.Agent, cfg.Rigs) - probeEnv := controllerQueryRuntimeEnv(cityPath, cfg, spec.Agent) - out, err := shellScaleCheck(prefixShellEnv(controllerQueryPrefixEnv(probeEnv), wq), dir, probeEnv) - if err != nil { - continue - } - if workQueryHasReadyWork(strings.TrimSpace(out)) { - namedWorkReady[identity] = true - } - } for identity, spec := range namedSpecs { canonicalBead, hasCanonical := findCanonicalNamedSessionBead(bp.sessionBeads, spec) if !hasCanonical { @@ -407,17 +484,21 @@ func buildDesiredStateWithSessionBeads( // Phase 2: discover session beads created outside config iteration // (e.g., by "gc session new"). Include them in desired state if they // have a valid template and are not held/closed. - applySessionBeadDesiredOverlay(bp, cfg, desired, suspendedRigPaths, stderr) + applySessionBeadDesiredOverlay(bp, cfg, desired, suspendedRigPaths, poolScaleCheckPartialTemplates, namedScaleCheckPartialTemplates, stderr) return DesiredStateResult{ - State: desired, - BaseState: baseDesired, - ScaleCheckCounts: scaleCheckCounts, - AssignedWorkBeads: assignedWorkBeads, - AssignedWorkStores: assignedWorkStores, - NamedSessionDemand: namedWorkReady, - StoreQueryPartial: storePartial, - BeaconTime: beaconTime, + State: desired, + BaseState: baseDesired, + ScaleCheckCounts: scaleCheckCounts, + ScaleCheckPartialTemplates: scaleCheckPartialTemplates, + PoolScaleCheckPartialTemplates: poolScaleCheckPartialTemplates, + NamedScaleCheckPartialTemplates: namedScaleCheckPartialTemplates, + AssignedWorkBeads: assignedWorkBeads, + AssignedWorkStores: assignedWorkStores, + AssignedWorkStoreRefs: assignedWorkStoreRefs, + NamedSessionDemand: namedWorkReady, + StoreQueryPartial: storePartial, + BeaconTime: beaconTime, } } @@ -450,9 +531,11 @@ func applySessionBeadDesiredOverlay( cfg *config.City, desired map[string]TemplateParams, suspendedRigPaths map[string]bool, + poolScaleCheckPartialTemplates map[string]bool, + namedScaleCheckPartialTemplates map[string]bool, stderr io.Writer, ) { - realizedRoots := discoverSessionBeadsWithRoots(bp, cfg, desired, suspendedRigPaths, stderr) + realizedRoots := discoverSessionBeadsWithRoots(bp, cfg, desired, suspendedRigPaths, poolScaleCheckPartialTemplates, namedScaleCheckPartialTemplates, stderr) realizeDependencyFloors(bp, cfg, desired, realizedRoots, suspendedRigPaths, stderr) } @@ -481,21 +564,20 @@ func refreshDesiredStateWithSessionBeads( bp := newAgentBuildParams(cityName, cityPath, cfg, sp, result.BeaconTime, store, stderr) bp.sessionBeads = sessionBeads - applySessionBeadDesiredOverlay(bp, cfg, refreshed.State, buildSuspendedRigPaths(cfg), stderr) + applySessionBeadDesiredOverlay(bp, cfg, refreshed.State, buildSuspendedRigPaths(cfg), result.PoolScaleCheckPartialTemplates, result.NamedScaleCheckPartialTemplates, stderr) return refreshed } // collectAssignedWorkBeads queries each store (city + rigs) for actionable // assigned work. It includes in-progress assigned work plus open assigned // work that is actually ready. Routed-but-unassigned pool queue work is -// intentionally excluded here; new session demand comes from scale_check -// (and work_query as a defense-in-depth wake signal), while this helper is -// only for preserving sessions that already own actionable work. +// intentionally excluded here, except stranded in-progress pool work with no +// assignee is included so reconciliation can reopen it for normal claiming. func collectAssignedWorkBeads( cfg *config.City, cityStore beads.Store, ) ([]beads.Bead, bool) { - result, _, partial := collectAssignedWorkBeadsWithStores(cfg, cityStore, nil, nil) + result, _, _, partial := collectAssignedWorkBeadsWithStores(cfg, cityStore, nil, nil, nil) return result, partial } @@ -504,42 +586,547 @@ func collectAssignedWorkBeadsWithStores( cityStore beads.Store, rigStores map[string]beads.Store, suspendedRigPaths map[string]bool, -) ([]beads.Bead, []beads.Store, bool) { + sessionBeads *sessionBeadSnapshot, +) ([]beads.Bead, []beads.Store, []string, bool) { // Use CachingStore-wrapped stores. Creating raw bdStoreForCity per rig // spawns bd subprocesses on every tick, saturating dolt. - stores := []beads.Store{cityStore} + type workStore struct { + store beads.Store + ref string + } + stores := []workStore{{store: cityStore}} for _, rig := range cfg.Rigs { if suspendedRigPaths[filepath.Clean(rig.Path)] { continue } if s, ok := rigStores[rig.Name]; ok { - stores = append(stores, s) + stores = append(stores, workStore{store: s, ref: rig.Name}) } } + type storeAssignedWorkResult struct { + beads []beads.Bead + stores []beads.Store + storeRefs []string + errs []error + } + results := make([]storeAssignedWorkResult, len(stores)) + var wg sync.WaitGroup + for idx, source := range stores { + idx, source := idx, source + wg.Add(1) + go func() { + defer wg.Done() + var result []beads.Bead + var resultStores []beads.Store + var resultStoreRefs []string + var errs []error + seen := make(map[string]struct{}) + // In-progress beads with an assignee (active work), plus stranded + // unassigned pool work that needs to be reopened. This pass runs + // across every store before any ready handoff probes, so already + // active work never waits behind unrelated ready scans. + if inProgress, err := listForControllerDemand(source.store, beads.ListQuery{Status: "in_progress"}); err == nil { + appendInProgressWorkUnique(cfg, &result, &resultStores, &resultStoreRefs, inProgress, seen, source.store, source.ref) + } else { + errs = append(errs, fmt.Errorf("List(in_progress): %w", err)) + if beads.IsPartialResult(err) && len(inProgress) > 0 { + appendInProgressWorkUnique(cfg, &result, &resultStores, &resultStoreRefs, inProgress, seen, source.store, source.ref) + } + } + results[idx] = storeAssignedWorkResult{beads: result, stores: resultStores, storeRefs: resultStoreRefs, errs: errs} + }() + } + wg.Wait() + var result []beads.Bead var resultStores []beads.Store + var resultStoreRefs []string var partial bool - for _, s := range stores { - seen := make(map[string]struct{}) - // In-progress beads with an assignee (active work). - if inProgress, err := s.List(beads.ListQuery{Status: "in_progress", Live: true}); err == nil { - appendAssignedUnique(&result, &resultStores, inProgress, seen, s) - } else { - log.Printf("collectAssignedWorkBeads: List(in_progress) failed: %v", err) + for _, r := range results { + result = append(result, r.beads...) + resultStores = append(resultStores, r.stores...) + resultStoreRefs = append(resultStoreRefs, r.storeRefs...) + for _, err := range r.errs { + log.Printf("collectAssignedWorkBeads: %v", err) partial = true } - // Ready beads with an assignee (queued direct handoff work that is - // actually runnable, not merely open). This is a lifecycle gate, so - // bypass the cache when a CachingStore wrapper is present. - if ready, err := beads.ReadyLive(s); err == nil { - appendAssignedUnique(&result, &resultStores, ready, seen, s) - } else { - log.Printf("collectAssignedWorkBeads: Ready() failed: %v", err) + } + skipReadyAssignees := assignedWorkAssigneeSet(result) + expandSkipAssigneesWithSessionIdentities(skipReadyAssignees, sessionBeads) + assignees := readyAssignedWorkAssignees(cfg, sessionBeads, skipReadyAssignees) + if len(skipReadyAssignees) > 0 && len(assignees) == 0 { + return result, resultStores, resultStoreRefs, partial + } + + readyResults := make([]storeAssignedWorkResult, len(stores)) + for idx, source := range stores { + idx, source := idx, source + wg.Add(1) + go func() { + defer wg.Done() + var ready []beads.Bead + var err error + var errs []error + if len(assignees) == 0 { + ready, err = readyForControllerDemandQuery(source.store, beads.ReadyQuery{Limit: assignedWorkReadyLimit(cfg)}) + if err != nil { + errs = append(errs, fmt.Errorf("Ready(): %w", err)) + } + } else { + for _, assignee := range assignees { + part, partErr := readyForControllerDemandQuery(source.store, beads.ReadyQuery{Assignee: assignee, Limit: assignedWorkReadyLimit(cfg)}) + if partErr != nil { + errs = append(errs, fmt.Errorf("Ready(assignee=%q): %w", assignee, partErr)) + } + ready = append(ready, part...) + } + } + var readyBeads []beads.Bead + var readyStores []beads.Store + var readyStoreRefs []string + seen := make(map[string]struct{}) + appendAssignedUnique(&readyBeads, &readyStores, &readyStoreRefs, ready, seen, source.store, source.ref) + readyResults[idx] = storeAssignedWorkResult{beads: readyBeads, stores: readyStores, storeRefs: readyStoreRefs, errs: errs} + }() + } + wg.Wait() + for _, r := range readyResults { + result = append(result, r.beads...) + resultStores = append(resultStores, r.stores...) + resultStoreRefs = append(resultStoreRefs, r.storeRefs...) + for _, err := range r.errs { + log.Printf("collectAssignedWorkBeads: %v", err) partial = true } } - return result, resultStores, partial + return result, resultStores, resultStoreRefs, partial +} + +func assignedWorkReadyLimit(cfg *config.City) int { + if cfg == nil { + return config.DefaultMaxWakesPerTick + } + return cfg.Daemon.MaxWakesPerTickOrDefault() +} + +func assignedWorkAssigneeSet(work []beads.Bead) map[string]struct{} { + if len(work) == 0 { + return nil + } + result := make(map[string]struct{}) + for _, bead := range work { + assignee := strings.TrimSpace(bead.Assignee) + if assignee == "" { + continue + } + if bead.Status != "open" && bead.Status != "in_progress" { + continue + } + result[assignee] = struct{}{} + } + return result +} + +func expandSkipAssigneesWithSessionIdentities(skip map[string]struct{}, sessionBeads *sessionBeadSnapshot) { + if len(skip) == 0 || sessionBeads == nil { + return + } + for _, session := range sessionBeads.Open() { + ids := []string{ + session.ID, + session.Metadata["session_name"], + session.Metadata["configured_named_identity"], + } + matched := false + for _, id := range ids { + if _, ok := skip[strings.TrimSpace(id)]; ok { + matched = true + break + } + } + if !matched { + continue + } + for _, id := range ids { + id = strings.TrimSpace(id) + if id != "" { + skip[id] = struct{}{} + } + } + } +} + +func readyAssignedWorkAssignees(cfg *config.City, sessionBeads *sessionBeadSnapshot, skip map[string]struct{}) []string { + seen := make(map[string]struct{}) + var result []string + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := skip[value]; ok { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + result = append(result, value) + } + if sessionBeads != nil { + for _, session := range sessionBeads.Open() { + if session.Status == "closed" { + continue + } + add(session.ID) + add(session.Metadata["session_name"]) + add(session.Metadata["configured_named_identity"]) + } + } + if cfg != nil { + for i := range cfg.NamedSessions { + if cfg.NamedSessions[i].Mode != "on_demand" { + continue + } + add(cfg.NamedSessions[i].QualifiedName()) + } + } + return result +} + +func defaultScaleCheckTargetForAgent( + cityPath string, + cfg *config.City, + agentCfg *config.Agent, + cityStore beads.Store, + rigStores map[string]beads.Store, +) defaultScaleCheckTarget { + target := defaultScaleCheckTarget{ + template: agentCfg.QualifiedName(), + storeKey: "city", + store: cityStore, + } + rigName := configuredRigName(cityPath, agentCfg, cfg.Rigs) + if rigName == "" { + return target + } + target.storeKey = "rig:" + rigName + if rigStores != nil { + if rigStore := rigStores[rigName]; rigStore != nil { + target.store = rigStore + return target + } + } + target.store = nil + target.err = fmt.Errorf("default scale_check %s: rig store %q unavailable", target.template, rigName) + return target +} + +func defaultScaleCheckCounts(targets []defaultScaleCheckTarget) (map[string]int, map[string]bool, []error) { + counts := make(map[string]int, len(targets)) + if len(targets) == 0 { + return counts, nil, nil + } + + type scaleStoreGroup struct { + store beads.Store + templates map[string]struct{} + } + groups := make(map[string]*scaleStoreGroup) + var errs []error + var partialTemplates map[string]bool + for _, target := range targets { + template := strings.TrimSpace(target.template) + if template == "" { + continue + } + counts[template] = 0 + if target.err != nil { + errs = append(errs, target.err) + partialTemplates = markScaleCheckPartialTemplate(partialTemplates, template) + } + if target.store == nil { + if target.err == nil { + errs = append(errs, fmt.Errorf("default scale_check %s: store unavailable", template)) + } + partialTemplates = markScaleCheckPartialTemplate(partialTemplates, template) + continue + } + key := strings.TrimSpace(target.storeKey) + if key == "" { + key = fmt.Sprintf("%p", target.store) + } + group := groups[key] + if group == nil { + group = &scaleStoreGroup{store: target.store, templates: make(map[string]struct{})} + groups[key] = group + } + group.templates[template] = struct{}{} + } + + for key, group := range groups { + ready, err := readyForControllerDemand(group.store) + if err != nil { + errs = append(errs, fmt.Errorf("default scale_check %s templates=%s: Ready(): %w", key, strings.Join(sortedStringSet(group.templates), ","), err)) + partialTemplates = markScaleCheckPartialSet(partialTemplates, group.templates) + if !beads.IsPartialResult(err) || len(ready) == 0 { + continue + } + } + for _, b := range ready { + if strings.TrimSpace(b.Assignee) != "" { + continue + } + template := strings.TrimSpace(b.Metadata["gc.routed_to"]) + if _, ok := group.templates[template]; ok { + counts[template]++ + } + } + } + return counts, partialTemplates, errs +} + +func defaultNamedSessionDemand(targets []defaultScaleCheckTarget, cfg *config.City, cityName string) (map[string]bool, map[string]bool, []error) { + demand := make(map[string]bool) + if len(targets) == 0 || cfg == nil { + return demand, nil, nil + } + + type scaleStoreGroup struct { + store beads.Store + templates map[string]struct{} + } + groups := make(map[string]*scaleStoreGroup) + var errs []error + var partialTemplates map[string]bool + for _, target := range targets { + template := strings.TrimSpace(target.template) + if template == "" { + continue + } + if target.err != nil { + errs = append(errs, target.err) + partialTemplates = markScaleCheckPartialTemplate(partialTemplates, template) + } + if target.store == nil { + if target.err == nil { + errs = append(errs, fmt.Errorf("default scale_check %s: store unavailable", template)) + } + partialTemplates = markScaleCheckPartialTemplate(partialTemplates, template) + continue + } + key := strings.TrimSpace(target.storeKey) + if key == "" { + key = fmt.Sprintf("%p", target.store) + } + group := groups[key] + if group == nil { + group = &scaleStoreGroup{store: target.store, templates: make(map[string]struct{})} + groups[key] = group + } + group.templates[template] = struct{}{} + } + + namedByIdentity := make(map[string]namedSessionSpec) + identitiesByTemplate := make(map[string][]string) + for i := range cfg.NamedSessions { + identity := cfg.NamedSessions[i].QualifiedName() + spec, ok := findNamedSessionSpec(cfg, cityName, identity) + if !ok || spec.Mode == "always" { + continue + } + template := strings.TrimSpace(namedSessionBackingTemplate(spec)) + if template == "" { + continue + } + namedByIdentity[spec.Identity] = spec + identitiesByTemplate[template] = append(identitiesByTemplate[template], spec.Identity) + } + + for key, group := range groups { + ready, err := readyForControllerDemand(group.store) + if err != nil { + errs = append(errs, fmt.Errorf("default scale_check %s templates=%s: Ready(): %w", key, strings.Join(sortedStringSet(group.templates), ","), err)) + partialTemplates = markScaleCheckPartialSet(partialTemplates, group.templates) + if !beads.IsPartialResult(err) || len(ready) == 0 { + continue + } + } + for _, b := range ready { + if strings.TrimSpace(b.Assignee) != "" { + continue + } + routedTo := strings.TrimSpace(b.Metadata["gc.routed_to"]) + if routedTo == "" { + continue + } + if spec, ok := namedByIdentity[routedTo]; ok { + template := strings.TrimSpace(namedSessionBackingTemplate(spec)) + if _, targetTemplate := group.templates[template]; targetTemplate { + demand[spec.Identity] = true + } + continue + } + if _, targetTemplate := group.templates[routedTo]; !targetTemplate { + continue + } + identities := identitiesByTemplate[routedTo] + if len(identities) == 1 { + demand[identities[0]] = true + } + } + } + return demand, partialTemplates, errs +} + +func markScaleCheckPartialTemplate(partials map[string]bool, template string) map[string]bool { + template = strings.TrimSpace(template) + if template == "" { + return partials + } + if partials == nil { + partials = make(map[string]bool) + } + partials[template] = true + return partials +} + +func markScaleCheckPartialSet(partials map[string]bool, templates map[string]struct{}) map[string]bool { + for template := range templates { + partials = markScaleCheckPartialTemplate(partials, template) + } + return partials +} + +func mergeScaleCheckPartialTemplates(dst, src map[string]bool) map[string]bool { + for template, partial := range src { + if partial { + dst = markScaleCheckPartialTemplate(dst, template) + } + } + return dst +} + +func sortedBoolMapKeys(values map[string]bool) []string { + out := make([]string, 0, len(values)) + for value, include := range values { + if include { + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +func retainScaleCheckPartialPoolDesired(counts map[string]int, sessionBeads *sessionBeadSnapshot, partialTemplates map[string]bool) map[string]int { + if len(partialTemplates) == 0 || sessionBeads == nil { + return counts + } + retained := make(map[string]int) + for _, b := range sessionBeads.Open() { + template := strings.TrimSpace(b.Metadata["template"]) + if !partialTemplates[template] || !isPoolManagedSessionBead(b) || !scaleCheckPartialSessionRetainable(b) { + continue + } + retained[template]++ + } + if len(retained) == 0 { + return counts + } + if counts == nil { + counts = make(map[string]int) + } + for template, count := range retained { + if counts[template] < count { + counts[template] = count + } + } + return counts +} + +// Preserve dormant affected-template beads during transient scale_check +// failures, but do not count them as awake demand. +func scaleCheckPartialSessionPreservable(b beads.Bead) bool { + switch strings.TrimSpace(b.Metadata["state"]) { + case "", "active", "awake", "creating", "asleep", "stopped", "suspended", "quarantined", "draining", "drained", "archived": + return true + default: + return isPendingPoolCreate(b) + } +} + +func scaleCheckPartialSessionRetainable(b beads.Bead) bool { + switch strings.TrimSpace(b.Metadata["state"]) { + case "active", "awake", "creating": + return true + default: + return isPendingPoolCreate(b) + } +} + +func sortedStringSet(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func listForControllerDemand(store beads.Store, query beads.ListQuery) ([]beads.Bead, error) { + if _, ok := store.(interface { + CachedList(beads.ListQuery) ([]beads.Bead, bool) + }); ok { + cacheQuery := query + cacheQuery.Live = false + return store.List(cacheQuery) + } + liveQuery := query + liveQuery.Live = true + return store.List(liveQuery) +} + +func readyForControllerDemand(store beads.Store) ([]beads.Bead, error) { + // Controller demand reads are intentionally cache-tolerant, not + // authoritative lifecycle gates; CachedReady falls back whenever the cache + // has dirty or unknown dependency coverage. + if cached, ok := store.(interface { + CachedReady() ([]beads.Bead, bool) + }); ok { + if ready, ok := cached.CachedReady(); ok { + return ready, nil + } + } + return beads.ReadyLive(store) +} + +func readyForControllerDemandQuery(store beads.Store, query beads.ReadyQuery) ([]beads.Bead, error) { + if cached, ok := store.(interface { + CachedReady() ([]beads.Bead, bool) + }); ok { + if ready, ok := cached.CachedReady(); ok { + return filterReadyForControllerDemand(ready, query), nil + } + } + return store.Ready(query) +} + +func filterReadyForControllerDemand(ready []beads.Bead, query beads.ReadyQuery) []beads.Bead { + if query == (beads.ReadyQuery{}) { + return ready + } + result := make([]beads.Bead, 0, len(ready)) + for _, bead := range ready { + if query.Assignee != "" && bead.Assignee != query.Assignee { + continue + } + result = append(result, bead) + if query.Limit > 0 && len(result) >= query.Limit { + break + } + } + return result } // mergeNamedSessionDemand ensures that named-session assignee demand is @@ -567,27 +1154,45 @@ func mergeNamedSessionDemand(poolDesired map[string]int, namedDemand map[string] } } -func appendAssignedUnique(dst *[]beads.Bead, stores *[]beads.Store, beadList []beads.Bead, seen map[string]struct{}, store beads.Store) { +func appendInProgressWorkUnique(cfg *config.City, dst *[]beads.Bead, stores *[]beads.Store, storeRefs *[]string, beadList []beads.Bead, seen map[string]struct{}, store beads.Store, storeRef string) { for _, b := range beadList { - if strings.TrimSpace(b.Assignee) == "" { - continue - } - // Session beads are not actionable work — filter them at the source - // so all consumers see only real tasks. Message beads are NOT filtered - // here because they represent mail that should wake/materialize sessions; - // idle nudge filters messages locally since mail nudging is handled - // separately by the mail system. - if b.Type == sessionBeadType { + if strings.TrimSpace(b.Assignee) == "" && !isRecoverableUnassignedInProgressPoolWork(cfg, b) { continue } - if _, ok := seen[b.ID]; ok { + appendWorkUnique(dst, stores, storeRefs, b, seen, store, storeRef) + } +} + +func appendAssignedUnique(dst *[]beads.Bead, stores *[]beads.Store, storeRefs *[]string, beadList []beads.Bead, seen map[string]struct{}, store beads.Store, storeRef string) { + for _, b := range beadList { + if strings.TrimSpace(b.Assignee) == "" { continue } - seen[b.ID] = struct{}{} - *dst = append(*dst, b) - if stores != nil { - *stores = append(*stores, store) - } + appendWorkUnique(dst, stores, storeRefs, b, seen, store, storeRef) + } +} + +func appendWorkUnique(dst *[]beads.Bead, stores *[]beads.Store, storeRefs *[]string, b beads.Bead, seen map[string]struct{}, store beads.Store, storeRef string) { + // Invariant: dst, stores, and storeRefs are kept index-aligned by this + // shared growth path and the shared seen guard. + // Session beads are not actionable work — filter them at the source + // so all consumers see only real tasks. Message beads are NOT filtered + // here because they represent mail that should wake/materialize sessions; + // idle nudge filters messages locally since mail nudging is handled + // separately by the mail system. + if b.Type == sessionBeadType { + return + } + if _, ok := seen[b.ID]; ok { + return + } + seen[b.ID] = struct{}{} + *dst = append(*dst, b) + if stores != nil { + *stores = append(*stores, store) + } + if storeRefs != nil { + *storeRefs = append(*storeRefs, storeRef) } } @@ -620,7 +1225,7 @@ func discoverSessionBeads( desired map[string]TemplateParams, stderr io.Writer, ) { - discoverSessionBeadsWithRoots(bp, cfg, desired, nil, stderr) + discoverSessionBeadsWithRoots(bp, cfg, desired, nil, nil, nil, stderr) } func discoverSessionBeadsWithRoots( @@ -628,6 +1233,8 @@ func discoverSessionBeadsWithRoots( cfg *config.City, desired map[string]TemplateParams, suspendedRigPaths map[string]bool, + poolScaleCheckPartialTemplates map[string]bool, + namedScaleCheckPartialTemplates map[string]bool, stderr io.Writer, ) map[string]bool { sessionBeads := bp.sessionBeads @@ -659,13 +1266,13 @@ func discoverSessionBeadsWithRoots( // but we still need the bead in desired state so the reconciler // doesn't classify it as orphaned. Only skip if we can't resolve // the template. - template := b.Metadata["template"] - if template == "" { - template = b.Metadata["common_name"] - } + template := resolvedSessionTemplate(b, cfg) if template == "" { continue } + poolScaleCheckPartial := poolScaleCheckPartialTemplates[template] + namedScaleCheckPartial := namedScaleCheckPartialTemplates[template] && isNamedSessionBead(b) + scaleCheckPartial := scaleCheckPartialSessionPreservable(b) && (poolScaleCheckPartial || namedScaleCheckPartial) // Find the config agent for this template. cfgAgent := findAgentByTemplate(cfg, template) if cfgAgent == nil { @@ -690,10 +1297,11 @@ func discoverSessionBeadsWithRoots( if isEphemeralSessionBeadForAgent(b, cfgAgent) { manualSession := isManualSessionBeadForAgent(b, cfgAgent) creating := b.Metadata["state"] == "creating" - if isPoolManagedSessionBead(b) && !manualSession && !isNamedSessionBead(b) && !creating { + pendingCreate := isPendingPoolCreate(b) + if isPoolManagedSessionBead(b) && !manualSession && !isNamedSessionBead(b) && !creating && !pendingCreate && !scaleCheckPartial { continue } - if !manualSession && !desiredHasTemplate(desired, template) { + if !manualSession && (!creating || isStaleCreating(b)) && !desiredHasTemplate(desired, template) && !pendingCreate && !scaleCheckPartial { continue } } @@ -724,7 +1332,7 @@ func discoverSessionBeadsWithRoots( // inputs aligned across buildDesiredState paths. Named beads // intentionally pass through with the base shape (see // canonicalSessionIdentity). - resolveAgent, sessionQualifiedName = canonicalSessionIdentity(cfgAgent, b) + resolveAgent, sessionQualifiedName = canonicalSessionIdentityWithConfig(cfg, cfgAgent, b) } fpExtra := buildFingerprintExtra(resolveAgent) tp, err := resolveTemplateForSessionBead(bp, resolveAgent, sessionQualifiedName, fpExtra, b) @@ -756,6 +1364,10 @@ func discoverSessionBeadsWithRoots( return roots } +func isPendingPoolCreate(b beads.Bead) bool { + return isPoolManagedSessionBead(b) && strings.TrimSpace(b.Metadata["pending_create_claim"]) == boolMetadata(true) +} + func realizeDependencyFloors( bp *agentBuildParams, cfg *config.City, @@ -786,7 +1398,7 @@ func realizeDependencyFloors( if agentInSuspendedRig(bp.cityPath, depAgent, cfg.Rigs, suspendedRigPaths) { continue } - ensureDependencyOnlyTemplate(bp, depAgent, desired, stderr) + ensureDependencyOnlyTemplate(bp, cfg, depAgent, desired, stderr) visit(dep) } } @@ -797,6 +1409,7 @@ func realizeDependencyFloors( func ensureDependencyOnlyTemplate( bp *agentBuildParams, + cfg *config.City, cfgAgent *config.Agent, desired map[string]TemplateParams, stderr io.Writer, @@ -804,6 +1417,11 @@ func ensureDependencyOnlyTemplate( if cfgAgent == nil || !cfgAgent.SupportsGenericEphemeralSessions() || desiredHasTemplate(desired, cfgAgent.QualifiedName()) { return } + qualifiedName := cfgAgent.QualifiedName() + if err := validateAgentSessionTransportForBuild(bp, cfgAgent, qualifiedName); err != nil { + fmt.Fprintf(stderr, "buildDesiredState: dependency floor %q: %v (skipping)\n", qualifiedName, err) //nolint:errcheck + return + } if bp.beadStore == nil { name := cfgAgent.Name @@ -828,7 +1446,6 @@ func ensureDependencyOnlyTemplate( // Bead selection keys off the configured base template, not the pool- // instance form, because normalizedSessionTemplate reads the bead's // "template" metadata which is always the base. - qualifiedName := cfgAgent.QualifiedName() sessionBead, err := selectOrCreateDependencyPoolSessionBead(bp, cfgAgent, qualifiedName) if err != nil { fmt.Fprintf(stderr, "buildDesiredState: dependency floor %q: %v (skipping)\n", qualifiedName, err) //nolint:errcheck @@ -840,7 +1457,7 @@ func ensureDependencyOnlyTemplate( // Otherwise GC_ALIAS would be the base "rig/dog" here and "rig/dog-1" // on the realize path, oscillating across ticks and triggering the // reconciler's config-drift drain on the live dependency-floor session. - resolveAgent, resolveQN := canonicalSessionIdentity(cfgAgent, sessionBead) + resolveAgent, resolveQN := canonicalSessionIdentityWithConfig(cfg, cfgAgent, sessionBead) // Dep-floor slot-1 fallback. The guard triggers when the helper returned // the BASE form — meaning no pool_slot was stamped yet. Keying off // resolveQN (a stable value) rather than pointer identity keeps the @@ -898,6 +1515,10 @@ func realizePoolDesiredSessions( stderr io.Writer, ) { qualifiedName := cfgAgent.QualifiedName() + if err := validateAgentSessionTransportForBuild(bp, cfgAgent, qualifiedName); err != nil { + fmt.Fprintf(stderr, "buildDesiredState: pool %q: %v (skipping)\n", qualifiedName, err) //nolint:errcheck + return + } used := make(map[string]bool) usedSlots := make(map[int]bool) for _, request := range poolState.Requests { @@ -987,6 +1608,10 @@ func resolveTemplateForSessionBead( // - Instance-expanding agent without a slot stamp → (cfgAgent, // cfgAgent.QualifiedName()); realize will claim and stamp later. func canonicalSessionIdentity(cfgAgent *config.Agent, bead beads.Bead) (*config.Agent, string) { + return canonicalSessionIdentityWithConfig(nil, cfgAgent, bead) +} + +func canonicalSessionIdentityWithConfig(cfg *config.City, cfgAgent *config.Agent, bead beads.Bead) (*config.Agent, string) { if cfgAgent == nil { return nil, "" } @@ -996,7 +1621,7 @@ func canonicalSessionIdentity(cfgAgent *config.Agent, bead beads.Bead) (*config. if !cfgAgent.SupportsInstanceExpansion() { return cfgAgent, cfgAgent.QualifiedName() } - slot := existingPoolSlot(cfgAgent, bead) + slot := existingPoolSlotWithConfig(cfg, cfgAgent, bead) if slot <= 0 { return cfgAgent, cfgAgent.QualifiedName() } @@ -1095,23 +1720,139 @@ func existingPoolSlot(cfgAgent *config.Agent, sessionBead beads.Bead) int { return slot } } - agentName := strings.TrimSpace(sessionBeadAgentName(sessionBead)) - if agentName == "" || cfgAgent == nil { + if cfgAgent == nil { return 0 } - if slot := resolvePoolSlot(agentName, cfgAgent.QualifiedName()); slot > 0 { + if slot := resolvePersistedPoolIdentitySlot(cfgAgent, true, sessionBeadAgentName(sessionBead), sessionBead.Metadata["alias"]); slot > 0 { return slot } - if slot := resolvePoolSlot(agentName, cfgAgent.Name); slot > 0 { - return slot + if strings.TrimSpace(sessionBead.Metadata["alias"]) == "" && !beadOwnsPoolSessionName(sessionBead) { + if slot := resolvePersistedPoolIdentitySlot(cfgAgent, true, sessionBead.Metadata["session_name"]); slot > 0 { + return slot + } + } + return 0 +} + +func resolvePersistedPoolIdentitySlot(cfgAgent *config.Agent, allowLocalIdentity bool, candidates ...string) int { + if cfgAgent == nil { + return 0 + } + for _, name := range candidates { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if slot := resolvePoolSlot(name, cfgAgent.QualifiedName()); slot > 0 { + return slot + } + if cfgAgent.BindingName != "" { + if slot := resolvePoolSlot(name, cfgAgent.BindingQualifiedName()); slot > 0 { + return slot + } + } + if cfgAgent.BindingName == "" && allowLocalIdentity { + if slot := resolvePoolSlot(name, cfgAgent.Name); slot > 0 { + return slot + } + } + for idx, themed := range cfgAgent.NamepoolNames { + themed = strings.TrimSpace(themed) + if themed == "" { + continue + } + if themed == name { + return idx + 1 + } + if strings.TrimSpace(cfgAgent.QualifiedInstanceName(themed)) == name { + return idx + 1 + } + } + } + return 0 +} + +func poolSlotHasConfiguredBound(cfgAgent *config.Agent) bool { + if cfgAgent == nil { + return false + } + if len(cfgAgent.NamepoolNames) > 0 { + return true + } + if maxSessions := cfgAgent.EffectiveMaxActiveSessions(); maxSessions != nil { + return true + } + return false +} + +func inBoundsPoolSlot(cfgAgent *config.Agent, slot int) bool { + if cfgAgent == nil || slot <= 0 || !poolSlotHasConfiguredBound(cfgAgent) { + return false + } + if len(cfgAgent.NamepoolNames) > 0 && slot > len(cfgAgent.NamepoolNames) { + return false + } + if maxSessions := cfgAgent.EffectiveMaxActiveSessions(); maxSessions != nil && *maxSessions > 0 && slot > *maxSessions { + return false + } + return true +} + +func existingPoolSlotWithConfig(cfg *config.City, cfgAgent *config.Agent, sessionBead beads.Bead) int { + if cfgAgent == nil { + return 0 } - for idx, themed := range cfgAgent.NamepoolNames { - if strings.TrimSpace(themed) == agentName { - return idx + 1 + storedTemplateMatches := cfg == nil || storedTemplateMatchesPoolTemplate(sessionBeadStoredTemplate(sessionBead), cfgAgent.QualifiedName(), cfg) + agentSlot := resolvePersistedPoolIdentitySlot(cfgAgent, storedTemplateMatches, sessionBeadAgentName(sessionBead)) + aliasSlot := resolvePersistedPoolIdentitySlot(cfgAgent, storedTemplateMatches, sessionBead.Metadata["alias"]) + sessionNameSlot := 0 + if storedTemplateMatches && strings.TrimSpace(sessionBead.Metadata["alias"]) == "" && !beadOwnsPoolSessionName(sessionBead) { + sessionNameSlot = resolvePersistedPoolIdentitySlot(cfgAgent, true, sessionBead.Metadata["session_name"]) + } + if sessionBead.Metadata["pool_slot"] != "" { + if slot, err := strconv.Atoi(strings.TrimSpace(sessionBead.Metadata["pool_slot"])); err == nil && slot > 0 { + if agentSlot > 0 && agentSlot == aliasSlot && agentSlot != slot { + return agentSlot + } + if !storedTemplateMatches && agentSlot == 0 && aliasSlot == 0 { + return 0 + } + if !inBoundsPoolSlot(cfgAgent, slot) { + if agentSlot > 0 { + return agentSlot + } + if aliasSlot > 0 { + return aliasSlot + } + if sessionNameSlot > 0 { + return sessionNameSlot + } + if poolSlotHasConfiguredBound(cfgAgent) { + return 0 + } + } + return slot } - if cfgAgent.Dir != "" && strings.TrimSpace(cfgAgent.QualifiedInstanceName(themed)) == agentName { - return idx + 1 + } + if poolSlotHasConfiguredBound(cfgAgent) { + if agentSlot > 0 && !inBoundsPoolSlot(cfgAgent, agentSlot) { + agentSlot = 0 } + if aliasSlot > 0 && !inBoundsPoolSlot(cfgAgent, aliasSlot) { + aliasSlot = 0 + } + if sessionNameSlot > 0 && !inBoundsPoolSlot(cfgAgent, sessionNameSlot) { + sessionNameSlot = 0 + } + } + if agentSlot > 0 { + return agentSlot + } + if aliasSlot > 0 { + return aliasSlot + } + if sessionNameSlot > 0 { + return sessionNameSlot } return 0 } @@ -1158,6 +1899,9 @@ func selectOrCreatePoolSessionBead( if isNamedSessionBead(bead) { continue } + if sessionBeadHasAssignedWork(bp.assignedWorkBeads, bead) { + continue + } if used[bead.ID] { continue } @@ -1168,7 +1912,23 @@ func selectOrCreatePoolSessionBead( return bead, nil } } - return createPoolSessionBead(bp.beadStore, template, bp.sessionBeads) + return createPoolSessionBead(bp.beadStore, template, bp.sessionBeads, poolSessionCreateStartedAt(bp)) +} + +func sessionBeadHasAssignedWork(workBeads []beads.Bead, sessionBead beads.Bead) bool { + for _, wb := range workBeads { + assignee := strings.TrimSpace(wb.Assignee) + if assignee == "" || (wb.Status != "open" && wb.Status != "in_progress") { + continue + } + if assignee == sessionBead.ID || assignee == strings.TrimSpace(sessionBead.Metadata["session_name"]) { + return true + } + if namedIdentity := strings.TrimSpace(sessionBead.Metadata["configured_named_identity"]); namedIdentity != "" && assignee == namedIdentity { + return true + } + } + return false } func selectOrCreateDependencyPoolSessionBead( @@ -1196,7 +1956,11 @@ func selectOrCreateDependencyPoolSessionBead( return bead, nil } } - return createPoolSessionBead(bp.beadStore, template, bp.sessionBeads) + return createPoolSessionBead(bp.beadStore, template, bp.sessionBeads, poolSessionCreateStartedAt(bp)) +} + +func poolSessionCreateStartedAt(_ *agentBuildParams) time.Time { + return time.Now().UTC() } func agentInSuspendedRig( @@ -1212,16 +1976,6 @@ func agentInSuspendedRig( return suspendedRigPaths[filepath.Clean(rigRootForName(rigName, rigs))] } -func namedSessionAllowsControllerWorkQuery(cityPath string, cfg *config.City, spec namedSessionSpec) bool { - if cfg == nil || spec.Agent == nil { - return false - } - if spec.Named != nil && strings.TrimSpace(spec.Named.Dir) != "" { - return true - } - return configuredRigName(cityPath, spec.Agent, cfg.Rigs) != "" -} - // prepareTemplateResolution installs any hook-backed files that must exist // before resolveTemplate fingerprints CopyFiles. This keeps generated hook // files from looking like config drift on the next reconcile tick. @@ -1247,10 +2001,28 @@ func prepareTemplateResolution(bp *agentBuildParams, cfgAgent *config.Agent, qua } func resolveTemplatePrepared(bp *agentBuildParams, cfgAgent *config.Agent, qualifiedName string, fpExtra map[string]string) (TemplateParams, error) { + if err := validateAgentSessionTransportForBuild(bp, cfgAgent, qualifiedName); err != nil { + return TemplateParams{}, err + } prepareTemplateResolution(bp, cfgAgent, qualifiedName, bp.stderr) return resolveTemplate(bp, cfgAgent, qualifiedName, fpExtra) } +func validateAgentSessionTransportForBuild(bp *agentBuildParams, cfgAgent *config.Agent, qualifiedName string) error { + if bp == nil || cfgAgent == nil { + return nil + } + resolved, err := config.ResolveProvider(cfgAgent, bp.workspace, bp.providers, bp.lookPath) + if err != nil { + return fmt.Errorf("agent %q: %w", qualifiedName, err) + } + transport := config.ResolveSessionCreateTransport(cfgAgent.Session, resolved) + if err := validateResolvedSessionTransport(resolved, transport, bp.sp); err != nil { + return fmt.Errorf("agent %q: %w", qualifiedName, err) + } + return nil +} + // installAgentSideEffects performs idempotent side effects for a resolved // agent: hook installation and ACP route registration. Called from // buildDesiredState on every tick; safe to repeat. diff --git a/cmd/gc/build_desired_state_test.go b/cmd/gc/build_desired_state_test.go index e7e54f1e6c..d8bf142ee7 100644 --- a/cmd/gc/build_desired_state_test.go +++ b/cmd/gc/build_desired_state_test.go @@ -15,6 +15,7 @@ import ( "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/beads/contract" + "github.com/gastownhall/gascity/internal/clock" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/runtime" @@ -28,6 +29,131 @@ func (s listFailStore) List(_ beads.ListQuery) ([]beads.Bead, error) { return nil, errors.New("list failed") } +type readyFailStore struct { + beads.Store + readyCalls int +} + +func (s *readyFailStore) Ready(...beads.ReadyQuery) ([]beads.Bead, error) { + s.readyCalls++ + return nil, errors.New("backing ready should not be used") +} + +type readyStaticStore struct { + beads.Store + ready []beads.Bead + readyCalls int +} + +func (s *readyStaticStore) Ready(...beads.ReadyQuery) ([]beads.Bead, error) { + s.readyCalls++ + out := make([]beads.Bead, len(s.ready)) + copy(out, s.ready) + return out, nil +} + +type readyQueryRecordingStore struct { + *beads.MemStore + readyQueries []beads.ReadyQuery +} + +func (s *readyQueryRecordingStore) Ready(query ...beads.ReadyQuery) ([]beads.Bead, error) { + if len(query) == 0 { + s.readyQueries = append(s.readyQueries, beads.ReadyQuery{}) + } else { + s.readyQueries = append(s.readyQueries, query[0]) + } + return s.MemStore.Ready(query...) +} + +type demandListCountingStore struct { + beads.Store + liveInProgressLists int + liveOpenMolecules int +} + +func (s *demandListCountingStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.Live && query.Status == "in_progress" { + s.liveInProgressLists++ + } + if query.Live && query.Status == "open" && query.Type == "molecule" { + s.liveOpenMolecules++ + } + return s.Store.List(query) +} + +type demandRefreshFailStore struct { + beads.Store + failNextGet bool + liveInProgressLists int +} + +func (s *demandRefreshFailStore) Get(id string) (beads.Bead, error) { + if s.failNextGet { + s.failNextGet = false + return beads.Bead{}, errors.New("transient get failure") + } + return s.Store.Get(id) +} + +func (s *demandRefreshFailStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.Live && query.Status == "in_progress" { + s.liveInProgressLists++ + } + return s.Store.List(query) +} + +type partialAssignedWorkStore struct { + *beads.MemStore + partialInProgress bool + partialReady bool +} + +type controllerDemandPartialStore struct { + *beads.MemStore +} + +func (s *controllerDemandPartialStore) Ready(query ...beads.ReadyQuery) ([]beads.Bead, error) { + rows, err := s.MemStore.Ready(query...) + if err != nil { + return nil, err + } + if len(query) == 0 { + return rows, &beads.PartialResultError{Op: "bd ready", Err: errors.New("skipped corrupt controller demand bead")} + } + return rows, nil +} + +type acpOnlyDesiredStateProvider struct { + *runtime.Fake +} + +func (p *acpOnlyDesiredStateProvider) SupportsTransport(transport string) bool { + return transport == config.SessionTransportACP +} + +func (s *partialAssignedWorkStore) List(query beads.ListQuery) ([]beads.Bead, error) { + rows, err := s.MemStore.List(query) + if err != nil { + return nil, err + } + if s.partialInProgress && query.Status == "in_progress" && query.Live { + return rows, &beads.PartialResultError{Op: "bd list", Err: errors.New("skipped corrupt in-progress bead")} + } + return rows, nil +} + +func (s *partialAssignedWorkStore) Ready(query ...beads.ReadyQuery) ([]beads.Bead, error) { + rows, err := s.MemStore.Ready(query...) + if err != nil { + return nil, err + } + if s.partialReady { + return rows, &beads.PartialResultError{Op: "bd ready", Err: errors.New("skipped corrupt ready bead")} + } + return rows, nil +} + func TestCollectAssignedWorkBeads_IncludesReadyOpenAssignedHandoff(t *testing.T) { store := beads.NewMemStore() handoff, err := store.Create(beads.Bead{ @@ -59,6 +185,96 @@ func TestCollectAssignedWorkBeads_IncludesReadyOpenAssignedHandoff(t *testing.T) } } +func TestCollectAssignedWorkBeadsUsesCachedReadyReadModel(t *testing.T) { + backing := &readyFailStore{Store: beads.NewMemStore()} + handoff, err := backing.Create(beads.Bead{ + Title: "merge me", + Type: "task", + Status: "open", + Assignee: "repo/refinery", + }) + if err != nil { + t.Fatalf("create handoff bead: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + + got, _ := collectAssignedWorkBeads(&config.City{}, cache) + if len(got) != 1 || got[0].ID != handoff.ID { + t.Fatalf("collectAssignedWorkBeads returned %#v, want [%s]", got, handoff.ID) + } + if backing.readyCalls != 0 { + t.Fatalf("backing Ready calls = %d, want cached demand read", backing.readyCalls) + } +} + +func TestCollectAssignedWorkBeadsUsesCachedInProgressReadModel(t *testing.T) { + backing := &demandListCountingStore{Store: beads.NewMemStore()} + work, err := backing.Create(beads.Bead{ + Title: "active handoff", + Type: "task", + Status: "in_progress", + Assignee: "repo/refinery", + }) + if err != nil { + t.Fatalf("create active bead: %v", err) + } + if err := backing.Update(work.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("set active bead in_progress: %v", err) + } + work, err = backing.Get(work.ID) + if err != nil { + t.Fatalf("reload active bead: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + + got, _ := collectAssignedWorkBeads(&config.City{}, cache) + if len(got) != 1 || got[0].ID != work.ID { + t.Fatalf("collectAssignedWorkBeads returned %#v, want [%s]", got, work.ID) + } + if backing.liveInProgressLists != 0 { + t.Fatalf("live in_progress list calls = %d, want cached demand read", backing.liveInProgressLists) + } +} + +func TestCollectAssignedWorkBeadsFallsBackLiveWhenCachedInProgressDirty(t *testing.T) { + backing := &demandRefreshFailStore{Store: beads.NewMemStore()} + work, err := backing.Create(beads.Bead{ + Title: "handoff becomes active", + Type: "task", + }) + if err != nil { + t.Fatalf("create active bead: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + + status := "in_progress" + assignee := "repo/refinery" + backing.failNextGet = true + if err := cache.Update(work.ID, beads.UpdateOpts{Status: &status, Assignee: &assignee}); err != nil { + t.Fatalf("Update(active): %v", err) + } + + got, partial := collectAssignedWorkBeads(&config.City{}, cache) + if partial { + t.Fatal("collectAssignedWorkBeads reported partial with successful live fallback") + } + if len(got) != 1 || got[0].ID != work.ID || got[0].Status != "in_progress" || got[0].Assignee != "repo/refinery" { + t.Fatalf("collectAssignedWorkBeads returned %#v, want live in-progress %s", got, work.ID) + } + if backing.liveInProgressLists != 1 { + t.Fatalf("live in_progress list calls = %d, want dirty cache fallback", backing.liveInProgressLists) + } +} + func TestCollectAssignedWorkBeads_ExcludesBlockedOpenAssignedHandoff(t *testing.T) { store := beads.NewMemStore() blocker, err := store.Create(beads.Bead{ @@ -88,6 +304,334 @@ func TestCollectAssignedWorkBeads_ExcludesBlockedOpenAssignedHandoff(t *testing. } } +func TestDefaultScaleCheckCountsUsesCachedReadyReadModel(t *testing.T) { + backing := &readyFailStore{Store: beads.NewMemStore()} + if _, err := backing.Create(beads.Bead{ + Title: "queued routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "gascity/workflows.codex-min", + }, + }); err != nil { + t.Fatalf("create routed bead: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + + counts, _, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{{ + template: "gascity/workflows.codex-min", + storeKey: "rig:gascity", + store: cache, + }}) + if len(errs) != 0 { + t.Fatalf("defaultScaleCheckCounts errs = %v", errs) + } + if got := counts["gascity/workflows.codex-min"]; got != 1 { + t.Fatalf("defaultScaleCheckCounts = %d, want 1", got) + } + if backing.readyCalls != 0 { + t.Fatalf("backing Ready calls = %d, want cached demand read", backing.readyCalls) + } +} + +func TestDefaultScaleCheckCountsIgnoresOpenMoleculeContainers(t *testing.T) { + backing := &demandListCountingStore{Store: beads.NewMemStore()} + if _, err := backing.Create(beads.Bead{ + Title: "workflow root", + Type: "molecule", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "gascity/workflows.codex-min", + }, + }); err != nil { + t.Fatalf("create molecule bead: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + + counts, _, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{{ + template: "gascity/workflows.codex-min", + storeKey: "rig:gascity", + store: cache, + }}) + if len(errs) != 0 { + t.Fatalf("defaultScaleCheckCounts errs = %v", errs) + } + if got := counts["gascity/workflows.codex-min"]; got != 0 { + t.Fatalf("defaultScaleCheckCounts = %d, want molecule container ignored", got) + } + if backing.liveOpenMolecules != 0 { + t.Fatalf("live open molecule list calls = %d, want no molecule demand query", backing.liveOpenMolecules) + } +} + +func TestDefaultScaleCheckCountsHonorsCachedWriteThroughDependencies(t *testing.T) { + backing := &readyFailStore{Store: beads.NewMemStore()} + blocker, err := backing.Create(beads.Bead{ + Title: "blocked earlier step", + Type: "task", + Status: "open", + }) + if err != nil { + t.Fatalf("create blocker: %v", err) + } + blocked, err := backing.Create(beads.Bead{ + Title: "future routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "gascity/workflows.codex-max", + }, + }) + if err != nil { + t.Fatalf("create blocked: %v", err) + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + if err := cache.DepAdd(blocked.ID, blocker.ID, "blocks"); err != nil { + t.Fatalf("DepAdd: %v", err) + } + + counts, _, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{{ + template: "gascity/workflows.codex-max", + storeKey: "rig:gascity", + store: cache, + }}) + if len(errs) != 0 { + t.Fatalf("defaultScaleCheckCounts errs = %v", errs) + } + if got := counts["gascity/workflows.codex-max"]; got != 0 { + t.Fatalf("defaultScaleCheckCounts = %d, want blocked future work excluded", got) + } + if backing.readyCalls != 0 { + t.Fatalf("backing Ready calls = %d, want cached demand read", backing.readyCalls) + } +} + +func TestDefaultScaleCheckCountsFallsBackWhenCachedEventDepsUnknown(t *testing.T) { + backing := &readyStaticStore{ + Store: beads.NewMemStore(), + ready: []beads.Bead{{ + ID: "gc-ready", + Title: "ready routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "gascity/workflows.codex-max", + }, + }}, + } + cache := beads.NewCachingStoreForTest(backing, nil) + if err := cache.PrimeActive(); err != nil { + t.Fatalf("PrimeActive: %v", err) + } + cache.ApplyEvent("bead.created", []byte(`{"id":"gc-blocked","status":"open","metadata":{"gc.routed_to":"gascity/workflows.codex-max"}}`)) + + counts, _, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{{ + template: "gascity/workflows.codex-max", + storeKey: "rig:gascity", + store: cache, + }}) + if len(errs) != 0 { + t.Fatalf("defaultScaleCheckCounts errs = %v", errs) + } + if got := counts["gascity/workflows.codex-max"]; got != 1 { + t.Fatalf("defaultScaleCheckCounts = %d, want live ready fallback count only", got) + } + if backing.readyCalls != 1 { + t.Fatalf("backing Ready calls = %d, want one live ready fallback", backing.readyCalls) + } +} + +func TestDefaultScaleCheckCountsUsesPartialReadyRows(t *testing.T) { + store := &partialAssignedWorkStore{MemStore: beads.NewMemStore(), partialReady: true} + if _, err := store.Create(beads.Bead{ + Title: "queued routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "gascity/workflows.codex-max", + }, + }); err != nil { + t.Fatalf("create routed bead: %v", err) + } + + counts, partialTemplates, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{{ + template: "gascity/workflows.codex-max", + storeKey: "rig:gascity", + store: store, + }}) + if got := counts["gascity/workflows.codex-max"]; got != 1 { + t.Fatalf("defaultScaleCheckCounts = %d, want survivor row counted", got) + } + if len(errs) != 1 || !beads.IsPartialResult(errs[0]) { + t.Fatalf("defaultScaleCheckCounts errs = %v, want partial-result diagnostic", errs) + } + if !partialTemplates["gascity/workflows.codex-max"] { + t.Fatalf("partialTemplates = %v, want affected template marked partial", partialTemplates) + } +} + +func TestDefaultScaleCheckCountsReadyErrorNamesAffectedTemplates(t *testing.T) { + store := &readyFailStore{Store: beads.NewMemStore()} + + _, partialTemplates, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{ + {template: "gascity/workflows.codex-min", storeKey: "rig:gascity", store: store}, + {template: "gascity/workflows.codex-max", storeKey: "rig:gascity", store: store}, + }) + if len(errs) != 1 { + t.Fatalf("defaultScaleCheckCounts errs = %v, want one grouped Ready diagnostic", errs) + } + msg := errs[0].Error() + for _, want := range []string{"rig:gascity", "gascity/workflows.codex-min", "gascity/workflows.codex-max"} { + if !strings.Contains(msg, want) { + t.Fatalf("defaultScaleCheckCounts err = %q, want affected template %q", msg, want) + } + } + for _, want := range []string{"gascity/workflows.codex-min", "gascity/workflows.codex-max"} { + if !partialTemplates[want] { + t.Fatalf("partialTemplates = %v, want %q marked partial", partialTemplates, want) + } + } +} + +func TestDefaultNamedSessionDemandUsesPartialReadyRows(t *testing.T) { + store := &partialAssignedWorkStore{MemStore: beads.NewMemStore(), partialReady: true} + if _, err := store.Create(beads.Bead{ + Title: "queued worker work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "worker", + }, + }); err != nil { + t.Fatalf("create routed bead: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + }}, + NamedSessions: []config.NamedSession{{ + Name: "primary", + Template: "worker", + Mode: "on_demand", + }}, + } + + demand, partialTemplates, errs := defaultNamedSessionDemand([]defaultScaleCheckTarget{{ + template: "worker", + storeKey: "rig:gascity", + store: store, + }}, cfg, "test-city") + if !demand["primary"] { + t.Fatalf("defaultNamedSessionDemand[primary] = false, want survivor row counted") + } + if len(errs) != 1 || !beads.IsPartialResult(errs[0]) { + t.Fatalf("defaultNamedSessionDemand errs = %v, want partial-result diagnostic", errs) + } + msg := errs[0].Error() + for _, want := range []string{"rig:gascity", "worker"} { + if !strings.Contains(msg, want) { + t.Fatalf("defaultNamedSessionDemand err = %q, want affected template %q", msg, want) + } + } + if !partialTemplates["worker"] { + t.Fatalf("partialTemplates = %v, want worker marked partial", partialTemplates) + } +} + +func TestDefaultScaleCheckCountsReportsMissingRigStore(t *testing.T) { + cityPath := t.TempDir() + cfg := &config.City{ + Rigs: []config.Rig{{ + Name: "repo", + Path: filepath.Join(cityPath, "repos", "repo"), + }}, + } + agent := &config.Agent{Name: "worker", Dir: filepath.Join("repos", "repo")} + cityStore := beads.NewMemStore() + if _, err := cityStore.Create(beads.Bead{ + Title: "wrong-store routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "repos/repo/worker", + }, + }); err != nil { + t.Fatalf("create city routed bead: %v", err) + } + target := defaultScaleCheckTargetForAgent(cityPath, cfg, agent, cityStore, nil) + + counts, partialTemplates, errs := defaultScaleCheckCounts([]defaultScaleCheckTarget{target}) + if got := counts["repos/repo/worker"]; got != 0 { + t.Fatalf("defaultScaleCheckCounts = %d, want 0", got) + } + if len(errs) != 1 { + t.Fatalf("defaultScaleCheckCounts errs = %v, want one missing rig-store diagnostic", errs) + } + if !strings.Contains(errs[0].Error(), `rig store "repo" unavailable`) { + t.Fatalf("defaultScaleCheckCounts err = %v, want missing rig-store diagnostic", errs[0]) + } + if !partialTemplates["repos/repo/worker"] { + t.Fatalf("partialTemplates = %v, want missing rig-store template marked partial", partialTemplates) + } +} + +func TestBuildDesiredStateDefaultScaleCheckMissingRigStoreReportsZeroDemand(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "rig-owned routed work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "repos/repo/worker", + }, + }); err != nil { + t.Fatalf("create city routed bead: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{ + Name: "repo", + Path: filepath.Join(cityPath, "repos", "repo"), + }}, + Agents: []config.Agent{{ + Name: "worker", + Dir: filepath.Join("repos", "repo"), + StartCommand: "true", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(1), + }}, + } + + var stderr strings.Builder + got := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, &stderr) + if demand := got.ScaleCheckCounts["repos/repo/worker"]; demand != 0 { + t.Fatalf("ScaleCheckCounts[repos/repo/worker] = %d, want 0 without rig store", demand) + } + if got.StoreQueryPartial { + t.Fatalf("StoreQueryPartial = true, want false for scoped default scale_check failure") + } + if !got.ScaleCheckPartialTemplates["repos/repo/worker"] { + t.Fatalf("ScaleCheckPartialTemplates = %v, want missing rig-store template marked partial", got.ScaleCheckPartialTemplates) + } + if len(got.State) != 0 { + t.Fatalf("desired sessions = %d, want none without rig store demand", len(got.State)) + } + if !strings.Contains(stderr.String(), `rig store "repo" unavailable`) { + t.Fatalf("stderr = %q, want missing rig-store diagnostic", stderr.String()) + } +} + func TestCollectAssignedWorkBeads_ExcludesRoutedToMetadataWithoutAssignee(t *testing.T) { t.Parallel() store := beads.NewMemStore() @@ -153,9 +697,337 @@ func TestCollectAssignedWorkBeads_ExcludesSessionBeads(t *testing.T) { } } -func TestCollectAssignedWorkBeadsWithStores_TracksRigStore(t *testing.T) { - cityStore := beads.NewMemStore() - rigStore := beads.NewMemStore() +func TestCollectAssignedWorkBeads_PreservesPartialInProgressSurvivors(t *testing.T) { + t.Parallel() + + store := &partialAssignedWorkStore{ + MemStore: beads.NewMemStore(), + partialInProgress: true, + } + work, err := store.Create(beads.Bead{ + Title: "assigned active work", + Type: "task", + Assignee: "worker-1", + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + if err := store.Update(work.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("set work in_progress: %v", err) + } + work, err = store.Get(work.ID) + if err != nil { + t.Fatalf("reload work bead: %v", err) + } + + got, stores, storeRefs, partial := collectAssignedWorkBeadsWithStores(&config.City{}, store, nil, nil, nil) + if !partial { + t.Fatal("partial = false, want true") + } + if len(got) != 1 || got[0].ID != work.ID { + t.Fatalf("collectAssignedWorkBeadsWithStores returned %#v, want partial survivor %s", got, work.ID) + } + if len(stores) != 1 || stores[0] != store { + t.Fatalf("stores = %#v, want source store for partial survivor", stores) + } + if len(storeRefs) != 1 || storeRefs[0] != "" { + t.Fatalf("storeRefs = %#v, want city store ref for partial survivor", storeRefs) + } +} + +func TestCollectAssignedWorkBeads_PreservesPartialReadySurvivors(t *testing.T) { + t.Parallel() + + store := &partialAssignedWorkStore{ + MemStore: beads.NewMemStore(), + partialReady: true, + } + work, err := store.Create(beads.Bead{ + Title: "assigned ready work", + Type: "task", + Assignee: "worker-1", + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + + got, stores, storeRefs, partial := collectAssignedWorkBeadsWithStores(&config.City{}, store, nil, nil, nil) + if !partial { + t.Fatal("partial = false, want true") + } + if len(got) != 1 || got[0].ID != work.ID { + t.Fatalf("collectAssignedWorkBeadsWithStores returned %#v, want partial ready survivor %s", got, work.ID) + } + if len(stores) != 1 || stores[0] != store { + t.Fatalf("stores = %#v, want source store for partial survivor", stores) + } + if len(storeRefs) != 1 || storeRefs[0] != "" { + t.Fatalf("storeRefs = %#v, want city store ref for partial survivor", storeRefs) + } +} + +func TestCollectAssignedWorkBeads_SkipsReadyProbeForInProgressAssignee(t *testing.T) { + store := &readyQueryRecordingStore{MemStore: beads.NewMemStore()} + session, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-session", + "template": "worker", + "state": "asleep", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + work, err := store.Create(beads.Bead{ + Title: "active work", + Type: "task", + Assignee: "worker-session", + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + if err := store.Update(work.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("mark work in_progress: %v", err) + } + work, err = store.Get(work.ID) + if err != nil { + t.Fatalf("reload work: %v", err) + } + snapshot := newSessionBeadSnapshot([]beads.Bead{session}) + + got, _, _, partial := collectAssignedWorkBeadsWithStores(&config.City{}, store, nil, nil, snapshot) + if partial { + t.Fatal("collectAssignedWorkBeadsWithStores reported partial results") + } + if len(got) != 1 || got[0].ID != work.ID { + t.Fatalf("got = %#v, want in-progress work %s", got, work.ID) + } + if len(store.readyQueries) != 0 { + t.Fatalf("Ready queried while in-progress work was already known: %#v", store.readyQueries) + } +} + +func TestCollectAssignedWorkBeads_SkipsCityReadyProbeForRigInProgressAssignee(t *testing.T) { + cityStore := &readyQueryRecordingStore{MemStore: beads.NewMemStore()} + rigStore := &readyQueryRecordingStore{MemStore: beads.NewMemStore()} + session, err := cityStore.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-session", + "template": "worker", + "state": "asleep", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + work, err := rigStore.Create(beads.Bead{ + Title: "active rig work", + Type: "task", + Assignee: "worker-session", + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + if err := rigStore.Update(work.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("mark work in_progress: %v", err) + } + work, err = rigStore.Get(work.ID) + if err != nil { + t.Fatalf("reload work: %v", err) + } + snapshot := newSessionBeadSnapshot([]beads.Bead{session}) + + got, _, _, partial := collectAssignedWorkBeadsWithStores( + &config.City{Rigs: []config.Rig{{Name: "repo", Path: "repo"}}}, + cityStore, + map[string]beads.Store{"repo": rigStore}, + nil, + snapshot, + ) + if partial { + t.Fatal("collectAssignedWorkBeadsWithStores reported partial results") + } + if len(got) != 1 || got[0].ID != work.ID { + t.Fatalf("got = %#v, want rig in-progress work %s", got, work.ID) + } + if len(cityStore.readyQueries) != 0 || len(rigStore.readyQueries) != 0 { + t.Fatalf("Ready queried while cross-store in-progress work was already known: city=%#v rig=%#v", cityStore.readyQueries, rigStore.readyQueries) + } +} + +func TestCollectAssignedWorkBeads_ReadyProbeStillRunsForOtherAssignees(t *testing.T) { + store := &readyQueryRecordingStore{MemStore: beads.NewMemStore()} + activeSession, err := store.Create(beads.Bead{ + Title: "active worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-active", + "template": "worker", + "state": "asleep", + }, + }) + if err != nil { + t.Fatalf("create active session bead: %v", err) + } + readySession, err := store.Create(beads.Bead{ + Title: "ready worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-ready", + "template": "worker", + "state": "asleep", + }, + }) + if err != nil { + t.Fatalf("create ready session bead: %v", err) + } + activeWork, err := store.Create(beads.Bead{ + Title: "active work", + Type: "task", + Assignee: "worker-active", + }) + if err != nil { + t.Fatalf("create active work bead: %v", err) + } + if err := store.Update(activeWork.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("mark active work in_progress: %v", err) + } + activeWork, err = store.Get(activeWork.ID) + if err != nil { + t.Fatalf("reload active work: %v", err) + } + readyWork, err := store.Create(beads.Bead{ + Title: "ready work", + Type: "task", + Status: "open", + Assignee: "worker-ready", + }) + if err != nil { + t.Fatalf("create ready work bead: %v", err) + } + snapshot := newSessionBeadSnapshot([]beads.Bead{activeSession, readySession}) + + got, _, _, partial := collectAssignedWorkBeadsWithStores(&config.City{}, store, nil, nil, snapshot) + if partial { + t.Fatal("collectAssignedWorkBeadsWithStores reported partial results") + } + gotIDs := make(map[string]bool) + for _, bead := range got { + gotIDs[bead.ID] = true + } + for _, want := range []string{activeWork.ID, readyWork.ID} { + if !gotIDs[want] { + t.Fatalf("collected work IDs = %#v, want %s", gotIDs, want) + } + } + queried := make(map[string]bool) + for _, query := range store.readyQueries { + queried[query.Assignee] = true + } + if queried["worker-active"] || queried[activeSession.ID] { + t.Fatalf("Ready queries = %#v, want no probe for active assignee", store.readyQueries) + } + if !queried["worker-ready"] { + t.Fatalf("Ready queries = %#v, want probe for worker-ready", store.readyQueries) + } +} + +func TestCollectAssignedWorkBeads_ReadyProbeIncludesActiveSessionAssignees(t *testing.T) { + store := &readyQueryRecordingStore{MemStore: beads.NewMemStore()} + activeSession, err := store.Create(beads.Bead{ + Title: "active worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-active", + "template": "worker", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create active session bead: %v", err) + } + sleepySession, err := store.Create(beads.Bead{ + Title: "sleepy worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-sleepy", + "template": "worker", + "state": "asleep", + }, + }) + if err != nil { + t.Fatalf("create sleepy session bead: %v", err) + } + readyWork, err := store.Create(beads.Bead{ + Title: "ready active work", + Type: "task", + Status: "open", + Assignee: "worker-active", + }) + if err != nil { + t.Fatalf("create ready work bead: %v", err) + } + snapshot := newSessionBeadSnapshot([]beads.Bead{activeSession, sleepySession}) + + got, _, _, partial := collectAssignedWorkBeadsWithStores(&config.City{}, store, nil, nil, snapshot) + if partial { + t.Fatal("collectAssignedWorkBeadsWithStores reported partial results") + } + if len(got) != 1 || got[0].ID != readyWork.ID { + t.Fatalf("got = %#v, want ready active-session work %s", got, readyWork.ID) + } + queried := make(map[string]bool) + for _, query := range store.readyQueries { + queried[query.Assignee] = true + } + if !queried["worker-active"] { + t.Fatalf("Ready queries = %#v, want probe for active session assignee", store.readyQueries) + } +} + +func TestReadyAssignedWorkAssigneesExcludeBroadIdentities(t *testing.T) { + got := readyAssignedWorkAssignees(&config.City{ + Agents: []config.Agent{{ + Dir: "repo", + Name: "worker", + }}, + NamedSessions: []config.NamedSession{ + {Template: "mayor", Mode: "always"}, + {Dir: "repo", Template: "named-worker", Mode: "on_demand"}, + }, + }, nil, nil) + + for _, disallowed := range []string{"repo/worker", "mayor"} { + for _, value := range got { + if value == disallowed { + t.Fatalf("ready assignees = %#v, want no broad identity %q", got, disallowed) + } + } + } + foundNamed := false + for _, value := range got { + if value == "repo/named-worker" { + foundNamed = true + } + } + if !foundNamed { + t.Fatalf("ready assignees = %#v, want on-demand named-session identity", got) + } +} + +func TestCollectAssignedWorkBeadsWithStores_TracksRigStore(t *testing.T) { + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() work, err := rigStore.Create(beads.Bead{ Title: "assigned rig work", Type: "task", @@ -173,11 +1045,12 @@ func TestCollectAssignedWorkBeadsWithStores_TracksRigStore(t *testing.T) { t.Fatalf("reload rig work bead: %v", err) } - got, stores, partial := collectAssignedWorkBeadsWithStores( + got, stores, storeRefs, partial := collectAssignedWorkBeadsWithStores( &config.City{Rigs: []config.Rig{{Name: "repo", Path: "/repo"}}}, cityStore, map[string]beads.Store{"repo": rigStore}, nil, + nil, ) if partial { t.Fatal("partial = true, want false") @@ -188,6 +1061,9 @@ func TestCollectAssignedWorkBeadsWithStores_TracksRigStore(t *testing.T) { if len(stores) != 1 || stores[0] != rigStore { t.Fatalf("stores = %#v, want [rig store]", stores) } + if len(storeRefs) != 1 || storeRefs[0] != "repo" { + t.Fatalf("storeRefs = %#v, want [repo]", storeRefs) + } } func TestCollectAssignedWorkBeadsWithStores_PreservesCrossStoreIDCollisions(t *testing.T) { @@ -229,11 +1105,12 @@ func TestCollectAssignedWorkBeadsWithStores_PreservesCrossStoreIDCollisions(t *t t.Fatalf("test setup expected overlapping city/rig IDs, got city %q rig %q", cityWork.ID, rigWork.ID) } - got, stores, partial := collectAssignedWorkBeadsWithStores( + got, stores, storeRefs, partial := collectAssignedWorkBeadsWithStores( &config.City{Rigs: []config.Rig{{Name: "repo", Path: "/repo"}}}, cityStore, map[string]beads.Store{"repo": rigStore}, nil, + nil, ) if partial { t.Fatal("partial = true, want false") @@ -244,12 +1121,21 @@ func TestCollectAssignedWorkBeadsWithStores_PreservesCrossStoreIDCollisions(t *t if len(stores) != len(got) { t.Fatalf("stores length = %d, want %d", len(stores), len(got)) } + if len(storeRefs) != len(got) { + t.Fatalf("storeRefs length = %d, want %d", len(storeRefs), len(got)) + } if got[0].ID != cityWork.ID || stores[0] != cityStore { t.Fatalf("first collected work = (%s, %#v), want city work/store", got[0].ID, stores[0]) } + if storeRefs[0] != "" { + t.Fatalf("first store ref = %q, want city ref", storeRefs[0]) + } if got[1].ID != rigWork.ID || stores[1] != rigStore { t.Fatalf("second collected work = (%s, %#v), want rig work/store", got[1].ID, stores[1]) } + if storeRefs[1] != "repo" { + t.Fatalf("second store ref = %q, want repo", storeRefs[1]) + } } func TestBuildDesiredState_UsesAgentHookOverride(t *testing.T) { @@ -281,6 +1167,49 @@ func TestBuildDesiredState_UsesAgentHookOverride(t *testing.T) { } } +func TestBuildDesiredStateRejectsExplicitTmuxAgentWhenSessionProviderCannotRouteTmux(t *testing.T) { + cityPath := t.TempDir() + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city", Provider: "opencode"}, + Session: config.SessionConfig{Provider: config.SessionTransportACP}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + Command: "echo", + Args: []string{"provider"}, + ACPCommand: "echo", + ACPArgs: []string{"acp"}, + PromptMode: "none", + SupportsACP: boolPtr(true), + }, + }, + Agents: []config.Agent{{ + Name: "worker", + Provider: "opencode", + Session: config.SessionTransportTmux, + MaxActiveSessions: intPtr(1), + ScaleCheck: "printf 1", + }}, + } + store := beads.NewMemStore() + sp := &acpOnlyDesiredStateProvider{Fake: runtime.NewFake()} + var stderr strings.Builder + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, sp, store, &stderr) + if len(dsResult.State) != 0 { + t.Fatalf("desired state size = %d, want 0: %#v", len(dsResult.State), dsResult.State) + } + beads, err := store.ListByLabel(sessionBeadLabel, 0) + if err != nil { + t.Fatalf("ListByLabel(%q): %v", sessionBeadLabel, err) + } + if len(beads) != 0 { + t.Fatalf("session bead count = %d, want 0: %#v", len(beads), beads) + } + if got := stderr.String(); !strings.Contains(got, "cannot route tmux sessions") { + t.Fatalf("stderr = %q, want tmux routing rejection", got) + } +} + func TestBuildDesiredState_InstallsGeminiHooksBeforeFingerprinting(t *testing.T) { cityPath := t.TempDir() cfg := &config.City{ @@ -600,7 +1529,99 @@ func TestBuildDesiredState_MinZeroDefaultScaleCheckRoutedWorkCreatesPoolSession( } } -func TestBuildDesiredState_OnDemandNamedSession_RoutedMetadataAloneDoesNotMaterialize(t *testing.T) { +func TestBuildDesiredState_PoolInFlightSessionsPreservePartialScaleDemand(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + const template = "worker" + + for i := 0; i < 5; i++ { + if _, err := store.Create(beads.Bead{ + Title: fmt.Sprintf("queued work %d", i+1), + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": template, + }, + }); err != nil { + t.Fatalf("create queued work: %v", err) + } + } + var inFlightSessionIDs []string + for i := 0; i < 2; i++ { + session, err := store.Create(beads.Bead{ + Title: template, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:" + template}, + Metadata: map[string]string{ + "template": template, + "agent_name": template, + "state": "asleep", + "pending_create_claim": boolMetadata(true), + poolManagedMetadataKey: boolMetadata(true), + }, + }) + if err != nil { + t.Fatalf("create pending pool session: %v", err) + } + if err := store.SetMetadata(session.ID, "session_name", PoolSessionName(template, session.ID)); err != nil { + t.Fatalf("set session_name: %v", err) + } + inFlightSessionIDs = append(inFlightSessionIDs, session.ID) + } + sessionSnapshot, err := loadSessionBeadSnapshot(store) + if err != nil { + t.Fatalf("load session snapshot: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: template, + StartCommand: "true", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(10), + }}, + } + + var stderr strings.Builder + dsResult := buildDesiredStateWithSessionBeads( + "test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), + store, nil, sessionSnapshot, nil, &stderr, + ) + + if got := dsResult.ScaleCheckCounts[template]; got != 5 { + t.Fatalf("ScaleCheckCounts[%s] = %d, want 5", template, got) + } + desired := 0 + for _, tp := range dsResult.State { + if tp.TemplateName == template { + desired++ + } + } + if desired != 5 { + t.Fatalf("%s desired sessions = %d, want 5 with two in-flight plus three new; stderr:\n%s", template, desired, stderr.String()) + } + desiredSessionNames := make(map[string]bool) + for _, tp := range dsResult.State { + if tp.TemplateName == template { + desiredSessionNames[tp.SessionName] = true + } + } + for _, id := range inFlightSessionIDs { + name := PoolSessionName(template, id) + if !desiredSessionNames[name] { + t.Fatalf("desired state did not preserve in-flight session %s (%s); desired=%#v", id, name, desiredSessionNames) + } + } + sessions, err := store.ListByLabel(sessionBeadLabel, 0) + if err != nil { + t.Fatalf("list session beads: %v", err) + } + if len(sessions) != 5 { + t.Fatalf("stored session beads = %d, want 5 total", len(sessions)) + } +} + +func TestBuildDesiredState_OnDemandNamedSession_DefaultRoutedWorkMaterializesNamedSession(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() if _, err := store.Create(beads.Bead{ @@ -628,11 +1649,150 @@ func TestBuildDesiredState_OnDemandNamedSession_RoutedMetadataAloneDoesNotMateri } dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + foundNamed := false + foundGeneric := false for _, tp := range dsResult.State { if tp.TemplateName == "mayor" { - t.Fatalf("routed metadata alone should not materialize on-demand named session: %+v", tp) + if tp.ConfiguredNamedIdentity == "mayor" { + foundNamed = true + continue + } + foundGeneric = true } } + if !foundNamed { + t.Fatal("default routed work should materialize the on-demand named session") + } + if foundGeneric { + t.Fatal("default routed work should not create a parallel generic session for the named template") + } + if !dsResult.NamedSessionDemand["mayor"] { + t.Fatal("NamedSessionDemand should record default routed work for mayor") + } +} + +func TestBuildDesiredState_OnDemandNamedSession_DefaultRoutedTemplateMaterializesSingletonIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "queued worker work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "worker", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + WorkQuery: "printf ''", + }}, + NamedSessions: []config.NamedSession{{ + Name: "primary", + Template: "worker", + Mode: "on_demand", + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + foundNamed := false + for _, tp := range dsResult.State { + if tp.TemplateName != "worker" { + continue + } + if tp.ConfiguredNamedIdentity == "primary" { + foundNamed = true + continue + } + t.Fatalf("routed singleton template created generic worker session: %+v", tp) + } + if !foundNamed { + t.Fatal("default routed work should materialize the singleton named identity for worker") + } + if !dsResult.NamedSessionDemand["primary"] { + t.Fatal("NamedSessionDemand should record singleton identity demand") + } +} + +func TestBuildDesiredState_OnDemandNamedSession_DefaultRoutedTemplateDoesNotPickAmbiguousIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "queued worker work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "worker", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + WorkQuery: "printf ''", + }}, + NamedSessions: []config.NamedSession{ + {Name: "primary", Template: "worker", Mode: "on_demand"}, + {Name: "secondary", Template: "worker", Mode: "on_demand"}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if dsResult.NamedSessionDemand["primary"] || dsResult.NamedSessionDemand["secondary"] { + t.Fatalf("ambiguous template route recorded named demand: %v", dsResult.NamedSessionDemand) + } + for _, tp := range dsResult.State { + switch tp.ConfiguredNamedIdentity { + case "primary", "secondary": + t.Fatalf("ambiguous template route materialized named identity: %+v", tp) + } + } +} + +func TestBuildDesiredState_OnDemandNamedSession_DefaultRoutedNoMatchDoesNotMaterialize(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "queued unmatched work", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": "missing", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + WorkQuery: "printf ''", + }}, + NamedSessions: []config.NamedSession{{ + Name: "primary", + Template: "worker", + Mode: "on_demand", + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if dsResult.NamedSessionDemand["primary"] { + t.Fatal("unmatched route should not record named-session demand") + } + if len(dsResult.State) != 0 { + t.Fatalf("unmatched route should not materialize sessions: %+v", dsResult.State) + } } func TestBuildDesiredState_OnDemandNamedSession_DirectAssigneeMaterializes(t *testing.T) { @@ -673,6 +1833,169 @@ func TestBuildDesiredState_OnDemandNamedSession_DirectAssigneeMaterializes(t *te } } +func TestBuildDesiredState_OnDemandNamedSession_IgnoresUnreachableAssignedWork(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + if err := os.MkdirAll(rigPath, 0o755); err != nil { + t.Fatalf("create rig dir: %v", err) + } + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + if _, err := cityStore.Create(beads.Bead{ + Title: "assigned mayor work in city store", + Type: "task", + Status: "open", + Assignee: "riga/mayor", + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "mayor", + Dir: "riga", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + WorkQuery: "printf ''", + }}, + NamedSessions: []config.NamedSession{{ + Template: "mayor", + Dir: "riga", + Mode: "on_demand", + }}, + } + + dsResult := buildDesiredStateWithSessionBeads( + "test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), + cityStore, map[string]beads.Store{"riga": rigStore}, nil, nil, io.Discard, + ) + for _, tp := range dsResult.State { + if tp.TemplateName == "riga/mayor" || tp.ConfiguredNamedIdentity == "riga/mayor" { + t.Fatalf("unreachable city-store assignee should not materialize rig named session: %+v", tp) + } + } + if dsResult.NamedSessionDemand["riga/mayor"] { + t.Fatal("unreachable city-store assignee should not record named-session demand") + } +} + +func TestBuildDesiredState_OnDemandNamedSession_ReachabilityUsesPerBeadSourceNotID(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + if err := os.MkdirAll(rigPath, 0o755); err != nil { + t.Fatalf("create rig dir: %v", err) + } + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + cityWork, err := cityStore.Create(beads.Bead{ + Title: "phantom city work", + Type: "task", + Status: "open", + Assignee: "riga/mayor", + }) + if err != nil { + t.Fatal(err) + } + rigWork, err := rigStore.Create(beads.Bead{ + Title: "same ID rig work for another session", + Type: "task", + Status: "open", + Assignee: "riga/other", + }) + if err != nil { + t.Fatal(err) + } + if cityWork.ID != rigWork.ID { + t.Fatalf("test setup expected overlapping city/rig IDs, got city %q rig %q", cityWork.ID, rigWork.ID) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "mayor", + Dir: "riga", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + WorkQuery: "printf ''", + }}, + NamedSessions: []config.NamedSession{{ + Template: "mayor", + Dir: "riga", + Mode: "on_demand", + }}, + } + + dsResult := buildDesiredStateWithSessionBeads( + "test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), + cityStore, map[string]beads.Store{"riga": rigStore}, nil, nil, io.Discard, + ) + if dsResult.NamedSessionDemand["riga/mayor"] { + t.Fatal("same-ID rig bead should not make the city-store assignment reachable") + } +} + +func TestBuildDesiredState_RigPoolIgnoresAssignedWorkInUnreachableStore(t *testing.T) { + cityPath := t.TempDir() + rigPath := filepath.Join(cityPath, "riga") + if err := os.MkdirAll(rigPath, 0o755); err != nil { + t.Fatalf("create rig dir: %v", err) + } + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + sessionBead, err := cityStore.Create(beads.Bead{ + Title: "asleep rig worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:riga/worker"}, + Metadata: map[string]string{ + "template": "riga/worker", + "session_name": "worker-gc-1", + "state": "asleep", + "pool_managed": "true", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + work, err := cityStore.Create(beads.Bead{ + Title: "unreachable city work for rig worker", + Type: "task", + Assignee: sessionBead.ID, + Metadata: map[string]string{"gc.routed_to": "riga/worker"}, + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + if err := cityStore.Update(work.ID, beads.UpdateOpts{Status: stringPtr("in_progress")}); err != nil { + t.Fatalf("set work in_progress: %v", err) + } + sessionSnapshot, err := loadSessionBeadSnapshot(cityStore) + if err != nil { + t.Fatalf("load session snapshot: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{Name: "riga", Path: rigPath}}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "riga", + StartCommand: "true", + MaxActiveSessions: intPtr(5), + ScaleCheck: "printf 0", + }}, + } + + dsResult := buildDesiredStateWithSessionBeads( + "test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), + cityStore, map[string]beads.Store{"riga": rigStore}, sessionSnapshot, nil, io.Discard, + ) + for _, tp := range dsResult.State { + if tp.TemplateName == "riga/worker" { + t.Fatalf("unreachable city-store work should not resume rig pool session: %+v", tp) + } + } +} + func TestBuildDesiredState_AlwaysNamedSession_MaterializesWithoutWorkBeads(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() @@ -694,6 +2017,12 @@ func TestBuildDesiredState_AlwaysNamedSession_MaterializesWithoutWorkBeads(t *te found := false for _, tp := range dsResult.State { if tp.TemplateName == "mayor" { + if tp.ConfiguredNamedIdentity != "mayor" { + t.Fatalf("ConfiguredNamedIdentity = %q, want mayor", tp.ConfiguredNamedIdentity) + } + if tp.ConfiguredNamedMode != "always" { + t.Fatalf("ConfiguredNamedMode = %q, want always", tp.ConfiguredNamedMode) + } found = true break } @@ -1086,7 +2415,7 @@ func TestBuildDesiredState_OnDemandNamedSession_ScaleCheckNonIntegerDoesNotFallT } } -func TestBuildDesiredState_OnDemandNamedSession_WorkQueryUsesExplicitRigPassword(t *testing.T) { +func TestBuildDesiredState_OnDemandNamedSession_RigWorkQueryDoesNotMaterialize(t *testing.T) { t.Setenv("GC_BEADS", "bd") t.Setenv("GC_DOLT_USER", "") t.Setenv("GC_DOLT_PASSWORD", "") @@ -1150,8 +2479,8 @@ func TestBuildDesiredState_OnDemandNamedSession_WorkQueryUsesExplicitRigPassword break } } - if !found { - t.Fatal("on-demand rig named session should materialize when work_query sees rig-scoped password") + if found { + t.Fatal("on-demand rig named session materialized from controller-side work_query") } } @@ -1431,7 +2760,7 @@ func TestBuildDesiredState_ManualImplicitPoolSessionsStayDesired(t *testing.T) { Labels: []string{sessionBeadLabel, "template:helper"}, Metadata: map[string]string{ "template": "helper", - "session_name": "s-mc-4wq", + "session_name": "s-real-world-app-4wq", "state": "creating", "manual_session": "true", "pending_create_claim": "true", @@ -1443,7 +2772,7 @@ func TestBuildDesiredState_ManualImplicitPoolSessionsStayDesired(t *testing.T) { Labels: []string{sessionBeadLabel, "template:helper"}, Metadata: map[string]string{ "template": "helper", - "session_name": "s-mc-bmr", + "session_name": "s-real-world-app-bmr", "alias": "hal", "state": "suspended", "manual_session": "true", @@ -1481,7 +2810,7 @@ func TestBuildDesiredState_ManualImplicitPoolSessionsStayDesired(t *testing.T) { dsResult := buildDesiredState("my-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) desired := dsResult.State - for _, sn := range []string{"s-mc-4wq", "s-mc-bmr"} { + for _, sn := range []string{"s-real-world-app-4wq", "s-real-world-app-bmr"} { tp, ok := desired[sn] if !ok { t.Fatalf("expected manual helper session %q in desired state, got keys %v", sn, mapKeys(desired)) @@ -1493,38 +2822,278 @@ func TestBuildDesiredState_ManualImplicitPoolSessionsStayDesired(t *testing.T) { t.Fatalf("desired[%q].ManualSession = false, want true", sn) } } - if got := desired["s-mc-bmr"].Alias; got != "hal" { - t.Fatalf("desired[s-mc-bmr].Alias = %q, want hal", got) + if got := desired["s-real-world-app-bmr"].Alias; got != "hal" { + t.Fatalf("desired[s-real-world-app-bmr].Alias = %q, want hal", got) } } -func TestBuildDesiredState_DrainedPoolManagedSessionIsNotRediscovered(t *testing.T) { +func TestBuildDesiredState_ScaleCheckErrorRetainsOnlyAffectedPoolSessions(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() - if _, err := store.Create(beads.Bead{ - Title: "claude", + workerSession := beads.Bead{ + ID: "session-worker", + Title: "worker", Type: sessionBeadType, - Labels: []string{sessionBeadLabel, "template:claude"}, + Status: "open", + Labels: []string{sessionBeadLabel, "template:worker"}, Metadata: map[string]string{ - "template": "claude", - "agent_name": "claude", - "session_name": "s-gc-drained", - "state": "asleep", - "sleep_reason": "drained", - "pool_managed": "true", + "session_name": "worker-bd-123", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", + }, + } + helperSession := beads.Bead{ + ID: "session-helper", + Title: "helper", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "template:helper"}, + Metadata: map[string]string{ + "session_name": "helper-bd-123", + "template": "helper", + "agent_name": "helper", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", }, - }); err != nil { - t.Fatal(err) } cfg := &config.City{ - Agents: []config.Agent{{ - Name: "claude", - MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(5), - }}, + Agents: []config.Agent{ + { + Name: "worker", + StartCommand: "echo", + ScaleCheck: "exit 42", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + }, + { + Name: "helper", + StartCommand: "echo", + ScaleCheck: "printf 0", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + }, + }, } - dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) - desired := dsResult.State + var stderr strings.Builder + result := buildDesiredStateWithSessionBeads( + "test-city", + cityPath, + time.Now().UTC(), + cfg, + runtime.NewFake(), + store, + nil, + newSessionBeadSnapshot([]beads.Bead{workerSession, helperSession}), + nil, + &stderr, + ) + + if result.StoreQueryPartial { + t.Fatalf("StoreQueryPartial = true, want false for scoped scale_check failure; stderr=%s", stderr.String()) + } + if !result.ScaleCheckPartialTemplates["worker"] { + t.Fatalf("ScaleCheckPartialTemplates[worker] = false, want true; templates=%v stderr=%s", result.ScaleCheckPartialTemplates, stderr.String()) + } + if !result.PoolScaleCheckPartialTemplates["worker"] { + t.Fatalf("PoolScaleCheckPartialTemplates[worker] = false, want true; templates=%v", result.PoolScaleCheckPartialTemplates) + } + if result.ScaleCheckPartialTemplates["helper"] { + t.Fatalf("ScaleCheckPartialTemplates[helper] = true, want false; templates=%v", result.ScaleCheckPartialTemplates) + } + if _, ok := result.State["worker-bd-123"]; !ok { + t.Fatalf("affected worker session not retained in desired state: keys=%v stderr=%s", mapKeys(result.State), stderr.String()) + } + if _, ok := result.State["helper-bd-123"]; ok { + t.Fatalf("unaffected helper session retained despite clean zero demand: keys=%v", mapKeys(result.State)) + } + if got := result.ScaleCheckCounts["worker"]; got != 0 { + t.Fatalf("ScaleCheckCounts[worker] = %d, want 0 on failed new-demand probe", got) + } +} + +func TestBuildDesiredState_ScaleCheckErrorPreservesDormantAffectedPoolSessionWithoutWakeDemand(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + workerSession := beads.Bead{ + ID: "session-worker", + Title: "worker", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "template:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-123", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "asleep", + }, + } + helperSession := beads.Bead{ + ID: "session-helper", + Title: "helper", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "template:helper"}, + Metadata: map[string]string{ + "session_name": "helper-bd-123", + "template": "helper", + "agent_name": "helper", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "asleep", + }, + } + cfg := &config.City{ + Agents: []config.Agent{ + { + Name: "worker", + StartCommand: "echo", + ScaleCheck: "exit 42", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + }, + { + Name: "helper", + StartCommand: "echo", + ScaleCheck: "printf 0", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + }, + }, + } + snapshot := newSessionBeadSnapshot([]beads.Bead{workerSession, helperSession}) + + var stderr strings.Builder + result := buildDesiredStateWithSessionBeads( + "test-city", + cityPath, + time.Now().UTC(), + cfg, + runtime.NewFake(), + store, + nil, + snapshot, + nil, + &stderr, + ) + + if result.StoreQueryPartial { + t.Fatalf("StoreQueryPartial = true, want false for scoped scale_check failure; stderr=%s", stderr.String()) + } + if _, ok := result.State["worker-bd-123"]; !ok { + t.Fatalf("dormant affected worker session not preserved in desired state: keys=%v stderr=%s", mapKeys(result.State), stderr.String()) + } + if _, ok := result.State["helper-bd-123"]; ok { + t.Fatalf("unaffected dormant helper session retained despite clean zero demand: keys=%v", mapKeys(result.State)) + } + + poolDesired := retainScaleCheckPartialPoolDesired( + PoolDesiredCounts(ComputePoolDesiredStates(cfg, nil, snapshot.Open(), result.ScaleCheckCounts)), + snapshot, + result.PoolScaleCheckPartialTemplates, + ) + if got := poolDesired["worker"]; got != 0 { + t.Fatalf("poolDesired[worker] = %d, want dormant preservation without wake demand", got) + } +} + +func TestBuildDesiredState_NamedScaleCheckPartialDoesNotRetainGenericPoolSession(t *testing.T) { + cityPath := t.TempDir() + store := &controllerDemandPartialStore{MemStore: beads.NewMemStore()} + poolSession := beads.Bead{ + ID: "session-worker-pool", + Title: "worker pool", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "template:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-123", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", + }, + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + StartCommand: "echo", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + }}, + NamedSessions: []config.NamedSession{{ + Name: "primary", + Template: "worker", + Mode: "on_demand", + }}, + } + + var stderr strings.Builder + result := buildDesiredStateWithSessionBeads( + "test-city", + cityPath, + time.Now().UTC(), + cfg, + runtime.NewFake(), + store, + nil, + newSessionBeadSnapshot([]beads.Bead{poolSession}), + nil, + &stderr, + ) + + if result.StoreQueryPartial { + t.Fatalf("StoreQueryPartial = true, want false for scoped named scale_check failure; stderr=%s", stderr.String()) + } + if !result.ScaleCheckPartialTemplates["worker"] { + t.Fatalf("ScaleCheckPartialTemplates[worker] = false, want named-session partial recorded; templates=%v stderr=%s", result.ScaleCheckPartialTemplates, stderr.String()) + } + if result.PoolScaleCheckPartialTemplates["worker"] { + t.Fatalf("PoolScaleCheckPartialTemplates[worker] = true, want false for named-session partial; templates=%v", result.PoolScaleCheckPartialTemplates) + } + if !result.NamedScaleCheckPartialTemplates["worker"] { + t.Fatalf("NamedScaleCheckPartialTemplates[worker] = false, want true; templates=%v", result.NamedScaleCheckPartialTemplates) + } + if _, ok := result.State["worker-bd-123"]; ok { + t.Fatalf("generic pool session retained by named-session partial: keys=%v stderr=%s", mapKeys(result.State), stderr.String()) + } +} + +func TestBuildDesiredState_DrainedPoolManagedSessionIsNotRediscovered(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "claude", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:claude"}, + Metadata: map[string]string{ + "template": "claude", + "agent_name": "claude", + "session_name": "s-gc-drained", + "state": "asleep", + "sleep_reason": "drained", + "pool_managed": "true", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "claude", + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(5), + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + desired := dsResult.State if _, ok := desired["s-gc-drained"]; ok { t.Fatalf("drained pool-managed session should not be rediscovered into desired state") @@ -1825,159 +3394,673 @@ func TestBuildDesiredState_StoreBackedPoolUsesLogicalInstanceIdentity(t *testing } } -func TestBuildDesiredState_StoreBackedPoolUsesQualifiedInstanceNameForBindings(t *testing.T) { +func TestBuildDesiredState_StoreBackedPoolUsesQualifiedInstanceNameForBindings(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "ops worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:ops.worker"}, + Metadata: map[string]string{ + "template": "ops.worker", + "session_name": "ops-worker-1", + "agent_name": "ops.worker", + "state": "active", + "pool_managed": "true", + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "worker", + BindingName: "ops", + WorkDir: ".gc/worktrees/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(2), + ScaleCheck: "printf 1", + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + var got TemplateParams + found := false + for _, tp := range dsResult.State { + if tp.TemplateName == "ops.worker" { + got = tp + found = true + break + } + } + if !found { + t.Fatalf("desired state missing binding-qualified pool session: keys=%v", mapKeys(dsResult.State)) + } + + wantInstance := cfg.Agents[0].QualifiedInstanceName("worker-1") + if got.InstanceName != wantInstance { + t.Fatalf("InstanceName = %q, want %q", got.InstanceName, wantInstance) + } + if got.Alias != wantInstance { + t.Fatalf("Alias = %q, want %q", got.Alias, wantInstance) + } + if got.Env["GC_AGENT"] != wantInstance { + t.Fatalf("GC_AGENT = %q, want %q", got.Env["GC_AGENT"], wantInstance) + } + wantWorkDir := filepath.Join(cityPath, ".gc", "worktrees", "ops.worker-1") + if got.WorkDir != wantWorkDir { + t.Fatalf("WorkDir = %q, want %q", got.WorkDir, wantWorkDir) + } +} + +func TestBuildDesiredState_RecoversPoolTemplateFromAliasOnlyBindingIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "ops furiosa", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "ops-furiosa-session", + "alias": "frontend/ops.furiosa", + "pool_slot": "1", + "pool_managed": "true", + "state": "active", + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Agents: []config.Agent{{ + Name: "worker", + Dir: "frontend", + BindingName: "ops", + NamepoolNames: []string{"furiosa", "nux"}, + WorkDir: ".gc/worktrees/{{.AgentBase}}", + ScaleCheck: "printf 1", + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["ops-furiosa-session"] + if !ok { + t.Fatalf("desired state missing alias-only pool session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "frontend/ops.worker" { + t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "frontend/ops.worker") + } + wantInstance := cfg.Agents[0].QualifiedInstanceName("furiosa") + if got.InstanceName != wantInstance { + t.Fatalf("InstanceName = %q, want %q", got.InstanceName, wantInstance) + } + if got.Alias != wantInstance { + t.Fatalf("Alias = %q, want %q", got.Alias, wantInstance) + } + if got.Env["GC_AGENT"] != wantInstance { + t.Fatalf("GC_AGENT = %q, want %q", got.Env["GC_AGENT"], wantInstance) + } + wantWorkDir := filepath.Join(cityPath, ".gc", "worktrees", "ops.furiosa") + if got.WorkDir != wantWorkDir { + t.Fatalf("WorkDir = %q, want %q", got.WorkDir, wantWorkDir) + } +} + +func TestBuildDesiredState_PendingCreatePoolSessionUsesConcreteBeadIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + workDir := filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "ant-adhoc-abc123") + if _, err := store.Create(beads.Bead{ + Title: "adhoc ant", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:demo/ant"}, + Metadata: map[string]string{ + "template": "demo/ant", + "session_name": "ant-adhoc-abc123", + "session_name_explicit": boolMetadata(true), + "agent_name": "demo/ant-adhoc-abc123", + "session_origin": "manual", + "pending_create_claim": boolMetadata(true), + "state": "creating", + "work_dir": workDir, + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Rigs: []config.Rig{{Name: "demo", Path: filepath.Join(cityPath, "repos", "demo")}}, + Agents: []config.Agent{{ + Name: "ant", + Dir: "demo", + Provider: "test-agent", + StartCommand: "true", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["ant-adhoc-abc123"] + if !ok { + t.Fatalf("desired state missing pending create session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "demo/ant" { + t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "demo/ant") + } + if got.InstanceName != "demo/ant-adhoc-abc123" { + t.Fatalf("InstanceName = %q, want %q", got.InstanceName, "demo/ant-adhoc-abc123") + } + if got.WorkDir != workDir { + t.Fatalf("WorkDir = %q, want %q", got.WorkDir, workDir) + } + if got.Env["GC_ALIAS"] != "demo/ant-adhoc-abc123" { + t.Fatalf("GC_ALIAS = %q, want %q", got.Env["GC_ALIAS"], "demo/ant-adhoc-abc123") + } +} + +func TestBuildDesiredState_PendingCreatePoolSessionStaysDesiredWithoutScaleDemand(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + sessionName := "workflows__codex-max-mc-new" + if _, err := store.Create(beads.Bead{ + Title: "codex-max", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:gascity/workflows.codex-max-1"}, + Metadata: map[string]string{ + "template": "gascity/workflows.codex-max", + "session_name": sessionName, + "agent_name": "gascity/workflows.codex-max-1", + "session_origin": "ephemeral", + "pool_managed": boolMetadata(true), + "pool_slot": "1", + "pending_create_claim": boolMetadata(true), + "state": "stopped", + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Rigs: []config.Rig{{Name: "gascity", Path: filepath.Join(cityPath, "repos", "gascity")}}, + Agents: []config.Agent{{ + Name: "workflows.codex-max", + Dir: "gascity", + Provider: "test-agent", + StartCommand: "true", + WorkDir: ".", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + ScaleCheck: "printf 0", + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if got := dsResult.ScaleCheckCounts["gascity/workflows.codex-max"]; got != 0 { + t.Fatalf("ScaleCheckCounts[gascity/workflows.codex-max] = %d, want 0", got) + } + got, ok := dsResult.State[sessionName] + if !ok { + t.Fatalf("desired state missing pending-create pool session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "gascity/workflows.codex-max" { + t.Fatalf("TemplateName = %q, want gascity/workflows.codex-max", got.TemplateName) + } + if got.InstanceName != sessionName { + t.Fatalf("InstanceName = %q, want existing session name %q", got.InstanceName, sessionName) + } +} + +func TestBuildDesiredState_PendingCreatePoolSessionCountsTowardScaleDemand(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + const template = "worker" + sessionName := "worker-mc-starting" + for i := 0; i < 2; i++ { + if _, err := store.Create(beads.Bead{ + Title: fmt.Sprintf("queued work %d", i+1), + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.routed_to": template, + }, + }); err != nil { + t.Fatalf("create queued work: %v", err) + } + } + if _, err := store.Create(beads.Bead{ + Title: template, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker-1"}, + Metadata: map[string]string{ + "template": template, + "session_name": sessionName, + "agent_name": "worker-1", + "session_origin": "ephemeral", + "pool_managed": boolMetadata(true), + "pool_slot": "1", + "pending_create_claim": boolMetadata(true), + "pending_create_started_at": time.Now().UTC().Format(time.RFC3339), + "state": "creating", + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Agents: []config.Agent{{ + Name: template, + StartCommand: "true", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + }}, + } + sessionSnapshot, err := loadSessionBeadSnapshot(store) + if err != nil { + t.Fatalf("load session snapshot: %v", err) + } + + trace := newPoolDesiredStateTestTrace(template) + var stderr strings.Builder + dsResult := buildDesiredStateWithSessionBeads( + "test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), + store, nil, sessionSnapshot, trace, &stderr, + ) + if got := dsResult.ScaleCheckCounts[template]; got != 2 { + t.Fatalf("ScaleCheckCounts[%s] = %d, want 2", template, got) + } + // The trace pins the buildDesiredState integration point: the pending + // create consumes one scale-demand slot before anonymous new requests are + // materialized. + if got := trace.decisionCounts[string(TraceSitePoolInFlightReuse)]; got != 1 { + t.Fatalf("in-flight reuse trace decisions = %d, want 1; stderr:\n%s", got, stderr.String()) + } + rec := poolTraceDecision(t, trace, TraceSitePoolInFlightReuse) + for key, want := range map[string]int{ + "scale_check": 2, + "in_flight": 1, + "reused": 1, + "anonymous_new": 1, + } { + if got := poolTraceFieldInt(t, rec.Fields, key); got != want { + t.Fatalf("%s = %d, want %d", key, got, want) + } + } + + var templateCount int + existing, ok := dsResult.State[sessionName] + if !ok { + t.Fatalf("desired state missing pending-create pool session: keys=%v", mapKeys(dsResult.State)) + } + for _, tp := range dsResult.State { + if tp.TemplateName == template { + templateCount++ + } + } + if templateCount != 2 { + t.Fatalf("desired %s sessions = %d, want 2; keys=%v", template, templateCount, mapKeys(dsResult.State)) + } + var anonymousNew *TemplateParams + for name, tp := range dsResult.State { + if tp.TemplateName == template && name != sessionName { + tpCopy := tp + anonymousNew = &tpCopy + break + } + } + if anonymousNew == nil { + t.Fatalf("desired state missing anonymous new pool session: keys=%v", mapKeys(dsResult.State)) + } + if existing.InstanceName != "worker-1" { + t.Fatalf("existing InstanceName = %q, want worker-1", existing.InstanceName) + } + if existing.PoolSlot != 1 { + t.Fatalf("existing PoolSlot = %d, want 1", existing.PoolSlot) + } + if anonymousNew.PoolSlot != 2 { + t.Fatalf("anonymous new PoolSlot = %d, want 2", anonymousNew.PoolSlot) + } +} + +func TestBuildDesiredState_LegacyAliaslessEphemeralPoolSessionFallsBackToSessionNameIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "legacy ant", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:demo/ant"}, + Metadata: map[string]string{ + "template": "demo/ant", + "agent_name": "demo/ant", + "session_name": "s-gc-legacy", + "session_origin": "ephemeral", + "state": "creating", + "work_dir": filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "ant"), + }, + }); err != nil { + t.Fatalf("create session bead: %v", err) + } + cfg := &config.City{ + Rigs: []config.Rig{{Name: "demo", Path: filepath.Join(cityPath, "repos", "demo")}}, + Agents: []config.Agent{{ + Name: "ant", + Dir: "demo", + Provider: "test-agent", + StartCommand: "true", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }}, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["s-gc-legacy"] + if !ok { + t.Fatalf("desired state missing legacy session: keys=%v", mapKeys(dsResult.State)) + } + if got.InstanceName != "demo/s-gc-legacy" { + t.Fatalf("InstanceName = %q, want %q", got.InstanceName, "demo/s-gc-legacy") + } + wantWorkDir := filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "s-gc-legacy") + if got.WorkDir != wantWorkDir { + t.Fatalf("WorkDir = %q, want %q", got.WorkDir, wantWorkDir) + } +} + +func TestBuildDesiredState_RediscoveriesUniqueLegacyLocalPoolTemplate(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "worker", + "session_name": "worker-5", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(1)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["worker-5"] + if !ok { + t.Fatalf("desired state missing legacy local session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "frontend/worker" { + t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "frontend/worker") + } +} + +func TestBuildDesiredState_DoesNotRediscoverAmbiguousLegacyLocalPoolTemplate(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "worker", + "session_name": "worker-5", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if _, ok := dsResult.State["worker-5"]; ok { + t.Fatalf("desired state %#v unexpectedly rediscovered ambiguous local pool template", dsResult.State["worker-5"]) + } +} + +func TestBuildDesiredState_RecoversPoolTemplateFromAgentNameOnlyLegacyLocalIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "agent_name": "worker-5", + "session_name": "worker-5", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(1)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["worker-5"] + if !ok { + t.Fatalf("desired state missing agent_name-only legacy session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "frontend/worker" { + t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "frontend/worker") + } +} + +func TestBuildDesiredState_DoesNotRecoverPoolTemplateFromAmbiguousLegacyLocalAlias(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "alias": "worker-5", + "session_name": "worker-5", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if _, ok := dsResult.State["worker-5"]; ok { + t.Fatalf("desired state %#v unexpectedly recovered ambiguous local alias identity", dsResult.State["worker-5"]) + } +} + +func TestBuildDesiredState_RediscoveriesLegacyCommonNamePoolTemplate(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "common_name": "worker", + "session_name": "worker-5", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(1)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + got, ok := dsResult.State["worker-5"] + if !ok { + t.Fatalf("desired state missing legacy common_name session: keys=%v", mapKeys(dsResult.State)) + } + if got.TemplateName != "frontend/worker" { + t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "frontend/worker") + } +} + +func TestBuildDesiredState_DoesNotRediscoverFreshCreatingOutOfBoundsQualifiedPoolIdentity(t *testing.T) { + cityPath := t.TempDir() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "agent_name": "frontend/worker-7", + "session_name": "custom-worker-7", + "state": "creating", + }, + }); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + }, + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + if _, ok := dsResult.State["custom-worker-7"]; ok { + t.Fatalf("desired state %#v unexpectedly kept fresh out-of-bounds qualified pool identity", dsResult.State["custom-worker-7"]) + } +} + +func TestBuildDesiredState_DoesNotRediscoverZeroCapacityQualifiedPoolIdentity(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() if _, err := store.Create(beads.Bead{ - Title: "ops worker", + Title: "worker session", Type: sessionBeadType, - Labels: []string{sessionBeadLabel, "template:ops.worker"}, + Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ - "template": "ops.worker", - "session_name": "ops-worker-1", - "agent_name": "ops.worker", - "state": "active", - "pool_managed": "true", + "agent_name": "frontend/worker-1", + "session_name": "custom-worker-1", + "state": "creating", }, }); err != nil { - t.Fatalf("create session bead: %v", err) + t.Fatal(err) } cfg := &config.City{ - Agents: []config.Agent{{ - Name: "worker", - BindingName: "ops", - WorkDir: ".gc/worktrees/{{.AgentBase}}", - MinActiveSessions: intPtr(0), - MaxActiveSessions: intPtr(2), - ScaleCheck: "printf 1", - }}, + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(0)}, + }, } dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) - var got TemplateParams - found := false - for _, tp := range dsResult.State { - if tp.TemplateName == "ops.worker" { - got = tp - found = true - break - } - } - if !found { - t.Fatalf("desired state missing binding-qualified pool session: keys=%v", mapKeys(dsResult.State)) - } - - wantInstance := cfg.Agents[0].QualifiedInstanceName("worker-1") - if got.InstanceName != wantInstance { - t.Fatalf("InstanceName = %q, want %q", got.InstanceName, wantInstance) - } - if got.Alias != wantInstance { - t.Fatalf("Alias = %q, want %q", got.Alias, wantInstance) - } - if got.Env["GC_AGENT"] != wantInstance { - t.Fatalf("GC_AGENT = %q, want %q", got.Env["GC_AGENT"], wantInstance) - } - wantWorkDir := filepath.Join(cityPath, ".gc", "worktrees", "ops.worker-1") - if got.WorkDir != wantWorkDir { - t.Fatalf("WorkDir = %q, want %q", got.WorkDir, wantWorkDir) + if _, ok := dsResult.State["custom-worker-1"]; ok { + t.Fatalf("desired state %#v unexpectedly kept zero-capacity qualified pool identity", dsResult.State["custom-worker-1"]) } } -func TestBuildDesiredState_PendingCreatePoolSessionUsesConcreteBeadIdentity(t *testing.T) { +func TestBuildDesiredState_DoesNotRediscoverStaleCreatingLegacyPoolTemplate(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() - workDir := filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "ant-adhoc-abc123") if _, err := store.Create(beads.Bead{ - Title: "adhoc ant", + Title: "worker session", Type: sessionBeadType, - Labels: []string{sessionBeadLabel, "template:demo/ant"}, + Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ - "template": "demo/ant", - "session_name": "ant-adhoc-abc123", - "session_name_explicit": boolMetadata(true), - "agent_name": "demo/ant-adhoc-abc123", - "session_origin": "manual", - "pending_create_claim": boolMetadata(true), - "state": "creating", - "work_dir": workDir, + "common_name": "worker", + "session_name": "worker-7", + "state": "creating", + "pending_create_started_at": time.Now().Add(-staleCreatingStateTimeout - time.Minute).UTC().Format(time.RFC3339), }, }); err != nil { - t.Fatalf("create session bead: %v", err) + t.Fatal(err) } cfg := &config.City{ - Rigs: []config.Rig{{Name: "demo", Path: filepath.Join(cityPath, "repos", "demo")}}, - Agents: []config.Agent{{ - Name: "ant", - Dir: "demo", - Provider: "test-agent", - StartCommand: "true", - WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", - MinActiveSessions: intPtr(0), - MaxActiveSessions: intPtr(4), - }}, + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + {Name: "worker", Dir: "backend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(1)}, + }, } dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) - got, ok := dsResult.State["ant-adhoc-abc123"] - if !ok { - t.Fatalf("desired state missing pending create session: keys=%v", mapKeys(dsResult.State)) - } - if got.TemplateName != "demo/ant" { - t.Fatalf("TemplateName = %q, want %q", got.TemplateName, "demo/ant") + if _, ok := dsResult.State["worker-7"]; ok { + t.Fatalf("desired state %#v unexpectedly kept stale creating legacy pool bead", dsResult.State["worker-7"]) } - if got.InstanceName != "demo/ant-adhoc-abc123" { - t.Fatalf("InstanceName = %q, want %q", got.InstanceName, "demo/ant-adhoc-abc123") +} + +func TestBuildDesiredState_DoesNotPreserveOutOfBoundsBoundedPoolSlotWithoutIdentity(t *testing.T) { + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", MaxActiveSessions: intPtr(5)}, + }, } - if got.WorkDir != workDir { - t.Fatalf("WorkDir = %q, want %q", got.WorkDir, workDir) + cfgAgent := &cfg.Agents[0] + bead := beads.Bead{ + Metadata: map[string]string{ + "template": "frontend/worker", + "pool_slot": "99", + }, } - if got.Env["GC_ALIAS"] != "demo/ant-adhoc-abc123" { - t.Fatalf("GC_ALIAS = %q, want %q", got.Env["GC_ALIAS"], "demo/ant-adhoc-abc123") + + if slot := existingPoolSlotWithConfig(cfg, cfgAgent, bead); slot != 0 { + t.Fatalf("existingPoolSlotWithConfig(out-of-bounds bounded slot) = %d, want 0", slot) } } -func TestBuildDesiredState_LegacyAliaslessEphemeralPoolSessionFallsBackToSessionNameIdentity(t *testing.T) { +func TestBuildDesiredState_DoesNotRecoverOutOfBoundsAliasOnlyBoundedPoolSlot(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() if _, err := store.Create(beads.Bead{ - Title: "legacy ant", + Title: "worker session", Type: sessionBeadType, - Labels: []string{sessionBeadLabel, "template:demo/ant"}, + Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ - "template": "demo/ant", - "agent_name": "demo/ant", - "session_name": "s-gc-legacy", - "session_origin": "ephemeral", - "state": "creating", - "work_dir": filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "ant"), + "template": "frontend/worker", + "alias": "frontend/worker-7", + "session_name": "custom-worker-7", + "state": "active", }, }); err != nil { - t.Fatalf("create session bead: %v", err) + t.Fatal(err) } cfg := &config.City{ - Rigs: []config.Rig{{Name: "demo", Path: filepath.Join(cityPath, "repos", "demo")}}, - Agents: []config.Agent{{ - Name: "ant", - Dir: "demo", - Provider: "test-agent", - StartCommand: "true", - WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", - MinActiveSessions: intPtr(0), - MaxActiveSessions: intPtr(4), - }}, + Agents: []config.Agent{ + {Name: "worker", Dir: "frontend", Provider: "test-agent", StartCommand: "true", WorkDir: ".", MaxActiveSessions: intPtr(5)}, + }, } dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) - got, ok := dsResult.State["s-gc-legacy"] - if !ok { - t.Fatalf("desired state missing legacy session: keys=%v", mapKeys(dsResult.State)) + if _, ok := dsResult.State["custom-worker-7"]; ok { + t.Fatalf("desired state %#v unexpectedly preserved out-of-bounds alias-only pool identity", dsResult.State["custom-worker-7"]) } - if got.InstanceName != "demo/s-gc-legacy" { - t.Fatalf("InstanceName = %q, want %q", got.InstanceName, "demo/s-gc-legacy") +} + +func TestClaimPoolSlot_PreservesStampedOutOfBoundsLiveIdentity(t *testing.T) { + cfgAgent := &config.Agent{Name: "worker", Dir: "frontend", MaxActiveSessions: intPtr(5)} + bead := beads.Bead{ + Metadata: map[string]string{ + "pool_slot": "7", + "agent_name": "frontend/worker-7", + "alias": "frontend/worker-7", + }, } - wantWorkDir := filepath.Join(cityPath, ".gc", "worktrees", "demo", "ants", "s-gc-legacy") - if got.WorkDir != wantWorkDir { - t.Fatalf("WorkDir = %q, want %q", got.WorkDir, wantWorkDir) + + if slot := existingPoolSlot(cfgAgent, bead); slot != 7 { + t.Fatalf("existingPoolSlot(stamped live slot) = %d, want 7", slot) + } + used := map[int]bool{} + if slot := claimPoolSlot(cfgAgent, bead, used); slot != 7 { + t.Fatalf("claimPoolSlot(stamped live slot) = %d, want 7", slot) } } @@ -2346,6 +4429,40 @@ func TestSelectOrCreatePoolSessionBead_SkipsDrained(t *testing.T) { } } +func TestSelectOrCreatePoolSessionBead_UsesFreshCreateTimeNotBeaconTime(t *testing.T) { + store := beads.NewMemStore() + snapshot := &sessionBeadSnapshot{} + cfgAgent := config.Agent{Name: "claude", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)} + anchor := time.Now().UTC() + oldBeacon := anchor.Add(-2 * staleCreatingStateTimeout) + beforeCreate := anchor.Add(-time.Second) + bp := &agentBuildParams{ + beadStore: store, + sessionBeads: snapshot, + agents: []config.Agent{cfgAgent}, + beaconTime: oldBeacon, + } + + result, err := selectOrCreatePoolSessionBead(bp, "claude", nil, map[string]bool{}) + if err != nil { + t.Fatalf("selectOrCreatePoolSessionBead: %v", err) + } + startedAt, err := time.Parse(time.RFC3339, result.Metadata["pending_create_started_at"]) + if err != nil { + t.Fatalf("parse pending_create_started_at %q: %v", result.Metadata["pending_create_started_at"], err) + } + if startedAt.Before(beforeCreate) { + t.Fatalf("pending_create_started_at = %s, want current create time after %s", startedAt, beforeCreate) + } + if !startedAt.After(oldBeacon.Add(staleCreatingStateTimeout)) { + t.Fatalf("pending_create_started_at = %s, want independent from stale beacon %s", startedAt, oldBeacon) + } + result.CreatedAt = oldBeacon + if staleCreatingState(result, &clock.Fake{Time: startedAt.Add(30 * time.Second)}) { + t.Fatal("fresh pool session was stale when row CreatedAt matched old controller beacon") + } +} + func TestSelectOrCreatePoolSessionBead_ReusesPreferredDrained(t *testing.T) { store := beads.NewMemStore() drained, err := store.Create(beads.Bead{ @@ -2455,6 +4572,46 @@ func TestSelectOrCreatePoolSessionBead_ReusesAvailableForNewTier(t *testing.T) { } } +func TestSelectOrCreatePoolSessionBead_SkipsAssignedForNewTier(t *testing.T) { + store := beads.NewMemStore() + assigned, err := store.Create(beads.Bead{ + Title: "claude", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "claude", + "agent_name": "claude", + "session_name": "claude-assigned", + "state": "active", + "pool_managed": "true", + }, + }) + if err != nil { + t.Fatal(err) + } + snapshot := &sessionBeadSnapshot{} + snapshot.add(assigned) + cfgAgent := config.Agent{Name: "claude", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)} + bp := &agentBuildParams{ + beadStore: store, + sessionBeads: snapshot, + agents: []config.Agent{cfgAgent}, + assignedWorkBeads: []beads.Bead{{ + ID: "w-assigned", + Status: "in_progress", + Assignee: assigned.ID, + }}, + } + + result, err := selectOrCreatePoolSessionBead(bp, "claude", nil, map[string]bool{}) + if err != nil { + t.Fatalf("selectOrCreatePoolSessionBead: %v", err) + } + if result.ID == assigned.ID { + t.Fatal("new-tier should not reuse a session bead that has assigned work") + } +} + func TestSelectOrCreatePoolSessionBead_SkipsAsleepBeads(t *testing.T) { // An asleep pool session should NOT be reused for new demand. // The reconciler should create a fresh session instead. @@ -2470,7 +4627,7 @@ func TestSelectOrCreatePoolSessionBead_SkipsAsleepBeads(t *testing.T) { Labels: []string{sessionBeadLabel, "template:polecat"}, Metadata: map[string]string{ "template": "polecat", - "session_name": "polecat-mc-old", + "session_name": "polecat-real-world-app-old", "state": "asleep", "pool_managed": "true", }, @@ -2506,7 +4663,7 @@ func TestSelectOrCreatePoolSessionBead_ReusesActiveBeforeCreatingNew(t *testing. Labels: []string{sessionBeadLabel, "template:polecat"}, Metadata: map[string]string{ "template": "polecat", - "session_name": "polecat-mc-live", + "session_name": "polecat-real-world-app-live", "state": "active", "pool_managed": "true", }, @@ -2542,7 +4699,7 @@ func TestSelectOrCreatePoolSessionBead_ReusesCreatingBeforeCreatingNew(t *testin Labels: []string{sessionBeadLabel, "template:polecat"}, Metadata: map[string]string{ "template": "polecat", - "session_name": "polecat-mc-new", + "session_name": "polecat-real-world-app-new", "state": "creating", "pool_managed": "true", }, @@ -2579,7 +4736,7 @@ func TestSelectOrCreatePoolSessionBead_SkipsAsleepButReusesActive(t *testing.T) Labels: []string{sessionBeadLabel, "template:polecat"}, Metadata: map[string]string{ "template": "polecat", - "session_name": "polecat-mc-old", + "session_name": "polecat-real-world-app-old", "state": "asleep", "pool_managed": "true", }, @@ -2593,7 +4750,7 @@ func TestSelectOrCreatePoolSessionBead_SkipsAsleepButReusesActive(t *testing.T) Labels: []string{sessionBeadLabel, "template:polecat"}, Metadata: map[string]string{ "template": "polecat", - "session_name": "polecat-mc-live", + "session_name": "polecat-real-world-app-live", "state": "active", "pool_managed": "true", }, @@ -2860,6 +5017,82 @@ func TestEnsureDependencyOnlyTemplate_StoreBackedUsesInstanceIdentity(t *testing } } +func TestBuildDesiredState_DependencyFloorIgnoresConfigBlindLegacySlotRecovery(t *testing.T) { + cityPath := t.TempDir() + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + { + Name: "db", + Dir: "gascity", + StartCommand: "true", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + ScaleCheck: "printf 0", + }, + { + Name: "api", + Dir: "gascity", + StartCommand: "true", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(3), + ScaleCheck: "printf 0", + DependsOn: []string{"gascity/db"}, + }, + }, + } + + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "api", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "template:gascity/api"}, + Metadata: map[string]string{ + "template": "gascity/api", + "agent_name": "gascity/api", + "session_name": "s-api-root", + "state": "active", + "pool_managed": "true", + "pool_slot": "1", + }, + }); err != nil { + t.Fatalf("seed api root bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Title: "db", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "agent_name": "db-2", + "session_name": "s-db-dep-legacy", + "state": "active", + "dependency_only": "true", + "pool_managed": "true", + }, + }); err != nil { + t.Fatalf("seed dependency-only db bead: %v", err) + } + + dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) + + var tp TemplateParams + var found bool + for _, entry := range dsResult.State { + if entry.TemplateName == "gascity/db" && entry.DependencyOnly { + tp = entry + found = true + break + } + } + if !found { + t.Fatalf("store-backed dependency floor for db not found: %+v", dsResult.State) + } + + if got, want := tp.Env["GC_ALIAS"], "gascity/db-1"; got != want { + t.Fatalf("store-backed dep-floor GC_ALIAS = %q, want %q when legacy bead lacks matching template metadata", got, want) + } +} + // TestBuildDesiredState_PoolBeadIdentityAgreesAcrossRealizeAndCanonicalHelper // is the round-trip regression for PR #833's canonicalization. It locks in the // actual invariant the fix promises: a pool-managed session bead produces the @@ -3030,11 +5263,7 @@ func TestBuildDesiredState_RigScopedScaleCheckExpandsRigTemplate(t *testing.T) { } } -// TestBuildDesiredState_NamedSessionWorkQueryExpandsRigTemplate verifies that -// {{.Rig}} in a named-session agent's work_query is substituted before the -// controller's work-readiness probe runs — regression test for #793, named -// session path at build_desired_state.go:341. -func TestBuildDesiredState_NamedSessionWorkQueryExpandsRigTemplate(t *testing.T) { +func TestBuildDesiredState_NamedSessionWorkQueryDoesNotDriveControllerDemand(t *testing.T) { cityPath := t.TempDir() rigDir := filepath.Join(cityPath, "alpha") if err := os.MkdirAll(rigDir, 0o755); err != nil { @@ -3050,11 +5279,7 @@ func TestBuildDesiredState_NamedSessionWorkQueryExpandsRigTemplate(t *testing.T) StartCommand: "true", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(1), - // work_query must produce non-empty output for on_demand demand. - // When {{.Rig}} is expanded the echo yields "alpha", which is - // treated as ready work. Unexpanded, the literal "{{.Rig}}" is - // still non-empty — so to discriminate, use a grep filter. - WorkQuery: "echo {{.Rig}} | grep alpha", + WorkQuery: "echo {{.Rig}} | grep alpha", }}, NamedSessions: []config.NamedSession{{ Template: "alpha/dog", @@ -3064,7 +5289,7 @@ func TestBuildDesiredState_NamedSessionWorkQueryExpandsRigTemplate(t *testing.T) dsResult := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), store, io.Discard) - if !dsResult.NamedSessionDemand["alpha/dog"] { - t.Errorf("NamedSessionDemand[alpha/dog] = false, want true (work_query {{.Rig}} should expand to alpha and grep match)") + if dsResult.NamedSessionDemand["alpha/dog"] { + t.Fatal("NamedSessionDemand[alpha/dog] came from controller-side work_query") } } diff --git a/cmd/gc/city_layout.go b/cmd/gc/city_layout.go index 19cc886a6a..42a30631c1 100644 --- a/cmd/gc/city_layout.go +++ b/cmd/gc/city_layout.go @@ -1,9 +1,7 @@ package main import ( - "path/filepath" - - "github.com/gastownhall/gascity/internal/citylayout" + "github.com/gastownhall/gascity/internal/cityinit" "github.com/gastownhall/gascity/internal/fsys" ) @@ -12,52 +10,17 @@ func ensureCityScaffold(cityPath string) error { } func ensureCityScaffoldFS(fs fsys.FS, cityPath string) error { - for _, rel := range []string{ - citylayout.RuntimeRoot, - citylayout.CacheRoot, - citylayout.SystemRoot, - filepath.Join(citylayout.RuntimeRoot, "runtime"), - } { - if err := fs.MkdirAll(filepath.Join(cityPath, rel), 0o755); err != nil { - return err - } - } - // Touch events.jsonl so gc doctor doesn't warn and events are ready. - eventsPath := filepath.Join(cityPath, citylayout.RuntimeRoot, "events.jsonl") - if _, err := fs.Stat(eventsPath); err != nil { - _ = fs.WriteFile(eventsPath, nil, 0o644) - } - return nil + return cityinit.EnsureCityScaffoldFS(fs, cityPath) } func cityAlreadyInitializedFS(fs fsys.FS, cityPath string) bool { - if fi, err := fs.Stat(filepath.Join(cityPath, citylayout.CityConfigFile)); err == nil && !fi.IsDir() { - return true - } - return cityHasScaffoldFS(fs, cityPath) + return cityinit.CityAlreadyInitializedFS(fs, cityPath) } func cityHasScaffoldFS(fs fsys.FS, cityPath string) bool { - requiredDirs := []string{ - filepath.Join(cityPath, citylayout.RuntimeRoot), - filepath.Join(cityPath, citylayout.RuntimeRoot, "cache"), - filepath.Join(cityPath, citylayout.RuntimeRoot, "runtime"), - filepath.Join(cityPath, citylayout.RuntimeRoot, "system"), - } - for _, dir := range requiredDirs { - fi, err := fs.Stat(dir) - if err != nil || !fi.IsDir() { - return false - } - } - fi, err := fs.Stat(filepath.Join(cityPath, citylayout.RuntimeRoot, "events.jsonl")) - return err == nil && !fi.IsDir() + return cityinit.CityHasScaffoldFS(fs, cityPath) } func cityCanResumeInitFS(fs fsys.FS, cityPath string) bool { - fi, err := fs.Stat(filepath.Join(cityPath, citylayout.CityConfigFile)) - if err != nil || fi.IsDir() { - return false - } - return cityHasScaffoldFS(fs, cityPath) + return cityinit.CityCanResumeInitFS(fs, cityPath) } diff --git a/cmd/gc/city_registry.go b/cmd/gc/city_registry.go index ed1fbd7486..5d3a8e16c5 100644 --- a/cmd/gc/city_registry.go +++ b/cmd/gc/city_registry.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "io" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/events" + "github.com/gastownhall/gascity/internal/pathutil" "github.com/gastownhall/gascity/internal/supervisor" ) @@ -58,9 +60,12 @@ type cityRegistry struct { snap atomic.Pointer[citySnapshot] // init/backoff state (co-protected by citiesMu) - initStatus map[string]cityInitProgress - initFailures map[string]*initFailRecord - panicHistory map[string]*panicRecord + initStatus map[string]cityInitProgress + initFailures map[string]*initFailRecord + panicHistory map[string]*panicRecord + pendingRequestIDs map[string]string // city path → request_id for async correlation + recentlyUnregistered map[string]time.Time // city path → unregister time (grace period for event delivery) + supervisorRecorder events.Recorder // supervisor-level event recorder for city lifecycle events gen uint64 // monotonic generation counter } @@ -68,10 +73,12 @@ type cityRegistry struct { // newCityRegistry creates a registry initialized with an empty snapshot. func newCityRegistry() *cityRegistry { r := &cityRegistry{ - cities: make(map[string]*managedCity), - initStatus: make(map[string]cityInitProgress), - initFailures: make(map[string]*initFailRecord), - panicHistory: make(map[string]*panicRecord), + cities: make(map[string]*managedCity), + initStatus: make(map[string]cityInitProgress), + initFailures: make(map[string]*initFailRecord), + panicHistory: make(map[string]*panicRecord), + pendingRequestIDs: make(map[string]string), + recentlyUnregistered: make(map[string]time.Time), } // Initialize with empty snapshot to prevent nil-dereference panic // if an API request arrives before the first reconciliation tick. @@ -85,6 +92,74 @@ func newCityRegistry() *cityRegistry { return r } +// StorePendingRequestID stores a request_id for async correlation. +func (r *cityRegistry) StorePendingRequestID(cityPath, requestID string) error { + key := pendingRequestKey(cityPath) + if err := supervisor.NewRegistry(supervisor.RegistryPath()).StorePendingCityRequestID(key, requestID); err != nil { + if errors.Is(err, supervisor.ErrPendingCityRequestExists) { + return api.ErrPendingRequestExists + } + return err + } + + r.citiesMu.Lock() + r.pendingRequestIDs[key] = requestID + r.citiesMu.Unlock() + return nil +} + +// ConsumePendingRequestID returns and removes the pending request_id for a city path. +func (r *cityRegistry) ConsumePendingRequestID(cityPath string) (string, bool, error) { + key := pendingRequestKey(cityPath) + r.citiesMu.Lock() + id, ok := r.pendingRequestIDs[key] + if ok { + if _, _, err := supervisor.NewRegistry(supervisor.RegistryPath()).ConsumePendingCityRequestID(key); err != nil { + r.citiesMu.Unlock() + return id, true, err + } + delete(r.pendingRequestIDs, key) + r.citiesMu.Unlock() + return id, true, nil + } + r.citiesMu.Unlock() + + id, ok, err := supervisor.NewRegistry(supervisor.RegistryPath()).ConsumePendingCityRequestID(key) + if err != nil { + return "", false, err + } + return id, ok, nil +} + +func pendingRequestKey(cityPath string) string { + return pathutil.NormalizePathForCompare(cityPath) +} + +// SetSupervisorRecorder installs the supervisor-level event recorder. +func (r *cityRegistry) SetSupervisorRecorder(rec events.Recorder) { + r.citiesMu.Lock() + defer r.citiesMu.Unlock() + r.supervisorRecorder = rec +} + +// SupervisorEventRecorder returns the supervisor-level event recorder. +func (r *cityRegistry) SupervisorEventRecorder() events.Recorder { + r.citiesMu.Lock() + defer r.citiesMu.Unlock() + return r.supervisorRecorder +} + +// MarkRecentlyUnregistered records a city path for transient event +// provider inclusion so SSE clients can observe completion events +// after the city is removed from the registry. +func (r *cityRegistry) MarkRecentlyUnregistered(cityPath string) { + r.citiesMu.Lock() + defer r.citiesMu.Unlock() + r.recentlyUnregistered[cityPath] = time.Now() +} + +const recentlyUnregisteredGrace = 2 * time.Minute + // Add inserts or replaces a city. Caller must not hold citiesMu. func (r *cityRegistry) Add(path string, mc *managedCity) { r.citiesMu.Lock() @@ -236,6 +311,26 @@ func (r *cityRegistry) TransientCityEventProviders() map[string]events.Provider } } + // Include recently-unregistered cities so SSE clients can + // observe completion events after the city leaves the registry. + r.citiesMu.Lock() + now := time.Now() + for path, ts := range r.recentlyUnregistered { + if now.Sub(ts) > recentlyUnregisteredGrace { + delete(r.recentlyUnregistered, path) + continue + } + name := filepath.Base(path) + if _, already := running[name]; already { + continue + } + if _, already := paths[name]; already { + continue + } + paths[name] = path + } + r.citiesMu.Unlock() + out := make(map[string]events.Provider, len(paths)) for name, path := range paths { evPath := filepath.Join(path, ".gc", "events.jsonl") diff --git a/cmd/gc/city_registry_test.go b/cmd/gc/city_registry_test.go index a35623b3e8..52a7651298 100644 --- a/cmd/gc/city_registry_test.go +++ b/cmd/gc/city_registry_test.go @@ -1,13 +1,16 @@ package main import ( + "errors" "io" "os" "path/filepath" "sync" + "syscall" "testing" "time" + "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/supervisor" ) @@ -26,6 +29,123 @@ func TestCityRegistryEmptySnapshot(t *testing.T) { } } +func TestCityRegistryPendingRequestIDCanonicalizesPath(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + reg := newCityRegistry() + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + linkPath := filepath.Join(t.TempDir(), "city-link") + if err := os.Symlink(cityPath, linkPath); err != nil { + t.Fatal(err) + } + + if err := reg.StorePendingRequestID(linkPath, "req-city"); err != nil { + t.Fatal(err) + } + + got, ok, err := reg.ConsumePendingRequestID(cityPath) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("pending request ID not found by canonical path") + } + if got != "req-city" { + t.Fatalf("request ID = %q, want req-city", got) + } +} + +func TestCityRegistryStorePendingRequestIDRejectsDuplicatePath(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + reg := newCityRegistry() + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + + if err := reg.StorePendingRequestID(cityPath, "req-first"); err != nil { + t.Fatal(err) + } + err := reg.StorePendingRequestID(cityPath, "req-second") + if !errors.Is(err, api.ErrPendingRequestExists) { + t.Fatalf("StorePendingRequestID duplicate error = %v, want ErrPendingRequestExists", err) + } + + got, ok, err := reg.ConsumePendingRequestID(cityPath) + if err != nil { + t.Fatal(err) + } + if !ok || got != "req-first" { + t.Fatalf("consumed pending request = (%q, %t), want req-first true", got, ok) + } +} + +func TestCityRegistryConsumePendingRequestIDIsAtomic(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + reg := newCityRegistry() + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := reg.StorePendingRequestID(cityPath, "req-city"); err != nil { + t.Fatal(err) + } + + lockPath := supervisor.RegistryPath() + ".lock" + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lockFile.Close() //nolint:errcheck + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + + type result struct { + id string + ok bool + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + for range 2 { + go func() { + <-start + id, ok, err := reg.ConsumePendingRequestID(cityPath) + results <- result{id: id, ok: ok, err: err} + }() + } + + close(start) + time.Sleep(50 * time.Millisecond) + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + + first := <-results + second := <-results + if first.err != nil { + t.Fatal(first.err) + } + if second.err != nil { + t.Fatal(second.err) + } + consumed := 0 + for _, got := range []result{first, second} { + if got.ok { + consumed++ + if got.id != "req-city" { + t.Fatalf("request ID = %q, want req-city", got.id) + } + } + } + if consumed != 1 { + t.Fatalf("consumed request ID %d times, want exactly once; first=%+v second=%+v", consumed, first, second) + } +} + func TestCityRegistryAddRemove(t *testing.T) { reg := newCityRegistry() diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index 828df44b2d..e85c774e02 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -27,6 +27,15 @@ import ( "github.com/gastownhall/gascity/internal/workspacesvc" ) +// reloadOrderDrainTimeout bounds how long config reload will wait for +// the outgoing order dispatcher's in-flight goroutines before replacing +// it. Reload runs on the tick loop, so a larger budget would stall all +// other subsystems. Dispatchers that do not drain within this budget are +// retained and drained again during controller shutdown; orphan tracking +// beads are still compensated by the next startup sweep if shutdown also +// cannot wait long enough. +const reloadOrderDrainTimeout = 1 * time.Second + // CityRuntime holds all running state for a single city's reconciliation // loop. It encapsulates the per-city lifecycle that was previously spread // across runController and controllerLoop. A machine-wide supervisor can @@ -49,12 +58,15 @@ type CityRuntime struct { buildFn func(*config.City, runtime.Provider, beads.Store) DesiredStateResult buildFnWithSessionBeads func(*config.City, runtime.Provider, beads.Store, map[string]beads.Store, *sessionBeadSnapshot, *sessionReconcilerTraceCycle) DesiredStateResult - dops drainOps - ct crashTracker - it idleTracker - wg wispGC - od orderDispatcher - trace *sessionReconcilerTraceManager + dops drainOps + ct crashTracker + it idleTracker + wg wispGC + od orderDispatcher + retiredOrderDispatchers []orderDispatcher + trace *sessionReconcilerTraceManager + + orderSweepWatchdogLast time.Time rec events.Recorder cs *controllerState // nil when controller-managed bead stores are unavailable @@ -68,8 +80,10 @@ type CityRuntime struct { standaloneRigStores map[string]beads.Store // Bead-driven reconciler state (Phase 2f). - sessionDrains *drainTracker // in-memory drain tracker; nil when bead reconciler disabled - demandSnapshot *runtimeDemandSnapshot + sessionDrains *drainTracker // in-memory drain tracker; nil when bead reconciler disabled + asyncStartLimiter *asyncStartLimiter + asyncStarts asyncStartTracker + demandSnapshot *runtimeDemandSnapshot convHandler *convergence.Handler // nil until bead store available convStoreAdapter *convergenceStoreAdapter // typed reference; avoids type assertions in tick/reconcile @@ -81,9 +95,10 @@ type CityRuntime struct { onStarted func() onStatus func(string) - shutdownOnce sync.Once - logPrefix string // "gc start" or "gc supervisor" - stdout, stderr io.Writer + shutdownOnce sync.Once + preserveSessionsShutdown atomic.Bool + logPrefix string // "gc start" or "gc supervisor" + stdout, stderr io.Writer } const runtimeDemandSnapshotMaxAge = 30 * time.Second @@ -204,6 +219,7 @@ func newCityRuntime(p CityRuntimeParams) *CityRuntime { poolSessions: p.PoolSessions, poolDeathHandlers: p.PoolDeathHandlers, suspendedNames: suspendedNames, + asyncStartLimiter: newAsyncStartLimiter(maxParallelStartsPerTick(p.Cfg)), convergenceReqCh: p.ConvergenceReqCh, reloadReqCh: func() chan reloadRequest { if p.ReloadReqCh != nil { @@ -374,6 +390,21 @@ func (cr *CityRuntime) run(ctx context.Context) { return } + cr.applyStartupConfigReload(ctx, dirty, &lastProviderName, cityRoot) + if ctx.Err() != nil { + return + } + + // Dispatch due orders before startup session reconciliation. A cold-start + // reconcile can take minutes when it has stale or config-drifted sessions; + // due event/condition formulas should not wait behind that maintenance work. + cr.safeTick(func() { + cr.dispatchOrders(ctx, cityRoot) + }, "startup-orders") + if ctx.Err() != nil { + return + } + // Session bead sync BEFORE reconciliation: ensures beads exist for // the reconciler to read/write hashes. Uses ListByLabel (indexed, // fast even before CachingStore is primed). @@ -395,6 +426,7 @@ func (cr *CityRuntime) run(ctx context.Context) { } }() + cleanupDeadRuntimeSessionCorpses(sessionBeads, cr.sessionDrains, cr.sp, cr.stderr) // Reap stale session beads from a previous run before building desired // state, so desired state does not reference already-closed beads (#742). if reapStaleSessionBeads(cr.cityBeadStore(), cr.sp, cr.sessionDrains, clock.Real{}, cr.stderr) > 0 { @@ -668,12 +700,17 @@ func (cr *CityRuntime) tick( } } + // Order dispatch is intentionally before the expensive session reconcile + // phases so due formulas are not starved by slow startup/config drift work. + cr.dispatchOrders(ctx, cityRoot) + // Session bead sync BEFORE reconciliation (one-tick state lag; see run()). // Post-reconcile sync was intentionally removed: the daemon's next tick // corrects bead state, and the pre-reconcile sync is sufficient for // the reconciler to read/write hashes during reconciliation. // Reap open session beads whose tmux session is dead before loading demand // so stale names cannot block desired-state computation (#742). + cleanupDeadRuntimeSessionCorpses(sessionBeads, cr.sessionDrains, cr.sp, cr.stderr) if reapStaleSessionBeads(cr.cityBeadStore(), cr.sp, cr.sessionDrains, clock.Real{}, cr.stderr) > 0 { sessionBeads = cr.loadSessionBeadSnapshot() } @@ -728,11 +765,6 @@ func (cr *CityRuntime) tick( } } - // Order dispatch. - if cr.od != nil { - cr.od.dispatch(ctx, cityRoot, time.Now()) - } - if cr.svc != nil { cr.svc.Tick(ctx, time.Now()) } @@ -757,6 +789,42 @@ func (cr *CityRuntime) tick( tickCompleted = true } +func (cr *CityRuntime) dispatchOrders(ctx context.Context, cityRoot string) { + if ctx.Err() != nil { + return + } + now := time.Now() + cr.runOrderTrackingSweepWatchdog(now) + if cr.od != nil { + cr.od.dispatch(ctx, cityRoot, now) + } +} + +func (cr *CityRuntime) runOrderTrackingSweepWatchdog(now time.Time) { + if !cr.orderSweepWatchdogLast.IsZero() && now.Sub(cr.orderSweepWatchdogLast) < orderTrackingSweepWatchdogInterval { + return + } + cr.orderSweepWatchdogLast = now + + store := cr.cityBeadStore() + if store == nil { + return + } + onlyOrders := map[string]struct{}{ + orderTrackingSweepOrder: {}, + } + n, err := sweepStaleOrderTracking(store, now, orderTrackingSweepWatchdogStaleAfter, onlyOrders, orderTrackingWatchdogMetadataInitiator) + if err != nil { + if cr.stderr != nil { + fmt.Fprintf(cr.stderr, "%s: order tracking sweep watchdog: %v\n", cr.logPrefix, err) //nolint:errcheck // best-effort stderr + } + return + } + if n > 0 && cr.stderr != nil { + fmt.Fprintf(cr.stderr, "%s: order tracking sweep watchdog closed %d stale tracking bead(s)\n", cr.logPrefix, n) //nolint:errcheck // best-effort stderr + } +} + func (cr *CityRuntime) handleReloadRequest(req *reloadRequest) { if req == nil { return @@ -817,6 +885,24 @@ func (cr *CityRuntime) reloadConfig( cr.reloadConfigTraced(ctx, lastProviderName, cityRoot, nil, reloadSourceWatch) } +func (cr *CityRuntime) applyStartupConfigReload( + ctx context.Context, + dirty *atomic.Bool, + lastProviderName *string, + cityRoot string, +) { + if cr.tomlPath == "" || cityRoot == "" || cr.configRev == "" || lastProviderName == nil || ctx.Err() != nil { + return + } + if dirty != nil { + dirty.Swap(false) + } + reply := cr.reloadConfigTraced(ctx, lastProviderName, cityRoot, nil, reloadSourceWatch) + if reply.Outcome == reloadOutcomeFailed && dirty != nil { + dirty.Store(true) + } +} + func (cr *CityRuntime) reloadConfigTraced( ctx context.Context, lastProviderName *string, @@ -899,12 +985,6 @@ func (cr *CityRuntime) reloadConfigTraced( } } - // System formulas/orders now arrive via the core bootstrap pack. - // gc-beads-bd ships inside the bd pack's assets/scripts/ and is - // materialized alongside the rest of the pack content. - if err := MaterializeBuiltinPacks(cityRoot); err != nil { - appendWarning(fmt.Sprintf("config reload: materializing builtin packs: %v", err)) - } if err := config.ValidateRigs(nextCfg.Rigs, config.EffectiveHQPrefix(nextCfg)); err != nil { appendWarning(fmt.Sprintf("config reload: %v", err)) } @@ -1026,6 +1106,21 @@ func (cr *CityRuntime) reloadConfigTraced( cr.wg = nil } + // Drain the outgoing dispatcher before replacing it so in-flight + // dispatchOne goroutines persist their tracking-bead outcomes against + // the store they were scheduled against. Reload runs on the same + // goroutine as tick, so no concurrent dispatch can create a new + // in-flight signal on this dispatcher while drain observes it. The + // reload budget is capped at reloadOrderDrainTimeout so a wedged exec + // order cannot stall the tick loop; timed-out dispatchers are retained + // and drained again during shutdown. + // Deriving from ctx (the tick ctx) lets a shutdown racing with reload + // short-circuit the drain instead of waiting the full 1s. + if cr.od != nil { + drainCtx, drainCancel := context.WithTimeout(ctx, reloadOrderDrainTimeout) + cr.drainOutgoingOrderDispatcher(drainCtx, cr.od) + drainCancel() + } cr.od = buildOrderDispatcher(cityRoot, nextCfg, cr.rec, cr.stderr) cr.serviceStateMu.Lock() @@ -1035,7 +1130,7 @@ func (cr *CityRuntime) reloadConfigTraced( cr.serviceStateMu.Unlock() if cr.cs != nil { - cr.cs.update(nextCfg, nextSp) + cr.cs.updateFromRuntime(nextCfg, nextSp, result.Revision) } if cr.svc != nil { if err := cr.svc.Reload(); err != nil { @@ -1149,15 +1244,19 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat } if sessionBeads == nil { - sessionBeads = cr.loadSessionBeadSnapshot() + var sessionQueryPartial bool + sessionBeads, sessionQueryPartial = cr.loadSessionBeadSnapshotWithPartial() + result.SessionQueryPartial = result.SessionQueryPartial || sessionQueryPartial } rigStores := cr.rigBeadStores() assignedWorkBeads := result.AssignedWorkBeads - if released := releaseOrphanedPoolAssignments(store, cr.cfg, sessionBeads.Open(), assignedWorkBeads, result.AssignedWorkStores); len(released) > 0 { + assignedWorkStoreRefs := result.AssignedWorkStoreRefs + released := releaseOrphanedPoolAssignmentsWhenSnapshotsComplete(store, cr.cfg, cr.cityPath, sessionBeads.Open(), result, rigStores) + if len(released) > 0 { for _, r := range released { fmt.Fprintf(cr.stderr, "released orphaned pool work: %s\n", r.ID) //nolint:errcheck } - assignedWorkBeads = filterReleasedAssignedWorkBeads(assignedWorkBeads, released) + assignedWorkBeads, assignedWorkStoreRefs = filterReleasedAssignedWorkSnapshot(assignedWorkBeads, assignedWorkStoreRefs, released) } // poolDesired determines how many sessions should be AWAKE. Uses the // same scale_check counts that buildDesiredState already computed (no @@ -1165,8 +1264,13 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat // work beads + new tier from scale_check + min fill. poolDesired := result.PoolDesiredCounts if poolDesired == nil { - poolDesired = PoolDesiredCounts(ComputePoolDesiredStatesTraced( - cr.cfg, assignedWorkBeads, sessionBeads.Open(), result.ScaleCheckCounts, trace)) + poolWorkBeads := filterAssignedWorkBeadsForPoolDemand(cr.cfg, cr.cityPath, sessionBeads.Open(), assignedWorkBeads, assignedWorkStoreRefs) + poolDesired = retainScaleCheckPartialPoolDesired( + PoolDesiredCounts(ComputePoolDesiredStatesTraced( + cr.cfg, poolWorkBeads, sessionBeads.Open(), result.ScaleCheckCounts, trace)), + sessionBeads, + result.PoolScaleCheckPartialTemplates, + ) } // Merge named-session assignee demand so on-demand named sessions with // direct work (Assignee match, no gc.routed_to) stay config-eligible. @@ -1191,9 +1295,11 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat desiredState, cr.cfg, cr.sp, - result.StoreQueryPartial, + result.snapshotQueryPartial(), ) > 0 { - sessionBeads = cr.loadSessionBeadSnapshot() + var sessionQueryPartial bool + sessionBeads, sessionQueryPartial = cr.loadSessionBeadSnapshotWithPartial() + result.SessionQueryPartial = result.SessionQueryPartial || sessionQueryPartial } open := sessionBeads.Open() @@ -1202,19 +1308,16 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat cfgNames := configuredSessionNamesWithSnapshot(cr.cfg, cityName, sessionBeads) - readyWaitSet, err := prepareWaitWakeStateForCity(cr.cityPath, store, time.Now()) + readyWaitSet, err := prepareWaitWakeStateForCityWithSnapshot(cr.cityPath, store, time.Now(), sessionBeads) if err != nil { fmt.Fprintf(cr.stderr, "%s: preparing waits: %v\n", cr.logPrefix, err) //nolint:errcheck readyWaitSet = nil } - // workSet: defense-in-depth wake signal from work_query. When work_query - // detects pending work but scale_check hasn't caught up yet, workSet - // ensures at least one session wakes without waiting for the next tick. - workSet := result.WorkSet - if workSet == nil { - workSet = computeWorkSet(cr.cfg, shellScaleCheck, cityName, cr.cityPath, store, sessionBeads, cr.stderr) - } + // Controller wake demand comes from assigned-work scans and scale_check. + // work_query remains the agent-side gc hook claim path; running every + // work_query here can block assigned-work resumes behind unrelated probes. + workSet := make(map[string]bool) if trace != nil { templateNames := make(map[string]struct{}) openCounts := make(map[string]int) @@ -1259,13 +1362,19 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat }) } trace.RecordCycleInputSnapshot(map[string]any{ - "desired_session_count": len(desiredState), - "open_session_count": len(open), - "scale_check_counts": result.ScaleCheckCounts, - "pool_desired": poolDesired, - "ready_wait_count": len(readyWaitSet), - "work_set_count": len(workSet), - "store_query_partial": result.StoreQueryPartial, + "desired_session_count": len(desiredState), + "open_session_count": len(open), + "scale_check_counts": result.ScaleCheckCounts, + "pool_desired": poolDesired, + "ready_wait_count": len(readyWaitSet), + "work_set_count": len(workSet), + "store_query_partial": result.StoreQueryPartial, + "scale_check_query_partial": len(result.ScaleCheckPartialTemplates) > 0, + "scale_check_partial_templates": sortedBoolMapKeys(result.ScaleCheckPartialTemplates), + "pool_scale_check_partial_templates": sortedBoolMapKeys(result.PoolScaleCheckPartialTemplates), + "named_scale_check_partial_templates": sortedBoolMapKeys(result.NamedScaleCheckPartialTemplates), + "session_query_partial": result.SessionQueryPartial, + "snapshot_query_partial": result.snapshotQueryPartial(), }) for _, agent := range cr.cfg.Agents { template := agent.QualifiedName() @@ -1292,15 +1401,21 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat } } - reconcileSessionBeadsTraced( + awakeAssignedWorkBeads := filterAssignedWorkBeadsForSessionWake(cr.cfg, cr.cityPath, open, assignedWorkBeads, assignedWorkStoreRefs) + reconcileSessionBeadsTracedWithNamedDemand( ctx, cr.cityPath, open, desiredState, cfgNames, cr.cfg, cr.sp, store, cr.dops, - assignedWorkBeads, rigStores, readyWaitSet, cr.sessionDrains, poolDesired, - result.StoreQueryPartial, + awakeAssignedWorkBeads, rigStores, readyWaitSet, cr.sessionDrains, poolDesired, + result.NamedSessionDemand, + result.snapshotQueryPartial(), workSet, cityName, cr.it, clock.Real{}, cr.rec, cr.cfg.Session.StartupTimeoutDuration(), cr.cfg.Daemon.DriftDrainTimeoutDuration(), cr.stdout, cr.stderr, trace, + withAsyncStartExecution(), + withAsyncStartFollowUp(cr.requestAsyncStartFollowUpTick), + withAsyncStartLimiter(cr.ensureAsyncStartLimiter()), + withAsyncStartTracker(&cr.asyncStarts), ) cr.requestDeferredDrainFollowUpTick() if trace != nil { @@ -1315,16 +1430,36 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat }) } } - if err := dispatchReadyWaitNudges(cr.cityPath, store, cr.sp, time.Now()); err != nil { + dispatchSessionBeads, err := loadSessionBeadSnapshot(store) + if err != nil { + fmt.Fprintf(cr.stderr, "%s: dispatching wait nudges: %v\n", cr.logPrefix, err) //nolint:errcheck + } else if err := dispatchReadyWaitNudgesWithSnapshot(cr.cityPath, store, time.Now(), dispatchSessionBeads); err != nil { fmt.Fprintf(cr.stderr, "%s: dispatching wait nudges: %v\n", cr.logPrefix, err) //nolint:errcheck } + // Drain queued nudges for ACP sessions in-process. The nudge + // poller (gc nudge poll) skips ACP sessions because it spawns a + // separate process without in-process ACP connections. The + // reconciler runs inside the supervisor and holds the live + // provider, so it can deliver directly. + acpTargets := buildACPNudgeTargets(cr.cityPath, cr.cfg, result, sessionBeads) + if len(acpTargets) > 0 { + if _, err := drainACPQueuedNudges(cr.cityPath, cr.sp, acpTargets, time.Now()); err != nil { + fmt.Fprintf(cr.stderr, "%s: draining ACP nudges: %v\n", cr.logPrefix, err) //nolint:errcheck + } + } + // Idle recovery: detect pool sessions stuck at the prompt after } func filterReleasedAssignedWorkBeads(assignedWorkBeads []beads.Bead, released []releasedPoolAssignment) []beads.Bead { + filtered, _ := filterReleasedAssignedWorkSnapshot(assignedWorkBeads, nil, released) + return filtered +} + +func filterReleasedAssignedWorkSnapshot(assignedWorkBeads []beads.Bead, assignedWorkStoreRefs []string, released []releasedPoolAssignment) ([]beads.Bead, []string) { if len(assignedWorkBeads) == 0 || len(released) == 0 { - return assignedWorkBeads + return assignedWorkBeads, assignedWorkStoreRefs } releasedIndexes := make(map[int]struct{}, len(released)) for _, r := range released { @@ -1337,16 +1472,28 @@ func filterReleasedAssignedWorkBeads(assignedWorkBeads []beads.Bead, released [] } } if len(releasedIndexes) == 0 { - return assignedWorkBeads + return assignedWorkBeads, assignedWorkStoreRefs } filtered := make([]beads.Bead, 0, len(assignedWorkBeads)-len(releasedIndexes)) + var filteredStoreRefs []string + // Preserve AssignedWorkBeads/AssignedWorkStoreRefs index alignment when + // both slices are complete; otherwise drop refs rather than guess. + if len(assignedWorkStoreRefs) == len(assignedWorkBeads) { + filteredStoreRefs = make([]string, 0, len(assignedWorkStoreRefs)-len(releasedIndexes)) + } for i, wb := range assignedWorkBeads { if _, ok := releasedIndexes[i]; ok { continue } filtered = append(filtered, wb) + if filteredStoreRefs != nil { + filteredStoreRefs = append(filteredStoreRefs, assignedWorkStoreRefs[i]) + } } - return filtered + if filteredStoreRefs == nil { + filteredStoreRefs = assignedWorkStoreRefs + } + return filtered, filteredStoreRefs } func (cr *CityRuntime) requestDeferredDrainFollowUpTick() { @@ -1362,6 +1509,44 @@ func (cr *CityRuntime) requestDeferredDrainFollowUpTick() { } } +func (cr *CityRuntime) ensureAsyncStartLimiter() *asyncStartLimiter { + capacity := maxParallelStartsPerTick(cr.cfg) + if cr.asyncStartLimiter == nil { + cr.asyncStartLimiter = newAsyncStartLimiter(capacity) + } else { + cr.asyncStartLimiter.resize(capacity) + } + return cr.asyncStartLimiter +} + +func (cr *CityRuntime) requestAsyncStartFollowUpTick() { + if cr == nil { + return + } + // Async completion can commit, rollback, or reject stale work; each case + // should prompt one cheap reconciliation pass to observe the new reality. + select { + case cr.pokeCh <- struct{}{}: + default: + } +} + +func (cr *CityRuntime) waitForAsyncStarts() { + if cr == nil { + return + } + timeout := time.Duration(0) + if cr.cfg != nil { + timeout = cr.cfg.Daemon.ShutdownTimeoutDuration() + } + if timeout <= 0 { + timeout = 5 * time.Second + } + if !cr.asyncStarts.wait(timeout) && cr.stderr != nil { + fmt.Fprintf(cr.stderr, "%s: async session starts still running after %s; continuing shutdown\n", cr.logPrefix, timeout) //nolint:errcheck // best-effort stderr + } +} + func sweepUndesiredPoolSessionBeads( store beads.Store, rigStores map[string]beads.Store, @@ -1374,6 +1559,7 @@ func sweepUndesiredPoolSessionBeads( if store == nil || sessionBeads == nil || cfg == nil || storeQueryPartial { return 0 } + startupTimeout := cfg.Session.StartupTimeoutDuration() var candidates []beads.Bead for _, bead := range sessionBeads.Open() { if bead.Status == "closed" { @@ -1389,11 +1575,11 @@ func sweepUndesiredPoolSessionBeads( continue } // Don't sweep beads that the reconciler still considers "start - // requested" — their work assignment window hasn't opened. Mirrors - // sessionStartRequested (session_reconcile.go) exactly so the two - // loops agree about ownership: - // - pending_create_claim=true: in-flight create claim, protected - // regardless of age until the lifecycle clears it. + // requested" — their work assignment window hasn't opened. The + // pending_create_claim lease mirrors the reconciler's recovery model: + // fresh start-in-flight and never-started queue entries are protected, + // but once that lease expires the crashed creator must not strand the + // pool slot forever. // - state=creating: protected until staleCreatingState would // return true (i.e., until staleCreatingStateTimeout has // elapsed; zero CreatedAt is treated as stale, matching @@ -1402,10 +1588,10 @@ func sweepUndesiredPoolSessionBeads( // on the same tick it's created (no work assigned → // GCSweepSessionBeads closes it), spinning the pool in a rapid // create→sweep→recreate loop. - if strings.TrimSpace(bead.Metadata["pending_create_claim"]) == "true" { + if pendingCreateClaimStillLeasedForSweep(bead, startupTimeout) { continue } - if strings.TrimSpace(bead.Metadata["state"]) == "creating" && !isStaleCreating(bead.CreatedAt) { + if strings.TrimSpace(bead.Metadata["state"]) == "creating" && !isStaleCreating(bead) { continue } // Age grace period for the post-creating, pre-wake window. After @@ -1468,21 +1654,33 @@ func sweepUndesiredPoolSessionBeads( return len(GCSweepSessionBeads(store, rigStores, candidates)) } +// pendingCreateClaimStillLeasedForSweep keeps pending_create_claim protection +// aligned with the reconciler: start-in-flight claims stay protected for the +// provider-start lease, never-started creates get the longer queue lease, and +// stale claims stop blocking pool-slot recovery. +func pendingCreateClaimStillLeasedForSweep(bead beads.Bead, startupTimeout time.Duration) bool { + return pendingCreateLeaseActive(bead, nil, startupTimeout) +} + // isStaleCreating mirrors staleCreatingState in session_reconcile.go without -// requiring a clock.Clock dependency: a zero CreatedAt is treated as stale, -// and otherwise the bead is stale once staleCreatingStateTimeout has elapsed. -// Keeping this shape identical to the reconciler's predicate means the sweep -// and the reconciler agree about which in-flight create beads are still alive. -func isStaleCreating(createdAt time.Time) bool { - if createdAt.IsZero() { +// requiring a clock.Clock dependency. It prefers the per-attempt +// pending_create_started_at marker and falls back to CreatedAt for older beads +// so the sweep and reconciler agree about which in-flight create beads are +// still alive. +func isStaleCreating(bead beads.Bead) bool { + now := time.Now() + if started, ok := parseRFC3339Metadata(bead.Metadata["pending_create_started_at"]); ok { + return !now.Before(started.Add(staleCreatingStateTimeout)) + } + if bead.CreatedAt.IsZero() { return true } - return time.Since(createdAt) >= staleCreatingStateTimeout + return !now.Before(bead.CreatedAt.Add(staleCreatingStateTimeout)) } -// parseRFC3339Metadata parses an RFC3339 timestamp metadata value. A missing -// or unparseable value returns ok=false; the caller treats that as "no per- -// start marker present" so older beads (pre-creation_complete_at rollout) +// parseRFC3339Metadata parses an RFC3339 timestamp metadata value. A missing, +// zero, or unparseable value returns ok=false; the caller treats that as "no +// per-start marker present" so older beads (pre-creation_complete_at rollout) // fall through to the default sweepable path rather than being protected // indefinitely. func parseRFC3339Metadata(v string) (time.Time, bool) { @@ -1494,6 +1692,9 @@ func parseRFC3339Metadata(v string) (time.Time, bool) { if err != nil { return time.Time{}, false } + if t.IsZero() { + return time.Time{}, false + } return t, true } @@ -1523,9 +1724,10 @@ func (cr *CityRuntime) controlDispatcherTick(ctx context.Context) { ) desiredState := wfcResult.State cfgNames := configuredSessionNamesWithSnapshot(filteredCfg, cr.cityName, sessionBeads) - _, updated := syncSessionBeadsWithSnapshot( + _, updated := syncSessionBeadsWithSnapshotAndRigStores( cr.cityPath, store, + cr.rigBeadStores(), desiredState, cr.sp, cfgNames, @@ -1536,13 +1738,18 @@ func (cr *CityRuntime) controlDispatcherTick(ctx context.Context) { sessionBeads, ) open := filterSessionBeadsByName(updated, cfgNames) - poolDesired := PoolDesiredCounts(ComputePoolDesiredStates( - filteredCfg, wfcResult.AssignedWorkBeads, open, wfcResult.ScaleCheckCounts)) + poolWorkBeads := filterAssignedWorkBeadsForPoolDemand(filteredCfg, cr.cityPath, open, wfcResult.AssignedWorkBeads, wfcResult.AssignedWorkStoreRefs) + poolDesired := retainScaleCheckPartialPoolDesired( + PoolDesiredCounts(ComputePoolDesiredStates( + filteredCfg, poolWorkBeads, open, wfcResult.ScaleCheckCounts)), + newSessionBeadSnapshot(open), + wfcResult.PoolScaleCheckPartialTemplates, + ) if poolDesired == nil { poolDesired = make(map[string]int) } mergeNamedSessionDemand(poolDesired, wfcResult.NamedSessionDemand, filteredCfg) - reconcileSessionBeadsAtPath( + reconcileSessionBeadsAtPathWithNamedDemand( ctx, cr.cityPath, open, @@ -1557,6 +1764,7 @@ func (cr *CityRuntime) controlDispatcherTick(ctx context.Context) { nil, // control-dispatcher ticks only need ownership continuity, not main-tick assigned/ready snapshots cr.sessionDrains, poolDesired, + wfcResult.NamedSessionDemand, false, // storeQueryPartial: config-change path doesn't query work beads nil, // workSet: not computed for config-change reconcile cr.cityName, @@ -1575,8 +1783,8 @@ func (cr *CityRuntime) controlDispatcherTick(ctx context.Context) { func (cr *CityRuntime) syncBeadsAndUpdateIndex(desiredState map[string]TemplateParams, sessionBeads *sessionBeadSnapshot) *sessionBeadSnapshot { store := cr.cityBeadStore() cfgNames := configuredSessionNamesWithSnapshot(cr.cfg, cr.cityName, sessionBeads) - _, updated := syncSessionBeadsWithSnapshot( - cr.cityPath, store, desiredState, cr.sp, cfgNames, cr.cfg, clock.Real{}, cr.stderr, cr.sessionDrains != nil, sessionBeads, + _, updated := syncSessionBeadsWithSnapshotAndRigStores( + cr.cityPath, store, cr.rigBeadStores(), desiredState, cr.sp, cfgNames, cr.cfg, clock.Real{}, cr.stderr, cr.sessionDrains != nil, sessionBeads, ) return updated } @@ -1600,16 +1808,21 @@ func (cr *CityRuntime) rigBeadStores() map[string]beads.Store { } func (cr *CityRuntime) loadSessionBeadSnapshot() *sessionBeadSnapshot { + sessionBeads, _ := cr.loadSessionBeadSnapshotWithPartial() + return sessionBeads +} + +func (cr *CityRuntime) loadSessionBeadSnapshotWithPartial() (*sessionBeadSnapshot, bool) { store := cr.cityBeadStore() if store == nil { - return nil + return nil, false } sessionBeads, err := loadSessionBeadSnapshot(store) if err != nil { fmt.Fprintf(cr.stderr, "%s: loading session beads: %v\n", cr.logPrefix, err) //nolint:errcheck - return nil + return nil, true } - return sessionBeads + return sessionBeads, false } func filterSessionBeadsByName(snapshot *sessionBeadSnapshot, names map[string]bool) []beads.Bead { @@ -1647,13 +1860,18 @@ func (cr *CityRuntime) loadDemandSnapshot( if sessionBeads != nil { openSessionBeads = sessionBeads.Open() } - result.PoolDesiredCounts = PoolDesiredCounts(ComputePoolDesiredStatesTraced( - cr.cfg, result.AssignedWorkBeads, openSessionBeads, result.ScaleCheckCounts, trace)) + poolWorkBeads := filterAssignedWorkBeadsForPoolDemand(cr.cfg, cr.cityPath, openSessionBeads, result.AssignedWorkBeads, result.AssignedWorkStoreRefs) + result.PoolDesiredCounts = retainScaleCheckPartialPoolDesired( + PoolDesiredCounts(ComputePoolDesiredStatesTraced( + cr.cfg, poolWorkBeads, openSessionBeads, result.ScaleCheckCounts, trace)), + sessionBeads, + result.PoolScaleCheckPartialTemplates, + ) if result.PoolDesiredCounts == nil { result.PoolDesiredCounts = make(map[string]int) } mergeNamedSessionDemand(result.PoolDesiredCounts, result.NamedSessionDemand, cr.cfg) - result.WorkSet = computeWorkSet(cr.cfg, shellScaleCheck, cr.cityName, cr.cityPath, cr.cityBeadStore(), sessionBeads, cr.stderr) + result.WorkSet = make(map[string]bool) cr.demandSnapshot = &runtimeDemandSnapshot{ createdAt: time.Now(), sessionFingerprint: sessionFingerprint, @@ -1697,7 +1915,7 @@ func demandSnapshotDemandSourcesEventBacked(cfg *config.City) bool { return false } for i := range cfg.Agents { - if strings.TrimSpace(cfg.Agents[i].ScaleCheck) != "" || strings.TrimSpace(cfg.Agents[i].WorkQuery) != "" { + if strings.TrimSpace(cfg.Agents[i].ScaleCheck) != "" { return false } } @@ -1788,20 +2006,99 @@ func (cr *CityRuntime) beginTraceCycle(trigger, detail string, sessionBeads *ses return cr.trace.beginCycle(info, cr.cfg, sessionBeads) } +func (cr *CityRuntime) drainOutgoingOrderDispatcher(ctx context.Context, od orderDispatcher) { + if od == nil { + return + } + if od.drain(ctx) { + return + } + cr.retiredOrderDispatchers = append(cr.retiredOrderDispatchers, od) +} + +func (cr *CityRuntime) drainOrderDispatchers(ctx context.Context) { + var retained []orderDispatcher + if cr.od != nil && !cr.od.drain(ctx) { + retained = append(retained, cr.od) + } + for _, od := range cr.retiredOrderDispatchers { + if od == nil { + continue + } + if !od.drain(ctx) { + retained = append(retained, od) + } + } + cr.retiredOrderDispatchers = retained +} + +func orderShutdownDrainTimeout(total time.Duration) time.Duration { + if total <= 0 { + return 0 + } + if total < reloadOrderDrainTimeout { + return total + } + return reloadOrderDrainTimeout +} + +func (cr *CityRuntime) recordPreservedShutdownTrace() { + trace := cr.beginTraceCycle("shutdown", "preserve_sessions", nil) + if trace == nil { + return + } + trace.recordOperation("lifecycle.shutdown.preserve_sessions", "", "", "", "retained", string(TraceOutcomeApplied), traceRecordPayload{ + "city_path": cr.cityPath, + "city_name": cr.cityName, + "reason": "supervisor_shutdown_preserve_mode", + }, "") + trace.end(TraceCompletionCompleted, traceRecordPayload{ + "phase": "shutdown", + "mode": "preserve_sessions", + "city_name": cr.cityName, + "reason": "supervisor_shutdown_preserve_mode", + }) +} + // shutdown performs graceful two-pass agent shutdown for this city. // Safe to call multiple times (e.g., from both panic recovery and // normal shutdown) — only the first call takes effect. func (cr *CityRuntime) shutdown() { cr.shutdownOnce.Do(func() { + cr.waitForAsyncStarts() + preserveSessions := cr.preserveSessionsShutdown.Load() + if preserveSessions { + cr.recordPreservedShutdownTrace() + } if cr.trace != nil { _ = cr.trace.Close() } if cr.svc != nil { + // Workspace-service proxies are process-group-bound, not preserved + // agent sessions. Close them so the next supervisor can reacquire + // their sockets and ports during re-adoption. if err := cr.svc.Close(); err != nil { fmt.Fprintf(cr.stderr, "%s: service shutdown: %v\n", cr.logPrefix, err) //nolint:errcheck // best-effort stderr } } - timeout := cr.cfg.Daemon.ShutdownTimeoutDuration() + if preserveSessions { + fmt.Fprintf(cr.stdout, "Preserving agent sessions for supervisor re-adoption.\n") //nolint:errcheck // best-effort stdout + return + } + // Drain order dispatchers with a small cap before stopping sessions. + // Use a fresh context because the tick ctx is already canceled at this + // point, which would make drain a no-op. shutdown_timeout remains the + // graceful session-stop budget; order drain does not silently halve it. + // Orphaned tracking beads (if drain times out) are closed by + // sweepOrphanedOrderTrackingRetry on next start. + total := cr.cfg.Daemon.ShutdownTimeoutDuration() + gracefulTimeout := total + if cr.od != nil || len(cr.retiredOrderDispatchers) > 0 { + drainTimeout := orderShutdownDrainTimeout(total) + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + cr.drainOrderDispatchers(drainCtx) + drainCancel() + } running, listErr := cr.sp.ListRunning("") if listErr != nil { if runtime.IsPartialListError(listErr) { @@ -1810,6 +2107,12 @@ func (cr *CityRuntime) shutdown() { fmt.Fprintf(cr.stderr, "%s: shutdown session listing failed: %v\n", cr.logPrefix, listErr) //nolint:errcheck // best-effort stderr } } - gracefulStopAll(running, cr.sp, timeout, cr.rec, cr.cfg, cr.cityBeadStore(), cr.stdout, cr.stderr) + store := cr.cityBeadStore() + markCityStopSessionSleepReason(store, cr.stderr) + gracefulStopAll(running, cr.sp, gracefulTimeout, cr.rec, cr.cfg, store, cr.stdout, cr.stderr) }) } + +func (cr *CityRuntime) preserveSessionsOnShutdown() { + cr.preserveSessionsShutdown.Store(true) +} diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index 059bc246e9..49c4ca439c 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/fsys" + "github.com/gastownhall/gascity/internal/orders" "github.com/gastownhall/gascity/internal/runtime" sessionauto "github.com/gastownhall/gascity/internal/runtime/auto" ) @@ -69,6 +71,35 @@ func TestSweepUndesiredPoolSessionBeads_KeepsRunningSessionsOpen(t *testing.T) { } } +// newTestCityRuntime builds a CityRuntime and registers a cleanup that +// cancels in-flight dispatched orders before invoking shutdown. Do NOT +// add a duplicate t.Cleanup(cr.shutdown) in callers — t.Cleanup is LIFO, +// and a duplicate would consume cr.shutdownOnce before this wrapper's +// cancel runs, reintroducing the .gc/ RemoveAll race. +func newTestCityRuntime(t *testing.T, params CityRuntimeParams) *CityRuntime { + t.Helper() + + cr := newCityRuntime(params) + t.Cleanup(func() { + // Tests pass context.Background to cr.tick, so dispatched orders + // cannot be canceled via tick ctx propagation. Type-assert to the + // concrete dispatcher (only it spawns subprocess goroutines that + // need cancellation; test fakes have nothing to interrupt). + cancelInflight(cr.od) + for _, od := range cr.retiredOrderDispatchers { + cancelInflight(od) + } + cr.shutdown() + }) + return cr +} + +func cancelInflight(od orderDispatcher) { + if m, ok := od.(*memoryOrderDispatcher); ok { + m.cancel() + } +} + func TestFilterReleasedAssignedWorkBeads_PreservesSameIDUnreleasedWork(t *testing.T) { assigned := []beads.Bead{ {ID: "gc-1", Title: "released city work"}, @@ -104,6 +135,17 @@ func TestFilterReleasedAssignedWorkBeads_IgnoresMismatchedReleasedIndex(t *testi } } +type sessionSnapshotListFailStore struct { + beads.Store +} + +func (s sessionSnapshotListFailStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.Label == sessionBeadLabel { + return nil, errors.New("session snapshot unavailable") + } + return s.Store.List(query) +} + func TestCityRuntimeRequestDeferredDrainFollowUpTick_PokesOnce(t *testing.T) { cr := &CityRuntime{ sessionDrains: newDrainTracker(), @@ -132,6 +174,45 @@ func TestCityRuntimeRequestDeferredDrainFollowUpTick_PokesOnce(t *testing.T) { } } +func TestCityRuntimeShutdownMarksCityStopSleepReason(t *testing.T) { + store := beads.NewMemStore() + session, err := store.Create(beads.Bead{ + Title: "control-dispatcher", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "control-dispatcher", + "template": "control-dispatcher", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + cr := &CityRuntime{ + cfg: &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Daemon: config.DaemonConfig{ShutdownTimeout: "0s"}, + }, + sp: runtime.NewFake(), + rec: events.Discard, + standaloneCityStore: store, + stdout: io.Discard, + stderr: io.Discard, + } + + cr.shutdown() + + got, err := store.Get(session.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Metadata["sleep_reason"] != sleepReasonCityStop { + t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], sleepReasonCityStop) + } +} + func TestCityRuntimeDemandSnapshotReusesStablePatrolDemand(t *testing.T) { buildCalls := 0 cr := &CityRuntime{ @@ -195,10 +276,402 @@ func TestCityRuntimeDemandSnapshotReusesStablePatrolDemand(t *testing.T) { } } +func TestCityRuntimeDemandSnapshotRetainsOnlyPoolScaleCheckPartials(t *testing.T) { + sessionBeads := newSessionBeadSnapshot([]beads.Bead{{ + ID: "session-worker", + Status: "open", + Metadata: map[string]string{ + "session_name": "worker-bd-123", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", + }, + }}) + + tests := []struct { + name string + result DesiredStateResult + want int + }{ + { + name: "pool partial retains awake pool session", + result: DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + ScaleCheckPartialTemplates: map[string]bool{"worker": true}, + PoolScaleCheckPartialTemplates: map[string]bool{"worker": true}, + }, + want: 1, + }, + { + name: "named partial does not retain generic pool session", + result: DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + ScaleCheckPartialTemplates: map[string]bool{"worker": true}, + NamedScaleCheckPartialTemplates: map[string]bool{"worker": true}, + }, + want: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cr := &CityRuntime{ + cityName: "test-city", + cityPath: t.TempDir(), + cfg: &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + }}, + }, + cs: &controllerState{ + eventProv: events.NewFake(), + }, + stderr: io.Discard, + } + cr.buildFnWithSessionBeads = func(*config.City, runtime.Provider, beads.Store, map[string]beads.Store, *sessionBeadSnapshot, *sessionReconcilerTraceCycle) DesiredStateResult { + return tc.result + } + + snapshot := cr.loadDemandSnapshot(sessionBeads, nil, "poke", false) + + if got := snapshot.result.PoolDesiredCounts["worker"]; got != tc.want { + t.Fatalf("PoolDesiredCounts[worker] = %d, want %d", got, tc.want) + } + }) + } +} + +func TestCityRuntimeAsyncStartLimiterUsesMaxWakesPerTick(t *testing.T) { + maxWakes := 7 + cfg := &config.City{Daemon: config.DaemonConfig{MaxWakesPerTick: &maxWakes}} + cr := &CityRuntime{cfg: cfg} + + if got := cr.ensureAsyncStartLimiter().capacity(); got != maxWakes { + t.Fatalf("limiter cap = %d, want %d", got, maxWakes) + } + + maxWakes = 2 + if got := cr.ensureAsyncStartLimiter().capacity(); got != maxWakes { + t.Fatalf("limiter cap after config change = %d, want %d", got, maxWakes) + } +} + +func TestCityRuntimeAsyncStartLimiterResizePreservesInFlightBudget(t *testing.T) { + maxWakes := 3 + cfg := &config.City{Daemon: config.DaemonConfig{MaxWakesPerTick: &maxWakes}} + cr := &CityRuntime{cfg: cfg} + limiter := cr.ensureAsyncStartLimiter() + + var releases []func() + for i := 0; i < maxWakes; i++ { + release, reserved, outcome := reserveAsyncStartSlot(context.Background(), limiter) + if !reserved { + t.Fatalf("reserve initial slot = %s, want success", outcome) + } + releases = append(releases, release) + } + + maxWakes = 2 + resized := cr.ensureAsyncStartLimiter() + if resized != limiter { + t.Fatal("resized limiter should preserve the same in-flight reservation counter") + } + if got := resized.capacity(); got != maxWakes { + t.Fatalf("resized cap = %d, want %d", got, maxWakes) + } + if _, reserved, outcome := reserveAsyncStartSlot(context.Background(), resized); reserved || outcome != "deferred_by_async_start_limit" { + t.Fatalf("reserve while old slots exceed resized cap = reserved %v outcome %q, want deferred", reserved, outcome) + } + + releases[0]() + if _, reserved, outcome := reserveAsyncStartSlot(context.Background(), resized); reserved || outcome != "deferred_by_async_start_limit" { + t.Fatalf("reserve at resized cap = reserved %v outcome %q, want deferred", reserved, outcome) + } + releases[1]() + release, reserved, outcome := reserveAsyncStartSlot(context.Background(), resized) + if !reserved { + t.Fatalf("reserve below resized cap = %s, want success", outcome) + } + release() + releases[2]() +} + +type recordingOrderDispatcher struct { + called atomic.Bool + calls atomic.Int32 + onDispatch func(context.Context, string, time.Time) + drainCalls int + drainCtxErr error +} + +func (r *recordingOrderDispatcher) dispatch(ctx context.Context, cityRoot string, now time.Time) { + r.calls.Add(1) + r.called.Store(true) + if r.onDispatch != nil { + r.onDispatch(ctx, cityRoot, now) + } +} + +func (r *recordingOrderDispatcher) drain(ctx context.Context) bool { + r.drainCalls++ + r.drainCtxErr = ctx.Err() + return true +} + +type blockingOrderDispatcher struct { + mu sync.Mutex + drainCalls int + ctxErrs []error + release chan struct{} + drained chan struct{} +} + +func newBlockingOrderDispatcher() *blockingOrderDispatcher { + return &blockingOrderDispatcher{ + release: make(chan struct{}), + drained: make(chan struct{}, 16), + } +} + +func (b *blockingOrderDispatcher) dispatch(context.Context, string, time.Time) {} + +func (b *blockingOrderDispatcher) drain(ctx context.Context) bool { + b.mu.Lock() + b.drainCalls++ + b.ctxErrs = append(b.ctxErrs, ctx.Err()) + b.mu.Unlock() + b.drained <- struct{}{} + select { + case <-b.release: + return true + case <-ctx.Done(): + return false + } +} + +func (b *blockingOrderDispatcher) waitForDrainCalls(t *testing.T, want int) { + t.Helper() + deadline := time.After(500 * time.Millisecond) + for { + b.mu.Lock() + got := b.drainCalls + b.mu.Unlock() + if got >= want { + return + } + select { + case <-b.drained: + case <-deadline: + t.Fatalf("drainCalls = %d, want at least %d", got, want) + } + } +} + +func (b *blockingOrderDispatcher) drainContextErrors() []error { + b.mu.Lock() + defer b.mu.Unlock() + return append([]error(nil), b.ctxErrs...) +} + +func TestCityRuntimeTickDispatchesOrdersBeforeDemandSnapshot(t *testing.T) { + store := beads.NewMemStore() + od := &recordingOrderDispatcher{} + cr := &CityRuntime{ + cityName: "test-city", + cityPath: t.TempDir(), + cfg: &config.City{Workspace: config.Workspace{Name: "test-city"}}, + sp: runtime.NewFake(), + standaloneCityStore: store, + od: od, + stdout: io.Discard, + stderr: io.Discard, + } + cr.buildFnWithSessionBeads = func(*config.City, runtime.Provider, beads.Store, map[string]beads.Store, *sessionBeadSnapshot, *sessionReconcilerTraceCycle) DesiredStateResult { + if !od.called.Load() { + t.Fatal("order dispatch should happen before demand snapshot build") + } + return DesiredStateResult{State: map[string]TemplateParams{}} + } + + var dirty atomic.Bool + var lastProviderName string + var prevPoolRunning map[string]bool + cr.tick(context.Background(), &dirty, &lastProviderName, cr.cityPath, &prevPoolRunning, "patrol") + + if !od.called.Load() { + t.Fatal("order dispatcher was not called") + } +} + +func TestCityRuntimeRunDispatchesOrdersBeforeStartupReconcile(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") + + cfg, err := config.Load(osFS{}, tomlPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + sp := runtime.NewFake() + od := &recordingOrderDispatcher{} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var started atomic.Bool + cr := newCityRuntime(CityRuntimeParams{ + CityPath: cityPath, + CityName: "test-city", + TomlPath: tomlPath, + Cfg: cfg, + SP: sp, + BuildFn: func(*config.City, runtime.Provider, beads.Store) DesiredStateResult { + if !od.called.Load() { + t.Fatal("order dispatch should happen before startup reconcile") + } + return DesiredStateResult{State: map[string]TemplateParams{}} + }, + Dops: newDrainOps(sp), + Rec: events.Discard, + OnStarted: func() { + started.Store(true) + cancel() + }, + Stdout: io.Discard, + Stderr: io.Discard, + }) + cr.od = od + + cs := newControllerState(context.Background(), cfg, sp, events.NewFake(), "test-city", cityPath) + cs.cityBeadStore = beads.NewMemStore() + cr.setControllerState(cs) + + cr.run(ctx) + + if !started.Load() { + t.Fatal("OnStarted was not called") + } + if got := od.calls.Load(); got != 1 { + t.Fatalf("order dispatch calls = %d, want 1", got) + } +} + +func TestCityRuntimeRunStartupOrderDispatchPanicIsRecovered(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") + + cfg, err := config.Load(osFS{}, tomlPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + sp := runtime.NewFake() + od := &recordingOrderDispatcher{ + onDispatch: func(context.Context, string, time.Time) { + panic("startup order boom") + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var stderr bytes.Buffer + var started atomic.Bool + cr := newCityRuntime(CityRuntimeParams{ + CityPath: cityPath, + CityName: "test-city", + TomlPath: tomlPath, + Cfg: cfg, + SP: sp, + BuildFn: func(*config.City, runtime.Provider, beads.Store) DesiredStateResult { + return DesiredStateResult{State: map[string]TemplateParams{}} + }, + Dops: newDrainOps(sp), + Rec: events.Discard, + OnStarted: func() { + started.Store(true) + cancel() + }, + Stdout: io.Discard, + Stderr: &stderr, + }) + cr.od = od + + cs := newControllerState(context.Background(), cfg, sp, events.NewFake(), "test-city", cityPath) + cs.cityBeadStore = beads.NewMemStore() + cr.setControllerState(cs) + + cr.run(ctx) + + if !started.Load() { + t.Fatal("OnStarted was not called after recovered startup order panic") + } + if got := od.calls.Load(); got != 1 { + t.Fatalf("order dispatch calls = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "trigger=startup-orders") { + t.Fatalf("stderr = %q, want startup-orders panic trigger", stderr.String()) + } + if !strings.Contains(stderr.String(), "startup order boom") { + t.Fatalf("stderr = %q, want recovered panic detail", stderr.String()) + } +} + +func TestOrderTrackingSweepWatchdogOnlyClosesSweepOrderTracking(t *testing.T) { + store := beads.NewMemStore() + sweepTracking, err := store.Create(beads.Bead{ + Title: "order:" + orderTrackingSweepOrder, + Labels: []string{"order-run:" + orderTrackingSweepOrder, labelOrderTracking}, + }) + if err != nil { + t.Fatalf("Create(sweep): %v", err) + } + mergeTracking, err := store.Create(beads.Bead{ + Title: "order:pr-merge-queue", + Labels: []string{"order-run:pr-merge-queue", labelOrderTracking}, + }) + if err != nil { + t.Fatalf("Create(merge): %v", err) + } + + cr := &CityRuntime{ + cityName: "test-city", + cfg: &config.City{Workspace: config.Workspace{Name: "test-city"}}, + standaloneCityStore: store, + stdout: io.Discard, + stderr: io.Discard, + logPrefix: "gc test", + } + cr.runOrderTrackingSweepWatchdog(time.Now().Add(orderTrackingSweepWatchdogStaleAfter + time.Second)) + + gotSweep, err := store.Get(sweepTracking.ID) + if err != nil { + t.Fatalf("Get(sweep): %v", err) + } + if gotSweep.Status != "closed" { + t.Fatalf("sweep tracking status = %s, want closed", gotSweep.Status) + } + gotMerge, err := store.Get(mergeTracking.ID) + if err != nil { + t.Fatalf("Get(merge): %v", err) + } + if gotMerge.Status != "open" { + t.Fatalf("merge tracking status = %s, want open", gotMerge.Status) + } +} + func TestCityRuntimeDemandSnapshotRefreshesWhenDemandCommandsAreCustom(t *testing.T) { cases := []struct { - name string - agent config.Agent + name string + agent config.Agent + wantBuilds int }{ { name: "custom scale_check", @@ -206,6 +679,7 @@ func TestCityRuntimeDemandSnapshotRefreshesWhenDemandCommandsAreCustom(t *testin Name: "worker", ScaleCheck: "test -f external-queue && echo 1 || echo 0", }, + wantBuilds: 2, }, { name: "custom work_query", @@ -213,6 +687,7 @@ func TestCityRuntimeDemandSnapshotRefreshesWhenDemandCommandsAreCustom(t *testin Name: "worker", WorkQuery: "gh issue list --json number --limit 1", }, + wantBuilds: 1, }, } @@ -240,13 +715,40 @@ func TestCityRuntimeDemandSnapshotRefreshesWhenDemandCommandsAreCustom(t *testin _ = cr.loadDemandSnapshot(sessionBeads, nil, "patrol", false) _ = cr.loadDemandSnapshot(sessionBeads, nil, "patrol", false) - if buildCalls != 2 { - t.Fatalf("buildDesiredState call count = %d, want 2 when demand command is not event-backed", buildCalls) + if buildCalls != tc.wantBuilds { + t.Fatalf("buildDesiredState call count = %d, want %d", buildCalls, tc.wantBuilds) } }) } } +func TestCityRuntimeDemandSnapshotDoesNotRunControllerWorkQuery(t *testing.T) { + cr := &CityRuntime{ + cityName: "test-city", + cityPath: t.TempDir(), + cfg: &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + WorkQuery: `printf '[{"id":"work-1"}]'`, + }}, + }, + cs: &controllerState{ + eventProv: events.NewFake(), + }, + stderr: io.Discard, + } + cr.buildFnWithSessionBeads = func(*config.City, runtime.Provider, beads.Store, map[string]beads.Store, *sessionBeadSnapshot, *sessionReconcilerTraceCycle) DesiredStateResult { + return DesiredStateResult{State: map[string]TemplateParams{}} + } + + snapshot := cr.loadDemandSnapshot(newSessionBeadSnapshot(nil), nil, "patrol", false) + + if len(snapshot.result.WorkSet) != 0 { + t.Fatalf("WorkSet = %#v, want empty; controller demand must not run work_query", snapshot.result.WorkSet) + } +} + func TestCityRuntimeDemandSnapshotReplaysACPRoutesOnCacheHit(t *testing.T) { defaultSP := runtime.NewFake() acpSP := runtime.NewFake() @@ -547,44 +1049,266 @@ func TestSweepUndesiredPoolSessionBeads_SkipsAwakeStateInPreWakeWindow(t *testin runtime.NewFake(), false, ) - if closed != 0 { - t.Fatalf("closed = %d, want 0 — state=awake in pre-wake window must receive same protection as state=active", closed) + if closed != 0 { + t.Fatalf("closed = %d, want 0 — state=awake in pre-wake window must receive same protection as state=active", closed) + } +} + +// Recovery of an already-active bead (recoverRunningPendingCreate path: +// state=active + pending_create_claim=true + alive runtime) must produce +// a fresh creation_complete_at so the healed bead stays protected in the +// pre-wake window on the following tick. This test asserts the sweep's +// side of that contract — a state=active bead with a fresh +// creation_complete_at and empty last_woke_at survives the sweep. +func TestSweepUndesiredPoolSessionBeads_SkipsRecoveredActiveBead(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-recovered", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + // Post-recovery shape: state was already active, recovery just + // cleared pending_create_claim and stamped a fresh marker. + "state": "active", + "state_reason": "creation_complete", + "creation_complete_at": time.Now().UTC().Format(time.RFC3339), + "last_woke_at": "", + // Historical counters survive recovery. + "wake_attempts": "1", + "continuation_epoch": "1", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + + closed := sweepUndesiredPoolSessionBeads( + store, + nil, + sessionBeads, + nil, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + runtime.NewFake(), + false, + ) + if closed != 0 { + t.Fatalf("closed = %d, want 0 — recovered active bead with fresh marker must survive pre-wake", closed) + } +} + +// Crashed-then-recently-restarted beads: wake_attempts/churn_count are +// preserved across a successful restart (CommitStartedPatch does not reset +// them), so the post-create guard CANNOT be keyed on those counters or a +// legitimate restart after a prior crash would fall into the same spin +// loop. Gating on a fresh creation_complete_at lets a just-restarted bead +// survive the pre-wake window even when its historical counters are +// non-zero. +func TestSweepUndesiredPoolSessionBeads_SkipsFreshRestartAfterPriorCrash(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-restart-after-crash", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + // Just-restarted after a prior crash: state transitioned back + // to active with a fresh creation_complete_at, but historical + // failure counters remain because clearWakeFailures only fires + // after the session is stable-long-enough. + "state": "active", + "state_reason": "creation_complete", + "creation_complete_at": time.Now().UTC().Format(time.RFC3339), + "wake_attempts": "2", + "churn_count": "1", + "continuation_epoch": "1", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + + closed := sweepUndesiredPoolSessionBeads( + store, + nil, + sessionBeads, + nil, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + runtime.NewFake(), + false, + ) + if closed != 0 { + t.Fatalf("closed = %d, want 0 — fresh restart after prior crash must survive the pre-wake window", closed) + } +} + +// Crashed beads (state=active, last_woke_at="" cleared by checkStability, +// creation_complete_at stale because the last successful start was long +// ago) MUST be sweepable. checkStability/checkChurn/start-failure do not +// touch creation_complete_at, so an old marker is the signal that the +// state=active+empty-last_woke_at shape came from a crash-clear rather +// than a fresh start. +func TestSweepUndesiredPoolSessionBeads_SweepsCrashedActiveBead(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-crashed", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "active", + "state_reason": "creation_complete", + "creation_complete_at": time.Now().Add(-2 * time.Minute).UTC().Format(time.RFC3339), + "last_woke_at": "", + "wake_attempts": "1", + "continuation_epoch": "1", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + + closed := sweepUndesiredPoolSessionBeads( + store, + nil, + sessionBeads, + nil, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + runtime.NewFake(), + false, + ) + if closed != 1 { + t.Fatalf("closed = %d, want 1 — crashed bead with stale creation_complete_at must be swept", closed) + } +} + +func TestSweepUndesiredPoolSessionBeads_SkipsPendingCreateClaim(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-123", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "pending_create_claim": "true", + "continuation_epoch": "1", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + + closed := sweepUndesiredPoolSessionBeads( + store, + nil, + sessionBeads, + nil, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + runtime.NewFake(), + false, + ) + if closed != 0 { + t.Fatalf("closed = %d, want 0 — pending_create_claim must be preserved", closed) + } + got, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Status == "closed" { + t.Fatalf("bead with pending_create_claim was swept closed: %+v", got) + } +} + +// #1460: pending_create_claim stays protected only for the pending-create +// lease. Once a never-started create ages past that lease, the sweep must +// reap it instead of preserving the pool slot forever. +func TestSweepUndesiredPoolSessionBeads_SweepsExpiredPendingCreateClaimLease(t *testing.T) { + store := beads.NewMemStore() + now := time.Now().UTC() + bead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-stale-claim", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + "state": "creating", + poolManagedMetadataKey: boolMetadata(true), + "pending_create_claim": "true", + "pending_create_started_at": pendingCreateStartedAtNow(now.Add(-(pendingCreateNeverStartedTimeout + time.Second))), + "continuation_epoch": "1", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + bead.CreatedAt = now.Add(-24 * time.Hour) + sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + + closed := sweepUndesiredPoolSessionBeads( + store, + nil, + sessionBeads, + nil, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + runtime.NewFake(), + false, + ) + if closed != 1 { + t.Fatalf("closed = %d, want 1 — expired pending_create_claim lease must be reaped", closed) } } -// Recovery of an already-active bead (recoverRunningPendingCreate path: -// state=active + pending_create_claim=true + alive runtime) must produce -// a fresh creation_complete_at so the healed bead stays protected in the -// pre-wake window on the following tick. This test asserts the sweep's -// side of that contract — a state=active bead with a fresh -// creation_complete_at and empty last_woke_at survives the sweep. -func TestSweepUndesiredPoolSessionBeads_SkipsRecoveredActiveBead(t *testing.T) { +func TestSweepUndesiredPoolSessionBeads_UsesPendingCreateStartedAtForCreatingState(t *testing.T) { store := beads.NewMemStore() + now := time.Now().UTC() bead, err := store.Create(beads.Bead{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ - "session_name": "worker-bd-recovered", - "template": "worker", - "agent_name": "worker", - "pool_slot": "1", - poolManagedMetadataKey: boolMetadata(true), - // Post-recovery shape: state was already active, recovery just - // cleared pending_create_claim and stamped a fresh marker. - "state": "active", - "state_reason": "creation_complete", - "creation_complete_at": time.Now().UTC().Format(time.RFC3339), - "last_woke_at": "", - // Historical counters survive recovery. - "wake_attempts": "1", - "continuation_epoch": "1", - "generation": "1", + "session_name": "worker-bd-fresh-create", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "creating", + "pending_create_started_at": pendingCreateStartedAtNow(now.Add(-30 * time.Second)), + "continuation_epoch": "1", + "generation": "1", }, }) if err != nil { t.Fatalf("Create: %v", err) } + bead.CreatedAt = now.Add(-2 * time.Minute) sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) closed := sweepUndesiredPoolSessionBeads( @@ -597,38 +1321,38 @@ func TestSweepUndesiredPoolSessionBeads_SkipsRecoveredActiveBead(t *testing.T) { false, ) if closed != 0 { - t.Fatalf("closed = %d, want 0 — recovered active bead with fresh marker must survive pre-wake", closed) + t.Fatalf("closed = %d, want 0 — fresh pending_create_started_at must keep old creating bead alive", closed) } } -// Crashed-then-recently-restarted beads: wake_attempts/churn_count are -// preserved across a successful restart (CommitStartedPatch does not reset -// them), so the post-create guard CANNOT be keyed on those counters or a -// legitimate restart after a prior crash would fall into the same spin -// loop. Gating on a fresh creation_complete_at lets a just-restarted bead -// survive the pre-wake window even when its historical counters are -// non-zero. -func TestSweepUndesiredPoolSessionBeads_SkipsFreshRestartAfterPriorCrash(t *testing.T) { +func TestIsStaleCreatingTreatsZeroPendingCreateStartedAtAsMissing(t *testing.T) { + now := time.Now().UTC() + bead := beads.Bead{ + Metadata: map[string]string{ + "state": "creating", + "pending_create_started_at": (time.Time{}).UTC().Format(time.RFC3339), + }, + CreatedAt: now, + } + + if isStaleCreating(bead) { + t.Fatal("zero pending_create_started_at should fall back to fresh CreatedAt") + } +} + +func TestSweepUndesiredPoolSessionBeads_ClosesStoppedSessions(t *testing.T) { store := beads.NewMemStore() bead, err := store.Create(beads.Bead{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ - "session_name": "worker-bd-restart-after-crash", + "session_name": "worker-bd-123", "template": "worker", "agent_name": "worker", "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), - // Just-restarted after a prior crash: state transitioned back - // to active with a fresh creation_complete_at, but historical - // failure counters remain because clearWakeFailures only fires - // after the session is stable-long-enough. - "state": "active", - "state_reason": "creation_complete", - "creation_complete_at": time.Now().UTC().Format(time.RFC3339), - "wake_attempts": "2", - "churn_count": "1", + "state": "drained", "continuation_epoch": "1", "generation": "1", }, @@ -647,40 +1371,45 @@ func TestSweepUndesiredPoolSessionBeads_SkipsFreshRestartAfterPriorCrash(t *test runtime.NewFake(), false, ) - if closed != 0 { - t.Fatalf("closed = %d, want 0 — fresh restart after prior crash must survive the pre-wake window", closed) + if closed != 1 { + t.Fatalf("closed = %d, want 1", closed) + } + got, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Status != "closed" { + t.Fatalf("stopped pool bead status = %q, want closed", got.Status) } } -// Crashed beads (state=active, last_woke_at="" cleared by checkStability, -// creation_complete_at stale because the last successful start was long -// ago) MUST be sweepable. checkStability/checkChurn/start-failure do not -// touch creation_complete_at, so an old marker is the signal that the -// state=active+empty-last_woke_at shape came from a crash-clear rather -// than a fresh start. -func TestSweepUndesiredPoolSessionBeads_SweepsCrashedActiveBead(t *testing.T) { +func TestSweepUndesiredPoolSessionBeads_KeepsAssignedSessionsOpen(t *testing.T) { store := beads.NewMemStore() bead, err := store.Create(beads.Bead{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ - "session_name": "worker-bd-crashed", + "session_name": "worker-bd-123", "template": "worker", "agent_name": "worker", "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), - "state": "active", - "state_reason": "creation_complete", - "creation_complete_at": time.Now().Add(-2 * time.Minute).UTC().Format(time.RFC3339), - "last_woke_at": "", - "wake_attempts": "1", + "state": "asleep", "continuation_epoch": "1", "generation": "1", }, }) if err != nil { - t.Fatalf("Create: %v", err) + t.Fatalf("Create session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Title: "assigned work", + Type: "task", + Status: "in_progress", + Assignee: "worker-bd-123", + }); err != nil { + t.Fatalf("Create work bead: %v", err) } sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) @@ -693,12 +1422,19 @@ func TestSweepUndesiredPoolSessionBeads_SweepsCrashedActiveBead(t *testing.T) { runtime.NewFake(), false, ) - if closed != 1 { - t.Fatalf("closed = %d, want 1 — crashed bead with stale creation_complete_at must be swept", closed) + if closed != 0 { + t.Fatalf("closed = %d, want 0", closed) + } + got, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Status == "closed" { + t.Fatalf("assigned pool bead was swept closed: %+v", got) } } -func TestSweepUndesiredPoolSessionBeads_SkipsPendingCreateClaim(t *testing.T) { +func TestSweepUndesiredPoolSessionBeads_SkipsPartialAssignedSnapshot(t *testing.T) { store := beads.NewMemStore() bead, err := store.Create(beads.Bead{ Title: "worker", @@ -710,7 +1446,7 @@ func TestSweepUndesiredPoolSessionBeads_SkipsPendingCreateClaim(t *testing.T) { "agent_name": "worker", "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), - "pending_create_claim": "true", + "state": "drained", "continuation_epoch": "1", "generation": "1", }, @@ -727,66 +1463,108 @@ func TestSweepUndesiredPoolSessionBeads_SkipsPendingCreateClaim(t *testing.T) { nil, &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, runtime.NewFake(), - false, + true, ) if closed != 0 { - t.Fatalf("closed = %d, want 0 — pending_create_claim must be preserved", closed) + t.Fatalf("closed = %d, want 0", closed) } got, err := store.Get(bead.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.Status == "closed" { - t.Fatalf("bead with pending_create_claim was swept closed: %+v", got) + t.Fatalf("partial assigned-work snapshot should suppress sweep: %+v", got) } } -// pending_create_claim is an authoritative ownership flag for the lifecycle -// reconciler (sessionStartRequested in session_reconcile.go). The sweep must -// honor that contract regardless of age — expiring it here would let the -// sweep close a bead the reconciler still considers live. -func TestSweepUndesiredPoolSessionBeads_SkipsStalePendingCreateClaim(t *testing.T) { +func TestCityRuntimeBeadReconcileTick_TransientStoreQueryPartialKeepsRunningPoolSessionUntilRecoveryTick(t *testing.T) { store := beads.NewMemStore() - bead, err := store.Create(beads.Bead{ + session, err := store.Create(beads.Bead{ Title: "worker", Type: sessionBeadType, + Status: "open", Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ - "session_name": "worker-bd-stale-claim", + "session_name": "worker-bd-123", "template": "worker", "agent_name": "worker", "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), - "pending_create_claim": "true", + "state": "awake", "continuation_epoch": "1", "generation": "1", }, }) if err != nil { - t.Fatalf("Create: %v", err) + t.Fatalf("Create session bead: %v", err) } - bead.CreatedAt = time.Now().Add(-2 * time.Minute) - sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) - closed := sweepUndesiredPoolSessionBeads( - store, - nil, - sessionBeads, - nil, - &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, - runtime.NewFake(), - false, - ) - if closed != 0 { - t.Fatalf("closed = %d, want 0 — pending_create_claim must remain authoritative regardless of age", closed) + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker-bd-123", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + + cr := &CityRuntime{ + cityPath: t.TempDir(), + cityName: "maintainer-city", + cfg: &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, + sp: sp, + standaloneCityStore: store, + sessionDrains: newDrainTracker(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, + } + + partialResult := DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + StoreQueryPartial: true, + } + cr.beadReconcileTick(context.Background(), partialResult, newSessionBeadSnapshot([]beads.Bead{session}), nil) + + afterPartial, err := store.Get(session.ID) + if err != nil { + t.Fatalf("Get after partial tick: %v", err) + } + if afterPartial.Status == "closed" { + t.Fatalf("partial tick closed running session: %+v", afterPartial) + } + if !sp.IsRunning("worker-bd-123") { + t.Fatal("partial tick should not stop the running worker") + } + + recoveredResult := DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + AssignedWorkBeads: []beads.Bead{ + workBead("ga-live", "worker", "worker-bd-123", "in_progress", 5), + }, + } + cr.beadReconcileTick(context.Background(), recoveredResult, cr.loadSessionBeadSnapshot(), nil) + + afterRecovered, err := store.Get(session.ID) + if err != nil { + t.Fatalf("Get after recovered tick: %v", err) + } + if afterRecovered.Status == "closed" { + t.Fatalf("recovered tick closed running session: %+v", afterRecovered) + } + if state := afterRecovered.Metadata["state"]; state == "drained" || state == "asleep" { + t.Fatalf("recovered tick state = %q, want active/awake", state) + } + if !sp.IsRunning("worker-bd-123") { + t.Fatal("recovered tick should keep the worker running") } } -func TestSweepUndesiredPoolSessionBeads_ClosesStoppedSessions(t *testing.T) { +func TestCityRuntimeBeadReconcileTick_ScaleCheckPartialKeepsOnlyAffectedPoolSession(t *testing.T) { store := beads.NewMemStore() - bead, err := store.Create(beads.Bead{ + worker, err := store.Create(beads.Bead{ + ID: "session-worker", Title: "worker", Type: sessionBeadType, + Status: "open", Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ "session_name": "worker-bd-123", @@ -794,42 +1572,101 @@ func TestSweepUndesiredPoolSessionBeads_ClosesStoppedSessions(t *testing.T) { "agent_name": "worker", "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), - "state": "drained", - "continuation_epoch": "1", + "state": "awake", "generation": "1", }, }) if err != nil { - t.Fatalf("Create: %v", err) + t.Fatalf("Create worker session: %v", err) + } + helper, err := store.Create(beads.Bead{ + ID: "session-helper", + Title: "helper", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "agent:helper"}, + Metadata: map[string]string{ + "session_name": "helper-bd-123", + "template": "helper", + "agent_name": "helper", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", + "generation": "1", + }, + }) + if err != nil { + t.Fatalf("Create helper session: %v", err) } - sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) - closed := sweepUndesiredPoolSessionBeads( - store, - nil, - sessionBeads, - nil, - &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, - runtime.NewFake(), - false, - ) - if closed != 1 { - t.Fatalf("closed = %d, want 1", closed) + sp := runtime.NewFake() + for _, name := range []string{"worker-bd-123", "helper-bd-123"} { + if err := sp.Start(context.Background(), name, runtime.Config{}); err != nil { + t.Fatalf("Start(%s): %v", name, err) + } } - got, err := store.Get(bead.ID) - if err != nil { - t.Fatalf("Get: %v", err) + + cityPath := t.TempDir() + cfg := &config.City{Agents: []config.Agent{ + { + Name: "worker", + StartCommand: "echo", + ScaleCheck: "exit 42", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + }, + { + Name: "helper", + StartCommand: "echo", + ScaleCheck: "printf 0", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + }, + }} + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "maintainer-city", + cfg: cfg, + sp: sp, + standaloneCityStore: store, + sessionDrains: newDrainTracker(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, } - if got.Status != "closed" { - t.Fatalf("stopped pool bead status = %q, want closed", got.Status) + + snapshot := newSessionBeadSnapshot([]beads.Bead{worker, helper}) + var stderr strings.Builder + result := buildDesiredStateWithSessionBeads("maintainer-city", cityPath, time.Now().UTC(), cfg, sp, store, nil, snapshot, nil, &stderr) + if result.StoreQueryPartial { + t.Fatalf("StoreQueryPartial = true, want false for scoped scale_check failure; stderr=%s", stderr.String()) + } + if !result.ScaleCheckPartialTemplates["worker"] || result.ScaleCheckPartialTemplates["helper"] { + t.Fatalf("ScaleCheckPartialTemplates = %v, want only worker", result.ScaleCheckPartialTemplates) + } + cr.beadReconcileTick(context.Background(), result, snapshot, nil) + + if drain := cr.sessionDrains.get(worker.ID); drain != nil { + t.Fatalf("affected worker session was scheduled for drain: reason=%s", drain.reason) + } + if cr.sessionDrains.get(helper.ID) == nil { + t.Fatal("unaffected helper session was not scheduled for drain") + } + if !sp.IsRunning("worker-bd-123") { + t.Fatal("affected worker session should remain running") + } + if !sp.IsRunning("helper-bd-123") { + t.Fatal("helper drain should be asynchronous and not stop immediately") } } -func TestSweepUndesiredPoolSessionBeads_KeepsAssignedSessionsOpen(t *testing.T) { +func TestCityRuntimeBeadReconcileTick_ScaleCheckPartialPreservesDormantAffectedPoolSessionWithoutDrain(t *testing.T) { store := beads.NewMemStore() - bead, err := store.Create(beads.Bead{ + worker, err := store.Create(beads.Bead{ + ID: "session-worker", Title: "worker", Type: sessionBeadType, + Status: "open", Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ "session_name": "worker-bd-123", @@ -838,119 +1675,138 @@ func TestSweepUndesiredPoolSessionBeads_KeepsAssignedSessionsOpen(t *testing.T) "pool_slot": "1", poolManagedMetadataKey: boolMetadata(true), "state": "asleep", - "continuation_epoch": "1", "generation": "1", }, }) if err != nil { - t.Fatalf("Create session bead: %v", err) + t.Fatalf("Create worker session: %v", err) } - if _, err := store.Create(beads.Bead{ - Title: "assigned work", - Type: "task", - Status: "in_progress", - Assignee: "worker-bd-123", - }); err != nil { - t.Fatalf("Create work bead: %v", err) + + sp := runtime.NewFake() + cityPath := t.TempDir() + cfg := &config.City{Agents: []config.Agent{{ + Name: "worker", + StartCommand: "echo", + ScaleCheck: "exit 42", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(5), + }}} + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "maintainer-city", + cfg: cfg, + sp: sp, + standaloneCityStore: store, + sessionDrains: newDrainTracker(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, } - sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) - closed := sweepUndesiredPoolSessionBeads( - store, - nil, - sessionBeads, - nil, - &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, - runtime.NewFake(), - false, - ) - if closed != 0 { - t.Fatalf("closed = %d, want 0", closed) + snapshot := newSessionBeadSnapshot([]beads.Bead{worker}) + var stderr strings.Builder + result := buildDesiredStateWithSessionBeads("maintainer-city", cityPath, time.Now().UTC(), cfg, sp, store, nil, snapshot, nil, &stderr) + if _, ok := result.State["worker-bd-123"]; !ok { + t.Fatalf("affected dormant worker session not preserved in desired state: keys=%v stderr=%s", mapKeys(result.State), stderr.String()) } - got, err := store.Get(bead.ID) + + cr.beadReconcileTick(context.Background(), result, snapshot, nil) + + if drain := cr.sessionDrains.get(worker.ID); drain != nil { + t.Fatalf("affected dormant worker session was scheduled for drain: reason=%s", drain.reason) + } + got, err := store.Get(worker.ID) if err != nil { - t.Fatalf("Get: %v", err) + t.Fatalf("Get worker session: %v", err) } if got.Status == "closed" { - t.Fatalf("assigned pool bead was swept closed: %+v", got) + t.Fatalf("affected dormant worker session was closed: %+v", got) + } + if state := got.Metadata["state"]; state != "asleep" { + t.Fatalf("affected dormant worker state = %q, want asleep", state) + } + if sp.IsRunning("worker-bd-123") { + t.Fatal("affected dormant worker should not be woken by scale_check retention") } } -func TestSweepUndesiredPoolSessionBeads_SkipsPartialAssignedSnapshot(t *testing.T) { +func TestCityRuntimeBeadReconcileTick_StoreQueryPartialDoesNotReleaseAssignedWork(t *testing.T) { store := beads.NewMemStore() - bead, err := store.Create(beads.Bead{ - Title: "worker", - Type: sessionBeadType, - Labels: []string{sessionBeadLabel, "agent:worker"}, + work, err := store.Create(beads.Bead{ + ID: "ga-live", + Title: "live assigned work from partial snapshot", + Type: "task", + Status: "in_progress", + Assignee: "worker-session", Metadata: map[string]string{ - "session_name": "worker-bd-123", - "template": "worker", - "agent_name": "worker", - "pool_slot": "1", - poolManagedMetadataKey: boolMetadata(true), - "state": "drained", - "continuation_epoch": "1", - "generation": "1", + "gc.routed_to": "worker", }, }) if err != nil { - t.Fatalf("Create: %v", err) + t.Fatalf("Create work bead: %v", err) } - sessionBeads := newSessionBeadSnapshot([]beads.Bead{bead}) + inProgress := "in_progress" + if err := store.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("mark work in_progress: %v", err) + } + work.Status = inProgress - closed := sweepUndesiredPoolSessionBeads( - store, - nil, - sessionBeads, - nil, - &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, - runtime.NewFake(), - true, - ) - if closed != 0 { - t.Fatalf("closed = %d, want 0", closed) + cr := &CityRuntime{ + cityPath: t.TempDir(), + cityName: "maintainer-city", + cfg: &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, + sp: runtime.NewFake(), + standaloneCityStore: store, + sessionDrains: newDrainTracker(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, } - got, err := store.Get(bead.ID) + + cr.beadReconcileTick(context.Background(), DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + AssignedWorkBeads: []beads.Bead{work}, + AssignedWorkStores: []beads.Store{store}, + StoreQueryPartial: true, + }, newSessionBeadSnapshot(nil), nil) + + got, err := store.Get(work.ID) if err != nil { - t.Fatalf("Get: %v", err) + t.Fatalf("Get work after partial tick: %v", err) } - if got.Status == "closed" { - t.Fatalf("partial assigned-work snapshot should suppress sweep: %+v", got) + if got.Status != "in_progress" || got.Assignee != "worker-session" { + t.Fatalf("partial assigned-work snapshot released work: status=%q assignee=%q", got.Status, got.Assignee) } } -func TestCityRuntimeBeadReconcileTick_TransientStoreQueryPartialKeepsRunningPoolSessionUntilRecoveryTick(t *testing.T) { - store := beads.NewMemStore() - session, err := store.Create(beads.Bead{ - Title: "worker", - Type: sessionBeadType, - Status: "open", - Labels: []string{sessionBeadLabel, "agent:worker"}, +func TestCityRuntimeBeadReconcileTick_SessionQueryPartialDoesNotReleaseAssignedWork(t *testing.T) { + base := beads.NewMemStore() + store := sessionSnapshotListFailStore{Store: base} + work, err := base.Create(beads.Bead{ + ID: "ga-live", + Title: "live assigned work from partial session snapshot", + Type: "task", + Status: "in_progress", + Assignee: "worker-session", Metadata: map[string]string{ - "session_name": "worker-bd-123", - "template": "worker", - "agent_name": "worker", - "pool_slot": "1", - poolManagedMetadataKey: boolMetadata(true), - "state": "awake", - "continuation_epoch": "1", - "generation": "1", + "gc.routed_to": "worker", }, }) if err != nil { - t.Fatalf("Create session bead: %v", err) + t.Fatalf("Create work bead: %v", err) } - - sp := runtime.NewFake() - if err := sp.Start(context.Background(), "worker-bd-123", runtime.Config{}); err != nil { - t.Fatalf("Start: %v", err) + inProgress := "in_progress" + if err := base.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("mark work in_progress: %v", err) } + work.Status = inProgress cr := &CityRuntime{ cityPath: t.TempDir(), cityName: "maintainer-city", cfg: &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, - sp: sp, + sp: runtime.NewFake(), standaloneCityStore: store, sessionDrains: newDrainTracker(), rec: events.Discard, @@ -958,45 +1814,19 @@ func TestCityRuntimeBeadReconcileTick_TransientStoreQueryPartialKeepsRunningPool stderr: io.Discard, } - partialResult := DesiredStateResult{ - State: map[string]TemplateParams{}, - ScaleCheckCounts: map[string]int{"worker": 0}, - StoreQueryPartial: true, - } - cr.beadReconcileTick(context.Background(), partialResult, newSessionBeadSnapshot([]beads.Bead{session}), nil) - - afterPartial, err := store.Get(session.ID) - if err != nil { - t.Fatalf("Get after partial tick: %v", err) - } - if afterPartial.Status == "closed" { - t.Fatalf("partial tick closed running session: %+v", afterPartial) - } - if !sp.IsRunning("worker-bd-123") { - t.Fatal("partial tick should not stop the running worker") - } - - recoveredResult := DesiredStateResult{ - State: map[string]TemplateParams{}, - ScaleCheckCounts: map[string]int{"worker": 0}, - AssignedWorkBeads: []beads.Bead{ - workBead("ga-live", "worker", "worker-bd-123", "in_progress", 5), - }, - } - cr.beadReconcileTick(context.Background(), recoveredResult, cr.loadSessionBeadSnapshot(), nil) + cr.beadReconcileTick(context.Background(), DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + AssignedWorkBeads: []beads.Bead{work}, + AssignedWorkStores: []beads.Store{store}, + }, nil, nil) - afterRecovered, err := store.Get(session.ID) + got, err := base.Get(work.ID) if err != nil { - t.Fatalf("Get after recovered tick: %v", err) - } - if afterRecovered.Status == "closed" { - t.Fatalf("recovered tick closed running session: %+v", afterRecovered) - } - if state := afterRecovered.Metadata["state"]; state == "drained" || state == "asleep" { - t.Fatalf("recovered tick state = %q, want active/awake", state) + t.Fatalf("Get work after partial tick: %v", err) } - if !sp.IsRunning("worker-bd-123") { - t.Fatal("recovered tick should keep the worker running") + if got.Status != "in_progress" || got.Assignee != "worker-session" { + t.Fatalf("partial session snapshot released work: status=%q assignee=%q", got.Status, got.Assignee) } } @@ -1091,7 +1921,7 @@ func TestCityRuntimeBeadReconcileTick_KeepsAssignedPoolWorkerAwake(t *testing.T) Status: "open", Labels: []string{sessionBeadLabel, "agent:gascity/claude"}, Metadata: map[string]string{ - "session_name": "claude-mc-live", + "session_name": "claude-real-world-app-live", "template": "gascity/claude", "agent_name": "gascity/claude", "pool_slot": "1", @@ -1106,7 +1936,7 @@ func TestCityRuntimeBeadReconcileTick_KeepsAssignedPoolWorkerAwake(t *testing.T) } sp := runtime.NewFake() - if err := sp.Start(context.Background(), "claude-mc-live", runtime.Config{}); err != nil { + if err := sp.Start(context.Background(), "claude-real-world-app-live", runtime.Config{}); err != nil { t.Fatalf("Start: %v", err) } @@ -1126,7 +1956,7 @@ func TestCityRuntimeBeadReconcileTick_KeepsAssignedPoolWorkerAwake(t *testing.T) State: map[string]TemplateParams{}, ScaleCheckCounts: map[string]int{"gascity/claude": 0}, AssignedWorkBeads: []beads.Bead{ - workBead("ga-live", "gascity/claude", "claude-mc-live", "in_progress", 5), + workBead("ga-live", "gascity/claude", "claude-real-world-app-live", "in_progress", 5), }, } @@ -1143,7 +1973,7 @@ func TestCityRuntimeBeadReconcileTick_KeepsAssignedPoolWorkerAwake(t *testing.T) if state := got.Metadata["state"]; state == "drained" || state == "asleep" { t.Fatalf("assigned pool worker state = %q, want active/awake", state) } - if !sp.IsRunning("claude-mc-live") { + if !sp.IsRunning("claude-real-world-app-live") { t.Fatal("assigned pool worker should still be running") } } @@ -1156,7 +1986,7 @@ func TestCityRuntimeBeadReconcileTick_SweepRespectsLiveAssignedWork(t *testing.T Status: "open", Labels: []string{sessionBeadLabel, "agent:worker"}, Metadata: map[string]string{ - "session_name": "worker-mc-live", + "session_name": "worker-real-world-app-live", "template": "worker", "agent_name": "worker", "pool_slot": "1", @@ -1179,7 +2009,7 @@ func TestCityRuntimeBeadReconcileTick_SweepRespectsLiveAssignedWork(t *testing.T Title: "future work", Type: "task", Status: "open", - Assignee: "worker-mc-live", + Assignee: "worker-real-world-app-live", Metadata: map[string]string{"gc.routed_to": "worker"}, }); err != nil { t.Fatalf("Create work bead: %v", err) @@ -1493,7 +2323,7 @@ func TestCityRuntimeReloadProviderSwapPreservesDrainTracker(t *testing.T) { } sp := runtime.NewFake() var stdout bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1542,7 +2372,7 @@ func TestCityRuntimeReloadProviderSwapFailsOnPartialSessionListing(t *testing.T) } var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1592,7 +2422,7 @@ func TestCityRuntimeReloadProviderSwapFailsOnSessionListingError(t *testing.T) { } var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1639,7 +2469,7 @@ func TestCityRuntimeReloadAllowsRegistryAliasDifferentFromWorkspaceName(t *testi sp := runtime.NewFake() var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "machine-alias", TomlPath: tomlPath, @@ -1683,7 +2513,7 @@ func TestCityRuntimeReloadLifecycleFailureKeepsOldConfig(t *testing.T) { sp := runtime.NewFake() var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1768,7 +2598,7 @@ func TestCityRuntimeReloadRetriesTransientLifecycleFailure(t *testing.T) { sp := runtime.NewFake() var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1860,7 +2690,7 @@ func TestCityRuntimeReloadStrictWarningsReturnedOnFailure(t *testing.T) { sp := runtime.NewFake() var stdout bytes.Buffer var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -1934,7 +2764,7 @@ func TestCityRuntimeReloadNonStrictWarningsReturnedOnValidationFailure(t *testin } sp := runtime.NewFake() var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2022,36 +2852,208 @@ func TestCityRuntimeHandleReloadRequestInitializesConfigDirty(t *testing.T) { doneCh: make(chan reloadControlReply, 1), } cr := &CityRuntime{ - pokeCh: make(chan struct{}, 1), + pokeCh: make(chan struct{}, 1), + } + + cr.handleReloadRequest(req) + + if cr.configDirty == nil { + t.Fatal("configDirty was not initialized") + } + if !cr.configDirty.Load() { + t.Fatal("configDirty = false, want reload request to mark dirty") + } + if cr.activeReload != req { + t.Fatal("activeReload was not recorded") + } + select { + case <-cr.pokeCh: + default: + t.Fatal("reload request did not enqueue poke") + } + select { + case reply := <-acceptedCh: + if reply.Outcome != reloadOutcomeAccepted { + t.Fatalf("reply.Outcome = %q, want %q", reply.Outcome, reloadOutcomeAccepted) + } + default: + t.Fatal("reload request did not receive accepted reply") + } +} + +func TestCityRuntimeReloadSameRevisionIsNoOp(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") + + cfg, configRev := loadCityRuntimeControllerConfig(t, cityPath) + + sp := runtime.NewFake() + var stdout bytes.Buffer + cr := newTestCityRuntime(t, CityRuntimeParams{ + CityPath: cityPath, + CityName: "test-city", + TomlPath: tomlPath, + ConfigRev: configRev, + Cfg: cfg, + SP: sp, + BuildFn: func(*config.City, runtime.Provider, beads.Store) DesiredStateResult { + return DesiredStateResult{State: map[string]TemplateParams{}} + }, + Dops: newDrainOps(sp), + Rec: events.Discard, + Stdout: &stdout, + Stderr: io.Discard, + }) + + oldCfg := cr.cfg + lastProviderName := "fake" + cr.reloadConfig(context.Background(), &lastProviderName, cityPath) + + if cr.cfg != oldCfg { + t.Fatal("same-revision reload should keep existing config pointer") + } + if cr.configRev != configRev { + t.Fatalf("configRev = %q, want %q", cr.configRev, configRev) + } + if lastProviderName != "fake" { + t.Fatalf("lastProviderName = %q, want fake", lastProviderName) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty for same-revision reload", stdout.String()) + } +} + +func TestCityRuntimeReloadRetainsTimedOutDispatcherForShutdownDrain(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") + + cfg, configRev := loadCityRuntimeControllerConfig(t, cityPath) + + od := newBlockingOrderDispatcher() + var stdout bytes.Buffer + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "test-city", + tomlPath: tomlPath, + configRev: configRev, + cfg: cfg, + sp: runtime.NewFake(), + dops: newDrainOps(runtime.NewFake()), + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: io.Discard, + configName: "test-city", + } + + writeCityRuntimeConfigWithShutdownTimeout(t, tomlPath, "fake", "1s") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + lastProviderName := "fake" + cr.reloadConfig(ctx, &lastProviderName, cityPath) + od.waitForDrainCalls(t, 1) + + shutdownDone := make(chan struct{}) + go func() { + cr.shutdown() + close(shutdownDone) + }() + od.waitForDrainCalls(t, 2) + close(od.release) + select { + case <-shutdownDone: + case <-time.After(2 * time.Second): + t.Fatal("shutdown did not return after retained dispatcher was released") + } +} + +func TestCityRuntimeReloadDrainShortCircuitsOnTickContextCancel(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") + + cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + + od := newBlockingOrderDispatcher() + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "test-city", + tomlPath: tomlPath, + configRev: configRev, + cfg: cfg, + sp: runtime.NewFake(), + dops: newDrainOps(runtime.NewFake()), + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: io.Discard, + stderr: io.Discard, + configName: "test-city", + } + + writeCityRuntimeConfigWithShutdownTimeout(t, tomlPath, "fake", "1s") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + lastProviderName := "fake" + start := time.Now() + cr.reloadConfig(ctx, &lastProviderName, cityPath) + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("reload drain took %s after tick context cancellation, want <200ms", elapsed) } + errs := od.drainContextErrors() + if len(errs) == 0 || !errors.Is(errs[0], context.Canceled) { + t.Fatalf("drain ctx error = %v, want context.Canceled", errs) + } + close(od.release) +} - cr.handleReloadRequest(req) +func TestCityRuntimeReloadDrainBoundedByTimeout(t *testing.T) { + cityPath := t.TempDir() + tomlPath := filepath.Join(cityPath, "city.toml") + writeCityRuntimeConfig(t, tomlPath, "fake") - if cr.configDirty == nil { - t.Fatal("configDirty was not initialized") - } - if !cr.configDirty.Load() { - t.Fatal("configDirty = false, want reload request to mark dirty") - } - if cr.activeReload != req { - t.Fatal("activeReload was not recorded") - } - select { - case <-cr.pokeCh: - default: - t.Fatal("reload request did not enqueue poke") + cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath) + if err != nil { + t.Fatalf("load config: %v", err) } - select { - case reply := <-acceptedCh: - if reply.Outcome != reloadOutcomeAccepted { - t.Fatalf("reply.Outcome = %q, want %q", reply.Outcome, reloadOutcomeAccepted) - } - default: - t.Fatal("reload request did not receive accepted reply") + configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + + od := newBlockingOrderDispatcher() + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "test-city", + tomlPath: tomlPath, + configRev: configRev, + cfg: cfg, + sp: runtime.NewFake(), + dops: newDrainOps(runtime.NewFake()), + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: io.Discard, + stderr: io.Discard, + configName: "test-city", + } + + writeCityRuntimeConfigWithShutdownTimeout(t, tomlPath, "fake", "1s") + lastProviderName := "fake" + start := time.Now() + cr.reloadConfig(context.Background(), &lastProviderName, cityPath) + elapsed := time.Since(start) + if elapsed < reloadOrderDrainTimeout || elapsed > reloadOrderDrainTimeout+500*time.Millisecond { + t.Fatalf("reload elapsed = %s, want bounded near %s", elapsed, reloadOrderDrainTimeout) } + close(od.release) } -func TestCityRuntimeReloadSameRevisionIsNoOp(t *testing.T) { +func TestCityRuntimeRunReloadsConfigBeforeStartupReconcile(t *testing.T) { cityPath := t.TempDir() tomlPath := filepath.Join(cityPath, "city.toml") writeCityRuntimeConfig(t, tomlPath, "fake") @@ -2062,8 +3064,26 @@ func TestCityRuntimeReloadSameRevisionIsNoOp(t *testing.T) { } configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + if err := os.WriteFile(tomlPath, []byte(`[workspace] +name = "test-city" + +[beads] +provider = "file" + +[session] +provider = "fake" + +[[agent]] +name = "fresh-agent" +`), 0o644); err != nil { + t.Fatalf("write updated config: %v", err) + } + sp := runtime.NewFake() - var stdout bytes.Buffer + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var sawFreshAgent atomic.Bool cr := newCityRuntime(CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", @@ -2071,30 +3091,28 @@ func TestCityRuntimeReloadSameRevisionIsNoOp(t *testing.T) { ConfigRev: configRev, Cfg: cfg, SP: sp, - BuildFn: func(*config.City, runtime.Provider, beads.Store) DesiredStateResult { + BuildFn: func(cfg *config.City, _ runtime.Provider, _ beads.Store) DesiredStateResult { + for _, agent := range cfg.Agents { + if agent.Name == "fresh-agent" { + sawFreshAgent.Store(true) + } + } + cancel() return DesiredStateResult{State: map[string]TemplateParams{}} }, Dops: newDrainOps(sp), Rec: events.Discard, - Stdout: &stdout, + Stdout: io.Discard, Stderr: io.Discard, }) + cs := newControllerState(context.Background(), cfg, sp, events.NewFake(), "test-city", cityPath) + cs.cityBeadStore = beads.NewMemStore() + cr.setControllerState(cs) - oldCfg := cr.cfg - lastProviderName := "fake" - cr.reloadConfig(context.Background(), &lastProviderName, cityPath) + cr.run(ctx) - if cr.cfg != oldCfg { - t.Fatal("same-revision reload should keep existing config pointer") - } - if cr.configRev != configRev { - t.Fatalf("configRev = %q, want %q", cr.configRev, configRev) - } - if lastProviderName != "fake" { - t.Fatalf("lastProviderName = %q, want fake", lastProviderName) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty for same-revision reload", stdout.String()) + if !sawFreshAgent.Load() { + t.Fatalf("startup did not see reloaded fresh-agent; agents = %#v", cr.cfg.Agents) } } @@ -2109,7 +3127,7 @@ func TestNewCityRuntimeUsesRegisteredAliasForEffectiveIdentity(t *testing.T) { } sp := runtime.NewFake() - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "machine-alias", TomlPath: tomlPath, @@ -2140,14 +3158,10 @@ func TestCityRuntimeReloadKeepsRegisteredAliasForEffectiveIdentity(t *testing.T) tomlPath := filepath.Join(cityPath, "city.toml") writeCityRuntimeConfigNamed(t, tomlPath, "declared-city", "fake") - cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath) - if err != nil { - t.Fatalf("load config: %v", err) - } - configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + cfg, configRev := loadCityRuntimeControllerConfig(t, cityPath) sp := runtime.NewFake() - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "machine-alias", TomlPath: tomlPath, @@ -2193,18 +3207,14 @@ func TestCityRuntimeManualReloadReplyWaitsForTickCompletion(t *testing.T) { tomlPath := filepath.Join(cityPath, "city.toml") writeCityRuntimeConfig(t, tomlPath, "fake") - cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath) - if err != nil { - t.Fatalf("load config: %v", err) - } - configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + cfg, configRev := loadCityRuntimeControllerConfig(t, cityPath) doneCh := make(chan reloadControlReply, 1) dirty := &atomic.Bool{} dirty.Store(true) sp := runtime.NewFake() var stdout bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2271,7 +3281,7 @@ func TestCityRuntimeReloadRestartsConfigWatcherWithNewPackTargets(t *testing.T) dirty := &atomic.Bool{} pokeCh := make(chan struct{}, 8) var stdout, stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2339,18 +3349,14 @@ func TestCityRuntimeManualReloadPanicAfterReloadKeepsReloadReplyAndClears(t *tes tomlPath := filepath.Join(cityPath, "city.toml") writeCityRuntimeConfig(t, tomlPath, "fake") - cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath) - if err != nil { - t.Fatalf("load config: %v", err) - } - configRev := config.Revision(fsys.OSFS{}, prov, cfg, cityPath) + cfg, configRev := loadCityRuntimeControllerConfig(t, cityPath) doneCh := make(chan reloadControlReply, 1) dirty := &atomic.Bool{} dirty.Store(true) sp := runtime.NewFake() var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2405,7 +3411,7 @@ func TestCityRuntimeWatchReloadPanicRestoresDirty(t *testing.T) { dirty.Store(true) sp := runtime.NewFake() var stderr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2448,9 +3454,10 @@ func TestCityRuntimeRunStopsBeforeStartedWhenCanceledDuringStartup(t *testing.T) sp := runtime.NewFake() var stdout bytes.Buffer var started bool + od := &recordingOrderDispatcher{} ctx, cancel := context.WithCancel(context.Background()) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2466,6 +3473,7 @@ func TestCityRuntimeRunStopsBeforeStartedWhenCanceledDuringStartup(t *testing.T) Stdout: &stdout, Stderr: io.Discard, }) + cr.od = od cs := newControllerState(context.Background(), cfg, sp, events.NewFake(), "test-city", cityPath) cs.cityBeadStore = beads.NewMemStore() @@ -2476,6 +3484,9 @@ func TestCityRuntimeRunStopsBeforeStartedWhenCanceledDuringStartup(t *testing.T) if started { t.Fatal("OnStarted called after cancellation") } + if got := od.calls.Load(); got != 1 { + t.Fatalf("order dispatch calls = %d, want startup dispatch before cancellation", got) + } if strings.Contains(stdout.String(), "City started.") { t.Fatalf("stdout = %q, want no started banner after cancellation", stdout.String()) } @@ -2566,7 +3577,7 @@ func TestCityRuntimeRun_PanicInStartupDoesNotShutdownCity(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2635,7 +3646,7 @@ func TestCityRuntimeRun_RetriesStartupAfterRecoveredPanicBeforeStarted(t *testin ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2730,7 +3741,7 @@ func TestCityRuntimeRun_ConvergenceStartupErrorDoesNotBlockStarted(t *testing.T) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2791,7 +3802,7 @@ func TestCityRuntimeRun_RetriesConvergenceStartupUntilIndexPopulated(t *testing. ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2862,7 +3873,7 @@ func TestCityRuntimeRunShutsDownSessionsOnContextCancel(t *testing.T) { var stdout bytes.Buffer ctx, cancel := context.WithCancel(context.Background()) - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "test-city", TomlPath: tomlPath, @@ -2902,6 +3913,253 @@ func TestCityRuntimeRunShutsDownSessionsOnContextCancel(t *testing.T) { } } +// orderingFakeProvider appends "stop:" to seq when Stop is called so +// tests can assert ordering relative to other lifecycle events. +type orderingFakeProvider struct { + *runtime.Fake + mu sync.Mutex + seq []string +} + +func (p *orderingFakeProvider) Stop(name string) error { + p.mu.Lock() + p.seq = append(p.seq, "stop:"+name) + p.mu.Unlock() + return p.Fake.Stop(name) +} + +func (p *orderingFakeProvider) events() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.seq...) +} + +type interruptStopsProvider struct { + *runtime.Fake +} + +func (p *interruptStopsProvider) Interrupt(name string) error { + if err := p.Fake.Interrupt(name); err != nil { + return err + } + return p.Stop(name) +} + +// TestCityRuntimeShutdownDrainsOrderDispatch verifies shutdown invokes +// orderDispatcher.drain with a fresh (non-canceled) context before +// stopping sessions — regression for #991. +func TestCityRuntimeShutdownDrainsOrderDispatch(t *testing.T) { + cfg := &config.City{} + cfg.Daemon.ShutdownTimeout = "1s" + + sp := runtime.NewFake() + od := &recordingOrderDispatcher{} + + var stdout, stderr bytes.Buffer + cr := &CityRuntime{ + cfg: cfg, + sp: sp, + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: &stderr, + } + + cr.shutdown() + + if od.drainCalls != 1 { + t.Fatalf("drainCalls = %d, want 1", od.drainCalls) + } + if od.drainCtxErr != nil { + t.Fatalf("drain received a canceled ctx (%v); shutdown must pass a fresh context", od.drainCtxErr) + } +} + +func TestCityRuntimeShutdownPreservesFullGracefulBudgetWithOrders(t *testing.T) { + cfg := &config.City{} + cfg.Daemon.ShutdownTimeout = "1s" + + sp := &interruptStopsProvider{Fake: runtime.NewFake()} + if err := sp.Start(context.Background(), "probe", runtime.Config{}); err != nil { + t.Fatalf("start session: %v", err) + } + od := &recordingOrderDispatcher{} + + var stdout, stderr bytes.Buffer + cr := &CityRuntime{ + cfg: cfg, + sp: sp, + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: &stderr, + } + + cr.shutdown() + + if !strings.Contains(stdout.String(), "waiting 1s") { + t.Fatalf("stdout = %q, want full 1s graceful session budget", stdout.String()) + } +} + +// TestCityRuntimeShutdownBlockedDispatchPersistsOutcomeBeforeGracefulStop +// is the AC regression for #991: "a blocked/fake dispatch cannot let +// controller exit before the tracking bead is closed or failure metadata +// is persisted." It starts a real memoryOrderDispatcher, wedges its exec +// until after shutdown is invoked, and asserts both that the tracking +// bead is closed before shutdown returns AND that session Stop happens +// AFTER the dispatch finishes — proving drain blocks gracefulStopAll. +func TestCityRuntimeShutdownBlockedDispatchPersistsOutcomeBeforeGracefulStop(t *testing.T) { + store := beads.NewMemStore() + release := make(chan struct{}) + execStarted := make(chan struct{}) + execDone := make(chan struct{}) + + fakeExec := func(_ context.Context, _, _ string, _ []string) ([]byte, error) { + close(execStarted) + <-release + close(execDone) + return []byte("ok\n"), nil + } + + ad := buildOrderDispatcherFromListExec( + []orders.Order{{Name: "blocked", Trigger: "cooldown", Interval: "2m", Exec: "scripts/blocked.sh"}}, + store, nil, fakeExec, nil, + ) + if ad == nil { + t.Fatal("expected non-nil dispatcher") + } + + ad.dispatch(context.Background(), t.TempDir(), time.Now()) + <-execStarted + + sp := &orderingFakeProvider{Fake: runtime.NewFake()} + if err := sp.Start(context.Background(), "probe", runtime.Config{}); err != nil { + t.Fatalf("start session: %v", err) + } + + cfg := &config.City{} + cfg.Daemon.ShutdownTimeout = "200ms" + + var stdout, stderr bytes.Buffer + cr := &CityRuntime{ + cfg: cfg, + sp: sp, + od: ad, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: &stderr, + } + + shutdownDone := make(chan struct{}) + go func() { + cr.shutdown() + close(shutdownDone) + }() + + // shutdown must not return while exec is blocked. + select { + case <-shutdownDone: + t.Fatal("shutdown returned before drain waited for in-flight dispatch") + case <-time.After(100 * time.Millisecond): + } + + // Session must not have been stopped yet — drain is still waiting. + if got := sp.events(); len(got) != 0 { + t.Fatalf("session lifecycle ran before drain completed: %v", got) + } + + close(release) + <-execDone + + select { + case <-shutdownDone: + case <-time.After(5 * time.Second): + t.Fatal("shutdown did not return after dispatch completed") + } + + // Tracking bead outcome must be persisted before shutdown returned. + all, err := store.ListByLabel("order-run:blocked", 0, beads.IncludeClosed) + if err != nil { + t.Fatalf("ListByLabel: %v", err) + } + foundExecLabel := false + for _, b := range all { + for _, l := range b.Labels { + if l == "exec" { + foundExecLabel = true + } + } + } + if !foundExecLabel { + t.Fatalf("tracking bead missing exec outcome label after shutdown; beads=%+v", all) + } + + // gracefulStopAll must have run after drain. + got := sp.events() + if len(got) == 0 || got[0] != "stop:probe" { + t.Fatalf("expected stop:probe after drain, got %v", got) + } +} + +func TestCityRuntimeShutdownPreservesFullGracefulBudgetWhenNoOrders(t *testing.T) { + cfg := &config.City{} + cfg.Daemon.ShutdownTimeout = "1s" + + sp := &interruptStopsProvider{Fake: runtime.NewFake()} + if err := sp.Start(context.Background(), "probe", runtime.Config{}); err != nil { + t.Fatalf("start session: %v", err) + } + var stdout, stderr bytes.Buffer + cr := &CityRuntime{ + cfg: cfg, + sp: sp, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: &stderr, + } + + cr.shutdown() + + if !strings.Contains(stdout.String(), "waiting 1s") { + t.Fatalf("stdout = %q, want full 1s graceful session budget", stdout.String()) + } +} + +func TestCityRuntimeShutdownZeroTimeoutDoesNotWaitForOrderDrain(t *testing.T) { + cfg := &config.City{} + cfg.Daemon.ShutdownTimeout = "0s" + + od := newBlockingOrderDispatcher() + var stdout, stderr bytes.Buffer + cr := &CityRuntime{ + cfg: cfg, + sp: runtime.NewFake(), + od: od, + rec: events.Discard, + logPrefix: "gc start", + stdout: &stdout, + stderr: &stderr, + } + + done := make(chan struct{}) + go func() { + cr.shutdown() + close(done) + }() + + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("shutdown waited on order drain despite shutdown_timeout=0s") + } + close(od.release) +} + func TestCityRuntimeShutdownWarnsWhenSessionListingIsPartial(t *testing.T) { sp := &partialListPoolProvider{ Fake: runtime.NewFake(), @@ -2944,17 +4202,37 @@ func TestCityRuntimeShutdownWarnsWhenSessionListingIsPartial(t *testing.T) { func writeCityRuntimeConfig(t *testing.T, tomlPath, provider string) { t.Helper() + clearInheritedBeadsEnv(t) writeCityRuntimeConfigNamed(t, tomlPath, "test-city", provider) } +func loadCityRuntimeControllerConfig(t *testing.T, cityPath string) (*config.City, string) { + t.Helper() + cfg, prov, err := loadCityConfigWithBuiltinPacks(cityPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + applyFeatureFlags(cfg) + return cfg, config.Revision(fsys.OSFS{}, prov, cfg, cityPath) +} + func writeCityRuntimeConfigNamed(t *testing.T, tomlPath, name, provider string) { t.Helper() + clearInheritedBeadsEnv(t) data := []byte("[workspace]\nname = \"" + name + "\"\n\n[beads]\nprovider = \"file\"\n\n[session]\nprovider = \"" + provider + "\"\n") if err := os.WriteFile(tomlPath, data, 0o644); err != nil { t.Fatalf("write config: %v", err) } } +func writeCityRuntimeConfigWithShutdownTimeout(t *testing.T, tomlPath, provider, timeout string) { + t.Helper() + data := []byte("[workspace]\nname = \"test-city\"\n\n[beads]\nprovider = \"file\"\n\n[session]\nprovider = \"" + provider + "\"\n\n[daemon]\nshutdown_timeout = \"" + timeout + "\"\n") + if err := os.WriteFile(tomlPath, data, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } +} + func warningsContain(warnings []string, substr string) bool { for _, warning := range warnings { if strings.Contains(warning, substr) { @@ -2966,6 +4244,7 @@ func warningsContain(warnings []string, substr string) bool { func writeCityRuntimeConfigWithIncludes(t *testing.T, tomlPath string, includes []string) { t.Helper() + clearInheritedBeadsEnv(t) var quoted []string for _, include := range includes { quoted = append(quoted, fmt.Sprintf("%q", include)) diff --git a/cmd/gc/city_status_snapshot.go b/cmd/gc/city_status_snapshot.go index d736b69c58..e1dfd56c35 100644 --- a/cmd/gc/city_status_snapshot.go +++ b/cmd/gc/city_status_snapshot.go @@ -56,6 +56,17 @@ func openCityStatusStore(cityPath string, stderr io.Writer) (beads.Store, int) { } func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath string, store beads.Store, stderr io.Writer) cityStatusSnapshot { + return collectCityStatusSnapshotFromStoreSnapshot(sp, cfg, cityPath, store, loadStatusSessionSnapshot(store), stderr) +} + +func collectCityStatusSnapshotFromStoreSnapshot( + sp runtime.Provider, + cfg *config.City, + cityPath string, + store beads.Store, + statusSnapshot *sessionBeadSnapshot, + stderr io.Writer, +) cityStatusSnapshot { suspended := os.Getenv("GC_SUSPENDED") == "1" if cfg != nil { suspended = citySuspended(cfg) @@ -66,6 +77,7 @@ func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath s Suspended: suspended, } snapshot.CityName = loadedCityName(cfg, cityPath) + registerStatusProviderACPRoutes(sp, statusSnapshot, snapshot.CityName, cfg) if cfg == nil { return snapshot } @@ -109,8 +121,8 @@ func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath s scaleLabel := fmt.Sprintf("scaled (min=%d, %s)", sp0.Min, maxDisplay) headerShown := false for _, qualifiedInstance := range discoverPoolInstances(a.Name, a.Dir, sp0, &a, snapshot.CityName, cfg.Workspace.SessionTemplate, sp) { - sn := cliSessionName(cityPath, snapshot.CityName, qualifiedInstance, cfg.Workspace.SessionTemplate) - obs := observeSessionTargetWithWarning("gc status", cityPath, store, sp, cfg, sn, stderr) + target := statusObservationTargetForIdentity(statusSnapshot, snapshot.CityName, qualifiedInstance, cfg.Workspace.SessionTemplate) + obs := observeSessionTargetWithWarning("gc status", cityPath, store, sp, cfg, target, stderr) _, instanceName := config.ParseQualifiedName(qualifiedInstance) row := cityStatusAgentRow{ Agent: StatusAgentJSON{ @@ -121,7 +133,7 @@ func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath s Suspended: suspended || obs.Suspended, Pool: nil, }, - SessionName: sn, + SessionName: target.runtimeSessionName, GroupName: a.QualifiedName(), Expanded: true, } @@ -139,8 +151,8 @@ func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath s continue } - sn := cliSessionName(cityPath, snapshot.CityName, a.QualifiedName(), cfg.Workspace.SessionTemplate) - obs := observeSessionTargetWithWarning("gc status", cityPath, store, sp, cfg, sn, stderr) + target := statusObservationTargetForIdentity(statusSnapshot, snapshot.CityName, a.QualifiedName(), cfg.Workspace.SessionTemplate) + obs := observeSessionTargetWithWarning("gc status", cityPath, store, sp, cfg, target, stderr) snapshot.Agents = append(snapshot.Agents, cityStatusAgentRow{ Agent: StatusAgentJSON{ Name: a.Name, @@ -149,7 +161,7 @@ func collectCityStatusSnapshot(sp runtime.Provider, cfg *config.City, cityPath s Running: obs.Running, Suspended: suspended || obs.Suspended, }, - SessionName: sn, + SessionName: target.runtimeSessionName, GroupName: a.QualifiedName(), Expanded: false, }) diff --git a/cmd/gc/city_status_snapshot_test.go b/cmd/gc/city_status_snapshot_test.go index ee91c9f4a1..e90f375ccd 100644 --- a/cmd/gc/city_status_snapshot_test.go +++ b/cmd/gc/city_status_snapshot_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "errors" "io" "path/filepath" @@ -94,6 +95,252 @@ func (s *failingStatusStore) Get(id string) (beads.Bead, error) { return s.MemStore.Get(id) } +type getSpyStatusStore struct { + *beads.MemStore + ids []string +} + +func (s *getSpyStatusStore) Get(id string) (beads.Bead, error) { + s.ids = append(s.ids, id) + return s.MemStore.Get(id) +} + +func TestCityStatusAgentObservationDoesNotResolveRuntimeNamesThroughStore(t *testing.T) { + sp := runtime.NewFake() + store := &getSpyStatusStore{MemStore: beads.NewMemStore()} + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Agents: []config.Agent{ + {Name: "dog", MaxActiveSessions: intPtr(2)}, + }, + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 2 { + t.Fatalf("agents = %d, want 2", len(snapshot.Agents)) + } + if len(store.ids) != 0 { + t.Fatalf("status observation performed bead Get calls for runtime names: %v", store.ids) + } +} + +func TestCityStatusUsesBeadBackedRuntimeNameForSingletonAgent(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "custom-mayor", runtime.Config{Command: "echo"}); err != nil { + t.Fatalf("Start: %v", err) + } + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "mayor", + Type: session.BeadType, + Labels: []string{session.LabelSession, "agent:mayor"}, + Metadata: map[string]string{ + "agent_name": "mayor", + "template": "mayor", + "session_name": "custom-mayor", + "state": "active", + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Agents: []config.Agent{{Name: "mayor", MaxActiveSessions: intPtr(1)}}, + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 1 { + t.Fatalf("agents = %d, want 1", len(snapshot.Agents)) + } + if !snapshot.Agents[0].Agent.Running { + t.Fatalf("singleton agent running = false, want true with bead-backed runtime name") + } + if got := snapshot.Agents[0].SessionName; got != "custom-mayor" { + t.Fatalf("SessionName = %q, want %q", got, "custom-mayor") + } +} + +func TestCityStatusUsesSessionBackedObservationForSuspendedCustomRuntimeName(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "custom-mayor", runtime.Config{Command: "echo"}); err != nil { + t.Fatalf("Start: %v", err) + } + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "mayor", + Type: session.BeadType, + Labels: []string{session.LabelSession, "agent:mayor"}, + Metadata: map[string]string{ + "agent_name": "mayor", + "template": "mayor", + "session_name": "custom-mayor", + "state": string(session.StateSuspended), + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Agents: []config.Agent{{Name: "mayor", MaxActiveSessions: intPtr(1)}}, + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 1 { + t.Fatalf("agents = %d, want 1", len(snapshot.Agents)) + } + if !snapshot.Agents[0].Agent.Running { + t.Fatalf("running = false, want true") + } + if !snapshot.Agents[0].Agent.Suspended { + t.Fatalf("suspended = false, want true from session-backed observation") + } +} + +func TestCityStatusUsesStatusSnapshotToRouteACPDrainMetadata(t *testing.T) { + oldBuild := buildSessionProviderByName + t.Cleanup(func() { buildSessionProviderByName = oldBuild }) + + defaultSP := runtime.NewFake() + acpSP := runtime.NewFake() + buildSessionProviderByName = func(name string, _ config.SessionConfig, _, _ string) (runtime.Provider, error) { + if name == "acp" { + return acpSP, nil + } + return defaultSP, nil + } + + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Session: config.SessionConfig{Provider: "fake"}, + Agents: []config.Agent{{Name: "reviewer", Session: "acp", MaxActiveSessions: intPtr(1)}}, + } + sp := newStatusSessionProviderForCity(cfg, t.TempDir()) + if err := acpSP.Start(context.Background(), "custom-reviewer", runtime.Config{Command: "echo"}); err != nil { + t.Fatalf("Start: %v", err) + } + if err := acpSP.SetMeta("custom-reviewer", "GC_DRAIN", "123"); err != nil { + t.Fatalf("SetMeta: %v", err) + } + + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "reviewer", + Type: session.BeadType, + Labels: []string{session.LabelSession, "agent:reviewer"}, + Metadata: map[string]string{ + "agent_name": "reviewer", + "template": "reviewer", + "transport": "acp", + "session_name": "custom-reviewer", + "state": string(session.StateActive), + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 1 { + t.Fatalf("agents = %d, want 1", len(snapshot.Agents)) + } + if !snapshot.Agents[0].Agent.Running { + t.Fatalf("running = false, want true") + } + + var stdout bytes.Buffer + renderCityStatusText(snapshot, newDrainOps(sp), &stdout) + if !strings.Contains(stdout.String(), "running (draining)") { + t.Fatalf("stdout = %q, want draining status for ACP-backed custom runtime name", stdout.String()) + } +} + +func TestCityStatusUsesBeadBackedRuntimeNameForPoolInstance(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "custom-dog-1", runtime.Config{Command: "echo"}); err != nil { + t.Fatalf("Start: %v", err) + } + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "dog", + Type: session.BeadType, + Labels: []string{session.LabelSession, "agent:dog-1"}, + Metadata: map[string]string{ + "agent_name": "dog-1", + "template": "dog", + "session_name": "custom-dog-1", + "pool_slot": "1", + "state": "active", + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Agents: []config.Agent{ + {Name: "dog", MaxActiveSessions: intPtr(2)}, + }, + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 2 { + t.Fatalf("agents = %d, want 2", len(snapshot.Agents)) + } + if got := snapshot.Agents[0].Agent.QualifiedName; got != "dog-1" { + t.Fatalf("first QualifiedName = %q, want dog-1", got) + } + if !snapshot.Agents[0].Agent.Running { + t.Fatalf("pool instance dog-1 running = false, want true with bead-backed runtime name") + } + if got := snapshot.Agents[0].SessionName; got != "custom-dog-1" { + t.Fatalf("dog-1 SessionName = %q, want %q", got, "custom-dog-1") + } + if snapshot.Agents[1].Agent.Running { + t.Fatalf("pool instance dog-2 running = true, want false") + } +} + +func TestCityStatusUsesBeadBackedRuntimeNameForStampedPoolSlotBead(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "custom-dog-1", runtime.Config{Command: "echo"}); err != nil { + t.Fatalf("Start: %v", err) + } + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "dog", + Type: session.BeadType, + Labels: []string{session.LabelSession, "agent:frontend/dog"}, + Metadata: map[string]string{ + "agent_name": "frontend/dog", + "template": "frontend/dog", + "session_name": "custom-dog-1", + "pool_slot": "1", + "state": "active", + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "city"}, + Rigs: []config.Rig{{Name: "frontend", Path: "/tmp/frontend"}}, + Agents: []config.Agent{ + {Name: "dog", Dir: "frontend", MaxActiveSessions: intPtr(2)}, + }, + } + + snapshot := collectCityStatusSnapshot(sp, cfg, "/home/user/city", store, io.Discard) + if len(snapshot.Agents) != 2 { + t.Fatalf("agents = %d, want 2", len(snapshot.Agents)) + } + if got := snapshot.Agents[0].Agent.QualifiedName; got != "frontend/dog-1" { + t.Fatalf("first QualifiedName = %q, want frontend/dog-1", got) + } + if !snapshot.Agents[0].Agent.Running { + t.Fatalf("pool instance frontend/dog-1 running = false, want true with stamped pool-slot bead") + } + if got := snapshot.Agents[0].SessionName; got != "custom-dog-1" { + t.Fatalf("frontend/dog-1 SessionName = %q, want %q", got, "custom-dog-1") + } +} + func TestCityStatusNamedSessionLookupErrorsAreSurfaced(t *testing.T) { sp := runtime.NewFake() dops := newFakeDrainOps() diff --git a/cmd/gc/cityinit_exact_output_test.go b/cmd/gc/cityinit_exact_output_test.go new file mode 100644 index 0000000000..92943a9176 --- /dev/null +++ b/cmd/gc/cityinit_exact_output_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "io" + "path/filepath" + "testing" + + "github.com/gastownhall/gascity/internal/fsys" +) + +func TestCityInitExactOutput_DefaultScaffold(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := doInit(fsys.NewFake(), "/bright-lights", defaultWizardConfig(), "", &stdout, &stderr) + + if code != 0 { + t.Fatalf("doInit code = %d, want 0", code) + } + const wantStdout = "[1/8] Creating runtime scaffold\n" + + "[2/8] Installing hooks (Claude Code)\n" + + "[3/8] Writing default prompts\n" + + "[4/8] Writing pack.toml\n" + + "[5/8] Writing city configuration\n" + + "Welcome to Gas City!\n" + + "Initialized city \"bright-lights\" with default mayor agent.\n" + if stdout.String() != wantStdout { + t.Fatalf("stdout = %q, want %q", stdout.String(), wantStdout) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestCityInitExactOutput_CommandProviderSkipReadiness(t *testing.T) { + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_DOLT", "skip") + disableBootstrapForTests(t) + + oldRegister := registerCityWithSupervisorTestHook + registerCityWithSupervisorTestHook = func(_ string, _ string, _ io.Writer, _ io.Writer) (bool, int) { + return true, 0 + } + t.Cleanup(func() { registerCityWithSupervisorTestHook = oldRegister }) + + var stdout, stderr bytes.Buffer + code := cmdInitWithOptions([]string{filepath.Join(t.TempDir(), "bright-lights")}, "codex", "", "", &stdout, &stderr, true) + + if code != 0 { + t.Fatalf("cmdInitWithOptions code = %d, want 0", code) + } + const wantStdout = "[1/8] Creating runtime scaffold\n" + + "[2/8] Installing hooks (Claude Code)\n" + + "[3/8] Writing default prompts\n" + + "[4/8] Writing pack.toml\n" + + "[5/8] Writing city configuration\n" + + "Welcome to Gas City!\n" + + "Initialized city \"bright-lights\" with default provider \"codex\".\n" + + "[6/8] Skipping provider readiness checks\n" + + "[7/8] Registering city with supervisor\n" + if stdout.String() != wantStdout { + t.Fatalf("stdout = %q, want %q", stdout.String(), wantStdout) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} diff --git a/cmd/gc/cityinit_impl.go b/cmd/gc/cityinit_impl.go index 9a2c8bc0dc..168d65488e 100644 --- a/cmd/gc/cityinit_impl.go +++ b/cmd/gc/cityinit_impl.go @@ -1,552 +1,166 @@ package main -// cityinit.Initializer implementation. Bridges the domain interface -// declared in internal/cityinit to the concrete scaffold + finalize -// helpers in this package. Supplied to api.NewSupervisorMux at -// construction so POST /v0/city calls Init in-process — no -// subprocess, no 30-second deadline, no stderr-scraping. -// -// The long-term plan is to move doInit/finalizeInit and their -// helpers into internal/cityinit so the domain logic physically -// lives in the object model (per engdocs/architecture/api-control-plane.md §1). This -// bridge is the minimum viable unblocker: the HTTP API no longer -// shells out, both surfaces drive the same in-process code path via -// the same typed contract, and the refactor of where the body lives -// is a follow-up. - import ( - "bytes" "context" "encoding/json" "errors" "fmt" "io" - "os" "path/filepath" - "sort" - "strings" - "syscall" "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/cityinit" "github.com/gastownhall/gascity/internal/citylayout" - "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/fsys" - "github.com/gastownhall/gascity/internal/supervisor" ) -// localInitializer implements cityinit.Initializer by dispatching to -// this package's existing doInit + finalizeInit functions. -type localInitializer struct{} - -// NewInitializer returns the Initializer the supervisor uses to -// service POST /v0/city. Exported so `gc supervisor run` can wire it -// into api.NewSupervisorMux. -func NewInitializer() cityinit.Initializer { - return localInitializer{} -} - -func ensureCityEventLog(cityPath string) { - if fr, err := events.NewFileRecorder(filepath.Join(cityPath, ".gc", "events.jsonl"), io.Discard); err == nil { - fr.Close() //nolint:errcheck // best-effort - } +func newCityInitService() (*cityinit.Service, error) { + return cityinit.NewService(cityinit.ServiceDeps{ + FS: fsys.OSScaffoldFS{}, + Initializer: initializerAdapter{}, + Registry: registryAdapter{}, + Reloader: reloaderAdapter{}, + LifecycleEvents: cityInitLifecycleEvents{stderr: io.Discard}, + }) } -func recordCityEvent(cityPath, eventType, subject string, payload any) { - fr, err := events.NewFileRecorder(filepath.Join(cityPath, ".gc", "events.jsonl"), io.Discard) - if err != nil { - return - } - defer fr.Close() //nolint:errcheck // best-effort +type initializerAdapter struct{} - raw, err := json.Marshal(payload) - if err != nil { - return - } - fr.Record(events.Event{ - Type: eventType, - Actor: "gc", - Subject: subject, - Payload: raw, - }) +func (initializerAdapter) Scaffold(ctx context.Context, req cityinit.InitRequest) error { + return cityInitDoInit(ctx, req) } -type scaffoldRollbackEntry struct { - mode os.FileMode - data []byte - linkTarget string +func (initializerAdapter) Finalize(ctx context.Context, req cityinit.InitRequest) error { + return cityInitFinalize(ctx, req) } -type scaffoldSnapshot struct { - root string - entries map[string]scaffoldRollbackEntry +type registryAdapter struct{} + +func (registryAdapter) Register(_ context.Context, dir, nameOverride string) error { + return registerCityForAPI(dir, nameOverride) } -type scaffoldRollbackState struct { - root string - before map[string]scaffoldRollbackEntry - after map[string]scaffoldRollbackEntry +func (registryAdapter) Find(ctx context.Context, name string) (cityinit.RegisteredCity, error) { + return cityInitFindRegisteredCity(ctx, name) } -func captureScaffoldSnapshot(root string) (*scaffoldSnapshot, error) { - snapshot := &scaffoldSnapshot{ - root: root, - entries: make(map[string]scaffoldRollbackEntry), - } - for _, rel := range scaffoldManagedPaths() { - if err := snapshot.capture(rel); err != nil { - return nil, err - } - } - return snapshot, nil +func (registryAdapter) Unregister(ctx context.Context, city cityinit.RegisteredCity) error { + return cityInitUnregisterCity(ctx, city) } -func scaffoldManagedPaths() []string { - seen := make(map[string]struct{}, len(initConventionDirs)+5) - paths := make([]string, 0, len(initConventionDirs)+5) - add := func(rel string) { - if rel == "" { - return - } - if _, ok := seen[rel]; ok { - return - } - seen[rel] = struct{}{} - paths = append(paths, rel) - } +type reloaderAdapter struct{} - add(citylayout.RuntimeRoot) - add("hooks") - add(citylayout.CityConfigFile) - add("pack.toml") - add(".gitignore") - for _, rel := range initConventionDirs { - add(rel) - } - return paths +func (reloaderAdapter) Reload() error { + return reloadSupervisorNoWaitHook() } -func newScaffoldRollbackState(root string) (*scaffoldRollbackState, error) { - snapshot, err := captureScaffoldSnapshot(root) - if err != nil { - return nil, err - } - return &scaffoldRollbackState{ - root: root, - before: snapshot.entries, - }, nil +func (reloaderAdapter) ReloadAfterUnregister() error { + return reloadSupervisorNoWaitHook() } -func (s *scaffoldSnapshot) capture(rel string) error { - abs := filepath.Join(s.root, rel) - _, err := os.Lstat(abs) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("snapshot %q: %w", abs, err) - } - return filepath.Walk(abs, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return fmt.Errorf("snapshot %q: %w", path, walkErr) - } - relPath, err := filepath.Rel(s.root, path) - if err != nil { - return fmt.Errorf("relative path for %q: %w", path, err) - } - entry := scaffoldRollbackEntry{mode: info.Mode()} - if info.Mode()&os.ModeSymlink != 0 { - target, err := os.Readlink(path) - if err != nil { - return fmt.Errorf("readlink %q: %w", path, err) - } - entry.linkTarget = target - } else if !info.IsDir() { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read %q: %w", path, err) - } - entry.data = data - } - s.entries[filepath.Clean(relPath)] = entry - return nil - }) +type cityInitLifecycleEvents struct { + stderr io.Writer } -func (s *scaffoldRollbackState) markScaffoldState() error { - snapshot, err := captureScaffoldSnapshot(s.root) +func (e cityInitLifecycleEvents) EnsureCityLog(cityPath string) error { + fr, err := events.NewFileRecorder(filepath.Join(cityPath, citylayout.RuntimeRoot, "events.jsonl"), e.stderrOrDiscard()) if err != nil { return err } - s.after = snapshot.entries + if err := fr.Close(); err != nil { + return fmt.Errorf("closing event log: %w", err) + } return nil } -func rollbackEntryEqual(a, b scaffoldRollbackEntry) bool { - return a.mode == b.mode && a.linkTarget == b.linkTarget && bytes.Equal(a.data, b.data) +func (e cityInitLifecycleEvents) CityCreated(cityPath, name string) error { + return e.record(cityPath, events.CityCreated, name, api.CityLifecyclePayload{Name: name, Path: cityPath}) } -func restoreRollbackEntry(abs string, entry scaffoldRollbackEntry) error { - switch { - case entry.mode.IsDir(): - return os.MkdirAll(abs, entry.mode.Perm()) - case entry.mode&os.ModeSymlink != 0: - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return err - } - if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { - return err - } - return os.Symlink(entry.linkTarget, abs) - default: - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return err - } - return os.WriteFile(abs, entry.data, entry.mode.Perm()) - } +func (e cityInitLifecycleEvents) CityUnregisterRequested(city cityinit.RegisteredCity) error { + return e.record(city.Path, events.CityUnregisterRequested, city.Name, api.CityLifecyclePayload{Name: city.Name, Path: city.Path}) } -func (s *scaffoldRollbackState) restore() error { - current, err := captureScaffoldSnapshot(s.root) +func (e cityInitLifecycleEvents) record(cityPath, eventType, subject string, payload api.CityLifecyclePayload) error { + fr, err := events.NewFileRecorder(filepath.Join(cityPath, citylayout.RuntimeRoot, "events.jsonl"), e.stderrOrDiscard()) if err != nil { return err } - - var errs []error - var createdDirs []string - for rel, after := range s.after { - before, hadBefore := s.before[rel] - currentEntry, existsNow := current.entries[rel] - switch { - case !hadBefore: - if after.mode.IsDir() { - createdDirs = append(createdDirs, rel) - continue - } - if existsNow && rollbackEntryEqual(currentEntry, after) { - if err := os.Remove(filepath.Join(s.root, rel)); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("remove %q: %w", filepath.Join(s.root, rel), err)) - } - } - case rollbackEntryEqual(before, after): - continue - default: - if after.mode.IsDir() { - continue - } - if existsNow && rollbackEntryEqual(currentEntry, after) { - if err := restoreRollbackEntry(filepath.Join(s.root, rel), before); err != nil { - errs = append(errs, fmt.Errorf("restore %q: %w", filepath.Join(s.root, rel), err)) - } - } - } - } - - for rel, before := range s.before { - if _, hadAfter := s.after[rel]; hadAfter { - continue - } - if before.mode.IsDir() { - continue - } - if _, existsNow := current.entries[rel]; existsNow { - continue - } - if err := restoreRollbackEntry(filepath.Join(s.root, rel), before); err != nil { - errs = append(errs, fmt.Errorf("restore %q: %w", filepath.Join(s.root, rel), err)) + raw, err := json.Marshal(payload) + if err != nil { + if closeErr := fr.Close(); closeErr != nil { + return errors.Join(err, fmt.Errorf("closing event log: %w", closeErr)) } + return err } - - sort.Slice(createdDirs, func(i, j int) bool { - return len(createdDirs[i]) > len(createdDirs[j]) + fr.Record(events.Event{ + Type: eventType, + Actor: "gc", + Subject: subject, + Payload: raw, }) - for _, rel := range createdDirs { - if err := os.Remove(filepath.Join(s.root, rel)); err != nil && !os.IsNotExist(err) { - if errors.Is(err, syscall.ENOTEMPTY) { - continue - } - errs = append(errs, fmt.Errorf("remove %q: %w", filepath.Join(s.root, rel), err)) - } - } - - if len(errs) > 0 { - return errors.Join(errs...) + if err := fr.Close(); err != nil { + return fmt.Errorf("closing event log: %w", err) } return nil } -// Scaffold runs the fast portion of city creation so the HTTP API -// handler can return 202 Accepted without blocking on the slow -// finalize work. Writes the on-disk shape (via doInit), then -// registers the city with the supervisor so the reconciler picks -// it up on its next tick. The reconciler owns finalize from there; -// readiness is signaled via city.ready / city.init_failed events on -// the supervisor event bus (see internal/api/event_payloads.go). -func (localInitializer) Scaffold(_ context.Context, req cityinit.InitRequest) (*cityinit.InitResult, error) { - if err := validateInitRequest(&req); err != nil { - return nil, err - } - dir := req.Dir - dirExisted := false - var rollbackState *scaffoldRollbackState - if _, err := os.Stat(dir); err == nil { - dirExisted = true - rollbackState, err = newScaffoldRollbackState(dir) - if err != nil { - return nil, fmt.Errorf("snapshot rollback state for %q: %w", dir, err) - } - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("stat directory %q: %w", dir, err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("creating directory %q: %w", dir, err) - } - - wiz := wizardConfig{ - configName: req.ConfigName, - provider: req.Provider, - startCommand: req.StartCommand, - bootstrapProfile: req.BootstrapProfile, - } - if wiz.configName == "" { - wiz.configName = "tutorial" - } - - if cityHasScaffoldFS(fsys.OSFS{}, dir) { - return nil, cityinit.ErrAlreadyInitialized - } - if code := doInit(fsys.OSFS{}, dir, wiz, req.NameOverride, io.Discard, io.Discard); code != 0 { - if dirExisted && rollbackState != nil { - if markErr := rollbackState.markScaffoldState(); markErr != nil { - return nil, errors.Join(fmt.Errorf("scaffold failed (exit %d)", code), fmt.Errorf("snapshot scaffold state for rollback: %w", markErr)) - } - if cleanupErr := rollbackState.restore(); cleanupErr != nil { - return nil, errors.Join(fmt.Errorf("scaffold failed (exit %d)", code), fmt.Errorf("restoring existing directory after scaffold failure: %w", cleanupErr)) - } - } - if code == initExitAlreadyInitialized { - return nil, cityinit.ErrAlreadyInitialized - } - return nil, fmt.Errorf("scaffold failed (exit %d)", code) - } - - cityName := resolveCityName(req.NameOverride, "", dir) - - // Create .gc/events.jsonl immediately before registration. Two reasons: - // - // 1. The supervisor event multiplexer (see - // internal/api/supervisor.go:buildMultiplexer) includes - // transient-city event providers via - // TransientCityEventSource. With the file in place, a - // subscriber to /v0/events/stream that connects right after - // POST returns 202 sees a non-empty multiplexer and can - // replay events via after_cursor=0. - // - // 2. The supervisor event stream's no-providers precheck rejects - // subscriptions with 503 when the multiplexer is empty. By - // populating at least one event log before registration, - // POST /v0/city → subscribe works even when no other cities - // exist yet (the fresh-supervisor scenario). - // - // The file creation is best-effort. city.created itself is emitted - // only after registration succeeds so synchronous failures do not - // leak a "created" event for a city the supervisor never adopted. - ensureCityEventLog(dir) - if dirExisted && rollbackState != nil { - if err := rollbackState.markScaffoldState(); err != nil { - return nil, fmt.Errorf("snapshot scaffold state for %q: %w", dir, err) - } +func (e cityInitLifecycleEvents) stderrOrDiscard() io.Writer { + if e.stderr != nil { + return e.stderr } - - // Register the city with the supervisor without blocking on the - // reconciler's tick. The standard registerCityWithSupervisor - // waits for prepareCityForSupervisor to complete, which is the - // very blocking behavior the async POST /v0/city contract - // exists to avoid. - if err := registerCityForAPI(dir, req.NameOverride); err != nil { - if dirExisted { - if rollbackState != nil { - if cleanupErr := rollbackState.restore(); cleanupErr != nil { - return nil, errors.Join(fmt.Errorf("register with supervisor: %w", err), fmt.Errorf("restoring existing directory after failed registration: %w", cleanupErr)) - } - } - } else if cleanupErr := os.RemoveAll(dir); cleanupErr != nil { - return nil, errors.Join(fmt.Errorf("register with supervisor: %w", err), fmt.Errorf("cleaning scaffold after failed registration: %w", cleanupErr)) - } - return nil, fmt.Errorf("register with supervisor: %w", err) - } - recordCityEvent(dir, events.CityCreated, cityName, api.CityCreatedPayload{Name: cityName, Path: dir}) - reloadSupervisorNoWaitHook() - - return &cityinit.InitResult{ - CityName: cityName, - CityPath: dir, - ProviderUsed: req.Provider, - }, nil + return io.Discard } -// Init scaffolds + finalizes a new city. Errors are mapped to the -// typed sentinels in package cityinit so callers (HTTP API, future -// in-process consumers) can pattern-match via errors.Is. -func (localInitializer) Init(_ context.Context, req cityinit.InitRequest) (*cityinit.InitResult, error) { - if err := validateInitRequest(&req); err != nil { - return nil, err - } - dir := req.Dir - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("creating directory %q: %w", dir, err) - } - +func cityInitDoInit(_ context.Context, req cityinit.InitRequest) error { wiz := wizardConfig{ configName: req.ConfigName, provider: req.Provider, startCommand: req.StartCommand, bootstrapProfile: req.BootstrapProfile, } - if wiz.configName == "" { - wiz.configName = "tutorial" - } - - // Check for an already-initialized directory BEFORE calling - // doInit so we can return ErrAlreadyInitialized without also - // writing "gc init: already initialized" to stderr (the CLI - // path wants that; the API does not). - if cityHasScaffoldFS(fsys.OSFS{}, dir) { - return nil, cityinit.ErrAlreadyInitialized - } - - // doInit writes directly to io.Writer arguments (CLI progress - // narration). The API path discards those; error return is - // carried as an exit code, which we translate into typed - // sentinels below. - if code := doInit(fsys.OSFS{}, dir, wiz, req.NameOverride, io.Discard, io.Discard); code != 0 { + if code := doInit(fsys.OSFS{}, req.Dir, wiz, req.NameOverride, io.Discard, io.Discard); code != 0 { if code == initExitAlreadyInitialized { - return nil, cityinit.ErrAlreadyInitialized + return cityinit.ErrAlreadyInitialized } - return nil, fmt.Errorf("scaffold failed (exit %d)", code) + return fmt.Errorf("scaffold failed (exit %d)", code) } + return nil +} - // finalizeInit likewise writes to io.Writer and returns 0/1. - // Discard its narration; the HTTP response conveys structured - // errors via the sentinel types. - if code := finalizeInit(dir, io.Discard, io.Discard, initFinalizeOptions{ +func cityInitFinalize(_ context.Context, req cityinit.InitRequest) error { + if code := finalizeInit(req.Dir, io.Discard, io.Discard, initFinalizeOptions{ skipProviderReadiness: req.SkipProviderReadiness, showProgress: false, commandName: "gc init", }); code != 0 { - // finalizeInit's current contract is "blocked, check - // stderr". Without a structured return type we can't map - // to specific sentinels; future work splits this out. - return nil, fmt.Errorf("finalize failed (exit %d)", code) + return fmt.Errorf("finalize failed (exit %d)", code) } - - cityName := resolveCityName(req.NameOverride, "", dir) - return &cityinit.InitResult{ - CityName: cityName, - CityPath: dir, - ProviderUsed: req.Provider, - }, nil + return nil } -// Unregister removes the city's registry entry and signals the -// supervisor to reconcile. Fire-and-forget: returns as soon as the -// registry file is updated and the reload signal is sent. The -// supervisor reconciler discovers the missing entry on its next -// tick, stops the city's controller, and emits city.unregistered -// (or city.unregister_failed on stop failure). See cmd_supervisor.go -// for the reconciler side. -// -// Looks the city up by name. The registry is keyed by path on disk, -// so we scan entries to find the one whose effective name matches. -// Name collisions would violate the registry's uniqueness invariant -// and are rejected at Register time; we take the first match. -// -// Emits city.unregister_requested to the city's event log before -// unregistering so /v0/events/stream subscribers see the start of -// the teardown. Once the registry entry is gone, the transient -// event-provider lookup (cityRegistry.TransientCityEventProviders) -// will still surface this city to new subscribers via its snap.all -// entry until the reconciler fully drops it. -func (localInitializer) Unregister(_ context.Context, req cityinit.UnregisterRequest) (*cityinit.UnregisterResult, error) { - name := strings.TrimSpace(req.CityName) - if name == "" { - return nil, fmt.Errorf("%w: city_name is required", cityinit.ErrNotRegistered) - } - +func cityInitFindRegisteredCity(_ context.Context, name string) (cityinit.RegisteredCity, error) { reg := newSupervisorRegistry() entries, err := reg.List() if err != nil { - return nil, fmt.Errorf("reading supervisor registry: %w", err) + return cityinit.RegisteredCity{}, err } - var match supervisor.CityEntry - var found bool - for _, e := range entries { - if e.EffectiveName() == name { - match = e - found = true - break + for _, entry := range entries { + if entry.EffectiveName() == name { + return cityinit.RegisteredCity{ + Name: entry.EffectiveName(), + Path: entry.Path, + }, nil } } - if !found { - return nil, fmt.Errorf("%w: %q", cityinit.ErrNotRegistered, name) - } - - if err := reg.Unregister(match.Path); err != nil { - // Should not happen — we just read this entry — but wrap to - // satisfy the ErrNotRegistered contract if it does. - if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("%w: %q: %w", cityinit.ErrNotRegistered, name, err) - } - return nil, fmt.Errorf("removing %q from supervisor registry: %w", name, err) - } - recordCityEvent( - match.Path, - events.CityUnregisterRequested, - match.EffectiveName(), - api.CityUnregisterRequestedPayload{Name: match.EffectiveName(), Path: match.Path}, - ) - - // Wake the reconciler; same fire-and-forget signal the Scaffold - // path uses. If the supervisor isn't reachable the periodic - // ticker picks up the change on its next interval. - reloadSupervisorNoWait() - - return &cityinit.UnregisterResult{ - CityName: match.EffectiveName(), - CityPath: match.Path, - }, nil + return cityinit.RegisteredCity{}, fmt.Errorf("%w: %q", cityinit.ErrNotRegistered, name) } -// validateInitRequest performs the membership / mutual-exclusion -// checks that the HTTP layer previously did inline. Keeping them in -// the bridge means the CLI also benefits from the same validation -// when its call site moves (follow-up). -func validateInitRequest(req *cityinit.InitRequest) error { - if req.Dir == "" { - return fmt.Errorf("%w: dir is required", cityinit.ErrInvalidProvider) - } - if !filepath.IsAbs(req.Dir) { - return fmt.Errorf("dir must be absolute: %q", req.Dir) - } - if req.Provider == "" && req.StartCommand == "" { - return fmt.Errorf("%w: provider or start_command required", cityinit.ErrInvalidProvider) +func cityInitUnregisterCity(_ context.Context, city cityinit.RegisteredCity) error { + err := newSupervisorRegistry().Unregister(city.Path) + if errors.Is(err, cityinit.ErrNotRegistered) { + return fmt.Errorf("%w: %s", cityinit.ErrNotRegistered, city.Name) } - if req.Provider != "" && req.StartCommand != "" { - return fmt.Errorf("%w: provider and start_command are mutually exclusive", cityinit.ErrInvalidProvider) - } - if req.Provider != "" { - if _, ok := config.BuiltinProviders()[req.Provider]; !ok { - return fmt.Errorf("%w: unknown provider %q", cityinit.ErrInvalidProvider, req.Provider) - } - } - if req.BootstrapProfile != "" { - // normalizeBootstrapProfile accepts every spelling the CLI - // and HTTP API currently support; reuse it here so the two - // projections can't disagree. - if _, err := normalizeBootstrapProfile(req.BootstrapProfile); err != nil { - return fmt.Errorf("%w: %w", cityinit.ErrInvalidBootstrapProfile, err) - } - } - return nil + return err } diff --git a/cmd/gc/cityinit_impl_test.go b/cmd/gc/cityinit_impl_test.go index b941ef99c1..5f95678bce 100644 --- a/cmd/gc/cityinit_impl_test.go +++ b/cmd/gc/cityinit_impl_test.go @@ -15,6 +15,15 @@ import ( "github.com/gastownhall/gascity/internal/supervisor" ) +func mustNewCityInitService(t *testing.T) *cityinit.Service { + t.Helper() + svc, err := newCityInitService() + if err != nil { + t.Fatalf("newCityInitService: %v", err) + } + return svc +} + type fakeSupervisorRegistry struct { entries []supervisor.CityEntry listErr error @@ -37,101 +46,24 @@ func (f *fakeSupervisorRegistry) Unregister(string) error { return f.unregisterErr } -func TestValidateInitRequest(t *testing.T) { - absDir := filepath.Join(t.TempDir(), "city") - tests := []struct { - name string - req cityinit.InitRequest - wantErr error - wantContains string - }{ - { - name: "missing dir", - req: cityinit.InitRequest{Provider: "codex"}, - wantErr: cityinit.ErrInvalidProvider, - }, - { - name: "relative dir", - req: cityinit.InitRequest{Dir: "relative", Provider: "codex"}, - wantContains: "dir must be absolute", - }, - { - name: "missing provider and start command", - req: cityinit.InitRequest{Dir: absDir}, - wantErr: cityinit.ErrInvalidProvider, - }, - { - name: "provider and start command are mutually exclusive", - req: cityinit.InitRequest{Dir: absDir, Provider: "codex", StartCommand: "custom-agent"}, - wantErr: cityinit.ErrInvalidProvider, - wantContains: "mutually exclusive", - }, - { - name: "unknown provider", - req: cityinit.InitRequest{Dir: absDir, Provider: "not-a-provider"}, - wantErr: cityinit.ErrInvalidProvider, - }, - { - name: "bad bootstrap profile", - req: cityinit.InitRequest{Dir: absDir, Provider: "codex", BootstrapProfile: "moon-base"}, - wantErr: cityinit.ErrInvalidBootstrapProfile, - }, - { - name: "builtin provider", - req: cityinit.InitRequest{Dir: absDir, Provider: "codex"}, - wantErr: nil, - }, - { - name: "custom start command", - req: cityinit.InitRequest{Dir: absDir, StartCommand: "custom-agent"}, - wantErr: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := validateInitRequest(&tc.req) - if tc.wantErr == nil { - if tc.wantContains != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantContains) { - t.Fatalf("validateInitRequest() error = %v, want message containing %q", err, tc.wantContains) - } - return - } - if err != nil { - t.Fatalf("validateInitRequest() error = %v, want nil", err) - } - return - } - if !errors.Is(err, tc.wantErr) { - t.Fatalf("validateInitRequest() error = %v, want %v", err, tc.wantErr) - } - if tc.wantContains != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantContains) { - t.Fatalf("validateInitRequest() error = %v, want message containing %q", err, tc.wantContains) - } - } - }) - } -} - -func TestLocalInitializerScaffoldCreatesCityRegistersAndEmitsCreated(t *testing.T) { +func TestCityInitServiceScaffoldCreatesCityRegistersAndEmitsCreated(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) cityPath := filepath.Join(t.TempDir(), "api-city") reloadSawCreated := 0 oldReloadSupervisorNoWaitHook := reloadSupervisorNoWaitHook - reloadSupervisorNoWaitHook = func() { + reloadSupervisorNoWaitHook = func() error { evts, err := events.ReadFiltered(filepath.Join(cityPath, ".gc", "events.jsonl"), events.Filter{Type: events.CityCreated}) if err == nil { reloadSawCreated = len(evts) } + return nil } t.Cleanup(func() { reloadSupervisorNoWaitHook = oldReloadSupervisorNoWaitHook }) - result, err := localInitializer{}.Scaffold(context.Background(), cityinit.InitRequest{ + result, err := mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ Dir: cityPath, Provider: "codex", BootstrapProfile: bootstrapProfileSingleHostCompat, @@ -167,7 +99,7 @@ func TestLocalInitializerScaffoldCreatesCityRegistersAndEmitsCreated(t *testing. if len(evts) != 1 { t.Fatalf("city.created events = %d, want 1: %+v", len(evts), evts) } - var payload api.CityCreatedPayload + var payload api.CityLifecyclePayload if err := json.Unmarshal(evts[0].Payload, &payload); err != nil { t.Fatalf("unmarshal city.created payload: %v", err) } @@ -179,7 +111,7 @@ func TestLocalInitializerScaffoldCreatesCityRegistersAndEmitsCreated(t *testing. t.Fatalf("reload saw %d city.created events, want 1 before wake", reloadSawCreated) } - _, err = localInitializer{}.Scaffold(context.Background(), cityinit.InitRequest{ + _, err = mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ Dir: cityPath, Provider: "codex", }) @@ -188,7 +120,33 @@ func TestLocalInitializerScaffoldCreatesCityRegistersAndEmitsCreated(t *testing. } } -func TestLocalInitializerScaffoldDoesNotEmitCreatedWhenRegisterFails(t *testing.T) { +func TestCityInitServiceScaffoldReturnsReloadWarning(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + cityPath := filepath.Join(t.TempDir(), "api-city") + + oldReloadSupervisorNoWaitHook := reloadSupervisorNoWaitHook + reloadSupervisorNoWaitHook = func() error { + return errors.New("reload unavailable") + } + t.Cleanup(func() { + reloadSupervisorNoWaitHook = oldReloadSupervisorNoWaitHook + }) + + result, err := mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ + Dir: cityPath, + Provider: "codex", + BootstrapProfile: bootstrapProfileSingleHostCompat, + NameOverride: "api-city", + }) + if err != nil { + t.Fatalf("Scaffold: %v", err) + } + if result.ReloadWarning != "reload unavailable" { + t.Fatalf("ReloadWarning = %q, want reload unavailable", result.ReloadWarning) + } +} + +func TestCityInitServiceScaffoldDoesNotEmitCreatedWhenRegisterFails(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) cityPath := filepath.Join(t.TempDir(), "api-city") @@ -200,7 +158,7 @@ func TestLocalInitializerScaffoldDoesNotEmitCreatedWhenRegisterFails(t *testing. newSupervisorRegistry = oldNewSupervisorRegistry }) - _, err := localInitializer{}.Scaffold(context.Background(), cityinit.InitRequest{ + _, err := mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ Dir: cityPath, Provider: "codex", BootstrapProfile: bootstrapProfileSingleHostCompat, @@ -222,7 +180,7 @@ func TestLocalInitializerScaffoldDoesNotEmitCreatedWhenRegisterFails(t *testing. } } -func TestLocalInitializerScaffoldPreservesExistingDirectoryWhenRegisterFails(t *testing.T) { +func TestCityInitServiceScaffoldPreservesExistingDirectoryWhenRegisterFails(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) cityPath := filepath.Join(t.TempDir(), "api-city") keepPath := filepath.Join(cityPath, "keep.txt") @@ -248,7 +206,7 @@ func TestLocalInitializerScaffoldPreservesExistingDirectoryWhenRegisterFails(t * newSupervisorRegistry = oldNewSupervisorRegistry }) - _, err := localInitializer{}.Scaffold(context.Background(), cityinit.InitRequest{ + _, err := mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ Dir: cityPath, Provider: "codex", BootstrapProfile: bootstrapProfileSingleHostCompat, @@ -283,7 +241,7 @@ func TestLocalInitializerScaffoldPreservesExistingDirectoryWhenRegisterFails(t * } newSupervisorRegistry = oldNewSupervisorRegistry - result, err := localInitializer{}.Scaffold(context.Background(), cityinit.InitRequest{ + result, err := mustNewCityInitService(t).Scaffold(context.Background(), cityinit.InitRequest{ Dir: cityPath, Provider: "codex", BootstrapProfile: bootstrapProfileSingleHostCompat, @@ -297,12 +255,13 @@ func TestLocalInitializerScaffoldPreservesExistingDirectoryWhenRegisterFails(t * } } -func TestLocalInitializerInitScaffoldsAndFinalizes(t *testing.T) { +func TestCityInitServiceInitScaffoldsAndFinalizes(t *testing.T) { skipSlowCmdGCTest(t, "runs the full local init scaffold/finalize path; run make test-cmd-gc-process for full coverage") configureTestDoltIdentityEnv(t) + configureRealBdAndDoltPath(t) cityPath := filepath.Join(t.TempDir(), "init-city") - result, err := localInitializer{}.Init(context.Background(), cityinit.InitRequest{ + result, err := mustNewCityInitService(t).Init(context.Background(), cityinit.InitRequest{ Dir: cityPath, StartCommand: "true", NameOverride: "init-city", @@ -318,7 +277,7 @@ func TestLocalInitializerInitScaffoldsAndFinalizes(t *testing.T) { t.Fatalf(".gc missing after Init finalization: %v", err) } - _, err = localInitializer{}.Init(context.Background(), cityinit.InitRequest{ + _, err = mustNewCityInitService(t).Init(context.Background(), cityinit.InitRequest{ Dir: cityPath, StartCommand: "true", }) @@ -327,7 +286,7 @@ func TestLocalInitializerInitScaffoldsAndFinalizes(t *testing.T) { } } -func TestLocalInitializerUnregisterRemovesRegistryAndEmitsEvent(t *testing.T) { +func TestCityInitServiceUnregisterRemovesRegistryAndEmitsEvent(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) cityPath := filepath.Join(t.TempDir(), "bright-lights") if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { @@ -338,7 +297,7 @@ func TestLocalInitializerUnregisterRemovesRegistryAndEmitsEvent(t *testing.T) { t.Fatal(err) } - result, err := localInitializer{}.Unregister(context.Background(), cityinit.UnregisterRequest{ + result, err := mustNewCityInitService(t).Unregister(context.Background(), cityinit.UnregisterRequest{ CityName: " bright-lights ", }) if err != nil { @@ -367,7 +326,7 @@ func TestLocalInitializerUnregisterRemovesRegistryAndEmitsEvent(t *testing.T) { if evts[0].Actor != "gc" || evts[0].Subject != "bright-lights" { t.Fatalf("event actor/subject = %q/%q, want gc/bright-lights", evts[0].Actor, evts[0].Subject) } - var payload api.CityUnregisterRequestedPayload + var payload api.CityLifecyclePayload if err := json.Unmarshal(evts[0].Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } @@ -377,7 +336,37 @@ func TestLocalInitializerUnregisterRemovesRegistryAndEmitsEvent(t *testing.T) { assertSameTestPath(t, payload.Path, cityPath) } -func TestLocalInitializerUnregisterDoesNotEmitEventWhenRegistryWriteFails(t *testing.T) { +func TestCityInitServiceUnregisterReturnsReloadWarning(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + cityPath := filepath.Join(t.TempDir(), "bright-lights") + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + if err := reg.Register(cityPath, "bright-lights"); err != nil { + t.Fatal(err) + } + + oldReloadSupervisorNoWaitHook := reloadSupervisorNoWaitHook + reloadSupervisorNoWaitHook = func() error { + return errors.New("reload unavailable") + } + t.Cleanup(func() { + reloadSupervisorNoWaitHook = oldReloadSupervisorNoWaitHook + }) + + result, err := mustNewCityInitService(t).Unregister(context.Background(), cityinit.UnregisterRequest{ + CityName: "bright-lights", + }) + if err != nil { + t.Fatalf("Unregister: %v", err) + } + if result.ReloadWarning != "reload unavailable" { + t.Fatalf("ReloadWarning = %q, want reload unavailable", result.ReloadWarning) + } +} + +func TestCityInitServiceUnregisterDoesNotEmitEventWhenRegistryWriteFails(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) cityPath := filepath.Join(t.TempDir(), "bright-lights") if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { @@ -398,7 +387,7 @@ func TestLocalInitializerUnregisterDoesNotEmitEventWhenRegistryWriteFails(t *tes newSupervisorRegistry = oldNewSupervisorRegistry }) - _, err := localInitializer{}.Unregister(context.Background(), cityinit.UnregisterRequest{ + _, err := mustNewCityInitService(t).Unregister(context.Background(), cityinit.UnregisterRequest{ CityName: "bright-lights", }) if err == nil || !strings.Contains(err.Error(), "removing \"bright-lights\" from supervisor registry") { @@ -414,15 +403,15 @@ func TestLocalInitializerUnregisterDoesNotEmitEventWhenRegistryWriteFails(t *tes } } -func TestLocalInitializerUnregisterMissingCity(t *testing.T) { +func TestCityInitServiceUnregisterMissingCity(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) - _, err := localInitializer{}.Unregister(context.Background(), cityinit.UnregisterRequest{CityName: "missing"}) + _, err := mustNewCityInitService(t).Unregister(context.Background(), cityinit.UnregisterRequest{CityName: "missing"}) if !errors.Is(err, cityinit.ErrNotRegistered) { t.Fatalf("Unregister missing error = %v, want ErrNotRegistered", err) } - _, err = localInitializer{}.Unregister(context.Background(), cityinit.UnregisterRequest{}) + _, err = mustNewCityInitService(t).Unregister(context.Background(), cityinit.UnregisterRequest{}) if !errors.Is(err, cityinit.ErrNotRegistered) { t.Fatalf("Unregister blank error = %v, want ErrNotRegistered", err) } diff --git a/cmd/gc/cmd_agent_test.go b/cmd/gc/cmd_agent_test.go index d9c2dbdf5f..4cae7f6549 100644 --- a/cmd/gc/cmd_agent_test.go +++ b/cmd/gc/cmd_agent_test.go @@ -11,6 +11,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/formula" + "github.com/gastownhall/gascity/internal/formulatest" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/molecule" ) @@ -810,10 +811,9 @@ name = "test-city" } func TestLoadCityConfigFSAppliesFeatureFlags(t *testing.T) { - oldFormulaV2 := formula.IsFormulaV2Enabled() + formulatest.HoldV2ForTest(t) oldGraphApply := molecule.IsGraphApplyEnabled() t.Cleanup(func() { - formula.SetFormulaV2Enabled(oldFormulaV2) molecule.SetGraphApplyEnabled(oldGraphApply) }) diff --git a/cmd/gc/cmd_bd.go b/cmd/gc/cmd_bd.go index 702f0111d8..fa315987d2 100644 --- a/cmd/gc/cmd_bd.go +++ b/cmd/gc/cmd_bd.go @@ -24,7 +24,11 @@ rig directory to find the correct .beads database. This command resolves the rig automatically from the --rig flag or by detecting the bead prefix in the arguments. -All arguments after "gc bd" are forwarded to bd unchanged.`, +All arguments after "gc bd" are forwarded to bd unchanged. + +gc bd forces BD_EXPORT_AUTO=false to prevent bd's git auto-export hook +from wedging the wrapper after printing command output. If you need +auto-export behavior, invoke bd directly.`, Example: ` gc bd --rig my-project list gc bd --rig my-project create "New task" gc bd show my-project-abc # auto-detects rig from bead prefix @@ -62,6 +66,9 @@ func bdCommandEnv(cityPath string, cfg *config.City, target execStoreTarget) []s overrides["GC_STORE_ROOT"] = target.ScopeRoot overrides["GC_STORE_SCOPE"] = target.ScopeKind overrides["GC_BEADS_PREFIX"] = target.Prefix + // GC owns the Dolt-backed beads lifecycle; bd's git auto-export can run + // after command output and wedge the wrapper on git staging failures. + overrides["BD_EXPORT_AUTO"] = "false" return mergeRuntimeEnv(os.Environ(), overrides) } @@ -108,8 +115,11 @@ func doBd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "gc bd: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } - if !providerUsesBdStoreContract(rawBeadsProviderForScope(target.ScopeRoot, cityPath)) { - fmt.Fprintln(stderr, "gc bd: only supported for bd-backed beads providers") //nolint:errcheck // best-effort stderr + if provider := rawBeadsProviderForScope(target.ScopeRoot, cityPath); !providerUsesBdStoreContract(provider) { + fmt.Fprintf(stderr, "gc bd: only supported for bd-backed beads providers (resolved %q for %s)\n", provider, target.ScopeRoot) //nolint:errcheck // best-effort stderr + if hint := bdProviderMismatchHint(target.ScopeRoot, provider); hint != "" { + fmt.Fprintf(stderr, " hint: %s\n", hint) //nolint:errcheck // best-effort stderr + } return 1 } @@ -126,7 +136,7 @@ func doBd(args []string, stdout, stderr io.Writer) int { cmd.Stdin = os.Stdin cmd.Stdout = stdout cmd.Stderr = stderr - cmd.Env = bdCommandEnv(cityPath, cfg, target) + cmd.Env = workQueryEnvForDir(bdCommandEnv(cityPath, cfg, target), cmd.Dir) if err := cmd.Run(); err != nil { var exitErr *exec.ExitError @@ -204,6 +214,19 @@ func resolveBdScopeTarget(cfg *config.City, cityPath, rigName string, args []str return bdRigScopeTarget(cityPath, rig), nil } + cityTarget := bdCityScopeTarget(cityPath, cfg) + cityPrefix := config.EffectiveHQPrefix(cfg) + if cityPrefix != "" { + for _, arg := range args { + if strings.HasPrefix(arg, "-") || beadPrefix(cfg, arg) != cityPrefix { + continue + } + if bdBeadExists(cityPath, cityTarget, arg) { + return cityTarget, nil + } + } + } + // Auto-detect from bead IDs in args, but only accept candidates that // actually exist in the resolved rig store. This keeps hyphenated flag // values and other non-ID args from silently retargeting the command. @@ -231,15 +254,11 @@ func resolveBdScopeTarget(cfg *config.City, cityPath, rigName string, args []str return bdRigScopeTarget(cityPath, rig), nil } - return execStoreTarget{ - ScopeRoot: resolveStoreScopeRoot(cityPath, cityPath), - ScopeKind: "city", - Prefix: config.EffectiveHQPrefix(cfg), - }, nil + return cityTarget, nil } func bdRigForArg(cfg *config.City, arg string) (config.Rig, bool) { - if prefix := beadPrefix(arg); prefix != "" { + if prefix := beadPrefix(cfg, arg); prefix != "" { return findRigByPrefix(cfg, prefix) } return config.Rig{}, false @@ -261,3 +280,11 @@ func bdRigScopeTarget(cityPath string, rig config.Rig) execStoreTarget { RigName: rig.Name, } } + +func bdCityScopeTarget(cityPath string, cfg *config.City) execStoreTarget { + return execStoreTarget{ + ScopeRoot: resolveStoreScopeRoot(cityPath, cityPath), + ScopeKind: "city", + Prefix: config.EffectiveHQPrefix(cfg), + } +} diff --git a/cmd/gc/cmd_bd_store_bridge.go b/cmd/gc/cmd_bd_store_bridge.go index 2a7ab5b5ec..80fa42099d 100644 --- a/cmd/gc/cmd_bd_store_bridge.go +++ b/cmd/gc/cmd_bd_store_bridge.go @@ -196,6 +196,11 @@ func runBdStoreBridge(op string, args []string, dir, host, port, user string, st return fmt.Errorf("usage: close ") } return store.Close(args[0]) + case "reopen": + if len(args) < 1 { + return fmt.Errorf("usage: reopen ") + } + return store.Reopen(args[0]) case "list": query := beads.ListQuery{AllowScan: true} for _, arg := range args { @@ -305,6 +310,7 @@ func bdStoreBridgeEnv(dir, host, port, user, password string) map[string]string "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SERVER_PORT", "BEADS_DOLT_SERVER_USER", + "BD_EXPORT_AUTO", "GC_BEADS", "GC_BEADS_PREFIX", "GC_DOLT_DATABASE", @@ -325,6 +331,7 @@ func bdStoreBridgeEnv(dir, host, port, user, password string) map[string]string env["GC_DOLT_PASSWORD"] = password env["BEADS_DOLT_PASSWORD"] = password env["BEADS_DOLT_AUTO_START"] = "0" + env["BD_EXPORT_AUTO"] = "false" return env } diff --git a/cmd/gc/cmd_bd_store_bridge_test.go b/cmd/gc/cmd_bd_store_bridge_test.go index 5b7ea9e637..9067cc4d8c 100644 --- a/cmd/gc/cmd_bd_store_bridge_test.go +++ b/cmd/gc/cmd_bd_store_bridge_test.go @@ -48,10 +48,12 @@ BEADS_DOLT_SERVER_DATABASE=%s BEADS_CREDENTIALS_FILE=%s GC_BEADS=%s GC_BEADS_PREFIX=%s +BD_EXPORT_AUTO=%s ' \ "${BEADS_DIR:-}" "${GC_DOLT_HOST:-}" "${GC_DOLT_PORT:-}" "${GC_DOLT_USER:-}" "${GC_DOLT_PASSWORD:-}" \ "${BEADS_DOLT_SERVER_HOST:-}" "${BEADS_DOLT_SERVER_PORT:-}" "${BEADS_DOLT_SERVER_USER:-}" "${BEADS_DOLT_PASSWORD:-}" \ - "${BEADS_DOLT_SERVER_DATABASE:-}" "${BEADS_CREDENTIALS_FILE:-}" "${GC_BEADS:-}" "${GC_BEADS_PREFIX:-}" > "` + envFile + `" + "${BEADS_DOLT_SERVER_DATABASE:-}" "${BEADS_CREDENTIALS_FILE:-}" "${GC_BEADS:-}" "${GC_BEADS_PREFIX:-}" \ + "${BD_EXPORT_AUTO:-}" > "` + envFile + `" printf '%s ' "$*" > "` + argsFile + `" case "${1:-}" in @@ -153,6 +155,9 @@ func TestBdStoreBridgeCreateCmdProjectsCanonicalEnvAndClearsAmbientAuthority(t * if got := envMap["GC_BEADS_PREFIX"]; got != "" { t.Fatalf("GC_BEADS_PREFIX = %q, want empty after sanitization\n%s", got, string(envText)) } + if got := envMap["BD_EXPORT_AUTO"]; got != "false" { + t.Fatalf("BD_EXPORT_AUTO = %q, want false to suppress bridge auto-export\n%s", got, string(envText)) + } argsText, err := os.ReadFile(argsFile) if err != nil { diff --git a/cmd/gc/cmd_bd_test.go b/cmd/gc/cmd_bd_test.go index a83f1b1d58..65c171bc26 100644 --- a/cmd/gc/cmd_bd_test.go +++ b/cmd/gc/cmd_bd_test.go @@ -468,6 +468,69 @@ set -eu } } +func TestGcBdSuppressesBdAutoExportForJsonShowAndUpdate(t *testing.T) { + origCityFlag := cityFlag + origRigFlag := rigFlag + defer func() { + cityFlag = origCityFlag + rigFlag = origRigFlag + }() + cityFlag = "" + rigFlag = "" + + cityDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace] +name = "demo" +`), 0o644); err != nil { + t.Fatal(err) + } + + binDir := t.TempDir() + script := filepath.Join(binDir, "bd") + if err := os.WriteFile(script, []byte(`#!/bin/sh +set -eu +if [ "${BD_EXPORT_AUTO:-}" != "false" ]; then + echo "BD_EXPORT_AUTO=${BD_EXPORT_AUTO:-}" >&2 + exit 73 +fi +case "${1:-}" in + show) + printf '[{"id":"gc-1","title":"ok"}]\n' + ;; + update) + printf '{"id":"gc-1","status":"in_progress"}\n' + ;; + *) + exit 2 + ;; +esac +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GC_CITY_PATH", cityDir) + t.Setenv("BD_EXPORT_AUTO", "true") + + for _, args := range [][]string{ + {"show", "gc-1", "--json"}, + {"update", "gc-1", "--claim", "--json"}, + } { + var stdout, stderr bytes.Buffer + if got := doBd(args, &stdout, &stderr); got != 0 { + t.Fatalf("doBd(%v) = %d, want 0; stdout=%q stderr=%q", args, got, stdout.String(), stderr.String()) + } + if strings.TrimSpace(stdout.String()) == "" { + t.Fatalf("doBd(%v) produced empty stdout", args) + } + if stderr.String() != "" { + t.Fatalf("doBd(%v) stderr = %q, want empty", args, stderr.String()) + } + } +} + func TestGcBdDoesNotAutoRouteHyphenatedFlagValue(t *testing.T) { origCityFlag := cityFlag origRigFlag := rigFlag @@ -614,6 +677,61 @@ provider = "file" } } +// TestGcBdRejectsStaleFileMarkerWithDiagnosticHint asserts the error when +// a scope has a stale .gc/beads.json (file-store marker) but no +// .beads/metadata.json (bd-store marker): gc rejects with a hint that +// names the offending marker and suggests the fix. Regression for the +// post-#899 behavior change where stale migration artifacts silently +// reclassified rigs as file-backed with no diagnostic. +func TestGcBdRejectsStaleFileMarkerWithDiagnosticHint(t *testing.T) { + origCityFlag := cityFlag + origRigFlag := rigFlag + defer func() { + cityFlag = origCityFlag + rigFlag = origRigFlag + }() + cityFlag = "" + rigFlag = "" + + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "legacy-rig") + if err := os.MkdirAll(filepath.Join(rigDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace] +name = "demo" + +[beads] +provider = "bd" + +[[rigs]] +name = "legacy-rig" +path = "legacy-rig" +prefix = "lg" +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigDir, ".gc", "beads.json"), []byte(`{"seq":1,"beads":[]}`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GC_CITY_PATH", cityDir) + + var stdout, stderr bytes.Buffer + if got := doBd([]string{"--rig", "legacy-rig", "list"}, &stdout, &stderr); got == 0 { + t.Fatalf("doBd() = %d, want non-zero", got) + } + out := stderr.String() + if !strings.Contains(out, `resolved "file"`) { + t.Fatalf("stderr = %q, want named provider in error", out) + } + if !strings.Contains(out, ".gc/beads.json") { + t.Fatalf("stderr = %q, want named marker in hint", out) + } + if !strings.Contains(out, ".beads/metadata.json") { + t.Fatalf("stderr = %q, want named fix in hint", out) + } +} + func TestGcBdAllowsRigPassthroughForBdBackedRigUnderFileCity(t *testing.T) { origCityFlag := cityFlag origRigFlag := rigFlag @@ -831,7 +949,7 @@ func TestManagedBdRigProviderStoreRecoversAfterHardKillPortRebind(t *testing.T) if err != nil { t.Fatalf("providerStore.Create after rebind: %v", err) } - if got := beadPrefix(rebound.ID); got != "fe" { + if got := beadPrefix(nil, rebound.ID); got != "fe" { t.Fatalf("provider rebind bead prefix = %q, want %q", got, "fe") } @@ -882,7 +1000,7 @@ func TestManagedBdRigStoreConsistentAcrossRawBdGcBdAndProviderStore(t *testing.T if err != nil { t.Fatalf("providerStore.Create: %v", err) } - if got := beadPrefix(providerBead.ID); got != "fe" { + if got := beadPrefix(nil, providerBead.ID); got != "fe" { t.Fatalf("provider rig bead prefix = %q, want %q", got, "fe") } rawShow := runRawBDFromDir(t, bdPath, rawDir, "show", "--json", providerBead.ID) @@ -1010,7 +1128,7 @@ func TestManagedBdCityStoreConsistentAcrossRawBdGcBdAndProviderStore(t *testing. } rawID := parseCreatedBeadID(t, runRawBDFromDir(t, bdPath, rawDir, "create", "--json", "raw city bead", "-t", "task")) - if got := beadPrefix(rawID); got != "gc" { + if got := beadPrefix(nil, rawID); got != "gc" { t.Fatalf("raw city bead prefix = %q, want %q", got, "gc") } providerStore, err := openStoreAtForCity(cityPath, cityPath) @@ -1036,7 +1154,7 @@ func TestManagedBdCityStoreConsistentAcrossRawBdGcBdAndProviderStore(t *testing. if err != nil { t.Fatalf("providerStore.Create: %v", err) } - if got := beadPrefix(providerBead.ID); got != "gc" { + if got := beadPrefix(nil, providerBead.ID); got != "gc" { t.Fatalf("provider city bead prefix = %q, want %q", got, "gc") } rawShow := runRawBDFromDir(t, bdPath, rawDir, "show", "--json", providerBead.ID) @@ -1072,7 +1190,7 @@ func TestFreshManagedBdCityInitSeedsPinnedHQDatabaseAndKeepsGCPrefix(t *testing. t.Fatalf("MkdirAll(rawDir): %v", err) } rawID := parseCreatedBeadID(t, runRawBDFromDir(t, bdPath, rawDir, "create", "--json", "fresh city bead", "-t", "task")) - if got := beadPrefix(rawID); got != "gc" { + if got := beadPrefix(nil, rawID); got != "gc" { t.Fatalf("raw city bead prefix = %q, want %q", got, "gc") } providerStore, err := openStoreAtForCity(cityPath, cityPath) @@ -1083,7 +1201,7 @@ func TestFreshManagedBdCityInitSeedsPinnedHQDatabaseAndKeepsGCPrefix(t *testing. if err != nil { t.Fatalf("providerStore.Create: %v", err) } - if got := beadPrefix(providerBead.ID); got != "gc" { + if got := beadPrefix(nil, providerBead.ID); got != "gc" { t.Fatalf("provider city bead prefix = %q, want %q", got, "gc") } } @@ -1283,6 +1401,38 @@ func TestResolveBdScopeTargetUsesEnclosingRig(t *testing.T) { } } +func TestResolveBdScopeTargetRoutesExistingCityBeadFromRigCwd(t *testing.T) { + origProbe := bdBeadExists + defer func() { bdBeadExists = origProbe }() + bdBeadExists = func(_ string, target execStoreTarget, beadID string) bool { + return target.ScopeKind == "city" && beadID == "mc-city1" + } + + cityDir := filepath.Join(t.TempDir(), "city") + rigDir := filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(filepath.Join(rigDir, "nested"), 0o755); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "maintainer-city", Prefix: "mc"}, + Rigs: []config.Rig{{Name: "frontend", Path: "frontend", Prefix: "fr"}}, + } + setCwd(t, filepath.Join(rigDir, "nested")) + + got, err := resolveBdScopeTarget(cfg, cityDir, "", []string{"show", "mc-city1"}) + if err != nil { + t.Fatalf("resolveBdScopeTarget() error = %v", err) + } + want := execStoreTarget{ + ScopeRoot: cityDir, + ScopeKind: "city", + Prefix: "mc", + } + if got != want { + t.Fatalf("resolveBdScopeTarget() = %#v, want %#v", got, want) + } +} + func TestGcBdRespectsRawCityFlag(t *testing.T) { origCityFlag := cityFlag origRigFlag := rigFlag @@ -1490,6 +1640,8 @@ set -eu origPath := os.Getenv("PATH") t.Setenv("PATH", binDir+string(os.PathListSeparator)+origPath) t.Setenv("CAPTURE_PATH", capture) + t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") t.Setenv("GC_DOLT_PORT", "9999") var stdout, stderr bytes.Buffer diff --git a/cmd/gc/cmd_beads_city_test.go b/cmd/gc/cmd_beads_city_test.go index 25f865b927..b5e00d8fbc 100644 --- a/cmd/gc/cmd_beads_city_test.go +++ b/cmd/gc/cmd_beads_city_test.go @@ -286,6 +286,7 @@ func TestDoBeadsCityUseExternalStopsManagedLocalProvider(t *testing.T) { verifyCityExternalEndpoint = func(contract.ConfigState, string, string) error { return nil } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) var stdout, stderr bytes.Buffer code := doBeadsCityEndpoint(fsys.OSFS{}, cityDir, cityEndpointOptions{ External: true, @@ -331,6 +332,7 @@ func TestDoBeadsCityUseExternalValidationFailureDoesNotStopManagedLocalProvider( verifyCityExternalEndpoint = func(contract.ConfigState, string, string) error { return fmt.Errorf("nope") } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) var stdout, stderr bytes.Buffer code := doBeadsCityEndpoint(fsys.OSFS{}, cityDir, cityEndpointOptions{ External: true, @@ -387,6 +389,7 @@ func TestDoBeadsCityUseExternalStopFailureKeepsExternalConfig(t *testing.T) { verifyCityExternalEndpoint = func(contract.ConfigState, string, string) error { return nil } t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityDir) var stdout, stderr bytes.Buffer code := doBeadsCityEndpoint(fsys.OSFS{}, cityDir, cityEndpointOptions{ External: true, diff --git a/cmd/gc/cmd_citystatus.go b/cmd/gc/cmd_citystatus.go index 43f1c41073..8e5e5c510f 100644 --- a/cmd/gc/cmd_citystatus.go +++ b/cmd/gc/cmd_citystatus.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -105,12 +106,17 @@ func cmdCityStatus(args []string, jsonOutput bool, stdout, stderr io.Writer) int return 1 } - sp := newSessionProvider() + store, code := openCityStatusStore(cityPath, stderr) + if code != 0 { + return code + } + statusSnapshot := loadStatusSessionSnapshot(store) + sp := newStatusSessionProviderForCityWithSnapshot(cfg, cityPath, statusSnapshot) dops := newDrainOps(sp) if jsonOutput { - return doCityStatusJSON(sp, cfg, cityPath, stdout, stderr) + return doCityStatusJSONWithStoreAndSnapshot(sp, cfg, cityPath, store, statusSnapshot, stdout, stderr) } - return doCityStatus(sp, dops, cfg, cityPath, stdout, stderr) + return doCityStatusWithStoreAndSnapshot(sp, dops, cfg, cityPath, store, statusSnapshot, stdout, stderr) } func observeSessionTargetWithWarning( @@ -119,16 +125,63 @@ func observeSessionTargetWithWarning( store beads.Store, sp runtime.Provider, cfg *config.City, - target string, + target statusObservationTarget, stderr io.Writer, ) worker.LiveObservation { - obs, err := observeSessionTargetForStatus(cityPath, store, sp, cfg, target) + if store != nil && target.sessionID != "" { + handle, err := workerHandleForSessionWithConfig(cityPath, store, sp, cfg, target.sessionID) + if err == nil { + obs, err := worker.ObserveHandle(context.Background(), handle) + if err == nil { + return obs + } + } + } + + // Status already passes a concrete runtime session name. Resolving that + // string back through the bead store turns stopped pool instances such as + // "dog-1" into invalid bd show lookups, which can block the overview. + obs, err := observeSessionTargetForStatus(cityPath, nil, sp, cfg, target.runtimeSessionName) if err != nil && stderr != nil { - fmt.Fprintf(stderr, "%s: observing %q: %v\n", cmdName, target, err) //nolint:errcheck // best-effort stderr + fmt.Fprintf(stderr, "%s: observing %q: %v\n", cmdName, target.runtimeSessionName, err) //nolint:errcheck // best-effort stderr } return obs } +type statusObservationTarget struct { + runtimeSessionName string + sessionID string +} + +func loadStatusSessionSnapshot(store beads.Store) *sessionBeadSnapshot { + snapshot, err := loadSessionBeadSnapshot(store) + if err != nil { + return nil + } + return snapshot +} + +func statusObservationTargetForIdentity( + snapshot *sessionBeadSnapshot, + cityName string, + identity string, + sessionTemplate string, +) statusObservationTarget { + if snapshot != nil { + if bead, ok := snapshot.FindSessionBeadByTemplate(identity); ok { + if sessionName := strings.TrimSpace(bead.Metadata["session_name"]); sessionName != "" { + return statusObservationTarget{ + runtimeSessionName: sessionName, + sessionID: bead.ID, + } + } + } + } + return statusObservationTarget{ + runtimeSessionName: sessionName(nil, cityName, identity, sessionTemplate), + } +} + func namedSessionBlockedBySuspension(cfg *config.City, agentCfg *config.Agent, suspendedRigs map[string]bool) bool { if cfg == nil { return false @@ -155,8 +208,19 @@ func doCityStatus( if code != 0 { return code } + return doCityStatusWithStoreAndSnapshot(sp, dops, cfg, cityPath, store, loadStatusSessionSnapshot(store), stdout, stderr) +} - snapshot := collectCityStatusSnapshot(sp, cfg, cityPath, store, stderr) +func doCityStatusWithStoreAndSnapshot( + sp runtime.Provider, + dops drainOps, + cfg *config.City, + cityPath string, + store beads.Store, + statusSnapshot *sessionBeadSnapshot, + stdout, stderr io.Writer, +) int { + snapshot := collectCityStatusSnapshotFromStoreSnapshot(sp, cfg, cityPath, store, statusSnapshot, stderr) renderCityStatusText(snapshot, dops, stdout) if store != nil { @@ -186,8 +250,18 @@ func doCityStatusJSON( if code != 0 { return code } + return doCityStatusJSONWithStoreAndSnapshot(sp, cfg, cityPath, store, loadStatusSessionSnapshot(store), stdout, stderr) +} - snapshot := collectCityStatusSnapshot(sp, cfg, cityPath, store, stderr) +func doCityStatusJSONWithStoreAndSnapshot( + sp runtime.Provider, + cfg *config.City, + cityPath string, + store beads.Store, + statusSnapshot *sessionBeadSnapshot, + stdout, stderr io.Writer, +) int { + snapshot := collectCityStatusSnapshotFromStoreSnapshot(sp, cfg, cityPath, store, statusSnapshot, stderr) if store != nil { sessions, err := collectCitySessionCounts(cityPath, store, sp, cfg) if err != nil { diff --git a/cmd/gc/cmd_commands.go b/cmd/gc/cmd_commands.go index d35201be79..efe478cc93 100644 --- a/cmd/gc/cmd_commands.go +++ b/cmd/gc/cmd_commands.go @@ -16,6 +16,8 @@ import ( "github.com/spf13/cobra" ) +const docgenSkipAnnotation = "gc.docgen.skip" + func addDiscoveredCommandsToRoot(root *cobra.Command, entries []config.DiscoveredCommand, cityPath, cityName string, stdout, stderr io.Writer, warnOnCollision bool) { core := coreCommandNames(root) grouped := make(map[string][]config.DiscoveredCommand) @@ -46,8 +48,9 @@ func addDiscoveredCommandsToRoot(root *cobra.Command, entries []config.Discovere func newDiscoveredNamespaceCmd(binding string, entries []config.DiscoveredCommand, cityPath, cityName string, stdout, stderr io.Writer) *cobra.Command { ns := &cobra.Command{ - Use: binding, - Short: fmt.Sprintf("Commands from the %s import", binding), + Use: binding, + Short: fmt.Sprintf("Commands from the %s import", binding), + Annotations: map[string]string{docgenSkipAnnotation: "true"}, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, diff --git a/cmd/gc/cmd_config.go b/cmd/gc/cmd_config.go index 30852eba9c..2260c582af 100644 --- a/cmd/gc/cmd_config.go +++ b/cmd/gc/cmd_config.go @@ -27,13 +27,22 @@ func loadConfigCommandCityConfig(cityPath string) (*config.City, *config.Provena } func loadCityConfigWithBuiltinPacks(cityPath string, includes ...string) (*config.City, *config.Provenance, error) { + allIncludes, err := cityConfigIncludesWithBuiltinPacks(cityPath, includes...) + if err != nil { + return nil, nil, err + } + return config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"), allIncludes...) +} + +func cityConfigIncludesWithBuiltinPacks(cityPath string, includes ...string) ([]string, error) { if err := MaterializeBuiltinPacks(cityPath); err != nil { - return nil, nil, fmt.Errorf("materializing builtin packs: %w", err) + return nil, fmt.Errorf("materializing builtin packs: %w", err) } - allIncludes := make([]string, 0, len(includes)+3) + builtinIncludes := builtinPackIncludes(cityPath) + allIncludes := make([]string, 0, len(includes)+len(builtinIncludes)) allIncludes = append(allIncludes, includes...) - allIncludes = append(allIncludes, builtinPackIncludes(cityPath)...) - return config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"), allIncludes...) + allIncludes = append(allIncludes, builtinIncludes...) + return allIncludes, nil } func newConfigCmd(stdout, stderr io.Writer) *cobra.Command { diff --git a/cmd/gc/cmd_convoy_dispatch.go b/cmd/gc/cmd_convoy_dispatch.go index e727b660a1..635a81da6c 100644 --- a/cmd/gc/cmd_convoy_dispatch.go +++ b/cmd/gc/cmd_convoy_dispatch.go @@ -8,6 +8,7 @@ import ( "maps" "os" "os/signal" + "path/filepath" "strings" "syscall" @@ -117,25 +118,90 @@ func runControlDispatcher(beadID string, stdout, stderr io.Writer) error { return err } - // Try all stores (city + rigs) to find the bead. - store, bead, err := findBeadAcrossStores(cityPath, beadID, stderr) + // Manual control dispatch keeps the operator convenience of resolving a + // bead ID across city and rig stores. + store, bead, storePath, err := findBeadAcrossStores(cityPath, beadID, stderr) if err != nil { return fmt.Errorf("loading bead %s: %w", beadID, err) } - opts := dispatch.ProcessOptions{CityPath: cityPath} + return runControlDispatcherWithStore(cityPath, storePath, store, bead, beadID, stdout, stderr) +} + +func runControlDispatcherInStore(cityPath, storePath, beadID string, stdout, stderr io.Writer) error { + if cityPath == "" { + var err error + cityPath, err = resolveCity() + if err != nil { + return err + } + } + if storePath == "" { + storePath = cityPath + } + + cfg, err := loadCityConfig(cityPath, stderr) + if err != nil { + return err + } + resolveRigPaths(cityPath, cfg.Rigs) + store, err := openControlStoreAtForCity(storePath, cityPath, cfg) + if err != nil { + return fmt.Errorf("opening scoped control store %q: %w", storePath, err) + } + bead, err := store.Get(beadID) + if err != nil { + return fmt.Errorf("loading bead %s from scoped control store %q: %w", beadID, storePath, err) + } + + return runControlDispatcherWithStoreAndConfig(cityPath, storePath, store, bead, beadID, cfg, stdout, stderr) +} + +func runControlDispatcherWithStore(cityPath, storePath string, store beads.Store, bead beads.Bead, beadID string, stdout, stderr io.Writer) error { + return runControlDispatcherWithStoreAndConfig(cityPath, storePath, store, bead, beadID, nil, stdout, stderr) +} + +func runControlDispatcherWithStoreAndConfig(cityPath, storePath string, store beads.Store, bead beads.Bead, beadID string, cfg *config.City, stdout, stderr io.Writer) error { + restoreTraceWarnings := useWorkflowTraceWarnings(stderr) + defer restoreTraceWarnings() + var cfgLoadErr error + if cfg == nil { + cfg, cfgLoadErr = loadCityConfig(cityPath, stderr) + if cfg != nil { + resolveRigPaths(cityPath, cfg.Rigs) + } + } + if cfg != nil { + warnLegacyWorkflowTracePath(cityPath, cfg.Rigs, stderr) + } else { + warnLegacyWorkflowTracePath(cityPath, nil, stderr) + } + + opts := dispatch.ProcessOptions{CityPath: cityPath, StorePath: storePath} opts.Tracef = workflowTracef loadCfg := false switch bead.Metadata["gc.kind"] { case "check", "fanout", "retry-eval", "retry", "ralph": loadCfg = true + case "workflow-finalize": + // Need cfg to resolve "city:" / "rig:" store refs when + // closing parent source beads in their native stores. + loadCfg = true } if loadCfg { - cfg, err := loadCityConfig(cityPath, stderr) - if err != nil { - return err + if cfg == nil { + if cfgLoadErr != nil { + return cfgLoadErr + } + return fmt.Errorf("loading city config for %s: unavailable after warning-only load", cityPath) + } + opts.ResolveStoreRef = makeStoreRefResolver(cityPath, cfg) + if bead.Metadata["gc.kind"] == "workflow-finalize" { + sourceWorkflowCtx, cancelSourceWorkflowCtx := sourceWorkflowCommandContext() + defer cancelSourceWorkflowCtx() + opts.SourceWorkflowLock = makeSourceWorkflowLocker(sourceWorkflowCtx, cityPath, cfg, storePath) + opts.SourceWorkflowStores = makeSourceWorkflowStoresLister(cityPath, cfg) } - resolveRigPaths(cityPath, cfg.Rigs) switch bead.Metadata["gc.kind"] { case "check", "fanout": opts.FormulaSearchPaths = workflowFormulaSearchPaths(cfg, bead) @@ -179,43 +245,171 @@ func runControlDispatcher(beadID string, stdout, stderr io.Writer) error { return nil } +// makeStoreRefResolver returns a dispatch.ProcessOptions.ResolveStoreRef +// closure for the given city. The resolver maps "city:" and +// "rig:" gc.source_store_ref values to a beads.Store rooted at the +// matching scope. processWorkflowFinalize uses it to walk the source bead +// chain across store boundaries so a successful rig-scope workflow closes +// the city-scope source bead that spawned it (e.g. PR-review "Adopt PR" +// requests). +func makeStoreRefResolver(cityPath string, cfg *config.City) func(string) (beads.Store, error) { + cityName := loadedCityName(cfg, cityPath) + return func(ref string) (beads.Store, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("empty store ref") + } + switch { + case strings.HasPrefix(ref, "city:"): + name := strings.TrimSpace(strings.TrimPrefix(ref, "city:")) + // "city:" without a name still resolves to this city's store - + // older callers stamp ambiguous refs and the only reachable city + // from a control-dispatcher is the one it was launched in. + if name != "" && cityName != "" && name != cityName { + return nil, fmt.Errorf("city ref %q does not match this city %q", ref, cityName) + } + return openStoreAtForCity(cityPath, cityPath) + case strings.HasPrefix(ref, "rig:"): + name := strings.TrimSpace(strings.TrimPrefix(ref, "rig:")) + if name == "" { + return nil, fmt.Errorf("rig ref %q missing rig name", ref) + } + if cfg == nil { + return nil, fmt.Errorf("no city config available to resolve %q", ref) + } + for _, rig := range cfg.Rigs { + if rig.Name != name { + continue + } + return openControlStoreAtForCity(rig.Path, cityPath, cfg) + } + return nil, fmt.Errorf("rig %q not found in city config", name) + default: + return nil, fmt.Errorf("unsupported store ref scheme: %q", ref) + } + } +} + +func makeSourceWorkflowLocker(ctx context.Context, cityPath string, cfg *config.City, defaultStorePath string) func(storeRef, sourceBeadID string, fn func() error) error { + return func(storeRef, sourceBeadID string, fn func() error) error { + return sourceworkflow.WithLock(ctx, cityPath, sourceWorkflowLockScopeForStoreRef(cityPath, cfg, defaultStorePath, storeRef), sourceBeadID, fn) + } +} + +func makeSourceWorkflowStoresLister(cityPath string, cfg *config.City) func() ([]dispatch.SourceWorkflowStore, error) { + return makeSourceWorkflowStoresListerWithOpenStore(cityPath, cfg, func(dir string) (beads.Store, error) { + return openStoreAtForCity(dir, cityPath) + }) +} + +func makeSourceWorkflowStoresListerWithOpenStore(cityPath string, cfg *config.City, openStore func(string) (beads.Store, error)) func() ([]dispatch.SourceWorkflowStore, error) { + var ( + loaded bool + stores []dispatch.SourceWorkflowStore + loadErr error + ) + return func() ([]dispatch.SourceWorkflowStore, error) { + if loaded { + return stores, loadErr + } + loaded = true + views, skips, err := openSourceWorkflowStoresWith(cfg, cityPath, "", openStore) + if err != nil { + loadErr = err + return nil, err + } + if len(skips) > 0 { + msg := formatSourceWorkflowStoreSkips(skips) + workflowTracef("source-workflow stores warning=%q", msg) + loadErr = errors.New(msg) + return nil, loadErr + } + cityName := loadedCityName(cfg, cityPath) + stores = make([]dispatch.SourceWorkflowStore, 0, len(views)) + for _, view := range views { + stores = append(stores, dispatch.SourceWorkflowStore{ + Store: view.store, + StoreRef: workflowStoreRefForDir(view.path, cityPath, cityName, cfg), + }) + } + return stores, nil + } +} + +func sourceWorkflowLockScopeForStoreRef(cityPath string, cfg *config.City, defaultStorePath string, storeRef string) string { + return sourceworkflow.LockScopeForStoreRef(cityPath, defaultStorePath, storeRef, func(rigName string) (string, bool) { + if cfg != nil { + for _, rig := range cfg.Rigs { + if rig.Name != rigName { + continue + } + return rig.Path, true + } + } + return "", false + }) +} + +func openControlStoreAtForCity(storePath, cityPath string, cfg *config.City) (beads.Store, error) { + scopeRoot := resolveStoreScopeRoot(cityPath, storePath) + provider := rawBeadsProviderForScope(scopeRoot, cityPath) + if provider == "file" || strings.HasPrefix(provider, "exec:") { + return openStoreAtForCity(storePath, cityPath) + } + if samePath(scopeRoot, cityPath) { + return controlBdStoreForCity(scopeRoot, cityPath, cfg), nil + } + if cfg != nil { + for _, rig := range cfg.Rigs { + rigPath := rig.Path + if !filepath.IsAbs(rigPath) { + rigPath = filepath.Join(cityPath, rigPath) + } + if samePath(rigPath, scopeRoot) { + return controlBdStoreForRig(scopeRoot, cityPath, cfg), nil + } + } + } + // A bd-backed scope can outlive its rig entry in city.toml. Control paths + // still need write-capable bd commands with auto-export suppressed. + return controlBdStoreForRig(scopeRoot, cityPath, cfg), nil +} + // findBeadAcrossStores tries the city store first, then all rig stores, // returning the store and bead on first match. -func findBeadAcrossStores(cityPath, beadID string, warningWriter io.Writer) (beads.Store, beads.Bead, error) { +func findBeadAcrossStores(cityPath, beadID string, warningWriter io.Writer) (beads.Store, beads.Bead, string, error) { // Try city store first. cityStore, err := openStoreAtForCity(cityPath, cityPath) if err != nil { - return nil, beads.Bead{}, fmt.Errorf("opening city store: %w", err) + return nil, beads.Bead{}, "", fmt.Errorf("opening city store: %w", err) } - if bead, err := cityStore.Get(beadID); err == nil { - return cityStore, bead, nil + if b, err := cityStore.Get(beadID); err == nil { + return cityStore, b, cityPath, nil } else if !errors.Is(err, beads.ErrNotFound) { - return nil, beads.Bead{}, fmt.Errorf("getting bead %q from %s: %w", beadID, cityPath, err) + return nil, beads.Bead{}, "", fmt.Errorf("getting bead %q from %s: %w", beadID, cityPath, err) } // Try rig stores. cfg, err := loadCityConfig(cityPath, warningWriter) if err != nil { - return nil, beads.Bead{}, err + return nil, beads.Bead{}, "", fmt.Errorf("getting bead %q: not in city store, and config unavailable: %w", beadID, err) } - for _, dir := range convoyStoreCandidates(cfg, cityPath, beadID) { - if dir == cityPath { - continue - } - store, err := openStoreAtForCity(dir, cityPath) + resolveRigPaths(cityPath, cfg.Rigs) + for _, rig := range cfg.Rigs { + store, err := openControlStoreAtForCity(rig.Path, cityPath, cfg) if err != nil { - return nil, beads.Bead{}, fmt.Errorf("opening store %s: %w", dir, err) + return nil, beads.Bead{}, "", fmt.Errorf("opening rig store %q: %w", rig.Name, err) } bead, err := store.Get(beadID) if err != nil { if errors.Is(err, beads.ErrNotFound) { continue } - return nil, beads.Bead{}, fmt.Errorf("getting bead %q from %s: %w", beadID, dir, err) + return nil, beads.Bead{}, "", fmt.Errorf("getting bead %q from %s: %w", beadID, rig.Path, err) } - return store, bead, nil + return store, bead, rig.Path, nil } - return nil, beads.Bead{}, fmt.Errorf("getting bead %q: %w", beadID, beads.ErrNotFound) + return nil, beads.Bead{}, "", fmt.Errorf("getting bead %q: %w", beadID, beads.ErrNotFound) } func findUniqueBeadAcrossStoresView(cityPath, beadID string) (convoyStoreView, beads.Bead, error) { @@ -408,14 +602,14 @@ func newConvoyDeleteCmd(stdout, stderr io.Writer) *cobra.Command { var deleteBeads bool cmd := &cobra.Command{ Use: "delete ", - Short: "Close and optionally delete a convoy and all its beads", - Long: `Close all open beads in a convoy, then optionally delete them. + Short: "Close or delete a convoy and all its beads", + Long: `Close all open beads in a convoy, or delete them. Searches all stores (city + rigs) for the convoy root and all beads with matching gc.root_bead_id. Without --force, shows a preview. By default, beads are closed with gc.outcome=skipped. Use --delete to -also remove them from the store after closing.`, +remove them from the store via bd delete --cascade --force.`, Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { if cmdWorkflowDelete(args[0], force, deleteBeads, stdout, stderr) != 0 { @@ -425,7 +619,7 @@ also remove them from the store after closing.`, }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually close/delete (without this, shows preview)") - cmd.Flags().BoolVar(&deleteBeads, "delete", false, "Also delete beads from the store after closing") + cmd.Flags().BoolVar(&deleteBeads, "delete", false, "Delete beads from the store instead of closing") return cmd } @@ -482,6 +676,14 @@ func newConvoyReopenSourceCmd(stdout, stderr io.Writer) *cobra.Command { return cmd } +type workflowStoreMatch struct { + store beads.Store + beads []beads.Bead + label string + path string + runner beads.CommandRunner +} + func cmdWorkflowDelete(workflowID string, force, deleteBeads bool, stdout, stderr io.Writer) int { cityPath, err := resolveCity() if err != nil { @@ -493,16 +695,12 @@ func cmdWorkflowDelete(workflowID string, force, deleteBeads bool, stdout, stder fmt.Fprintf(stderr, "gc workflow delete: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } + resolveRigPaths(cityPath, cfg.Rigs) - type storeMatch struct { - store beads.Store - beads []beads.Bead - label string - } - var matches []storeMatch + var matches []workflowStoreMatch stores, err := openConvoyStores(cfg, cityPath, workflowID, func(dir string) (beads.Store, error) { - return openStoreAtForCity(dir, cityPath) + return openControlStoreAtForCity(dir, cityPath, cfg) }) if err != nil { fmt.Fprintf(stderr, "gc workflow delete: %v\n", err) //nolint:errcheck // best-effort stderr @@ -513,10 +711,12 @@ func cmdWorkflowDelete(workflowID string, force, deleteBeads bool, stdout, stder if len(found) == 0 { continue } - matches = append(matches, storeMatch{ - store: info.store, - beads: found, - label: workflowDeleteStoreLabel(cfg, cityPath, info.path), + matches = append(matches, workflowStoreMatch{ + store: info.store, + beads: found, + label: workflowDeleteStoreLabel(cfg, cityPath, info.path), + path: info.path, + runner: workflowDeleteRunnerForPath(cfg, cityPath, info.path), }) } @@ -549,41 +749,62 @@ func cmdWorkflowDelete(workflowID string, force, deleteBeads bool, stdout, stder return 0 } - // Phase 1: Batch close all open beads with gc.outcome=skipped. + if deleteBeads { + deleted, err := deleteWorkflowMatches(matches) + if err != nil { + fmt.Fprintf(stderr, " batch delete: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + fmt.Fprintf(stdout, "Deleted %d beads\n", deleted) //nolint:errcheck // best-effort stdout + return 0 + } + + closed := closeWorkflowMatches(matches) + fmt.Fprintf(stdout, "Closed %d open beads\n", closed) //nolint:errcheck // best-effort stdout + return 0 +} + +func closeWorkflowMatches(matches []workflowStoreMatch) int { closed := 0 for _, m := range matches { ids := workflowBeadIDs(m.beads) n, _ := m.store.CloseAll(ids, map[string]string{"gc.outcome": "skipped"}) closed += n } - fmt.Fprintf(stdout, "Closed %d open beads\n", closed) //nolint:errcheck // best-effort stdout + return closed +} - if !deleteBeads { - return 0 +func workflowDeleteRunnerForPath(cfg *config.City, cityPath, scopePath string) beads.CommandRunner { + if samePath(scopePath, cityPath) { + return bdCommandRunnerForCity(cityPath) } + return bdCommandRunnerForRig(cityPath, cfg, scopePath) +} +func deleteWorkflowMatches(matches []workflowStoreMatch) (int, error) { deleted := 0 - deleteFailed := false for _, m := range matches { - count, errs := deleteWorkflowBeads(m.store, workflowBeadIDs(m.beads)) - deleted += count - for _, err := range errs { - deleteFailed = true - fmt.Fprintf(stderr, " delete %s: %v\n", m.label, err) //nolint:errcheck // best-effort stderr + if m.runner == nil { + return deleted, fmt.Errorf("%s: delete runner missing", m.label) } + ids := workflowBeadIDs(m.beads) + args := append([]string{"delete"}, ids...) + args = append(args, "--cascade", "--force") + if _, err := m.runner(m.path, "bd", args...); err != nil { + return deleted, fmt.Errorf("%s: %w", m.label, err) + } + deleted += len(ids) } - fmt.Fprintf(stdout, "Deleted %d beads\n", deleted) //nolint:errcheck // best-effort stdout - if deleteFailed { - return 1 - } - return 0 + return deleted, nil } type sourceWorkflowStoreMatch struct { - label string - store beads.Store - roots []beads.Bead - beads []beads.Bead + label string + store beads.Store + roots []beads.Bead + beads []beads.Bead + path string + runner beads.CommandRunner } type sourceWorkflowStoreSelector struct { @@ -708,7 +929,7 @@ func applySourceWorkflowMatchCleanup(match sourceWorkflowStoreMatch, deleteBeads if !deleteBeads { return closed, deleted, incomplete } - count, errs := deleteWorkflowBeads(match.store, ids) + count, errs := deleteSourceWorkflowMatchBeads(match, ids) deleted += count for _, deleteErr := range errs { incomplete = true @@ -717,6 +938,13 @@ func applySourceWorkflowMatchCleanup(match sourceWorkflowStoreMatch, deleteBeads return closed, deleted, incomplete } +func deleteSourceWorkflowMatchBeads(match sourceWorkflowStoreMatch, ids []string) (int, []error) { + if len(ids) == 0 { + return 0, nil + } + return deleteWorkflowBeads(match.store, ids) +} + func cmdWorkflowDeleteSource(sourceBeadID string, selector sourceWorkflowStoreSelector, apply, deleteBeads bool, stdout, stderr io.Writer) int { cityPath, err := resolveCity() if err != nil { @@ -1049,28 +1277,107 @@ func collectSourceWorkflowMatches(cfg *config.City, cityPath, sourceBeadID, sour if err != nil { return nil, skips, err } - matches := make([]sourceWorkflowStoreMatch, 0, len(stores)) - for _, info := range stores { - rootStoreRef := workflowStoreRefForDir(info.path, cityPath, loadedCityName(cfg, cityPath), cfg) - roots, err := sourceworkflow.ListLiveRoots(info.store, sourceBeadID, sourceStoreRef, rootStoreRef) - if err != nil { - return nil, skips, err + matchesByLabel := map[string]sourceWorkflowStoreMatch{} + visited := map[string]struct{}{} + cityName := loadedCityName(cfg, cityPath) + + var collect func(string, string) error + collect = func(currentSourceID, currentSourceStoreRef string) error { + currentSourceID = strings.TrimSpace(currentSourceID) + if currentSourceID == "" { + return nil } - if len(roots) == 0 { + for _, info := range stores { + rootStoreRef := workflowStoreRefForDir(info.path, cityPath, cityName, cfg) + // Downward delete-source walks key by root store plus source + // identity. The upward finalize walk in internal/dispatch only + // needs source store plus bead ID because each hop has one parent. + visitKey := rootStoreRef + "\x00" + currentSourceStoreRef + "\x00" + currentSourceID + if _, ok := visited[visitKey]; ok { + continue + } + visited[visitKey] = struct{}{} + roots, err := sourceworkflow.ListLiveRoots(info.store, currentSourceID, currentSourceStoreRef, rootStoreRef) + if err != nil { + return err + } + if len(roots) > 0 { + beadSet := make([]beads.Bead, 0, len(roots)) + for _, root := range roots { + beadSet = append(beadSet, findWorkflowBeads(info.store, root.ID)...) + } + mergeSourceWorkflowMatch(matchesByLabel, sourceWorkflowStoreMatch{ + label: workflowDeleteStoreLabel(cfg, cityPath, info.path), + store: info.store, + roots: roots, + beads: uniqueBeads(beadSet), + path: info.path, + runner: workflowDeleteRunnerForPath(cfg, cityPath, info.path), + }) + } + children, err := sourceWorkflowChildSources(info.store, currentSourceID, currentSourceStoreRef, rootStoreRef) + if err != nil { + return err + } + for _, child := range children { + if err := collect(child.ID, rootStoreRef); err != nil { + return err + } + } + } + return nil + } + if err := collect(sourceBeadID, sourceStoreRef); err != nil { + return nil, skips, err + } + matches := make([]sourceWorkflowStoreMatch, 0, len(matchesByLabel)) + for _, match := range matchesByLabel { + match.roots = uniqueBeads(match.roots) + match.beads = uniqueBeads(match.beads) + matches = append(matches, match) + } + return matches, skips, nil +} + +func mergeSourceWorkflowMatch(matches map[string]sourceWorkflowStoreMatch, next sourceWorkflowStoreMatch) { + if next.label == "" { + return + } + current := matches[next.label] + if current.label == "" { + matches[next.label] = next + return + } + current.roots = append(current.roots, next.roots...) + current.beads = append(current.beads, next.beads...) + matches[next.label] = current +} + +func sourceWorkflowChildSources(store beads.Store, sourceBeadID, sourceStoreRef, rootStoreRef string) ([]beads.Bead, error) { + sourceBeadID = strings.TrimSpace(sourceBeadID) + if store == nil || sourceBeadID == "" { + return nil, nil + } + candidates, err := store.List(beads.ListQuery{ + IncludeClosed: true, + Metadata: map[string]string{ + "gc.source_bead_id": sourceBeadID, + }, + }) + if err != nil { + return nil, err + } + children := make([]beads.Bead, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.ID == "" || sourceworkflow.IsWorkflowRoot(candidate) { continue } - beadSet := make([]beads.Bead, 0, len(roots)) - for _, root := range roots { - beadSet = append(beadSet, findWorkflowBeads(info.store, root.ID)...) + if !sourceworkflow.WorkflowMatchesSource(candidate, sourceBeadID, sourceStoreRef, rootStoreRef) { + continue } - matches = append(matches, sourceWorkflowStoreMatch{ - label: workflowDeleteStoreLabel(cfg, cityPath, info.path), - store: info.store, - roots: roots, - beads: uniqueBeads(beadSet), - }) + children = append(children, candidate) } - return matches, skips, nil + return children, nil } func sourceWorkflowMatchLabels(matches []sourceWorkflowStoreMatch) []string { diff --git a/cmd/gc/cmd_convoy_dispatch_test.go b/cmd/gc/cmd_convoy_dispatch_test.go index ac506f78e3..0fc0390e26 100644 --- a/cmd/gc/cmd_convoy_dispatch_test.go +++ b/cmd/gc/cmd_convoy_dispatch_test.go @@ -2,12 +2,14 @@ package main import ( "bytes" + "encoding/json" "errors" "fmt" "io" "maps" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -98,6 +100,183 @@ func TestOpenSourceWorkflowStoresFailsOnlyWhenEverythingBroken(t *testing.T) { } } +func TestWorkflowFinalizeRetriesWhenSourceWorkflowStoreScanSkipsLiveRoot(t *testing.T) { + cityPath := "/city" + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{ + {Name: "alpha", Path: "rigs/alpha"}, + {Name: "broken", Path: "rigs/broken"}, + }, + } + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + brokenStore := beads.NewMemStore() + + citySource, err := cityStore.Create(beads.Bead{Title: "Adopt PR", Type: "task"}) + if err != nil { + t.Fatalf("Create(city source): %v", err) + } + rigLaunch, err := rigStore.Create(beads.Bead{ + Title: "Rig launch", + Type: "task", + Metadata: map[string]string{ + "gc.source_bead_id": citySource.ID, + "gc.source_store_ref": "city:test-city", + }, + }) + if err != nil { + t.Fatalf("Create(rig launch): %v", err) + } + workflow, err := rigStore.Create(beads.Bead{ + Title: "mol-adopt-pr-v2", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + "gc.source_bead_id": rigLaunch.ID, + "gc.source_store_ref": "rig:alpha", + }, + }) + if err != nil { + t.Fatalf("Create(workflow): %v", err) + } + cleanup, err := rigStore.Create(beads.Bead{ + Title: "cleanup", + Type: "task", + Metadata: map[string]string{ + "gc.outcome": "pass", + }, + }) + if err != nil { + t.Fatalf("Create(cleanup): %v", err) + } + if err := rigStore.Close(cleanup.ID); err != nil { + t.Fatalf("Close(cleanup): %v", err) + } + finalizer, err := rigStore.Create(beads.Bead{ + Title: "Finalize workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow-finalize", + "gc.root_bead_id": workflow.ID, + }, + }) + if err != nil { + t.Fatalf("Create(finalizer): %v", err) + } + if err := rigStore.DepAdd(finalizer.ID, cleanup.ID, "blocks"); err != nil { + t.Fatalf("DepAdd(finalizer->cleanup): %v", err) + } + if err := rigStore.DepAdd(workflow.ID, finalizer.ID, "blocks"); err != nil { + t.Fatalf("DepAdd(workflow->finalizer): %v", err) + } + hiddenRoot, err := brokenStore.Create(beads.Bead{ + Title: "hidden live workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + "gc.source_bead_id": citySource.ID, + sourceworkflow.SourceStoreRefMetadataKey: "city:test-city", + }, + }) + if err != nil { + t.Fatalf("Create(hidden root): %v", err) + } + + openStore := func(dir string) (beads.Store, error) { + switch filepath.Clean(dir) { + case filepath.Clean(cityPath): + return cityStore, nil + case filepath.Clean(filepath.Join(cityPath, "rigs/alpha")): + return rigStore, nil + case filepath.Clean(filepath.Join(cityPath, "rigs/broken")): + return nil, fmt.Errorf("simulated broken rig with live root %s", hiddenRoot.ID) + default: + return nil, fmt.Errorf("unexpected store path %s", dir) + } + } + resolver := func(ref string) (beads.Store, error) { + switch ref { + case "city:test-city": + return cityStore, nil + case "rig:alpha": + return rigStore, nil + default: + return nil, fmt.Errorf("unknown ref %s", ref) + } + } + + _, err = dispatch.ProcessControl(rigStore, finalizer, dispatch.ProcessOptions{ + ResolveStoreRef: resolver, + SourceWorkflowStores: makeSourceWorkflowStoresListerWithOpenStore(cityPath, cfg, openStore), + SourceWorkflowLock: func(_ string, _ string, fn func() error) error { return fn() }, + }) + if err == nil { + t.Fatal("ProcessControl(workflow-finalize) err = nil, want retryable skipped-store error") + } + if !strings.Contains(err.Error(), "source-workflow singleton scan skipped") { + t.Fatalf("ProcessControl error = %v, want skipped-store scan error", err) + } + + workflowAfter, err := rigStore.Get(workflow.ID) + if err != nil { + t.Fatalf("Get(workflow): %v", err) + } + if workflowAfter.Status == "closed" { + t.Fatal("workflow status = closed; want open so singleton scans still see the retrying root") + } + finalizerAfter, err := rigStore.Get(finalizer.ID) + if err != nil { + t.Fatalf("Get(finalizer): %v", err) + } + if finalizerAfter.Status == "closed" { + t.Fatal("finalizer status = closed; want open so source-chain closure retries after skipped scan") + } + rigLaunchAfter, err := rigStore.Get(rigLaunch.ID) + if err != nil { + t.Fatalf("Get(rig launch): %v", err) + } + if rigLaunchAfter.Status == "closed" { + t.Fatal("rig launch status = closed; want open until all source-workflow stores are scanned") + } + citySourceAfter, err := cityStore.Get(citySource.ID) + if err != nil { + t.Fatalf("Get(city source): %v", err) + } + if citySourceAfter.Status == "closed" { + t.Fatal("city source status = closed; want open while a skipped store may contain a live root") + } + hiddenRootAfter, err := brokenStore.Get(hiddenRoot.ID) + if err != nil { + t.Fatalf("Get(hidden root): %v", err) + } + if hiddenRootAfter.Status == "closed" { + t.Fatal("hidden root status = closed; want unchanged") + } +} + +func TestSourceWorkflowLockScopeForStoreRefUsesSharedHelper(t *testing.T) { + cityPath := "/city" + cfg := &config.City{ + Rigs: []config.Rig{ + {Name: "alpha", Path: "rigs/alpha"}, + }, + } + + got := sourceWorkflowLockScopeForStoreRef(cityPath, cfg, "", "rig:alpha") + want := sourceworkflow.LockScopeForStoreRef(cityPath, "", "rig:alpha", func(rigName string) (string, bool) { + if rigName != "alpha" { + return "", false + } + return "rigs/alpha", true + }) + if got != want { + t.Fatalf("sourceWorkflowLockScopeForStoreRef = %q, want shared helper scope %q", got, want) + } +} + type closeAllFailStore struct { beads.Store failOn map[string]struct{} @@ -185,8 +364,8 @@ func TestDecorateDynamicFragmentRecipeSupportsExplicitPerStepAgents(t *testing.T if control.Assignee != config.ControlDispatcherAgentName { t.Fatalf("review scope-check assignee = %q, want %q", control.Assignee, config.ControlDispatcherAgentName) } - if control.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("review scope-check gc.routed_to = %q, want %q", control.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if got := control.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("review scope-check gc.routed_to = %q, want empty direct dispatcher assignee", got) } if control.Metadata[graphExecutionRouteMetaKey] != "reviewer" { t.Fatalf("review scope-check execution route = %q, want reviewer", control.Metadata[graphExecutionRouteMetaKey]) @@ -313,6 +492,104 @@ func TestFindWorkflowBeadsResolvesLogicalWorkflowID(t *testing.T) { } } +func TestDeleteWorkflowMatchesUsesCascadeWithoutPreClose(t *testing.T) { + store := beads.NewMemStore() + root, err := store.Create(beads.Bead{ + Title: "Workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + }, + }) + if err != nil { + t.Fatalf("Create(root): %v", err) + } + child, err := store.Create(beads.Bead{ + Title: "Child", + Type: "task", + Metadata: map[string]string{ + "gc.root_bead_id": root.ID, + }, + }) + if err != nil { + t.Fatalf("Create(child): %v", err) + } + + var gotDir, gotName string + var gotArgs []string + deleted, err := deleteWorkflowMatches([]workflowStoreMatch{{ + store: store, + beads: []beads.Bead{root, child}, + label: "city", + path: "/city", + runner: func(dir, name string, args ...string) ([]byte, error) { + gotDir = dir + gotName = name + gotArgs = append([]string(nil), args...) + return nil, nil + }, + }}) + if err != nil { + t.Fatalf("deleteWorkflowMatches: %v", err) + } + if deleted != 2 { + t.Fatalf("deleted = %d, want 2", deleted) + } + if gotDir != "/city" || gotName != "bd" { + t.Fatalf("runner target = (%q, %q), want (/city, bd)", gotDir, gotName) + } + wantArgs := []string{"delete", root.ID, child.ID, "--cascade", "--force"} + if !slices.Equal(gotArgs, wantArgs) { + t.Fatalf("delete args = %#v, want %#v", gotArgs, wantArgs) + } + for _, id := range []string{root.ID, child.ID} { + after, err := store.Get(id) + if err != nil { + t.Fatalf("Get(%s): %v", id, err) + } + if after.Status != "open" || after.Metadata["gc.outcome"] == "skipped" { + t.Fatalf("bead %s mutated before delete: status=%q metadata=%#v", id, after.Status, after.Metadata) + } + } +} + +func TestDeleteWorkflowMatchesFailureDoesNotCloseBeads(t *testing.T) { + store := beads.NewMemStore() + root, err := store.Create(beads.Bead{ + Title: "Workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + }, + }) + if err != nil { + t.Fatalf("Create(root): %v", err) + } + + deleted, err := deleteWorkflowMatches([]workflowStoreMatch{{ + store: store, + beads: []beads.Bead{root}, + label: "city", + path: "/city", + runner: func(string, string, ...string) ([]byte, error) { + return nil, fmt.Errorf("delete failed") + }, + }}) + if err == nil { + t.Fatal("deleteWorkflowMatches returned nil error, want delete failure") + } + if deleted != 0 { + t.Fatalf("deleted = %d, want 0 after failed delete", deleted) + } + after, err := store.Get(root.ID) + if err != nil { + t.Fatalf("Get(root): %v", err) + } + if after.Status != "open" || after.Metadata["gc.outcome"] == "skipped" { + t.Fatalf("root mutated after failed delete: status=%q metadata=%#v", after.Status, after.Metadata) + } +} + func TestCmdWorkflowDeleteSourceClosesMatchedRootsAndClearsWorkflowID(t *testing.T) { cityDir := t.TempDir() if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { @@ -320,6 +597,7 @@ func TestCmdWorkflowDeleteSourceClosesMatchedRootsAndClearsWorkflowID(t *testing } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -394,46 +672,86 @@ func TestCmdWorkflowDeleteSourceClosesMatchedRootsAndClearsWorkflowID(t *testing } } -func TestCmdWorkflowDeleteSourceClosesGraphV2OnlyRoot(t *testing.T) { - // Regression: after the ListLiveRoots contract fix, the singleton - // scanner surfaces graph.v2-only roots (marked with - // gc.formula_contract=graph.v2 and no gc.kind=workflow). But - // findWorkflowBeads — the cleanup collector called from - // collectSourceWorkflowMatches — still required gc.kind=workflow, so - // delete-source would list the root and close nothing. This is the - // exact root shape #720 exists to recover. +func TestCmdWorkflowDeleteSourceFollowsRigLaunchSourceChain(t *testing.T) { cityDir := t.TempDir() - if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + rigDir := filepath.Join(cityDir, "rigs", "alpha") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatalf("MkdirAll(rigDir): %v", err) + } + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace] +name = "test-city" + +[daemon] +formula_v2 = true + +[[rigs]] +name = "alpha" +path = "rigs/alpha" +prefix = "BL" +`), 0o644); err != nil { t.Fatalf("write city.toml: %v", err) } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) - store, err := openStoreAtForCity(cityDir, cityDir) + if err := ensureScopedFileStoreLayout(cityDir); err != nil { + t.Fatalf("ensureScopedFileStoreLayout: %v", err) + } + if err := ensurePersistedScopeLocalFileStore(cityDir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(city): %v", err) + } + if err := os.MkdirAll(filepath.Join(rigDir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(rig .gc): %v", err) + } + if err := ensurePersistedScopeLocalFileStore(rigDir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(rig): %v", err) + } + + cityStore, err := openStoreAtForCity(cityDir, cityDir) if err != nil { - t.Fatalf("openStoreAtForCity: %v", err) + t.Fatalf("openStoreAtForCity(city): %v", err) } - source, err := store.Create(beads.Bead{Title: "Source", Type: "task", Status: "in_progress"}) + rigStore, err := openStoreAtForCity(rigDir, cityDir) if err != nil { - t.Fatalf("Create(source): %v", err) + t.Fatalf("openStoreAtForCity(rig): %v", err) } - // graph.v2-only root: no gc.kind=workflow label. - root, err := store.Create(beads.Bead{ - Title: "Graph workflow", + citySource, err := cityStore.Create(beads.Bead{Title: "City source", Type: "task", Status: "open"}) + if err != nil { + t.Fatalf("Create(city source): %v", err) + } + if err := cityStore.SetMetadata(citySource.ID, "workflow_id", "wf-stale"); err != nil { + t.Fatalf("SetMetadata(city workflow_id): %v", err) + } + rigLaunch, err := rigStore.Create(beads.Bead{ + Title: "Rig launch", + Type: "task", + Status: "closed", + Metadata: map[string]string{ + "gc.source_bead_id": citySource.ID, + sourceworkflow.SourceStoreRefMetadataKey: "city:test-city", + }, + }) + if err != nil { + t.Fatalf("Create(rig launch): %v", err) + } + root, err := rigStore.Create(beads.Bead{ + Title: "Workflow", Type: "task", Status: "in_progress", Metadata: map[string]string{ - "gc.formula_contract": "graph.v2", - "gc.source_bead_id": source.ID, + "gc.formula_contract": "graph.v2", + "gc.source_bead_id": rigLaunch.ID, + sourceworkflow.SourceStoreRefMetadataKey: "rig:alpha", }, }) if err != nil { t.Fatalf("Create(root): %v", err) } - child, err := store.Create(beads.Bead{ + child, err := rigStore.Create(beads.Bead{ Title: "Child", Type: "task", Status: "open", @@ -444,60 +762,155 @@ func TestCmdWorkflowDeleteSourceClosesGraphV2OnlyRoot(t *testing.T) { if err != nil { t.Fatalf("Create(child): %v", err) } - if err := store.SetMetadata(source.ID, "workflow_id", root.ID); err != nil { - t.Fatalf("SetMetadata(workflow_id): %v", err) - } var stdout, stderr bytes.Buffer - if code := cmdWorkflowDeleteSource(source.ID, sourceWorkflowStoreSelector{}, true, false, &stdout, &stderr); code != 0 { - t.Fatalf("cmdWorkflowDeleteSource = %d; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + selector := sourceWorkflowStoreSelector{storeRef: "city:test-city"} + if code := cmdWorkflowDeleteSource(citySource.ID, selector, true, false, &stdout, &stderr); code != 0 { + t.Fatalf("cmdWorkflowDeleteSource returned %d; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "result=cleaned") { t.Fatalf("stdout = %q, want cleaned result", stdout.String()) } - - reloaded, err := openStoreAtForCity(cityDir, cityDir) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + reloadedRig, err := openStoreAtForCity(rigDir, cityDir) if err != nil { - t.Fatalf("openStoreAtForCity(reload): %v", err) + t.Fatalf("openStoreAtForCity(rig reload): %v", err) } - updatedRoot, err := reloaded.Get(root.ID) + updatedRoot, err := reloadedRig.Get(root.ID) if err != nil { t.Fatalf("Get(root): %v", err) } if updatedRoot.Status != "closed" { - t.Fatalf("root status = %q, want closed (graph.v2-only root must be collected by findWorkflowBeads)", updatedRoot.Status) + t.Fatalf("root status = %q, want closed", updatedRoot.Status) } - updatedChild, err := reloaded.Get(child.ID) + updatedChild, err := reloadedRig.Get(child.ID) if err != nil { t.Fatalf("Get(child): %v", err) } if updatedChild.Status != "closed" { t.Fatalf("child status = %q, want closed", updatedChild.Status) } - updatedSource, err := reloaded.Get(source.ID) + reloadedCity, err := openStoreAtForCity(cityDir, cityDir) if err != nil { - t.Fatalf("Get(source): %v", err) + t.Fatalf("openStoreAtForCity(city reload): %v", err) } - if got := strings.TrimSpace(updatedSource.Metadata["workflow_id"]); got != "" { - t.Fatalf("source workflow_id = %q, want cleared", got) + updatedCitySource, err := reloadedCity.Get(citySource.ID) + if err != nil { + t.Fatalf("Get(city source): %v", err) + } + if got := strings.TrimSpace(updatedCitySource.Metadata["workflow_id"]); got != "" { + t.Fatalf("city source workflow_id = %q, want empty", got) } } -func TestCmdWorkflowReopenSourceClearsRoutedToForResling(t *testing.T) { - // Regression: reopen-source is the documented recovery path for a - // closed/assigned source bead whose workflow died. It cleared - // workflow_id + status + assignee but left gc.routed_to populated. - // sling.CheckBeadState treats a bead with gc.routed_to == target as - // already-routed and short-circuits on idempotency — so a re-sling - // of the recovered bead to the same target appeared to succeed while - // producing no live workflow. Operators following the cleanup hint - // ended up silently stuck. - cityDir := t.TempDir() - if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { - t.Fatalf("write city.toml: %v", err) +func TestCmdWorkflowDeleteSourceClosesGraphV2OnlyRoot(t *testing.T) { + // Regression: after the ListLiveRoots contract fix, the singleton + // scanner surfaces graph.v2-only roots (marked with + // gc.formula_contract=graph.v2 and no gc.kind=workflow). But + // findWorkflowBeads — the cleanup collector called from + // collectSourceWorkflowMatches — still required gc.kind=workflow, so + // delete-source would list the root and close nothing. This is the + // exact root shape #720 exists to recover. + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + prevCityFlag := cityFlag + cityFlag = "" + t.Cleanup(func() { cityFlag = prevCityFlag }) + + store, err := openStoreAtForCity(cityDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity: %v", err) + } + source, err := store.Create(beads.Bead{Title: "Source", Type: "task", Status: "in_progress"}) + if err != nil { + t.Fatalf("Create(source): %v", err) + } + // graph.v2-only root: no gc.kind=workflow label. + root, err := store.Create(beads.Bead{ + Title: "Graph workflow", + Type: "task", + Status: "in_progress", + Metadata: map[string]string{ + "gc.formula_contract": "graph.v2", + "gc.source_bead_id": source.ID, + }, + }) + if err != nil { + t.Fatalf("Create(root): %v", err) + } + child, err := store.Create(beads.Bead{ + Title: "Child", + Type: "task", + Status: "open", + Metadata: map[string]string{ + "gc.root_bead_id": root.ID, + }, + }) + if err != nil { + t.Fatalf("Create(child): %v", err) + } + if err := store.SetMetadata(source.ID, "workflow_id", root.ID); err != nil { + t.Fatalf("SetMetadata(workflow_id): %v", err) + } + + var stdout, stderr bytes.Buffer + if code := cmdWorkflowDeleteSource(source.ID, sourceWorkflowStoreSelector{}, true, false, &stdout, &stderr); code != 0 { + t.Fatalf("cmdWorkflowDeleteSource = %d; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "result=cleaned") { + t.Fatalf("stdout = %q, want cleaned result", stdout.String()) + } + + reloaded, err := openStoreAtForCity(cityDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(reload): %v", err) + } + updatedRoot, err := reloaded.Get(root.ID) + if err != nil { + t.Fatalf("Get(root): %v", err) + } + if updatedRoot.Status != "closed" { + t.Fatalf("root status = %q, want closed (graph.v2-only root must be collected by findWorkflowBeads)", updatedRoot.Status) + } + updatedChild, err := reloaded.Get(child.ID) + if err != nil { + t.Fatalf("Get(child): %v", err) + } + if updatedChild.Status != "closed" { + t.Fatalf("child status = %q, want closed", updatedChild.Status) + } + updatedSource, err := reloaded.Get(source.ID) + if err != nil { + t.Fatalf("Get(source): %v", err) + } + if got := strings.TrimSpace(updatedSource.Metadata["workflow_id"]); got != "" { + t.Fatalf("source workflow_id = %q, want cleared", got) + } +} + +func TestCmdWorkflowReopenSourceClearsRoutedToForResling(t *testing.T) { + // Regression: reopen-source is the documented recovery path for a + // closed/assigned source bead whose workflow died. It cleared + // workflow_id + status + assignee but left gc.routed_to populated. + // sling.CheckBeadState treats a bead with gc.routed_to == target as + // already-routed and short-circuits on idempotency — so a re-sling + // of the recovered bead to the same target appeared to succeed while + // producing no live workflow. Operators following the cleanup hint + // ended up silently stuck. + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -556,6 +969,7 @@ func TestCmdWorkflowReopenSourceConflictsWhenLiveRootExists(t *testing.T) { } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -597,6 +1011,7 @@ func TestCmdWorkflowDeleteSourcePreviewDoesNotClearStaleMetadata(t *testing.T) { } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -693,6 +1108,7 @@ func TestRunWorkflowReopenSourceConflictPropagatesExitCode(t *testing.T) { } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -802,8 +1218,11 @@ func TestDecorateDynamicFragmentRecipePreservesPoolFallbackAndScopeMetadata(t *t if control.Metadata["gc.scope_role"] != "control" { t.Fatalf("control gc.scope_role = %q, want control", control.Metadata["gc.scope_role"]) } - if control.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("control gc.routed_to = %q, want %q", control.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if control.Assignee != config.ControlDispatcherAgentName { + t.Fatalf("control assignee = %q, want %q", control.Assignee, config.ControlDispatcherAgentName) + } + if got := control.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("control gc.routed_to = %q, want empty direct dispatcher assignee", got) } if control.Metadata[graphExecutionRouteMetaKey] != "frontend/reviewer" { t.Fatalf("control execution route = %q, want frontend/reviewer", control.Metadata[graphExecutionRouteMetaKey]) @@ -916,12 +1335,1188 @@ func TestDecorateDynamicFragmentRecipeMarksRetryEvalAsScopedControl(t *testing.T if eval.Metadata["gc.scope_role"] != "control" { t.Fatalf("retry-eval gc.scope_role = %q, want control", eval.Metadata["gc.scope_role"]) } -} +} + +func TestRunWorkflowServeProcessesReadyControlBeadsThenExits(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + cdAgent := config.Agent{Name: config.ControlDispatcherAgentName} + wantQuery := workflowServeControlReadyQuery(cdAgent, "control-dispatcher") + var gotQueries []string + var gotDirs []string + var gotEnv []map[string]string + var controlled []string + sequence := [][]hookBead{ + {{ID: "gc-ctrl-1", Metadata: map[string]string{"gc.kind": "scope-check"}}}, + {{ID: "gc-ctrl-2", Metadata: map[string]string{"gc.kind": "workflow-finalize"}}}, + } + + workflowServeList = func(workQuery, dir string, env map[string]string) ([]hookBead, error) { + gotQueries = append(gotQueries, workQuery) + gotDirs = append(gotDirs, dir) + gotEnv = append(gotEnv, maps.Clone(env)) + if len(sequence) == 0 { + return nil, nil + } + next := sequence[0] + sequence = sequence[1:] + return next, nil + } + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { + controlled = append(controlled, beadID) + return nil + } + + if err := runWorkflowServe("", false, io.Discard, io.Discard); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + if !slices.Equal(controlled, []string{"gc-ctrl-1", "gc-ctrl-2"}) { + t.Fatalf("controlled beads = %#v, want two ready control beads in order", controlled) + } + if len(gotQueries) != 3 { + t.Fatalf("workflowServeList calls = %d, want 3", len(gotQueries)) + } + for i, got := range gotQueries { + if got != wantQuery { + t.Fatalf("workflowServeList query[%d] = %q, want %q", i, got, wantQuery) + } + } + for i, got := range gotDirs { + if canonicalTestPath(got) != canonicalTestPath(cityDir) { + t.Fatalf("workflowServeList dir[%d] = %q, want %q", i, got, cityDir) + } + } + for i, env := range gotEnv { + if env["GC_STORE_ROOT"] != cityDir { + t.Fatalf("workflowServeList env[%d] GC_STORE_ROOT = %q, want %q", i, env["GC_STORE_ROOT"], cityDir) + } + if env["GC_STORE_SCOPE"] != "city" { + t.Fatalf("workflowServeList env[%d] GC_STORE_SCOPE = %q, want city", i, env["GC_STORE_SCOPE"]) + } + } +} + +func TestRunWorkflowServeDrainsReadyBatchBeforeRequery(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + var controlled []string + calls := 0 + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + calls++ + switch calls { + case 1: + return []hookBead{ + {ID: "gc-ctrl-1", Metadata: map[string]string{"gc.kind": "scope-check"}}, + {ID: "gc-ctrl-2", Metadata: map[string]string{"gc.kind": "workflow-finalize"}}, + }, nil + default: + return nil, nil + } + } + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { + controlled = append(controlled, beadID) + return nil + } + + if err := runWorkflowServe("", false, io.Discard, io.Discard); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + if !slices.Equal(controlled, []string{"gc-ctrl-1", "gc-ctrl-2"}) { + t.Fatalf("controlled beads = %#v, want ready batch drained in order", controlled) + } + if calls != 2 { + t.Fatalf("workflowServeList calls = %d, want first ready batch plus idle check", calls) + } +} + +func TestRunWorkflowServeRoutesTraceOpenWarningsToCommandStderr(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + tracePath := filepath.Join(t.TempDir(), "missing", "workflow-trace.log") + t.Setenv("GC_WORKFLOW_TRACE", tracePath) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + return nil, nil + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if count := strings.Count(got, "opening workflow trace"); count != 1 { + t.Fatalf("warning count = %d, want 1; stderr=%q", count, got) + } + wantPrefix := fmt.Sprintf("gc convoy control --serve: warning: opening workflow trace %q:", tracePath) + if !strings.Contains(got, wantPrefix) { + t.Fatalf("stderr = %q, want warning prefix %q", got, wantPrefix) + } +} + +func TestRunWorkflowServeWarnsOnLegacyTracePath(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_WORKFLOW_TRACE", filepath.Join(cityDir, "control-dispatcher-trace.log")) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + return nil, nil + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if !strings.Contains(got, "legacy control-dispatcher trace path") { + t.Fatalf("stderr = %q, want legacy-trace warning", got) + } + if !strings.Contains(got, "change or unset GC_WORKFLOW_TRACE") { + t.Fatalf("stderr = %q, want explicit override guidance", got) + } + if !strings.Contains(got, filepath.Join(cityDir, ".gc", "runtime", "control-dispatcher-trace.log")) { + t.Fatalf("stderr = %q, want canonical runtime trace path guidance", got) + } +} + +func TestRunWorkflowServeWarnsWhenLegacyTraceFileStillExists(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + legacyTracePath := filepath.Join(cityDir, "control-dispatcher-trace.log") + if err := os.WriteFile(legacyTracePath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("write legacy trace: %v", err) + } + t.Setenv("GC_CITY", cityDir) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + return nil, nil + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if !strings.Contains(got, "legacy control-dispatcher trace file") { + t.Fatalf("stderr = %q, want legacy-trace artifact warning", got) + } + if !strings.Contains(got, legacyTracePath) { + t.Fatalf("stderr = %q, want legacy trace path %q", got, legacyTracePath) + } + if !strings.Contains(got, filepath.Join(cityDir, ".gc", "runtime", "control-dispatcher-trace.log")) { + t.Fatalf("stderr = %q, want canonical runtime trace path guidance", got) + } + if !strings.Contains(got, "restart or recycle the control-dispatcher session") { + t.Fatalf("stderr = %q, want restart guidance for still-growing legacy trace", got) + } +} + +func TestRunWorkflowServeWarnsWhenLegacyRigTraceFileStillExists(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n\n[[rigs]]\nname = \"alpha\"\npath = \"rigs/alpha\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + rigRoot := filepath.Join(cityDir, "rigs", "alpha") + if err := os.MkdirAll(rigRoot, 0o755); err != nil { + t.Fatalf("mkdir rig root: %v", err) + } + legacyTracePath := filepath.Join(rigRoot, "control-dispatcher-trace.log") + if err := os.WriteFile(legacyTracePath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("write legacy rig trace: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_RIG_ROOT", "") + + prevCityFlag := cityFlag + prevList := workflowServeList + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + return nil, nil + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if !strings.Contains(got, legacyTracePath) { + t.Fatalf("stderr = %q, want legacy rig trace path %q", got, legacyTracePath) + } + if !strings.Contains(got, "legacy control-dispatcher trace file") { + t.Fatalf("stderr = %q, want legacy rig trace warning", got) + } +} + +func TestRunWorkflowServeWarnsWhenLegacyEnvRigTraceFileStillExistsOutsideConfiguredRigs(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n\n[[rigs]]\nname = \"alpha\"\npath = \"rigs/alpha\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + rigRoot := filepath.Join(cityDir, "rigs", "beta") + if err := os.MkdirAll(rigRoot, 0o755); err != nil { + t.Fatalf("mkdir rig root: %v", err) + } + legacyTracePath := filepath.Join(rigRoot, "control-dispatcher-trace.log") + if err := os.WriteFile(legacyTracePath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("write legacy env rig trace: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_RIG_ROOT", rigRoot) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + return nil, nil + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if !strings.Contains(got, legacyTracePath) { + t.Fatalf("stderr = %q, want undeclared rig trace path %q", got, legacyTracePath) + } + if !strings.Contains(got, "legacy control-dispatcher trace file") { + t.Fatalf("stderr = %q, want undeclared rig trace warning", got) + } +} + +func TestRunControlDispatcherWithStoreRoutesRalphTraceWarningToStderr(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + checkPath := filepath.Join(cityDir, "pass-check.sh") + if err := os.WriteFile(checkPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pass-check.sh: %v", err) + } + t.Setenv("GC_WORKFLOW_TRACE", filepath.Join(t.TempDir(), "missing", "workflow-trace.log")) + + store := beads.NewMemStore() + workflow, err := store.Create(beads.Bead{ + Title: "workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + }, + }) + if err != nil { + t.Fatalf("create workflow bead: %v", err) + } + logical, err := store.Create(beads.Bead{ + Title: "logical", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.step_id": "implement", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + }, + }) + if err != nil { + t.Fatalf("create logical bead: %v", err) + } + run1, err := store.Create(beads.Bead{ + Title: "run 1", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "run", + "gc.step_id": "implement", + "gc.ralph_step_id": "implement", + "gc.attempt": "1", + "gc.step_ref": "implement.run.1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create run bead: %v", err) + } + check1, err := store.Create(beads.Bead{ + Title: "check 1", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "check", + "gc.step_id": "implement", + "gc.ralph_step_id": "implement", + "gc.attempt": "1", + "gc.step_ref": "implement.check.1", + "gc.check_mode": "exec", + "gc.check_path": checkPath, + "gc.check_timeout": "30s", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create check bead: %v", err) + } + if err := store.DepAdd(check1.ID, run1.ID, "blocks"); err != nil { + t.Fatalf("add check->run dep: %v", err) + } + if err := store.DepAdd(logical.ID, check1.ID, "blocks"); err != nil { + t.Fatalf("add logical->check dep: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := runControlDispatcherWithStore(cityDir, cityDir, store, check1, check1.ID, &stdout, &stderr); err != nil { + t.Fatalf("runControlDispatcherWithStore: %v", err) + } + + gotStderr := stderr.String() + if count := strings.Count(gotStderr, "opening workflow trace"); count != 1 { + t.Fatalf("warning count = %d, want 1; stderr=%q", count, gotStderr) + } + if !strings.Contains(gotStderr, "gc convoy control --serve: warning: opening workflow trace") { + t.Fatalf("stderr = %q, want workflow trace warning prefix", gotStderr) + } + if gotStdout := stdout.String(); !strings.Contains(gotStdout, "action=pass") { + t.Fatalf("stdout = %q, want processed pass action", gotStdout) + } + checkAfter, err := store.Get(check1.ID) + if err != nil { + t.Fatalf("reload check bead: %v", err) + } + if checkAfter.Status != "closed" || checkAfter.Metadata["gc.outcome"] != "pass" { + t.Fatalf("check bead = status %q outcome %q, want closed/pass", checkAfter.Status, checkAfter.Metadata["gc.outcome"]) + } +} + +func TestRunControlDispatcherWithStoreWarnsOnLegacyTracePath(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + checkPath := filepath.Join(cityDir, "pass-check.sh") + if err := os.WriteFile(checkPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pass-check.sh: %v", err) + } + legacyTracePath := filepath.Join(cityDir, "control-dispatcher-trace.log") + t.Setenv("GC_WORKFLOW_TRACE", legacyTracePath) + + store := beads.NewMemStore() + workflow, err := store.Create(beads.Bead{ + Title: "workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + }, + }) + if err != nil { + t.Fatalf("create workflow bead: %v", err) + } + logical, err := store.Create(beads.Bead{ + Title: "logical", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.step_id": "implement", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + }, + }) + if err != nil { + t.Fatalf("create logical bead: %v", err) + } + run1, err := store.Create(beads.Bead{ + Title: "run 1", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "run", + "gc.step_id": "implement", + "gc.ralph_step_id": "implement", + "gc.attempt": "1", + "gc.step_ref": "implement.run.1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create run bead: %v", err) + } + check1, err := store.Create(beads.Bead{ + Title: "check 1", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "check", + "gc.step_id": "implement", + "gc.ralph_step_id": "implement", + "gc.attempt": "1", + "gc.step_ref": "implement.check.1", + "gc.check_mode": "exec", + "gc.check_path": checkPath, + "gc.check_timeout": "30s", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create check bead: %v", err) + } + if err := store.DepAdd(check1.ID, run1.ID, "blocks"); err != nil { + t.Fatalf("add check->run dep: %v", err) + } + if err := store.DepAdd(logical.ID, check1.ID, "blocks"); err != nil { + t.Fatalf("add logical->check dep: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := runControlDispatcherWithStore(cityDir, cityDir, store, check1, check1.ID, &stdout, &stderr); err != nil { + t.Fatalf("runControlDispatcherWithStore: %v", err) + } + + got := stderr.String() + if !strings.Contains(got, legacyTracePath) { + t.Fatalf("stderr = %q, want legacy trace path %q", got, legacyTracePath) + } + if !strings.Contains(got, "change or unset GC_WORKFLOW_TRACE") { + t.Fatalf("stderr = %q, want explicit override guidance", got) + } +} + +func TestRunWorkflowServeDedupsTraceWarningsAcrossNestedControlDispatch(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + checkPath := filepath.Join(cityDir, "pass-check.sh") + if err := os.WriteFile(checkPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pass-check.sh: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_WORKFLOW_TRACE", filepath.Join(t.TempDir(), "missing", "workflow-trace.log")) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + store := beads.NewMemStore() + newCheckBead := func(stepID string) string { + t.Helper() + workflow, err := store.Create(beads.Bead{ + Title: "workflow " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + }, + }) + if err != nil { + t.Fatalf("create workflow bead for %s: %v", stepID, err) + } + logical, err := store.Create(beads.Bead{ + Title: "logical " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.step_id": stepID, + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + }, + }) + if err != nil { + t.Fatalf("create logical bead for %s: %v", stepID, err) + } + run, err := store.Create(beads.Bead{ + Title: "run " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "run", + "gc.step_id": stepID, + "gc.ralph_step_id": stepID, + "gc.attempt": "1", + "gc.step_ref": stepID + ".run.1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create run bead for %s: %v", stepID, err) + } + check, err := store.Create(beads.Bead{ + Title: "check " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "check", + "gc.step_id": stepID, + "gc.ralph_step_id": stepID, + "gc.attempt": "1", + "gc.step_ref": stepID + ".check.1", + "gc.check_mode": "exec", + "gc.check_path": checkPath, + "gc.check_timeout": "30s", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create check bead for %s: %v", stepID, err) + } + if err := store.DepAdd(check.ID, run.ID, "blocks"); err != nil { + t.Fatalf("add check->run dep for %s: %v", stepID, err) + } + if err := store.DepAdd(logical.ID, check.ID, "blocks"); err != nil { + t.Fatalf("add logical->check dep for %s: %v", stepID, err) + } + return check.ID + } + + checkOneID := newCheckBead("implement-a") + checkTwoID := newCheckBead("implement-b") + sequence := [][]hookBead{ + {{ID: checkOneID, Metadata: map[string]string{"gc.kind": "check"}}}, + {{ID: checkTwoID, Metadata: map[string]string{"gc.kind": "check"}}}, + } + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + if len(sequence) == 0 { + return nil, nil + } + next := sequence[0] + sequence = sequence[1:] + return next, nil + } + controlDispatcherServe = func(cityPath, storePath, beadID string, stdout, stderr io.Writer) error { + bead, err := store.Get(beadID) + if err != nil { + return err + } + return runControlDispatcherWithStore(cityPath, storePath, store, bead, beadID, stdout, stderr) + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if count := strings.Count(got, "opening workflow trace"); count != 1 { + t.Fatalf("warning count = %d, want 1 across nested control dispatch; stderr=%q", count, got) + } +} + +func TestRunWorkflowServeDedupsLegacyTraceWarningsAcrossNestedControlDispatch(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + checkPath := filepath.Join(cityDir, "pass-check.sh") + if err := os.WriteFile(checkPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pass-check.sh: %v", err) + } + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_WORKFLOW_TRACE", filepath.Join(cityDir, "control-dispatcher-trace.log")) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + store := beads.NewMemStore() + newCheckBead := func(stepID string) string { + t.Helper() + workflow, err := store.Create(beads.Bead{ + Title: "workflow " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + }, + }) + if err != nil { + t.Fatalf("create workflow bead for %s: %v", stepID, err) + } + logical, err := store.Create(beads.Bead{ + Title: "logical " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.step_id": stepID, + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + }, + }) + if err != nil { + t.Fatalf("create logical bead for %s: %v", stepID, err) + } + run, err := store.Create(beads.Bead{ + Title: "run " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "run", + "gc.step_id": stepID, + "gc.ralph_step_id": stepID, + "gc.attempt": "1", + "gc.step_ref": stepID + ".run.1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create run bead for %s: %v", stepID, err) + } + check, err := store.Create(beads.Bead{ + Title: "check " + stepID, + Type: "task", + Metadata: map[string]string{ + "gc.kind": "check", + "gc.step_id": stepID, + "gc.ralph_step_id": stepID, + "gc.attempt": "1", + "gc.step_ref": stepID + ".check.1", + "gc.check_mode": "exec", + "gc.check_path": checkPath, + "gc.check_timeout": "30s", + "gc.max_attempts": "1", + "gc.root_bead_id": workflow.ID, + "gc.logical_bead_id": logical.ID, + }, + }) + if err != nil { + t.Fatalf("create check bead for %s: %v", stepID, err) + } + if err := store.DepAdd(check.ID, run.ID, "blocks"); err != nil { + t.Fatalf("add check->run dep for %s: %v", stepID, err) + } + if err := store.DepAdd(logical.ID, check.ID, "blocks"); err != nil { + t.Fatalf("add logical->check dep for %s: %v", stepID, err) + } + return check.ID + } + + checkOneID := newCheckBead("implement-a") + checkTwoID := newCheckBead("implement-b") + sequence := [][]hookBead{ + {{ID: checkOneID, Metadata: map[string]string{"gc.kind": "check"}}}, + {{ID: checkTwoID, Metadata: map[string]string{"gc.kind": "check"}}}, + } + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + if len(sequence) == 0 { + return nil, nil + } + next := sequence[0] + sequence = sequence[1:] + return next, nil + } + controlDispatcherServe = func(cityPath, storePath, beadID string, stdout, stderr io.Writer) error { + bead, err := store.Get(beadID) + if err != nil { + return err + } + return runControlDispatcherWithStore(cityPath, storePath, store, bead, beadID, stdout, stderr) + } + + var stderr bytes.Buffer + if err := runWorkflowServe("", false, io.Discard, &stderr); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + got := stderr.String() + if count := strings.Count(got, "legacy control-dispatcher trace path"); count != 1 { + t.Fatalf("warning count = %d, want 1 across nested control dispatch; stderr=%q", count, got) + } +} + +func TestWorkflowServeControlReadyQueryUsesControlTiers(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName}) + if strings.Contains(query, "GC_SESSION_ORIGIN") { + t.Fatalf("workflowServeControlReadyQuery should not gate legacy routes on session origin: %q", query) + } + if strings.Contains(query, "bd list --status in_progress") { + t.Fatalf("workflowServeControlReadyQuery should not return in-progress control beads: %q", query) + } + if !strings.Contains(query, "BD_EXPORT_AUTO=false") { + t.Fatalf("workflowServeControlReadyQuery should disable bd auto-export: %q", query) + } + for _, want := range []string{ + `bd --readonly --sandbox ready --assignee="$cand"`, + `bd --readonly --sandbox ready --metadata-field "gc.routed_to=$GC_CONTROL_TARGET" --unassigned`, + `bd --readonly --sandbox ready --metadata-field "gc.routed_to=$GC_CONTROL_LEGACY_TARGET" --unassigned`, + } { + if !strings.Contains(query, want) { + t.Fatalf("workflowServeControlReadyQuery missing %q in %q", want, query) + } + } + if !strings.Contains(query, `--limit=20`) { + t.Fatalf("workflowServeControlReadyQuery missing scan limit: %q", query) + } +} + +func TestWorkflowServeControlReadyQueryIgnoresInProgressAssigned(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "GC_SESSION_NAME": "gascity--control-dispatcher", + "GC_ALIAS": "gascity/control-dispatcher", + "GC_SESSION_ORIGIN": "named", + }, `#!/bin/sh +set -eu +case "$*" in + "list --status in_progress --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-in-progress"}]' + ;; + "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-ready"}]' + ;; + "--readonly --sandbox ready --metadata-field gc.routed_to=gascity/control-dispatcher --unassigned --json --limit=20") + printf '[{"id":"ga-routed"}]' + ;; + *) + printf '[]' + ;; +esac +`) + assertJSONEqual(t, out, `[{"id":"ga-ready"},{"id":"ga-routed"}]`) +} + +func TestWorkflowServeControlReadyQueryIncludesMetadataRoutedWorkAfterAssignedPending(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "GC_SESSION_NAME": "gascity--control-dispatcher", + "GC_ALIAS": "gascity/control-dispatcher", + }, `#!/bin/sh +set -eu +case "$*" in + "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-pending","metadata":{"gc.kind":"retry"}}]' + ;; + "--readonly --sandbox ready --metadata-field gc.routed_to=gascity/control-dispatcher --unassigned --json --limit=20") + printf '[{"id":"ga-ready","metadata":{"gc.kind":"scope-check"}}]' + ;; + *) + printf '[]' + ;; +esac +`) + assertJSONEqual(t, out, `[{"id":"ga-pending","metadata":{"gc.kind":"retry"}},{"id":"ga-ready","metadata":{"gc.kind":"scope-check"}}]`) +} + +func TestWorkflowServeControlReadyQueryPreservesQueryPriorityWhenMerging(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "GC_SESSION_NAME": "gascity--control-dispatcher", + "GC_ALIAS": "gascity/control-dispatcher", + }, `#!/bin/sh +set -eu +case "$*" in + "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-z-assigned"},{"id":"ga-dup","source":"assigned"}]' + ;; + "--readonly --sandbox ready --metadata-field gc.routed_to=gascity/control-dispatcher --unassigned --json --limit=20") + printf '[{"id":"ga-a-routed"},{"id":"ga-dup","source":"routed"}]' + ;; + *) + printf '[]' + ;; +esac +`) + assertJSONEqual(t, out, `[{"id":"ga-z-assigned"},{"id":"ga-dup","source":"assigned"},{"id":"ga-a-routed"}]`) +} + +func TestWorkflowServeControlReadyQueryUsesConfiguredRuntimeNameWhenEnvIsManualSession(t *testing.T) { + query := workflowServeControlReadyQuery( + config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}, + "gascity--control-dispatcher", + ) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "GC_SESSION_ID": "mc-manual", + "GC_SESSION_NAME": "s-mc-manual", + "GC_AGENT": "s-mc-manual", + "GC_SESSION_ORIGIN": "manual", + }, `#!/bin/sh +set -eu +case "$*" in + "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-control-ready"}]' + ;; + *) + echo "unexpected first control query: $*" >&2 + exit 42 + ;; +esac +`) + assertJSONEqual(t, out, `[{"id":"ga-control-ready"}]`) +} + +func TestWorkflowServeControlReadyQueryPrioritizesConfiguredRuntimeName(t *testing.T) { + query := workflowServeControlReadyQuery( + config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}, + "gascity--control-dispatcher", + ) + tmp := t.TempDir() + logPath := filepath.Join(tmp, "bd.log") + bdPath := filepath.Join(tmp, "bd") + if err := os.WriteFile(bdPath, []byte(`#!/bin/sh +set -eu +[ "${BD_EXPORT_AUTO:-}" = "false" ] || { + echo "BD_EXPORT_AUTO=${BD_EXPORT_AUTO:-}" >&2 + exit 43 +} +printf '%s\n' "$*" >> "$BD_LOG" +case "$*" in + "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-control-ready"}]' + ;; + *) + printf '[]' + ;; +esac +`), 0o755); err != nil { + t.Fatalf("write fake bd: %v", err) + } + out, err := shellWorkQueryWithEnv(query, t.TempDir(), []string{ + "PATH=" + tmp + string(os.PathListSeparator) + os.Getenv("PATH"), + "BD_LOG=" + logPath, + "GC_SESSION_ID=mc-manual", + "GC_SESSION_NAME=s-mc-manual", + "GC_AGENT=s-mc-manual", + "GC_SESSION_ORIGIN=manual", + }) + if err != nil { + t.Fatalf("run workflow serve query: %v", err) + } + assertJSONEqual(t, out, `[{"id":"ga-control-ready"}]`) + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read bd log: %v", err) + } + firstCall, _, _ := strings.Cut(strings.TrimSpace(string(logData)), "\n") + if want := "--readonly --sandbox ready --assignee=gascity--control-dispatcher --json --limit=20"; firstCall != want { + t.Fatalf("first bd call = %q, want %q; all calls:\n%s", firstCall, want, string(logData)) + } +} + +func TestWorkflowServeControlReadyQueryQuotesMetadataFallbackTarget(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "my rig"}) + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "matched.args") + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "BD_MATCHED_ARGS": argsPath, + }, `#!/bin/sh +set -eu +if [ "$#" -eq 8 ] && + [ "$1" = "--readonly" ] && + [ "$2" = "--sandbox" ] && + [ "$3" = "ready" ] && + [ "$4" = "--metadata-field" ] && + [ "$5" = "gc.routed_to=my rig/control-dispatcher" ] && + [ "$6" = "--unassigned" ] && + [ "$7" = "--json" ] && + [ "$8" = "--limit=20" ]; then + printf '%s\n' "$@" > "$BD_MATCHED_ARGS" + printf '[{"id":"ga-routed"}]' + exit 0 +fi +printf '[]' +`) + assertJSONEqual(t, out, `[{"id":"ga-routed"}]`) + argsData, err := os.ReadFile(argsPath) + if err != nil { + t.Fatalf("read matched args: %v", err) + } + gotArgs := strings.Split(strings.TrimSpace(string(argsData)), "\n") + wantArgs := []string{"--readonly", "--sandbox", "ready", "--metadata-field", "gc.routed_to=my rig/control-dispatcher", "--unassigned", "--json", "--limit=20"} + if !slices.Equal(gotArgs, wantArgs) { + t.Fatalf("matched bd args = %#v, want %#v", gotArgs, wantArgs) + } +} + +func TestWorkflowServeControlReadyQueryUsesLegacyRouteForNamedSessions(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{ + "GC_SESSION_NAME": "gascity--control-dispatcher", + "GC_ALIAS": "gascity/control-dispatcher", + "GC_SESSION_ORIGIN": "named", + }, `#!/bin/sh +set -eu +case "$*" in + "--readonly --sandbox ready --metadata-field gc.routed_to=gascity/workflow-control --unassigned --json --limit=20") + printf '[{"id":"ga-legacy-route"}]' + ;; + *) + printf '[]' + ;; +esac +`) + assertJSONEqual(t, out, `[{"id":"ga-legacy-route"}]`) +} + +func runWorkflowServeShellQueryForTest(t *testing.T, query string, env map[string]string, bdScript string) string { + t.Helper() + + tmp := t.TempDir() + bdPath := filepath.Join(tmp, "bd") + if err := os.WriteFile(bdPath, []byte(bdScript), 0o755); err != nil { + t.Fatalf("write fake bd: %v", err) + } + + queryEnv := []string{"PATH=" + tmp + string(os.PathListSeparator) + os.Getenv("PATH")} + for key, value := range env { + queryEnv = append(queryEnv, key+"="+value) + } + out, err := shellWorkQueryWithEnv(query, t.TempDir(), queryEnv) + if err != nil { + t.Fatalf("run workflow serve query: %v", err) + } + return out +} + +func assertJSONEqual(t *testing.T, got, want string) { + t.Helper() + var gotValue any + if err := json.Unmarshal([]byte(got), &gotValue); err != nil { + t.Fatalf("unmarshal got JSON %q: %v", got, err) + } + var wantValue any + if err := json.Unmarshal([]byte(want), &wantValue); err != nil { + t.Fatalf("unmarshal want JSON %q: %v", want, err) + } + if !reflect.DeepEqual(gotValue, wantValue) { + t.Fatalf("JSON output = %s, want %s", got, want) + } +} + +// TestRunWorkflowServeOverridesInheritedCityBeadsDir is a regression test for +// #514: the serve path must pass rig-scoped env to work query subprocesses, +// not inherit a city-scoped BEADS_DIR from the parent. +func TestRunWorkflowServeOverridesInheritedCityBeadsDir(t *testing.T) { + clearGCEnv(t) + t.Setenv("GC_TMUX_SESSION", "host-session") + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "myrig-repo") + + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + cityToml := fmt.Sprintf("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n\n[[rigs]]\nname = \"myrig\"\npath = %q\n\n[[agent]]\nname = \"worker\"\ndir = \"myrig\"\n", rigDir) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("GC_CITY", cityDir) + // Pollute parent env with a city-scoped BEADS_DIR. Without the fix, + // this value leaks into work query subprocesses. + cityBeads := filepath.Join(cityDir, ".beads") + t.Setenv("BEADS_DIR", cityBeads) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + var capturedEnv map[string]string + workflowServeList = func(_, _ string, env map[string]string) ([]hookBead, error) { + capturedEnv = maps.Clone(env) + return nil, nil // no work: exits immediately + } + controlDispatcherServe = func(_, _, _ string, _ io.Writer, _ io.Writer) error { + return nil + } + + if err := runWorkflowServe("worker", false, io.Discard, io.Discard); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + if capturedEnv == nil { + t.Fatal("workflowServeList received nil env, want rig-scoped env") + } + wantBeads := filepath.Join(rigDir, ".beads") + if got := capturedEnv["BEADS_DIR"]; got != wantBeads { + t.Fatalf("BEADS_DIR = %q, want rig store %q", got, wantBeads) + } + if capturedEnv["BEADS_DIR"] == cityBeads { + t.Fatalf("BEADS_DIR inherited city store %q", cityBeads) + } + if got := capturedEnv["GC_STORE_ROOT"]; got != rigDir { + t.Fatalf("GC_STORE_ROOT = %q, want rig root %q", got, rigDir) + } + if got := capturedEnv["GC_STORE_SCOPE"]; got != "rig" { + t.Fatalf("GC_STORE_SCOPE = %q, want rig", got) + } +} + +func TestRunWorkflowServeProcessesControlBeadsInAgentStoreScope(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "myrig-repo") + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + cityToml := fmt.Sprintf(`[workspace] +name = "test-city" -func TestRunWorkflowServeProcessesReadyControlBeadsThenExits(t *testing.T) { - cityDir := t.TempDir() - if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { - t.Fatalf("write city.toml: %v", err) +[daemon] +formula_v2 = true + +[[rigs]] +name = "myrig" +path = %q +`, rigDir) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) } t.Setenv("GC_CITY", cityDir) @@ -941,63 +2536,226 @@ func TestRunWorkflowServeProcessesReadyControlBeadsThenExits(t *testing.T) { workflowServeIdlePollAttempts = prevAttempts }) - // The tiered query has sh -c wrapper; workflowServeQuery replaces the - // first --limit=1 with --limit=20 for scan width. - cdAgent := config.Agent{Name: config.ControlDispatcherAgentName} - wantQuery := workflowServeQuery(cdAgent.EffectiveWorkQuery()) - var gotQueries []string - var gotDirs []string - var gotEnv []map[string]string - var controlled []string - sequence := [][]hookBead{ - {{ID: "gc-ctrl-1", Metadata: map[string]string{"gc.kind": "scope-check"}}}, - {{ID: "gc-ctrl-2", Metadata: map[string]string{"gc.kind": "workflow-finalize"}}}, - } - - workflowServeList = func(workQuery, dir string, env map[string]string) ([]hookBead, error) { - gotQueries = append(gotQueries, workQuery) - gotDirs = append(gotDirs, dir) - gotEnv = append(gotEnv, maps.Clone(env)) - if len(sequence) == 0 { - return nil, nil + calls := 0 + var queryDir string + workflowServeList = func(_, dir string, _ map[string]string) ([]hookBead, error) { + calls++ + queryDir = dir + if calls == 1 { + return []hookBead{{ID: "gc-rig-control", Metadata: map[string]string{"gc.kind": "scope-check"}}}, nil } - next := sequence[0] - sequence = sequence[1:] - return next, nil + return nil, nil } - controlDispatcherServe = func(beadID string, _ io.Writer, _ io.Writer) error { - controlled = append(controlled, beadID) + + var gotCityPath, gotStorePath, gotBeadID string + controlDispatcherServe = func(cityPath, storePath, beadID string, _ io.Writer, _ io.Writer) error { + gotCityPath = cityPath + gotStorePath = storePath + gotBeadID = beadID return nil } - if err := runWorkflowServe("", false, io.Discard, io.Discard); err != nil { + if err := runWorkflowServe("myrig/control-dispatcher", false, io.Discard, io.Discard); err != nil { t.Fatalf("runWorkflowServe: %v", err) } + if canonicalTestPath(queryDir) != canonicalTestPath(rigDir) { + t.Fatalf("query dir = %q, want rig root %q", queryDir, rigDir) + } + if canonicalTestPath(gotCityPath) != canonicalTestPath(cityDir) { + t.Fatalf("control cityPath = %q, want %q", gotCityPath, cityDir) + } + if canonicalTestPath(gotStorePath) != canonicalTestPath(rigDir) { + t.Fatalf("control storePath = %q, want rig root %q", gotStorePath, rigDir) + } + if gotBeadID != "gc-rig-control" { + t.Fatalf("control beadID = %q, want gc-rig-control", gotBeadID) + } +} - if !slices.Equal(controlled, []string{"gc-ctrl-1", "gc-ctrl-2"}) { - t.Fatalf("controlled beads = %#v, want two ready control beads in order", controlled) +func TestOpenControlStoreDisablesAutoExportWithoutSandboxingWrites(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "myrig-repo") + if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o755); err != nil { + t.Fatal(err) } - if len(gotQueries) != 3 { - t.Fatalf("workflowServeList calls = %d, want 3", len(gotQueries)) + if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o755); err != nil { + t.Fatal(err) } - for i, got := range gotQueries { - if got != wantQuery { - t.Fatalf("workflowServeList query[%d] = %q, want %q", i, got, wantQuery) + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{Name: "myrig", Path: rigDir}}, + } + t.Setenv("GC_BEADS", "bd") + + var calls [][]string + var envs []map[string]string + prevRunner := beadsExecCommandRunnerWithEnv + beadsExecCommandRunnerWithEnv = func(env map[string]string) beads.CommandRunner { + envs = append(envs, maps.Clone(env)) + return func(_ string, name string, args ...string) ([]byte, error) { + if name != "bd" { + return nil, fmt.Errorf("unexpected command %q", name) + } + calls = append(calls, append([]string(nil), args...)) + return []byte(`[]`), nil } } - for i, got := range gotDirs { - if canonicalTestPath(got) != canonicalTestPath(cityDir) { - t.Fatalf("workflowServeList dir[%d] = %q, want %q", i, got, cityDir) + t.Cleanup(func() { beadsExecCommandRunnerWithEnv = prevRunner }) + + status := "closed" + cityStore, err := openControlStoreAtForCity(cityDir, cityDir, cfg) + if err != nil { + t.Fatalf("openControlStoreAtForCity(city): %v", err) + } + if err := cityStore.Update("ga-city-control", beads.UpdateOpts{Status: &status}); err != nil { + t.Fatalf("city control update: %v", err) + } + rigStore, err := openControlStoreAtForCity(rigDir, cityDir, cfg) + if err != nil { + t.Fatalf("openControlStoreAtForCity(rig): %v", err) + } + if err := rigStore.Update("ga-rig-control", beads.UpdateOpts{Status: &status}); err != nil { + t.Fatalf("rig control update: %v", err) + } + + if len(calls) != 2 { + t.Fatalf("bd calls = %#v, want two update calls", calls) + } + if len(envs) != 2 { + t.Fatalf("bd envs = %#v, want two command environments", envs) + } + for i, call := range calls { + if len(call) < 1 || call[0] != "update" { + t.Fatalf("bd call = %#v, want update ...", call) + } + if slices.Contains(call, "--sandbox") { + t.Fatalf("bd call = %#v, write-capable control stores must not use --sandbox", call) + } + if got := envs[i]["BD_EXPORT_AUTO"]; got != "false" { + t.Fatalf("bd env %d BD_EXPORT_AUTO = %q, want false", i, got) } } - for i, env := range gotEnv { - if env["GC_STORE_ROOT"] != cityDir { - t.Fatalf("workflowServeList env[%d] GC_STORE_ROOT = %q, want %q", i, env["GC_STORE_ROOT"], cityDir) +} + +func TestOpenControlStoreAtForCityPreservesFileAndExecProviderStores(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "rigs", "frontend") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + writeExecStoreCityConfig(t, cityDir, "metro-city", "ct", []config.Rig{{ + Name: "frontend", + Path: "rigs/frontend", + Prefix: "fe", + }}) + cfg := &config.City{ + Workspace: config.Workspace{Name: "metro-city", Prefix: "ct"}, + Rigs: []config.Rig{{ + Name: "frontend", + Path: "rigs/frontend", + Prefix: "fe", + }}, + } + + t.Run("file", func(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + store, err := openControlStoreAtForCity(rigDir, cityDir, cfg) + if err != nil { + t.Fatalf("openControlStoreAtForCity(file): %v", err) } - if env["GC_STORE_SCOPE"] != "city" { - t.Fatalf("workflowServeList env[%d] GC_STORE_SCOPE = %q, want city", i, env["GC_STORE_SCOPE"]) + if _, ok := store.(*beads.FileStore); !ok { + t.Fatalf("control store = %T, want *beads.FileStore for file provider", store) + } + }) + + t.Run("exec", func(t *testing.T) { + captureDir := t.TempDir() + script := writeExecCaptureScript(t, captureDir) + provider := "exec:" + script + t.Setenv("GC_BEADS", provider) + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + + store, err := openControlStoreAtForCity(rigDir, cityDir, cfg) + if err != nil { + t.Fatalf("openControlStoreAtForCity(exec): %v", err) + } + if _, err := store.Create(beads.Bead{Title: "rig"}); err != nil { + t.Fatalf("exec control Create: %v", err) + } + env := readExecCaptureEnv(t, filepath.Join(captureDir, "frontend.env")) + if got := env["GC_PROVIDER"]; got != provider { + t.Fatalf("exec GC_PROVIDER = %q, want %q", got, provider) + } + if got := env["GC_STORE_SCOPE"]; got != "rig" { + t.Fatalf("exec GC_STORE_SCOPE = %q, want rig", got) + } + }) +} + +func TestOpenControlStoreAtForCityUsesControlRunnerForStaleBdScope(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + staleRigDir := filepath.Join(cityDir, "rigs", "removed") + if err := os.MkdirAll(filepath.Join(staleRigDir, ".beads"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staleRigDir, ".beads", "metadata.json"), []byte(`{"database":"dolt","backend":"dolt","dolt_mode":"server","dolt_database":"removed"}`), 0o644); err != nil { + t.Fatalf("write stale rig metadata: %v", err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Rigs: []config.Rig{{Name: "active", Path: "rigs/active"}}, + } + t.Setenv("GC_BEADS", "bd") + + var calls [][]string + var envs []map[string]string + prevRunner := beadsExecCommandRunnerWithEnv + beadsExecCommandRunnerWithEnv = func(env map[string]string) beads.CommandRunner { + envs = append(envs, maps.Clone(env)) + return func(_ string, name string, args ...string) ([]byte, error) { + if name != "bd" { + return nil, fmt.Errorf("unexpected command %q", name) + } + calls = append(calls, append([]string(nil), args...)) + return []byte(`[]`), nil } } + t.Cleanup(func() { beadsExecCommandRunnerWithEnv = prevRunner }) + + status := "closed" + store, err := openControlStoreAtForCity(staleRigDir, cityDir, cfg) + if err != nil { + t.Fatalf("openControlStoreAtForCity(stale rig): %v", err) + } + if err := store.Update("ga-stale-control", beads.UpdateOpts{Status: &status}); err != nil { + t.Fatalf("stale rig control update: %v", err) + } + + if len(calls) != 1 { + t.Fatalf("bd calls = %#v, want one update call", calls) + } + if len(envs) != 1 { + t.Fatalf("bd envs = %#v, want one command environment", envs) + } + if call := calls[0]; len(call) < 1 || call[0] != "update" { + t.Fatalf("bd call = %#v, want update ...", calls[0]) + } + if slices.Contains(calls[0], "--sandbox") { + t.Fatalf("bd call = %#v, write-capable control stores must not use --sandbox", calls[0]) + } + if got := envs[0]["BD_EXPORT_AUTO"]; got != "false" { + t.Fatalf("BD_EXPORT_AUTO = %q, want false", got) + } + if got := envs[0]["BEADS_DIR"]; got != filepath.Join(staleRigDir, ".beads") { + t.Fatalf("BEADS_DIR = %q, want stale rig store", got) + } + if got := envs[0]["GC_RIG_ROOT"]; got != staleRigDir { + t.Fatalf("GC_RIG_ROOT = %q, want stale rig root", got) + } } func TestRunWorkflowServeUsesGCTemplateForSessionContext(t *testing.T) { @@ -1059,7 +2817,7 @@ max = 5 gotDir = dir return nil, nil } - controlDispatcherServe = func(_ string, _ io.Writer, _ io.Writer) error { + controlDispatcherServe = func(_, _, _ string, _ io.Writer, _ io.Writer) error { t.Fatal("controlDispatcherServe should not run when no control work is returned") return nil } @@ -1113,7 +2871,7 @@ func TestRunWorkflowServeRetriesBrieflyAfterProcessingBeforeIdleExit(t *testing. return nil, nil } } - controlDispatcherServe = func(beadID string, _ io.Writer, _ io.Writer) error { + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { controlled = append(controlled, beadID) return nil } @@ -1165,7 +2923,7 @@ func TestRunWorkflowServeSkipsPendingControlBeadAndProcessesLaterReady(t *testin return nil, nil } } - controlDispatcherServe = func(beadID string, _ io.Writer, _ io.Writer) error { + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { attempted = append(attempted, beadID) if beadID == "gc-pending" { return dispatch.ErrControlPending @@ -1186,6 +2944,65 @@ func TestRunWorkflowServeSkipsPendingControlBeadAndProcessesLaterReady(t *testin } } +func TestRunWorkflowServeSkipsLegacyOversizedControlAndProcessesLaterReady(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_CITY", cityDir) + + prevCityFlag := cityFlag + prevList := workflowServeList + prevControl := controlDispatcherServe + prevInterval := workflowServeIdlePollInterval + prevAttempts := workflowServeIdlePollAttempts + cityFlag = "" + workflowServeIdlePollInterval = 0 + workflowServeIdlePollAttempts = 0 + t.Cleanup(func() { + cityFlag = prevCityFlag + workflowServeList = prevList + controlDispatcherServe = prevControl + workflowServeIdlePollInterval = prevInterval + workflowServeIdlePollAttempts = prevAttempts + }) + + var attempted []string + var processed []string + calls := 0 + workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { + calls++ + switch calls { + case 1: + return []hookBead{ + {ID: "gc-legacy", Metadata: map[string]string{"gc.kind": "ralph"}}, + {ID: "gc-ready", Metadata: map[string]string{"gc.kind": "scope-check"}}, + }, nil + default: + return nil, nil + } + } + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { + attempted = append(attempted, beadID) + if beadID == "gc-legacy" { + return fmt.Errorf("gc-legacy: recording attempt log: setting metadata on %q: failed to record event: old_value is too large", beadID) + } + processed = append(processed, beadID) + return nil + } + + if err := runWorkflowServe("", false, io.Discard, io.Discard); err != nil { + t.Fatalf("runWorkflowServe: %v", err) + } + + if !slices.Equal(attempted, []string{"gc-legacy", "gc-ready"}) { + t.Fatalf("attempted beads = %#v, want legacy oversized control skipped before ready bead is processed", attempted) + } + if !slices.Equal(processed, []string{"gc-ready"}) { + t.Fatalf("processed beads = %#v, want only later ready bead to be processed", processed) + } +} + func TestRunWorkflowServeReturnsQueryError(t *testing.T) { cityDir := t.TempDir() if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n\n[daemon]\nformula_v2 = true\n"), 0o644); err != nil { @@ -1206,7 +3023,7 @@ func TestRunWorkflowServeReturnsQueryError(t *testing.T) { workflowServeList = func(_, _ string, _ map[string]string) ([]hookBead, error) { return nil, os.ErrDeadlineExceeded } - controlDispatcherServe = func(string, io.Writer, io.Writer) error { + controlDispatcherServe = func(_, _, _ string, _ io.Writer, _ io.Writer) error { t.Fatal("controlDispatcherServe should not be called on query failure") return nil } @@ -1293,7 +3110,7 @@ func TestRunWorkflowServeFollowUsesSweepFallback(t *testing.T) { return nil, nil } } - controlDispatcherServe = func(beadID string, _ io.Writer, _ io.Writer) error { + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { processed = append(processed, beadID) return os.ErrDeadlineExceeded } @@ -1301,8 +3118,9 @@ func TestRunWorkflowServeFollowUsesSweepFallback(t *testing.T) { wfcAgent := config.Agent{Name: "control-dispatcher", MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(1)} err := runWorkflowServeFollow( wfcAgent, - wfcAgent.EffectiveWorkQuery(), t.TempDir(), + t.TempDir(), + wfcAgent.EffectiveWorkQuery(), nil, io.Discard, ) @@ -1375,7 +3193,7 @@ func TestRunWorkflowServeFollowResetsBackoffForProcessedEventAndPending(t *testi return nil, nil } } - controlDispatcherServe = func(beadID string, _ io.Writer, _ io.Writer) error { + controlDispatcherServe = func(_, _ string, beadID string, _ io.Writer, _ io.Writer) error { if beadID == "gc-pending" { return dispatch.ErrControlPending } @@ -1383,7 +3201,7 @@ func TestRunWorkflowServeFollowResetsBackoffForProcessedEventAndPending(t *testi } agent := config.Agent{Name: "control-dispatcher"} - err := runWorkflowServeFollow(agent, agent.EffectiveWorkQuery(), t.TempDir(), nil, io.Discard) + err := runWorkflowServeFollow(agent, t.TempDir(), t.TempDir(), agent.EffectiveWorkQuery(), nil, io.Discard) if !errors.Is(err, stopErr) { t.Fatalf("runWorkflowServeFollow error = %v, want %v", err, stopErr) } @@ -1481,8 +3299,11 @@ func TestDecorateDynamicFragmentRecipeSynthesizesInheritedScopeChecks(t *testing if control.Metadata["gc.scope_ref"] != "body" { t.Fatalf("review scope-check gc.scope_ref = %q, want body", control.Metadata["gc.scope_ref"]) } - if control.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("review scope-check gc.routed_to = %q, want %q", control.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if control.Assignee != config.ControlDispatcherAgentName { + t.Fatalf("review scope-check assignee = %q, want %q", control.Assignee, config.ControlDispatcherAgentName) + } + if got := control.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("review scope-check gc.routed_to = %q, want empty direct dispatcher assignee", got) } if control.Metadata[graphExecutionRouteMetaKey] != "reviewer" { t.Fatalf("review scope-check execution route = %q, want reviewer", control.Metadata[graphExecutionRouteMetaKey]) @@ -1790,7 +3611,7 @@ name = "test-city" } t.Setenv("GC_BEADS", "exec:/definitely/missing/provider") - _, _, err := findBeadAcrossStores(cityPath, "gc-missing", io.Discard) + _, _, _, err := findBeadAcrossStores(cityPath, "gc-missing", io.Discard) if err == nil { t.Fatal("findBeadAcrossStores() error = nil, want provider failure") } @@ -1820,6 +3641,7 @@ prefix = "BL" } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -1938,6 +3760,7 @@ prefix = "BL" } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -2059,6 +3882,7 @@ prefix = "BL" } t.Setenv("GC_CITY", cityDir) t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") prevCityFlag := cityFlag cityFlag = "" t.Cleanup(func() { cityFlag = prevCityFlag }) @@ -2203,6 +4027,72 @@ func TestDeleteWorkflowBeadsRemovesDepsBeforeDelete(t *testing.T) { } } +func TestApplySourceWorkflowMatchCleanupDeletesOnlyCollectedWorkflowBeads(t *testing.T) { + store := beads.NewMemStore() + first, err := store.Create(beads.Bead{Title: "workflow first", Type: "task"}) + if err != nil { + t.Fatalf("Create(first): %v", err) + } + second, err := store.Create(beads.Bead{Title: "workflow second", Type: "task"}) + if err != nil { + t.Fatalf("Create(second): %v", err) + } + outside, err := store.Create(beads.Bead{Title: "outside follow-up", Type: "task"}) + if err != nil { + t.Fatalf("Create(outside): %v", err) + } + if err := store.DepAdd(first.ID, outside.ID, "blocks"); err != nil { + t.Fatalf("DepAdd(first->outside): %v", err) + } + if err := store.DepAdd(outside.ID, second.ID, "blocks"); err != nil { + t.Fatalf("DepAdd(outside->second): %v", err) + } + + runnerCalled := false + runner := func(_ string, _ string, _ ...string) ([]byte, error) { + runnerCalled = true + return []byte("ok"), nil + } + + var stderr bytes.Buffer + closed, deleted, incomplete := applySourceWorkflowMatchCleanup(sourceWorkflowStoreMatch{ + label: "rig:gascity", + store: store, + beads: []beads.Bead{first, second}, + path: "/repo", + runner: runner, + }, true, &stderr) + if incomplete { + t.Fatalf("cleanup incomplete; stderr=%s", stderr.String()) + } + if closed != 2 || deleted != 2 { + t.Fatalf("closed/deleted = %d/%d, want 2/2", closed, deleted) + } + if runnerCalled { + t.Fatal("cleanup used bd cascade runner; want explicit in-process deletion of collected IDs") + } + for _, id := range []string{first.ID, second.ID} { + if _, err := store.Get(id); err == nil { + t.Fatalf("Get(%s) succeeded after delete", id) + } + } + if got, err := store.Get(outside.ID); err != nil { + t.Fatalf("Get(outside): %v", err) + } else if got.Status != "open" { + t.Fatalf("outside status = %q, want open", got.Status) + } + if down, err := store.DepList(outside.ID, "down"); err != nil { + t.Fatalf("DepList(outside, down): %v", err) + } else if len(down) != 0 { + t.Fatalf("outside down deps = %#v, want none after collected bead deletion", down) + } + if up, err := store.DepList(outside.ID, "up"); err != nil { + t.Fatalf("DepList(outside, up): %v", err) + } else if len(up) != 0 { + t.Fatalf("outside up deps = %#v, want none after collected bead deletion", up) + } +} + type failingDeleteStore struct { *beads.MemStore failID string @@ -2423,6 +4313,131 @@ func TestWaitForRelevantWorkflowWakeTraceIncludesBackoffState(t *testing.T) { } } +func TestWorkflowTracefWarnsOnceWhenTracePathCannotBeOpened(t *testing.T) { + tracePath := filepath.Join(t.TempDir(), "missing", "workflow-trace.log") + t.Setenv("GC_WORKFLOW_TRACE", tracePath) + + var stderr bytes.Buffer + restoreWarnings := useWorkflowTraceWarnings(&stderr) + defer restoreWarnings() + + workflowTracef("first write") + workflowTracef("second write") + + got := stderr.String() + if count := strings.Count(got, "opening workflow trace"); count != 1 { + t.Fatalf("warning count = %d, want 1; stderr=%q", count, got) + } + if !strings.Contains(got, tracePath) { + t.Fatalf("stderr = %q, want missing trace path %q", got, tracePath) + } +} + +func TestWorkflowTracefFallsBackToSlingTrace(t *testing.T) { + tracePath := filepath.Join(t.TempDir(), "workflow-trace.log") + t.Setenv("GC_SLING_TRACE", tracePath) + + workflowTracef("fallback trace") + + traceBytes, err := os.ReadFile(tracePath) + if err != nil { + t.Fatalf("read trace: %v", err) + } + if !strings.Contains(string(traceBytes), "fallback trace") { + t.Fatalf("trace = %q, want fallback trace payload", traceBytes) + } +} + +func TestWorkflowTracefUsesRFC3339NanoTimestamp(t *testing.T) { + tracePath := filepath.Join(t.TempDir(), "workflow-trace.log") + t.Setenv("GC_WORKFLOW_TRACE", tracePath) + + fixedNow := time.Date(2026, 5, 5, 22, 12, 34, 345678901, time.UTC) + prevNow := workflowTraceNow + workflowTraceNow = func() time.Time { return fixedNow } + defer func() { + workflowTraceNow = prevNow + }() + + workflowTracef("precise trace") + + traceBytes, err := os.ReadFile(tracePath) + if err != nil { + t.Fatalf("read trace: %v", err) + } + + line := strings.TrimSpace(string(traceBytes)) + wantPrefix := fixedNow.Format(time.RFC3339Nano) + " " + if !strings.HasPrefix(line, wantPrefix) { + t.Fatalf("trace = %q, want prefix %q", line, wantPrefix) + } +} + +func TestWorkflowTraceWarningScopeResetsAcrossTopLevelInstalls(t *testing.T) { + badPath := filepath.Join(t.TempDir(), "missing", "workflow-trace.log") + var stderr bytes.Buffer + + restoreOne := useWorkflowTraceWarnings(&stderr) + workflowTraceWarnOpenFailure(badPath, os.ErrNotExist) + restoreOne() + + restoreTwo := useWorkflowTraceWarnings(&stderr) + workflowTraceWarnOpenFailure(badPath, os.ErrNotExist) + restoreTwo() + + if count := strings.Count(stderr.String(), "opening workflow trace"); count != 2 { + t.Fatalf("warning count = %d, want 2 across separate top-level installs; stderr=%q", count, stderr.String()) + } +} + +func TestWorkflowTraceWarningRestoreSupportsOutOfOrderRelease(t *testing.T) { + badPath := filepath.Join(t.TempDir(), "missing", "workflow-trace.log") + var outer bytes.Buffer + var inner bytes.Buffer + var fresh bytes.Buffer + + restoreOuter := useWorkflowTraceWarnings(&outer) + restoreInner := useWorkflowTraceWarnings(&inner) + + restoreOuter() + workflowTraceWarnOpenFailure(badPath, os.ErrNotExist) + restoreInner() + + if outer.Len() != 0 { + t.Fatalf("outer stderr = %q, want no warning after out-of-order outer restore", outer.String()) + } + if count := strings.Count(inner.String(), "opening workflow trace"); count != 1 { + t.Fatalf("inner warning count = %d, want 1 after out-of-order outer restore; stderr=%q", count, inner.String()) + } + + restoreFresh := useWorkflowTraceWarnings(&fresh) + workflowTraceWarnOpenFailure(badPath, os.ErrNotExist) + restoreFresh() + if count := strings.Count(fresh.String(), "opening workflow trace"); count != 1 { + t.Fatalf("fresh warning count = %d, want 1 after scopes reset; stderr=%q", count, fresh.String()) + } +} + +func TestWorkflowTraceWarnfDedupsMatchingInactiveScopeWriter(t *testing.T) { + var outer bytes.Buffer + var inner bytes.Buffer + + restoreOuter := useWorkflowTraceWarnings(&outer) + defer restoreOuter() + restoreInner := useWorkflowTraceWarnings(&inner) + defer restoreInner() + + workflowTraceWarnf(&outer, "duplicate", "outer warning\n") + workflowTraceWarnf(&outer, "duplicate", "outer warning\n") + + if count := strings.Count(outer.String(), "outer warning"); count != 1 { + t.Fatalf("outer warning count = %d, want 1; stderr=%q", count, outer.String()) + } + if inner.Len() != 0 { + t.Fatalf("inner stderr = %q, want no warning for outer-scope writer", inner.String()) + } +} + func TestFollowSleepDurationHandlesPathologicalInputs(t *testing.T) { prevSweep := workflowServeWakeSweepInterval prevMax := workflowServeMaxIdleSleep diff --git a/cmd/gc/cmd_doctor.go b/cmd/gc/cmd_doctor.go index 9b937ecced..2d2dab87ab 100644 --- a/cmd/gc/cmd_doctor.go +++ b/cmd/gc/cmd_doctor.go @@ -172,6 +172,12 @@ func doDoctor(fix, verbose bool, stdout, stderr io.Writer) int { d.Register(doctor.NewBinaryCheck("jq", "", exec.LookPath)) d.Register(doctor.NewBinaryCheck("pgrep", "", exec.LookPath)) d.Register(doctor.NewBinaryCheck("lsof", "", exec.LookPath)) + // beads.role must be set before any bd command runs; check it here so + // the missing-role error appears before the downstream data/Dolt checks + // that will all fail for the same root cause. + if initNeedsBdTooling(cityPath) { + d.Register(&doctor.BeadsRoleCheck{}) + } // Controller check + session checks (gated by controller state). controllerRunning := doctor.IsControllerRunning(cityPath) @@ -193,6 +199,7 @@ func doDoctor(fix, verbose bool, stdout, stderr io.Writer) int { if cfgErr == nil { d.Register(doctor.NewBDSplitStoreCheck(cityPath)) d.Register(doctor.NewBeadsStoreCheck(cityPath, storeFactory)) + d.Register(newV2RoutedToNamespaceCheck(cfg, cityPath, storeFactory)) d.Register(&sessionModelDoctorCheck{cfg: cfg, cityPath: cityPath, newStore: storeFactory}) } skipCityDoltCheck := os.Getenv("GC_DOLT") == "skip" || (!scopeUsesManagedBdStoreContract(cityPath, cityPath) && !workspaceNeedsCityDoltCheck(cityPath, cfg)) @@ -208,6 +215,16 @@ func doDoctor(fix, verbose bool, stdout, stderr io.Writer) int { d.Register(doctor.NewScopedDoltVersionCheckForConfig(cityPath, skipManagedDoltCheck, cfg, cfgErr)) d.Register(&doctor.EventsLogCheck{}) d.Register(doctor.NewEventLogSizeCheck()) + // Worktree checks deliberately run even when cfgErr != nil — they + // only need the city path, and a broken city.toml is exactly when + // silent disk-fill is most likely. The zero-value DoctorConfig + // produces sensible 10/50 GB defaults via its accessor methods. + var doctorCfg config.DoctorConfig + if cfg != nil { + doctorCfg = cfg.Doctor + } + d.Register(doctor.NewWorktreeDiskSizeCheck(doctorCfg)) + d.Register(doctor.NewNestedWorktreePruneCheck(doctorCfg)) // Custom types check — city store. d.Register(doctor.NewCustomTypesCheck(cityPath, "city")) diff --git a/cmd/gc/cmd_dolt_cleanup.go b/cmd/gc/cmd_dolt_cleanup.go new file mode 100644 index 0000000000..710785f8d8 --- /dev/null +++ b/cmd/gc/cmd_dolt_cleanup.go @@ -0,0 +1,1035 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/fsys" + "github.com/spf13/cobra" +) + +// CleanupSchemaVersion is the stable schema identifier for the JSON output of +// `gc dolt-cleanup --json`. Documented in AD-04 designer Wireframe 6. +const CleanupSchemaVersion = "gc.dolt.cleanup.v1" + +// CleanupReport is the typed JSON output of `gc dolt-cleanup`. +// +// Fields are populated incrementally: the port section is filled from the +// AD-04 §4.1 discovery chain; rigs_protected, dropped, purge, reaped are +// populated by their respective steps as they come online. The shape is +// stable from day one — empty arrays and zero structs render as `[]` / +// `{...}` so callers can rely on the schema across versions. +type CleanupReport struct { + Schema string `json:"schema"` + Port CleanupPortReport `json:"port"` + RigsProtected []CleanupRigProtection `json:"rigs_protected"` + ForceBlockers []CleanupForceBlocker `json:"force_blockers"` + Dropped CleanupDroppedReport `json:"dropped"` + Purge CleanupPurgeReport `json:"purge"` + Reaped CleanupReapedReport `json:"reaped"` + Summary CleanupSummary `json:"summary"` + Errors []CleanupError `json:"errors"` +} + +// CleanupPortReport is the resolved-port section of the JSON envelope. +type CleanupPortReport struct { + Resolved int `json:"resolved"` + Source string `json:"source"` + Fallback bool `json:"fallback"` +} + +// CleanupRigProtection records a registered rig DB whose name will not be +// dropped even if it appears in the orphan scan. +type CleanupRigProtection struct { + Rig string `json:"rig"` + DB string `json:"db"` +} + +// CleanupForceBlocker records a condition that would block a future forced +// cleanup but does not make dry-run output an error. +type CleanupForceBlocker struct { + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + Error string `json:"error"` +} + +// CleanupDroppedReport summarizes the drop step. +type CleanupDroppedReport struct { + Count int `json:"count"` + BytesFreed int64 `json:"bytes_freed"` + // Names lists the databases the drop step targeted: the candidates in + // dry-run, the actually-dropped names in --force. Order follows the + // SHOW DATABASES result. + Names []string `json:"names"` + Failed []CleanupDropFailure `json:"failed"` + Skipped []DoltDropSkip `json:"skipped"` +} + +// CleanupDropFailure records a single drop step that did not complete. +type CleanupDropFailure struct { + Name string `json:"name"` + Error string `json:"error"` +} + +// CleanupPurgeReport summarizes the purge step. +type CleanupPurgeReport struct { + OK bool `json:"ok"` + // BytesReclaimed is an estimate in dry-run mode and confirmed reclaimed + // bytes in --force mode. Failed forced purge calls do not contribute. + BytesReclaimed int64 `json:"bytes_reclaimed"` +} + +// CleanupReapedReport summarizes the orphan-process reap step. +type CleanupReapedReport struct { + Count int `json:"count"` + ProtectedPIDs []int `json:"protected_pids"` + // VanishedPIDs records reap targets missing before any signal was sent. + // Post-SIGTERM disappearance is counted as a successful reap because this + // process sent the termination signal and the process exited before SIGKILL. + VanishedPIDs []int `json:"vanished_pids"` + // Targets records the PIDs the reaper identified as test orphans (the + // reap candidates). Populated in both dry-run and --force; --force + // additionally drives Count to reflect actually-killed processes. + Targets []CleanupReapTarget `json:"targets"` + Errors []string `json:"errors"` +} + +// CleanupReapTarget is a single orphan dolt sql-server process the reaper +// identified for termination. +type CleanupReapTarget struct { + PID int `json:"pid"` + ConfigPath string `json:"config_path"` +} + +// CleanupSummary aggregates totals across the three steps. +type CleanupSummary struct { + BytesFreedDisk int64 `json:"bytes_freed_disk"` + BytesFreedRSS int64 `json:"bytes_freed_rss"` + ErrorsTotal int `json:"errors_total"` +} + +// CleanupError is a single error entry tagged with the stage that produced +// it. Stage values are e.g. "drop", "purge", "reap", "port". +type CleanupError struct { + Stage string `json:"stage"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Error string `json:"error"` +} + +const ( + cleanupErrorKindInvalidMaxOrphanDBs = "invalid-max-orphan-dbs" + cleanupErrorKindMaxOrphanRefusal = "max-orphan-refusal" + cleanupErrorKindRigProtection = "rig-protection" +) + +// MarshalJSON ensures slices serialize as `[]` rather than `null` for empty +// values. The JSON contract documents these as always-present arrays. +func (r CleanupReport) MarshalJSON() ([]byte, error) { + type alias CleanupReport + if r.RigsProtected == nil { + r.RigsProtected = []CleanupRigProtection{} + } + if r.ForceBlockers == nil { + r.ForceBlockers = []CleanupForceBlocker{} + } + if r.Dropped.Failed == nil { + r.Dropped.Failed = []CleanupDropFailure{} + } + if r.Dropped.Skipped == nil { + r.Dropped.Skipped = []DoltDropSkip{} + } + if r.Reaped.ProtectedPIDs == nil { + r.Reaped.ProtectedPIDs = []int{} + } + if r.Reaped.VanishedPIDs == nil { + r.Reaped.VanishedPIDs = []int{} + } + if r.Reaped.Targets == nil { + r.Reaped.Targets = []CleanupReapTarget{} + } + if r.Reaped.Errors == nil { + r.Reaped.Errors = []string{} + } + if r.Dropped.Names == nil { + r.Dropped.Names = []string{} + } + if r.Errors == nil { + r.Errors = []CleanupError{} + } + return json.Marshal(alias(r)) +} + +// cleanupOptions bundles the inputs to runDoltCleanup so the command body +// stays Cobra-free and testable. The Cobra command builds an options value +// from flags and city state and hands it off. +// +// DiscoverProcesses and KillProcess are injection points for tests; in +// production they default to the /proc walker and syscall.Kill respectively. +// HomeDir defaults to the live $HOME and seeds ~/.gotmp/Test* recognition. +// TempDir defaults to the live os.TempDir() and lets the reaper recognize +// Go test temp roots and known Gas City test prefixes on hosts where TMPDIR +// is not /tmp. +type cleanupOptions struct { + Flag string + CityPort int + PortResolution PortResolution + Rigs []resolverRig + FS fsys.FS + JSON bool + Probe bool + Force bool + Host string + HomeDir string + TempDir string + MaxOrphanDBs int + + // StalePrefixes overrides defaultStaleDatabasePrefixes when non-empty. + // Set by tests; production passes nil and falls back to the built-in. + StalePrefixes []string + + // DoltClient is the SQL surface used by the drop and purge stages. When + // nil, those stages no-op (the report still renders, just without DB + // operations) — useful for tests that exercise the port resolver and + // reaper in isolation. + DoltClient CleanupDoltClient + // DoltClientOpenErr records a failed attempt to open the production SQL + // client. Tests that intentionally omit DoltClient leave this nil. + DoltClientOpenErr error + + DiscoverProcesses func() ([]DoltProcInfo, error) + ActiveTestRoots []string + KillProcess func(pid int, sig syscall.Signal) error + ReapGracePeriod time.Duration +} + +// runDoltCleanup is the testable core of the `gc dolt-cleanup` command. It +// applies the AD-04 §4.1 port-resolution chain, optionally probes the +// resolved port, runs the orphan-process reaper, and writes either a +// CleanupReport JSON envelope or a human-readable summary to stdout. +// Returns the exit code. +// +// Drop and purge stages are populated when a Dolt SQL client is available; +// otherwise the report still renders with errors describing the unreachable +// data plane. +func runDoltCleanup(opts cleanupOptions, stdout, stderr io.Writer) int { + if opts.MaxOrphanDBs < 0 { + report := CleanupReport{Schema: CleanupSchemaVersion} + recordCleanupErrorKind( + &report, + "config", + cleanupErrorKindInvalidMaxOrphanDBs, + "--max-orphan-dbs", + fmt.Errorf("--max-orphan-dbs must be non-negative, got %d", opts.MaxOrphanDBs), + ) + emitReport(report, PortResolution{}, opts, stdout, stderr) + return 1 + } + + resolution := cleanupPortResolution(opts) + opts.PortResolution = resolution + protections, protectionErrors := rigProtections(opts.Rigs, opts.FS) + + report := CleanupReport{ + Schema: CleanupSchemaVersion, + Port: CleanupPortReport{ + Resolved: resolution.Port, + Source: resolution.Source, + Fallback: resolution.Fallback, + }, + RigsProtected: protections, + } + for _, e := range protectionErrors { + recordCleanupForceBlocker(&report, cleanupErrorKindRigProtection, e.rig, e.err) + } + if opts.Force { + for _, e := range protectionErrors { + recordCleanupErrorKind(&report, "rig", cleanupErrorKindRigProtection, e.rig, e.err) + } + } + recordUnsafeRigDatabaseNames(&report) + + if fatalAttempt, err := fatalPortResolutionAttempt(resolution); err != nil { + fatalResolution := resolution + fatalResolution.Port = 0 + fatalResolution.Source = fatalAttempt.Source + fatalResolution.Fallback = false + report.Port = CleanupPortReport{ + Resolved: 0, + Source: fatalAttempt.Source, + Fallback: false, + } + recordCleanupError(&report, "port", fatalAttempt.Source, err) + emitReport(report, fatalResolution, opts, stdout, stderr) + return 1 + } + + if opts.Probe { + host := opts.Host + if host == "" { + host = "127.0.0.1" + } + if err := probeDoltPort(host, resolution.Port); err != nil { + report.Errors = append(report.Errors, CleanupError{ + Stage: "port", + Error: err.Error(), + }) + report.Summary.ErrorsTotal++ + emitReport(report, resolution, opts, stdout, stderr) + return 1 + } + } + + if runDropStage(&report, opts) { + runPurgeStage(&report, opts) + runReapStage(&report, opts) + } + report.Summary.BytesFreedDisk = report.Purge.BytesReclaimed + + emitReport(report, resolution, opts, stdout, stderr) + if opts.DoltClientOpenErr != nil { + return 1 + } + return 0 +} + +func cleanupPortResolution(opts cleanupOptions) PortResolution { + if opts.PortResolution.Port != 0 || opts.PortResolution.Source != "" || len(opts.PortResolution.Tried) != 0 { + return opts.PortResolution + } + return ResolveDoltPort(PortResolverInput{ + Flag: opts.Flag, + CityPort: opts.CityPort, + Rigs: opts.Rigs, + FS: opts.FS, + }) +} + +func recordCleanupError(report *CleanupReport, stage, name string, err error) { + recordCleanupErrorKind(report, stage, "", name, err) +} + +func recordCleanupErrorKind(report *CleanupReport, stage, kind, name string, err error) { + entry := CleanupError{Stage: stage, Kind: kind, Error: err.Error()} + if name != "" { + entry.Name = name + } + report.Errors = append(report.Errors, entry) + report.Summary.ErrorsTotal++ +} + +func recordCleanupForceBlocker(report *CleanupReport, kind, name string, err error) { + entry := CleanupForceBlocker{Kind: kind, Error: err.Error()} + if name != "" { + entry.Name = name + } + report.ForceBlockers = append(report.ForceBlockers, entry) +} + +// runReapStage discovers live `dolt sql-server` processes, classifies them +// against the rig-port and test-config-path allowlists, and (when --force is +// set) sends SIGTERM followed by SIGKILL after a grace period. Errors are +// recorded into the CleanupReport but do not abort the run — partial reap +// progress is more useful than failing the whole stage. +func runReapStage(report *CleanupReport, opts cleanupOptions) { + discover := opts.DiscoverProcesses + if discover == nil { + discover = discoverDoltProcesses + } + procs, err := discover() + if err != nil { + report.Errors = append(report.Errors, CleanupError{Stage: "reap", Error: err.Error()}) + report.Summary.ErrorsTotal++ + report.Reaped.Errors = append(report.Reaped.Errors, err.Error()) + return + } + + rigPorts := protectedDoltPortsForReap(opts) + tempDir := opts.TempDir + if tempDir == "" { + tempDir = os.TempDir() + } + activeTestRoots := opts.ActiveTestRoots + if activeTestRoots == nil { + activeTestRoots = discoverActiveTestRoots(opts.HomeDir, tempDir) + } + plan := planOrphanReap(procs, rigPorts, opts.HomeDir, tempDir, activeTestRoots) + + report.Reaped.ProtectedPIDs = nil + for _, p := range plan.Protected { + report.Reaped.ProtectedPIDs = append(report.Reaped.ProtectedPIDs, p.PID) + } + report.Reaped.Targets = nil + for _, t := range plan.Reap { + report.Reaped.Targets = append(report.Reaped.Targets, CleanupReapTarget{PID: t.PID, ConfigPath: t.ConfigPath}) + } + + if !opts.Force { + report.Reaped.Count = len(plan.Reap) + report.Summary.BytesFreedRSS = sumReapTargetRSS(plan.Reap, nil) + return + } + + killFn := opts.KillProcess + if killFn == nil { + killFn = killProcess + } + grace := opts.ReapGracePeriod + if grace <= 0 { + grace = 250 * time.Millisecond + } + + reaped := 0 + gone := make(map[int]bool, len(plan.Reap)) + sigtermSent := make(map[int]bool, len(plan.Reap)) + for _, target := range plan.Reap { + switch revalidateReapTarget(report, discover, target, rigPorts, opts.HomeDir, tempDir, activeTestRoots, "SIGTERM") { + case reapRevalidationEligible: + case reapRevalidationVanished: + appendVanishedPID(report, target.PID) + continue + default: + continue + } + if err := killFn(target.PID, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + gone[target.PID] = true + } else { + recordReapSignalError(report, target.PID, syscall.SIGTERM, err) + } + continue + } + sigtermSent[target.PID] = true + } + if grace > 0 { + time.Sleep(grace) + } + + for _, target := range plan.Reap { + if gone[target.PID] || !sigtermSent[target.PID] { + continue + } + switch revalidateReapTarget(report, discover, target, rigPorts, opts.HomeDir, tempDir, activeTestRoots, "SIGKILL") { + case reapRevalidationEligible: + case reapRevalidationVanished: + gone[target.PID] = true + continue + default: + continue + } + if err := killFn(target.PID, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + gone[target.PID] = true + } else { + recordReapSignalError(report, target.PID, syscall.SIGKILL, err) + } + continue + } + gone[target.PID] = true + } + for _, target := range plan.Reap { + if gone[target.PID] { + reaped++ + } + } + report.Reaped.Count = reaped + report.Summary.BytesFreedRSS = sumReapTargetRSS(plan.Reap, gone) +} + +func protectedDoltPortsForReap(opts cleanupOptions) map[int]string { + ports := loadRigDoltPorts(opts.Rigs, opts.FS) + if opts.PortResolution.Port <= 0 { + return ports + } + if opts.PortResolution.Fallback { + return ports + } + source := opts.PortResolution.Source + if source == "" { + source = "selected" + } + if _, ok := ports[opts.PortResolution.Port]; !ok { + ports[opts.PortResolution.Port] = source + } + return ports +} + +type reapRevalidationStatus int + +const ( + reapRevalidationEligible reapRevalidationStatus = iota + reapRevalidationProtected + reapRevalidationVanished + reapRevalidationError +) + +func revalidateReapTarget(report *CleanupReport, discover func() ([]DoltProcInfo, error), target ReapTarget, rigPorts map[int]string, homeDir, tempDir string, activeTestRoots []string, signalName string) reapRevalidationStatus { + refreshed, err := discover() + if err != nil { + recordReapRevalidationError(report, signalName, err) + return reapRevalidationError + } + for _, proc := range refreshed { + if proc.PID != target.PID { + continue + } + recheck := classifyDoltProcess(proc, rigPorts, homeDir, tempDir, activeTestRoots) + if recheck.Action != "reap" || recheck.ConfigPath != target.ConfigPath || !sameReapProcessIdentity(target, proc) { + appendProtectedPID(report, target.PID) + return reapRevalidationProtected + } + return reapRevalidationEligible + } + return reapRevalidationVanished +} + +func sameReapProcessIdentity(target ReapTarget, proc DoltProcInfo) bool { + return target.StartTimeTicks != 0 && proc.StartTimeTicks == target.StartTimeTicks +} + +func recordReapRevalidationError(report *CleanupReport, signalName string, err error) { + msg := fmt.Sprintf("revalidate before %s: %v", signalName, err) + report.Reaped.Errors = append(report.Reaped.Errors, msg) + report.Errors = append(report.Errors, CleanupError{ + Stage: "reap", + Error: msg, + }) + report.Summary.ErrorsTotal++ +} + +func sumReapTargetRSS(targets []ReapTarget, include map[int]bool) int64 { + var total int64 + for _, target := range targets { + if include != nil && !include[target.PID] { + continue + } + if target.RSSBytes > 0 { + total += target.RSSBytes + } + } + return total +} + +func fatalPortResolutionError(resolution PortResolution) error { + _, err := fatalPortResolutionAttempt(resolution) + return err +} + +func fatalPortResolutionAttempt(resolution PortResolution) (PortResolutionAttempt, error) { + for _, attempt := range resolution.Tried { + if attempt.Status != "error" { + continue + } + if attempt.Source != flagDoltPortSource && attempt.Source != cityConfigDoltPortSource && !isRigPortFileSource(attempt.Source) { + continue + } + if attempt.Detail != "" { + return attempt, errors.New(attempt.Detail) + } + return attempt, fmt.Errorf("%s resolution failed", attempt.Source) + } + return PortResolutionAttempt{}, nil +} + +func isRigPortFileSource(source string) bool { + return filepath.Base(source) == "dolt-server.port" && filepath.Base(filepath.Dir(source)) == ".beads" +} + +func appendProtectedPID(report *CleanupReport, pid int) { + for _, existing := range report.Reaped.ProtectedPIDs { + if existing == pid { + return + } + } + report.Reaped.ProtectedPIDs = append(report.Reaped.ProtectedPIDs, pid) +} + +func appendVanishedPID(report *CleanupReport, pid int) { + for _, existing := range report.Reaped.VanishedPIDs { + if existing == pid { + return + } + } + report.Reaped.VanishedPIDs = append(report.Reaped.VanishedPIDs, pid) +} + +func recordReapSignalError(report *CleanupReport, pid int, sig syscall.Signal, err error) { + sigName := reapSignalName(sig) + report.Reaped.Errors = append(report.Reaped.Errors, fmt.Sprintf("pid %d %s: %v", pid, sigName, err)) + report.Errors = append(report.Errors, CleanupError{ + Stage: "reap", + Name: fmt.Sprintf("pid %d", pid), + Error: fmt.Sprintf("%s: %v", sigName, err), + }) + report.Summary.ErrorsTotal++ +} + +func reapSignalName(sig syscall.Signal) string { + switch sig { + case syscall.SIGTERM: + return "SIGTERM" + case syscall.SIGKILL: + return "SIGKILL" + default: + return sig.String() + } +} + +func emitReport(report CleanupReport, resolution PortResolution, opts cleanupOptions, stdout, stderr io.Writer) { + if opts.JSON { + data, err := json.Marshal(report) + if err != nil { + fmt.Fprintf(stderr, "gc dolt-cleanup: marshal report: %v\n", err) //nolint:errcheck + return + } + fmt.Fprintln(stdout, string(data)) //nolint:errcheck + return + } + + emitHumanReport(report, resolution, opts, stdout) +} + +// emitHumanReport writes the operator-facing wireframe to stdout. Output is +// plain text with small unicode glyphs (⚠ ✓ ✖) — no ANSI escapes — so it +// behaves correctly under NO_COLOR or when piped to a file. +func emitHumanReport(report CleanupReport, resolution PortResolution, opts cleanupOptions, stdout io.Writer) { + host := opts.Host + if host == "" { + host = "127.0.0.1" + } + switch { + case resolution.Port <= 0: + fmt.Fprintln(stdout, "✖ Dolt server port: unresolved") //nolint:errcheck + fmt.Fprintln(stdout, " Tried sources, in order:") //nolint:errcheck + for _, attempt := range resolution.Tried { + fmt.Fprintf(stdout, " %-46s %s\n", attempt.Source, attemptStatusLabel(attempt)) //nolint:errcheck + } + case resolution.Fallback: + fmt.Fprintf(stdout, "⚠ Dolt server port: %d (legacy default — fallback)\n", resolution.Port) //nolint:errcheck + fmt.Fprintln(stdout, " Tried sources, in order:") //nolint:errcheck + for _, attempt := range resolution.Tried { + fmt.Fprintf(stdout, " %-46s %s\n", attempt.Source, attemptStatusLabel(attempt)) //nolint:errcheck + } + default: + fmt.Fprintf(stdout, "Dolt server: %s:%d (resolved from %s)\n", host, resolution.Port, resolution.Source) //nolint:errcheck + } + + emitDroppedSection(report, stdout) + emitOrphansSection(report, stdout) + emitProtectedSection(report, stdout) + emitForceBlockersSection(report, stdout) + emitErrorsOrSummary(report, opts, stdout) + if !opts.Force { + fmt.Fprintln(stdout, "") //nolint:errcheck + fmt.Fprintln(stdout, "Re-run with --force to apply.") //nolint:errcheck + } +} + +func emitDroppedSection(report CleanupReport, stdout io.Writer) { + fmt.Fprintln(stdout, "") //nolint:errcheck + fmt.Fprintf(stdout, "DROPPED-DATABASE DIRECTORIES (%d)\n", report.Dropped.Count) //nolint:errcheck + if len(report.Dropped.Names) == 0 { + fmt.Fprintln(stdout, " (none)") //nolint:errcheck + return + } + for _, name := range report.Dropped.Names { + fmt.Fprintf(stdout, " %s\n", name) //nolint:errcheck + } + for _, f := range report.Dropped.Failed { + fmt.Fprintf(stdout, " ✖ %s — %s\n", f.Name, f.Error) //nolint:errcheck + } + for _, s := range report.Dropped.Skipped { + fmt.Fprintf(stdout, " skipped %s — %s\n", s.Name, s.Reason) //nolint:errcheck + } +} + +func emitOrphansSection(report CleanupReport, stdout io.Writer) { + fmt.Fprintln(stdout, "") //nolint:errcheck + fmt.Fprintf(stdout, "ORPHAN dolt sql-server PROCESSES (%d)\n", len(report.Reaped.Targets)) //nolint:errcheck + if len(report.Reaped.Targets) == 0 { + fmt.Fprintln(stdout, " (none)") //nolint:errcheck + return + } + for _, t := range report.Reaped.Targets { + path := t.ConfigPath + if path == "" { + path = "(no --config flag)" + } + fmt.Fprintf(stdout, " PID %d %s\n", t.PID, path) //nolint:errcheck + } +} + +func emitProtectedSection(report CleanupReport, stdout io.Writer) { + fmt.Fprintln(stdout, "") //nolint:errcheck + fmt.Fprintln(stdout, "PROTECTED") //nolint:errcheck + if len(report.RigsProtected) == 0 && len(report.Reaped.ProtectedPIDs) == 0 { + fmt.Fprintln(stdout, " (none)") //nolint:errcheck + return + } + for _, rp := range report.RigsProtected { + fmt.Fprintf(stdout, " rig %q → DB %q\n", rp.Rig, rp.DB) //nolint:errcheck + } + for _, pid := range report.Reaped.ProtectedPIDs { + fmt.Fprintf(stdout, " PID %d (active server or non-test path)\n", pid) //nolint:errcheck + } +} + +func emitForceBlockersSection(report CleanupReport, stdout io.Writer) { + if len(report.ForceBlockers) == 0 { + return + } + fmt.Fprintln(stdout, "") //nolint:errcheck + fmt.Fprintf(stdout, "FORCE BLOCKERS (%d)\n", len(report.ForceBlockers)) //nolint:errcheck + for _, blocker := range report.ForceBlockers { + if blocker.Name != "" { + fmt.Fprintf(stdout, " [%s] %s - %s\n", blocker.Kind, blocker.Name, blocker.Error) //nolint:errcheck + } else { + fmt.Fprintf(stdout, " [%s] %s\n", blocker.Kind, blocker.Error) //nolint:errcheck + } + } +} + +func emitErrorsOrSummary(report CleanupReport, opts cleanupOptions, stdout io.Writer) { + fmt.Fprintln(stdout, "") //nolint:errcheck + if len(report.Errors) > 0 { + fmt.Fprintf(stdout, "ERRORS (%d)\n", len(report.Errors)) //nolint:errcheck + for _, e := range report.Errors { + if e.Name != "" { + fmt.Fprintf(stdout, " [%s] %s — %s\n", e.Stage, e.Name, e.Error) //nolint:errcheck + } else { + fmt.Fprintf(stdout, " [%s] %s\n", e.Stage, e.Error) //nolint:errcheck + } + } + fmt.Fprintln(stdout, "") //nolint:errcheck + } + + fmt.Fprintln(stdout, "SUMMARY") //nolint:errcheck + verb := "would free" + if opts.Force { + verb = "freed" + } + fmt.Fprintf(stdout, " Disk %s: %s\n", verb, formatBytes(report.Purge.BytesReclaimed)) //nolint:errcheck + fmt.Fprintf(stdout, " Drops: %d (failed: %d)\n", report.Dropped.Count, len(report.Dropped.Failed)) //nolint:errcheck + purgeStatus := "skipped" + if opts.Force { + if report.Purge.OK { + purgeStatus = "ok" + } else { + purgeStatus = "failed" + } + } + fmt.Fprintf(stdout, " Purge: %s\n", purgeStatus) //nolint:errcheck + fmt.Fprintf(stdout, " Reaped: %d (protected: %d)\n", report.Reaped.Count, len(report.Reaped.ProtectedPIDs)) //nolint:errcheck + fmt.Fprintf(stdout, " Errors: %d\n", report.Summary.ErrorsTotal) //nolint:errcheck +} + +// formatBytes formats a byte count as "N B", "N.N KiB", "N.N MiB", or +// "N.N GiB" — the binary-prefix scale operators expect for disk +// reclamation reports. +func formatBytes(n int64) string { + const ( + KiB int64 = 1 << 10 + MiB int64 = 1 << 20 + GiB int64 = 1 << 30 + ) + switch { + case n >= GiB: + return fmt.Sprintf("%.1f GiB", float64(n)/float64(GiB)) + case n >= MiB: + return fmt.Sprintf("%.1f MiB", float64(n)/float64(MiB)) + case n >= KiB: + return fmt.Sprintf("%.1f KiB", float64(n)/float64(KiB)) + default: + return fmt.Sprintf("%d B", n) + } +} + +func attemptStatusLabel(a PortResolutionAttempt) string { + switch a.Status { + case "found": + return "← " + a.Detail + case "error": + if a.Detail != "" { + return "error: " + a.Detail + } + return "error" + case "not-provided": + return "not provided" + case "not-set": + return "not set" + case "not-found": + return "not found" + default: + return a.Status + } +} + +func probeDoltPort(host string, port int) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + if err != nil { + return fmt.Errorf("dolt server at %s unreachable: %w", addr, err) + } + _ = conn.Close() + return nil +} + +// newDoltCleanupCmd builds the `gc dolt-cleanup` Cobra command. +// +// Top-level (not under a `dolt` parent) because the existing `dolt` pack +// binding owns that namespace. The pack's `gc dolt cleanup` script can +// delegate to this Go-side command once feature parity lands. +func newDoltCleanupCmd(stdout, stderr io.Writer) *cobra.Command { + var ( + portFlag string + jsonOut bool + probe bool + force bool + maxOrphanDBs int + ) + + cmd := &cobra.Command{ + Use: "dolt-cleanup", + Short: "Find and remove orphaned Dolt databases (Go-side core)", + Long: `gc dolt-cleanup is the Go-side implementation of the operational Dolt +cleanup tool. It resolves the Dolt server port via the AD-04 chain +(--port > city dolt.port > /.beads/dolt-server.port > 3307), +drops stale test/agent databases, calls DOLT_PURGE_DROPPED_DATABASES +to reclaim disk, and reaps orphaned dolt sql-server processes left +over from leaked test harnesses. Invalid explicit ports and unreadable +or invalid city/rig port settings fail closed before cleanup stages run; +only absent rig port files can reach the legacy default. The legacy +default is a connection fallback only; it does not protect port 3307 +from orphan-process reaping. + +Dry-run by default. Pass --force to actually drop, purge, and kill. +Pass --max-orphan-dbs with --force to refuse all destructive cleanup +stages if the live apply-time stale database count exceeds the +scan-time threshold. The default 0 disables this guard; negative values +are rejected before any city lookup or cleanup stage runs. +Active rig dolt servers, registered rig databases, active test temp roots, +and processes outside the test-config-path allowlist (/tmp/Test*, +os.TempDir()/Test*, known Gas City test prefixes, ~/.gotmp/Test*) are always +protected — see the PROTECTED section of the +report. Destructive drops are limited to known stale test database name +shapes and conservative SQL identifier characters; skipped stale matches +are reported in dropped.skipped. Rig dolt_database names used for purge +must use the same identifier shape: ASCII letters, digits, underscores, +and non-leading hyphens. Missing or silent rig metadata disables forced +drop/purge because the live database name cannot be proven safe. + +JSON envelope schema is stable: gc.dolt.cleanup.v1. Automation that +uses --json must inspect summary.errors_total and errors, and must also +refuse to invoke --force when dry-run force_blockers is non-empty. +force_blockers reports conditions that would block forced cleanup without +incrementing errors_total. The rig-protection blocker is intentionally +global: missing or silent rig metadata prevents forced drop/purge because +the command cannot prove all registered rig databases are protected. +Cleanup stage errors are reported in the envelope even when the command +can still return successfully after emitting the report.`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if maxOrphanDBs < 0 { + err := fmt.Errorf("--max-orphan-dbs must be >= 0") + if jsonOut { + report := CleanupReport{Schema: CleanupSchemaVersion} + recordCleanupErrorKind(&report, "options", cleanupErrorKindInvalidMaxOrphanDBs, "", err) + emitReport(report, PortResolution{}, cleanupOptions{JSON: true}, stdout, stderr) + } else { + fmt.Fprintf(stderr, "gc dolt-cleanup: %v\n", err) //nolint:errcheck + } + return errExit + } + cityPath, err := resolveCity() + if err != nil { + fmt.Fprintf(stderr, "gc dolt-cleanup: %v\n", err) //nolint:errcheck + return errExit + } + cfg, err := loadCityConfig(cityPath, stderr) + if err != nil { + fmt.Fprintf(stderr, "gc dolt-cleanup: %v\n", err) //nolint:errcheck + return errExit + } + rigs := loadResolverRigs(cityPath, cfg) + homeDir, _ := os.UserHomeDir() + opts := cleanupOptions{ + Flag: portFlag, + CityPort: cfg.Dolt.Port, + Rigs: rigs, + FS: fsys.OSFS{}, + JSON: jsonOut, + Probe: probe, + Force: force, + Host: cfg.Dolt.Host, + HomeDir: homeDir, + TempDir: os.TempDir(), + MaxOrphanDBs: maxOrphanDBs, + } + + // Resolve the port first so we can open a Dolt connection at the + // right address. Failed opens are reported by runDoltCleanup inside + // the typed cleanup envelope. + resolution := ResolveDoltPort(PortResolverInput{ + Flag: opts.Flag, CityPort: opts.CityPort, Rigs: opts.Rigs, FS: opts.FS, + }) + opts.PortResolution = resolution + host := opts.Host + if host == "" { + host = "127.0.0.1" + } + if fatalPortResolutionError(resolution) == nil { + client, openErr := newSQLCleanupDoltClient(host, strconv.Itoa(resolution.Port)) + if openErr != nil { + opts.DoltClientOpenErr = openErr + } else { + opts.DoltClient = client + defer client.Close() //nolint:errcheck + } + } + + if code := runDoltCleanup(opts, stdout, stderr); code != 0 { + return errExit + } + return nil + }, + } + cmd.Flags().StringVar(&portFlag, "port", "", "override the resolved Dolt port") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON envelope (gc.dolt.cleanup.v1)") + cmd.Flags().BoolVar(&probe, "probe", false, "TCP-probe the resolved port; fail if unreachable") + cmd.Flags().BoolVar(&force, "force", false, "actually drop, purge, and kill orphaned resources (default: dry-run)") + cmd.Flags().IntVar(&maxOrphanDBs, "max-orphan-dbs", 0, "with --force, refuse cleanup when live stale database count exceeds this limit") + return cmd +} + +// rigProtections projects the resolver's rig list into the JSON-envelope +// rigs_protected entries. The DB name is read from each rig's +// /.beads/metadata.json `dolt_database` field. Missing, silent, +// unreadable, or corrupt metadata is returned as an error so forced destructive +// work can fail closed instead of pretending the fallback is the live DB +// identity. Order is HQ-first to match the port-resolution preference. +func rigProtections(rigs []resolverRig, fs fsys.FS) ([]CleanupRigProtection, []rigProtectionError) { + out := make([]CleanupRigProtection, 0, len(rigs)) + var errs []rigProtectionError + for _, r := range orderRigsHQFirst(rigs) { + resolution := resolveRigDoltDatabase(r, fs) + out = append(out, CleanupRigProtection{Rig: r.Name, DB: resolution.name}) + if resolution.err != nil { + errs = append(errs, rigProtectionError{rig: r.Name, err: resolution.err}) + } + } + return out, errs +} + +type rigProtectionError struct { + rig string + err error +} + +func recordUnsafeRigDatabaseNames(report *CleanupReport) { + for _, rp := range report.RigsProtected { + if validDoltDatabaseIdentifier(rp.DB) { + continue + } + err := fmt.Errorf("rig %q dolt_database %q is not cleanup-safe", rp.Rig, rp.DB) + recordCleanupForceBlocker(report, cleanupErrorKindRigProtection, rp.Rig, err) + recordCleanupErrorKind(report, "rig", cleanupErrorKindRigProtection, rp.Rig, err) + } +} + +func hasRigProtectionError(report *CleanupReport) bool { + for _, e := range report.Errors { + if e.Kind == cleanupErrorKindRigProtection || e.Stage == "rig" { + return true + } + } + return false +} + +// rigDoltDatabaseName returns the rig's dolt database name as recorded in its +// metadata.json, falling back to rig.Name only as a report label when metadata +// is missing or silent. +func rigDoltDatabaseName(r resolverRig, fs fsys.FS) string { + return resolveRigDoltDatabase(r, fs).name +} + +type rigDoltDatabaseResolution struct { + name string + err error +} + +func resolveRigDoltDatabase(r resolverRig, fs fsys.FS) rigDoltDatabaseResolution { + if fs == nil { + return rigDoltDatabaseResolution{ + name: r.Name, + err: fmt.Errorf("missing filesystem for rig metadata; cannot verify live dolt database name"), + } + } + metadataPath := filepath.Join(r.Path, ".beads", "metadata.json") + data, err := fs.ReadFile(metadataPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return rigDoltDatabaseResolution{ + name: r.Name, + err: fmt.Errorf("missing rig metadata %s; cannot verify live dolt database name", metadataPath), + } + } + return rigDoltDatabaseResolution{ + name: r.Name, + err: fmt.Errorf("read rig metadata %s: %w", metadataPath, err), + } + } + var meta map[string]any + if err := json.Unmarshal(data, &meta); err != nil { + return rigDoltDatabaseResolution{ + name: r.Name, + err: fmt.Errorf("parse rig metadata %s: %w", metadataPath, err), + } + } + if db, ok := meta["dolt_database"]; ok { + s := strings.TrimSpace(fmt.Sprint(db)) + if s != "" && s != "" { + return rigDoltDatabaseResolution{name: s} + } + } + return rigDoltDatabaseResolution{ + name: r.Name, + err: fmt.Errorf("rig metadata %s lacks dolt_database; cannot verify live dolt database name", metadataPath), + } +} + +// loadResolverRigs builds the resolver's rig list from a city config. The HQ +// rig (the city itself) is added first so it wins the AD-04 §4.1 tie when +// multiple /.beads/dolt-server.port files exist; non-HQ rigs follow +// in city.toml order. Paths are resolved to absolute form via +// resolveRigPaths so the resolver's filesystem reads work regardless of how +// the rig was registered. +func loadResolverRigs(cityPath string, cfg *config.City) []resolverRig { + rigs := make([]config.Rig, len(cfg.Rigs)) + copy(rigs, cfg.Rigs) + resolveRigPaths(cityPath, rigs) + + out := make([]resolverRig, 0, len(rigs)+1) + out = append(out, resolverRig{ + Name: cfg.EffectiveCityName(), + Path: cityPath, + HQ: true, + }) + for _, r := range rigs { + out = append(out, resolverRig{ + Name: r.Name, + Path: r.Path, + HQ: false, + }) + } + return out +} diff --git a/cmd/gc/cmd_dolt_cleanup_test.go b/cmd/gc/cmd_dolt_cleanup_test.go new file mode 100644 index 0000000000..52c468989d --- /dev/null +++ b/cmd/gc/cmd_dolt_cleanup_test.go @@ -0,0 +1,1462 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "syscall" + "testing" + + "github.com/gastownhall/gascity/internal/fsys" +) + +func TestCleanupReportJSONShape(t *testing.T) { + r := CleanupReport{ + Schema: "gc.dolt.cleanup.v1", + Port: CleanupPortReport{ + Resolved: 28231, + Source: "/city/.beads/dolt-server.port", + Fallback: false, + }, + Dropped: CleanupDroppedReport{ + Skipped: []DoltDropSkip{{ + Name: "testdb.invalid", + Reason: DropSkipReasonInvalidIdentifier, + }}, + }, + } + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + got := string(data) + + wantKeys := []string{ + `"schema":"gc.dolt.cleanup.v1"`, + `"port":{`, + `"rigs_protected":[]`, + `"force_blockers":[]`, + `"dropped":{`, + `"purge":{`, + `"reaped":{`, + `"summary":{`, + `"errors":[]`, + `"skipped":[{"name":"testdb.invalid","reason":"invalid-identifier"}]`, + } + for _, key := range wantKeys { + if !strings.Contains(got, key) { + t.Errorf("JSON missing %q\nfull JSON:\n%s", key, got) + } + } + for _, key := range []string{`"Name":"testdb.invalid"`, `"Reason":"invalid-identifier"`} { + if strings.Contains(got, key) { + t.Errorf("JSON leaked Go field name %q\nfull JSON:\n%s", key, got) + } + } +} + +func TestDoltCleanupCmdRejectsNegativeMaxOrphanDBsBeforeCityResolution(t *testing.T) { + t.Chdir(t.TempDir()) + + var stdout, stderr bytes.Buffer + cmd := newDoltCleanupCmd(&stdout, &stderr) + cmd.SetArgs([]string{"--json", "--max-orphan-dbs", "-1"}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute succeeded; want negative --max-orphan-dbs rejected") + } + out := stdout.String() + if !strings.Contains(out, `"kind":"invalid-max-orphan-dbs"`) { + t.Fatalf("stdout missing structured max-orphan validation kind:\nstdout=%s\nstderr=%s", out, stderr.String()) + } + if strings.Contains(stderr.String(), "not in a Gas City workspace") { + t.Fatalf("negative max-orphan validation happened after city resolution:\nstdout=%s\nstderr=%s", out, stderr.String()) + } +} + +func TestRunDoltCleanupRejectsNegativeMaxOrphanDBs(t *testing.T) { + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + JSON: true, + MaxOrphanDBs: -1, + } + + code := runDoltCleanup(opts, &stdout, &stderr) + if code == 0 { + t.Fatalf("runDoltCleanup exit=0; want negative MaxOrphanDBs rejected") + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Kind != cleanupErrorKindInvalidMaxOrphanDBs || !strings.Contains(r.Errors[0].Error, "non-negative") { + t.Fatalf("Errors = %+v, want invalid max orphan validation error", r.Errors) + } +} + +func TestRunDoltCleanup_JSONOutputsResolvedPort(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + rigs := []resolverRig{{Name: "hq", Path: "/city", HQ: true}} + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Flag: "", + CityPort: 0, + Rigs: rigs, + FS: fs, + JSON: true, + Probe: false, // skip TCP probe in unit tests + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("runDoltCleanup exit=%d, stderr=%q", code, stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if r.Schema != "gc.dolt.cleanup.v1" { + t.Errorf("Schema = %q", r.Schema) + } + if r.Port.Resolved != 28231 { + t.Errorf("Port.Resolved = %d, want 28231", r.Port.Resolved) + } + if r.Port.Fallback { + t.Errorf("Port.Fallback = true, want false") + } +} + +func TestRunDoltCleanup_HumanOutputShowsPortAndFallbackWarning(t *testing.T) { + fs := fsys.NewFake() + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fs, + JSON: false, + Probe: false, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "3307") { + t.Errorf("stdout missing legacy port 3307: %s", out) + } + if !strings.Contains(strings.ToLower(out), "fallback") && !strings.Contains(strings.ToLower(out), "legacy default") { + t.Errorf("stdout missing fallback indicator: %s", out) + } +} + +func TestRunDoltCleanup_FlagOverridesEverything(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Flag: "9999", + CityPort: 4242, + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Probe: false, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d", code) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if r.Port.Resolved != 9999 { + t.Errorf("Port.Resolved = %d, want 9999", r.Port.Resolved) + } + if r.Port.Source != "--port flag" { + t.Errorf("Port.Source = %q", r.Port.Source) + } +} + +func TestRunDoltCleanup_ForceProtectsSelectedPortWithoutRigPortFile(t *testing.T) { + for _, tc := range []struct { + name string + flag string + cityPort int + wantPort int + }{ + {name: "flag", flag: "43306", cityPort: 43307, wantPort: 43306}, + {name: "city config", cityPort: 43307, wantPort: 43307}, + } { + t.Run(tc.name, func(t *testing.T) { + var killed []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Flag: tc.flag, + CityPort: tc.cityPort, + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return []DoltProcInfo{{ + PID: 4444, + Ports: []int{tc.wantPort}, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestActive/config.yaml"}, + StartTimeTicks: 10, + }}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + killed = append(killed, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Port.Resolved != tc.wantPort { + t.Fatalf("Port.Resolved = %d, want %d", r.Port.Resolved, tc.wantPort) + } + if len(killed) != 0 { + t.Fatalf("KillProcess called for selected active port: %v", killed) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 for process listening on selected port", r.Reaped.Count) + } + if !equalIntSlice(r.Reaped.ProtectedPIDs, []int{4444}) { + t.Errorf("ProtectedPIDs = %v, want [4444]", r.Reaped.ProtectedPIDs) + } + }) + } +} + +func TestRunDoltCleanup_InvalidPortFlagIsFatal(t *testing.T) { + for _, flag := range []string{"not-a-number", "0", "-1", "65536", "70000"} { + t.Run(flag, func(t *testing.T) { + client := &fakeCleanupDoltClient{ + databases: []string{"testdb_abc"}, + } + var killed []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Flag: flag, + CityPort: 4242, + FS: fsys.NewFake(), + JSON: true, + Force: true, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return []DoltProcInfo{{PID: 4444, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}}}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + killed = append(killed, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit=0, want invalid explicit --port to fail\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("DropDatabase called for invalid --port: %v", client.dropped) + } + if len(killed) != 0 { + t.Fatalf("KillProcess called for invalid --port: %v", killed) + } + foundPortError := false + for _, entry := range r.Errors { + if entry.Stage == "port" && strings.Contains(entry.Error, "invalid port") { + foundPortError = true + } + } + if !foundPortError { + t.Fatalf("Errors missing fatal port validation entry: %+v", r.Errors) + } + }) + } +} + +func TestRunDoltCleanup_InvalidCityConfigPortIsFatal(t *testing.T) { + client := &fakeCleanupDoltClient{ + databases: []string{"testdb_abc"}, + } + var killed []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + CityPort: 70000, + FS: fsys.NewFake(), + JSON: true, + Force: true, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return []DoltProcInfo{{PID: 4444, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}}}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + killed = append(killed, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit=0, want invalid city config port to fail\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("DropDatabase called for invalid city config port: %v", client.dropped) + } + if len(killed) != 0 { + t.Fatalf("KillProcess called for invalid city config port: %v", killed) + } + if r.Port.Resolved != 0 { + t.Fatalf("Port.Resolved = %d, want 0 for unresolved fatal city config port", r.Port.Resolved) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "port" || r.Errors[0].Name != "city config dolt.port" || !strings.Contains(r.Errors[0].Error, "65535") { + t.Fatalf("Errors = %+v, want fatal city config port validation error", r.Errors) + } +} + +func TestRunDoltCleanup_BadRigPortFileIsFatal(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*fsys.Fake) + wantError string + }{ + { + name: "empty", + setup: func(fs *fsys.Fake) { fs.Files["/city/.beads/dolt-server.port"] = []byte("\n") }, + wantError: "empty", + }, + { + name: "malformed", + setup: func(fs *fsys.Fake) { fs.Files["/city/.beads/dolt-server.port"] = []byte("not-a-port\n") }, + wantError: "invalid port", + }, + { + name: "out of range", + setup: func(fs *fsys.Fake) { fs.Files["/city/.beads/dolt-server.port"] = []byte("70000\n") }, + wantError: "65535", + }, + { + name: "unreadable", + setup: func(fs *fsys.Fake) { fs.Errors["/city/.beads/dolt-server.port"] = os.ErrPermission }, + wantError: "permission", + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs := fsys.NewFake() + tc.setup(fs) + client := &fakeCleanupDoltClient{ + databases: []string{"testdb_abc"}, + } + var killed []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "city", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Force: true, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return []DoltProcInfo{{PID: 4444, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}}}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + killed = append(killed, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit=0, want bad rig port file to fail closed\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("DropDatabase called after bad rig port file: %v", client.dropped) + } + if len(killed) != 0 { + t.Fatalf("KillProcess called after bad rig port file: %v", killed) + } + if r.Port.Resolved != 0 { + t.Fatalf("Port.Resolved = %d, want 0 for unresolved fatal port", r.Port.Resolved) + } + foundPortError := false + for _, entry := range r.Errors { + if entry.Stage == "port" && strings.Contains(entry.Error, tc.wantError) { + foundPortError = true + } + } + if !foundPortError { + t.Fatalf("Errors missing fatal rig port-file entry containing %q: %+v", tc.wantError, r.Errors) + } + }) + } +} + +func TestRunDoltCleanup_ForceDoesNotProtectLegacyFallbackPort(t *testing.T) { + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return []DoltProcInfo{{ + PID: 4444, + Ports: []int{LegacyDefaultDoltPort}, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestLegacyFallback/config.yaml"}, + StartTimeTicks: 10, + }}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return syscall.ESRCH + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if r.Port.Resolved != LegacyDefaultDoltPort || !r.Port.Fallback { + t.Fatalf("Port = %+v, want legacy fallback", r.Port) + } + if !equalIntSlice(r.Reaped.ProtectedPIDs, nil) { + t.Fatalf("ProtectedPIDs = %v, want none for legacy fallback test process", r.Reaped.ProtectedPIDs) + } + if len(signals) != 1 || signals[0] != syscall.SIGTERM { + t.Fatalf("signals = %v, want legacy fallback process to stay eligible for SIGTERM", signals) + } +} + +func TestRunDoltCleanup_SQLClientOpenFailureIsTypedAndFatal(t *testing.T) { + fs := fsys.NewFake() + putFakeDirTree(fs, "/city/.beads/dolt/.dolt_dropped_databases", map[string]int64{ + "dropped_db/data.bin": 4096, + }) + fs.Files["/city/.beads/metadata.json"] = []byte(`{"dolt_database":"hq"}`) + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "city", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Force: true, + DoltClientOpenErr: fmt.Errorf("open dolt connection: refused"), + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit=0, want SQL open failure to make forced cleanup fail\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if r.Summary.ErrorsTotal != 2 { + t.Fatalf("Summary.ErrorsTotal = %d, want drop and purge open errors; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + hasDrop := false + hasPurge := false + for _, entry := range r.Errors { + if strings.Contains(entry.Error, "open dolt connection: refused") { + switch entry.Stage { + case "drop": + hasDrop = true + case "purge": + hasPurge = true + } + } + } + if !hasDrop || !hasPurge { + t.Fatalf("Errors = %+v, want typed drop and purge SQL-open errors", r.Errors) + } + if r.Purge.OK { + t.Fatalf("Purge.OK = true, want false when SQL-backed purge could not run") + } + if r.Summary.BytesFreedDisk != 0 { + t.Fatalf("Summary.BytesFreedDisk = %d, want 0 because forced purge did not run", r.Summary.BytesFreedDisk) + } +} + +func TestRunDoltCleanup_RigsProtectedFromRegistry(t *testing.T) { + // Wireframe-6 schema requires rigs_protected to enumerate registered rigs. + // One entry per registered rig (HQ + non-HQ); each rig's DB name equals + // its rig name in this codebase (`gascity`, `beads`, etc.). Order is + // HQ-first to match the resolver's port-resolution preference. + fs := fsys.NewFake() + rigs := []resolverRig{ + {Name: "gascity", Path: "/city", HQ: true}, + {Name: "beads", Path: "/beads", HQ: false}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: rigs, + FS: fs, + JSON: true, + Probe: false, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + want := []CleanupRigProtection{ + {Rig: "gascity", DB: "gascity"}, + {Rig: "beads", DB: "beads"}, + } + if len(r.RigsProtected) != len(want) { + t.Fatalf("RigsProtected len = %d, want %d (got %v)", len(r.RigsProtected), len(want), r.RigsProtected) + } + for i, w := range want { + if r.RigsProtected[i] != w { + t.Errorf("RigsProtected[%d] = %+v, want %+v", i, r.RigsProtected[i], w) + } + } +} + +func TestRunDoltCleanup_DryRunReportsReapPlanWithoutKilling(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + procs := []DoltProcInfo{ + {PID: 1138290, Ports: []int{28231}, Argv: []string{"dolt", "sql-server"}}, + {PID: 1281044, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestA/config.yaml"}}, + {PID: 1319499, Ports: []int{33400}, Argv: []string{"dolt", "sql-server", "--config", "/tmp/be-s9d-bench-dolt/config.yaml"}}, + } + killed := []int{} + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Probe: false, + HomeDir: "/home/u", + // Force not set → dry-run. + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + KillProcess: func(pid int, _ syscall.Signal) error { + killed = append(killed, pid) + return nil + }, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Reaped.Count != 1 { + t.Errorf("Reaped.Count = %d, want 1 (one orphan, dry-run)", r.Reaped.Count) + } + wantProtected := []int{1138290, 1319499} + if !equalIntSlice(r.Reaped.ProtectedPIDs, wantProtected) { + t.Errorf("ProtectedPIDs = %v, want %v", r.Reaped.ProtectedPIDs, wantProtected) + } + if len(killed) != 0 { + t.Errorf("KillProcess called %d times in dry-run; want 0 (dry-run is non-destructive)", len(killed)) + } +} + +func TestRunDoltCleanup_DryRunAllowsProcessTempRootTestConfig(t *testing.T) { + procs := []DoltProcInfo{{ + PID: 1281044, + Argv: []string{"dolt", "sql-server", "--config", "/var/tmp/go-test/TestA/config.yaml"}, + }} + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + TempDir: "/var/tmp/go-test", + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Reaped.Count != 1 { + t.Errorf("Reaped.Count = %d, want 1 for os.TempDir()/Test* config", r.Reaped.Count) + } + if len(r.Reaped.ProtectedPIDs) != 0 { + t.Errorf("ProtectedPIDs = %v, want none for os.TempDir()/Test* config", r.Reaped.ProtectedPIDs) + } +} + +func TestRunDoltCleanup_ForceKillsOrphans(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + procs := []DoltProcInfo{ + {PID: 1138290, Ports: []int{28231}, Argv: []string{"dolt", "sql-server"}, StartTimeTicks: 10}, + {PID: 1281044, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestA/config.yaml"}, StartTimeTicks: 20}, + {PID: 1281099, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestB/config.yaml"}, StartTimeTicks: 30}, + } + var termed []int + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + KillProcess: func(pid int, sig syscall.Signal) error { + if sig == syscall.SIGTERM { + termed = append(termed, pid) + } + return syscall.ESRCH // pretend the process is already gone after TERM + }, + ReapGracePeriod: 1, // tiny so the test doesn't sleep meaningfully + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if r.Reaped.Count != 2 { + t.Errorf("Reaped.Count = %d, want 2", r.Reaped.Count) + } + wantTermed := []int{1281044, 1281099} + if !equalIntSlice(termed, wantTermed) { + t.Errorf("SIGTERM-ed PIDs = %v, want %v", termed, wantTermed) + } +} + +func TestRunDoltCleanup_ForceReportsReapedRSSBytes(t *testing.T) { + procs := []DoltProcInfo{ + {PID: 1281044, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestA/config.yaml"}, RSSBytes: 4096, StartTimeTicks: 20}, + {PID: 1281099, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestB/config.yaml"}, RSSBytes: 8192, StartTimeTicks: 30}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + KillProcess: func(_ int, _ syscall.Signal) error { return syscall.ESRCH }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Reaped.Count != 2 { + t.Fatalf("Reaped.Count = %d, want 2", r.Reaped.Count) + } + if r.Summary.BytesFreedRSS != 12288 { + t.Errorf("Summary.BytesFreedRSS = %d, want 12288", r.Summary.BytesFreedRSS) + } +} + +func TestRunDoltCleanup_ForceCountsSuccessfulKill(t *testing.T) { + procs := []DoltProcInfo{ + {PID: 4444, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, StartTimeTicks: 10}, + } + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if r.Reaped.Count != len(procs) { + t.Errorf("Reaped.Count = %d, want %d", r.Reaped.Count, len(procs)) + } + if r.Summary.ErrorsTotal != 0 { + t.Errorf("Summary.ErrorsTotal = %d, want 0", r.Summary.ErrorsTotal) + } + if len(r.Errors) != 0 || len(r.Reaped.Errors) != 0 { + t.Errorf("errors = %#v, reap errors = %#v; want none", r.Errors, r.Reaped.Errors) + } + if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL { + t.Errorf("signals = %v, want [SIGTERM SIGKILL]", signals) + } +} + +func TestRunDoltCleanup_ForceCountsPostSIGTERMGoneAsReaped(t *testing.T) { + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + switch discoverCalls { + case 1, 2: + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + RSSBytes: 4096, + StartTimeTicks: 10, + }}, nil + default: + return nil, nil + } + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if discoverCalls != 3 { + t.Fatalf("DiscoverProcesses calls = %d, want initial, pre-SIGTERM, pre-SIGKILL", discoverCalls) + } + if len(signals) != 1 || signals[0] != syscall.SIGTERM { + t.Fatalf("signals = %v, want [SIGTERM]", signals) + } + if r.Reaped.Count != 1 { + t.Errorf("Reaped.Count = %d, want 1 when process vanishes after our SIGTERM", r.Reaped.Count) + } + if r.Summary.BytesFreedRSS != 4096 { + t.Errorf("Summary.BytesFreedRSS = %d, want 4096", r.Summary.BytesFreedRSS) + } + if len(r.Reaped.VanishedPIDs) != 0 { + t.Errorf("VanishedPIDs = %v, want none for post-SIGTERM success", r.Reaped.VanishedPIDs) + } +} + +func TestRunDoltCleanup_ForceRevalidatesPIDBeforeSIGTERM(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + if discoverCalls == 1 { + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + }}, nil + } + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + Ports: []int{28231}, + StartTimeTicks: 10, + }}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if discoverCalls != 2 { + t.Fatalf("DiscoverProcesses called %d time(s), want initial scan plus pre-SIGTERM revalidation", discoverCalls) + } + if len(signals) != 0 { + t.Fatalf("signals = %v, want none after PID reclassified as protected before SIGTERM", signals) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because SIGTERM was skipped", r.Reaped.Count) + } + if !equalIntSlice(r.Reaped.ProtectedPIDs, []int{4444}) { + t.Errorf("ProtectedPIDs = %v, want [4444] after revalidation", r.Reaped.ProtectedPIDs) + } +} + +func TestRunDoltCleanup_ForceSkipsSignalWhenPIDStartTimeChanges(t *testing.T) { + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + if discoverCalls == 1 { + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + }}, nil + } + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 11, + }}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if discoverCalls != 2 { + t.Fatalf("DiscoverProcesses calls = %d, want 2", discoverCalls) + } + if len(signals) != 0 { + t.Fatalf("signals = %v, want none after PID start time changed", signals) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because PID identity changed", r.Reaped.Count) + } + if !equalIntSlice(r.Reaped.ProtectedPIDs, []int{4444}) { + t.Errorf("ProtectedPIDs = %v, want [4444] after PID identity changed", r.Reaped.ProtectedPIDs) + } +} + +func TestRunDoltCleanup_ForceDoesNotCountMissingPIDAfterRevalidation(t *testing.T) { + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + if discoverCalls == 1 { + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + }}, nil + } + return nil, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if discoverCalls != 2 { + t.Fatalf("DiscoverProcesses calls = %d, want 2", discoverCalls) + } + if len(signals) != 0 { + t.Fatalf("signals = %v, want none when pre-SIGTERM refresh misses the PID", signals) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because missing-on-refresh is not a confirmed kill", r.Reaped.Count) + } + if !equalIntSlice(r.Reaped.VanishedPIDs, []int{4444}) { + t.Errorf("VanishedPIDs = %v, want [4444]", r.Reaped.VanishedPIDs) + } +} + +func TestRunDoltCleanup_ForceSkipsSIGKILLWhenRevalidationDiscoverErrors(t *testing.T) { + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + if discoverCalls == 1 { + return []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + }}, nil + } + return nil, fmt.Errorf("transient /proc walk failed") + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if discoverCalls != 2 { + t.Fatalf("DiscoverProcesses calls = %d, want 2", discoverCalls) + } + if len(signals) != 0 { + t.Fatalf("signals = %v, want none when pre-SIGTERM revalidation fails", signals) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because SIGKILL was skipped", r.Reaped.Count) + } + if r.Summary.ErrorsTotal != 1 { + t.Errorf("Summary.ErrorsTotal = %d, want 1", r.Summary.ErrorsTotal) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "reap" || !strings.Contains(r.Errors[0].Error, "revalidate before SIGTERM") { + t.Fatalf("Errors = %+v, want revalidation reap error", r.Errors) + } +} + +func TestRunDoltCleanup_ForceSkipsSIGKILLWhenProcessBecomesProtected(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/.beads/dolt-server.port"] = []byte("28231\n") + + discoverCalls := 0 + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "hq", Path: "/city", HQ: true}}, + FS: fs, + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + discoverCalls++ + proc := DoltProcInfo{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + } + if discoverCalls >= 3 { + proc.Ports = []int{28231} + } + return []DoltProcInfo{proc}, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if discoverCalls != 3 { + t.Fatalf("DiscoverProcesses calls = %d, want initial, pre-SIGTERM, pre-SIGKILL", discoverCalls) + } + if len(signals) != 1 || signals[0] != syscall.SIGTERM { + t.Fatalf("signals = %v, want only SIGTERM before protected SIGKILL revalidation", signals) + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because SIGKILL was skipped", r.Reaped.Count) + } + if !equalIntSlice(r.Reaped.ProtectedPIDs, []int{4444}) { + t.Errorf("ProtectedPIDs = %v, want [4444]", r.Reaped.ProtectedPIDs) + } +} + +func TestRunDoltCleanup_ForceRecordsKillError(t *testing.T) { + procs := []DoltProcInfo{ + {PID: 4444, Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, StartTimeTicks: 10}, + } + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { return procs, nil }, + KillProcess: func(_ int, sig syscall.Signal) error { + if sig == syscall.SIGTERM { + return nil + } + return syscall.EPERM + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(r.Reaped.Errors) == 0 { + t.Errorf("Reaped.Errors empty; want non-zero kill error") + } + if r.Reaped.Count != 0 { + t.Errorf("Reaped.Count = %d, want 0 because SIGKILL failed", r.Reaped.Count) + } + if r.Summary.ErrorsTotal != 1 { + t.Errorf("Summary.ErrorsTotal = %d, want 1", r.Summary.ErrorsTotal) + } + if len(r.Errors) != 1 { + t.Fatalf("len(Errors) = %d, want 1: %#v", len(r.Errors), r.Errors) + } + if r.Errors[0].Stage != "reap" || r.Errors[0].Name != "pid 4444" || !strings.Contains(r.Errors[0].Error, "SIGKILL") { + t.Errorf("Errors[0] = %#v, want top-level reap SIGKILL error for pid 4444", r.Errors[0]) + } +} + +func TestRunDoltCleanup_RigsProtectedReadsDoltDatabaseFromMetadata(t *testing.T) { + // When a rig's metadata.json sets dolt_database, the protection entry MUST + // use that value as DB (not the rig name) so the drop step doesn't + // accidentally target a rig DB whose operator-chosen name differs from + // the rig's registered name. Falls back to rig.Name when metadata is + // missing or doesn't specify dolt_database. + fs := fsys.NewFake() + fs.Files["/city/.beads/metadata.json"] = []byte(`{"dolt_database":"hq"}`) + fs.Files["/rigs/foo/.beads/metadata.json"] = []byte(`{"dolt_database":"foo_db"}`) + fs.Files["/rigs/bar/.beads/metadata.json"] = []byte(`{"database":"sqlite"}`) // no dolt_database + // /rigs/missing has no metadata.json at all. + + rigs := []resolverRig{ + {Name: "city", Path: "/city", HQ: true}, + {Name: "foo", Path: "/rigs/foo"}, + {Name: "bar", Path: "/rigs/bar"}, + {Name: "missing", Path: "/rigs/missing"}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: rigs, + FS: fs, + JSON: true, + Probe: false, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("runDoltCleanup exit=%d, stderr=%q", code, stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + want := []CleanupRigProtection{ + {Rig: "city", DB: "hq"}, // from metadata + {Rig: "foo", DB: "foo_db"}, // from metadata + {Rig: "bar", DB: "bar"}, // metadata present but no dolt_database — fall back to rig.Name + {Rig: "missing", DB: "missing"}, // no metadata — fall back to rig.Name + } + if len(r.RigsProtected) != len(want) { + t.Fatalf("RigsProtected len = %d, want %d (got %+v)", len(r.RigsProtected), len(want), r.RigsProtected) + } + for i, w := range want { + if r.RigsProtected[i] != w { + t.Errorf("RigsProtected[%d] = %+v, want %+v", i, r.RigsProtected[i], w) + } + } +} + +func TestRunDoltCleanup_DryRunReportsUnsafeRigDatabaseName(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/rigs/foo/.beads/metadata.json"] = []byte(`{"dolt_database":"foo db"}`) + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "foo", Path: "/rigs/foo"}}, + FS: fs, + JSON: true, + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "rig" || r.Errors[0].Name != "foo" || !strings.Contains(r.Errors[0].Error, "foo db") { + t.Fatalf("Errors = %+v, want typed rig error naming unsafe dolt_database", r.Errors) + } +} + +func TestRunDoltCleanup_DryRunDoesNotCountMissingRigMetadataAsError(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/rigs/silent/.beads/metadata.json"] = []byte(`{"database":"sqlite"}`) + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{ + {Name: "missing", Path: "/rigs/missing"}, + {Name: "silent", Path: "/rigs/silent"}, + }, + FS: fs, + JSON: true, + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%s", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + + if r.Summary.ErrorsTotal != 0 { + t.Fatalf("Summary.ErrorsTotal = %d, want 0 for dry-run metadata gaps; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 0 { + t.Fatalf("Errors = %+v, want none for dry-run metadata gaps", r.Errors) + } + out := stdout.String() + for _, want := range []string{ + `"force_blockers":[`, + `"kind":"rig-protection"`, + `"name":"missing"`, + `"name":"silent"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("stdout missing dry-run force blocker %q:\n%s", want, out) + } + } +} + +func TestRunDoltCleanup_ForceDisablesDropAndPurgeWhenRigMetadataMissing(t *testing.T) { + fs := fsys.NewFake() + putFakeDirTree(fs, "/rigs/foo/.beads/dolt/.dolt_dropped_databases", map[string]int64{ + "db_a/data.bin": 100, + }) + client := &fakeCleanupDoltClient{ + databases: []string{"foo", "testdb_foo_live"}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "foo", Path: "/rigs/foo"}}, + FS: fs, + JSON: true, + Force: true, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%q", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("dropped = %v, want no forced drops when rig metadata is missing", client.dropped) + } + if client.purged != 0 { + t.Fatalf("purged = %d, want no forced purge when rig metadata is missing", client.purged) + } + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "rig" || r.Errors[0].Name != "foo" || !strings.Contains(r.Errors[0].Error, "missing") { + t.Fatalf("Errors = %+v, want missing metadata rig protection error", r.Errors) + } +} + +func TestRunDoltCleanup_ForceDisablesDropAndPurgeWhenRigMetadataLacksDoltDatabase(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/rigs/foo/.beads/metadata.json"] = []byte(`{"database":"sqlite"}`) + client := &fakeCleanupDoltClient{ + databases: []string{"foo", "testdb_foo_live"}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "foo", Path: "/rigs/foo"}}, + FS: fs, + JSON: true, + Force: true, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%q", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("dropped = %v, want no forced drops when rig metadata lacks dolt_database", client.dropped) + } + if client.purged != 0 { + t.Fatalf("purged = %d, want no forced purge when rig metadata lacks dolt_database", client.purged) + } + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "rig" || r.Errors[0].Name != "foo" || !strings.Contains(r.Errors[0].Error, "dolt_database") { + t.Fatalf("Errors = %+v, want missing dolt_database rig protection error", r.Errors) + } +} + +func TestRunDoltCleanup_ForceRefusesDropWhenApplyPlanExceedsMaxOrphanDBs(t *testing.T) { + client := &fakeCleanupDoltClient{ + databases: []string{"testdb_a", "testdb_b", "testdb_c"}, + } + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + FS: fsys.NewFake(), + JSON: true, + Force: true, + MaxOrphanDBs: 2, + DoltClient: client, + DiscoverProcesses: func() ([]DoltProcInfo, error) { return nil, nil }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%q", code, stderr.String()) + } + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("dropped = %v, want no forced drops when apply plan exceeds max", client.dropped) + } + if r.Dropped.Count != 3 || !equalStringSlice(r.Dropped.Names, []string{"testdb_a", "testdb_b", "testdb_c"}) { + t.Fatalf("Dropped = %+v, want planned drops when max-orphan guard refuses", r.Dropped) + } + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "drop" || !strings.Contains(r.Errors[0].Error, "--max-orphan-dbs") || strings.Contains(r.Errors[0].Error, "max_orphans_for_sql") { + t.Fatalf("Errors = %+v, want user-facing max orphan DB refusal", r.Errors) + } + if !strings.Contains(stdout.String(), `"kind":"max-orphan-refusal"`) { + t.Fatalf("stdout missing structured max-orphan refusal kind:\n%s", stdout.String()) + } +} + +func TestRunDoltCleanup_MaxOrphanRefusalAbortsForcedPurgeAndReap(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/rigs/foo/.beads/metadata.json"] = []byte(`{"dolt_database":"foo"}`) + putFakeDirTree(fs, "/rigs/foo/.beads/dolt/.dolt_dropped_databases", map[string]int64{ + "dropped/data.bin": 100, + }) + + client := &fakeCleanupDoltClient{ + databases: []string{"foo", "testdb_a", "testdb_b", "testdb_c"}, + } + procs := []DoltProcInfo{{ + PID: 4444, + Argv: []string{"dolt", "sql-server", "--config", "/tmp/TestX/config.yaml"}, + StartTimeTicks: 10, + }} + var signals []syscall.Signal + + var stdout, stderr bytes.Buffer + opts := cleanupOptions{ + Rigs: []resolverRig{{Name: "foo", Path: "/rigs/foo"}}, + FS: fs, + JSON: true, + Force: true, + MaxOrphanDBs: 2, + DoltClient: client, + HomeDir: "/home/u", + DiscoverProcesses: func() ([]DoltProcInfo, error) { + return procs, nil + }, + KillProcess: func(_ int, sig syscall.Signal) error { + signals = append(signals, sig) + return nil + }, + ReapGracePeriod: 1, + } + code := runDoltCleanup(opts, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d, stderr=%q", code, stderr.String()) + } + + var r CleanupReport + if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %s", err, stdout.String()) + } + if len(client.dropped) != 0 { + t.Fatalf("dropped = %v, want no forced drops when apply plan exceeds max", client.dropped) + } + if client.purged != 0 { + t.Fatalf("purged = %d, want max-orphan refusal to skip forced purge", client.purged) + } + if len(signals) != 0 { + t.Fatalf("signals = %v, want max-orphan refusal to skip forced reap", signals) + } + if r.Purge.BytesReclaimed != 0 || r.Purge.OK { + t.Fatalf("Purge = %+v, want no forced purge result after max-orphan refusal", r.Purge) + } + if r.Reaped.Count != 0 || len(r.Reaped.Targets) != 0 { + t.Fatalf("Reaped = %+v, want no forced reap result after max-orphan refusal", r.Reaped) + } + if r.Summary.BytesFreedDisk != 0 || r.Summary.BytesFreedRSS != 0 { + t.Fatalf("Summary = %+v, want no freed resources after max-orphan refusal", r.Summary) + } + if r.Summary.ErrorsTotal != 1 { + t.Fatalf("Summary.ErrorsTotal = %d, want 1; errors=%+v", r.Summary.ErrorsTotal, r.Errors) + } + if len(r.Errors) != 1 || r.Errors[0].Stage != "drop" || !strings.Contains(r.Errors[0].Error, "--max-orphan-dbs") { + t.Fatalf("Errors = %+v, want max-orphan drop refusal only", r.Errors) + } + if !strings.Contains(stdout.String(), `"kind":"max-orphan-refusal"`) { + t.Fatalf("stdout missing structured max-orphan refusal kind:\n%s", stdout.String()) + } +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalIntSlice(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/gc/cmd_dolt_config.go b/cmd/gc/cmd_dolt_config.go index 7449a894af..14c7973452 100644 --- a/cmd/gc/cmd_dolt_config.go +++ b/cmd/gc/cmd_dolt_config.go @@ -27,6 +27,7 @@ func newDoltConfigCmd(_ io.Writer, stderr io.Writer) *cobra.Command { port string dataDir string logLevel string + archiveLevel int cityPath string scopeDir string issuePrefix string @@ -39,7 +40,7 @@ func newDoltConfigCmd(_ io.Writer, stderr io.Writer) *cobra.Command { Hidden: true, Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - if err := writeManagedDoltConfigFile(configFile, host, port, dataDir, logLevel); err != nil { + if err := writeManagedDoltConfigFile(configFile, host, port, dataDir, logLevel, archiveLevel); err != nil { fmt.Fprintf(stderr, "gc dolt-config write-managed: %v\n", err) //nolint:errcheck return errExit } @@ -51,6 +52,7 @@ func newDoltConfigCmd(_ io.Writer, stderr io.Writer) *cobra.Command { writeManaged.Flags().StringVar(&port, "port", "", "listener port") writeManaged.Flags().StringVar(&dataDir, "data-dir", "", "Dolt data directory") writeManaged.Flags().StringVar(&logLevel, "log-level", "warning", "Dolt log level") + writeManaged.Flags().IntVar(&archiveLevel, "archive-level", 0, "Dolt auto_gc archive_level (0=off, 1=on)") _ = writeManaged.MarkFlagRequired("file") _ = writeManaged.MarkFlagRequired("host") _ = writeManaged.MarkFlagRequired("port") @@ -97,7 +99,7 @@ func newDoltConfigCmd(_ io.Writer, stderr io.Writer) *cobra.Command { return cmd } -func writeManagedDoltConfigFile(path, host, port, dataDir, logLevel string) error { +func writeManagedDoltConfigFile(path, host, port, dataDir, logLevel string, archiveLevel int) error { if path == "" { return fmt.Errorf("missing --file") } @@ -137,8 +139,8 @@ data_dir: %q behavior: auto_gc_behavior: enable: true - archive_level: 1 -`, logLevel, port, host, dataDir) + archive_level: %d +`, logLevel, port, host, dataDir, archiveLevel) if err := fsys.WriteFileAtomic(fsys.OSFS{}, path, []byte(content), 0o644); err != nil { return fmt.Errorf("write config file: %w", err) } diff --git a/cmd/gc/cmd_dolt_config_test.go b/cmd/gc/cmd_dolt_config_test.go index b29f1de726..b7e75c9fb5 100644 --- a/cmd/gc/cmd_dolt_config_test.go +++ b/cmd/gc/cmd_dolt_config_test.go @@ -36,7 +36,7 @@ func TestDoltConfigWriteManagedCmd(t *testing.T) { "port: 3311", "host: 127.0.0.1", `data_dir: "/tmp/city/.beads/dolt"`, - "archive_level: 1", + "archive_level: 0", "back_log: 50", "max_connections_timeout_millis: 5000", } { @@ -48,7 +48,7 @@ func TestDoltConfigWriteManagedCmd(t *testing.T) { func TestDoltConfigWriterIncludesDoctorExpectedCoreValues(t *testing.T) { configPath := filepath.Join(t.TempDir(), "packs", "dolt", "dolt-config.yaml") - if err := writeManagedDoltConfigFile(configPath, "127.0.0.1", "3311", "/tmp/city/.beads/dolt", "warning"); err != nil { + if err := writeManagedDoltConfigFile(configPath, "127.0.0.1", "3311", "/tmp/city/.beads/dolt", "warning", 0); err != nil { t.Fatalf("writeManagedDoltConfigFile: %v", err) } @@ -110,6 +110,45 @@ func testYAMLValueEqual(got, want any) bool { return false } +func TestDoltConfigWriteManagedCmd_ExplicitArchiveLevel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "packs", "dolt", "dolt-config.yaml") + var stdout, stderr bytes.Buffer + code := run([]string{ + "dolt-config", "write-managed", + "--file", configPath, + "--host", "127.0.0.1", + "--port", "3311", + "--data-dir", "/tmp/city/.beads/dolt", + "--archive-level", "1", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", configPath, err) + } + if !strings.Contains(string(data), "archive_level: 1") { + t.Fatalf("config missing archive_level: 1:\n%s", data) + } +} + +func TestWriteManagedDoltConfigFile_DefaultLogLevel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "packs", "dolt", "dolt-config.yaml") + if err := writeManagedDoltConfigFile(configPath, "127.0.0.1", "3311", "/tmp/dolt-data", "", 0); err != nil { + t.Fatalf("writeManagedDoltConfigFile: %v", err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + text := string(data) + if !strings.Contains(text, "log_level: warning") { + t.Fatalf("empty logLevel should default to warning, got:\n%s", text) + } +} + func TestDoltConfigNormalizeScopeCmd(t *testing.T) { cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "frontend") diff --git a/cmd/gc/cmd_dolt_state.go b/cmd/gc/cmd_dolt_state.go index bb398efa23..4075d2b409 100644 --- a/cmd/gc/cmd_dolt_state.go +++ b/cmd/gc/cmd_dolt_state.go @@ -289,12 +289,12 @@ func newDoltStateCmd(stdout, stderr io.Writer) *cobra.Command { resetProbe := &cobra.Command{ Use: "reset-probe", - Short: "Drop the managed Dolt health probe database", + Short: "Reset managed Dolt health probe artifacts", Hidden: true, Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { if !forceReset { - fmt.Fprintf(stderr, "gc dolt-state reset-probe: refusing to drop %s without --force; this database may contain a legacy bead store in old metadata\n", managedDoltProbeDatabase) //nolint:errcheck + fmt.Fprintf(stderr, "gc dolt-state reset-probe: refusing to reset health probe artifacts without --force; %s may contain a legacy bead store in old metadata\n", managedDoltProbeDatabase) //nolint:errcheck return errExit } if err := managedDoltResetProbe(hostText, portText, userText); err != nil { @@ -307,7 +307,7 @@ func newDoltStateCmd(stdout, stderr io.Writer) *cobra.Command { resetProbe.Flags().StringVar(&hostText, "host", "", "Dolt host") resetProbe.Flags().StringVar(&portText, "port", "", "Dolt port") resetProbe.Flags().StringVar(&userText, "user", "", "Dolt user") - resetProbe.Flags().BoolVar(&forceReset, "force", false, "acknowledge dropping the managed probe database") + resetProbe.Flags().BoolVar(&forceReset, "force", false, "acknowledge dropping the legacy probe database and GC-owned probe table") _ = resetProbe.MarkFlagRequired("port") cmd.AddCommand(resetProbe) diff --git a/cmd/gc/cmd_dolt_state_test.go b/cmd/gc/cmd_dolt_state_test.go index 2b64a257d4..90c33b1fc4 100644 --- a/cmd/gc/cmd_dolt_state_test.go +++ b/cmd/gc/cmd_dolt_state_test.go @@ -520,6 +520,203 @@ func TestDoltStateAllocatePortCmdRepairsStoppedProviderStateFromOwnedLivePortHol } } +func TestDoltStateAllocatePortCmdRepairsMissingProviderStateFromPublishedHint(t *testing.T) { + cityPath := t.TempDir() + stateFile := filepath.Join(t.TempDir(), "dolt-provider-state.json") + layout, err := resolveManagedDoltRuntimeLayout(cityPath) + if err != nil { + t.Fatalf("resolveManagedDoltRuntimeLayout: %v", err) + } + if err := os.MkdirAll(layout.DataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(data dir): %v", err) + } + + port := reserveRandomTCPPort(t) + listener := startTCPListenerProcessInDir(t, port, layout.DataDir) + defer func() { + _ = listener.Process.Kill() + _ = listener.Wait() + }() + + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityPath), doltRuntimeState{ + Running: false, + PID: 0, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(published): %v", err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "allocate-port", "--city", cityPath, "--state-file", stateFile}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != strconv.Itoa(port) { + t.Fatalf("allocate-port = %q, want %d", got, port) + } + + state, err := readDoltRuntimeStateFile(stateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if !state.Running { + t.Fatalf("repaired state running = false, want true") + } + if state.Port != port { + t.Fatalf("repaired state port = %d, want %d", state.Port, port) + } + if state.PID != listener.Process.Pid { + t.Fatalf("repaired state pid = %d, want %d", state.PID, listener.Process.Pid) + } + + if _, err := os.Stat(layout.StateFile); !os.IsNotExist(err) { + t.Fatalf("canonical provider state was touched for non-canonical --state-file: %v", err) + } +} + +func TestDoltStateAllocatePortCmdRepairsMissingCanonicalProviderStateFromPublishedHint(t *testing.T) { + cityPath := t.TempDir() + layout, err := resolveManagedDoltRuntimeLayout(cityPath) + if err != nil { + t.Fatalf("resolveManagedDoltRuntimeLayout: %v", err) + } + if err := os.MkdirAll(layout.DataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(data dir): %v", err) + } + + port := reserveRandomTCPPort(t) + listener := startTCPListenerProcessInDir(t, port, layout.DataDir) + defer func() { + _ = listener.Process.Kill() + _ = listener.Wait() + }() + + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityPath), doltRuntimeState{ + Running: false, + PID: 0, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(published): %v", err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "allocate-port", "--city", cityPath}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != strconv.Itoa(port) { + t.Fatalf("allocate-port = %q, want %d", got, port) + } + + state, err := readDoltRuntimeStateFile(layout.StateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if !state.Running { + t.Fatalf("repaired state running = false, want true") + } + if state.Port != port { + t.Fatalf("repaired state port = %d, want %d", state.Port, port) + } + if state.PID != listener.Process.Pid { + t.Fatalf("repaired state pid = %d, want %d", state.PID, listener.Process.Pid) + } +} + +func TestDoltStateAllocatePortCmdRepairsStaleWrongPortProviderStateFromPublishedHint(t *testing.T) { + cityPath := t.TempDir() + stateFile := filepath.Join(t.TempDir(), "dolt-provider-state.json") + layout, err := resolveManagedDoltRuntimeLayout(cityPath) + if err != nil { + t.Fatalf("resolveManagedDoltRuntimeLayout: %v", err) + } + if err := os.MkdirAll(layout.DataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(data dir): %v", err) + } + + stalePort := reserveRandomTCPPort(t) + if err := writeDoltRuntimeStateFile(stateFile, doltRuntimeState{ + Running: true, + PID: 999999, + Port: stalePort, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(provider): %v", err) + } + + port := reserveRandomTCPPort(t) + listener := startTCPListenerProcessInDir(t, port, layout.DataDir) + defer func() { + _ = listener.Process.Kill() + _ = listener.Wait() + }() + + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityPath), doltRuntimeState{ + Running: false, + PID: 0, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(published): %v", err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "allocate-port", "--city", cityPath, "--state-file", stateFile}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != strconv.Itoa(port) { + t.Fatalf("allocate-port = %q, want %d", got, port) + } + + state, err := readDoltRuntimeStateFile(stateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if !state.Running { + t.Fatalf("repaired state running = false, want true") + } + if state.Port != port { + t.Fatalf("repaired state port = %d, want %d", state.Port, port) + } + if state.PID != listener.Process.Pid { + t.Fatalf("repaired state pid = %d, want %d", state.PID, listener.Process.Pid) + } + if _, err := os.Stat(layout.StateFile); !os.IsNotExist(err) { + t.Fatalf("canonical provider state was touched for non-canonical --state-file: %v", err) + } +} + +func TestDoltStateAllocatePortCmdIgnoresMalformedPublishedHint(t *testing.T) { + cityPath := t.TempDir() + stateFile := filepath.Join(t.TempDir(), "dolt-provider-state.json") + publishedPath := managedDoltStatePath(cityPath) + if err := os.MkdirAll(filepath.Dir(publishedPath), 0o755); err != nil { + t.Fatalf("MkdirAll(published dir): %v", err) + } + if err := os.WriteFile(publishedPath, []byte("{not-json"), 0o644); err != nil { + t.Fatalf("write malformed published hint: %v", err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "allocate-port", "--city", cityPath, "--state-file", stateFile}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stderr = %s", code, stderr.String()) + } + if _, err := strconv.Atoi(strings.TrimSpace(stdout.String())); err != nil { + t.Fatalf("allocate-port output %q is not a port: %v", stdout.String(), err) + } + if _, err := os.Stat(stateFile); !os.IsNotExist(err) { + t.Fatalf("provider state was written from malformed hint: %v", err) + } +} + func TestDoltStateAllocatePortCmdSkipsOccupiedSeedPort(t *testing.T) { cityPath := t.TempDir() @@ -1358,27 +1555,31 @@ while True: func startUnixSocketProcess(t *testing.T, socketPath string) *exec.Cmd { t.Helper() + readyPath := filepath.Join(t.TempDir(), "ready") proc := exec.Command("python3", "-c", ` import os import socket import sys import time path = sys.argv[1] +ready_path = sys.argv[2] if os.path.exists(path): os.remove(path) sock = socket.socket(socket.AF_UNIX) sock.bind(path) sock.listen(1) +with open(ready_path, "w") as f: + f.write("ready\n") while True: time.sleep(1) -`, socketPath) +`, socketPath, readyPath) if err := proc.Start(); err != nil { t.Fatalf("start unix socket process: %v", err) } deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(socketPath); err == nil { - if open, openErr := fileOpenedByAnyProcess(socketPath); openErr == nil && open { + if _, readyErr := os.Stat(readyPath); readyErr == nil { return proc } } @@ -1396,24 +1597,28 @@ func startOpenFileProcess(t *testing.T, path string) *exec.Cmd { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } + readyPath := filepath.Join(t.TempDir(), "ready") proc := exec.Command("python3", "-c", ` import os import sys import time path = sys.argv[1] +ready_path = sys.argv[2] f = open(path, "a+") f.write("held") f.flush() +with open(ready_path, "w") as f_ready: + f_ready.write("ready\n") while True: time.sleep(1) -`, path) +`, path, readyPath) if err := proc.Start(); err != nil { t.Fatalf("start open-file process: %v", err) } deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(path); err == nil { - if open, openErr := fileOpenedByAnyProcess(path); openErr == nil && open { + if _, readyErr := os.Stat(readyPath); readyErr == nil { return proc } } @@ -1431,6 +1636,7 @@ func startOpenFileAndTCPListenerProcess(t *testing.T, path string, port int, dir if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } + readyPath := filepath.Join(t.TempDir(), "ready") proc := exec.Command("python3", "-c", ` import os import signal @@ -1439,6 +1645,7 @@ import sys import time path = sys.argv[1] port = int(sys.argv[2]) +ready_path = sys.argv[3] f = open(path, "a+") f.write("held") f.flush() @@ -1446,13 +1653,15 @@ sock = socket.socket() sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("127.0.0.1", port)) sock.listen(5) +with open(ready_path, "w") as f_ready: + f_ready.write("ready\n") def _stop(*_args): raise SystemExit(0) signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGINT, _stop) while True: time.sleep(1) -`, path, strconv.Itoa(port)) +`, path, strconv.Itoa(port), readyPath) if strings.TrimSpace(dir) != "" { proc.Dir = dir } @@ -1462,7 +1671,7 @@ while True: deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(path); err == nil { - if open, openErr := fileOpenedByAnyProcess(path); openErr == nil && open { + if _, readyErr := os.Stat(readyPath); readyErr == nil { conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond) if err == nil { _ = conn.Close() @@ -1569,8 +1778,20 @@ func TestDoltStateReadOnlyCheckCmdDetectsReadOnly(t *testing.T) { writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh set -eu printf '%s\n' "$*" >> "$INVOCATION_FILE" -echo 'database is read only' >&2 -exit 1 +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\ninformation_schema\nmysql\ndolt_cluster\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) + echo 'database is read only' >&2 + exit 1 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac `) t.Setenv("INVOCATION_FILE", invocationFile) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -1584,8 +1805,14 @@ exit 1 if err != nil { t.Fatalf("ReadFile(invocation): %v", err) } - assertNoManagedDoltProbeDrop(t, "read-only-check invocation", string(invocation)) - assertManagedDoltProbeWrites(t, "read-only-check invocation", string(invocation)) + text := string(invocation) + bt := "`" + assertNoManagedDoltProbeDrop(t, "read-only-check invocation", text) + assertNoManagedDoltProbeLegacyTarget(t, "read-only-check invocation", text) + wantWrite := "REPLACE INTO " + bt + "gascity" + bt + "." + bt + managedDoltProbeTable + bt + " VALUES (1)" + if !strings.Contains(text, wantWrite) { + t.Fatalf("read-only-check invocation = %s, want %q", text, wantWrite) + } } func TestDoltStateReadOnlyCheckCmdReturnsErrExitWhenWritable(t *testing.T) { @@ -1594,7 +1821,15 @@ func TestDoltStateReadOnlyCheckCmdReturnsErrExitWhenWritable(t *testing.T) { writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh set -eu printf '%s\n' "$*" >> "$INVOCATION_FILE" -exit 0 +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\n' + exit 0 + ;; + *) + exit 0 + ;; +esac `) t.Setenv("INVOCATION_FILE", invocationFile) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -1604,6 +1839,52 @@ exit 0 if code != 1 { t.Fatalf("run() = %d, want 1; stderr = %s", code, stderr.String()) } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + assertNoManagedDoltProbeLegacyTarget(t, "read-only-check writable invocation", string(invocation)) +} + +func TestDoltStateReadOnlyCheckCmdNoUserDatabaseReturnsDiagnostic(t *testing.T) { + binDir := t.TempDir() + invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") + writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$INVOCATION_FILE" +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ninformation_schema\nmysql\ndolt_cluster\nperformance_schema\nsys\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) + echo "unexpected write probe without a user database" >&2 + exit 2 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac +`) + t.Setenv("INVOCATION_FILE", invocationFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "read-only-check", "--host", "127.0.0.1", "--port", "3311", "--user", "root"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("run() = %d, want 1; stdout = %s stderr = %s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "no user database") { + t.Fatalf("stderr = %q, want no-user-database diagnostic", stderr.String()) + } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + if strings.Contains(string(invocation), "CREATE TABLE IF NOT EXISTS") { + t.Fatalf("read-only-check ran write probe without user database:\n%s", invocation) + } } func TestDoltStateResetProbeCmdDropsManagedProbeDatabase(t *testing.T) { @@ -1612,7 +1893,22 @@ func TestDoltStateResetProbeCmdDropsManagedProbeDatabase(t *testing.T) { writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh set -eu printf '%s\n' "$*" >> "$INVOCATION_FILE" -exit 0 +case "$*" in + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\ninformation_schema\nbeads\n__gc_probe\n' + exit 0 + ;; + *"DROP DATABASE IF EXISTS __gc_probe"*) + exit 0 + ;; + *"DROP TABLE IF EXISTS"*"__gc_read_only_probe"*) + exit 0 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac `) t.Setenv("INVOCATION_FILE", invocationFile) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -1630,6 +1926,11 @@ exit 0 if !strings.Contains(text, "DROP DATABASE IF EXISTS "+managedDoltProbeDatabase) { t.Fatalf("reset-probe invocation = %s, want managed probe drop", text) } + for _, want := range []string{"DROP TABLE IF EXISTS `gascity`.`" + managedDoltProbeTable + "`", "DROP TABLE IF EXISTS `beads`.`" + managedDoltProbeTable + "`"} { + if !strings.Contains(text, want) { + t.Fatalf("reset-probe invocation = %s, want %q", text, want) + } + } } func TestDoltStateResetProbeCmdRequiresForce(t *testing.T) { @@ -1638,7 +1939,8 @@ func TestDoltStateResetProbeCmdRequiresForce(t *testing.T) { if code != 1 { t.Fatalf("run() = %d, want 1; stderr = %s", code, stderr.String()) } - if !strings.Contains(stderr.String(), "refusing to drop "+managedDoltProbeDatabase+" without --force") || + if !strings.Contains(stderr.String(), "refusing to reset health probe artifacts without --force") || + !strings.Contains(stderr.String(), managedDoltProbeDatabase) || !strings.Contains(stderr.String(), "legacy bead store") { t.Fatalf("stderr = %q, want force warning with legacy bead store context", stderr.String()) } @@ -1692,7 +1994,11 @@ case "$*" in *"sql -q SELECT active_branch()"*) exit 0 ;; - *"sql -q CREATE DATABASE IF NOT EXISTS __gc_probe; CREATE TABLE IF NOT EXISTS __gc_probe.__probe (k INT PRIMARY KEY); REPLACE INTO __gc_probe.__probe VALUES (1);"*) + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\ninformation_schema\nmysql\ndolt_cluster\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) echo 'database is read only' >&2 exit 1 ;; @@ -1730,14 +2036,74 @@ esac } text := string(invocation) assertNoManagedDoltProbeDrop(t, "health-check read-only probe", text) - assertManagedDoltProbeWrites(t, "health-check read-only probe", text) - for _, want := range []string{"--host 127.0.0.1", "--port 3311", "--user root", "SELECT active_branch()", "information_schema.PROCESSLIST"} { + assertNoManagedDoltProbeLegacyTarget(t, "health-check read-only probe", text) + bt := "`" + wantWrite := "REPLACE INTO " + bt + "gascity" + bt + "." + bt + managedDoltProbeTable + bt + " VALUES (1)" + if !strings.Contains(text, wantWrite) { + t.Fatalf("health-check probe = %s, want %q", text, wantWrite) + } + for _, want := range []string{"--host 127.0.0.1", "--port 3311", "--user root", "SELECT active_branch()", "information_schema.PROCESSLIST", "SHOW DATABASES"} { if strings.Contains(text, want) == false { t.Fatalf("dolt invocation missing %q: %s", want, text) } } } +func TestDoltStateHealthCheckCmdNoUserDatabaseReportsUnknown(t *testing.T) { + binDir := t.TempDir() + invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") + writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$INVOCATION_FILE" +case "$*" in + *"sql -q SELECT active_branch()"*) + exit 0 + ;; + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ninformation_schema\nmysql\ndolt_cluster\nperformance_schema\nsys\n__gc_probe\n' + exit 0 + ;; + *"sql -r csv -q SELECT COUNT(*) AS cnt FROM information_schema.PROCESSLIST"*) + printf 'cnt\n0\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*) + echo "unexpected write probe without a user database" >&2 + exit 2 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac +`) + t.Setenv("INVOCATION_FILE", invocationFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "health-check", "--host", "0.0.0.0", "--port", "3311", "--user", "root", "--check-read-only"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stdout = %s stderr = %s", code, stdout.String(), stderr.String()) + } + got := parseDoltStateOutput(t, stdout.String()) + if got["query_ready"] != "true" { + t.Fatalf("query_ready = %q, want true", got["query_ready"]) + } + if got["read_only"] != "unknown" { + t.Fatalf("read_only = %q, want unknown", got["read_only"]) + } + if got["connection_count"] != "0" { + t.Fatalf("connection_count = %q, want 0", got["connection_count"]) + } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + if strings.Contains(string(invocation), "CREATE TABLE IF NOT EXISTS") { + t.Fatalf("health-check ran write probe without user database:\n%s", invocation) + } +} + func TestDoltStateHealthCheckCmdSkipsReadOnlyAndBestEffortCount(t *testing.T) { binDir := t.TempDir() invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") @@ -1777,9 +2143,13 @@ esac t.Fatalf("ReadFile(invocation): %v", err) } text := string(invocation) - if strings.Contains(text, "CREATE DATABASE IF NOT EXISTS __gc_probe") { + if strings.Contains(text, "CREATE TABLE IF NOT EXISTS") && strings.Contains(text, managedDoltProbeTable) { t.Fatalf("health-check unexpectedly ran read-only probe: %s", text) } + if strings.Contains(text, "SHOW DATABASES") { + t.Fatalf("health-check unexpectedly enumerated databases without --check-read-only: %s", text) + } + assertNoManagedDoltProbeLegacyTarget(t, "health-check skip-read-only probe", text) for _, want := range []string{"SELECT active_branch()", "information_schema.PROCESSLIST"} { if strings.Contains(text, want) == false { t.Fatalf("dolt invocation missing %q: %s", want, text) @@ -1816,7 +2186,11 @@ case "$*" in *"sql -q SELECT active_branch()"*) exit 0 ;; - *"sql -q CREATE DATABASE IF NOT EXISTS __gc_probe; CREATE TABLE IF NOT EXISTS __gc_probe.__probe (k INT PRIMARY KEY); REPLACE INTO __gc_probe.__probe VALUES (1);"*) + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) echo 'probe exploded' >&2 exit 1 ;; @@ -2177,7 +2551,7 @@ set -eu printf '%s\n' "$*" >> "$INVOCATION_FILE" case "$*" in "sql-server --config "*) - config_file=${*#sql-server --config } + config_file=$3 port=$(awk '/port:/ {print $2; exit}' "$config_file") data_dir=$(awk '/data_dir:/ {print $2; exit}' "$config_file" | tr -d '"') exec python3 - "$port" "$data_dir" <<'INNERPY' @@ -2209,7 +2583,11 @@ INNERPY *"SELECT active_branch()"*) exit 0 ;; - *"CREATE DATABASE IF NOT EXISTS __gc_probe;"*) + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) if [ -f "$READ_ONLY_ONCE" ]; then rm -f "$READ_ONLY_ONCE" echo "read only" >&2 @@ -2233,7 +2611,7 @@ esac }) var stdout, stderr bytes.Buffer - code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "1000"}, &stdout, &stderr) + code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "5000"}, &stdout, &stderr) if code != 0 { t.Fatalf("run() = %d, stdout = %s stderr = %s", code, stdout.String(), stderr.String()) } @@ -2278,6 +2656,127 @@ esac } } +func TestDoltStateRecoverManagedCmdNoUserDatabaseHealthSucceeds(t *testing.T) { + skipSlowCmdGCTest(t, "spawns managed dolt recovery processes; run make test-cmd-gc-process for full coverage") + cityPath := t.TempDir() + layout, err := resolveManagedDoltRuntimeLayout(cityPath) + if err != nil { + t.Fatalf("resolveManagedDoltRuntimeLayout: %v", err) + } + if err := os.MkdirAll(filepath.Dir(layout.PIDFile), 0o755); err != nil { + t.Fatalf("MkdirAll(runtime dir): %v", err) + } + if err := os.MkdirAll(layout.DataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(data dir): %v", err) + } + + port := reserveRandomTCPPort(t) + original := startTCPListenerProcessInDir(t, port, layout.DataDir) + defer func() { + _ = original.Process.Kill() + _ = original.Wait() + }() + if err := os.WriteFile(layout.PIDFile, []byte(strconv.Itoa(original.Process.Pid)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(pid): %v", err) + } + if err := writeDoltRuntimeStateFile(layout.StateFile, doltRuntimeState{ + Running: true, + PID: original.Process.Pid, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile: %v", err) + } + + binDir := t.TempDir() + invocationFile := filepath.Join(t.TempDir(), "dolt-invocation.txt") + writeFakeDoltSQLBinary(t, binDir, invocationFile, `#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$INVOCATION_FILE" +case "$*" in + "sql-server --config "*) + config_file=$3 + port=$(awk '/port:/ {print $2; exit}' "$config_file") + data_dir=$(awk '/data_dir:/ {print $2; exit}' "$config_file" | tr -d '"') + exec python3 - "$port" "$data_dir" <<'INNERPY' +import os +import signal +import socket +import sys +import time + +port = int(sys.argv[1]) +data_dir = sys.argv[2] +if data_dir: + os.chdir(data_dir) +sock = socket.socket() +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(("127.0.0.1", port)) +sock.listen(5) +def _stop(*_args): + raise SystemExit(0) +signal.signal(signal.SIGTERM, _stop) +signal.signal(signal.SIGINT, _stop) +while True: + time.sleep(1) +INNERPY + ;; + *"SELECT COUNT(*) AS cnt FROM information_schema.PROCESSLIST"*) + printf 'cnt\n0\n' + ;; + *"SELECT active_branch()"*) + exit 0 + ;; + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ninformation_schema\nmysql\ndolt_cluster\nperformance_schema\nsys\n__gc_probe\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*) + echo "unexpected write probe without a user database" >&2 + exit 2 + ;; + *) + echo "unexpected command: $*" >&2 + exit 2 + ;; +esac +`) + t.Setenv("INVOCATION_FILE", invocationFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Cleanup(func() { + if state, err := readDoltRuntimeStateFile(layout.StateFile); err == nil && state.PID > 0 { + _ = terminateManagedDoltPID(state.PID) + } + }) + + var stdout, stderr bytes.Buffer + code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "5000"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("run() = %d, stdout = %s stderr = %s", code, stdout.String(), stderr.String()) + } + got := parseDoltStateOutput(t, stdout.String()) + if got["diagnosed_read_only"] != "false" { + t.Fatalf("diagnosed_read_only = %q, want false", got["diagnosed_read_only"]) + } + if got["had_pid"] != "true" { + t.Fatalf("had_pid = %q, want true", got["had_pid"]) + } + if got["ready"] != "true" { + t.Fatalf("ready = %q, want true", got["ready"]) + } + if got["healthy"] != "true" { + t.Fatalf("healthy = %q, want true", got["healthy"]) + } + invocation, err := os.ReadFile(invocationFile) + if err != nil { + t.Fatalf("ReadFile(invocation): %v", err) + } + if strings.Contains(string(invocation), "CREATE TABLE IF NOT EXISTS") { + t.Fatalf("recover-managed ran write probe without user database:\n%s", invocation) + } +} + func TestRecoverManagedDoltProcessReturnsWhenConcurrentStarterBecomesReady(t *testing.T) { cityPath := t.TempDir() layout, err := resolveManagedDoltRuntimeLayout(cityPath) @@ -2534,7 +3033,7 @@ func TestDoltStateRecoverManagedCmdClearsPublishedStateWhenPreflightCleanupFails defer func() { managedDoltPreflightCleanupFn = oldPreflight }() var stdout, stderr bytes.Buffer - code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "1000"}, &stdout, &stderr) + code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "5000"}, &stdout, &stderr) if code != 1 { t.Fatalf("run() = %d, want 1; stdout = %s stderr = %s", code, stdout.String(), stderr.String()) } @@ -2606,7 +3105,7 @@ set -eu printf '%s\n' "$*" >> "$INVOCATION_FILE" case "$*" in "sql-server --config "*) - config_file=${*#sql-server --config } + config_file=$3 port=$(awk '/port:/ {print $2; exit}' "$config_file") data_dir=$(awk '/data_dir:/ {print $2; exit}' "$config_file" | tr -d '"') exec python3 - "$port" "$data_dir" <<'INNERPY' @@ -2648,7 +3147,11 @@ INNERPY echo "final health probe failed" >&2 exit 1 ;; - *"CREATE DATABASE IF NOT EXISTS __gc_probe;"*) + *"sql -r csv -q SHOW DATABASES"*) + printf 'Database\ngascity\n' + exit 0 + ;; + *"CREATE TABLE IF NOT EXISTS"*"__gc_read_only_probe"*) exit 0 ;; *) @@ -2667,7 +3170,7 @@ esac }) var stdout, stderr bytes.Buffer - code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "1000"}, &stdout, &stderr) + code := run([]string{"dolt-state", "recover-managed", "--city", cityPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--user", "root", "--timeout-ms", "5000"}, &stdout, &stderr) if code != 1 { t.Fatalf("run() = %d, want 1; stdout = %s stderr = %s", code, stdout.String(), stderr.String()) } diff --git a/cmd/gc/cmd_events.go b/cmd/gc/cmd_events.go index 8a089ffa60..bd0b06e199 100644 --- a/cmd/gc/cmd_events.go +++ b/cmd/gc/cmd_events.go @@ -40,6 +40,31 @@ type eventsAPITransportError struct { err error } +type cliWireEvent struct { + Actor string `json:"actor"` + Message string `json:"message,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Seq int64 `json:"seq"` + Subject string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` +} + +type cliWireTaggedEvent struct { + Actor string `json:"actor"` + City string `json:"city"` + Message string `json:"message,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Seq int64 `json:"seq"` + Subject string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` +} + +type cliEventEnvelope = cliWireEvent + +type cliTaggedEventEnvelope = cliWireTaggedEvent + func (e *eventsAPIError) Error() string { if e == nil { return "request failed" @@ -498,7 +523,7 @@ func doEventsSeq(scope eventsAPIScope, stdout, stderr io.Writer) int { return 0 } -func readLocalCityEvents(scope eventsAPIScope, apiErr error, typeFilter, sinceFlag string, warningWriter io.Writer) ([]genclient.WireEvent, bool, error) { +func readLocalCityEvents(scope eventsAPIScope, apiErr error, typeFilter, sinceFlag string, warningWriter io.Writer) ([]cliWireEvent, bool, error) { if !shouldUseLocalCityEventsFallback(scope, apiErr) { return nil, false, nil } @@ -512,7 +537,7 @@ func readLocalCityEvents(scope eventsAPIScope, apiErr error, typeFilter, sinceFl if err != nil { return nil, true, fmt.Errorf("reading local city events: %w", err) } - items := make([]genclient.WireEvent, 0, len(all)) + items := make([]cliWireEvent, 0, len(all)) for _, item := range all { items = append(items, localWireEvent(item, warningWriter)) } @@ -603,30 +628,49 @@ func eventsSinceCutoff(sinceFlag string) (time.Time, error) { return time.Now().Add(-d), nil } -func localWireEvent(e events.Event, warningWriter io.Writer) genclient.WireEvent { - item := genclient.WireEvent{ +func localWireEvent(e events.Event, _ io.Writer) cliWireEvent { + item := cliWireEvent{ Actor: e.Actor, Seq: int64(e.Seq), Ts: e.Ts, Type: e.Type, } if e.Subject != "" { - item.Subject = &e.Subject + item.Subject = e.Subject } if e.Message != "" { - item.Message = &e.Message + item.Message = e.Message } if len(e.Payload) > 0 && string(e.Payload) != "null" { - var payload genclient.EventPayload - if err := payload.UnmarshalJSON(e.Payload); err == nil { - item.Payload = &payload - } else if warningWriter != nil { - fmt.Fprintf(warningWriter, "gc events: warning: decoding local event payload for seq %d type %s: %v\n", e.Seq, e.Type, err) //nolint:errcheck // best-effort stderr - } + item.Payload = append(json.RawMessage(nil), e.Payload...) } return item } +func cityWireEventFromTyped(item genclient.TypedEventStreamEnvelope) (cliWireEvent, error) { + data, err := json.Marshal(item) + if err != nil { + return cliWireEvent{}, err + } + var out cliWireEvent + if err := json.Unmarshal(data, &out); err != nil { + return cliWireEvent{}, err + } + return out, nil +} + +func supervisorWireEventFromTyped(item genclient.TypedTaggedEventStreamEnvelope) (cliWireTaggedEvent, error) { + data, err := json.Marshal(item) + if err != nil { + return cliWireTaggedEvent{}, err + } + var out cliWireTaggedEvent + if err := json.Unmarshal(data, &out); err != nil { + return cliWireTaggedEvent{}, err + } + return out, nil +} + func doEventsFollow(scope eventsAPIScope, typeFilter string, payloadMatch map[string][]string, afterSeq uint64, afterCursor string, stdout, stderr io.Writer) int { if scope.localOnly { printStreamingCityAPIRequirement("--follow", stderr) @@ -747,9 +791,9 @@ func probeCityEventsReachable(ctx context.Context, client *genclient.ClientWithR return eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) } -func fetchCityEvents(ctx context.Context, client *genclient.ClientWithResponses, cityName, typeFilter, sinceFlag string) ([]genclient.WireEvent, error) { +func fetchCityEvents(ctx context.Context, client *genclient.ClientWithResponses, cityName, typeFilter, sinceFlag string) ([]cliWireEvent, error) { limit := int64(500) - var all []genclient.WireEvent + var all []cliWireEvent var cursor *string for { @@ -773,7 +817,13 @@ func fetchCityEvents(ctx context.Context, client *genclient.ClientWithResponses, if resp.JSON200 == nil || resp.JSON200.Items == nil { return all, nil } - all = append(all, *resp.JSON200.Items...) + for _, item := range *resp.JSON200.Items { + wire, err := cityWireEventFromTyped(item) + if err != nil { + return nil, fmt.Errorf("decoding city event list item: %w", err) + } + all = append(all, wire) + } if resp.JSON200.NextCursor == nil || strings.TrimSpace(*resp.JSON200.NextCursor) == "" { return all, nil } @@ -802,7 +852,7 @@ func fetchCityHeadIndex(ctx context.Context, client *genclient.ClientWithRespons return index, nil } -func fetchSupervisorEvents(ctx context.Context, client *genclient.ClientWithResponses, typeFilter, sinceFlag string) ([]genclient.WireTaggedEvent, error) { +func fetchSupervisorEvents(ctx context.Context, client *genclient.ClientWithResponses, typeFilter, sinceFlag string) ([]cliWireTaggedEvent, error) { return fetchSupervisorEventsWithLimit(ctx, client, typeFilter, sinceFlag, 0) } @@ -811,7 +861,7 @@ func fetchSupervisorEvents(ctx context.Context, client *genclient.ClientWithResp // most recent `limit` events. Used by fetchSupervisorHeadCursor so // computing the head cursor is a cheap round-trip instead of downloading // every event in the supervisor's history. -func fetchSupervisorEventsWithLimit(ctx context.Context, client *genclient.ClientWithResponses, typeFilter, sinceFlag string, limit int64) ([]genclient.WireTaggedEvent, error) { +func fetchSupervisorEventsWithLimit(ctx context.Context, client *genclient.ClientWithResponses, typeFilter, sinceFlag string, limit int64) ([]cliWireTaggedEvent, error) { params := &genclient.GetV0EventsParams{} if strings.TrimSpace(typeFilter) != "" { params.Type = &typeFilter @@ -830,9 +880,17 @@ func fetchSupervisorEventsWithLimit(ctx context.Context, client *genclient.Clien return nil, err } if resp.JSON200 == nil || resp.JSON200.Items == nil { - return []genclient.WireTaggedEvent{}, nil + return []cliWireTaggedEvent{}, nil + } + items := make([]cliWireTaggedEvent, 0, len(*resp.JSON200.Items)) + for _, item := range *resp.JSON200.Items { + wire, err := supervisorWireEventFromTyped(item) + if err != nil { + return nil, fmt.Errorf("decoding supervisor event list item: %w", err) + } + items = append(items, wire) } - return *resp.JSON200.Items, nil + return items, nil } // fetchSupervisorHeadCursor asks the supervisor for its current head @@ -878,14 +936,14 @@ func eventsListError(statusCode int, problem *genclient.ErrorModel) error { func printJSONLines(items any, stdout, stderr io.Writer) int { switch typed := items.(type) { - case []genclient.WireEvent: + case []cliWireEvent: for _, item := range typed { if err := writeJSONLValue(stdout, item); err != nil { fmt.Fprintf(stderr, "gc events: marshal: %v\n", err) //nolint:errcheck return 1 } } - case []genclient.WireTaggedEvent: + case []cliWireTaggedEvent: for _, item := range typed { if err := writeJSONLValue(stdout, item); err != nil { fmt.Fprintf(stderr, "gc events: marshal: %v\n", err) //nolint:errcheck @@ -924,11 +982,11 @@ func writeJSONLValue(stdout io.Writer, value any) error { return err } -func filterCityEvents(items []genclient.WireEvent, afterSeq uint64, typeFilter string, payloadMatch map[string][]string) []genclient.WireEvent { +func filterCityEvents(items []cliWireEvent, afterSeq uint64, typeFilter string, payloadMatch map[string][]string) []cliWireEvent { if len(items) == 0 { - return []genclient.WireEvent{} + return []cliWireEvent{} } - out := make([]genclient.WireEvent, 0, len(items)) + out := make([]cliWireEvent, 0, len(items)) for _, item := range items { if uint64(item.Seq) <= afterSeq { continue @@ -944,11 +1002,11 @@ func filterCityEvents(items []genclient.WireEvent, afterSeq uint64, typeFilter s return out } -func filterSupervisorEvents(items []genclient.WireTaggedEvent, typeFilter string, payloadMatch map[string][]string) []genclient.WireTaggedEvent { +func filterSupervisorEvents(items []cliWireTaggedEvent, typeFilter string, payloadMatch map[string][]string) []cliWireTaggedEvent { if len(items) == 0 { - return []genclient.WireTaggedEvent{} + return []cliWireTaggedEvent{} } - out := make([]genclient.WireTaggedEvent, 0, len(items)) + out := make([]cliWireTaggedEvent, 0, len(items)) for _, item := range items { if typeFilter != "" && item.Type != typeFilter { continue @@ -961,9 +1019,9 @@ func filterSupervisorEvents(items []genclient.WireTaggedEvent, typeFilter string return out } -func filterSupervisorEventsAfterCursor(items []genclient.WireTaggedEvent, cursor, typeFilter string, payloadMatch map[string][]string) []genclient.WireTaggedEvent { +func filterSupervisorEventsAfterCursor(items []cliWireTaggedEvent, cursor, typeFilter string, payloadMatch map[string][]string) []cliWireTaggedEvent { cursors := events.ParseCursor(cursor) - out := make([]genclient.WireTaggedEvent, 0, len(items)) + out := make([]cliWireTaggedEvent, 0, len(items)) for _, item := range items { if uint64(item.Seq) <= cursors[item.City] { continue @@ -1302,7 +1360,7 @@ func (d *sseDecoder) Next() (sseFrame, error) { return sseFrame{}, io.EOF } -func supervisorCursorFor(items []genclient.WireTaggedEvent) string { +func supervisorCursorFor(items []cliWireTaggedEvent) string { if len(items) == 0 { return "" } @@ -1320,39 +1378,16 @@ func supervisorCursorFor(items []genclient.WireTaggedEvent) string { // identical JSONL output. The only structural difference between the // two shapes is the optional Workflow projection that the stream // attaches to bead events; list results omit it. -func cityEnvelopesFor(items []genclient.WireEvent) []genclient.EventStreamEnvelope { - out := make([]genclient.EventStreamEnvelope, 0, len(items)) - for _, item := range items { - out = append(out, genclient.EventStreamEnvelope{ - Actor: item.Actor, - Message: item.Message, - Payload: item.Payload, - Seq: item.Seq, - Subject: item.Subject, - Ts: item.Ts, - Type: item.Type, - }) - } - return out +func cityEnvelopesFor(items []cliWireEvent) []cliEventEnvelope { + out := make([]cliEventEnvelope, 0, len(items)) + return append(out, items...) } // taggedEnvelopesFor is the supervisor-scope analog of cityEnvelopesFor, // preserving the City tag for the aggregated events stream. -func taggedEnvelopesFor(items []genclient.WireTaggedEvent) []genclient.TaggedEventStreamEnvelope { - out := make([]genclient.TaggedEventStreamEnvelope, 0, len(items)) - for _, item := range items { - out = append(out, genclient.TaggedEventStreamEnvelope{ - Actor: item.Actor, - City: item.City, - Message: item.Message, - Payload: item.Payload, - Seq: item.Seq, - Subject: item.Subject, - Ts: item.Ts, - Type: item.Type, - }) - } - return out +func taggedEnvelopesFor(items []cliWireTaggedEvent) []cliTaggedEventEnvelope { + out := make([]cliTaggedEventEnvelope, 0, len(items)) + return append(out, items...) } func matchPayload(payload any, payloadMatch map[string][]string) bool { diff --git a/cmd/gc/cmd_events_test.go b/cmd/gc/cmd_events_test.go index 7d3c62eced..bddec16ff7 100644 --- a/cmd/gc/cmd_events_test.go +++ b/cmd/gc/cmd_events_test.go @@ -18,14 +18,14 @@ import ( ) func TestDoEventsCityDefaultUsesJSONLItems(t *testing.T) { - items := []genclient.WireEvent{ - {Actor: "human", Seq: 1, Subject: stringPtr("gc-1"), Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, - {Actor: "gc", Seq: 2, Subject: stringPtr("mayor"), Ts: time.Unix(1700000010, 0).UTC(), Type: "session.woke"}, + items := []cliWireEvent{ + {Actor: "human", Seq: 1, Subject: "gc-1", Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, + {Actor: "gc", Seq: 2, Subject: "mayor", Ts: time.Unix(1700000010, 0).UTC(), Type: "session.woke"}, } server := newEventsTestServer(t, testEventRoutes{ cityEvents: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-GC-Index", "2") - writeJSONResponse(t, w, genclient.ListBodyWireEvent{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, cityEventsListResponse(t, items)) }, }) defer server.Close() @@ -40,9 +40,9 @@ func TestDoEventsCityDefaultUsesJSONLItems(t *testing.T) { if len(lines) != 2 { t.Fatalf("got %d JSONL lines, want 2; output=%q", len(lines), stdout.String()) } - var got []genclient.WireEvent + var got []cliWireEvent for _, line := range lines { - var item genclient.WireEvent + var item cliWireEvent if err := json.Unmarshal([]byte(line), &item); err != nil { t.Fatalf("unmarshal line: %v; line=%q", err, line) } @@ -54,12 +54,12 @@ func TestDoEventsCityDefaultUsesJSONLItems(t *testing.T) { } func TestDoEventsSupervisorDefaultUsesTaggedJSONLItems(t *testing.T) { - items := []genclient.WireTaggedEvent{ - {Actor: "human", City: "alpha", Seq: 3, Subject: stringPtr("gc-1"), Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, + items := []cliWireTaggedEvent{ + {Actor: "human", City: "alpha", Seq: 3, Subject: "gc-1", Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, } server := newEventsTestServer(t, testEventRoutes{ supervisorEvents: func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(t, w, genclient.SupervisorEventListOutputBody{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, supervisorEventsListResponse(t, items)) }, }) defer server.Close() @@ -70,7 +70,7 @@ func TestDoEventsSupervisorDefaultUsesTaggedJSONLItems(t *testing.T) { t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) } - var got genclient.WireTaggedEvent + var got cliWireTaggedEvent if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) } @@ -83,8 +83,8 @@ func TestDoEventsSeqCityUsesIndexHeader(t *testing.T) { server := newEventsTestServer(t, testEventRoutes{ cityEvents: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-GC-Index", "7") - items := []genclient.WireEvent{} - writeJSONResponse(t, w, genclient.ListBodyWireEvent{Items: &items, Total: 0}) + items := []cliWireEvent{} + writeJSONResponse(t, w, cityEventsListResponse(t, items)) }, }) defer server.Close() @@ -100,13 +100,13 @@ func TestDoEventsSeqCityUsesIndexHeader(t *testing.T) { } func TestDoEventsSeqSupervisorPrintsCompositeCursor(t *testing.T) { - items := []genclient.WireTaggedEvent{ + items := []cliWireTaggedEvent{ {Actor: "human", City: "beta", Seq: 9, Ts: time.Unix(1700000001, 0).UTC(), Type: "mail.sent"}, {Actor: "human", City: "alpha", Seq: 4, Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, } server := newEventsTestServer(t, testEventRoutes{ supervisorEvents: func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(t, w, genclient.SupervisorEventListOutputBody{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, supervisorEventsListResponse(t, items)) }, }) defer server.Close() @@ -152,7 +152,7 @@ func TestDoEventsFallsBackToLocalCityEventsWhenCityStopped(t *testing.T) { t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) } - var got genclient.WireEvent + var got cliWireEvent if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) } @@ -192,7 +192,7 @@ func TestDoEventsFallsBackToLocalCityEventsOnTypedStoppedCityNotFound(t *testing t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) } - var got genclient.WireEvent + var got cliWireEvent if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) } @@ -311,7 +311,7 @@ func TestDoEventsFallsBackToLocalCityEventsForExplicitLocalSupervisorAPI(t *test t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) } - var got genclient.WireEvent + var got cliWireEvent if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) } @@ -346,7 +346,7 @@ func TestDoEventsFallsBackToLocalCityEventsForExplicitLocalSupervisorAPITranspor t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) } - var got genclient.WireEvent + var got cliWireEvent if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) } @@ -355,6 +355,79 @@ func TestDoEventsFallsBackToLocalCityEventsForExplicitLocalSupervisorAPITranspor } } +func TestDoEventsReadsCustomCityEventTypesThroughAPI(t *testing.T) { + cityDir := t.TempDir() + items := []cliWireEvent{{ + Actor: "human", + Seq: 1, + Subject: "fixture", + Ts: time.Unix(1700000000, 0).UTC(), + Type: "app.custom", + Message: "custom event", + Payload: json.RawMessage(`{"source":"test"}`), + }} + + server := newEventsTestServer(t, testEventRoutes{ + cityEvents: func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("type"); got != "app.custom" { + t.Fatalf("type query = %q, want app.custom", got) + } + w.Header().Set("X-GC-Index", "1") + writeJSONResponse(t, w, cityEventsListResponse(t, items)) + }, + }) + defer server.Close() + + var stdout, stderr bytes.Buffer + code := doEvents(eventsAPIScope{ + apiURL: server.URL, + cityName: "mc-city", + cityPath: cityDir, + }, "app.custom", "", nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) + } + + var got cliWireEvent + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("unmarshal stdout: %v; output=%s", err, stdout.String()) + } + if got.Type != "app.custom" || got.Subject != "fixture" || got.Message != "custom event" { + t.Fatalf("custom event = %+v", got) + } + if string(got.Payload) != `{"source":"test"}` { + t.Fatalf("custom event payload = %s", got.Payload) + } +} + +func TestDoEventsDoesNotReadLocalUntypedCityEventsForExplicitRemoteAPI(t *testing.T) { + cityDir := t.TempDir() + rec := newTestProvider(t, filepath.Join(cityDir, ".gc")) + rec.Record(events.Event{Type: "app.custom", Actor: "human"}) + + server := newEventsTestServer(t, testEventRoutes{ + cityEvents: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-GC-Index", "0") + writeJSONResponse(t, w, cityEventsListResponse(t, []cliWireEvent{})) + }, + }) + defer server.Close() + + var stdout, stderr bytes.Buffer + code := doEvents(eventsAPIScope{ + apiURL: server.URL, + cityName: "mc-city", + cityPath: cityDir, + explicitAPI: true, + }, "app.custom", "", nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doEvents = %d, want 0; stderr=%s", code, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "" { + t.Fatalf("stdout = %q, want explicit remote API result", stdout.String()) + } +} + func TestDoEventsSeqFallsBackToLocalCityEventHeadWhenCityStopped(t *testing.T) { cityDir := t.TempDir() rec := newTestProvider(t, filepath.Join(cityDir, ".gc")) @@ -554,14 +627,14 @@ func TestDoEventsWatchStoppedCityAfterSeqRequiresRunningAPI(t *testing.T) { } func TestDoEventsWatchCityBufferedReplayUsesEnvelopeSchema(t *testing.T) { - items := []genclient.WireEvent{ - {Actor: "human", Seq: 1, Subject: stringPtr("gc-1"), Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, - {Actor: "human", Message: stringPtr("hello"), Seq: 2, Subject: stringPtr("gc-2"), Ts: time.Unix(1700000010, 0).UTC(), Type: "mail.sent"}, + items := []cliWireEvent{ + {Actor: "human", Seq: 1, Subject: "gc-1", Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, + {Actor: "human", Message: "hello", Seq: 2, Subject: "gc-2", Ts: time.Unix(1700000010, 0).UTC(), Type: "mail.sent"}, } server := newEventsTestServer(t, testEventRoutes{ cityEvents: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-GC-Index", "2") - writeJSONResponse(t, w, genclient.ListBodyWireEvent{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, cityEventsListResponse(t, items)) }, }) defer server.Close() @@ -586,15 +659,15 @@ func TestDoEventsWatchCityBufferedReplayUsesEnvelopeSchema(t *testing.T) { } func TestDoEventsWatchCityBufferedReplayAfterSeqSkipsHeadProbe(t *testing.T) { - items := []genclient.WireEvent{ - {Actor: "human", Seq: 1, Subject: stringPtr("gc-1"), Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, - {Actor: "human", Message: stringPtr("hello"), Seq: 2, Subject: stringPtr("gc-2"), Ts: time.Unix(1700000010, 0).UTC(), Type: "mail.sent"}, + items := []cliWireEvent{ + {Actor: "human", Seq: 1, Subject: "gc-1", Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, + {Actor: "human", Message: "hello", Seq: 2, Subject: "gc-2", Ts: time.Unix(1700000010, 0).UTC(), Type: "mail.sent"}, } server := newEventsTestServer(t, testEventRoutes{ cityEvents: func(w http.ResponseWriter, _ *http.Request) { // Buffered replay for --after only needs the JSON body; a missing // X-GC-Index header should not block replay. - writeJSONResponse(t, w, genclient.ListBodyWireEvent{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, cityEventsListResponse(t, items)) }, }) defer server.Close() @@ -619,13 +692,13 @@ func TestDoEventsWatchCityBufferedReplayAfterSeqSkipsHeadProbe(t *testing.T) { } func TestDoEventsWatchSupervisorBufferedReplayUsesTaggedEnvelopeSchema(t *testing.T) { - items := []genclient.WireTaggedEvent{ + items := []cliWireTaggedEvent{ {Actor: "human", City: "alpha", Seq: 2, Ts: time.Unix(1700000000, 0).UTC(), Type: "bead.created"}, {Actor: "gc", City: "beta", Seq: 5, Ts: time.Unix(1700000010, 0).UTC(), Type: "session.woke"}, } server := newEventsTestServer(t, testEventRoutes{ supervisorEvents: func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(t, w, genclient.SupervisorEventListOutputBody{Items: &items, Total: int64(len(items))}) + writeJSONResponse(t, w, supervisorEventsListResponse(t, items)) }, }) defer server.Close() @@ -653,8 +726,8 @@ func TestDoEventsWatchTimesOutWithoutMatch(t *testing.T) { server := newEventsTestServer(t, testEventRoutes{ cityEvents: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-GC-Index", "3") - items := []genclient.WireEvent{} - writeJSONResponse(t, w, genclient.ListBodyWireEvent{Items: &items, Total: 0}) + items := []cliWireEvent{} + writeJSONResponse(t, w, cityEventsListResponse(t, items)) }, cityStream: func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -792,6 +865,40 @@ func writeJSONResponse(t *testing.T, w http.ResponseWriter, body any) { } } +func cityEventsListResponse(t *testing.T, items []cliWireEvent) genclient.ListBodyWireEvent { + t.Helper() + typed := make([]genclient.TypedEventStreamEnvelope, 0, len(items)) + for _, item := range items { + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal city event item: %v", err) + } + var envelope genclient.TypedEventStreamEnvelope + if err := envelope.UnmarshalJSON(data); err != nil { + t.Fatalf("unmarshal typed city event item: %v; item=%s", err, data) + } + typed = append(typed, envelope) + } + return genclient.ListBodyWireEvent{Items: &typed, Total: int64(len(typed))} +} + +func supervisorEventsListResponse(t *testing.T, items []cliWireTaggedEvent) genclient.SupervisorEventListOutputBody { + t.Helper() + typed := make([]genclient.TypedTaggedEventStreamEnvelope, 0, len(items)) + for _, item := range items { + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal supervisor event item: %v", err) + } + var envelope genclient.TypedTaggedEventStreamEnvelope + if err := envelope.UnmarshalJSON(data); err != nil { + t.Fatalf("unmarshal typed supervisor event item: %v; item=%s", err, data) + } + typed = append(typed, envelope) + } + return genclient.SupervisorEventListOutputBody{Items: &typed, Total: int64(len(typed))} +} + func writeProblemResponse(t *testing.T, w http.ResponseWriter, body any) { t.Helper() w.Header().Set("Content-Type", "application/problem+json") diff --git a/cmd/gc/cmd_handoff.go b/cmd/gc/cmd_handoff.go index 5675c6ca63..b66d73e3ec 100644 --- a/cmd/gc/cmd_handoff.go +++ b/cmd/gc/cmd_handoff.go @@ -17,8 +17,9 @@ import ( func newHandoffCmd(stdout, stderr io.Writer) *cobra.Command { var target string + var auto bool cmd := &cobra.Command{ - Use: "handoff [message]", + Use: "handoff [subject] [message]", Short: "Send handoff mail and restart controller-managed sessions", Long: `Convenience command for context handoff. @@ -33,6 +34,10 @@ For controller-restartable sessions, equivalent to: gc mail send $GC_ALIAS [message] gc runtime request-restart +Auto handoff (--auto): sends mail to self and returns without requesting a +restart. This is for PreCompact hooks, where the provider is already managing +the context compaction lifecycle. + Remote handoff (--target): sends mail to a target session. If the target is controller-restartable, kills it so the reconciler restarts it with the handoff mail waiting. For on-demand configured named targets, sends mail and returns @@ -44,21 +49,32 @@ For controller-restartable targets, equivalent to: gc session kill Self-handoff requires session context (GC_ALIAS or GC_SESSION_ID, plus -GC_SESSION_NAME and city context env). Remote handoff accepts a session alias or ID.`, - Args: cobra.RangeArgs(1, 2), +GC_SESSION_NAME and city context env). Remote handoff accepts a session alias +or ID. Subject is required unless --auto is set.`, + Args: func(cmd *cobra.Command, args []string) error { + if auto { + return cobra.MaximumNArgs(2)(cmd, args) + } + return cobra.RangeArgs(1, 2)(cmd, args) + }, RunE: func(_ *cobra.Command, args []string) error { - if cmdHandoff(args, target, stdout, stderr) != 0 { + if cmdHandoff(args, target, auto, stdout, stderr) != 0 { return errExit } return nil }, } cmd.Flags().StringVar(&target, "target", "", "Remote session alias or ID to handoff (kills only controller-restartable sessions)") + cmd.Flags().BoolVar(&auto, "auto", false, "Send handoff mail without requesting restart (for PreCompact hooks)") return cmd } -func cmdHandoff(args []string, target string, stdout, stderr io.Writer) int { +func cmdHandoff(args []string, target string, auto bool, stdout, stderr io.Writer) int { if target != "" { + if auto { + fmt.Fprintln(stderr, "gc handoff: --auto cannot be used with --target") //nolint:errcheck // best-effort stderr + return 1 + } return cmdHandoffRemote(args, target, stdout, stderr) } @@ -74,9 +90,13 @@ func cmdHandoff(args []string, target string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "hint: run \"gc doctor\" for diagnostics") //nolint:errcheck // best-effort stderr return 1 } + rec := openCityRecorderAt(current.cityPath, stderr) + if auto { + return doHandoffAuto(store, rec, current.display, args, stdout, stderr) + } + sp := newSessionProvider() dops := newDrainOps(sp) - rec := openCityRecorderAt(current.cityPath, stderr) cfg, _ := loadCityConfig(current.cityPath, stderr) persistRestart := sessionRestartPersister(current.cityPath, store, sp, cfg, current.sessionName) @@ -92,8 +112,8 @@ func cmdHandoff(args []string, target string, stdout, stderr io.Writer) int { select {} } -// cmdHandoffRemote sends handoff mail to a remote session and stops the target -// only when the controller can restart it. Returns immediately. +// cmdHandoffRemote sends handoff mail to a remote session and kills its runtime. +// Returns immediately (non-blocking). The reconciler restarts the target. func cmdHandoffRemote(args []string, target string, stdout, stderr io.Writer) int { targetInfo, err := resolveSessionRuntimeTarget(target, stderr) if err != nil { @@ -150,48 +170,23 @@ func doHandoff(store beads.Store, rec events.Recorder, dops drainOps, persistRes func doHandoffWithOutcome(store beads.Store, rec events.Recorder, dops drainOps, persistRestart func() error, sessionAddress, sessionName string, args []string, stdout, stderr io.Writer, ) handoffOutcome { - subject := args[0] - var message string - if len(args) > 1 { - message = args[1] - } - - b, err := store.Create(beads.Bead{ - Title: subject, - Description: message, - Type: "message", - Assignee: sessionAddress, - From: sessionAddress, - Labels: []string{"thread:" + handoffThreadID()}, - }) - if err != nil { - fmt.Fprintf(stderr, "gc handoff: creating mail: %v\n", err) //nolint:errcheck // best-effort stderr + b, ok := createHandoffMail(store, rec, sessionAddress, sessionAddress, args, "HANDOFF: context cycle", stderr) + if !ok { return handoffOutcome{code: 1} } - rec.Record(events.Event{ - Type: events.MailSent, - Actor: sessionAddress, - Subject: b.ID, - Message: sessionAddress, - Payload: mailEventPayload(nil), - }) restartable, err := sessionRestartableByController(store, sessionName) if err != nil { fmt.Fprintf(stderr, "gc handoff: checking session type: %v\n", err) //nolint:errcheck // best-effort stderr return handoffOutcome{code: 1} } - // On-demand named sessions are human-attended and the controller cannot - // respawn their process after a restart request. Preserve the handoff - // mail so context survives, but skip both restart flags. Regression - // guard: gastownhall/gascity#744. if !restartable { if err := clearRestartRequest(store, dops, sessionName); err != nil { fmt.Fprintf(stderr, "gc handoff: clearing stale restart request: %v\n", err) //nolint:errcheck // best-effort stderr - return handoffOutcome{code: 1, restartRequested: false} + return handoffOutcome{code: 1} } fmt.Fprintf(stdout, "Handoff: sent mail %s (named session; restart skipped).\n", b.ID) //nolint:errcheck // best-effort stdout - return handoffOutcome{code: 0, restartRequested: false} + return handoffOutcome{code: 0} } if err := dops.setRestartRequested(sessionName); err != nil { @@ -216,6 +211,55 @@ func doHandoffWithOutcome(store beads.Store, rec events.Recorder, dops drainOps, return handoffOutcome{code: 0, restartRequested: true} } +// doHandoffAuto sends handoff mail to self without requesting restart. +func doHandoffAuto(store beads.Store, rec events.Recorder, sessionAddress string, args []string, stdout, stderr io.Writer) int { + b, ok := createHandoffMail(store, rec, sessionAddress, sessionAddress, args, "context cycle", stderr) + if !ok { + return 1 + } + fmt.Fprintf(stdout, "Handoff: sent auto mail %s (restart skipped).\n", b.ID) //nolint:errcheck // best-effort stdout + return 0 +} + +func createHandoffMail(store beads.Store, rec events.Recorder, senderAddress, recipientAddress string, args []string, defaultSubject string, stderr io.Writer) (beads.Bead, bool) { + subject := defaultSubject + if len(args) > 0 { + subject = args[0] + } + var message string + if len(args) > 1 { + message = args[1] + } + metadata, err := mailSenderRouteMetadata(store, senderAddress) + if err != nil { + fmt.Fprintf(stderr, "gc handoff: resolving sender route: %v\n", err) //nolint:errcheck // best-effort stderr + return beads.Bead{}, false + } + senderDisplay := mailSenderDisplayFromMetadata(senderAddress, metadata) + + b, err := store.Create(beads.Bead{ + Title: subject, + Description: message, + Type: "message", + Assignee: recipientAddress, + From: senderDisplay, + Labels: []string{"thread:" + handoffThreadID()}, + Metadata: metadata, + }) + if err != nil { + fmt.Fprintf(stderr, "gc handoff: creating mail: %v\n", err) //nolint:errcheck // best-effort stderr + return beads.Bead{}, false + } + rec.Record(events.Event{ + Type: events.MailSent, + Actor: senderDisplay, + Subject: b.ID, + Message: recipientAddress, + Payload: mailEventPayload(nil), + }) + return b, true +} + func sessionRestartableByController(store beads.Store, sessionName string) (bool, error) { if store == nil || sessionName == "" { return true, nil @@ -267,37 +311,15 @@ func clearRestartRequest(store beads.Store, dops drainOps, sessionName string) e return errors.Join(errs...) } -// doHandoffRemote sends handoff mail to a remote session and stops the target -// only when the controller can restart it. +// doHandoffRemote sends handoff mail to a remote session and kills its runtime. +// Non-blocking: returns immediately after killing the session. func doHandoffRemote(store beads.Store, rec events.Recorder, sp runtime.Provider, sessionName, targetAddress, sender string, args []string, stdout, stderr io.Writer, ) int { - subject := args[0] - var message string - if len(args) > 1 { - message = args[1] - } - - // Send mail to target. - b, err := store.Create(beads.Bead{ - Title: subject, - Description: message, - Type: "message", - Assignee: targetAddress, - From: sender, - Labels: []string{"thread:" + handoffThreadID()}, - }) - if err != nil { - fmt.Fprintf(stderr, "gc handoff: creating mail: %v\n", err) //nolint:errcheck // best-effort stderr + b, ok := createHandoffMail(store, rec, sender, targetAddress, args, "HANDOFF: context cycle", stderr) + if !ok { return 1 } - rec.Record(events.Event{ - Type: events.MailSent, - Actor: sender, - Subject: b.ID, - Message: targetAddress, - Payload: mailEventPayload(nil), - }) restartable, err := sessionRestartableByController(store, sessionName) if err != nil { diff --git a/cmd/gc/cmd_handoff_test.go b/cmd/gc/cmd_handoff_test.go index 0ceba9e951..9b83daec87 100644 --- a/cmd/gc/cmd_handoff_test.go +++ b/cmd/gc/cmd_handoff_test.go @@ -75,22 +75,112 @@ func TestHandoffSuccess(t *testing.T) { } } +func TestCmdHandoffAutoSendsMailWithoutBlocking(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"demo\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", cityDir) + t.Setenv("GC_ALIAS", "mayor") + t.Setenv("GC_SESSION_NAME", "mayor") + + var stdout, stderr bytes.Buffer + cmd := newHandoffCmd(&stdout, &stderr) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--auto", "context cycle"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("gc handoff --auto failed: %v; stderr=%s", err, stderr.String()) + } + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + all, err := store.ListOpen() + if err != nil { + t.Fatalf("ListOpen: %v", err) + } + if len(all) != 1 { + t.Fatalf("got %d open beads, want 1", len(all)) + } + if got := all[0].Title; got != "context cycle" { + t.Fatalf("mail title = %q, want context cycle", got) + } + if got := all[0].Type; got != "message" { + t.Fatalf("mail type = %q, want message", got) + } + if strings.Contains(stdout.String(), "requesting restart") { + t.Fatalf("stdout = %q, --auto must not request restart", stdout.String()) + } + if !strings.Contains(stdout.String(), "auto") { + t.Fatalf("stdout = %q, want auto handoff confirmation", stdout.String()) + } +} + +func TestCmdHandoffAutoUsesDefaultSubject(t *testing.T) { + cityDir := t.TempDir() + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"demo\"\n"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", cityDir) + t.Setenv("GC_ALIAS", "mayor") + t.Setenv("GC_SESSION_NAME", "mayor") + + var stdout, stderr bytes.Buffer + cmd := newHandoffCmd(&stdout, &stderr) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--auto"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("gc handoff --auto failed: %v; stderr=%s", err, stderr.String()) + } + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + all, err := store.ListOpen() + if err != nil { + t.Fatalf("ListOpen: %v", err) + } + if len(all) != 1 { + t.Fatalf("got %d open beads, want 1", len(all)) + } + if got := all[0].Title; got != "context cycle" { + t.Fatalf("mail title = %q, want context cycle", got) + } +} + +func TestCmdHandoffAutoRejectsTarget(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := cmdHandoff([]string{"context cycle"}, "mayor", true, &stdout, &stderr); code == 0 { + t.Fatal("cmdHandoff returned 0 for --auto with --target") + } + if !strings.Contains(stderr.String(), "--auto cannot be used with --target") { + t.Fatalf("stderr = %q, want --auto/--target conflict", stderr.String()) + } +} + // Regression for gastownhall/gascity#744: // gc handoff on a named (human-attended) session used to call // setRestartRequested unconditionally. The controller cannot respawn a -// user-started session, so the PreCompact hook crashed the mayor to the -// user's shell on every context compaction. doHandoff must recognize the -// named-session case, still send the handoff mail, and skip both the -// tmux and bead restart flags. +// user-started session, so the PreCompact hook crashed the user to their shell +// on every context compaction. doHandoff must recognize the named-session +// case, still send the handoff mail, and skip both the tmux and bead restart +// flags. func TestDoHandoff_Regression744_NamedSessionSkipsRestart(t *testing.T) { store := beads.NewMemStore() rec := events.NewFake() dops := newFakeDrainOps() var stdout, stderr bytes.Buffer - // Seed a session bead marked as a configured named session (i.e. the - // mayor). IsNamedSessionBead returns true for beads whose metadata - // contains configured_named_session="true". b, err := store.Create(beads.Bead{ Type: sessionBeadType, Labels: []string{"gc:session"}, @@ -119,8 +209,7 @@ func TestDoHandoff_Regression744_NamedSessionSkipsRestart(t *testing.T) { outcome := doHandoffWithOutcome(store, rec, dops, func() error { persistCalled = true return nil - }, "mayor", "mayor", - []string{"HANDOFF: context full"}, &stdout, &stderr) + }, "mayor", "mayor", []string{"HANDOFF: context full"}, &stdout, &stderr) if outcome.code != 0 { t.Fatalf("code = %d, want 0; stderr: %s", outcome.code, stderr.String()) } @@ -128,7 +217,6 @@ func TestDoHandoff_Regression744_NamedSessionSkipsRestart(t *testing.T) { t.Fatal("restartRequested = true, want false for on-demand named session") } - // Mail must still be sent — context preservation is the whole point. mailFound := false all, _ := store.ListOpen() for _, got := range all { @@ -140,18 +228,12 @@ func TestDoHandoff_Regression744_NamedSessionSkipsRestart(t *testing.T) { if !mailFound { t.Fatalf("handoff mail not created; beads=%v", all) } - - // Restart must NOT be requested — the controller can't respawn a - // user-started named session. if dops.restartRequested["mayor"] { - t.Errorf("restart-requested flag is still set — named sessions must skip restart (gascity#744)") + t.Errorf("restart-requested flag is still set; named sessions must skip restart") } if persistCalled { - t.Error("persistRestart was called — named sessions must skip persisted restart requests") + t.Error("persistRestart was called; named sessions must skip persisted restart requests") } - - // Bead-level restart flags must also be absent, including stale flags - // left behind by older handoff implementations. refreshed, err := store.Get(b.ID) if err != nil { t.Fatalf("fetching seeded bead: %v", err) @@ -162,8 +244,6 @@ func TestDoHandoff_Regression744_NamedSessionSkipsRestart(t *testing.T) { if refreshed.Metadata["continuation_reset_pending"] != "" { t.Errorf("continuation_reset_pending = %q, want cleared for named session", refreshed.Metadata["continuation_reset_pending"]) } - - // Stdout should not promise a restart the controller can't deliver. if strings.Contains(stdout.String(), "requesting restart") { t.Errorf("stdout = %q, must not promise a restart for named sessions", stdout.String()) } @@ -263,12 +343,36 @@ func TestDoHandoff_NamedAlwaysSessionRequestsRestart(t *testing.T) { } } +func TestHandoffWithMessage(t *testing.T) { + store := beads.NewMemStore() + rec := events.NewFake() + dops := newFakeDrainOps() + var stdout, stderr bytes.Buffer + + code := doHandoff(store, rec, dops, nil, "polecat-1", "gc-city-polecat-1", + []string{"HANDOFF: PR review needed", "PR #42 is open, tests passing, needs review from refinery"}, + &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + + all, _ := store.ListOpen() + if len(all) != 1 { + t.Fatalf("got %d beads, want 1", len(all)) + } + b := all[0] + if b.Description != "PR #42 is open, tests passing, needs review from refinery" { + t.Errorf("Description = %q, want body text", b.Description) + } +} + func TestCmdHandoff_Regression744_NamedSessionReturnsWithoutBlocking(t *testing.T) { cityDir := t.TempDir() if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte("[workspace]\nname = \"demo\"\n"), 0o644); err != nil { t.Fatalf("write city.toml: %v", err) } t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") t.Setenv("GC_CITY", cityDir) t.Setenv("GC_CITY_PATH", cityDir) t.Setenv("GC_ALIAS", "mayor") @@ -298,7 +402,7 @@ func TestCmdHandoff_Regression744_NamedSessionReturnsWithoutBlocking(t *testing. var stdout, stderr bytes.Buffer done := make(chan int, 1) go func() { - done <- cmdHandoff([]string{"HANDOFF: context full"}, "", &stdout, &stderr) + done <- cmdHandoff([]string{"HANDOFF: context full"}, "", false, &stdout, &stderr) }() select { @@ -306,7 +410,7 @@ func TestCmdHandoff_Regression744_NamedSessionReturnsWithoutBlocking(t *testing. if code != 0 { t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) } - case <-time.After(2 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("cmdHandoff blocked for named on-demand session") } if !strings.Contains(stdout.String(), "restart skipped") { @@ -314,29 +418,6 @@ func TestCmdHandoff_Regression744_NamedSessionReturnsWithoutBlocking(t *testing. } } -func TestHandoffWithMessage(t *testing.T) { - store := beads.NewMemStore() - rec := events.NewFake() - dops := newFakeDrainOps() - var stdout, stderr bytes.Buffer - - code := doHandoff(store, rec, dops, nil, "polecat-1", "gc-city-polecat-1", - []string{"HANDOFF: PR review needed", "PR #42 is open, tests passing, needs review from refinery"}, - &stdout, &stderr) - if code != 0 { - t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) - } - - all, _ := store.ListOpen() - if len(all) != 1 { - t.Fatalf("got %d beads, want 1", len(all)) - } - b := all[0] - if b.Description != "PR #42 is open, tests passing, needs review from refinery" { - t.Errorf("Description = %q, want body text", b.Description) - } -} - func TestHandoffMissingSubject(t *testing.T) { store := beads.NewMemStore() rec := events.NewFake() @@ -537,6 +618,7 @@ func TestHandoffRemoteNotRunning(t *testing.T) { func TestCmdHandoffRemoteDefaultSenderFallsBackToGCAliasWhenSessionIDMissing(t *testing.T) { t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") t.Setenv("GC_MAIL", "") cityPath := t.TempDir() @@ -549,14 +631,15 @@ func TestCmdHandoffRemoteDefaultSenderFallsBackToGCAliasWhenSessionIDMissing(t * if err != nil { t.Fatalf("openCityStoreAt: %v", err) } - if _, err := store.Create(beads.Bead{ + senderBead, err := store.Create(beads.Bead{ Type: session.BeadType, Labels: []string{session.LabelSession}, Metadata: map[string]string{ "alias": "sender", "session_name": "sender-gc-42", }, - }); err != nil { + }) + if err != nil { t.Fatalf("Create sender: %v", err) } if _, err := store.Create(beads.Bead{ @@ -603,6 +686,12 @@ func TestCmdHandoffRemoteDefaultSenderFallsBackToGCAliasWhenSessionIDMissing(t * if msg.From != "sender" { t.Fatalf("message From = %q, want sender", msg.From) } + if msg.Metadata["mail.from_session_id"] != senderBead.ID { + t.Fatalf("mail.from_session_id = %q, want %q", msg.Metadata["mail.from_session_id"], senderBead.ID) + } + if msg.Metadata["mail.from_display"] != "sender" { + t.Fatalf("mail.from_display = %q, want sender", msg.Metadata["mail.from_display"]) + } if msg.Assignee != "recipient" { t.Fatalf("message Assignee = %q, want recipient", msg.Assignee) } diff --git a/cmd/gc/cmd_hook.go b/cmd/gc/cmd_hook.go index f40525f99f..d55890fd61 100644 --- a/cmd/gc/cmd_hook.go +++ b/cmd/gc/cmd_hook.go @@ -20,38 +20,44 @@ func newHookCmd(stdout, stderr io.Writer) *cobra.Command { var hookFormat string cmd := &cobra.Command{ Use: "hook [agent]", - Short: "Check for available work (use --inject for Stop hook output)", + Short: "Check for available work", Long: `Checks for available work using the agent's work_query config. Without --inject: prints raw output, exits 0 if work exists, 1 if empty. -With --inject: wraps output in for hook injection, always exits 0. +With --inject: silent legacy Stop-hook compatibility; skips the work query and always exits 0. The agent is determined from $GC_AGENT or a positional argument.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - code := cmdHook(args, inject, stdout, stderr) - if hookFormat != "" { - code = cmdHookWithFormat(args, inject, hookFormat, stdout, stderr) - } - if code != 0 { + if cmdHookWithFormat(args, inject, hookFormat, stdout, stderr) != 0 { return errExit } return nil }, } - cmd.Flags().BoolVar(&inject, "inject", false, "output block for hook injection") + cmd.Flags().BoolVar(&inject, "inject", false, "silent legacy Stop-hook compatibility; skip work query and exit 0") cmd.Flags().StringVar(&hookFormat, "hook-format", "", "format hook output for a provider") + if flag := cmd.Flags().Lookup("hook-format"); flag != nil { + flag.Hidden = true + } return cmd } // cmdHook is the CLI entry point for gc hook. Resolves the agent from // $GC_AGENT or a positional argument, loads the city config, and runs // the agent's work query. -func cmdHook(args []string, inject bool, stdout, stderr io.Writer) int { - return cmdHookWithFormat(args, inject, "", stdout, stderr) +func cmdHook(args []string, stdout, stderr io.Writer) int { + return cmdHookWithFormat(args, false, "", stdout, stderr) } func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, stderr io.Writer) int { + if inject { + return 0 + } + // Accepted for compatibility with installed hook commands; non-inject + // gc hook output is intentionally raw regardless of provider format. + _ = hookFormat + agentName := os.Getenv("GC_ALIAS") if agentName == "" { agentName = os.Getenv("GC_AGENT") @@ -70,26 +76,17 @@ func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, st agentName = args[0] } if agentName == "" { - if inject { - return 0 // --inject always exits 0 - } fmt.Fprintln(stderr, "gc hook: agent not specified (set $GC_AGENT or pass as argument)") //nolint:errcheck // best-effort stderr return 1 } cityPath, err := resolveCity() if err != nil { - if inject { - return 0 - } fmt.Fprintf(stderr, "gc hook: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } cfg, err := loadCityConfig(cityPath, stderr) if err != nil { - if inject { - return 0 - } fmt.Fprintf(stderr, "gc hook: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } @@ -100,26 +97,17 @@ func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, st resolveRigPaths(cityPath, cfg.Rigs) if citySuspended(cfg) { - if inject { - return 0 - } fmt.Fprintln(stderr, "gc hook: city is suspended") //nolint:errcheck // best-effort stderr return 1 } a, ok := resolveAgentIdentity(cfg, agentName, currentRigContext(cfg)) if !ok { - if inject { - return 0 - } fmt.Fprintf(stderr, "gc hook: agent %q not found in config\n", agentName) //nolint:errcheck // best-effort stderr return 1 } if isAgentEffectivelySuspended(cfg, &a) { - if inject { - return 0 - } fmt.Fprintf(stderr, "gc hook: agent %q is suspended\n", agentName) //nolint:errcheck // best-effort stderr return 1 } @@ -140,9 +128,8 @@ func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, st // names; named-session context preserves the runtime-supplied owner // env while selecting the backing config through GC_TEMPLATE. resolvedAgentName := a.QualifiedName() - resolvedSessionName := cliSessionName(cityPath, cityName, resolvedAgentName, cfg.Workspace.SessionTemplate) agentForQuery := resolvedAgentName - sessionForQuery := resolvedSessionName + sessionForQuery := "" if sessionTemplateContext { agentForQuery = os.Getenv("GC_ALIAS") if agentForQuery == "" { @@ -152,6 +139,8 @@ func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, st agentForQuery = os.Getenv("GC_AGENT") } sessionForQuery = os.Getenv("GC_SESSION_NAME") + } else { + sessionForQuery = cliSessionName(cityPath, cityName, resolvedAgentName, cfg.Workspace.SessionTemplate) } overrides := hookQueryEnv(cityPath, cfg, &a) overrides["GC_AGENT"] = agentForQuery @@ -166,7 +155,7 @@ func cmdHookWithFormat(args []string, inject bool, hookFormat string, stdout, st runner := func(command, dir string) (string, error) { return shellWorkQueryWithEnv(command, dir, queryEnv) } - return doHookWithFormat(workQuery, workDir, inject, hookFormat, runner, stdout, stderr) + return doHook(workQuery, workDir, inject, runner, stdout, stderr) } // hookQueryEnv returns the full work-query environment for a hook subprocess. @@ -198,9 +187,7 @@ func shellWorkQueryWithEnv(command, dir string, env []string) (string, error) { if dir != "" { cmd.Dir = dir } - if env != nil { - cmd.Env = workQueryEnvForDir(env, dir) - } + cmd.Env = workQueryEnvForDir(env, dir) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("running work query %q: %w", command, err) @@ -215,7 +202,7 @@ func shellWorkQueryWithEnv(command, dir string, env []string) (string, error) { // that inspect $PWD. func workQueryEnvForDir(env []string, dir string) []string { if env == nil { - return nil + env = mergeRuntimeEnv(os.Environ(), nil) } if dir == "" { return env @@ -226,17 +213,14 @@ func workQueryEnvForDir(env []string, dir string) []string { // doHook is the pure logic for gc hook. Runs the work query and outputs // results based on mode. Without inject: prints raw output, returns 0 if -// work, 1 if empty. With inject: wraps in , always returns 0. +// work, 1 if empty. With inject: skips the work query and returns 0. func doHook(workQuery, dir string, inject bool, runner WorkQueryRunner, stdout, stderr io.Writer) int { - return doHookWithFormat(workQuery, dir, inject, "", runner, stdout, stderr) -} + if inject { + return 0 + } -func doHookWithFormat(workQuery, dir string, inject bool, hookFormat string, runner WorkQueryRunner, stdout, stderr io.Writer) int { output, err := runner(workQuery, dir) if err != nil { - if inject { - return 0 // --inject always exits 0 - } fmt.Fprintf(stderr, "gc hook: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } @@ -245,14 +229,6 @@ func doHookWithFormat(workQuery, dir string, inject bool, hookFormat string, run normalized := normalizeWorkQueryOutput(trimmed) hasWork := workQueryHasReadyWork(normalized) - if inject { - if hasWork { - content := formatHookInjectReminder(normalized) - _ = writeProviderHookContext(stdout, hookFormat, content) - } - return 0 // --inject always exits 0 - } - // Non-inject mode: print raw output. Return 0 only when work exists. if !hasWork { if normalized != "" { @@ -264,23 +240,6 @@ func doHookWithFormat(workQuery, dir string, inject bool, hookFormat string, run return 0 } -func formatHookInjectReminder(normalizedWork string) string { - return fmt.Sprintf(` -You have pending work. Pick up the next item: - - -%s - - -Use the bead id from the work item: -- If the item is not assigned to you yet, run `+"`bd update --claim`"+`. -- Do the requested work. -- When done, run `+"`bd close `"+`. -Run `+"`gc hook`"+` to see the full queue. - -`, normalizedWork) -} - func workQueryHasReadyWork(output string) bool { if output == "" { return false diff --git a/cmd/gc/cmd_hook_test.go b/cmd/gc/cmd_hook_test.go index 6f39547b8e..0f7ad12b47 100644 --- a/cmd/gc/cmd_hook_test.go +++ b/cmd/gc/cmd_hook_test.go @@ -85,34 +85,192 @@ func TestHookInjectSuppressesNoReadyMessage(t *testing.T) { } } -func TestHookInjectFormatsOutput(t *testing.T) { +func TestHookInjectIsNonIntrusiveWithWork(t *testing.T) { runner := func(string, string) (string, error) { return "hw-1 open Fix the bug\n", nil } var stdout, stderr bytes.Buffer code := doHook("bd ready", "", true, runner, &stdout, &stderr) if code != 0 { t.Errorf("doHook(inject, work) = %d, want 0", code) } - out := stdout.String() - if !strings.Contains(out, "") { - t.Errorf("stdout missing : %q", out) + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty non-intrusive inject output", stdout.String()) + } +} + +func TestHookInjectDoesNotRunWorkQuery(t *testing.T) { + called := false + runner := func(string, string) (string, error) { + called = true + return "hw-1 open Fix the bug\n", nil + } + var stdout, stderr bytes.Buffer + code := doHook("bd ready", "", true, runner, &stdout, &stderr) + if code != 0 { + t.Errorf("doHook(inject, work) = %d, want 0", code) + } + if called { + t.Fatal("inject mode ran the work query even though its output is ignored") + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty non-intrusive inject output", stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want empty", stderr.String()) + } +} + +func TestHookCommandCodexInjectDoesNotBlockStop(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + cityToml := `[workspace] +name = "test-city" + +[[agent]] +name = "worker" +work_query = "printf '[{\"id\":\"hw-1\",\"title\":\"Fix the bug\"}]'" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GC_CITY", cityDir) + + var stdout, stderr bytes.Buffer + cmd := newHookCmd(&stdout, &stderr) + cmd.SetArgs([]string{"worker", "--inject", "--hook-format", "codex"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("gc hook command failed: %v; stderr=%s", err, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty non-blocking Stop hook output", stdout.String()) + } +} + +func TestHookCommandInjectSkipsConfiguredWorkQuery(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + marker := filepath.Join(t.TempDir(), "work-query-ran") + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + cityToml := fmt.Sprintf(`[workspace] +name = "test-city" + +[[agent]] +name = "worker" +work_query = "printf ran > %q" +`, marker) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GC_CITY", cityDir) + + var stdout, stderr bytes.Buffer + cmd := newHookCmd(&stdout, &stderr) + cmd.SetArgs([]string{"worker", "--inject", "--hook-format", "codex"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("gc hook command failed: %v; stderr=%s", err, stderr.String()) } - if !strings.Contains(out, "") { - t.Errorf("stdout missing : %q", out) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("inject mode ran configured work_query; marker stat err=%v", err) } - if !strings.Contains(out, "") { - t.Errorf("stdout missing : %q", out) + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty non-blocking Stop hook output", stdout.String()) + } +} + +func TestHookCommandHookFormatIsIgnoredForNonInjectOutput(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + cityToml := `[workspace] +name = "test-city" + +[[agent]] +name = "worker" +work_query = "printf '[{\"id\":\"hw-1\",\"title\":\"Fix the bug\"}]'" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GC_CITY", cityDir) + + run := func(args ...string) (string, string, error) { + var stdout, stderr bytes.Buffer + cmd := newHookCmd(&stdout, &stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err + } + + rawOut, rawErr, err := run("worker") + if err != nil { + t.Fatalf("gc hook worker failed: %v; stderr=%s", err, rawErr) + } + formattedOut, formattedErr, err := run("worker", "--hook-format", "codex") + if err != nil { + t.Fatalf("gc hook worker --hook-format codex failed: %v; stderr=%s", err, formattedErr) + } + if formattedOut != rawOut { + t.Fatalf("hook-format changed non-inject output:\nraw: %q\nformatted: %q", rawOut, formattedOut) + } + if formattedErr != rawErr { + t.Fatalf("hook-format changed non-inject stderr:\nraw: %q\nformatted: %q", rawErr, formattedErr) + } + if strings.Contains(formattedOut, "system-reminder") { + t.Fatalf("non-inject hook output was provider-formatted: %q", formattedOut) } - if !strings.Contains(out, "hw-1") { - t.Errorf("stdout missing work item: %q", out) +} + +func TestCmdHookSessionTemplateContextDoesNotScanSessionsForName(t *testing.T) { + clearGCEnv(t) + cityDir := t.TempDir() + fakeBin := t.TempDir() + logPath := filepath.Join(t.TempDir(), "bd.log") + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + cityToml := `[workspace] +name = "test-city" + +[[agent]] +name = "worker" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } + fakeBD := filepath.Join(fakeBin, "bd") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$*\" >> %q\nprintf '[]'\n", logPath) + if err := os.WriteFile(fakeBD, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_TEMPLATE", "worker") + t.Setenv("GC_ALIAS", "worker-1") + t.Setenv("GC_SESSION_ID", "mc-session") + t.Setenv("GC_SESSION_NAME", "runtime-session") + + var stdout, stderr bytes.Buffer + code := cmdHookWithFormat(nil, false, "", &stdout, &stderr) + if code != 1 { + t.Fatalf("cmdHookWithFormat() = %d, want 1 for empty work; stderr=%s", code, stderr.String()) } - if !strings.Contains(out, "gc hook") { - t.Errorf("stdout missing 'gc hook' hint: %q", out) + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", logPath, err) } - if !strings.Contains(out, "bd update --claim") { - t.Errorf("stdout missing claim command: %q", out) + logText := string(logData) + if strings.Contains(logText, "--label=gc:session") { + t.Fatalf("gc hook scanned all session beads before running work_query:\n%s", logText) } - if !strings.Contains(out, "bd close ") { - t.Errorf("stdout missing close command: %q", out) + if !strings.Contains(logText, "--assignee=runtime-session") { + t.Fatalf("gc hook did not pass runtime session name into work_query; bd log:\n%s", logText) } } @@ -213,7 +371,7 @@ max = 5 } var stdout, stderr bytes.Buffer - code := cmdHook(nil, false, &stdout, &stderr) + code := cmdHook(nil, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -292,7 +450,7 @@ dir = "myrig" t.Setenv("BEADS_DIR", cityBeads) var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -359,7 +517,7 @@ dir = "myrig" t.Setenv("GC_DIR", rigAbs) var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -416,7 +574,7 @@ work_query = "bd {{.CityName}} {{.Rig}} {{.AgentBase}}" t.Setenv("GC_DIR", rigDir) var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -464,7 +622,7 @@ dir = "workdir" t.Setenv("GC_CITY", cityDir) var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -538,7 +696,7 @@ max = 5 } var stdout, stderr bytes.Buffer - code := cmdHook(nil, false, &stdout, &stderr) + code := cmdHook(nil, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -602,7 +760,7 @@ name = "worker" t.Setenv("GC_CITY", cityDir) var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } @@ -662,7 +820,7 @@ dir = "myrig" wantSession := cliSessionName(cityDir, "test-city", wantAgent, "") var stdout, stderr bytes.Buffer - code := cmdHook([]string{"worker"}, false, &stdout, &stderr) + code := cmdHook([]string{"worker"}, &stdout, &stderr) if code != 0 { t.Fatalf("cmdHook() = %d, want 0; stderr=%s", code, stderr.String()) } diff --git a/cmd/gc/cmd_init.go b/cmd/gc/cmd_init.go index bb8e97f7d4..bf02993d9f 100644 --- a/cmd/gc/cmd_init.go +++ b/cmd/gc/cmd_init.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/BurntSushi/toml" + "github.com/gastownhall/gascity/internal/cityinit" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/fsys" @@ -58,16 +59,7 @@ type initPackConfig struct { Global config.PackGlobal `toml:"global,omitempty"` } -var initConventionDirs = []string{ - "agents", - "commands", - "doctor", - citylayout.FormulasRoot, - citylayout.OrdersRoot, - "template-fragments", - "overlays", - "assets", -} +var initConventionDirs = cityinit.InitConventionDirs() // wizardConfig carries the results of the interactive init wizard (or defaults // for non-interactive paths). doInit uses it to decide which config to write. @@ -90,8 +82,8 @@ func canBootstrapExistingCity(wiz wizardConfig) bool { } const ( - bootstrapProfileK8sCell = "k8s-cell" - bootstrapProfileSingleHostCompat = "single-host-compat" + bootstrapProfileK8sCell = cityinit.BootstrapProfileK8sCell + bootstrapProfileSingleHostCompat = cityinit.BootstrapProfileSingleHostCompat ) // isTerminal reports whether f is connected to a terminal (not a pipe or file). @@ -381,16 +373,7 @@ func normalizeInitProvider(provider string) (string, error) { } func normalizeBootstrapProfile(profile string) (string, error) { - switch strings.TrimSpace(profile) { - case "": - return "", nil - case bootstrapProfileK8sCell, "kubernetes", "kubernetes-cell": - return bootstrapProfileK8sCell, nil - case bootstrapProfileSingleHostCompat: - return bootstrapProfileSingleHostCompat, nil - default: - return "", fmt.Errorf("unknown bootstrap profile %q", profile) - } + return cityinit.NormalizeBootstrapProfile(profile) } func initPromptTemplatePath(templatePath string) (string, bool) { @@ -1079,13 +1062,7 @@ func overrideCityName(f fsys.FS, tomlPath, name string, stderr io.Writer) int { // Priority: explicit --name flag > name set on the source/template config > // target directory basename. func resolveCityName(nameOverride, sourceName, cityPath string) string { - if n := strings.TrimSpace(nameOverride); n != "" { - return n - } - if n := strings.TrimSpace(sourceName); n != "" { - return n - } - return strings.TrimSpace(filepath.Base(cityPath)) + return cityinit.ResolveCityName(nameOverride, sourceName, cityPath) } func cmdInitFromDirWithOptions(fromDir string, args []string, nameOverride string, stdout, stderr io.Writer, skipProviderReadiness bool) int { diff --git a/cmd/gc/cmd_mail.go b/cmd/gc/cmd_mail.go index 8188c493c7..506a099294 100644 --- a/cmd/gc/cmd_mail.go +++ b/cmd/gc/cmd_mail.go @@ -9,7 +9,10 @@ import ( "os" "sort" "strings" + "sync" "text/tabwriter" + "unicode" + "unicode/utf8" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" @@ -25,6 +28,12 @@ import ( // Errors are non-fatal. type nudgeFunc func(recipient string) error +const ( + mailInjectMaxMessages = 3 + mailInjectBodyPreviewSize = 240 + mailInjectPreviewScanSize = 4096 +) + func newMailNudgeFunc(sender string) nudgeFunc { return func(recipient string) error { target, err := resolveNudgeTarget(recipient, io.Discard) @@ -73,12 +82,13 @@ hooks to deliver mail notifications into agent prompts.`, func newMailArchiveCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ - Use: "archive ", - Short: "Archive a message without reading it", - Long: `Close a message bead without displaying its contents. + Use: "archive ...", + Short: "Archive one or more messages without reading them", + Long: `Close one or more message beads without displaying their contents. -Use this to dismiss a message without reading it. The message is marked -as closed and will no longer appear in mail check or inbox results.`, +Use this to dismiss messages without reading them. Each message is marked +as closed and will no longer appear in mail check or inbox results. When +multiple IDs are passed, they are archived in a single batch round-trip.`, Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { if cmdMailArchive(args, stdout, stderr) != 0 { @@ -99,15 +109,22 @@ func cmdMailArchive(args []string, stdout, stderr io.Writer) int { return doMailArchive(mp, rec, args, stdout, stderr) } -// doMailArchive closes a message without displaying it. Accepts an -// injected provider and recorder for testability. +// doMailArchive closes one or more message beads. For a single ID the +// behavior matches the pre-batch CLI byte-for-byte; for two or more IDs it +// delegates to mp.ArchiveMany for a single-round-trip close and prints one +// result line per id. func doMailArchive(mp mail.Provider, rec events.Recorder, args []string, stdout, stderr io.Writer) int { if len(args) < 1 { fmt.Fprintln(stderr, "gc mail archive: missing message ID") //nolint:errcheck // best-effort stderr return 1 } - id := args[0] + if len(args) == 1 { + return doMailArchiveSingle(mp, rec, args[0], stdout, stderr) + } + return doMailArchiveMany(mp, rec, args, stdout, stderr) +} +func doMailArchiveSingle(mp mail.Provider, rec events.Recorder, id string, stdout, stderr io.Writer) int { if err := mp.Archive(id); err != nil { if errors.Is(err, mail.ErrAlreadyArchived) { fmt.Fprintf(stdout, "Already archived %s\n", id) //nolint:errcheck // best-effort stdout @@ -128,6 +145,36 @@ func doMailArchive(mp mail.Provider, rec events.Recorder, args []string, stdout, return 0 } +func doMailArchiveMany(mp mail.Provider, rec events.Recorder, ids []string, stdout, stderr io.Writer) int { + results, err := mp.ArchiveMany(ids) + if err != nil { + telemetry.RecordMailOp(context.Background(), "archive", err) + fmt.Fprintf(stderr, "gc mail archive: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + exit := 0 + for _, r := range results { + switch { + case r.Err == nil: + telemetry.RecordMailOp(context.Background(), "archive", nil) + rec.Record(events.Event{ + Type: events.MailArchived, + Actor: eventActor(), + Subject: r.ID, + Payload: mailEventPayload(nil), + }) + fmt.Fprintf(stdout, "Archived message %s\n", r.ID) //nolint:errcheck // best-effort stdout + case errors.Is(r.Err, mail.ErrAlreadyArchived): + fmt.Fprintf(stdout, "Already archived %s\n", r.ID) //nolint:errcheck // best-effort stdout + default: + telemetry.RecordMailOp(context.Background(), "archive", r.Err) + fmt.Fprintf(stderr, "gc mail archive %s: %v\n", r.ID, r.Err) //nolint:errcheck // best-effort stderr + exit = 1 + } + } + return exit +} + func newMailCheckCmd(stdout, stderr io.Writer) *cobra.Command { var inject bool var hookFormat string @@ -213,7 +260,7 @@ func doMailCheckTargetWithFormat(mp mail.Provider, target resolvedMailTarget, in if inject { if len(messages) > 0 { - _ = writeProviderHookContext(stdout, hookFormat, formatInjectOutput(messages)) + _ = writeProviderHookContextForEvent(stdout, hookFormat, "UserPromptSubmit", formatInjectOutput(messages)) } return 0 // --inject always exits 0 } @@ -232,20 +279,88 @@ func formatInjectOutput(messages []mail.Message) string { var sb strings.Builder sb.WriteString("\n") fmt.Fprintf(&sb, "You have %d unread message(s).\n\n", len(messages)) - for _, m := range messages { - subject := strings.TrimSpace(m.Subject) - body := strings.TrimSpace(m.Body) + limit := len(messages) + if limit > mailInjectMaxMessages { + limit = mailInjectMaxMessages + fmt.Fprintf(&sb, "Showing the first %d message(s) here; run 'gc mail inbox' for the full list.\n\n", limit) + } + for _, m := range messages[:limit] { + subject, subjectTruncated := mailInjectSubjectPreview(m.Subject) + body, bodyTruncated := mailInjectBodyPreview(m.Body) if subject != "" && subject != body { - fmt.Fprintf(&sb, "- %s from %s [%s]: %s\n", m.ID, m.From, m.Subject, m.Body) + fmt.Fprintf(&sb, "- %s from %s [%s", m.ID, m.From, subject) + if subjectTruncated { + sb.WriteString(" ... [subject truncated]") + } + fmt.Fprintf(&sb, "]: %s", body) } else { - fmt.Fprintf(&sb, "- %s from %s: %s\n", m.ID, m.From, m.Body) + fmt.Fprintf(&sb, "- %s from %s: %s", m.ID, m.From, body) } + if bodyTruncated { + sb.WriteString(" ... [preview truncated]") + } + sb.WriteByte('\n') } sb.WriteString("\nRun 'gc mail read ' for full details, or 'gc mail inbox' to see all.\n") sb.WriteString("\n") return sb.String() } +func mailInjectSubjectPreview(subject string) (string, bool) { + return mailInjectTextPreview(subject, mailInjectBodyPreviewSize) +} + +func mailInjectBodyPreview(body string) (string, bool) { + return mailInjectTextPreview(body, mailInjectBodyPreviewSize) +} + +func mailInjectTextPreview(text string, limit int) (string, bool) { + if limit <= 0 { + return "", strings.TrimSpace(text) != "" + } + + var sb strings.Builder + scanned := 0 + pendingSpace := false + for len(text) > 0 { + if scanned >= mailInjectPreviewScanSize { + return sb.String(), true + } + + r, size := utf8.DecodeRuneInString(text) + if scanned+size > mailInjectPreviewScanSize { + return sb.String(), true + } + text = text[size:] + scanned += size + + if unicode.IsSpace(r) || unicode.IsControl(r) { + if sb.Len() > 0 { + pendingSpace = true + } + continue + } + + encodedLen := utf8.RuneLen(r) + if encodedLen < 0 { + encodedLen = len(string(r)) + } + needed := encodedLen + if pendingSpace && sb.Len() > 0 { + needed++ + } + if sb.Len()+needed > limit { + return sb.String(), true + } + if pendingSpace && sb.Len() > 0 { + sb.WriteByte(' ') + pendingSpace = false + } + sb.WriteRune(r) + } + return sb.String(), false +} + func defaultMailIdentity() string { return defaultMailIdentityCandidates()[0] } @@ -313,14 +428,14 @@ func sessionMailboxAddresses(b beads.Bead) []string { return addresses } -func resolveMailIdentity(store beads.Store, identifier string) (string, error) { +func resolveMailIdentityCached(store beads.Store, identifier string, cache *mailIdentitySessionCache) (string, error) { if identifier == "" || identifier == "human" { return "human", nil } sessionID, err := resolveSessionID(store, identifier) if err != nil { if errors.Is(err, session.ErrSessionNotFound) { - if target, matched, targetErr := resolveLiveConfiguredNamedMailTarget(store, identifier); targetErr != nil { + if target, matched, targetErr := resolveLiveConfiguredNamedMailTargetCached(store, identifier, cache); targetErr != nil { return "", targetErr } else if matched { return target.display, nil @@ -343,6 +458,10 @@ func resolveMailIdentity(store beads.Store, identifier string) (string, error) { } func resolveMailIdentityWithConfig(cityPath string, cfg *config.City, store beads.Store, identifier string) (string, error) { + return resolveMailIdentityWithConfigCached(cityPath, cfg, store, identifier, nil) +} + +func resolveMailIdentityWithConfigCached(cityPath string, cfg *config.City, store beads.Store, identifier string, cache *mailIdentitySessionCache) (string, error) { if identifier == "" || identifier == "human" { return "human", nil } @@ -363,7 +482,7 @@ func resolveMailIdentityWithConfig(cityPath string, cfg *config.City, store bead return "", err } } - if target, matched, targetErr := resolveLiveConfiguredNamedMailTarget(store, identifier); targetErr != nil { + if target, matched, targetErr := resolveLiveConfiguredNamedMailTargetCached(store, identifier, cache); targetErr != nil { return "", targetErr } else if matched { return target.display, nil @@ -371,19 +490,23 @@ func resolveMailIdentityWithConfig(cityPath string, cfg *config.City, store bead if address, ok := configuredMailboxAddressWithConfig(cityPath, cfg, identifier); ok { return address, nil } - return resolveMailIdentity(store, identifier) + return resolveMailIdentityCached(store, identifier, cache) } func resolveMailRecipientIdentity(cityPath string, cfg *config.City, store beads.Store, identifier string) (string, error) { + return resolveMailRecipientIdentityCached(cityPath, cfg, store, identifier, nil) +} + +func resolveMailRecipientIdentityCached(cityPath string, cfg *config.City, store beads.Store, identifier string, cache *mailIdentitySessionCache) (string, error) { if identifier == "" || identifier == "human" { return "human", nil } - if target, matched, targetErr := resolveLiveConfiguredNamedMailTarget(store, identifier); targetErr != nil { + if target, matched, targetErr := resolveLiveConfiguredNamedMailTargetCached(store, identifier, cache); targetErr != nil { return "", targetErr } else if matched { return target.display, nil } - return resolveMailIdentityWithConfig(cityPath, cfg, store, identifier) + return resolveMailIdentityWithConfigCached(cityPath, cfg, store, identifier, cache) } func configuredMailboxAddress(identifier string) (string, bool) { @@ -415,14 +538,12 @@ func configuredMailboxAddressWithConfig(cityPath string, cfg *config.City, ident return spec.Identity, true } -func listLiveSessionMailboxes(store beads.Store) (map[string]bool, error) { +func listLiveSessionMailboxesCached(store beads.Store, cache *mailIdentitySessionCache) (map[string]bool, error) { recipients := map[string]bool{"human": true} if store == nil { return recipients, nil } - all, err := store.List(beads.ListQuery{ - Label: session.LabelSession, - }) + all, err := listMailIdentitySessions(store, cache) if err != nil { return nil, err } @@ -442,14 +563,89 @@ type resolvedMailTarget struct { recipients []string } -func resolveLiveConfiguredNamedMailTarget(store beads.Store, identifier string) (resolvedMailTarget, bool, error) { +func mailSenderRouteMetadata(store beads.Store, sender string) (map[string]string, error) { + sender = strings.TrimSpace(sender) + if store == nil || sender == "" || sender == "human" { + return nil, nil + } + sessionID, err := resolveSessionID(store, sender) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrAmbiguous) { + return nil, nil + } + return nil, fmt.Errorf("resolving sender route %q: %w", sender, err) + } + b, err := store.Get(sessionID) + if err != nil { + return nil, fmt.Errorf("loading sender session %q: %w", sessionID, err) + } + display := mailSenderDisplayAddress(b, sender) + return map[string]string{ + mail.FromSessionIDMetadataKey: sessionID, + mail.FromDisplayMetadataKey: display, + }, nil +} + +func mailSenderDisplayAddress(b beads.Bead, fallback string) string { + if alias := strings.TrimSpace(b.Metadata["alias"]); alias != "" { + return alias + } + fallback = strings.TrimSpace(fallback) + if fallback != "" && fallback != b.ID { + return fallback + } + if name := strings.TrimSpace(b.Metadata["session_name"]); name != "" { + return name + } + if b.ID != "" { + return b.ID + } + return fallback +} + +func mailSenderDisplayFromMetadata(fallback string, metadata map[string]string) string { + if metadata != nil { + if display := strings.TrimSpace(metadata[mail.FromDisplayMetadataKey]); display != "" { + return display + } + } + return strings.TrimSpace(fallback) +} + +// mailIdentitySessionCache memoizes a single gc:session enumeration so that +// repeated identity-resolution attempts (multi-candidate retry, sender + +// recipient resolution in the same command, etc.) share the same broad scan. +// A nil cache disables memoization; the zero value memoizes on first use. +type mailIdentitySessionCache struct { + mu sync.Mutex + list []beads.Bead + fetched bool +} + +func listMailIdentitySessions(store beads.Store, cache *mailIdentitySessionCache) ([]beads.Bead, error) { + if cache == nil { + return store.List(beads.ListQuery{Label: session.LabelSession}) + } + cache.mu.Lock() + defer cache.mu.Unlock() + if cache.fetched { + return cache.list, nil + } + list, err := store.List(beads.ListQuery{Label: session.LabelSession}) + if err != nil { + return nil, err + } + cache.list = list + cache.fetched = true + return list, nil +} + +func resolveLiveConfiguredNamedMailTargetCached(store beads.Store, identifier string, cache *mailIdentitySessionCache) (resolvedMailTarget, bool, error) { identifier = normalizeNamedSessionTarget(identifier) if store == nil || identifier == "" || identifier == "human" || strings.Contains(identifier, "/") { return resolvedMailTarget{}, false, nil } - all, err := store.List(beads.ListQuery{ - Label: session.LabelSession, - }) + all, err := listMailIdentitySessions(store, cache) if err != nil { return resolvedMailTarget{}, false, err } @@ -494,13 +690,17 @@ func resolveLiveConfiguredNamedMailTarget(store beads.Store, identifier string) } func resolveMailTargets(store beads.Store, identifier string) (resolvedMailTarget, error) { + return resolveMailTargetsCached(store, identifier, nil) +} + +func resolveMailTargetsCached(store beads.Store, identifier string, cache *mailIdentitySessionCache) (resolvedMailTarget, error) { if identifier == "" || identifier == "human" { return resolvedMailTarget{display: "human", recipients: []string{"human"}}, nil } sessionID, err := resolveSessionID(store, identifier) if err != nil { if errors.Is(err, session.ErrSessionNotFound) { - if target, matched, targetErr := resolveLiveConfiguredNamedMailTarget(store, identifier); targetErr != nil { + if target, matched, targetErr := resolveLiveConfiguredNamedMailTargetCached(store, identifier, cache); targetErr != nil { return resolvedMailTarget{}, targetErr } else if matched { return target, nil @@ -559,8 +759,11 @@ func resolveDefaultMailTargetsForCommand(stderr io.Writer, cmdName string) (reso _ = code return resolvedMailTarget{}, false } + // Memoize the gc:session enumeration so multi-candidate retry shares one + // broad scan instead of issuing one per candidate (ga-q6ct Layer 2). + cache := &mailIdentitySessionCache{} for _, c := range candidates { - target, err := resolveMailTargets(store, c) + target, err := resolveMailTargetsCached(store, c, cache) if err == nil { return target, true } @@ -574,9 +777,13 @@ func resolveDefaultMailTargetsForCommand(stderr io.Writer, cmdName string) (reso } func resolveDefaultMailSenderForCommand(cityPath string, cfg *config.City, store beads.Store, stderr io.Writer, cmdName string) (string, bool) { + return resolveDefaultMailSenderForCommandCached(cityPath, cfg, store, stderr, cmdName, nil) +} + +func resolveDefaultMailSenderForCommandCached(cityPath string, cfg *config.City, store beads.Store, stderr io.Writer, cmdName string, cache *mailIdentitySessionCache) (string, bool) { candidates := defaultMailIdentityCandidates() for _, c := range candidates { - sender, err := resolveMailIdentityWithConfig(cityPath, cfg, store, c) + sender, err := resolveMailIdentityWithConfigCached(cityPath, cfg, store, c, cache) if err == nil { return sender, true } @@ -689,6 +896,10 @@ func collectMailCounts(count func(string) (int, int, error), recipients []string return total, unread, nil } +type multiRecipientMailCounter interface { + CountRecipients([]string) (int, int, error) +} + func newMailSendCmd(stdout, stderr io.Writer) *cobra.Command { var notify bool var all bool @@ -723,6 +934,8 @@ Use --all to broadcast to all live sessions (excluding sender and "human").`, }, } cmd.Flags().BoolVar(¬ify, "notify", false, "nudge the recipient after sending") + cmd.Flags().BoolVar(¬ify, "nudge", false, "alias for --notify") + _ = cmd.Flags().MarkHidden("nudge") cmd.Flags().BoolVar(&all, "all", false, "broadcast to all live sessions (excludes sender and human)") cmd.Flags().StringVar(&from, "from", "", "sender identity (default: $GC_SESSION_ID, $GC_ALIAS, $GC_AGENT, or \"human\")") cmd.Flags().StringVar(&to, "to", "", "recipient address (alternative to positional argument)") @@ -796,6 +1009,7 @@ func newMailReplyCmd(stdout, stderr io.Writer) *cobra.Command { Long: `Reply to a message. The reply is addressed to the original sender. Inherits the thread ID from the original message for conversation tracking. +Use --notify to nudge the recipient after replying. Use -s/--subject for the reply subject and -m/--message for the reply body.`, Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { @@ -808,6 +1022,8 @@ Use -s/--subject for the reply subject and -m/--message for the reply body.`, cmd.Flags().StringVarP(&subject, "subject", "s", "", "reply subject line") cmd.Flags().StringVarP(&message, "message", "m", "", "reply body text") cmd.Flags().BoolVar(¬ify, "notify", false, "nudge the recipient after replying") + cmd.Flags().BoolVar(¬ify, "nudge", false, "alias for --notify") + _ = cmd.Flags().MarkHidden("nudge") return cmd } @@ -843,10 +1059,12 @@ func newMailMarkUnreadCmd(stdout, stderr io.Writer) *cobra.Command { func newMailDeleteCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ - Use: "delete ", - Short: "Delete a message (closes the bead)", - Long: `Delete a message by closing the bead. Same effect as archive but with different user intent.`, - Args: cobra.ArbitraryArgs, + Use: "delete ...", + Short: "Delete one or more messages (closes the beads)", + Long: `Delete one or more messages by closing the beads. Same effect as archive +but with different user intent. When multiple IDs are passed, they are +deleted in a single batch round-trip.`, + Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { if cmdMailDelete(args, stdout, stderr) != 0 { return errExit @@ -858,9 +1076,9 @@ func newMailDeleteCmd(stdout, stderr io.Writer) *cobra.Command { func newMailThreadCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ - Use: "thread ", + Use: "thread ", Short: "List all messages in a thread", - Long: `Show all messages sharing a thread ID, ordered by time.`, + Long: `Show all messages sharing a thread ID or message ID, ordered by time.`, Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { if cmdMailThread(args, stdout, stderr) != 0 { @@ -913,8 +1131,12 @@ func cmdMailSend(args []string, notify bool, all bool, from string, to string, s fmt.Fprintf(stderr, "gc mail send: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } + // Memoize the gc:session enumeration so identity resolution (sender + + // recipient + listLiveSessionMailboxes) shares one broad scan instead of + // issuing one per call site (ga-q6ct Layer 3). + idCache := &mailIdentitySessionCache{} if store != nil { - validRecipients, err = listLiveSessionMailboxes(store) + validRecipients, err = listLiveSessionMailboxesCached(store, idCache) if err != nil { fmt.Fprintf(stderr, "gc mail send: listing live sessions: %v\n", err) //nolint:errcheck // best-effort stderr return 1 @@ -925,7 +1147,7 @@ func cmdMailSend(args []string, notify bool, all bool, from string, to string, s if sender == "" { if store != nil { var ok bool - sender, ok = resolveDefaultMailSenderForCommand(cityPath, cfg, store, stderr, "gc mail send") + sender, ok = resolveDefaultMailSenderForCommandCached(cityPath, cfg, store, stderr, "gc mail send", idCache) if !ok { return 1 } @@ -933,7 +1155,7 @@ func cmdMailSend(args []string, notify bool, all bool, from string, to string, s sender = defaultMailIdentity() } } else if sender != "human" && store != nil { - sender, err = resolveMailIdentityWithConfig(cityPath, cfg, store, sender) + sender, err = resolveMailIdentityWithConfigCached(cityPath, cfg, store, sender, idCache) if err != nil { fmt.Fprintf(stderr, "gc mail send: invalid sender %q: %v\n", sender, err) //nolint:errcheck // best-effort stderr return 1 @@ -963,7 +1185,7 @@ func cmdMailSend(args []string, notify bool, all bool, from string, to string, s } } if !all && len(args) > 0 && store != nil { - canonicalTo, err := resolveMailRecipientIdentity(cityPath, cfg, store, args[0]) + canonicalTo, err := resolveMailRecipientIdentityCached(cityPath, cfg, store, args[0], idCache) if err != nil { fmt.Fprintf(stderr, "gc mail send: unknown recipient %q: %v\n", args[0], err) //nolint:errcheck // best-effort stderr return 1 @@ -1016,7 +1238,7 @@ func doMailSend(mp mail.Provider, rec events.Recorder, validRecipients map[strin } rec.Record(events.Event{ Type: events.MailSent, - Actor: sender, + Actor: m.From, Subject: m.ID, Message: to, Payload: mailEventPayload(&m), @@ -1071,7 +1293,7 @@ func doMailSendAll(mp mail.Provider, rec events.Recorder, validRecipients map[st } rec.Record(events.Event{ Type: events.MailSent, - Actor: sender, + Actor: m.From, Subject: m.ID, Message: to, Payload: mailEventPayload(&m), @@ -1206,25 +1428,46 @@ func cmdMailReply(args []string, subject, message string, notify bool, stdout, s rec := openCityRecorder(stderr) sender := defaultMailIdentity() - var hasStore bool - if sender != "human" { - if !isStorelessMailProvider() { - hasStore = true - store, storeCode := openCityStore(stderr, "gc mail reply") + providerName := mailProviderName() + var store beads.Store + var cityPath string + var cfg *config.City + var notifySetupErr error + if sender != "human" || notify { + switch { + case strings.HasPrefix(providerName, "exec:"): + var err error + cityPath, err = resolveCity() + if err == nil { + cfg, _ = loadCityConfig(cityPath, stderr) + store, err = openCityStoreAt(cityPath) + } + if err != nil { + notifySetupErr = err + store = nil + } + case !isStorelessMailProvider(): + var storeCode int + store, storeCode = openCityStore(stderr, "gc mail reply") if store == nil { return storeCode } - cityPath, err := resolveCity() + var err error + cityPath, err = resolveCity() if err != nil { fmt.Fprintf(stderr, "gc mail reply: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } - cfg, _ := loadCityConfig(cityPath, stderr) - resolved, ok := resolveDefaultMailSenderForCommand(cityPath, cfg, store, stderr, "gc mail reply") - if !ok { - return 1 + cfg, _ = loadCityConfig(cityPath, stderr) + } + if sender != "human" { + if store != nil { + resolved, ok := resolveDefaultMailSenderForCommand(cityPath, cfg, store, stderr, "gc mail reply") + if !ok { + return 1 + } + sender = resolved } - sender = resolved } } @@ -1235,8 +1478,10 @@ func cmdMailReply(args []string, subject, message string, notify bool, stdout, s } var nf nudgeFunc - if notify && hasStore { + if notify && store != nil { nf = newMailNudgeFunc(sender) + } else if notify && strings.HasPrefix(providerName, "exec:") && notifySetupErr != nil { + fmt.Fprintf(stderr, "gc mail reply: --notify requested but no city store available; nudge skipped: %v\n", notifySetupErr) //nolint:errcheck // best-effort stderr } return doMailReply(mp, rec, args[0], sender, subject, body, nf, stdout, stderr) @@ -1252,7 +1497,7 @@ func doMailReply(mp mail.Provider, rec events.Recorder, id, sender, subject, bod } rec.Record(events.Event{ Type: events.MailReplied, - Actor: sender, + Actor: reply.From, Subject: reply.ID, Message: reply.To, Payload: mailEventPayload(&reply), @@ -1343,13 +1588,22 @@ func cmdMailDelete(args []string, stdout, stderr io.Writer) int { return doMailDelete(mp, rec, args, stdout, stderr) } -// doMailDelete closes a message bead (same as archive but different intent). +// doMailDelete closes one or more message beads (same as archive but +// different intent). Single-id behavior matches the pre-batch CLI +// byte-for-byte; multi-id uses mp.DeleteMany to preserve provider delete +// semantics. func doMailDelete(mp mail.Provider, rec events.Recorder, args []string, stdout, stderr io.Writer) int { if len(args) < 1 { fmt.Fprintln(stderr, "gc mail delete: missing message ID") //nolint:errcheck // best-effort stderr return 1 } - id := args[0] + if len(args) == 1 { + return doMailDeleteSingle(mp, rec, args[0], stdout, stderr) + } + return doMailDeleteMany(mp, rec, args, stdout, stderr) +} + +func doMailDeleteSingle(mp mail.Provider, rec events.Recorder, id string, stdout, stderr io.Writer) int { if err := mp.Delete(id); err != nil { if errors.Is(err, mail.ErrAlreadyArchived) { fmt.Fprintf(stdout, "Already deleted %s\n", id) //nolint:errcheck // best-effort stdout @@ -1370,6 +1624,36 @@ func doMailDelete(mp mail.Provider, rec events.Recorder, args []string, stdout, return 0 } +func doMailDeleteMany(mp mail.Provider, rec events.Recorder, ids []string, stdout, stderr io.Writer) int { + results, err := mp.DeleteMany(ids) + if err != nil { + telemetry.RecordMailOp(context.Background(), "delete", err) + fmt.Fprintf(stderr, "gc mail delete: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + exit := 0 + for _, r := range results { + switch { + case r.Err == nil: + telemetry.RecordMailOp(context.Background(), "delete", nil) + rec.Record(events.Event{ + Type: events.MailDeleted, + Actor: eventActor(), + Subject: r.ID, + Payload: mailEventPayload(nil), + }) + fmt.Fprintf(stdout, "Deleted message %s\n", r.ID) //nolint:errcheck // best-effort stdout + case errors.Is(r.Err, mail.ErrAlreadyArchived): + fmt.Fprintf(stdout, "Already deleted %s\n", r.ID) //nolint:errcheck // best-effort stdout + default: + telemetry.RecordMailOp(context.Background(), "delete", r.Err) + fmt.Fprintf(stderr, "gc mail delete %s: %v\n", r.ID, r.Err) //nolint:errcheck // best-effort stderr + exit = 1 + } + } + return exit +} + // cmdMailThread lists messages in a thread. func cmdMailThread(args []string, stdout, stderr io.Writer) int { mp, code := openCityMailProvider(stderr, "gc mail thread") @@ -1382,19 +1666,19 @@ func cmdMailThread(args []string, stdout, stderr io.Writer) int { // doMailThread shows all messages in a thread. func doMailThread(mp mail.Provider, args []string, stdout, stderr io.Writer) int { if len(args) < 1 { - fmt.Fprintln(stderr, "gc mail thread: missing thread ID") //nolint:errcheck // best-effort stderr + fmt.Fprintln(stderr, "gc mail thread: missing thread or message ID") //nolint:errcheck // best-effort stderr return 1 } - threadID := args[0] + id := args[0] - msgs, err := mp.Thread(threadID) + msgs, err := mp.Thread(id) if err != nil { fmt.Fprintf(stderr, "gc mail thread: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } if len(msgs) == 0 { - fmt.Fprintf(stdout, "No messages in thread %s\n", threadID) //nolint:errcheck // best-effort stdout + fmt.Fprintf(stdout, "No messages in thread %s\n", id) //nolint:errcheck // best-effort stdout return 0 } @@ -1433,7 +1717,13 @@ func doMailCount(mp mail.Provider, recipient string, stdout, stderr io.Writer) i } func doMailCountTarget(mp mail.Provider, target resolvedMailTarget, stdout, stderr io.Writer) int { - total, unread, err := collectMailCounts(mp.Count, target.recipients) + var total, unread int + var err error + if counter, ok := mp.(multiRecipientMailCounter); ok { + total, unread, err = counter.CountRecipients(target.recipients) + } else { + total, unread, err = collectMailCounts(mp.Count, target.recipients) + } if err != nil { fmt.Fprintf(stderr, "gc mail count: %v\n", err) //nolint:errcheck // best-effort stderr return 1 diff --git a/cmd/gc/cmd_mail_test.go b/cmd/gc/cmd_mail_test.go index 2e51d16a61..f065f7d1b1 100644 --- a/cmd/gc/cmd_mail_test.go +++ b/cmd/gc/cmd_mail_test.go @@ -10,12 +10,15 @@ import ( "syscall" "testing" "time" + "unicode/utf8" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/mail" "github.com/gastownhall/gascity/internal/mail/beadmail" + mailexec "github.com/gastownhall/gascity/internal/mail/exec" + "github.com/gastownhall/gascity/internal/nudgequeue" "github.com/gastownhall/gascity/internal/session" ) @@ -35,7 +38,13 @@ func (countOnlyMailProvider) Read(string) (mail.Message, error) { panic("unex func (countOnlyMailProvider) MarkRead(string) error { panic("unexpected MarkRead") } func (countOnlyMailProvider) MarkUnread(string) error { panic("unexpected MarkUnread") } func (countOnlyMailProvider) Archive(string) error { panic("unexpected Archive") } -func (countOnlyMailProvider) Delete(string) error { panic("unexpected Delete") } +func (countOnlyMailProvider) ArchiveMany([]string) ([]mail.ArchiveResult, error) { + panic("unexpected ArchiveMany") +} +func (countOnlyMailProvider) Delete(string) error { panic("unexpected Delete") } +func (countOnlyMailProvider) DeleteMany([]string) ([]mail.ArchiveResult, error) { + panic("unexpected DeleteMany") +} func (countOnlyMailProvider) Check(string) ([]mail.Message, error) { panic("unexpected Check") } func (countOnlyMailProvider) Reply(string, string, string, string) (mail.Message, error) { panic("unexpected Reply") @@ -353,6 +362,86 @@ func TestResolveDefaultMailTargetsForCommand_FallsBackToGCAliasWhenSessionIDMiss } } +func TestResolveDefaultMailSenderForCommand_UsesDisplayAliasBeforeSessionName(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_MAIL", "") + + cityPath := t.TempDir() + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Setenv("GC_CITY", cityPath) + + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + b, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-1", + "session_name": "workflows__codex-min-mc-abc123", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + cfg, _ := loadCityConfig(cityPath) + + t.Setenv("GC_SESSION_ID", b.ID) + t.Setenv("GC_ALIAS", "gascity/workflows.codex-min-1") + t.Setenv("GC_AGENT", "gascity/workflows.codex-min-1") + + var stderr bytes.Buffer + sender, ok := resolveDefaultMailSenderForCommand(cityPath, cfg, store, &stderr, "gc mail send") + if !ok { + t.Fatalf("resolveDefaultMailSenderForCommand() = not ok; stderr=%q", stderr.String()) + } + if sender != "gascity/workflows.codex-min-1" { + t.Fatalf("sender = %q, want display alias", sender) + } +} + +func TestResolveMailIdentityWithConfig_ExplicitAliasUsesDisplayAlias(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_MAIL", "") + + cityPath := t.TempDir() + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Setenv("GC_CITY", cityPath) + + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-16", + "session_name": "workflows__codex-min-mc-explicit", + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + cfg, _ := loadCityConfig(cityPath) + + for _, from := range []string{"gascity/workflows.codex-min-16", "workflows.codex-min-16"} { + t.Run(from, func(t *testing.T) { + sender, err := resolveMailIdentityWithConfig(cityPath, cfg, store, from) + if err != nil { + t.Fatalf("resolveMailIdentityWithConfig(%q): %v", from, err) + } + if sender != "gascity/workflows.codex-min-16" { + t.Fatalf("sender = %q, want display alias", sender) + } + }) + } +} + func TestResolveDefaultMailSenderForCommand_FallsBackToGCAliasWhenSessionIDMissing(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_MAIL", "") @@ -407,14 +496,15 @@ func TestCmdMailSendDefaultSenderFallsBackToGCAliasWhenSessionIDMissing(t *testi if err != nil { t.Fatalf("openCityStoreAt: %v", err) } - if _, err := store.Create(beads.Bead{ + senderBead, err := store.Create(beads.Bead{ Type: session.BeadType, Labels: []string{session.LabelSession}, Metadata: map[string]string{ "alias": "sender", "session_name": "sender-gc-42", }, - }); err != nil { + }) + if err != nil { t.Fatalf("Create sender: %v", err) } if _, err := store.Create(beads.Bead{ @@ -460,6 +550,12 @@ func TestCmdMailSendDefaultSenderFallsBackToGCAliasWhenSessionIDMissing(t *testi if msg.From != "sender" { t.Fatalf("message From = %q, want sender", msg.From) } + if msg.Metadata["mail.from_session_id"] != senderBead.ID { + t.Fatalf("mail.from_session_id = %q, want %q", msg.Metadata["mail.from_session_id"], senderBead.ID) + } + if msg.Metadata["mail.from_display"] != "sender" { + t.Fatalf("mail.from_display = %q, want sender", msg.Metadata["mail.from_display"]) + } if msg.Assignee != "recipient" { t.Fatalf("message Assignee = %q, want recipient", msg.Assignee) } @@ -1404,6 +1500,246 @@ func TestCmdMailReply_FallsBackToGCSessionIDWhenAliasMissing(t *testing.T) { } } +func TestCmdMailReplyHumanNotifyQueuesNudge(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_MAIL", "") + t.Setenv("GC_SESSION", "fake") + t.Setenv("GC_ALIAS", "") + t.Setenv("GC_SESSION_ID", "") + t.Setenv("GC_AGENT", "") + + cityPath := t.TempDir() + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Setenv("GC_CITY", cityPath) + + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + sessionBead, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "alice", + "session_name": "alice-session", + "provider": "fake", + }, + }) + if err != nil { + t.Fatalf("Create(session): %v", err) + } + + mp := beadmail.New(store) + original, err := mp.Send("alice", "human", "Hello", "first") + if err != nil { + t.Fatalf("mp.Send(): %v", err) + } + + var stdout, stderr bytes.Buffer + code := cmdMailReply([]string{original.ID, "reply body"}, "", "", true, &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdMailReply() = %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "to alice") { + t.Fatalf("stdout = %q, want reply addressed to alice", stdout.String()) + } + + state, err := nudgequeue.LoadState(cityPath) + if err != nil { + t.Fatalf("LoadState(): %v", err) + } + if len(state.Pending) != 1 { + t.Fatalf("pending nudges = %d, want 1; state=%+v stderr=%s", len(state.Pending), state, stderr.String()) + } + nudge := state.Pending[0] + if nudge.Agent != "alice" { + t.Fatalf("nudge.Agent = %q, want alice", nudge.Agent) + } + if nudge.SessionID != sessionBead.ID { + t.Fatalf("nudge.SessionID = %q, want %q", nudge.SessionID, sessionBead.ID) + } + if nudge.Source != "mail" { + t.Fatalf("nudge.Source = %q, want mail", nudge.Source) + } + if nudge.Message != "You have mail from human" { + t.Fatalf("nudge.Message = %q", nudge.Message) + } +} + +func TestCmdMailReplyExecProviderNotifyQueuesNudge(t *testing.T) { + cityPath, sessionID, script := setupExecMailReplyNudgeTest(t) + t.Setenv("GC_MAIL", "exec:"+script) + + var stdout, stderr bytes.Buffer + code := cmdMailReply([]string{"gc-1", "reply body"}, "", "", true, &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdMailReply() = %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + + assertQueuedMailNudge(t, cityPath, sessionID, stderr.String()) +} + +func TestMailReplyNudgeAliasQueuesNudge(t *testing.T) { + cityPath, sessionID, script := setupExecMailReplyNudgeTest(t) + t.Setenv("GC_MAIL", "exec:"+script) + + var stdout, stderr bytes.Buffer + cmd := newMailReplyCmd(&stdout, &stderr) + if cmd.Flags().Lookup("nudge") == nil { + t.Fatal("reply command missing --nudge alias") + } + cmd.SetArgs([]string{"gc-1", "--nudge", "reply body"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("reply --nudge: %v; stdout=%s stderr=%s", err, stdout.String(), stderr.String()) + } + + assertQueuedMailNudge(t, cityPath, sessionID, stderr.String()) +} + +func TestCmdMailReplyExecProviderNotifyWithoutCityWarnsAndSendsReply(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_MAIL", "exec:"+writeExecReplyScript(t)) + t.Setenv("GC_SESSION", "fake") + t.Setenv("GC_CITY", "") + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_ALIAS", "") + t.Setenv("GC_SESSION_ID", "") + t.Setenv("GC_AGENT", "") + t.Chdir(t.TempDir()) + + var stdout, stderr bytes.Buffer + code := cmdMailReply([]string{"gc-1", "reply body"}, "", "", true, &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdMailReply() = %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "Replied to gc-1") { + t.Fatalf("stdout = %q, want reply confirmation", stdout.String()) + } + if !strings.Contains(stderr.String(), "--notify requested but no city store available") { + t.Fatalf("stderr = %q, want notify warning", stderr.String()) + } +} + +func TestCmdMailReplyExecProviderNotifyResolvesNonHumanSender(t *testing.T) { + cityPath, sessionID, script := setupExecMailReplyNudgeTest(t) + t.Setenv("GC_MAIL", "exec:"+script) + t.Setenv("GC_SESSION_ID", "bob-session") + + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "bob", + "session_name": "bob-session", + "provider": "fake", + }, + }); err != nil { + t.Fatalf("Create(sender session): %v", err) + } + + var stdout, stderr bytes.Buffer + code := cmdMailReply([]string{"gc-1", "reply body"}, "", "", true, &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdMailReply() = %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + + assertQueuedMailNudgeMessage(t, cityPath, sessionID, "You have mail from bob", stderr.String()) +} + +func setupExecMailReplyNudgeTest(t *testing.T) (string, string, string) { + t.Helper() + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + t.Setenv("GC_ALIAS", "") + t.Setenv("GC_SESSION_ID", "") + t.Setenv("GC_AGENT", "") + + cityPath := t.TempDir() + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"test-city\"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Setenv("GC_CITY", cityPath) + t.Setenv("GC_CITY_PATH", cityPath) + + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + sessionBead, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "alice", + "session_name": "alice-session", + "provider": "fake", + }, + }) + if err != nil { + t.Fatalf("Create(session): %v", err) + } + + return cityPath, sessionBead.ID, writeExecReplyScript(t) +} + +func writeExecReplyScript(t *testing.T) string { + t.Helper() + script := filepath.Join(t.TempDir(), "mail-exec") + data := `#!/bin/sh +case "$1" in + ensure-running) + exit 0 + ;; + reply) + cat >/dev/null + printf '{"id":"exec-reply-1","from":"human","to":"alice","subject":"RE: Hello","body":"reply body","created_at":"2026-04-28T00:00:00Z","read":false,"thread_id":"thread-1","reply_to":"%s"}\n' "$2" + exit 0 + ;; + *) + exit 2 + ;; +esac +` + if err := os.WriteFile(script, []byte(data), 0o755); err != nil { + t.Fatalf("WriteFile(exec script): %v", err) + } + return script +} + +func assertQueuedMailNudge(t *testing.T, cityPath, sessionID, stderr string) { + t.Helper() + assertQueuedMailNudgeMessage(t, cityPath, sessionID, "You have mail from human", stderr) +} + +func assertQueuedMailNudgeMessage(t *testing.T, cityPath, sessionID, message, stderr string) { + t.Helper() + state, err := nudgequeue.LoadState(cityPath) + if err != nil { + t.Fatalf("LoadState(): %v", err) + } + if len(state.Pending) != 1 { + t.Fatalf("pending nudges = %d, want 1; state=%+v stderr=%s", len(state.Pending), state, stderr) + } + nudge := state.Pending[0] + if nudge.Agent != "alice" { + t.Fatalf("nudge.Agent = %q, want alice", nudge.Agent) + } + if nudge.SessionID != sessionID { + t.Fatalf("nudge.SessionID = %q, want %q", nudge.SessionID, sessionID) + } + if nudge.Source != "mail" { + t.Fatalf("nudge.Source = %q, want mail", nudge.Source) + } + if nudge.Message != message { + t.Fatalf("nudge.Message = %q", nudge.Message) + } +} + // --- gc mail mark-read / mark-unread --- func TestMailMarkReadSuccess(t *testing.T) { @@ -1454,6 +1790,105 @@ func TestMailDeleteSuccess(t *testing.T) { } } +func TestMailDeleteMultiSuccess(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + for i := 0; i < 3; i++ { + if _, err := mp.Send("human", "mayor", "", "batch me"); err != nil { + t.Fatalf("Send %d: %v", i, err) + } + } + + var stdout, stderr bytes.Buffer + rec := &memRecorder{} + code := doMailDelete(mp, rec, []string{"gc-1", "gc-2", "gc-3"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("doMailDelete = %d, want 0; stderr: %s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"Deleted message gc-1", "Deleted message gc-2", "Deleted message gc-3"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q:\n%s", want, out) + } + } + if n := len(rec.events); n != 3 { + t.Errorf("recorded events = %d, want 3", n) + } + for _, id := range []string{"gc-1", "gc-2", "gc-3"} { + b, err := store.Get(id) + if err != nil { + t.Fatalf("Get(%s): %v", id, err) + } + if b.Status != "closed" { + t.Errorf("bead %s Status = %q, want closed", id, b.Status) + } + } +} + +func TestMailDeleteMultiPartialFailure(t *testing.T) { + mp := mail.NewFake() + m1, _ := mp.Send("human", "mayor", "", "one") + m2, _ := mp.Send("human", "mayor", "", "two") + if err := mp.Archive(m2.ID); err != nil { + t.Fatalf("pre-archive m2: %v", err) + } + + var stdout, stderr bytes.Buffer + code := doMailDelete(mp, events.Discard, []string{m1.ID, m2.ID, "ghost"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("doMailDelete = %d, want 1; stderr: %s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "Deleted message "+m1.ID) { + t.Errorf("stdout missing Deleted for m1:\n%s", out) + } + if !strings.Contains(out, "Already deleted "+m2.ID) { + t.Errorf("stdout missing Already deleted for m2:\n%s", out) + } + if !strings.Contains(stderr.String(), "gc mail delete ghost") { + t.Errorf("stderr missing per-id error for ghost:\n%s", stderr.String()) + } +} + +func TestMailDeleteMultiExecProviderUsesDeleteCommand(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "ops.log") + scriptPath := filepath.Join(dir, "mail-provider") + script := fmt.Sprintf(`#!/bin/sh +set -eu +op="$1" +case "$op" in + ensure-running) + ;; + archive|delete) + printf '%%s %%s\n' "$op" "$2" >> %q + ;; + *) + echo "unexpected op $op" >&2 + exit 2 + ;; +esac +`, logPath) + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile(script): %v", err) + } + + mp := mailexec.NewProvider(scriptPath) + var stdout, stderr bytes.Buffer + code := doMailDelete(mp, events.Discard, []string{"msg-1", "msg-2"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("doMailDelete = %d, want 0; stderr: %s", code, stderr.String()) + } + gotBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile(log): %v", err) + } + want := "delete msg-1\ndelete msg-2\n" + if got := string(gotBytes); got != want { + t.Fatalf("exec operations = %q, want %q", got, want) + } +} + // --- gc mail thread --- func TestMailThreadSuccess(t *testing.T) { @@ -1657,6 +2092,57 @@ func TestMailArchiveAlreadyClosed(t *testing.T) { } } +func TestMailArchiveMultiSuccess(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + for i := 0; i < 3; i++ { + if _, err := mp.Send("human", "mayor", "", "batch"); err != nil { + t.Fatalf("Send %d: %v", i, err) + } + } + + var stdout, stderr bytes.Buffer + rec := &memRecorder{} + code := doMailArchive(mp, rec, []string{"gc-1", "gc-2", "gc-3"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("doMailArchive = %d, want 0; stderr: %s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"Archived message gc-1", "Archived message gc-2", "Archived message gc-3"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q:\n%s", want, out) + } + } + if n := len(rec.events); n != 3 { + t.Errorf("recorded events = %d, want 3", n) + } +} + +func TestMailArchiveMultiPartialFailure(t *testing.T) { + mp := mail.NewFake() + m1, _ := mp.Send("human", "mayor", "", "one") + m2, _ := mp.Send("human", "mayor", "", "two") + if err := mp.Archive(m2.ID); err != nil { + t.Fatalf("pre-archive: %v", err) + } + + var stdout, stderr bytes.Buffer + code := doMailArchive(mp, events.Discard, []string{m1.ID, m2.ID, "ghost"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("doMailArchive = %d, want 1; stderr: %s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "Archived message "+m1.ID) { + t.Errorf("stdout missing Archived for m1:\n%s", out) + } + if !strings.Contains(out, "Already archived "+m2.ID) { + t.Errorf("stdout missing Already archived for m2:\n%s", out) + } + if !strings.Contains(stderr.String(), "gc mail archive ghost") { + t.Errorf("stderr missing per-id error for ghost:\n%s", stderr.String()) + } +} + // --- gc mail send --notify --- func TestMailSendNotifySuccess(t *testing.T) { @@ -1838,6 +2324,17 @@ func TestMailSendToFlag(t *testing.T) { } } +func TestMailSendAcceptsNudgeAlias(t *testing.T) { + var stdout, stderr bytes.Buffer + cmd := newMailSendCmd(&stdout, &stderr) + if cmd.Flags().Lookup("nudge") == nil { + t.Fatal("send command missing --nudge alias") + } + if err := cmd.Flags().Set("nudge", "true"); err != nil { + t.Fatalf("set --nudge: %v", err) + } +} + // --- gc mail send --all --- func TestMailSendAll(t *testing.T) { @@ -2041,6 +2538,143 @@ func TestMailCheckInjectFormatsMessages(t *testing.T) { } } +func TestMailCheckInjectLimitsMessageCount(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + mp.Send("sender-a", "recipient", "", "first") //nolint:errcheck + mp.Send("sender-b", "recipient", "", "second") //nolint:errcheck + mp.Send("sender-c", "recipient", "", "third") //nolint:errcheck + mp.Send("sender-d", "recipient", "", "fourth") //nolint:errcheck + + var stdout bytes.Buffer + code := doMailCheck(mp, "recipient", true, &stdout, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("doMailCheck = %d, want 0", code) + } + + out := stdout.String() + for _, want := range []string{"4 unread message(s)", "gc-1 from sender-a", "gc-2 from sender-b", "gc-3 from sender-c", "Showing the first 3 message(s)"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "gc-4") || strings.Contains(out, "fourth") { + t.Errorf("stdout should not include the fourth message:\n%s", out) + } +} + +func TestMailCheckInjectTruncatesLongBodies(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + longBody := "prefix " + strings.Repeat("x", mailInjectBodyPreviewSize+100) + mp.Send("sender-a", "recipient", "Long body", longBody) //nolint:errcheck + + var stdout bytes.Buffer + code := doMailCheck(mp, "recipient", true, &stdout, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("doMailCheck = %d, want 0", code) + } + + out := stdout.String() + if !strings.Contains(out, "Long body") { + t.Errorf("stdout missing subject:\n%s", out) + } + if !strings.Contains(out, "... [preview truncated]") { + t.Errorf("stdout missing truncation marker:\n%s", out) + } + if strings.Contains(out, strings.Repeat("x", mailInjectBodyPreviewSize+80)) { + t.Errorf("stdout includes too much of the long body:\n%s", out) + } +} + +func TestMailCheckInjectCompactsAndBoundsLongSubjects(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + longSubject := "subject\n\tline " + strings.Repeat("x", mailInjectBodyPreviewSize+100) + " tail" + mp.Send("sender-a", "recipient", longSubject, "short body") //nolint:errcheck + + var stdout bytes.Buffer + code := doMailCheck(mp, "recipient", true, &stdout, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("doMailCheck = %d, want 0", code) + } + + out := stdout.String() + if !strings.Contains(out, "[subject line ") { + t.Fatalf("stdout missing compacted subject prefix:\n%s", out) + } + if strings.Contains(out, "subject\n\tline") { + t.Fatalf("stdout contains raw multiline subject:\n%s", out) + } + if strings.Contains(out, strings.Repeat("x", mailInjectBodyPreviewSize+80)) { + t.Fatalf("stdout includes too much of the long subject:\n%s", out) + } + if !strings.Contains(out, "... [subject truncated]") { + t.Fatalf("stdout missing subject truncation marker:\n%s", out) + } +} + +func TestMailCheckInjectOmitsSubjectWhenFullBodyMatches(t *testing.T) { + store := beads.NewMemStore() + mp := beadmail.New(store) + longBody := strings.Repeat("x", mailInjectBodyPreviewSize+100) + mp.Send("sender-a", "recipient", longBody, longBody) //nolint:errcheck + + var stdout bytes.Buffer + code := doMailCheck(mp, "recipient", true, &stdout, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("doMailCheck = %d, want 0", code) + } + + out := stdout.String() + if strings.Contains(out, "["+longBody+"]") { + t.Errorf("stdout should not repeat a matching subject after body truncation:\n%s", out) + } + if !strings.Contains(out, "gc-1 from sender-a: ") { + t.Errorf("stdout missing compact message format:\n%s", out) + } + if !strings.Contains(out, "... [preview truncated]") { + t.Errorf("stdout missing truncation marker:\n%s", out) + } +} + +func TestMailInjectBodyPreviewUsesBoundedScan(t *testing.T) { + body := strings.Repeat(" ", mailInjectPreviewScanSize+1) + "tail" + preview, truncated := mailInjectBodyPreview(body) + if !truncated { + t.Fatalf("mailInjectBodyPreview did not truncate after scan budget") + } + if preview != "" { + t.Fatalf("mailInjectBodyPreview = %q, want empty preview after leading-space budget", preview) + } +} + +func TestMailInjectBodyPreviewCompactsWhitespace(t *testing.T) { + preview, truncated := mailInjectBodyPreview(" first\n\tsecond third ") + if truncated { + t.Fatalf("mailInjectBodyPreview truncated short body") + } + if preview != "first second third" { + t.Fatalf("mailInjectBodyPreview = %q, want %q", preview, "first second third") + } +} + +func TestMailInjectBodyPreviewKeepsUTF8Boundary(t *testing.T) { + prefix := strings.Repeat("a", mailInjectBodyPreviewSize-1) + compact := prefix + "界tail" + + preview, truncated := mailInjectBodyPreview(compact) + if !truncated { + t.Fatalf("mailInjectBodyPreview did not truncate long body") + } + if preview != prefix { + t.Fatalf("mailInjectBodyPreview = %q, want %q", preview, prefix) + } + if !utf8.ValidString(preview) { + t.Fatalf("mailInjectBodyPreview returned invalid UTF-8: %q", preview) + } +} + func TestMailCheckInjectDoesNotCloseBeads(t *testing.T) { store := beads.NewMemStore() mp := beadmail.New(store) @@ -2098,3 +2732,127 @@ func TestMailCheckInjectFiltersCorrectly(t *testing.T) { t.Errorf("stdout missing correct count:\n%s", out) } } + +// --- ga-q6ct: identity-resolution session-list cache --- + +// countingMailIdentityListStore counts broad gc:session List calls (the same +// query the cmd_mail identity-resolution path issues) so tests can assert the +// per-command cache budget. +type countingMailIdentityListStore struct { + beads.Store + sessionListCalls int +} + +func (s *countingMailIdentityListStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.Label == session.LabelSession && len(query.Metadata) == 0 { + s.sessionListCalls++ + } + return s.Store.List(query) +} + +func TestResolveLiveConfiguredNamedMailTargetCached_SharesCacheAcrossCalls(t *testing.T) { + // Pin: when a single command invocation resolves multiple identity + // candidates (or recipient + sender both), the broad gc:session + // enumeration runs at most once via the shared cache. + base := beads.NewMemStore() + store := &countingMailIdentityListStore{Store: base} + + if _, err := base.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + namedSessionIdentityMetadata: "gascity/builder", + "alias": "builder-1", + }, + }); err != nil { + t.Fatalf("Create session: %v", err) + } + + cache := &mailIdentitySessionCache{} + for _, id := range []string{"unmatched-a", "unmatched-b", "unmatched-c"} { + if _, _, err := resolveLiveConfiguredNamedMailTargetCached(store, id, cache); err != nil { + t.Fatalf("resolve(%q): %v", id, err) + } + } + + if store.sessionListCalls != 1 { + t.Errorf("broad gc:session List calls = %d, want 1 (cache must dedupe across resolutions)", store.sessionListCalls) + } +} + +func TestResolveLiveConfiguredNamedMailTargetCached_NilCacheStillFetches(t *testing.T) { + // Backward-compat: passing nil cache should still resolve correctly, + // issuing a broad scan per call (the legacy behavior). + base := beads.NewMemStore() + store := &countingMailIdentityListStore{Store: base} + + for _, id := range []string{"a", "b"} { + if _, _, err := resolveLiveConfiguredNamedMailTargetCached(store, id, nil); err != nil { + t.Fatalf("resolve(%q): %v", id, err) + } + } + + if store.sessionListCalls != 2 { + t.Errorf("broad gc:session List calls = %d, want 2 (no cache → per-call scan)", store.sessionListCalls) + } +} + +func TestListLiveSessionMailboxesCached_UsesCache(t *testing.T) { + // Pin: listLiveSessionMailboxesCached + a sibling resolve call sharing + // the same cache hit the store at most once for the broad enumeration. + base := beads.NewMemStore() + store := &countingMailIdentityListStore{Store: base} + + if _, err := base.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + namedSessionIdentityMetadata: "gascity/mayor", + "alias": "mayor", + }, + }); err != nil { + t.Fatalf("Create session: %v", err) + } + + cache := &mailIdentitySessionCache{} + if _, err := listLiveSessionMailboxesCached(store, cache); err != nil { + t.Fatalf("listLiveSessionMailboxesCached: %v", err) + } + if _, _, err := resolveLiveConfiguredNamedMailTargetCached(store, "no-match", cache); err != nil { + t.Fatalf("resolveLiveConfiguredNamedMailTargetCached: %v", err) + } + + if store.sessionListCalls != 1 { + t.Errorf("broad gc:session List calls = %d, want 1 across listLiveSessionMailboxes + resolve sharing one cache", store.sessionListCalls) + } +} + +func TestResolveMailIdentityWithConfigCached_SharedCacheSurvivesFallbackMiss(t *testing.T) { + // Pin: the shared cache must stay in effect even when identity resolution + // misses every shortcut and falls back to the generic resolution path. + base := beads.NewMemStore() + store := &countingMailIdentityListStore{Store: base} + + if _, err := base.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + namedSessionIdentityMetadata: "gascity/worker", + "alias": "worker", + }, + }); err != nil { + t.Fatalf("Create session: %v", err) + } + + cache := &mailIdentitySessionCache{} + if _, err := listLiveSessionMailboxesCached(store, cache); err != nil { + t.Fatalf("listLiveSessionMailboxesCached: %v", err) + } + if _, err := resolveMailIdentityWithConfigCached("", nil, store, "no-match", cache); !errors.Is(err, session.ErrSessionNotFound) { + t.Fatalf("resolveMailIdentityWithConfigCached(no-match) error = %v, want ErrSessionNotFound", err) + } + + if store.sessionListCalls != 1 { + t.Errorf("broad gc:session List calls = %d, want 1 across listLiveSessionMailboxes + fallback miss resolution", store.sessionListCalls) + } +} diff --git a/cmd/gc/cmd_nudge.go b/cmd/gc/cmd_nudge.go index 149b95b4f0..d38bed1e4a 100644 --- a/cmd/gc/cmd_nudge.go +++ b/cmd/gc/cmd_nudge.go @@ -84,6 +84,13 @@ func (t nudgeTarget) agentKey() string { return t.sessionName } +func (t nudgeTarget) pollerKey() string { + if t.sessionID != "" { + return t.sessionID + } + return t.agentKey() +} + func (t nudgeTarget) queueKeys() []string { var keys []string seen := map[string]bool{} @@ -357,7 +364,7 @@ func cmdNudgeDrainWithFormat(args []string, inject bool, hookFormat string, stdo } var writeErr error if inject { - writeErr = writeProviderHookContext(stdout, hookFormat, out) + writeErr = writeProviderHookContextForEvent(stdout, hookFormat, "UserPromptSubmit", out) } else { _, writeErr = io.WriteString(stdout, out) } @@ -450,7 +457,7 @@ func cmdNudgePoll(args []string, sessionName string, interval, quiescence time.D return 0 } missingSince = time.Time{} - delivered, pollErr := tryDeliverQueuedNudgesByPoller(target, store, sp, quiescence) + delivered, pollErr := tryDeliverQueuedNudgesByPoller(target, store, sp, quiescence, obs) if pollErr != nil { fmt.Fprintf(stderr, "gc nudge poll: %v\n", pollErr) //nolint:errcheck } @@ -719,8 +726,8 @@ func parseNudgeDeliveryMode(raw string) (nudgeDeliveryMode, error) { } } -func tryDeliverQueuedNudgesByPoller(target nudgeTarget, store beads.Store, sp runtime.Provider, quiescence time.Duration) (bool, error) { - if !pollerSessionIdleEnough(target, store, sp, quiescence) { +func tryDeliverQueuedNudgesByPoller(target nudgeTarget, store beads.Store, sp runtime.Provider, quiescence time.Duration, obs worker.LiveObservation) (bool, error) { + if !pollerSessionIdleEnough(target, sp, quiescence, obs) { return false, nil } items, err := claimDueQueuedNudgesForTarget(target.cityPath, target, time.Now()) @@ -779,12 +786,25 @@ func tryDeliverQueuedNudgesByPoller(target nudgeTarget, store beads.Store, sp ru return true, ackQueuedNudges(target.cityPath, queuedNudgeIDs(items)) } -func pollerSessionIdleEnough(target nudgeTarget, store beads.Store, sp runtime.Provider, quiescence time.Duration) bool { - obs, err := workerObserveNudgeTarget(target, store, sp) - if err != nil || obs.LastActivity == nil || obs.LastActivity.IsZero() { +func pollerSessionIdleEnough(target nudgeTarget, sp runtime.Provider, quiescence time.Duration, obs worker.LiveObservation) bool { + if quiescence <= 0 { + return true + } + if obs.LastActivity != nil && !obs.LastActivity.IsZero() { + return time.Since(*obs.LastActivity) >= quiescence + } + if target.sessionName == "" { + return false + } + waiter, ok := sp.(runtime.IdleWaitProvider) + if !ok { return false } - return time.Since(*obs.LastActivity) >= quiescence + // The poller may take up to the quiescence window to exit while this + // runtime idle check is in progress. + ctx, cancel := context.WithTimeout(context.Background(), quiescence) + defer cancel() + return waiter.WaitForIdle(ctx, target.sessionName, quiescence) == nil } func maybeStartNudgePoller(target nudgeTarget) { @@ -794,7 +814,7 @@ func maybeStartNudgePoller(target nudgeTarget) { if target.sessionTransport() == "acp" { return } - if err := startNudgePoller(target.cityPath, target.agentKey(), target.sessionName); err != nil { + if err := startNudgePoller(target.cityPath, target.pollerKey(), target.sessionName); err != nil { return } } @@ -1393,9 +1413,9 @@ func pruneExpiredQueuedNudges(state *nudgeQueueState, store beads.Store, now tim item.LastError = "expired" } state.Dead = append(state.Dead, item) - if err := markQueuedNudgeTerminal(store, item, "expired", item.LastError, "", now); err != nil { - return err - } + // Best-effort: remove expired item from pending even if bead update fails. + // A failed bead update here would trap the item in pending forever. + _ = markQueuedNudgeTerminal(store, item, "expired", item.LastError, "", now) continue } filtered = append(filtered, item) @@ -1414,9 +1434,8 @@ func recoverExpiredInFlightNudges(state *nudgeQueueState, store beads.Store, now item.LastError = "expired" } state.Dead = append(state.Dead, item) - if err := markQueuedNudgeTerminal(store, item, "expired", item.LastError, "", now); err != nil { - return err - } + // Best-effort: remove expired item from in-flight even if bead update fails. + _ = markQueuedNudgeTerminal(store, item, "expired", item.LastError, "", now) continue } if item.LeaseUntil.IsZero() || !item.LeaseUntil.After(now) { diff --git a/cmd/gc/cmd_nudge_test.go b/cmd/gc/cmd_nudge_test.go index 07e739b201..5a5f126001 100644 --- a/cmd/gc/cmd_nudge_test.go +++ b/cmd/gc/cmd_nudge_test.go @@ -15,12 +15,9 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" + "github.com/gastownhall/gascity/internal/worker" ) -type noActivityCapabilityProvider struct { - *runtime.Fake -} - func intPtrNudge(n int) *int { return &n } type missingNudgeBeadStore struct { @@ -42,6 +39,25 @@ func (s *missingNudgeBeadStore) Close(id string) error { return s.MemStore.Close(id) } +type ambiguousNudgeBeadStore struct { + *beads.MemStore + ambiguousID string +} + +func (s *ambiguousNudgeBeadStore) SetMetadataBatch(id string, kvs map[string]string) error { + if id == s.ambiguousID { + return fmt.Errorf("setting metadata on %q: exit status 1: Error resolving %s: ambiguous ID %q matches 86 issues: [gc-170 gc-171 gc-172 ...]\nUse more characters to disambiguate", id, id, id) + } + return s.MemStore.SetMetadataBatch(id, kvs) +} + +func (s *ambiguousNudgeBeadStore) Close(id string) error { + if id == s.ambiguousID { + return fmt.Errorf("closing bead %q: exit status 1: Error resolving %s: ambiguous ID %q matches 86 issues: [gc-170 gc-171 gc-172 ...]\nUse more characters to disambiguate", id, id, id) + } + return s.MemStore.Close(id) +} + type unrelatedNotFoundNudgeBeadStore struct { *beads.MemStore errorID string @@ -54,10 +70,6 @@ func (s *unrelatedNotFoundNudgeBeadStore) SetMetadataBatch(id string, kvs map[st return s.MemStore.SetMetadataBatch(id, kvs) } -func (p *noActivityCapabilityProvider) Capabilities() runtime.ProviderCapabilities { - return runtime.ProviderCapabilities{} -} - func TestMarkQueuedNudgeTerminalFallsBackWhenStoredBeadIDEmpty(t *testing.T) { store := beads.NewMemStore() item := queuedNudge{ @@ -193,6 +205,75 @@ func TestPruneExpiredQueuedNudgesIgnoresMissingTerminalBead(t *testing.T) { } } +func TestMarkQueuedNudgeTerminalHandlesAmbiguousBeadID(t *testing.T) { + store := &ambiguousNudgeBeadStore{MemStore: beads.NewMemStore(), ambiguousID: "gc-17"} + item := queuedNudge{ + ID: "nudge-ambiguous", + Agent: "wendy.wendy", + SessionID: "mc-ayq6xi", + Source: "session", + Message: "follow up", + BeadID: "gc-17", + CreatedAt: time.Now().Add(-time.Minute).UTC(), + } + createdID, created, err := ensureQueuedNudgeBead(store, item) + if err != nil { + t.Fatalf("ensureQueuedNudgeBead: %v", err) + } + if !created { + t.Fatal("expected ensureQueuedNudgeBead to create a backing nudge bead") + } + + now := time.Now().UTC() + item.LastError = "expired" + if err := markQueuedNudgeTerminal(store, item, "expired", "expired", "", now); err != nil { + t.Fatalf("markQueuedNudgeTerminal with ambiguous BeadID: %v", err) + } + + bead, err := store.Get(createdID) + if err != nil { + t.Fatalf("Get(%q): %v", createdID, err) + } + if bead.Status != "closed" { + t.Fatalf("bead.Status = %q, want closed", bead.Status) + } + if bead.Metadata["state"] != "expired" { + t.Fatalf("state = %q, want expired", bead.Metadata["state"]) + } +} + +func TestPruneExpiredQueuedNudgesWithAmbiguousBeadIDContinues(t *testing.T) { + // Regression: stale entries with short bead IDs (e.g. "gc-17") that match many + // beads in a large store used to abort the entire nudge processing loop. + store := &ambiguousNudgeBeadStore{MemStore: beads.NewMemStore(), ambiguousID: "gc-17"} + now := time.Now().UTC() + state := &nudgeQueueState{ + Pending: []queuedNudge{ + { + ID: "nudge-ambiguous", + BeadID: "gc-17", + Agent: "gc-ub35o", + SessionID: "gc-ub35o", + Source: "session", + Message: "Run gc hook", + CreatedAt: now.Add(-8 * 24 * time.Hour), + DeliverAfter: now.Add(-8 * 24 * time.Hour), + ExpiresAt: now.Add(-7 * 24 * time.Hour), + }, + }, + } + + if err := pruneExpiredQueuedNudges(state, store, now); err != nil { + t.Fatalf("pruneExpiredQueuedNudges: %v", err) + } + if len(state.Pending) != 0 { + t.Fatalf("pending = %d, want 0 (stale entry must be pruned)", len(state.Pending)) + } + if len(state.Dead) != 1 { + t.Fatalf("dead = %d, want 1", len(state.Dead)) + } +} + func TestDeliverSessionNudgeWithProviderWaitIdleQueuesForCodex(t *testing.T) { t.Setenv("GC_BEADS", "file") dir := t.TempDir() @@ -485,16 +566,49 @@ func TestDeliverSessionNudgeWithProviderWaitIdleStartsClaudePollerWhenQueued(t * } } -func TestPollerSessionIdleEnoughUsesLastActivityWithoutCapabilityFlag(t *testing.T) { +func TestPollerSessionIdleEnoughUsesSuppliedLastActivity(t *testing.T) { + target := nudgeTarget{sessionName: "sess-worker"} + last := time.Now().Add(-5 * time.Second) + obs := worker.LiveObservation{LastActivity: &last} + + if !pollerSessionIdleEnough(target, nil, 3*time.Second, obs) { + t.Fatal("pollerSessionIdleEnough = false, want true when supplied last activity is old enough") + } + + recent := time.Now().Add(-1 * time.Second) + obs.LastActivity = &recent + if pollerSessionIdleEnough(target, nil, 3*time.Second, obs) { + t.Fatal("pollerSessionIdleEnough = true, want false when supplied last activity is too recent") + } +} + +func TestPollerSessionIdleEnoughFallsBackToIdleWaitWhenActivityUnavailable(t *testing.T) { fake := runtime.NewFake() if err := fake.Start(context.Background(), "sess-worker", runtime.Config{}); err != nil { t.Fatalf("Start: %v", err) } - fake.SetActivity("sess-worker", time.Now().Add(-5*time.Second)) + fake.WaitForIdleErrors["sess-worker"] = nil target := nudgeTarget{sessionName: "sess-worker"} + obs := worker.LiveObservation{} - if !pollerSessionIdleEnough(target, nil, &noActivityCapabilityProvider{Fake: fake}, 3*time.Second) { - t.Fatal("pollerSessionIdleEnough = false, want true when last activity is old enough") + if !pollerSessionIdleEnough(target, fake, 3*time.Second, obs) { + t.Fatal("pollerSessionIdleEnough = false, want idle wait fallback to allow delivery") + } + + var sawWait bool + for _, call := range fake.Calls { + if call.Method == "WaitForIdle" && call.Name == "sess-worker" { + sawWait = true + break + } + } + if !sawWait { + t.Fatalf("calls = %#v, want WaitForIdle fallback", fake.Calls) + } + + fake.WaitForIdleErrors["sess-worker"] = errors.New("timed out waiting for idle") + if pollerSessionIdleEnough(target, fake, 3*time.Second, obs) { + t.Fatal("pollerSessionIdleEnough = true, want idle wait error to suppress delivery") } } @@ -757,6 +871,61 @@ func TestSendMailNotifyWithProviderStartsClaudePollerWhenQueueingRunningSession( } } +func TestSendMailNotifyWithWorkerStartsPollerBySessionIDForAliasedTarget(t *testing.T) { + t.Setenv("GC_BEADS", "file") + dir := t.TempDir() + store := openNudgeBeadStore(dir) + fake := runtime.NewFake() + mgr := newSessionManagerWithConfig(dir, store, fake, nil) + info, err := mgr.Create(context.Background(), "mayor", "Mayor", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := mgr.Start(context.Background(), info.ID, "", runtime.Config{WorkDir: dir}); err != nil { + t.Fatalf("Start: %v", err) + } + if err := store.SetMetadata(info.ID, "alias", "mayor"); err != nil { + t.Fatalf("SetMetadata(alias): %v", err) + } + target := nudgeTarget{ + cityPath: dir, + alias: "mayor", + agent: config.Agent{Name: "mayor", MaxActiveSessions: intPtrNudge(1)}, + sessionID: info.ID, + resolved: &config.ResolvedProvider{Name: "codex"}, + sessionName: info.SessionName, + } + + called := false + prev := startNudgePoller + startNudgePoller = func(cityPath, agentName, sessionName string) error { + called = true + if cityPath != dir || agentName != info.ID || sessionName != info.SessionName { + t.Fatalf("unexpected poller args city=%q agent=%q session=%q", cityPath, agentName, sessionName) + } + return nil + } + t.Cleanup(func() { startNudgePoller = prev }) + + if err := sendMailNotifyWithWorker(target, store, fake, "human"); err != nil { + t.Fatalf("sendMailNotifyWithWorker: %v", err) + } + if !called { + t.Fatal("startNudgePoller was not called") + } + + pending, inFlight, dead, err := listQueuedNudgesForTarget(dir, target, time.Now()) + if err != nil { + t.Fatalf("listQueuedNudgesForTarget: %v", err) + } + if len(pending) != 1 || len(inFlight) != 0 || len(dead) != 0 { + t.Fatalf("pending/inFlight/dead = %d/%d/%d, want 1/0/0", len(pending), len(inFlight), len(dead)) + } + if pending[0].Agent != "mayor" || pending[0].SessionID != info.ID { + t.Fatalf("queued nudge agent/session = %q/%q, want mayor/%s", pending[0].Agent, pending[0].SessionID, info.ID) + } +} + func TestSendMailNotifyWithProviderWaitIdleWrapsDirectDeliveryInSystemReminder(t *testing.T) { t.Setenv("GC_BEADS", "file") dir := t.TempDir() @@ -981,7 +1150,8 @@ func TestTryDeliverQueuedNudgesByPollerDeliversAndAcks(t *testing.T) { if err := mgr.Start(context.Background(), info.ID, "", runtime.Config{WorkDir: dir}); err != nil { t.Fatalf("Start: %v", err) } - fake.Activity = map[string]time.Time{info.SessionName: time.Now().Add(-10 * time.Second)} + idleSince := time.Now().Add(-10 * time.Second) + fake.Activity = map[string]time.Time{info.SessionName: idleSince} target := nudgeTarget{ cityPath: dir, @@ -990,8 +1160,9 @@ func TestTryDeliverQueuedNudgesByPollerDeliversAndAcks(t *testing.T) { resolved: &config.ResolvedProvider{Name: "codex"}, sessionName: info.SessionName, } + obs := worker.LiveObservation{Running: true, LastActivity: &idleSince} - delivered, err := tryDeliverQueuedNudgesByPoller(target, store, fake, 3*time.Second) + delivered, err := tryDeliverQueuedNudgesByPoller(target, store, fake, 3*time.Second, obs) if err != nil { t.Fatalf("tryDeliverQueuedNudgesByPoller: %v", err) } @@ -1042,7 +1213,8 @@ func TestTryDeliverQueuedNudgesByPollerLeavesACPDeliveryUnwrapped(t *testing.T) if err := fake.Start(context.Background(), "sess-worker", runtime.Config{}); err != nil { t.Fatalf("Start: %v", err) } - fake.Activity = map[string]time.Time{"sess-worker": time.Now().Add(-10 * time.Second)} + idleSince := time.Now().Add(-10 * time.Second) + fake.Activity = map[string]time.Time{"sess-worker": idleSince} target := nudgeTarget{ cityPath: dir, @@ -1051,8 +1223,9 @@ func TestTryDeliverQueuedNudgesByPollerLeavesACPDeliveryUnwrapped(t *testing.T) resolved: &config.ResolvedProvider{Name: "codex"}, sessionName: "sess-worker", } + obs := worker.LiveObservation{Running: true, LastActivity: &idleSince} - delivered, err := tryDeliverQueuedNudgesByPoller(target, openNudgeBeadStore(dir), fake, 3*time.Second) + delivered, err := tryDeliverQueuedNudgesByPoller(target, openNudgeBeadStore(dir), fake, 3*time.Second, obs) if err != nil { t.Fatalf("tryDeliverQueuedNudgesByPoller: %v", err) } diff --git a/cmd/gc/cmd_order.go b/cmd/gc/cmd_order.go index 1a3c7d09aa..c103f19700 100644 --- a/cmd/gc/cmd_order.go +++ b/cmd/gc/cmd_order.go @@ -32,7 +32,7 @@ tick and dispatches work when a trigger opens.`, Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { if len(args) == 0 { - fmt.Fprintln(stderr, "gc order: missing subcommand (list, show, run, check, history)") //nolint:errcheck // best-effort stderr + fmt.Fprintln(stderr, "gc order: missing subcommand (list, show, run, check, history, sweep-tracking)") //nolint:errcheck // best-effort stderr } else { fmt.Fprintf(stderr, "gc order: unknown subcommand %q\n", args[0]) //nolint:errcheck // best-effort stderr } @@ -45,6 +45,7 @@ tick and dispatches work when a trigger opens.`, newOrderRunCmd(stdout, stderr), newOrderCheckCmd(stdout, stderr), newOrderHistoryCmd(stdout, stderr), + newOrderSweepTrackingCmd(stdout, stderr), ) return cmd } @@ -84,8 +85,10 @@ Use --rig to disambiguate same-name orders in different rigs.`, } return nil }, + ValidArgsFunction: completeOrderNames, } cmd.Flags().StringVar(&rig, "rig", "", "rig name to disambiguate same-name orders") + _ = cmd.RegisterFlagCompletionFunc("rig", completeRigFlagNames) return cmd } @@ -107,8 +110,10 @@ Use --rig to disambiguate same-name orders in different rigs.`, } return nil }, + ValidArgsFunction: completeOrderNames, } cmd.Flags().StringVar(&rig, "rig", "", "rig name to disambiguate same-name orders") + _ = cmd.RegisterFlagCompletionFunc("rig", completeRigFlagNames) return cmd } @@ -150,8 +155,33 @@ name. Use --rig to filter by rig.`, } return nil }, + ValidArgsFunction: completeOrderNames, } cmd.Flags().StringVar(&rig, "rig", "", "rig name to filter order history") + _ = cmd.RegisterFlagCompletionFunc("rig", completeRigFlagNames) + return cmd +} + +func newOrderSweepTrackingCmd(stdout, stderr io.Writer) *cobra.Command { + staleAfter := defaultOrderTrackingSweepStaleAfter + quiet := false + cmd := &cobra.Command{ + Use: "sweep-tracking", + Short: "Close stale order-tracking beads", + Long: `Close stale open order-tracking beads. + +This is intended for maintenance exec orders. It only closes tracking beads +older than --stale-after so a fresh in-flight order is not interrupted.`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if cmdOrderSweepTracking(staleAfter, quiet, stdout, stderr) != 0 { + return errExit + } + return nil + }, + } + cmd.Flags().DurationVar(&staleAfter, "stale-after", defaultOrderTrackingSweepStaleAfter, "minimum age for an open tracking bead to be closed") + cmd.Flags().BoolVar(&quiet, "quiet", false, "suppress success output") return cmd } @@ -416,6 +446,18 @@ func cmdOrderRun(name, rig string, stdout, stderr io.Writer) int { return 1 } if a.IsExec() { + if a.Trigger == "event" { + store, storeCode := openOrderStoreForOrder(cityPath, cfg, a, stderr, "gc order run") + if store == nil { + return storeCode + } + ep, epCode := openCityEventsProvider(stderr, "gc order run") + if ep == nil { + return epCode + } + defer ep.Close() //nolint:errcheck // best-effort + return doOrderRunExecTracked(a, cityPath, cfg, store, ep, stdout, stderr) + } return doOrderRunExec(a, cityPath, cfg, stdout, stderr) } store, storeCode := openOrderStoreForOrder(cityPath, cfg, a, stderr, "gc order run") @@ -448,13 +490,19 @@ func doOrderRun(aa []orders.Order, name, rig, cityPath string, store beads.Store fmt.Fprintf(stderr, "gc order run: %v\n", cfgErr) //nolint:errcheck // best-effort stderr return 1 } - return doOrderRunExec(a, cityPath, cfg, stdout, stderr) + return doOrderRunExecTracked(a, cityPath, cfg, store, ep, stdout, stderr) } - // Capture event head before wisp creation (race-free cursor). + // Capture event head before wisp creation (race-free cursor). Event runs + // fail closed when the cursor cannot be read. var headSeq uint64 if a.Trigger == "event" && ep != nil { - headSeq, _ = ep.LatestSeq() + var err error + headSeq, err = ep.LatestSeq() + if err != nil { + fmt.Fprintf(stderr, "gc order run: reading event cursor for %s: %v\n", a.ScopedName(), err) //nolint:errcheck // best-effort stderr + return 1 + } } scoped := a.ScopedName() @@ -486,8 +534,16 @@ func doOrderRun(aa []orders.Order, name, rig, cityPath string, store beads.Store return 1 } + var pool string + if a.Pool != "" { + pool, err = qualifyOrderPool(a, cfg) + if err != nil { + fmt.Fprintf(stderr, "gc order run: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + } + if a.Pool != "" && cfg != nil { - pool := qualifyPool(a.Pool, a.Rig) if err := applyGraphRouting(recipe, nil, pool, nil, "", "", "", "", store, cityName, cityPath, cfg); err != nil { fmt.Fprintf(stderr, "gc order run: routing decoration failed: %v\n", err) //nolint:errcheck // best-effort stderr } @@ -512,9 +568,7 @@ func doOrderRun(aa []orders.Order, name, rig, cityPath string, store beads.Store ) } if a.Pool != "" { - update.Metadata = map[string]string{ - "gc.routed_to": qualifyPool(a.Pool, a.Rig), - } + update.Metadata = map[string]string{"gc.routed_to": pool} } if err := store.Update(rootID, update); err != nil { fmt.Fprintf(stderr, "gc order run: labeling wisp: %v\n", err) //nolint:errcheck // best-effort stderr @@ -523,12 +577,52 @@ func doOrderRun(aa []orders.Order, name, rig, cityPath string, store beads.Store fmt.Fprintf(stdout, "Order %q executed: wisp %s", name, rootID) //nolint:errcheck if a.Pool != "" { - fmt.Fprintf(stdout, " → gc.routed_to=%s", qualifyPool(a.Pool, a.Rig)) //nolint:errcheck + fmt.Fprintf(stdout, " → gc.routed_to=%s", pool) //nolint:errcheck } fmt.Fprintln(stdout) //nolint:errcheck return 0 } +func doOrderRunExecTracked(a orders.Order, cityPath string, cfg *config.City, store beads.Store, ep events.Provider, stdout, stderr io.Writer) int { + if a.Trigger != "event" || ep == nil { + return doOrderRunExec(a, cityPath, cfg, stdout, stderr) + } + + scoped := a.ScopedName() + headSeq, err := ep.LatestSeq() + if err != nil { + fmt.Fprintf(stderr, "gc order run: reading event cursor for %s: %v\n", scoped, err) //nolint:errcheck // best-effort stderr + return 1 + } + tracking, err := store.Create(beads.Bead{ + Title: "order:" + scoped, + Labels: []string{"order-run:" + scoped, labelOrderTracking}, + }) + if err != nil { + fmt.Fprintf(stderr, "gc order run: creating exec tracking bead for %s: %v\n", scoped, err) //nolint:errcheck // best-effort stderr + return 1 + } + defer store.Close(tracking.ID) //nolint:errcheck // best-effort close + + // Persist the event cursor before running the command so manual event execs + // do not leave the controller cursor stale after the side effect. + if err := store.Update(tracking.ID, beads.UpdateOpts{Labels: eventCursorLabels(scoped, headSeq)}); err != nil { + fmt.Fprintf(stderr, "gc order run: labeling exec event cursor for %s: %v\n", scoped, err) //nolint:errcheck // best-effort stderr + return 1 + } + + code := doOrderRunExec(a, cityPath, cfg, stdout, stderr) + labels := []string{"exec"} + if code != 0 { + labels = []string{"exec-failed"} + } + if err := store.Update(tracking.ID, beads.UpdateOpts{Labels: labels}); err != nil { + fmt.Fprintf(stderr, "gc order run: labeling exec tracking bead for %s: %v\n", scoped, err) //nolint:errcheck // best-effort stderr + return 1 + } + return code +} + // doOrderRunExec runs an exec order directly via shell. func doOrderRunExec(a orders.Order, cityPath string, cfg *config.City, stdout, stderr io.Writer) int { var maxTimeout time.Duration @@ -574,7 +668,7 @@ func cmdOrderCheck(stdout, stderr io.Writer) int { return epCode } defer ep.Close() //nolint:errcheck // best-effort - return doOrderCheckWithStoresResolver(aa, time.Now(), ep, cachedOrderStoresResolver(cityPath, cfg), stdout, stderr) + return doOrderCheckWithStoresResolverScoped(cityPath, cfg, aa, time.Now(), ep, cachedOrderStoresResolver(cityPath, cfg), stdout, stderr) } // orderLastRunFn returns a LastRunFunc that queries BdStore for the most @@ -638,6 +732,10 @@ func doOrderCheck(aa []orders.Order, now time.Time, lastRunFn orders.LastRunFunc } func doOrderCheckWithStoresResolver(aa []orders.Order, now time.Time, ep events.Provider, resolveStores orderStoresResolver, stdout, stderr io.Writer) int { + return doOrderCheckWithStoresResolverScoped("", nil, aa, now, ep, resolveStores, stdout, stderr) +} + +func doOrderCheckWithStoresResolverScoped(cityPath string, cfg *config.City, aa []orders.Order, now time.Time, ep events.Provider, resolveStores orderStoresResolver, stdout, stderr io.Writer) int { if len(aa) == 0 { fmt.Fprintln(stdout, "No orders found.") //nolint:errcheck // best-effort stdout return 1 @@ -676,7 +774,12 @@ func doOrderCheckWithStoresResolver(aa []orders.Order, now time.Time, ep events. return cursor } } - result := orders.CheckTrigger(a, now, lastRunFn, ep, cursorFn) + triggerOpts, err := orderTriggerOptions(cityPath, cfg, a) + if err != nil { + fmt.Fprintf(stderr, "gc order check: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + result := orders.CheckTriggerWithOptions(a, now, lastRunFn, ep, cursorFn, triggerOpts) if lastRunErr != nil { fmt.Fprintf(stderr, "gc order check: reading last run for %s: %v\n", a.ScopedName(), lastRunErr) //nolint:errcheck // best-effort stderr return 1 @@ -835,6 +938,34 @@ func doOrderHistoryWithStoresResolver(name, rig string, aa []orders.Order, resol return 0 } +// --- gc order sweep-tracking --- + +func cmdOrderSweepTracking(staleAfter time.Duration, quiet bool, stdout, stderr io.Writer) int { + if staleAfter <= 0 { + fmt.Fprintln(stderr, "gc order sweep-tracking: --stale-after must be positive") //nolint:errcheck // best-effort stderr + return 1 + } + cityPath, err := resolveCity() + if err != nil { + fmt.Fprintf(stderr, "gc order sweep-tracking: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + store, err := openStoreAtForCity(cityPath, cityPath) + if err != nil { + fmt.Fprintf(stderr, "gc order sweep-tracking: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + closed, err := sweepStaleOrderTracking(store, time.Now(), staleAfter, nil, orderTrackingSweepMetadataInitiator) + if err != nil { + fmt.Fprintf(stderr, "gc order sweep-tracking: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + if !quiet { + fmt.Fprintf(stdout, "closed %d stale order-tracking bead(s)\n", closed) //nolint:errcheck // best-effort stdout + } + return 0 +} + // findOrder looks up an order by name and optional rig. // When rig is empty, returns the first match by name (prefers city-level). // When rig is non-empty, matches exact rig. diff --git a/cmd/gc/cmd_order_test.go b/cmd/gc/cmd_order_test.go index 33861e865c..56c46ec84c 100644 --- a/cmd/gc/cmd_order_test.go +++ b/cmd/gc/cmd_order_test.go @@ -599,6 +599,37 @@ func TestOrderCheckWithStoresResolverUsesLegacyCityStore(t *testing.T) { } } +func TestOrderCheckConditionUsesCityScope(t *testing.T) { + cityDir := t.TempDir() + orderDir := filepath.Join(cityDir, "packs", "workflows", "orders") + check := fmt.Sprintf( + `test "$GC_CITY_PATH" = '%s' && test "$GC_STORE_ROOT" = '%s' && test "$GC_STORE_SCOPE" = city && test "$ORDER_DIR" = '%s'`, + cityDir, + cityDir, + orderDir, + ) + aa := []orders.Order{{ + Name: "pr-review-router", + Trigger: "condition", + Check: check, + Formula: "mol-pr-review-router", + Pool: "workflows.pr-review-router", + Source: filepath.Join(orderDir, "pr-review-router.toml"), + }} + resolver := func(orders.Order) ([]beads.Store, error) { + return []beads.Store{beads.NewMemStore()}, nil + } + + var stdout, stderr bytes.Buffer + code := doOrderCheckWithStoresResolverScoped(cityDir, &config.City{}, aa, time.Now(), nil, resolver, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderCheckWithStoresResolverScoped = %d, want 0; stderr: %s; stdout: %s", code, stderr.String(), stdout.String()) + } + if !strings.Contains(stdout.String(), "yes") { + t.Fatalf("stdout missing due row:\n%s", stdout.String()) + } +} + func TestOrderCheckWithStoresResolverFailsWhenLegacyEventCursorReadFails(t *testing.T) { rigStore := beads.NewMemStore() legacyStore := labelFailListStore{ @@ -690,6 +721,312 @@ func TestOrderRun(t *testing.T) { } } +func TestOrderRunEventExecAdvancesCursor(t *testing.T) { + cityDir := t.TempDir() + writeFile(t, filepath.Join(cityDir, "city.toml"), `[workspace] +name = "test-city" +`) + store := beads.NewMemStore() + eventLog := events.NewFake() + eventLog.Record(events.Event{Type: events.BeadClosed, Actor: "test"}) + headSeq, err := eventLog.LatestSeq() + if err != nil { + t.Fatalf("LatestSeq(): %v", err) + } + aa := []orders.Order{{ + Name: "release-exec", + Trigger: "event", + On: events.BeadClosed, + Exec: "printf ok", + }} + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "release-exec", "", cityDir, store, eventLog, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + results, err := store.ListByLabel("order-run:release-exec", 0, beads.IncludeClosed) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + for _, want := range []string{"order:release-exec", fmt.Sprintf("seq:%d", headSeq), "exec"} { + if !slicesContain(results[0].Labels, want) { + t.Fatalf("tracking bead labels = %v, want %s", results[0].Labels, want) + } + } +} + +func TestCmdOrderRunEventExecAdvancesCursor(t *testing.T) { + cityDir := t.TempDir() + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") + t.Setenv("GC_EVENTS", "") + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", cityDir) + t.Setenv("GC_CITY_ROOT", cityDir) + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + t.Chdir(cityDir) + + writeFile(t, filepath.Join(cityDir, "city.toml"), `[workspace] +name = "test-city" +`) + if err := os.MkdirAll(filepath.Join(cityDir, "orders"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(cityDir, "orders", "release-exec.toml"), `[order] +exec = "printf ok" +trigger = "event" +on = "bead.closed" +`) + var eventStderr bytes.Buffer + eventLog, err := events.NewFileRecorder(filepath.Join(cityDir, ".gc", "events.jsonl"), &eventStderr) + if err != nil { + t.Fatalf("NewFileRecorder(): %v", err) + } + eventLog.Record(events.Event{Type: events.BeadClosed, Actor: "test"}) + headSeq, err := eventLog.LatestSeq() + if err != nil { + t.Fatalf("LatestSeq(): %v", err) + } + if err := eventLog.Close(); err != nil { + t.Fatalf("Close(): %v", err) + } + + var stdout, stderr bytes.Buffer + code := cmdOrderRun("release-exec", "", &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + store, err := openStoreAtForCity(cityDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(): %v", err) + } + results, err := store.ListByLabel("order-run:release-exec", 0, beads.IncludeClosed) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + for _, want := range []string{"order:release-exec", fmt.Sprintf("seq:%d", headSeq), "exec"} { + if !slicesContain(results[0].Labels, want) { + t.Fatalf("tracking bead labels = %v, want %s", results[0].Labels, want) + } + } +} + +func TestOrderRunEventFormulaLatestSeqErrorDoesNotInstantiate(t *testing.T) { + aa := []orders.Order{{ + Name: "release-watch", + Trigger: "event", + On: events.BeadClosed, + Formula: "test-formula", + FormulaLayer: sharedTestFormulaDir, + }} + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "release-watch", "", "/city", store, events.NewFailFake(), &stdout, &stderr) + if code != 1 { + t.Fatalf("doOrderRun = %d, want 1 when event cursor cannot be read; stdout: %s", code, stdout.String()) + } + results, err := store.ListByLabel("order-run:release-watch", 0, beads.IncludeClosed) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 0 { + t.Fatalf("store.ListByLabel() len = %d, want 0 (%#v)", len(results), results) + } + if !strings.Contains(stderr.String(), "reading event cursor for release-watch") { + t.Fatalf("stderr = %q, want event cursor read failure", stderr.String()) + } +} + +func TestOrderRunResolvesPackBindingForPool(t *testing.T) { + aa := []orders.Order{ + {Name: "digest", Formula: "mol-digest", Trigger: "cooldown", Interval: "24h", Pool: "dog", FormulaLayer: sharedTestFormulaDir}, + } + cityDir := t.TempDir() + writeOrderRunImportFixture(t, cityDir, "maintenance") + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "digest", "", cityDir, store, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + results, err := store.ListByLabel("order-run:digest", 0) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + if got := results[0].Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Fatalf("gc.routed_to = %q, want maintenance.dog", got) + } + if !strings.Contains(stdout.String(), "gc.routed_to=maintenance.dog") { + t.Fatalf("stdout = %q, want binding-qualified route", stdout.String()) + } +} + +func TestOrderRunResolvesImportedPackPoolAgainstCityShadow(t *testing.T) { + cityDir := t.TempDir() + writeImportedDogOrderFixture(t, cityDir, true) + _, aa := loadImportedDogOrders(t, cityDir) + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "digest", "", cityDir, store, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + results, err := store.ListByLabel("order-run:digest", 0) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + if got := results[0].Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Fatalf("gc.routed_to = %q, want maintenance.dog", got) + } +} + +func TestOrderRunResolvesImportedPackPoolAgainstSiblingImportCollision(t *testing.T) { + cityDir := t.TempDir() + writeImportedDogOrderFixture(t, cityDir, false, "gastown") + _, aa := loadImportedDogOrders(t, cityDir) + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "digest", "", cityDir, store, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + results, err := store.ListByLabel("order-run:digest", 0) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + if got := results[0].Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Fatalf("gc.routed_to = %q, want maintenance.dog", got) + } +} + +func TestOrderRunPrefersCityShadowForPool(t *testing.T) { + aa := []orders.Order{ + {Name: "digest", Formula: "mol-digest", Trigger: "cooldown", Interval: "24h", Pool: "dog", FormulaLayer: sharedTestFormulaDir}, + } + cityDir := t.TempDir() + writeOrderRunImportFixture(t, cityDir, "maintenance") + writeFile(t, filepath.Join(cityDir, "city.toml"), `[workspace] +name = "shadow-city" +prefix = "shd" + +[[agent]] +name = "dog" +`) + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "digest", "", cityDir, store, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("doOrderRun = %d, want 0; stderr: %s", code, stderr.String()) + } + + results, err := store.ListByLabel("order-run:digest", 0) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 1 { + t.Fatalf("store.ListByLabel() len = %d, want 1 (%#v)", len(results), results) + } + if got := results[0].Metadata["gc.routed_to"]; got != "dog" { + t.Fatalf("gc.routed_to = %q, want dog", got) + } + if !strings.Contains(stdout.String(), "gc.routed_to=dog") { + t.Fatalf("stdout = %q, want city-local route", stdout.String()) + } +} + +func TestOrderRunRejectsAmbiguousPackPool(t *testing.T) { + aa := []orders.Order{ + {Name: "digest", Formula: "mol-digest", Trigger: "cooldown", Interval: "24h", Pool: "dog", FormulaLayer: sharedTestFormulaDir}, + } + cityDir := t.TempDir() + writeOrderRunImportFixture(t, cityDir, "gastown", "maintenance") + store := beads.NewMemStore() + + var stdout, stderr bytes.Buffer + code := doOrderRun(aa, "digest", "", cityDir, store, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("doOrderRun = %d, want 1; stdout: %s stderr: %s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), `ambiguous pool "dog"`) { + t.Fatalf("stderr = %q, want ambiguity error", stderr.String()) + } + results, err := store.ListByLabel("order-run:digest", 0) + if err != nil { + t.Fatalf("store.ListByLabel(): %v", err) + } + if len(results) != 0 { + t.Fatalf("store.ListByLabel() len = %d, want 0 (%#v)", len(results), results) + } +} + +func writeOrderRunImportFixture(t *testing.T, cityDir string, bindings ...string) { + t.Helper() + + packRoot := filepath.Join(cityDir, "packs") + if err := os.MkdirAll(packRoot, 0o755); err != nil { + t.Fatal(err) + } + + writeFile(t, filepath.Join(cityDir, "city.toml"), ` +[workspace] +name = "test-city" +`) + + var packToml strings.Builder + packToml.WriteString(` +[pack] +name = "test-city" +schema = 1 +`) + for _, binding := range bindings { + packDir := filepath.Join(packRoot, binding) + if err := os.MkdirAll(packDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(packDir, "pack.toml"), ` +[pack] +name = "`+binding+`" +schema = 1 + +[[agent]] +name = "dog" +scope = "city" +`) + packToml.WriteString(` +[imports.` + binding + `] +source = "./packs/` + binding + `" +`) + } + writeFile(t, filepath.Join(cityDir, "pack.toml"), packToml.String()) +} + func TestOrderRunNoPool(t *testing.T) { aa := []orders.Order{ {Name: "cleanup", Formula: "mol-cleanup", Trigger: "cron", Schedule: "0 3 * * *", FormulaLayer: sharedTestFormulaDir}, @@ -831,8 +1168,8 @@ title = "Do work" if bead.Assignee != config.ControlDispatcherAgentName { t.Fatalf("finalizer assignee = %q, want %q", bead.Assignee, config.ControlDispatcherAgentName) } - if bead.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("finalizer gc.routed_to = %q, want %q", bead.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if bead.Metadata["gc.routed_to"] != "" { + t.Fatalf("finalizer gc.routed_to = %q, want empty for concrete control dispatcher assignee", bead.Metadata["gc.routed_to"]) } if bead.Metadata[graphExecutionRouteMetaKey] != "quinn" { t.Fatalf("finalizer execution route = %q, want quinn", bead.Metadata[graphExecutionRouteMetaKey]) diff --git a/cmd/gc/cmd_prime.go b/cmd/gc/cmd_prime.go index e13071636c..7fcb562463 100644 --- a/cmd/gc/cmd_prime.go +++ b/cmd/gc/cmd_prime.go @@ -175,7 +175,7 @@ func doPrimeWithHookFormat(args []string, stdout, stderr io.Writer, hookMode boo fmt.Fprintf(stderr, "gc prime: no city config found: %v\n", err) //nolint:errcheck return 1 } - fmt.Fprint(stdout, defaultPrimePrompt) //nolint:errcheck // best-effort stdout + writePrimePromptWithFormat(stdout, "", "", defaultPrimePrompt, hookMode, hookFormat, suppressHookPrompt) return 0 } cfg, err := loadCityConfig(cityPath, stderr) @@ -184,7 +184,7 @@ func doPrimeWithHookFormat(args []string, stdout, stderr io.Writer, hookMode boo fmt.Fprintf(stderr, "gc prime: loading city config: %v\n", err) //nolint:errcheck return 1 } - fmt.Fprint(stdout, defaultPrimePrompt) //nolint:errcheck // best-effort stdout + writePrimePromptWithFormat(stdout, "", "", defaultPrimePrompt, hookMode, hookFormat, suppressHookPrompt) return 0 } resolveRigPaths(cityPath, cfg.Rigs) @@ -317,7 +317,7 @@ func doPrimeWithHookFormat(args []string, stdout, stderr io.Writer, hookMode boo // when the agent has no prompt_template and doesn't match a builtin // worker prompt — a supported config shape, so the default prompt is // the correct output even under --strict. - fmt.Fprint(stdout, defaultPrimePrompt) //nolint:errcheck // best-effort stdout + writePrimePromptWithFormat(stdout, "", agentName, defaultPrimePrompt, hookMode, hookFormat, suppressHookPrompt) return 0 } @@ -396,7 +396,7 @@ func writePrimePromptWithFormat(stdout io.Writer, cityName, agentName, prompt st prompt = prependHookBeacon(cityName, agentName, prompt) } if hookMode && hookFormat != "" { - _ = writeProviderHookContext(stdout, hookFormat, prompt) + _ = writeProviderHookContextForEvent(stdout, hookFormat, "SessionStart", prompt) return } fmt.Fprint(stdout, prompt) //nolint:errcheck // best-effort stdout @@ -561,9 +561,11 @@ func findAgentByName(cfg *config.City, name string) (config.Agent, bool) { // to currentRigContext when run manually. func buildPrimeContext(cityPath, cityName string, a *config.Agent, rigs []config.Rig, stderr io.Writer) PromptContext { ctx := PromptContext{ - CityRoot: cityPath, - TemplateName: a.Name, - Env: a.Env, + CityRoot: cityPath, + TemplateName: a.Name, + BindingName: a.BindingName, + BindingPrefix: a.BindingPrefix(), + Env: a.Env, } // Agent identity: prefer GC_ALIAS, then GC_AGENT, else config. diff --git a/cmd/gc/cmd_prime_test.go b/cmd/gc/cmd_prime_test.go index df4920bc55..15c1b7190b 100644 --- a/cmd/gc/cmd_prime_test.go +++ b/cmd/gc/cmd_prime_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/fsys" ) func TestBuildPrimeContextFallsBackToConfiguredRigRoot(t *testing.T) { @@ -68,6 +69,43 @@ func TestBuildPrimeContextLogsTemplateExpansionWarning(t *testing.T) { } } +func TestBuildPrimeContextRendersBindingQualifiedRoute(t *testing.T) { + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + t.Setenv("GC_DIR", "") + t.Setenv("GC_BRANCH", "") + t.Setenv("GC_AGENT", "") + t.Setenv("GC_ALIAS", "") + + cityPath := t.TempDir() + promptDir := filepath.Join(cityPath, "prompts") + if err := os.MkdirAll(promptDir, 0o755); err != nil { + t.Fatalf("MkdirAll(promptDir): %v", err) + } + if err := os.WriteFile(filepath.Join(promptDir, "polecat.template.md"), []byte("route={{ .RigName }}/{{ .BindingPrefix }}refinery\nbinding={{ .BindingName }}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(prompt): %v", err) + } + + ctx := buildPrimeContext(cityPath, "test-city", &config.Agent{ + Name: "polecat", + Dir: "demo", + BindingName: "gastown", + }, []config.Rig{{Name: "demo", Path: filepath.Join(cityPath, "repos", "demo")}}, nil) + + if ctx.BindingName != "gastown" { + t.Fatalf("BindingName = %q, want gastown", ctx.BindingName) + } + if ctx.BindingPrefix != "gastown." { + t.Fatalf("BindingPrefix = %q, want gastown.", ctx.BindingPrefix) + } + var stderr bytes.Buffer + got := renderPrompt(fsys.OSFS{}, cityPath, "test-city", "prompts/polecat.template.md", ctx, "", &stderr, nil, nil, nil) + want := "route=demo/gastown.refinery\nbinding=gastown\n" + if got != want { + t.Fatalf("rendered prompt = %q, want %q; stderr=%q", got, want, stderr.String()) + } +} + func TestDoPrime_RendersConventionDiscoveredRootCityAgent(t *testing.T) { cityDir := t.TempDir() if err := os.MkdirAll(filepath.Join(cityDir, "agents", "ada"), 0o755); err != nil { @@ -428,6 +466,34 @@ prompt_template = "prompts/worker.md" } } +func TestDoPrimeWithHookFormat_FormatsDefaultFallback(t *testing.T) { + t.Setenv("GC_CITY", filepath.Join(t.TempDir(), "missing-city")) + t.Setenv("GC_ALIAS", "") + t.Setenv("GC_AGENT", "") + + var stdout, stderr bytes.Buffer + code := doPrimeWithHookFormat(nil, &stdout, &stderr, true, hookOutputFormatCodex, false) + if code != 0 { + t.Fatalf("doPrimeWithHookFormat() = %d, want 0; stderr=%q", code, stderr.String()) + } + + var payload struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout is not hook JSON: %v\n%s", err, stdout.String()) + } + if got, want := payload.HookSpecificOutput.HookEventName, "SessionStart"; got != want { + t.Fatalf("hookEventName = %q, want %q", got, want) + } + if !strings.Contains(payload.HookSpecificOutput.AdditionalContext, "# Gas City Agent") { + t.Fatalf("additionalContext = %q, want default prime prompt", payload.HookSpecificOutput.AdditionalContext) + } +} + func withPrimeHookStdin(t *testing.T, payload map[string]string) { t.Helper() diff --git a/cmd/gc/cmd_reload.go b/cmd/gc/cmd_reload.go index 037657604e..698f282580 100644 --- a/cmd/gc/cmd_reload.go +++ b/cmd/gc/cmd_reload.go @@ -31,7 +31,15 @@ const ( ) var ( - controllerReloadAcceptTimeout = 5 * time.Second + // controllerReloadAcceptTimeout is how long a reload request waits for + // the controller's main goroutine to drain it from reloadReqCh. The + // main goroutine is blocked while a reconcile tick runs, and ticks can + // take 30s–90s+ under bead-store churn (see issue #1560). 5s was + // dramatically too short and produced "controller is busy" rejections + // for many minutes at a time. 60s gives the controller enough headroom + // to finish a tick before the reload is rejected, while still bounding + // the wait for genuinely deadlocked controllers. + controllerReloadAcceptTimeout = 60 * time.Second sendReloadControlRequestHook = sendReloadControlRequest reloadUnavailableMessageHook = reloadUnavailableMessage supervisorAPIBaseURLHook = supervisorAPIBaseURL @@ -168,13 +176,9 @@ func sendReloadControlRequest(cityPath string, req reloadControlRequest) (reload if err != nil { return reloadControlReply{}, fmt.Errorf("marshaling request: %w", err) } - readTimeout := 15 * time.Second - if req.Wait && req.Timeout != "" { - timeout, err := time.ParseDuration(req.Timeout) - if err != nil { - return reloadControlReply{}, fmt.Errorf("parsing request timeout: %w", err) - } - readTimeout = controllerReloadAcceptTimeout + timeout + 10*time.Second + readTimeout, err := reloadControlReadTimeout(req) + if err != nil { + return reloadControlReply{}, err } resp, err := sendControllerCommandWithReadTimeout(cityPath, "reload:"+string(data), readTimeout) if err != nil { @@ -187,6 +191,18 @@ func sendReloadControlRequest(cityPath string, req reloadControlRequest) (reload return reply, nil } +func reloadControlReadTimeout(req reloadControlRequest) (time.Duration, error) { + readTimeout := 2*controllerReloadAcceptTimeout + 10*time.Second + if req.Wait && req.Timeout != "" { + timeout, err := time.ParseDuration(req.Timeout) + if err != nil { + return 0, fmt.Errorf("parsing request timeout: %w", err) + } + readTimeout += timeout + } + return readTimeout, nil +} + func reloadUnavailableMessage(cityPath string) string { info, ok := supervisorCityInfo(cityPath) if !ok { diff --git a/cmd/gc/cmd_reload_test.go b/cmd/gc/cmd_reload_test.go index f6859f40eb..ef7617dd80 100644 --- a/cmd/gc/cmd_reload_test.go +++ b/cmd/gc/cmd_reload_test.go @@ -375,6 +375,40 @@ func TestHandleReloadSocketCmdWaitsForAcceptedAfterHandoff(t *testing.T) { } } +func TestControllerReloadAcceptTimeoutDefault(t *testing.T) { + if controllerReloadAcceptTimeout != 60*time.Second { + t.Fatalf("controllerReloadAcceptTimeout = %s, want 60s", controllerReloadAcceptTimeout) + } +} + +func TestReloadControlReadTimeoutAsyncOutlastsAcceptAndAckWindow(t *testing.T) { + readTimeout, err := reloadControlReadTimeout(reloadControlRequest{Wait: false}) + if err != nil { + t.Fatal(err) + } + if readTimeout <= 15*time.Second { + t.Fatalf("async read timeout = %s, want above old 15s client deadline", readTimeout) + } + if wantMin := 2*controllerReloadAcceptTimeout + 5*time.Second; readTimeout <= wantMin { + t.Fatalf("async read timeout = %s, want above controller window %s", readTimeout, wantMin) + } +} + +func TestReloadControlReadTimeoutWaitIncludesRequestedTimeout(t *testing.T) { + oldAccept := controllerReloadAcceptTimeout + controllerReloadAcceptTimeout = 20 * time.Millisecond + t.Cleanup(func() { controllerReloadAcceptTimeout = oldAccept }) + + readTimeout, err := reloadControlReadTimeout(reloadControlRequest{Wait: true, Timeout: "40ms"}) + if err != nil { + t.Fatal(err) + } + want := 2*controllerReloadAcceptTimeout + 40*time.Millisecond + 10*time.Second + if readTimeout != want { + t.Fatalf("read timeout = %s, want %s", readTimeout, want) + } +} + func TestSendReloadControlRequestNoChange(t *testing.T) { sp := runtime.NewFake() @@ -392,14 +426,16 @@ func TestSendReloadControlRequestNoChange(t *testing.T) { } dir := shortSocketTempDir(t, "gc-reload-no-change-") + cleanupManagedDoltTestCity(t, dir) if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { t.Fatal(err) } tomlPath := writeCityTOML(t, dir, "test", "mayor") - cfg, prov, err := config.LoadWithIncludes(osFS{}, tomlPath) + cfg, prov, err := loadCityConfigWithBuiltinPacks(dir) if err != nil { t.Fatal(err) } + applyFeatureFlags(cfg) configRev := config.Revision(osFS{}, prov, cfg, dir) var stdout, stderr bytes.Buffer @@ -459,17 +495,26 @@ func TestSendReloadControlRequestInvalidConfig(t *testing.T) { } dir := shortSocketTempDir(t, "gc-reload-invalid-") + cleanupManagedDoltTestCity(t, dir) if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { t.Fatal(err) } tomlPath := writeCityTOML(t, dir, "test", "mayor") - cfg, prov, err := config.LoadWithIncludes(osFS{}, tomlPath) + cfg, prov, err := loadCityConfigWithBuiltinPacks(dir) if err != nil { t.Fatal(err) } + applyFeatureFlags(cfg) + var stdout, stderr bytes.Buffer + allOrders, err := scanAllOrders(dir, cfg, &stderr, "gc reload test") + if err != nil { + t.Fatal(err) + } + for _, order := range allOrders { + cfg.Orders.Skip = append(cfg.Orders.Skip, order.Name) + } configRev := config.Revision(osFS{}, prov, cfg, dir) - var stdout, stderr bytes.Buffer done := make(chan struct{}) go func() { runController(dir, tomlPath, cfg, configRev, buildFn, nil, sp, nil, nil, nil, nil, events.Discard, nil, &stdout, &stderr) @@ -494,21 +539,48 @@ func TestSendReloadControlRequestInvalidConfig(t *testing.T) { } } + oldDebounce := debounceDelay + debounceDelay = 30 * time.Second + t.Cleanup(func() { + debounceDelay = oldDebounce + }) if err := os.WriteFile(tomlPath, []byte("[[[ bad toml"), 0o644); err != nil { t.Fatal(err) } - reply, err := sendReloadControlRequest(dir, reloadControlRequest{Wait: true, Timeout: "1s"}) - if err != nil { - t.Fatalf("sendReloadControlRequest: %v", err) - } - if reply.Outcome != reloadOutcomeFailed { - t.Fatalf("reply.Outcome = %q, want %q", reply.Outcome, reloadOutcomeFailed) + stdoutBeforeInvalid := stdout.String() + var reply reloadControlReply + deadline = time.After(45 * time.Second) + for { + reply, err = sendReloadControlRequest(dir, reloadControlRequest{Wait: true, Timeout: "30s"}) + if err != nil { + t.Fatalf("sendReloadControlRequest: %v", err) + } + if reply.Outcome != reloadOutcomeBusy { + break + } + if strings.Contains(stderr.String(), "config reload") { + break + } + select { + case <-deadline: + t.Fatalf("reload stayed busy; last reply = %+v", reply) + default: + time.Sleep(10 * time.Millisecond) + } } - if !strings.Contains(reply.Error, "parsing city.toml") { + switch { + case reply.Outcome == reloadOutcomeBusy: + if !strings.Contains(stderr.String(), "config reload") { + t.Fatalf("busy reload did not produce invalid config error; stderr=%q", stderr.String()) + } + case reply.Outcome != reloadOutcomeFailed: + t.Fatalf("reply.Outcome = %q, want %q; stdout=%q stderr=%q", + reply.Outcome, reloadOutcomeFailed, stdout.String(), stderr.String()) + case !strings.Contains(reply.Error, "parsing city.toml"): t.Fatalf("reply.Error = %q", reply.Error) } - if strings.Contains(stdout.String(), "Config reloaded:") { + if strings.Contains(strings.TrimPrefix(stdout.String(), stdoutBeforeInvalid), "Config reloaded:") { t.Fatalf("stdout unexpectedly contains reload success: %q", stdout.String()) } } diff --git a/cmd/gc/cmd_restart.go b/cmd/gc/cmd_restart.go index 77b006f4be..e60c4f6bc3 100644 --- a/cmd/gc/cmd_restart.go +++ b/cmd/gc/cmd_restart.go @@ -76,6 +76,7 @@ quick way to force-refresh all agents working on a particular project.`, } return nil }, + ValidArgsFunction: completeRigNames, } } @@ -163,15 +164,12 @@ func doRigRestart( } } else { // Pool agent: resolve live instances from beads first, then legacy discovery. - for _, ref := range resolvePoolSessionRefs(store, a.Name, a.Dir, sp0, &a, cityName, sessionTemplate, sp, stderr) { - running, err := workerSessionTargetRunningWithConfig("", store, sp, cfg, ref.sessionName) - if err != nil { - fmt.Fprintf(stderr, "gc rig restart: observing %s: %v\n", ref.sessionName, err) //nolint:errcheck - return 1 - } - if !running { - continue - } + refs, err := selectRunningPoolSessionRefs(store, sp, cfg, resolvePoolSessionRefs(store, cfg, a.Name, a.Dir, sp0, &a, cityName, sessionTemplate, sp, stderr)) + if err != nil { + fmt.Fprintf(stderr, "gc rig restart: observing %s: %v\n", a.QualifiedName(), err) //nolint:errcheck + return 1 + } + for _, ref := range refs { targets = append(targets, stopTarget{ name: ref.sessionName, template: a.QualifiedName(), diff --git a/cmd/gc/cmd_restart_test.go b/cmd/gc/cmd_restart_test.go index cffe04fa71..813c6e9953 100644 --- a/cmd/gc/cmd_restart_test.go +++ b/cmd/gc/cmd_restart_test.go @@ -253,6 +253,253 @@ func TestDoRigRestart_UsesUnlimitedPoolSessionBeadsForCustomSessionNames(t *test } } +func TestDoRigRestart_UsesBoundPoolSlotOnlySessionBeadForCustomSessionName(t *testing.T) { + sp := runtime.NewFake() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "frontend/ops.furiosa", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "frontend/ops.worker", + "session_name": "custom-ops-furiosa", + "pool_slot": "1", + "state": "awake", + }, + }); err != nil { + t.Fatal(err) + } + if err := sp.Start(context.Background(), "custom-ops-furiosa", runtime.Config{Command: "echo"}); err != nil { + t.Fatal(err) + } + + rec := events.NewFake() + agents := []config.Agent{{ + Name: "worker", + Dir: "frontend", + BindingName: "ops", + NamepoolNames: []string{"furiosa", "nux"}, + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(2), ScaleCheck: "echo 1", + }} + + var stdout, stderr bytes.Buffer + code := doRigRestart(sp, rec, store, nil, agents, "frontend", "city", "{{.Agent}}", &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + if sp.IsRunning("custom-ops-furiosa") { + t.Fatal("custom bound pool session still running after rig restart") + } + if len(rec.Events) != 1 { + t.Fatalf("got %d events, want 1", len(rec.Events)) + } + if rec.Events[0].Subject != "frontend/ops.furiosa" { + t.Fatalf("event subject = %q, want %q", rec.Events[0].Subject, "frontend/ops.furiosa") + } +} + +func TestDoRigRestart_UsesTemplateIdentityPoolSlotSessionBeadForCustomSessionName(t *testing.T) { + sp := runtime.NewFake() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "frontend/worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "frontend/worker", + "agent_name": "frontend/worker", + "session_name": "custom-worker-7", + "pool_slot": "7", + "state": "awake", + }, + }); err != nil { + t.Fatal(err) + } + if err := sp.Start(context.Background(), "custom-worker-7", runtime.Config{Command: "echo"}); err != nil { + t.Fatal(err) + } + + rec := events.NewFake() + agents := []config.Agent{{ + Name: "worker", + Dir: "frontend", + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(10), ScaleCheck: "echo 1", + }} + + var stdout, stderr bytes.Buffer + code := doRigRestart(sp, rec, store, nil, agents, "frontend", "city", "{{.Agent}}", &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + if sp.IsRunning("custom-worker-7") { + t.Fatal("template-identity pool session still running after rig restart") + } + if len(rec.Events) != 1 { + t.Fatalf("got %d events, want 1", len(rec.Events)) + } + if rec.Events[0].Subject != "frontend/worker-7" { + t.Fatalf("event subject = %q, want %q", rec.Events[0].Subject, "frontend/worker-7") + } +} + +func TestDoRigRestart_DoesNotTargetOutOfBoundsAliasOnlyBoundedPoolIdentity(t *testing.T) { + sp := runtime.NewFake() + store := beads.NewMemStore() + if _, err := store.Create(beads.Bead{ + Title: "stale out-of-bounds pool instance", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "frontend/worker", + "alias": "frontend/worker-7", + "session_name": "custom-worker-7", + "state": "awake", + }, + }); err != nil { + t.Fatal(err) + } + if err := sp.Start(context.Background(), "custom-worker-7", runtime.Config{Command: "echo"}); err != nil { + t.Fatal(err) + } + + rec := events.NewFake() + agents := []config.Agent{{ + Name: "worker", + Dir: "frontend", + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(5), ScaleCheck: "echo 1", + }} + + var stdout, stderr bytes.Buffer + code := doRigRestart(sp, rec, store, nil, agents, "frontend", "city", "{{.Agent}}", &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + if !sp.IsRunning("custom-worker-7") { + t.Fatal("out-of-bounds pool session should not have been restarted") + } + if len(rec.Events) != 0 { + t.Fatalf("got %d events, want 0", len(rec.Events)) + } +} + +func TestDoRigRestart_PrefersLiveFallbackCandidateOncePerLogicalInstance(t *testing.T) { + sp := runtime.NewFake() + store := beads.NewMemStore() + for _, bead := range []beads.Bead{ + { + Title: "stale duplicate", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "frontend/worker", + "session_name": "s-stale-worker-7", + "pool_slot": "7", + }, + }, + { + Title: "live legacy", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "common_name": "worker", + "session_name": "worker-7", + "alias": "frontend/worker-7", + }, + }, + } { + if _, err := store.Create(bead); err != nil { + t.Fatal(err) + } + } + if err := sp.Start(context.Background(), "worker-7", runtime.Config{Command: "echo"}); err != nil { + t.Fatal(err) + } + + rec := events.NewFake() + agents := []config.Agent{{ + Name: "worker", + Dir: "frontend", + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(10), ScaleCheck: "echo 1", + }} + + var stdout, stderr bytes.Buffer + code := doRigRestart(sp, rec, store, nil, agents, "frontend", "city", "{{.Agent}}", &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + if sp.IsRunning("worker-7") { + t.Fatal("live fallback session still running after rig restart") + } + if len(rec.Events) != 1 { + t.Fatalf("got %d events, want 1", len(rec.Events)) + } + if rec.Events[0].Subject != "frontend/worker-7" { + t.Fatalf("event subject = %q, want %q", rec.Events[0].Subject, "frontend/worker-7") + } +} + +func TestDoRigRestart_StopsAllLiveCandidatesForLogicalInstance(t *testing.T) { + sp := runtime.NewFake() + store := beads.NewMemStore() + for _, bead := range []beads.Bead{ + { + Title: "stale duplicate", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "frontend/worker", + "session_name": "s-stale-worker-7", + "pool_slot": "7", + }, + }, + { + Title: "live legacy", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "common_name": "worker", + "session_name": "worker-7", + "alias": "frontend/worker-7", + }, + }, + } { + if _, err := store.Create(bead); err != nil { + t.Fatal(err) + } + } + for _, sessionName := range []string{"s-stale-worker-7", "worker-7"} { + if err := sp.Start(context.Background(), sessionName, runtime.Config{Command: "echo"}); err != nil { + t.Fatal(err) + } + } + + rec := events.NewFake() + agents := []config.Agent{{ + Name: "worker", + Dir: "frontend", + MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(10), ScaleCheck: "echo 1", + }} + + var stdout, stderr bytes.Buffer + code := doRigRestart(sp, rec, store, nil, agents, "frontend", "city", "{{.Agent}}", &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) + } + for _, sessionName := range []string{"s-stale-worker-7", "worker-7"} { + if sp.IsRunning(sessionName) { + t.Fatalf("%s still running after rig restart", sessionName) + } + } + if len(rec.Events) != 2 { + t.Fatalf("got %d events, want 2", len(rec.Events)) + } + for _, event := range rec.Events { + if event.Subject != "frontend/worker-7" { + t.Fatalf("event subject = %q, want %q", event.Subject, "frontend/worker-7") + } + } +} + func TestDoRigRestart_UsesLegacyPoolAgentLabelForCustomSessionNames(t *testing.T) { sp := runtime.NewFake() store := beads.NewMemStore() diff --git a/cmd/gc/cmd_rig.go b/cmd/gc/cmd_rig.go index b773e4d80a..1ca9009643 100644 --- a/cmd/gc/cmd_rig.go +++ b/cmd/gc/cmd_rig.go @@ -229,9 +229,9 @@ func doRigAdd(fs fsys.FS, cityPath, rigPath string, includes []string, nameOverr fmt.Fprintf(stderr, "gc rig add: loading config: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } - if cityUsesBdStoreContract(cityPath) && (cfg.Dolt.Host != "" || cfg.Dolt.Port != 0) { - cityDoltConfigs.Store(cityPath, cfg.Dolt) - defer cityDoltConfigs.Delete(cityPath) + if cityUsesBdStoreContract(cityPath) && cityDoltConfigHasLifecycleFields(cfg.Dolt) { + registerCityDoltConfig(cityPath, cfg.Dolt) + defer clearCityDoltConfig(cityPath) } rootDefaultRigImports, err := config.LoadRootPackDefaultRigImports(fs, cityPath) if err != nil { @@ -763,6 +763,7 @@ database remains accessible. Use "gc rig resume" to restore.`, } return nil }, + ValidArgsFunction: completeRigNames, } } @@ -843,6 +844,7 @@ The reconciler will start the rig's agents on its next tick.`, } return nil }, + ValidArgsFunction: completeRigNames, } } @@ -925,6 +927,7 @@ binding from .gc/site.toml.`, } return nil }, + ValidArgsFunction: completeRigNames, } } diff --git a/cmd/gc/cmd_rig_endpoint.go b/cmd/gc/cmd_rig_endpoint.go index 0186c788c7..2e6a065112 100644 --- a/cmd/gc/cmd_rig_endpoint.go +++ b/cmd/gc/cmd_rig_endpoint.go @@ -57,6 +57,7 @@ This command owns the rig's canonical .beads/config.yaml topology state.`, } return nil }, + ValidArgsFunction: completeRigNames, } cmd.Flags().BoolVar(&opts.Inherit, "inherit", false, "inherit the city endpoint") cmd.Flags().BoolVar(&opts.External, "external", false, "set an explicit external endpoint for the rig") @@ -538,7 +539,7 @@ func readCanonicalProjectID(metadataPath string) (string, error) { func readDatabaseProjectID(ctx context.Context, db *sql.DB) (string, bool, error) { var projectID string if err := db.QueryRowContext(ctx, "SELECT value FROM metadata WHERE `key` = '_project_id'").Scan(&projectID); err != nil { - if err == sql.ErrNoRows { + if err == sql.ErrNoRows || isMissingDoltMetadataTableError(err) { return "", false, nil } return "", false, fmt.Errorf("read database _project_id: %w", err) @@ -550,6 +551,17 @@ func readDatabaseProjectID(ctx context.Context, db *sql.DB) (string, bool, error return projectID, true, nil } +func isMissingDoltMetadataTableError(err error) bool { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1146 { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "table not found: metadata") || + strings.Contains(msg, "table 'metadata' doesn't exist") || + strings.Contains(msg, "no such table: metadata") +} + type fileSnapshot struct { path string data []byte diff --git a/cmd/gc/cmd_rig_endpoint_test.go b/cmd/gc/cmd_rig_endpoint_test.go index a41fb3d8c7..bda0678bb1 100644 --- a/cmd/gc/cmd_rig_endpoint_test.go +++ b/cmd/gc/cmd_rig_endpoint_test.go @@ -1172,6 +1172,7 @@ func TestVerifyExternalDoltEndpointRejectsProjectIdentityMismatch(t *testing.T) if err != nil { t.Skip("dolt not installed") } + bdPath := waitTestRealBDPath(t) oldResolve := resolveProviderLifecycleGCBinary resolveProviderLifecycleGCBinary = func() string { return currentGCBinaryForTests(t) } t.Cleanup(func() { resolveProviderLifecycleGCBinary = oldResolve }) @@ -1200,7 +1201,7 @@ func TestVerifyExternalDoltEndpointRejectsProjectIdentityMismatch(t *testing.T) t.Setenv("GC_CITY_PATH", cityDir) t.Setenv("GC_BEADS", "bd") t.Setenv("GC_DOLT", "") - t.Setenv("PATH", strings.Join([]string{"/home/ubuntu/.local/bin", filepath.Dir(doltPath), os.Getenv("PATH")}, string(os.PathListSeparator))) + t.Setenv("PATH", strings.Join([]string{filepath.Dir(bdPath), filepath.Dir(doltPath), os.Getenv("PATH")}, string(os.PathListSeparator))) if err := ensureBeadsProvider(cityDir); err != nil { t.Fatalf("ensureBeadsProvider: %v", err) @@ -1276,6 +1277,7 @@ func TestVerifyExternalDoltEndpointRejectsMissingLocalProjectID(t *testing.T) { if err != nil { t.Skip("dolt not installed") } + bdPath := waitTestRealBDPath(t) oldResolve := resolveProviderLifecycleGCBinary resolveProviderLifecycleGCBinary = func() string { return currentGCBinaryForTests(t) } t.Cleanup(func() { resolveProviderLifecycleGCBinary = oldResolve }) @@ -1304,7 +1306,7 @@ func TestVerifyExternalDoltEndpointRejectsMissingLocalProjectID(t *testing.T) { t.Setenv("GC_CITY_PATH", cityDir) t.Setenv("GC_BEADS", "bd") t.Setenv("GC_DOLT", "") - t.Setenv("PATH", strings.Join([]string{"/home/ubuntu/.local/bin", filepath.Dir(doltPath), os.Getenv("PATH")}, string(os.PathListSeparator))) + t.Setenv("PATH", strings.Join([]string{filepath.Dir(bdPath), filepath.Dir(doltPath), os.Getenv("PATH")}, string(os.PathListSeparator))) if err := ensureBeadsProvider(cityDir); err != nil { t.Fatalf("ensureBeadsProvider: %v", err) diff --git a/cmd/gc/cmd_runtime_drain.go b/cmd/gc/cmd_runtime_drain.go index 7630faf2da..9981fa6e04 100644 --- a/cmd/gc/cmd_runtime_drain.go +++ b/cmd/gc/cmd_runtime_drain.go @@ -5,9 +5,12 @@ import ( "errors" "fmt" "io" + "os/signal" "strconv" + "syscall" "time" + "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" "github.com/spf13/cobra" @@ -95,6 +98,9 @@ func (o *providerDrainOps) setRestartRequested(sessionName string) error { func (o *providerDrainOps) isRestartRequested(sessionName string) (bool, error) { val, err := o.sp.GetMeta(sessionName, "GC_RESTART_REQUESTED") if err != nil { + if runtime.IsSessionGone(err) { + return false, nil + } return false, fmt.Errorf("reading GC_RESTART_REQUESTED: %w", err) } return val != "", nil @@ -366,21 +372,28 @@ func cmdRuntimeDrainAck(args []string, stdout, stderr io.Writer) int { func newRuntimeRequestRestartCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ Use: "request-restart", - Short: "Request controller restart this session (blocks until killed)", + Short: "Request controller restart this session (waits to be killed)", Long: `Signal the controller to stop and restart this session. -Sets GC_RESTART_REQUESTED metadata on the session, then blocks forever. -The controller will stop the session on its next reconcile tick and -restart it fresh. The blocking prevents the agent from consuming more -context while waiting. +Sets GC_RESTART_REQUESTED metadata on the session, then waits while the +controller stops the session on its next reconcile tick and restarts it +fresh. The wait keeps the agent idle so it does not consume more context +in the interim. + +Under normal operation the controller SIGKILLs the process tree before +this command returns. If the controller accepts the stop handoff, the +runtime is already gone, or a SIGINT/SIGTERM is received, the command +exits 0 cleanly. If the controller has not acted within a bounded +timeout (max(5*PatrolInterval, 5min), capped at 30min) the command exits +1 with a diagnostic pointing at controller health. -For on-demand configured named sessions, the controller cannot restart the -user-attended process. In that case this command reports that restart was -skipped and returns without blocking. No session.draining event is emitted -when restart is skipped. +For on-demand configured named sessions, the controller cannot restart +the user-attended process. In that case this command reports that +restart was skipped and returns immediately. No session.draining event +is emitted when restart is skipped. This command is designed to be called from within a session context. -It emits a session.draining event before blocking.`, +It emits a session.draining event before waiting.`, Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { if cmdRuntimeRequestRestart(stdout, stderr) != 0 { @@ -431,13 +444,38 @@ func cmdRuntimeRequestRestart(stdout, stderr io.Writer) int { return handle.Reset(context.Background()) } } - return doRuntimeRequestRestart(dops, persistRestart, rec, current.display, current.sessionName, stdout, stderr) + sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return doRuntimeRequestRestart(sigCtx, dops, persistRestart, rec, current.display, current.sessionName, + controllerRestartPollInterval, controllerRestartTimeout(cfg), stdout, stderr) } -// doRuntimeRequestRestart sets the restart-requested flag and blocks forever. -// The controller will kill and restart the session on its next tick. -func doRuntimeRequestRestart(dops drainOps, persistRestart func() error, rec events.Recorder, - targetName, sn string, stdout, stderr io.Writer, +const controllerRestartPollInterval = 1 * time.Second + +// controllerRestartTimeout computes the bounded timeout for waiting on the +// controller to act on a restart request: max(5*PatrolInterval, 5min), capped at 30min. +func controllerRestartTimeout(cfg *config.City) time.Duration { + const floor = 5 * time.Minute + const ceil = 30 * time.Minute + patrol := 30 * time.Second + if cfg != nil { + patrol = cfg.Daemon.PatrolIntervalDuration() + } + d := 5 * patrol + if d < floor { + d = floor + } + if d > ceil { + d = ceil + } + return d +} + +// doRuntimeRequestRestart sets the restart-requested flag then polls until the +// controller accepts the stop handoff (exit 0), the context is canceled by a +// signal (exit 0), or the bounded timeout expires (exit 1 with diagnostic). +func doRuntimeRequestRestart(ctx context.Context, dops drainOps, persistRestart func() error, rec events.Recorder, + targetName, sn string, pollInterval, timeout time.Duration, stdout, stderr io.Writer, ) int { if err := dops.setRestartRequested(sn); err != nil { fmt.Fprintf(stderr, "gc runtime request-restart: %v\n", err) //nolint:errcheck // best-effort stderr @@ -456,10 +494,40 @@ func doRuntimeRequestRestart(dops drainOps, persistRestart func() error, rec eve Subject: targetName, Message: "restart requested by session", }) - fmt.Fprintln(stdout, "Restart requested. Blocking until controller kills this session...") //nolint:errcheck // best-effort stdout - - // Block forever. The controller will kill the entire process tree. - select {} + fmt.Fprintf(stdout, "Restart requested. Waiting up to %s for controller to stop this session...\n", timeout) //nolint:errcheck // best-effort stdout + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + var lastPollErr error + + for { + select { + case <-ctx.Done(): + // Signal received; leave the flag set so the controller still acts on its next tick. + fmt.Fprintln(stderr, "gc runtime request-restart: signal received; restart request remains set; controller will stop this session on its next reconcile tick") //nolint:errcheck // best-effort stderr + return 0 + case <-ticker.C: + requested, err := dops.isRestartRequested(sn) + switch { + case err != nil: + lastPollErr = err + case !requested: + // The controller accepted the stop handoff or the runtime is already gone. + return 0 + default: + lastPollErr = nil + } + if time.Now().After(deadline) { + if lastPollErr != nil { + fmt.Fprintf(stderr, "gc runtime request-restart: controller did not act within %s; last poll error: %v; check `gc dashboard` or `gc trace`\n", timeout, lastPollErr) //nolint:errcheck // best-effort stderr + } else { + fmt.Fprintf(stderr, "gc runtime request-restart: controller did not act within %s; check `gc dashboard` or `gc trace`\n", timeout) //nolint:errcheck // best-effort stderr + } + return 1 + } + } + } } // doRuntimeDrainAck sets the drain-ack flag on the session. The controller diff --git a/cmd/gc/cmd_runtime_drain_test.go b/cmd/gc/cmd_runtime_drain_test.go index 685840e72e..c84dce9f90 100644 --- a/cmd/gc/cmd_runtime_drain_test.go +++ b/cmd/gc/cmd_runtime_drain_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "testing" "time" @@ -18,14 +19,45 @@ import ( "github.com/gastownhall/gascity/internal/runtime" ) +// drainOpsWithCountdown wraps fakeDrainOps and returns false for isRestartRequested +// after N calls, simulating the reconciler clearing the flag without concurrent map access. +type drainOpsWithCountdown struct { + *fakeDrainOps + remaining int + cleared bool +} + +func (c *drainOpsWithCountdown) isRestartRequested(sessionName string) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.err != nil { + return false, c.err + } + if !c.restartRequested[sessionName] { + if c.cleared { + return false, nil + } + return false, errors.New("restart flag was not set before polling") + } + if c.remaining <= 0 { + delete(c.restartRequested, sessionName) + c.cleared = true + return false, nil + } + c.remaining-- + return true, nil +} + // fakeDrainOps is a test double for drainOps. type fakeDrainOps struct { + mu sync.Mutex draining map[string]bool drainTimes map[string]time.Time // when drain was set acked map[string]bool restartRequested map[string]bool driftRestart map[string]bool err error // injected error for all ops + restartReadErr error setDrainCalls []string clearDrainCalls []string } @@ -41,6 +73,8 @@ func newFakeDrainOps() *fakeDrainOps { } func (f *fakeDrainOps) setDrain(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() f.setDrainCalls = append(f.setDrainCalls, sessionName) if f.err != nil { return f.err @@ -51,6 +85,8 @@ func (f *fakeDrainOps) setDrain(sessionName string) error { } func (f *fakeDrainOps) clearDrain(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() f.clearDrainCalls = append(f.clearDrainCalls, sessionName) if f.err != nil { return f.err @@ -61,6 +97,8 @@ func (f *fakeDrainOps) clearDrain(sessionName string) error { } func (f *fakeDrainOps) isDraining(sessionName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return false, f.err } @@ -68,6 +106,8 @@ func (f *fakeDrainOps) isDraining(sessionName string) (bool, error) { } func (f *fakeDrainOps) drainStartTime(sessionName string) (time.Time, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return time.Time{}, f.err } @@ -79,6 +119,8 @@ func (f *fakeDrainOps) drainStartTime(sessionName string) (time.Time, error) { } func (f *fakeDrainOps) setDrainAck(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return f.err } @@ -87,6 +129,8 @@ func (f *fakeDrainOps) setDrainAck(sessionName string) error { } func (f *fakeDrainOps) isDrainAcked(sessionName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return false, f.err } @@ -94,6 +138,8 @@ func (f *fakeDrainOps) isDrainAcked(sessionName string) (bool, error) { } func (f *fakeDrainOps) setRestartRequested(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return f.err } @@ -102,13 +148,20 @@ func (f *fakeDrainOps) setRestartRequested(sessionName string) error { } func (f *fakeDrainOps) isRestartRequested(sessionName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return false, f.err } + if f.restartReadErr != nil { + return false, f.restartReadErr + } return f.restartRequested[sessionName], nil } func (f *fakeDrainOps) clearRestartRequested(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return f.err } @@ -117,6 +170,8 @@ func (f *fakeDrainOps) clearRestartRequested(sessionName string) error { } func (f *fakeDrainOps) setDriftRestart(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return f.err } @@ -125,6 +180,8 @@ func (f *fakeDrainOps) setDriftRestart(sessionName string) error { } func (f *fakeDrainOps) isDriftRestart(sessionName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return false, f.err } @@ -132,6 +189,8 @@ func (f *fakeDrainOps) isDriftRestart(sessionName string) (bool, error) { } func (f *fakeDrainOps) clearDriftRestart(sessionName string) error { + f.mu.Lock() + defer f.mu.Unlock() if f.err != nil { return f.err } @@ -479,7 +538,8 @@ func TestDoRuntimeRequestRestartError(t *testing.T) { dops := newFakeDrainOps() dops.err = errors.New("tmux borked") var stdout, stderr bytes.Buffer - code := doRuntimeRequestRestart(dops, nil, events.Discard, "worker", "worker", &stdout, &stderr) + code := doRuntimeRequestRestart(context.Background(), dops, nil, events.Discard, "worker", "worker", + time.Millisecond, time.Second, &stdout, &stderr) if code != 1 { t.Fatalf("code = %d, want 1", code) } @@ -488,6 +548,136 @@ func TestDoRuntimeRequestRestartError(t *testing.T) { } } +func TestDoRuntimeRequestRestartFlagCleared(t *testing.T) { + dops := &drainOpsWithCountdown{fakeDrainOps: newFakeDrainOps(), remaining: 2} + + var stdout, stderr bytes.Buffer + code := doRuntimeRequestRestart(context.Background(), dops, nil, events.Discard, "worker", "worker", + 10*time.Millisecond, 5*time.Second, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0 when flag cleared; stderr: %s", code, stderr.String()) + } + if stderr.Len() > 0 { + t.Errorf("unexpected stderr: %q", stderr.String()) + } + if got := stdout.String(); !strings.Contains(got, "Waiting up to 5s") { + t.Errorf("stdout = %q, want bounded wait banner", got) + } + if dops.restartRequested["worker"] { + t.Error("restart flag should be cleared by the simulated reconciler") + } +} + +func TestDoRuntimeRequestRestartTimeout(t *testing.T) { + dops := newFakeDrainOps() + + var stdout, stderr bytes.Buffer + code := doRuntimeRequestRestart(context.Background(), dops, nil, events.Discard, "worker", "worker", + 10*time.Millisecond, 25*time.Millisecond, &stdout, &stderr) + if code != 1 { + t.Fatalf("code = %d, want 1 on timeout", code) + } + if got := stderr.String(); !strings.Contains(got, "controller did not act within") { + t.Errorf("stderr = %q, want timeout diagnostic", got) + } + if !strings.Contains(stderr.String(), "gc dashboard") { + t.Errorf("stderr = %q, want gc dashboard hint", stderr.String()) + } +} + +func TestDoRuntimeRequestRestartTimeoutReportsLastPollError(t *testing.T) { + dops := newFakeDrainOps() + dops.restartReadErr = errors.New("metadata read failed") + + var stdout, stderr bytes.Buffer + code := doRuntimeRequestRestart(context.Background(), dops, nil, events.Discard, "worker", "worker", + 10*time.Millisecond, 25*time.Millisecond, &stdout, &stderr) + if code != 1 { + t.Fatalf("code = %d, want 1 on timeout", code) + } + if got := stderr.String(); !strings.Contains(got, "last poll error: metadata read failed") { + t.Errorf("stderr = %q, want last poll error", got) + } +} + +func TestDoRuntimeRequestRestartContextCancel(t *testing.T) { + dops := newFakeDrainOps() + + ctx, cancel := context.WithCancel(context.Background()) + var stdout, stderr bytes.Buffer + + done := make(chan int, 1) + go func() { + done <- doRuntimeRequestRestart(ctx, dops, nil, events.Discard, "worker", "worker", + 10*time.Millisecond, 30*time.Second, &stdout, &stderr) + }() + + time.Sleep(30 * time.Millisecond) + cancel() + + select { + case code := <-done: + if code != 0 { + t.Fatalf("code = %d, want 0 on context cancel", code) + } + // Flag must remain set so the controller can still act on its next tick. + if !dops.restartRequested["worker"] { + t.Error("restart flag should remain set after context cancel") + } + if got := stderr.String(); !strings.Contains(got, "restart request remains set") { + t.Errorf("stderr = %q, want pending restart warning", got) + } + case <-time.After(2 * time.Second): + t.Fatal("doRuntimeRequestRestart did not exit on context cancel") + } +} + +func TestControllerRestartTimeout(t *testing.T) { + tests := []struct { + name string + cfg *config.City + want time.Duration + }{ + {name: "nil config uses floor", cfg: nil, want: 5 * time.Minute}, + {name: "empty interval uses floor", cfg: &config.City{}, want: 5 * time.Minute}, + {name: "below floor clamps up", cfg: &config.City{Daemon: config.DaemonConfig{PatrolInterval: "15s"}}, want: 5 * time.Minute}, + {name: "middle range uses multiplier", cfg: &config.City{Daemon: config.DaemonConfig{PatrolInterval: "2m"}}, want: 10 * time.Minute}, + {name: "ceiling edge", cfg: &config.City{Daemon: config.DaemonConfig{PatrolInterval: "6m"}}, want: 30 * time.Minute}, + {name: "above ceiling clamps down", cfg: &config.City{Daemon: config.DaemonConfig{PatrolInterval: "10m"}}, want: 30 * time.Minute}, + {name: "invalid duration uses default floor", cfg: &config.City{Daemon: config.DaemonConfig{PatrolInterval: "later"}}, want: 5 * time.Minute}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := controllerRestartTimeout(tt.cfg); got != tt.want { + t.Fatalf("controllerRestartTimeout() = %s, want %s", got, tt.want) + } + }) + } +} + +type getMetaErrorProvider struct { + *runtime.Fake + err error +} + +func (p *getMetaErrorProvider) GetMeta(_, _ string) (string, error) { + return "", p.err +} + +func TestProviderDrainOpsIsRestartRequestedTreatsGoneSessionAsCleared(t *testing.T) { + dops := newDrainOps(&getMetaErrorProvider{ + Fake: runtime.NewFake(), + err: runtime.ErrSessionNotFound, + }) + requested, err := dops.isRestartRequested("worker") + if err != nil { + t.Fatalf("isRestartRequested returned gone-session error: %v", err) + } + if requested { + t.Fatal("isRestartRequested = true, want false for gone session") + } +} + func TestRequestRestartAcceptsNoArgs(t *testing.T) { // Verify the cobra command accepts no args. var stdout, stderr bytes.Buffer @@ -513,6 +703,7 @@ func TestRuntimeRequestRestartNamedOnDemandReturnsWithoutBlocking(t *testing.T) t.Fatalf("write city.toml: %v", err) } t.Setenv("GC_BEADS", "file") + t.Setenv("GC_BEADS_SCOPE_ROOT", "") t.Setenv("GC_CITY", cityDir) t.Setenv("GC_CITY_PATH", cityDir) t.Setenv("GC_ALIAS", "mayor") @@ -556,7 +747,7 @@ func TestRuntimeRequestRestartNamedOnDemandReturnsWithoutBlocking(t *testing.T) if code != 0 { t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) } - case <-time.After(2 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("cmdRuntimeRequestRestart blocked for named on-demand session") } if !strings.Contains(stdout.String(), "Restart skipped for named session") { @@ -617,7 +808,7 @@ func (p *removeMetaErrorProvider) RemoveMeta(_, _ string) error { return p.err } -func TestProviderDrainOpsClearRestartRequestedIgnoresGoneSession(t *testing.T) { +func TestProviderDrainOpsClearRestartRequestedTreatsSessionGoneAsBenign(t *testing.T) { dops := newDrainOps(&removeMetaErrorProvider{ Fake: runtime.NewFake(), err: errors.New("no tmux server running"), diff --git a/cmd/gc/cmd_session.go b/cmd/gc/cmd_session.go index fc49ade40a..0a58fc915f 100644 --- a/cmd/gc/cmd_session.go +++ b/cmd/gc/cmd_session.go @@ -96,6 +96,7 @@ according to the selected semantic intent.`, } return nil }, + ValidArgsFunction: completeSessionIDs, } cmd.Flags().StringVar(&intent, "intent", string(session.SubmitIntentDefault), "submit intent: default, follow_up, or interrupt_now") return cmd @@ -315,7 +316,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, fmt.Fprintf(stdout, "Session %s created from template %q (reconciler will start it).\n", info.ID, canonicalTemplate) //nolint:errcheck // best-effort stdout if !shouldAttachNewSession(noAttach, sessionTransport) { - if sessionTransport == "acp" && !noAttach { + if sessionTransport == config.SessionTransportACP && !noAttach { fmt.Fprintln(stdout, "Session uses ACP transport; not attaching.") //nolint:errcheck // best-effort stdout } return 0 @@ -409,7 +410,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, fmt.Fprintf(stdout, "Session %s created from template %q.\n", info.ID, canonicalTemplate) //nolint:errcheck // best-effort stdout if !shouldAttachNewSession(noAttach, sessionTransport) { - if sessionTransport == "acp" && !noAttach { + if sessionTransport == config.SessionTransportACP && !noAttach { fmt.Fprintln(stdout, "Session uses ACP transport; not attaching.") //nolint:errcheck // best-effort stdout } return 0 @@ -429,7 +430,7 @@ func newSessionStoredMCPMetadata( alias, template, provider, workDir, transport string, metadata map[string]string, ) (map[string]string, error) { - if strings.TrimSpace(transport) != "acp" { + if strings.TrimSpace(transport) != config.SessionTransportACP { return metadata, nil } mcpServers, err := resolvedRuntimeMCPServersWithConfig( @@ -469,8 +470,21 @@ type acpRouteRegistrar interface { func validateResolvedSessionTransport(resolved *config.ResolvedProvider, transport string, sp runtime.Provider) error { transport = strings.TrimSpace(transport) - if transport != "acp" { + switch transport { + case "": return nil + case config.SessionTransportTmux: + if sessionProviderSupportsTmux(sp) { + return nil + } + providerName := transport + if resolved != nil && resolved.Name != "" { + providerName = resolved.Name + } + return fmt.Errorf("provider %q requires tmux transport but the session provider cannot route tmux sessions", providerName) + case config.SessionTransportACP: + default: + return fmt.Errorf("unknown session transport %q", transport) } providerName := "" if resolved != nil { @@ -496,7 +510,7 @@ func sessionProviderSupportsACP(sp runtime.Provider) bool { return false } if provider, ok := sp.(runtime.TransportCapabilityProvider); ok { - return provider.SupportsTransport("acp") + return provider.SupportsTransport(config.SessionTransportACP) } if _, ok := sp.(acpRouteRegistrar); ok { return true @@ -504,6 +518,13 @@ func sessionProviderSupportsACP(sp runtime.Provider) bool { return false } +func sessionProviderSupportsTmux(sp runtime.Provider) bool { + if provider, ok := sp.(runtime.TransportCapabilityProvider); ok { + return provider.SupportsTransport(config.SessionTransportTmux) + } + return true +} + func resolvedSessionCommand(cityPath string, resolved *config.ResolvedProvider, optionOverrides map[string]string, transport string) (string, error) { if resolved == nil { return "", fmt.Errorf("resolved provider is nil") @@ -955,6 +976,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -1022,7 +1044,7 @@ func cmdSessionAttach(args []string, stdout, stderr io.Writer) int { // // stderr receives projection errors (use io.Discard to ignore). // -// sessionKind mirrors the mc_session_kind bead metadata: "provider" means +// sessionKind mirrors the real_world_app_session_kind bead metadata: "provider" means // the session was created from a bare provider name (not an agent template), // so the agent-template lookup should be skipped. This matches the guard in // the API handler (handler_session_chat.go). @@ -1115,6 +1137,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -1195,6 +1218,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -1253,6 +1277,7 @@ func newSessionRenameCmd(stdout, stderr io.Writer) *cobra.Command { } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -1394,6 +1419,7 @@ func newSessionPeekCmd(stdout, stderr io.Writer) *cobra.Command { } return nil }, + ValidArgsFunction: completeSessionIDs, } cmd.Flags().IntVar(&lines, "lines", 50, "number of lines to capture") return cmd @@ -1456,6 +1482,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -1529,6 +1556,7 @@ joined automatically.`, } return nil }, + ValidArgsFunction: completeSessionIDs, } cmd.Flags().StringVar(&delivery, "delivery", string(nudgeDeliveryWaitIdle), "delivery mode: immediate, wait-idle, or queue") return cmd @@ -1652,7 +1680,7 @@ func sessionExplicitNameForNewSession(agent *config.Agent, alias string) (string } func shouldAttachNewSession(noAttach bool, transport string) bool { - return !noAttach && transport != "acp" + return !noAttach && transport != config.SessionTransportACP } // formatDuration formats a duration for human display. diff --git a/cmd/gc/cmd_session_logs.go b/cmd/gc/cmd_session_logs.go index 66d56fe647..24e23b5ed1 100644 --- a/cmd/gc/cmd_session_logs.go +++ b/cmd/gc/cmd_session_logs.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "strings" "time" @@ -13,6 +14,7 @@ import ( sessionpkg "github.com/gastownhall/gascity/internal/session" workdirutil "github.com/gastownhall/gascity/internal/workdir" "github.com/gastownhall/gascity/internal/worker" + workertranscript "github.com/gastownhall/gascity/internal/worker/transcript" "github.com/spf13/cobra" ) @@ -51,6 +53,7 @@ Use -f to follow new messages as they arrive.`, } return nil }, + ValidArgsFunction: completeSessionIDs, } cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow new messages as they arrive") cmd.Flags().IntVar(&tail, "tail", 10, "Number of most recent transcript entries to show (0 = all; compact dividers count as entries)") @@ -81,7 +84,12 @@ func cmdSessionLogs(args []string, follow bool, tail int, stdout, stderr io.Writ ok bool ) if err == nil && store != nil { - path, provider, ok = resolveStoredSessionLogSource(cityPath, cfg, store, identifier, searchPaths) + var diagnostic string + path, provider, ok, diagnostic = resolveStoredSessionLogSource(cityPath, cfg, store, identifier, searchPaths) + if ok && path == "" && diagnostic != "" { + fmt.Fprintf(stderr, "gc session logs: %s\n", diagnostic) //nolint:errcheck // best-effort stderr + return 1 + } } if !ok { workDir, found := resolveConfiguredSessionLogContext(cityPath, cfg, identifier) @@ -107,27 +115,49 @@ func resolveSessionLogPath(searchPaths []string, logCtx sessionLogContext) strin return factory.DiscoverTranscript(logCtx.provider, logCtx.workDir, logCtx.sessionKey) } -func resolveStoredSessionLogSource(cityPath string, cfg *config.City, store beads.Store, identifier string, searchPaths []string) (string, string, bool) { +func resolveStoredSessionLogSource(cityPath string, cfg *config.City, store beads.Store, identifier string, searchPaths []string) (string, string, bool, string) { logCtx, ok := resolveSessionLogContext(cityPath, cfg, store, identifier) if !ok { - return "", "", false + return "", "", false, "" } if logCtx.sessionID != "" { handle, err := workerHandleForSessionWithConfig(cityPath, store, newSessionProvider(), cfg, logCtx.sessionID) if err == nil { if path, pathErr := handle.TranscriptPath(context.Background()); pathErr == nil && strings.TrimSpace(path) != "" { - return path, logCtx.provider, true + return path, logCtx.provider, true, "" } } } - path := resolveSessionLogPath(searchPaths, logCtx) - if path == "" && canFallbackStoredSessionLogByWorkDir(store, logCtx) { + path := "" + fallbackAllowed := canFallbackStoredSessionLogByWorkDir(store, logCtx) + if strings.TrimSpace(logCtx.sessionKey) != "" { + path = resolveSessionKeyedLogPath(searchPaths, logCtx) + if path == "" && fallbackAllowed { + path = resolveSessionLogPath(searchPaths, logCtx) + } + } else if fallbackAllowed { + path = resolveSessionLogPath(searchPaths, logCtx) + } + if !sessionLogPathFreshEnough(path, logCtx.createdAt) { + path = "" + } + if path == "" && fallbackAllowed { factory, err := worker.NewFactory(worker.FactoryConfig{SearchPaths: searchPaths}) if err == nil { path = factory.DiscoverWorkDirTranscript(logCtx.provider, logCtx.workDir) } } - return path, logCtx.provider, true + if !sessionLogPathFreshEnough(path, logCtx.createdAt) { + path = "" + } + if path == "" && !fallbackAllowed { + return "", logCtx.provider, true, ambiguousSessionLogDiagnostic(logCtx) + } + return path, logCtx.provider, true, "" +} + +func resolveSessionKeyedLogPath(searchPaths []string, logCtx sessionLogContext) string { + return workertranscript.DiscoverKeyedPath(searchPaths, logCtx.provider, logCtx.workDir, logCtx.sessionKey) } type sessionLogContext struct { @@ -135,6 +165,7 @@ type sessionLogContext struct { workDir string sessionKey string provider string + createdAt time.Time } func resolveSessionLogContext(cityPath string, cfg *config.City, store beads.Store, identifier string) (sessionLogContext, bool) { @@ -162,19 +193,44 @@ func resolveSessionLogContext(cityPath string, cfg *config.City, store beads.Sto workDir: workDir, sessionKey: strings.TrimSpace(b.Metadata["session_key"]), provider: provider, + createdAt: b.CreatedAt, }, true } +func sessionLogPathFreshEnough(path string, sessionCreatedAt time.Time) bool { + if strings.TrimSpace(path) == "" { + return false + } + if sessionCreatedAt.IsZero() { + return true + } + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.ModTime().Before(sessionCreatedAt.Add(-2 * time.Second)) +} + func canFallbackStoredSessionLogByWorkDir(store beads.Store, logCtx sessionLogContext) bool { if store == nil || strings.TrimSpace(logCtx.sessionID) == "" || strings.TrimSpace(logCtx.workDir) == "" { return false } - all, err := store.ListByLabel(sessionpkg.LabelSession, 0) + all, err := sessionLogFallbackCandidates(store, logCtx.workDir, logCtx.provider) if err != nil { return false } + targetLive := false + for _, b := range all { + if b.ID == logCtx.sessionID { + targetLive = sessionLogFallbackCandidateLive(b) + break + } + } matches := 0 for _, b := range all { + if !sessionpkg.IsSessionBeadOrRepairable(b) { + continue + } if strings.TrimSpace(b.Metadata["work_dir"]) != logCtx.workDir { continue } @@ -185,6 +241,9 @@ func canFallbackStoredSessionLogByWorkDir(store beads.Store, logCtx sessionLogCo if logCtx.provider != "" && provider != "" && provider != logCtx.provider { continue } + if targetLive && b.ID != logCtx.sessionID && !sessionLogFallbackCandidateLive(b) { + continue + } matches++ if matches > 1 { return false @@ -193,6 +252,64 @@ func canFallbackStoredSessionLogByWorkDir(store beads.Store, logCtx sessionLogCo return matches == 1 } +func sessionLogFallbackCandidates(store beads.Store, workDir, provider string) ([]beads.Bead, error) { + candidates := make(map[string]beads.Bead) + add := func(filters map[string]string) error { + found, err := store.ListByMetadata(filters, 0) + if err != nil { + return err + } + for _, b := range found { + candidates[b.ID] = b + } + return nil + } + if strings.TrimSpace(provider) == "" { + if err := add(map[string]string{"work_dir": workDir}); err != nil { + return nil, err + } + } else { + if err := add(map[string]string{"work_dir": workDir, "provider": provider}); err != nil { + return nil, err + } + if err := add(map[string]string{"work_dir": workDir, "provider_kind": provider}); err != nil { + return nil, err + } + } + out := make([]beads.Bead, 0, len(candidates)) + for _, b := range candidates { + out = append(out, b) + } + return out, nil +} + +func sessionLogFallbackCandidateLive(b beads.Bead) bool { + if b.Status == "closed" { + return false + } + switch sessionpkg.State(strings.TrimSpace(b.Metadata["state"])) { + case sessionpkg.StateActive, sessionpkg.StateAwake, sessionpkg.StateCreating, sessionpkg.StateDraining: + return true + default: + return false + } +} + +func ambiguousSessionLogDiagnostic(logCtx sessionLogContext) string { + sessionID := strings.TrimSpace(logCtx.sessionID) + if sessionID == "" { + sessionID = "requested session" + } + provider := strings.TrimSpace(logCtx.provider) + if provider == "" { + provider = "provider" + } + if strings.TrimSpace(logCtx.sessionKey) == "" { + return fmt.Sprintf("session %q has no session_key and workdir fallback is ambiguous for %s work_dir %q", sessionID, provider, logCtx.workDir) + } + return fmt.Sprintf("no exact transcript found for session %q and workdir fallback is ambiguous for %s work_dir %q", sessionID, provider, logCtx.workDir) +} + func resolveConfiguredSessionLogContext(cityPath string, cfg *config.City, identifier string) (string, bool) { if cfg == nil { return "", false @@ -364,7 +481,7 @@ func printLogEntry(w io.Writer, e *worker.TranscriptEntry) { mc := resolveMessage(e.Message) if mc == nil { - // Unparseable message — print raw truncated. + // Unparseable message; print raw truncated. if len(e.Message) > 0 { raw := string(e.Message) if len(raw) > 200 { diff --git a/cmd/gc/cmd_session_logs_test.go b/cmd/gc/cmd_session_logs_test.go index 0a03159f69..a96eaefc4d 100644 --- a/cmd/gc/cmd_session_logs_test.go +++ b/cmd/gc/cmd_session_logs_test.go @@ -47,6 +47,30 @@ func writeNamedTestSession(t *testing.T, searchBase, workDir, fileName string, l return path } +type noLabelScanSessionLogStore struct { + *beads.MemStore +} + +func (s *noLabelScanSessionLogStore) ListByLabel(label string, _ int, _ ...beads.QueryOpt) ([]beads.Bead, error) { + return nil, fmt.Errorf("unexpected label scan for %q", label) +} + +func writeCodexTestSession(t *testing.T, searchBase, workDir, fileName string, lines ...string) string { + t.Helper() + dayDir := filepath.Join(searchBase, "2026", "05", "04") + if err := os.MkdirAll(dayDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dayDir, fileName) + allLines := append([]string{ + fmt.Sprintf(`{"timestamp":"2026-05-04T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir), + }, lines...) + if err := os.WriteFile(path, []byte(strings.Join(allLines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + return path +} + func TestDoSessionLogsBasic(t *testing.T) { searchBase := t.TempDir() workDir := t.TempDir() @@ -123,7 +147,7 @@ func TestDoSessionLogsTailReturnsLastNEntries(t *testing.T) { t.Errorf("tail=2 should include the last entry 'reply-3', got: %s", out) } // Everything before the last 2 must be absent. In particular, the FIRST - // entry must not leak through — that was the bug the user reported. + // entry must not leak through; that was the bug the user reported. forbidden := []string{"first", "reply-1", "second", "reply-2"} for _, s := range forbidden { if strings.Contains(out, s) { @@ -327,10 +351,13 @@ func TestResolveStoredSessionLogSource_UniqueWorkDirFallsBackBeyondLatestAlias(t }, }) - got, provider, ok := resolveStoredSessionLogSource("", nil, store, "mayor", []string{searchBase}) + got, provider, ok, diagnostic := resolveStoredSessionLogSource("", nil, store, "mayor", []string{searchBase}) if !ok { t.Fatal("resolveStoredSessionLogSource() = not found, want found") } + if diagnostic != "" { + t.Fatalf("resolveStoredSessionLogSource() diagnostic = %q, want empty", diagnostic) + } if provider != "claude" { t.Fatalf("resolveStoredSessionLogSource() provider = %q, want %q", provider, "claude") } @@ -371,7 +398,7 @@ func TestResolveStoredSessionLogSource_DoesNotCrossAmbiguousWorkDir(t *testing.T }, }) - got, provider, ok := resolveStoredSessionLogSource("", nil, store, "mayor", []string{searchBase}) + got, provider, ok, diagnostic := resolveStoredSessionLogSource("", nil, store, "mayor", []string{searchBase}) if !ok { t.Fatal("resolveStoredSessionLogSource() = not found, want found") } @@ -381,6 +408,110 @@ func TestResolveStoredSessionLogSource_DoesNotCrossAmbiguousWorkDir(t *testing.T if got != "" { t.Fatalf("resolveStoredSessionLogSource() path = %q, want empty for ambiguous same-workdir transcript", got) } + if !strings.Contains(diagnostic, "ambiguous") { + t.Fatalf("resolveStoredSessionLogSource() diagnostic = %q, want ambiguous", diagnostic) + } +} + +func TestResolveStoredSessionLogSource_CodexDoesNotUseAmbiguousWorkDirFallback(t *testing.T) { + store := beads.NewMemStore() + workDir := t.TempDir() + searchBase := t.TempDir() + writeCodexTestSession(t, searchBase, workDir, "rollout-current.jsonl", + `{"timestamp":"2026-05-04T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"wrong session"}]}}`, + ) + + for _, name := range []string{"workflows__codex-max-mc-one", "workflows__codex-max-mc-two"} { + b, _ := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": name, + "provider": "codex", + "provider_kind": "codex", + "session_key": "019df2fd-078f-7cb2-93c8-5c649a15eabe", + "session_name": name, + "state": "gc_swept", + "work_dir": workDir, + }, + }) + _ = store.Close(b.ID) + } + + got, provider, ok, diagnostic := resolveStoredSessionLogSource("", nil, store, "workflows__codex-max-mc-one", []string{searchBase}) + if !ok { + t.Fatal("resolveStoredSessionLogSource() = not found, want found") + } + if provider != "codex" { + t.Fatalf("resolveStoredSessionLogSource() provider = %q, want codex", provider) + } + if got != "" { + t.Fatalf("resolveStoredSessionLogSource() path = %q, want empty for ambiguous codex workdir", got) + } + if !strings.Contains(diagnostic, "ambiguous") { + t.Fatalf("resolveStoredSessionLogSource() diagnostic = %q, want ambiguous", diagnostic) + } +} + +func TestCanFallbackStoredSessionLogByWorkDirUsesTargetedLookup(t *testing.T) { + store := &noLabelScanSessionLogStore{MemStore: beads.NewMemStore()} + workDir := t.TempDir() + b, _ := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "worker", + "provider": "codex", + "session_name": "worker", + "state": "awake", + "work_dir": workDir, + }, + }) + + ok := canFallbackStoredSessionLogByWorkDir(store, sessionLogContext{ + sessionID: b.ID, + workDir: workDir, + provider: "codex", + }) + if !ok { + t.Fatal("canFallbackStoredSessionLogByWorkDir() = false, want true") + } +} + +func TestCanFallbackStoredSessionLogByWorkDirIgnoresAsleepPeersForLiveTarget(t *testing.T) { + store := beads.NewMemStore() + workDir := t.TempDir() + target, _ := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "worker", + "provider": "codex", + "session_name": "worker", + "state": "awake", + "work_dir": workDir, + }, + }) + _, _ = store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "old-worker", + "provider": "codex", + "session_name": "old-worker", + "state": "asleep", + "work_dir": workDir, + }, + }) + + ok := canFallbackStoredSessionLogByWorkDir(store, sessionLogContext{ + sessionID: target.ID, + workDir: workDir, + provider: "codex", + }) + if !ok { + t.Fatal("canFallbackStoredSessionLogByWorkDir() = false, want true") + } } func TestDoSessionLogsNegativeTail(t *testing.T) { diff --git a/cmd/gc/cmd_session_pin.go b/cmd/gc/cmd_session_pin.go index 4d67cc47ae..5376377f8b 100644 --- a/cmd/gc/cmd_session_pin.go +++ b/cmd/gc/cmd_session_pin.go @@ -25,6 +25,7 @@ canonical bead so the reconciler can start it when unblocked.`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -43,6 +44,7 @@ normal wake/sleep rules on its next pass.`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -79,8 +81,9 @@ func cmdSessionSetPin(args []string, pinned bool, stdout, stderr io.Writer) int id, err = resolveSessionIDWithConfig(cityPath, cfg, store, args[0]) if err != nil { id, err = resolveSessionIDMaterializingNamedWithMetadata(cityPath, cfg, store, args[0], map[string]string{ - "pin_awake": "true", - "pending_create_claim": "", + "pin_awake": "true", + "pending_create_claim": "", + "pending_create_started_at": "", }) materializedForPin = err == nil } diff --git a/cmd/gc/cmd_session_reset.go b/cmd/gc/cmd_session_reset.go index 18899c15c9..e0c7569a83 100644 --- a/cmd/gc/cmd_session_reset.go +++ b/cmd/gc/cmd_session_reset.go @@ -17,7 +17,9 @@ func newSessionResetCmd(stdout, stderr io.Writer) *cobra.Command { The controller stops the current runtime and starts the same session again with fresh provider conversation state. Session identity, alias, mail, and queued -work remain attached to the existing session bead. +work remain attached to the existing session bead. For named sessions, reset +also clears any tripped named-session respawn circuit breaker before requesting +the fresh restart. Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, Args: cobra.ExactArgs(1), @@ -27,6 +29,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -68,6 +71,20 @@ func cmdSessionReset(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "gc session reset: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } + + bead, err := store.Get(sessionID) + if err != nil { + fmt.Fprintf(stderr, "gc session reset: loading session %s: %v\n", sessionID, err) //nolint:errcheck // best-effort stderr + return 1 + } + identity := namedSessionIdentity(bead) + if identity != "" { + if err := resetSessionCircuitBreakerOnController(cityPath, sessionID, identity); err != nil { + fmt.Fprintf(stderr, "gc session reset: clearing session circuit breaker for %q: %v\n", identity, err) //nolint:errcheck // best-effort stderr + return 1 + } + } + if err := handle.Reset(context.Background()); err != nil { fmt.Fprintf(stderr, "gc session reset: %v\n", err) //nolint:errcheck // best-effort stderr return 1 diff --git a/cmd/gc/cmd_session_reset_test.go b/cmd/gc/cmd_session_reset_test.go index 5261822c42..4744a51200 100644 --- a/cmd/gc/cmd_session_reset_test.go +++ b/cmd/gc/cmd_session_reset_test.go @@ -1,10 +1,12 @@ package main import ( + "bufio" "bytes" "net" "os" "path/filepath" + "strings" "testing" "time" @@ -12,13 +14,104 @@ import ( "github.com/gastownhall/gascity/internal/session" ) +// TestCmdSessionReset_ClearsCircuitBreaker verifies that running +// `gc session reset ` clears a tripped session circuit breaker +// for the matching named session, so the supervisor will respawn the +// session on the next tick. This is the operator-facing remediation path +// the breaker's ERROR log message points at. +func TestCmdSessionReset_ClearsCircuitBreaker(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := shortSocketTempDir(t, "gc-session-reset-cb-") + t.Setenv("GC_CITY", cityDir) + writeGenericNamedSessionCityTOML(t, cityDir) + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + const identity = "session-a" + bead, err := store.Create(beads.Bead{ + Title: "named session", + Type: session.BeadType, + Labels: []string{session.LabelSession, "template:worker"}, + Metadata: map[string]string{ + "alias": identity, + "template": "worker", + "session_name": "s-gc-reset-cb-test", + "state": "awake", + namedSessionMetadataKey: "true", + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-10T12:00:00Z"]`, + }, + }) + if err != nil { + t.Fatalf("store.Create(session bead): %v", err) + } + + // Trip the breaker by recording enough restarts inside + // the rolling window with no progress events. + cb := newSessionCircuitBreaker(sessionCircuitBreakerConfig{ + Window: 30 * time.Minute, + MaxRestarts: 3, + }) + restore := setSessionCircuitBreakerForTest(cb) + defer restore() + now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC) + for i := 0; i < 4; i++ { + cb.RecordRestart(identity, now.Add(time.Duration(i)*time.Second)) + } + if !cb.IsOpen(identity, now.Add(time.Minute)) { + t.Fatalf("precondition: expected breaker OPEN for %q after 4 restarts", identity) + } + + lis, err := startControllerSocket( + cityDir, + func() {}, + nil, + make(chan reloadRequest), + make(chan convergenceRequest, 1), + make(chan struct{}, 1), + make(chan struct{}, 1), + ) + if err != nil { + t.Fatalf("startControllerSocket: %v", err) + } + defer lis.Close() //nolint:errcheck + defer os.Remove(controllerSocketPath(cityDir)) //nolint:errcheck + + var stdout, stderr bytes.Buffer + if code := cmdSessionReset([]string{identity}, &stdout, &stderr); code != 0 { + t.Fatalf("cmdSessionReset = %d, want 0; stderr=%s", code, stderr.String()) + } + + if cb.IsOpen(identity, now.Add(time.Minute)) { + t.Fatalf("breaker still OPEN for %q after `gc session reset %s`", identity, identity) + } + updated, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("store.Get(session bead): %v", err) + } + if got := updated.Metadata[sessionCircuitStateMetadata]; got != "" { + t.Fatalf("persisted circuit state = %q, want cleared", got) + } + if got := updated.Metadata[sessionCircuitRestartsMetadata]; got != "" { + t.Fatalf("persisted restart history = %q, want cleared", got) + } +} + func TestCmdSessionReset_RequestsFreshRestartWithController(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") cityDir := shortSocketTempDir(t, "gc-session-reset-") t.Setenv("GC_CITY", cityDir) - writeNamedSessionCityTOML(t, cityDir) + writeGenericNamedSessionCityTOML(t, cityDir) if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { t.Fatalf("MkdirAll(.gc): %v", err) } @@ -28,12 +121,12 @@ func TestCmdSessionReset_RequestsFreshRestartWithController(t *testing.T) { t.Fatalf("openCityStoreAt: %v", err) } bead, err := store.Create(beads.Bead{ - Title: "manual mayor", + Title: "manual session", Type: session.BeadType, - Labels: []string{session.LabelSession, "template:mayor"}, + Labels: []string{session.LabelSession, "template:worker"}, Metadata: map[string]string{ "alias": "sky", - "template": "mayor", + "template": "worker", "session_name": "s-gc-reset-test", "state": "awake", "session_key": "original-key", @@ -100,7 +193,7 @@ func TestCmdSessionReset_RequestsFreshRestartWithController(t *testing.T) { case cmd, ok := <-commands: if !ok { if len(gotCommands) != 3 { - t.Fatalf("controller commands = %v, want ping plus 2 pokes", gotCommands) + t.Fatalf("controller commands = %v, want ping, poke, poke", gotCommands) } break } @@ -109,8 +202,8 @@ func TestCmdSessionReset_RequestsFreshRestartWithController(t *testing.T) { t.Fatalf("timed out waiting for controller pokes, got %v", gotCommands) } } - wantCommands := []string{"ping\n", "poke\n", "poke\n"} - for i, want := range wantCommands { + wantExact := []string{"ping\n", "poke\n", "poke\n"} + for i, want := range wantExact { if gotCommands[i] != want { t.Fatalf("controller command %d = %q, want %q", i, gotCommands[i], want) } @@ -137,3 +230,193 @@ func TestCmdSessionReset_RequestsFreshRestartWithController(t *testing.T) { t.Fatalf("started_config_hash = %q, want original hash preserved until reconcile", got.Metadata["started_config_hash"]) } } + +func TestCmdSessionReset_ControllerClearFailureDoesNotQueueRestart(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := shortSocketTempDir(t, "gc-session-reset-clear-fail-") + t.Setenv("GC_CITY", cityDir) + writeGenericNamedSessionCityTOML(t, cityDir) + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + bead, err := store.Create(beads.Bead{ + Title: "generic named session", + Type: session.BeadType, + Labels: []string{session.LabelSession, "template:worker"}, + Metadata: map[string]string{ + "alias": "session-a", + "template": "worker", + "session_name": "s-gc-reset-clear-fail", + "state": "awake", + "session_key": "original-key", + "started_config_hash": "hash-before-reset", + namedSessionMetadataKey: "true", + namedSessionIdentityMetadata: "session-a", + }, + }) + if err != nil { + t.Fatalf("store.Create(session bead): %v", err) + } + + sockPath := filepath.Join(cityDir, ".gc", "controller.sock") + lis, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen(%q): %v", sockPath, err) + } + defer lis.Close() //nolint:errcheck + + commands := make(chan string, 3) + errCh := make(chan error, 1) + go func() { + defer close(commands) + for i := 0; i < 3; i++ { + conn, err := lis.Accept() + if err != nil { + errCh <- err + return + } + buf := make([]byte, 256) + n, err := conn.Read(buf) + if err != nil { + conn.Close() //nolint:errcheck + errCh <- err + return + } + cmd := string(buf[:n]) + commands <- cmd + reply := "ok\n" + if cmd == "ping\n" { + reply = "123\n" + } else if strings.HasPrefix(cmd, "session-circuit-reset:") { + reply = `{"outcome":"failed","error":"clear failed"}` + "\n" + } + if _, err := conn.Write([]byte(reply)); err != nil { + conn.Close() //nolint:errcheck + errCh <- err + return + } + conn.Close() //nolint:errcheck + } + }() + + var stdout, stderr bytes.Buffer + if code := cmdSessionReset([]string{"session-a"}, &stdout, &stderr); code != 1 { + t.Fatalf("cmdSessionReset = %d, want 1; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), `clearing session circuit breaker for "session-a": clear failed`) { + t.Fatalf("stderr = %q, want controller clear failure", stderr.String()) + } + + gotCommands := make([]string, 0, 3) + deadline := time.After(2 * time.Second) + for len(gotCommands) < 3 { + select { + case err := <-errCh: + if err != nil { + t.Fatalf("controller socket: %v", err) + } + case cmd, ok := <-commands: + if !ok { + t.Fatalf("controller commands = %v, want ping, poke, reset", gotCommands) + } + gotCommands = append(gotCommands, cmd) + case <-deadline: + t.Fatalf("timed out waiting for controller commands, got %v", gotCommands) + } + } + if gotCommands[0] != "ping\n" || gotCommands[1] != "poke\n" || !strings.HasPrefix(gotCommands[2], "session-circuit-reset:") { + t.Fatalf("controller commands = %v, want ping, poke, reset", gotCommands) + } + + reloaded, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt(reload): %v", err) + } + got, err := reloaded.Get(bead.ID) + if err != nil { + t.Fatalf("store.Get(%s): %v", bead.ID, err) + } + if got.Metadata["restart_requested"] == "true" { + t.Fatalf("restart_requested = true, want no queued reset after controller clear failure") + } + if got.Metadata["continuation_reset_pending"] == "true" { + t.Fatalf("continuation_reset_pending = true, want no queued reset after controller clear failure") + } +} + +func TestResetSessionCircuitBreakerOnControllerMalformedReply(t *testing.T) { + cityDir := shortSocketTempDir(t, "gc-session-reset-malformed-") + if err := os.MkdirAll(filepath.Join(cityDir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + sockPath := filepath.Join(cityDir, ".gc", "controller.sock") + lis, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen(%q): %v", sockPath, err) + } + defer lis.Close() //nolint:errcheck + + errCh := make(chan error, 1) + go func() { + conn, err := lis.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() //nolint:errcheck + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + errCh <- scanner.Err() + return + } + if _, err := conn.Write([]byte("not-json\n")); err != nil { + errCh <- err + } + }() + + err = resetSessionCircuitBreakerOnController(cityDir, "session-id", "rig-a/session-a") + if err == nil { + t.Fatal("resetSessionCircuitBreakerOnController = nil, want decode error") + } + if !strings.Contains(err.Error(), "decoding session circuit reset reply") { + t.Fatalf("error = %v, want decode context", err) + } + select { + case err := <-errCh: + if err != nil { + t.Fatalf("controller socket: %v", err) + } + default: + } +} + +func writeGenericNamedSessionCityTOML(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + data := []byte(`[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "session-a" +provider = "codex" +start_command = "echo" + +[[named_session]] +template = "session-a" +`) + if err := os.WriteFile(filepath.Join(dir, "city.toml"), data, 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } +} diff --git a/cmd/gc/cmd_session_test.go b/cmd/gc/cmd_session_test.go index e7be2b480f..46efcfd5ec 100644 --- a/cmd/gc/cmd_session_test.go +++ b/cmd/gc/cmd_session_test.go @@ -394,7 +394,7 @@ func TestCmdSessionNew_ACPTemplatePersistsStoredMCPMetadata(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") - cityDir := t.TempDir() + cityDir := shortSocketTempDir(t, "gc-session-mcp-") t.Setenv("GC_CITY", cityDir) writePoolACPSessionCityTOML(t, cityDir) writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` @@ -533,6 +533,34 @@ args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] } } +func TestCmdSessionNewRejectsExplicitTmuxAgentWhenCitySessionProviderIsACP(t *testing.T) { + t.Setenv("GC_BEADS", "file") + + oldBuild := buildSessionProviderByName + t.Cleanup(func() { buildSessionProviderByName = oldBuild }) + buildSessionProviderByName = func(name string, sc config.SessionConfig, cityName, cityPath string) (runtime.Provider, error) { + if name == "acp" { + return &transportCapableSessionProvider{Fake: runtime.NewFake()}, nil + } + return oldBuild(name, sc, cityName, cityPath) + } + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writePoolACPCityExplicitTmuxAgentTOML(t, cityDir) + + var stdout, stderr bytes.Buffer + if code := cmdSessionNew([]string{"demo/ant"}, "", "", "", true, &stdout, &stderr); code == 0 { + t.Fatalf("cmdSessionNew(explicit tmux on ACP city) = %d, want failure", code) + } + if !strings.Contains(stderr.String(), "requires tmux transport") { + t.Fatalf("stderr = %q, want tmux transport error", stderr.String()) + } + if got := sessionBeads(t, cityDir); len(got) != 0 { + t.Fatalf("session bead count = %d, want 0", len(got)) + } +} + func TestCmdSessionNew_PoolTemplateRejectsAliasMatchingConcreteIdentity(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") @@ -841,6 +869,77 @@ func TestBuildResumeCommandUsesBuiltinAncestorForClaudeSettings(t *testing.T) { } } +func TestBuildResumeCommandIncludesWrappedCodexResumeDefaults(t *testing.T) { + cityDir := t.TempDir() + base := "builtin:codex" + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "codex-mini"}, + }, + Providers: map[string]config.ProviderSpec{ + "codex-mini": { + Base: &base, + Command: "aimux", + Args: []string{ + "run", "codex", "--", + "--dangerously-bypass-approvals-and-sandbox", + "-m", "gpt-5.3-codex-spark", + "-c", "model_reasoning_effort=\"medium\"", + }, + PathCheck: "true", + ResumeCommand: "aimux run codex -- --dangerously-bypass-approvals-and-sandbox -m gpt-5.3-codex-spark resume {{.SessionKey}}", + }, + }, + } + info := session.Info{ + Template: "worker", + Command: "codex", + Provider: "codex-mini", + WorkDir: "/tmp/workdir", + SessionKey: "abc-123", + } + + cmd, _ := buildResumeCommand(cityDir, cfg, info, "", io.Discard) + want := "aimux run codex -- --dangerously-bypass-approvals-and-sandbox -m gpt-5.3-codex-spark resume -c model_reasoning_effort=medium abc-123" + if cmd != want { + t.Fatalf("resume command = %q, want %q", cmd, want) + } +} + +func TestBuildResumeCommandProviderKindSkipsTemplateCollision(t *testing.T) { + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "runner", Provider: "agent-provider"}, + }, + Providers: map[string]config.ProviderSpec{ + "runner": { + Command: "true", + Args: []string{"provider"}, + ResumeFlag: "--resume", + }, + "agent-provider": { + Command: "true", + Args: []string{"agent"}, + ResumeFlag: "--resume", + }, + }, + } + info := session.Info{ + Template: "runner", + Command: "stale", + WorkDir: "/tmp/workdir", + SessionKey: "abc-123", + } + + cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "provider", io.Discard) + want := "true provider --resume abc-123" + if cmd != want { + t.Fatalf("resume command = %q, want %q", cmd, want) + } +} + func TestSessionReason_FallsThroughToProviderForSleepingAttachment(t *testing.T) { provider := runtime.NewFake() if err := provider.Start(context.Background(), "sleeping-worker", runtime.Config{Command: "echo"}); err != nil { @@ -1361,6 +1460,42 @@ acp_args = ["acp"] } } +func writePoolACPCityExplicitTmuxAgentTOML(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + rigRoot := filepath.Join(dir, "repos", "demo") + if err := os.MkdirAll(rigRoot, 0o755); err != nil { + t.Fatalf("MkdirAll(rig root): %v", err) + } + data := []byte(fmt.Sprintf(`[workspace] +name = "test-city" + +[beads] +provider = "file" + +[session] +provider = "acp" + +[[rigs]] +name = "demo" +path = %q + +[[agent]] +name = "ant" +dir = "demo" +provider = "codex" +session = "tmux" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 +`, rigRoot)) + if err := os.WriteFile(filepath.Join(dir, "city.toml"), data, 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } +} + func sessionBeads(t *testing.T, cityDir string) []beads.Bead { t.Helper() store, err := openCityStoreAt(cityDir) @@ -1626,6 +1761,32 @@ func TestValidateResolvedSessionTransportAcceptsRoutedACPProvider(t *testing.T) } } +func TestValidateResolvedSessionTransportAcceptsTmuxTransport(t *testing.T) { + if err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + }, config.SessionTransportTmux, runtime.NewFake()); err != nil { + t.Fatalf("validateResolvedSessionTransport() = %v, want nil", err) + } +} + +func TestValidateResolvedSessionTransportRejectsTmuxWhenSessionProviderIsACPOnly(t *testing.T) { + err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + }, config.SessionTransportTmux, &transportCapableSessionProvider{Fake: runtime.NewFake()}) + if err == nil || !strings.Contains(err.Error(), "requires tmux transport") { + t.Fatalf("validateResolvedSessionTransport() error = %v, want tmux routing error", err) + } +} + +func TestValidateResolvedSessionTransportRejectsUnknownTransport(t *testing.T) { + err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + }, "stdio", runtime.NewFake()) + if err == nil || !strings.Contains(err.Error(), "unknown session transport") { + t.Fatalf("validateResolvedSessionTransport() error = %v, want unknown transport error", err) + } +} + func TestValidateResolvedSessionTransportRejectsRoutedProviderWhenTransportCapabilityDisablesACP(t *testing.T) { err := validateResolvedSessionTransport(&config.ResolvedProvider{ Name: "opencode", diff --git a/cmd/gc/cmd_session_wake.go b/cmd/gc/cmd_session_wake.go index da32fda502..e28b50844a 100644 --- a/cmd/gc/cmd_session_wake.go +++ b/cmd/gc/cmd_session_wake.go @@ -3,8 +3,10 @@ package main import ( "fmt" "io" + "strings" "time" + "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/session" "github.com/spf13/cobra" @@ -31,6 +33,7 @@ Accepts a session ID (e.g., gc-42) or session alias (e.g., mayor).`, } return nil }, + ValidArgsFunction: completeSessionIDs, } } @@ -61,6 +64,7 @@ func cmdSessionWake(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "gc session wake: %s is not a session\n", id) //nolint:errcheck return 1 } + hasRunnableTemplate := sessionWakeHasRunnableTemplate(b, cfg) session.RepairEmptyType(store, &b) nudgeIDs, err := session.WakeSession(store, b, time.Now().UTC()) if err != nil { @@ -71,6 +75,17 @@ func cmdSessionWake(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "gc session wake: updating metadata: %v\n", err) //nolint:errcheck return 1 } + if !hasRunnableTemplate && sessionWakeRequestedCreate(b) { + if err := store.SetMetadataBatch(id, map[string]string{ + "state": string(session.StateAsleep), + "state_reason": "", + "pending_create_claim": "", + "pending_create_started_at": "", + }); err != nil { + fmt.Fprintf(stderr, "gc session wake: updating metadata: %v\n", err) //nolint:errcheck + return 1 + } + } if cityErr == nil { if err := withdrawQueuedWaitNudges(cityPath, nudgeIDs); err != nil { fmt.Fprintf(stderr, "gc session wake: warning: withdrawing queued wait nudges: %v\n", err) //nolint:errcheck @@ -85,3 +100,19 @@ func cmdSessionWake(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "Session %s: wake requested.\n", id) //nolint:errcheck return 0 } + +func sessionWakeHasRunnableTemplate(b beads.Bead, cfg *config.City) bool { + if cfg == nil { + return true + } + template := normalizedSessionTemplate(b, cfg) + if template == "" { + template = b.Metadata["template"] + } + return findAgentByTemplate(cfg, template) != nil +} + +func sessionWakeRequestedCreate(b beads.Bead) bool { + state := session.State(strings.TrimSpace(b.Metadata["state"])) + return state == session.StateSuspended || state == session.StateDrained +} diff --git a/cmd/gc/cmd_session_wake_test.go b/cmd/gc/cmd_session_wake_test.go index 79db1e786a..6256352ee0 100644 --- a/cmd/gc/cmd_session_wake_test.go +++ b/cmd/gc/cmd_session_wake_test.go @@ -21,27 +21,30 @@ func TestSessionWake_StateTransitionsAndMetadata(t *testing.T) { metadata map[string]string wantState string wantSleepReason string + wantPending string }{ { - name: "suspended becomes asleep", + name: "suspended requests start", metadata: map[string]string{ "template": "worker", "state": "suspended", "held_until": future, "sleep_reason": "user-hold", }, - wantState: "asleep", + wantState: "creating", wantSleepReason: "", + wantPending: "true", }, { - name: "drained becomes asleep", + name: "drained requests start", metadata: map[string]string{ "template": "worker", "state": "drained", "sleep_reason": "drained", }, - wantState: "asleep", + wantState: "creating", wantSleepReason: "", + wantPending: "true", }, { name: "creating clears quarantine but stays creating", @@ -54,6 +57,7 @@ func TestSessionWake_StateTransitionsAndMetadata(t *testing.T) { }, wantState: "creating", wantSleepReason: "", + wantPending: "", }, { name: "active stays active", @@ -64,6 +68,7 @@ func TestSessionWake_StateTransitionsAndMetadata(t *testing.T) { }, wantState: "active", wantSleepReason: "idle", + wantPending: "", }, } @@ -93,6 +98,9 @@ func TestSessionWake_StateTransitionsAndMetadata(t *testing.T) { if got := updated.Metadata["sleep_reason"]; got != tt.wantSleepReason { t.Fatalf("sleep_reason = %q, want %q", got, tt.wantSleepReason) } + if got := updated.Metadata["pending_create_claim"]; got != tt.wantPending { + t.Fatalf("pending_create_claim = %q, want %q", got, tt.wantPending) + } if got := updated.Metadata["held_until"]; got != "" { t.Fatalf("held_until = %q, want empty", got) } @@ -225,7 +233,7 @@ func TestCmdSessionWake_ManagedBdPokesControllerAndMovesSuspendedToAsleep(t *tes } } -func TestCmdSessionWake_PokesManagedControllerAndMovesSuspendedToAsleep(t *testing.T) { +func TestCmdSessionWake_PokesManagedControllerAndRequestsSuspendedStart(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") @@ -329,8 +337,11 @@ func TestCmdSessionWake_PokesManagedControllerAndMovesSuspendedToAsleep(t *testi if err != nil { t.Fatalf("store.Get(%s): %v", sessionID, err) } - if got := updated.Metadata["state"]; got != "asleep" { - t.Fatalf("state = %q, want asleep", got) + if got := updated.Metadata["state"]; got != "creating" { + t.Fatalf("state = %q, want creating", got) + } + if got := updated.Metadata["pending_create_claim"]; got != "true" { + t.Fatalf("pending_create_claim = %q, want true", got) } if got := updated.Metadata["held_until"]; got != "" { t.Fatalf("held_until = %q, want empty", got) diff --git a/cmd/gc/cmd_sling.go b/cmd/gc/cmd_sling.go index e737ea0cb7..7623bfeb57 100644 --- a/cmd/gc/cmd_sling.go +++ b/cmd/gc/cmd_sling.go @@ -18,6 +18,7 @@ import ( "github.com/gastownhall/gascity/internal/formula" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" + "github.com/gastownhall/gascity/internal/shellquote" "github.com/gastownhall/gascity/internal/sling" "github.com/gastownhall/gascity/internal/sourceworkflow" "github.com/gastownhall/gascity/internal/telemetry" @@ -25,6 +26,21 @@ import ( "github.com/spf13/cobra" ) +func init() { + sling.SetTracer(func(format string, args ...any) { + path := strings.TrimSpace(os.Getenv("GC_SLING_TRACE")) + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() //nolint:errcheck + fmt.Fprintf(f, "%s %s\n", time.Now().UTC().Format(time.RFC3339Nano), fmt.Sprintf(format, args...)) //nolint:errcheck + }) +} + // slingStdin returns the reader for --stdin input. Extracted for testability. var slingStdin = func() io.Reader { return os.Stdin } @@ -149,9 +165,7 @@ func shellSlingRunner(dir, command string, env map[string]string) (string, error if dir != "" { cmd.Dir = dir } - if len(env) > 0 { - cmd.Env = mergeRuntimeEnv(os.Environ(), env) - } + cmd.Env = mergeRuntimeEnv(os.Environ(), env) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("running %q: %w", command, err) @@ -198,6 +212,7 @@ func cmdSling(args []string, isFormula, doNudge, force bool, title string, vars cityName := loadedCityName(cfg, cityPath) var target, beadOrFormula string + var sourceBead existingSlingSourceBead switch { case fromStdin: target = args[0] @@ -205,6 +220,13 @@ func cmdSling(args []string, isFormula, doNudge, force bool, title string, vars case len(args) == 2: target = args[0] beadOrFormula = args[1] + if !isFormula { + sourceBead, err = probeExistingSlingSourceBead(cfg, cityPath, beadOrFormula) + if err != nil { + fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + } default: // 1-arg: bead ID only, resolve target from rig's default_sling_target. beadOrFormula = args[0] @@ -212,11 +234,19 @@ func cmdSling(args []string, isFormula, doNudge, force bool, title string, vars fmt.Fprintf(stderr, "gc sling: --formula requires explicit target\n") //nolint:errcheck // best-effort stderr return 1 } - if !canInferSlingDefaultTargetFromBead(cfg, beadOrFormula) { + sourceBead, err = probeExistingSlingSourceBead(cfg, cityPath, beadOrFormula) + if err != nil { + fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + if !canInferSlingDefaultTargetFromBead(cfg, beadOrFormula) && !sourceBead.exists { fmt.Fprintf(stderr, "gc sling: inline text requires explicit target\n usage: gc sling %q\n", beadOrFormula) //nolint:errcheck // best-effort stderr return 1 } - bp := sling.BeadPrefix(beadOrFormula) + bp := sling.BeadPrefixForCity(cfg, beadOrFormula) + if sourceBead.prefix != "" { + bp = sourceBead.prefix + } if bp == "" { fmt.Fprintf(stderr, "gc sling: cannot derive rig from bead %q (no prefix)\n", beadOrFormula) //nolint:errcheck // best-effort stderr return 1 @@ -247,20 +277,42 @@ func cmdSling(args []string, isFormula, doNudge, force bool, title string, vars sp := newSessionProvider() - storeDir, store, err := openSlingStoreForSource(cfg, cityPath, beadOrFormula, a) - if err != nil { - fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr - return 1 + var storeDir string + var store beads.Store + if sourceBead.exists { + storeDir = sourceBead.storeDir + store, err = openStoreAtForCity(storeDir, cityPath) + if err != nil { + fmt.Fprintf(stderr, "gc sling: opening store %s: %v\n", storeDir, err) //nolint:errcheck // best-effort stderr + return 1 + } + } else { + storeDir, store, err = openSlingStoreForSource(cfg, cityPath, beadOrFormula, a) + if err != nil { + fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } } storeRef := workflowStoreRefForDir(storeDir, cityPath, cityName, cfg) storeEnv := slingStoreEnv(cfg, cityPath, storeDir) + if sourceBead.exists && looksLikeInlineText(cfg, beadOrFormula) { + fmt.Fprintf(stderr, "gc sling: found existing bead %q in %s; routing it instead of creating inline text\n", beadOrFormula, storeRef) //nolint:errcheck // best-effort stderr + } // Inline text mode: if the argument doesn't look like a bead ID // (and we're not in formula mode), create a task bead from the text. // During dry-run, mark the text as preview-only instead of creating it. inlineText := false if !isFormula { - createInlineBead, previewInlineText := resolveInlineBeadAction(cfg, beadOrFormula, dryRun) + inlineProbeStore := store + if !sourceBead.exists && sourceBead.checked && looksLikeInlineText(cfg, beadOrFormula) { + inlineProbeStore = nil + } + createInlineBead, previewInlineText, err := resolveInlineBeadAction(cfg, beadOrFormula, dryRun, inlineProbeStore) + if err != nil { + fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } inlineText = previewInlineText if createInlineBead { created, err := store.Create(beads.Bead{Title: beadOrFormula, Description: stdinDescription, Type: "task"}) @@ -331,7 +383,6 @@ func cmdSling(args []string, isFormula, doNudge, force bool, title string, vars } return out, nil }, - Stderr: stderr, } return doSlingBatch(opts, deps, store, stdout, stderr) @@ -363,8 +414,13 @@ func findRigByPrefix(cfg *config.City, prefix string) (config.Rig, bool) { return sling.FindRigByPrefix(cfg, prefix) } -func beadPrefix(beadID string) string { - return sling.BeadPrefix(beadID) +// beadPrefix returns the rig prefix for beadID, preferring the longest +// configured prefix when cfg is non-nil. Pass cfg whenever the caller +// needs hyphenated rig prefixes (e.g. "agent-diagnostics-hnn") to +// resolve correctly; otherwise the underlying sling.BeadPrefix's +// first-dash split is used. +func beadPrefix(cfg *config.City, beadID string) string { + return sling.BeadPrefixForCity(cfg, beadID) } func rigDirForBead(cfg *config.City, beadID string) string { @@ -392,7 +448,7 @@ func resolveSlingStoreRoot(cfg *config.City, cityPath, beadOrFormula string, a c // resolveStoreScopeRoot would silently alias them to the city // scope. Skip them so sling falls back to the agent's rig_dir or // the city store instead of operating on the wrong store. - if bp := beadPrefix(beadOrFormula); bp != "" && !looksLikeInlineText(cfg, beadOrFormula) { + if bp := beadPrefix(cfg, beadOrFormula); bp != "" && !looksLikeInlineText(cfg, beadOrFormula) { if sling.IsHQPrefix(cfg, bp) { return storeDir } @@ -415,6 +471,50 @@ func openSlingStoreForSource(cfg *config.City, cityPath, beadOrFormula string, a return storeDir, store, nil } +type existingSlingSourceBead struct { + exists bool + checked bool + storeDir string + prefix string +} + +func probeExistingSlingSourceBead(cfg *config.City, cityPath, beadID string) (existingSlingSourceBead, error) { + storeDir, prefix, ok := slingSourceStoreRootForCandidate(cfg, cityPath, beadID) + if !ok { + return existingSlingSourceBead{}, nil + } + store, err := openStoreAtForCity(storeDir, cityPath) + if err != nil { + return existingSlingSourceBead{}, fmt.Errorf("opening store %s: %w", storeDir, err) + } + exists, err := sling.ProbeBeadInStore(store, beadID) + if err != nil { + return existingSlingSourceBead{}, fmt.Errorf("checking bead candidate %q: %w", beadID, err) + } + if !exists { + return existingSlingSourceBead{checked: true, storeDir: storeDir, prefix: prefix}, nil + } + return existingSlingSourceBead{exists: true, checked: true, storeDir: storeDir, prefix: prefix}, nil +} + +func slingSourceStoreRootForCandidate(cfg *config.City, cityPath, beadID string) (string, string, bool) { + if cfg == nil || !isBeadIDCandidate(beadID) { + return "", "", false + } + bp := sling.BeadPrefixForCity(cfg, beadID) + if bp == "" { + return "", "", false + } + if sling.IsHQPrefix(cfg, bp) { + return resolveStoreScopeRoot(cityPath, cityPath), bp, true + } + rig, found := findRigByPrefix(cfg, bp) + if !found || strings.TrimSpace(rig.Path) == "" { + return "", "", false + } + return resolveStoreScopeRoot(cityPath, rig.Path), bp, true +} + func canInferSlingDefaultTargetFromBead(cfg *config.City, beadOrFormula string) bool { return looksLikeBeadID(beadOrFormula) || looksLikeConfiguredBeadID(cfg, beadOrFormula) } @@ -488,7 +588,7 @@ func (r cliBeadRouter) Route(_ context.Context, req sling.RouteRequest) error { if r.deps.Runner == nil { return fmt.Errorf("custom sling_query requires a runner") } - slingCmd := sling.BuildSlingCommandForAgent("sling_query", agentCfg.EffectiveSlingQuery(), req.BeadID, r.deps.CityPath, r.deps.CityName, agentCfg, r.deps.Cfg.Rigs, r.deps.Stderr) + slingCmd, _ := sling.BuildSlingCommandForAgent("sling_query", agentCfg.EffectiveSlingQuery(), req.BeadID, r.deps.CityPath, r.deps.CityName, agentCfg, r.deps.Cfg.Rigs) _, err := r.deps.Runner(req.WorkDir, slingCmd, req.Env) return err } @@ -782,12 +882,12 @@ func missingBeadForceApplies(opts sling.SlingOpts) bool { } func sourceWorkflowCleanupCommand(sourceBeadID, storeRef string) string { - args := []string{"gc workflow delete-source", sourceBeadID} + args := []string{"gc", "workflow", "delete-source", sourceBeadID} if storeRef = strings.TrimSpace(storeRef); storeRef != "" { args = append(args, "--store-ref", storeRef) } args = append(args, "--apply") - return strings.Join(args, " ") + return shellquote.Join(args) } func printSourceWorkflowConflict(stderr io.Writer, conflictErr *sourceworkflow.ConflictError, storeRef string) { @@ -850,7 +950,7 @@ func collectConflictErrors(err error, visit func(*sourceworkflow.ConflictError)) // buildSlingFormulaVars merges caller-provided vars with the runtime context // needed by common work formulas. Explicit --var entries always win. func buildSlingFormulaVars(formulaName, beadID string, userVars []string, a config.Agent, deps slingDeps) map[string]string { - vars := make(map[string]string, len(userVars)+3) + vars := make(map[string]string, len(userVars)+6) for _, v := range userVars { key, value, ok := strings.Cut(v, "=") if ok && key != "" { @@ -866,11 +966,20 @@ func buildSlingFormulaVars(formulaName, beadID string, userVars []string, a conf } vars[key] = value } + addRoutingVar := func(key, value string) { + if _, explicit := vars[key]; explicit { + return + } + vars[key] = value + } if beadID != "" { // Attached work formulas conventionally expect issue=. addVar("issue", beadID) } + addRoutingVar("rig_name", a.Dir) + addRoutingVar("binding_name", a.BindingName) + addRoutingVar("binding_prefix", a.BindingPrefix()) autoBranch := slingFormulaTargetBranch(beadID, deps, a) if slingFormulaUsesBaseBranch(formulaName) { @@ -949,7 +1058,7 @@ func formatBeadLabel(id, title string) string { // printCrossRigSection prints the Cross-rig dry-run section if applicable. func printCrossRigSection(w func(string), beadID string, a config.Agent, cfg *config.City) { if msg := checkCrossRig(beadID, a, cfg); msg != "" { - bp := sling.BeadPrefix(beadID) + bp := sling.BeadPrefixForCity(cfg, beadID) rp := rigPrefixForAgent(a, cfg) w("Cross-rig:") w(fmt.Sprintf(" Bead %s (prefix %q) targets %s (rig prefix %q).", beadID, bp, a.QualifiedName(), rp)) @@ -1467,34 +1576,46 @@ func dryRunSingle(opts slingOpts, deps slingDeps, querier BeadQuerier, stdout, s w(" This creates a wisp and returns its root bead ID.") w("") - routeCmd := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), "", deps.CityPath, deps.CityName, a, deps.Cfg.Rigs, stderr) + routeCmd, _ := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), "", deps.CityPath, deps.CityName, a, deps.Cfg.Rigs) w("Route command (not executed):") w(" " + routeCmd) w(" The wisp root bead (not the formula name) is routed to the agent.") w("") } else { - // Work section (bead info). - printBeadInfo(w, querier, opts.BeadOrFormula) - - // Cross-rig section. - printCrossRigSection(w, opts.BeadOrFormula, a, deps.Cfg) - - // Idempotency section -- use preflight result instead of re-querying. - check := sling.CheckBeadState(querier, opts.BeadOrFormula, a, deps) - if check.Idempotent { - w("Idempotency:") - w(" Bead " + opts.BeadOrFormula + " is already routed to " + a.QualifiedName() + ".") - w(" Without --force, sling would skip routing (exit 0).") + if opts.InlineText { + w("Work:") + w(" Would create new task bead with title=" + fmt.Sprintf("%q", opts.BeadOrFormula)) w("") + } else { + printBeadInfo(w, querier, opts.BeadOrFormula) + printCrossRigSection(w, opts.BeadOrFormula, a, deps.Cfg) + + check := sling.CheckBeadState(querier, opts.BeadOrFormula, a, deps) + if check.Idempotent { + w("Idempotency:") + w(" Bead " + opts.BeadOrFormula + " is already routed to " + a.QualifiedName() + ".") + w(" Without --force, sling would skip routing (exit 0).") + w("") + } } - // Attach formula section (--on or default). - // Dry-run does NOT auto-burn molecules (no mutations). + // Inline-text previews skip the molecule pre-check: the bead + // does not exist yet, so the "no existing children" claim + // would be vacuously true and misleading. + preCheck := !opts.InlineText + // In inline-text mode the live path creates a fresh bead first + // and operates on the new ID; reuse a placeholder in preview + // commands so operators don't read the inline title as the bead + // ID a real run would attach to or route. + previewBeadID := opts.BeadOrFormula + if opts.InlineText { + previewBeadID = "" + } if opts.OnFormula != "" { - // Read-only check: does the bead already have an attached molecule? - if label, id := sling.FindBlockingMolecule(querier, opts.BeadOrFormula, deps.Store); label != "" { - fmt.Fprintf(stderr, "gc sling: bead %s already has attached %s %s\n", opts.BeadOrFormula, label, id) //nolint:errcheck - return 1 + if preCheck { + if rc := dryRunReportBlockingMolecule(opts, deps, querier, stderr); rc != 0 { + return rc + } } w("Attach formula:") w(" Formula: " + opts.OnFormula) @@ -1502,33 +1623,38 @@ func dryRunSingle(opts slingOpts, deps slingDeps, querier BeadQuerier, stdout, s w(" bead. The agent receives the original bead with the workflow") w(" attached, rather than a standalone wisp.") w("") - cookCmd := fmt.Sprintf("bd mol cook --formula=%s --on=%s", opts.OnFormula, opts.BeadOrFormula) + cookCmd := fmt.Sprintf("bd mol cook --formula=%s --on=%s", opts.OnFormula, previewBeadID) if opts.Title != "" { cookCmd += fmt.Sprintf(" --title=%s", opts.Title) } w(" Would run: " + cookCmd) - w(" Pre-check: " + opts.BeadOrFormula + " has no existing molecule/wisp children ✓") + if preCheck { + w(" Pre-check: " + opts.BeadOrFormula + " has no existing molecule/wisp children ✓") + } w("") } else if !opts.NoFormula && a.EffectiveDefaultSlingFormula() != "" { - if label, id := sling.FindBlockingMolecule(querier, opts.BeadOrFormula, deps.Store); label != "" { - fmt.Fprintf(stderr, "gc sling: bead %s already has attached %s %s\n", opts.BeadOrFormula, label, id) //nolint:errcheck - return 1 + if preCheck { + if rc := dryRunReportBlockingMolecule(opts, deps, querier, stderr); rc != 0 { + return rc + } } w("Default formula:") w(" Formula: " + a.EffectiveDefaultSlingFormula()) w(" Target " + a.QualifiedName() + " has a default_sling_formula configured.") w(" A wisp will be attached automatically (use --no-formula to suppress).") w("") - cookCmd := fmt.Sprintf("bd mol cook --formula=%s --on=%s", a.EffectiveDefaultSlingFormula(), opts.BeadOrFormula) + cookCmd := fmt.Sprintf("bd mol cook --formula=%s --on=%s", a.EffectiveDefaultSlingFormula(), previewBeadID) if opts.Title != "" { cookCmd += fmt.Sprintf(" --title=%s", opts.Title) } w(" Would run: " + cookCmd) - w(" Pre-check: " + opts.BeadOrFormula + " has no existing molecule/wisp children ✓") + if preCheck { + w(" Pre-check: " + opts.BeadOrFormula + " has no existing molecule/wisp children ✓") + } w("") } - routeCmd := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), opts.BeadOrFormula, deps.CityPath, deps.CityName, a, deps.Cfg.Rigs, stderr) + routeCmd, _ := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), previewBeadID, deps.CityPath, deps.CityName, a, deps.Cfg.Rigs) w("Route command (not executed):") w(" " + routeCmd) if !sling.IsCustomSlingQuery(a) { @@ -1620,7 +1746,7 @@ func dryRunBatch(opts slingOpts, deps slingDeps, stdout, _ io.Writer, // Route commands. w("Route commands (not executed):") for _, c := range open { - routeCmd := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), c.ID, deps.CityPath, deps.CityName, a, deps.Cfg.Rigs, io.Discard) + routeCmd, _ := sling.BuildSlingCommandForAgent("sling_query", a.EffectiveSlingQuery(), c.ID, deps.CityPath, deps.CityName, a, deps.Cfg.Rigs) w(" " + routeCmd) } w("") @@ -1690,6 +1816,18 @@ func printBeadInfo(w func(string), q BeadQuerier, beadID string) { w("") } +// dryRunReportBlockingMolecule returns 1 (and emits a stderr diagnostic) +// when the bead already has an attached molecule that would block +// formula attachment, otherwise 0. +func dryRunReportBlockingMolecule(opts slingOpts, deps slingDeps, querier BeadQuerier, stderr io.Writer) int { + label, id := sling.FindBlockingMolecule(querier, opts.BeadOrFormula, deps.Store) + if label == "" { + return 0 + } + fmt.Fprintf(stderr, "gc sling: bead %s already has attached %s %s\n", opts.BeadOrFormula, label, id) //nolint:errcheck // best-effort stderr + return 1 +} + // printNudgePreview prints the Nudge section for dry-run output. func printNudgePreview(w func(string), a config.Agent, cityName string, sp runtime.Provider, store beads.Store, cfg *config.City, @@ -1719,8 +1857,11 @@ func isCustomSlingQuery(a config.Agent) bool { // "gc-r5sr6bm"). Short suffixes (1-4 chars) are accepted // unconditionally. Longer suffixes (5-8 chars) must contain at least // one digit to distinguish base36 hashes from English words like -// "hello-world". Strings with spaces or multiple dashes (like -// "code-review") are treated as inline text for ad-hoc bead creation. +// "hello-world". This is the cfg-free heuristic and rejects bead IDs +// whose rig prefix contains a hyphen ("agent-diagnostics-hnn"); those +// are accepted by looksLikeConfiguredBeadID, which consults cfg.Rigs. +// Multi-dash strings with no matching configured rig prefix are +// treated as inline text for ad-hoc bead creation. func looksLikeBeadID(s string) bool { _, baseSuffix, ok := sling.BeadIDParts(s) if !ok || len(baseSuffix) > 8 { @@ -1741,15 +1882,53 @@ func looksLikeBeadIDSuffix(baseSuffix string) bool { return false } -func shouldCreateInlineBead(cfg *config.City, beadOrFormula string) bool { - return looksLikeInlineText(cfg, beadOrFormula) +func resolveInlineBeadAction(cfg *config.City, beadOrFormula string, dryRun bool, store beads.Store) (createInlineBead, previewInlineText bool, err error) { + // Fast path: heuristics already classify this as a bead ID. + if !looksLikeInlineText(cfg, beadOrFormula) { + return false, false, nil + } + // Store probe: covers IDs that pass the shape pre-check but fail the + // heuristic (e.g. descriptive multi-dash IDs like "fo-spawn-storm"). + // A store hit means the bead exists and should be routed, not created. + if store != nil && isBeadIDCandidate(beadOrFormula) { + exists, err := sling.ProbeBeadInStore(store, beadOrFormula) + if err != nil { + return false, false, fmt.Errorf("checking bead candidate %q: %w", beadOrFormula, err) + } + if exists { + return false, false, nil + } + } + if dryRun { + return false, true, nil + } + return true, false, nil } -func resolveInlineBeadAction(cfg *config.City, beadOrFormula string, dryRun bool) (createInlineBead, previewInlineText bool) { - if dryRun && looksLikeInlineText(cfg, beadOrFormula) { - return false, true +// isBeadIDCandidate reports whether s has the shape of a potential bead ID: +// no whitespace, starts with a letter, contains only letters, digits, hyphens, +// underscores, and dots, and has at least one hyphen. Used to gate the store +// probe before falling back to inline-text creation. +func isBeadIDCandidate(s string) bool { + if s == "" || strings.ContainsAny(s, " \t\n") { + return false + } + first := s[0] + if (first < 'a' || first > 'z') && (first < 'A' || first > 'Z') { + return false + } + hasDash := false + for _, c := range s { + switch { + case c == '-': + hasDash = true + case c == '_' || c == '.': + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + default: + return false + } } - return shouldCreateInlineBead(cfg, beadOrFormula), false + return hasDash } func looksLikeInlineText(cfg *config.City, beadOrFormula string) bool { @@ -1757,22 +1936,7 @@ func looksLikeInlineText(cfg *config.City, beadOrFormula string) bool { } func looksLikeConfiguredBeadID(cfg *config.City, s string) bool { - prefix, baseSuffix, ok := sling.BeadIDParts(s) - if !ok || len(baseSuffix) > 8 { - return false - } - if cfg == nil { - return false - } - if strings.EqualFold(prefix, config.EffectiveHQPrefix(cfg)) { - return true - } - for i := range cfg.Rigs { - if strings.EqualFold(prefix, cfg.Rigs[i].EffectivePrefix()) { - return true - } - } - return false + return sling.LooksLikeConfiguredBeadID(cfg, s) } // rigPrefixForAgent returns the effective bead prefix for the rig that an @@ -1794,7 +1958,7 @@ func rigPrefixForAgent(a config.Agent, cfg *config.City) string { // doesn't match the target agent's rig prefix. Returns "" when the check // passes or can't be performed (missing prefix, city-wide agent, no rig). func checkCrossRig(beadID string, a config.Agent, cfg *config.City) string { - bp := sling.BeadPrefix(beadID) + bp := sling.BeadPrefixForCity(cfg, beadID) if bp == "" { return "" } diff --git a/cmd/gc/cmd_sling_test.go b/cmd/gc/cmd_sling_test.go index 80087870b5..1c9c886a66 100644 --- a/cmd/gc/cmd_sling_test.go +++ b/cmd/gc/cmd_sling_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "maps" "net" "os" "os/exec" @@ -114,7 +115,7 @@ func (s *slingTestStore) Get(id string) (beads.Bead, error) { } b, ok := s.synthetic[id] if !ok { - if _, _, looksLikeBead := sling.BeadIDParts(id); !looksLikeBead { + if !slingTestLooksLikeBeadID(id) { return beads.Bead{}, err } return s.ensureSynthetic(id), nil @@ -122,6 +123,52 @@ func (s *slingTestStore) Get(id string) (beads.Bead, error) { return b, nil } +// slingTestLooksLikeBeadID accepts the same single-dash shapes as +// sling.BeadIDParts plus multi-dash shapes whose trailing token has the +// bead-suffix shape: alphanumeric, ≤8 chars, and either ≤4 chars long +// or containing at least one digit. The digit-or-≤4 rule mirrors +// looksLikeBeadIDSuffix and prevents prose like "code-review-please" +// (suffix "please" — 6 chars, no digit) from being silently fabricated +// as a synthetic bead and masking the auto-create-text-bead branch in +// tests. Tests that rely on multi-dash bead IDs whose suffix violates +// this shape must seed beads explicitly. +func slingTestLooksLikeBeadID(id string) bool { + if _, _, ok := sling.BeadIDParts(id); ok { + return true + } + id = strings.TrimSpace(id) + if id == "" || strings.ContainsAny(id, " \t\n") { + return false + } + last := strings.LastIndex(id, "-") + if last <= 0 || last == len(id)-1 { + return false + } + suffix := id[last+1:] + base := suffix + if dot := strings.IndexByte(suffix, '.'); dot > 0 { + base = suffix[:dot] + } + if base == "" || len(base) > 8 { + return false + } + hasDigit := false + for _, c := range base { + switch { + case c >= '0' && c <= '9': + hasDigit = true + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + default: + return false + } + } + if len(base) > 4 && !hasDigit { + return false + } + return true +} + func (s *slingTestStore) SetMetadata(id, key, value string) error { if err := s.Store.SetMetadata(id, key, value); err == nil || !errors.Is(err, beads.ErrNotFound) { return err @@ -503,6 +550,36 @@ func TestShellSlingRunnerOverridesInheritedBDEnv(t *testing.T) { } } +func TestShellSlingRunnerStripsInheritedSecrets(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghs_should_not_leak") + t.Setenv("OPENAI_API_KEY", "sk-should-not-leak") + + out, err := shellSlingRunner("", `printf '%s|%s' "${GITHUB_TOKEN:-unset}" "${OPENAI_API_KEY:-unset}"`, nil) + if err != nil { + t.Fatalf("shellSlingRunner: %v", err) + } + if got := strings.TrimSpace(out); got != "unset|unset" { + t.Fatalf("shellSlingRunner inherited secrets = %q, want unset|unset", got) + } +} + +func TestSourceWorkflowCleanupCommandQuotesUntrustedArgs(t *testing.T) { + got := sourceWorkflowCleanupCommand("ga-1; touch /tmp/pwn", "rig:demo; rm -rf /") + if got == "gc workflow delete-source ga-1; touch /tmp/pwn --store-ref rig:demo; rm -rf / --apply" { + t.Fatalf("cleanup command left shell metacharacters unquoted: %q", got) + } + args := shellquote.Split(got) + want := []string{"gc", "workflow", "delete-source", "ga-1; touch /tmp/pwn", "--store-ref", "rig:demo; rm -rf /", "--apply"} + if len(args) != len(want) { + t.Fatalf("cleanup command args = %#v, want %#v", args, want) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("cleanup command arg[%d] = %q, want %q (command %q)", i, args[i], want[i], got) + } + } +} + func TestDoSlingBeadToPool(t *testing.T) { runner := newFakeRunner() sp := runtime.NewFake() @@ -1021,6 +1098,183 @@ dir = "frontend" return cityDir } +// setupRigScopedBdCity writes a city.toml with one rig ("frontend", +// prefix "FE") and a rig-scoped .beads/config.yaml compatible with the +// bd provider contract. Returns the city and rig paths. Used by the +// #200 regression guards for the bd provider. +func setupRigScopedBdCity(t *testing.T) (cityDir, rigDir string) { + t.Helper() + cityDir = t.TempDir() + rigDir = filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(filepath.Join(rigDir, ".beads"), 0o700); err != nil { + t.Fatalf("MkdirAll(rig): %v", err) + } + if err := os.WriteFile(filepath.Join(rigDir, ".beads", "config.yaml"), []byte(`issue_prefix: FE +gc.endpoint_origin: inherited_city +gc.endpoint_status: verified +dolt.auto-start: false +`), 0o644); err != nil { + t.Fatal(err) + } + cityToml := `[workspace] +name = "demo" + +[[rigs]] +name = "frontend" +path = "frontend" +prefix = "FE" + +[[agent]] +name = "worker" +dir = "frontend" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + return cityDir, rigDir +} + +// bdInvocation records a single bd subprocess call — env snapshot, +// dir, and argv — so tests can assert on the scope the command ran in. +type bdInvocation struct { + Env map[string]string + Dir string + Args []string +} + +// installCaptureBdRunner swaps beadsExecCommandRunnerWithEnv with a +// fake that records every bd invocation and returns plausible +// responses for the subcommands cmdSling's inline-text path actually +// runs (show, create, update). Unexpected subcommands fail the test +// loudly so drift in sling's bd usage surfaces instead of silently +// passing. Returns a pointer to the capture slice; auto-restores via +// t.Cleanup. +func installCaptureBdRunner(t *testing.T) *[]bdInvocation { + t.Helper() + orig := beadsExecCommandRunnerWithEnv + t.Cleanup(func() { beadsExecCommandRunnerWithEnv = orig }) + + calls := &[]bdInvocation{} + beadsExecCommandRunnerWithEnv = func(env map[string]string) beads.CommandRunner { + snap := maps.Clone(env) + return func(dir, name string, args ...string) ([]byte, error) { + *calls = append(*calls, bdInvocation{Env: snap, Dir: dir, Args: append([]string(nil), args...)}) + if name != "bd" { + t.Errorf("unexpected command %q args=%v", name, args) + return nil, fmt.Errorf("unexpected command %q", name) + } + switch { + case len(args) >= 2 && args[0] == "create" && args[1] == "--json": + title := "" + if len(args) > 2 { + title = args[2] + } + return []byte(fmt.Sprintf(`{"id":"FE-abc","title":%q,"status":"open","issue_type":"task","created_at":"2026-04-22T00:00:00Z","assignee":"","from":"","parent":"","ref":"","needs":null,"description":"","labels":null}`, title)), nil + case len(args) >= 2 && args[0] == "update" && args[1] == "--json": + return []byte(`{}`), nil + case len(args) >= 2 && args[0] == "show" && args[1] == "--json": + return nil, fmt.Errorf("issue not found") + case len(args) >= 2 && args[0] == "list" && args[1] == "--json": + return []byte(`[]`), nil + default: + t.Errorf("unexpected bd subcommand args=%v — fake must be extended if sling now invokes this", args) + return nil, fmt.Errorf("unexpected bd subcommand args=%v", args) + } + } + } + return calls +} + +// firstBdCreate returns the first `bd create --json` invocation +// captured by installCaptureBdRunner, or fails the test if none was +// observed. +func firstBdCreate(t *testing.T, calls []bdInvocation) bdInvocation { + t.Helper() + for _, c := range calls { + if len(c.Args) >= 2 && c.Args[0] == "create" && c.Args[1] == "--json" { + return c + } + } + t.Fatalf("no bd create invocation observed. Captured %d calls: %v", len(calls), calls) + return bdInvocation{} +} + +// Regression guard for #200: on 0.13.5 the pre-bdStoreForRig code path +// hardcoded BEADS_DIR to /.beads for every bd subprocess, so +// bd create landed the inline bead in the city store and the cross-rig +// guard blocked routing. Commit 92c6c0d7 introduced bdStoreForRig + +// bdRuntimeEnvForRig which silently fixed it; this test locks the +// invariant for the default bd provider so the scoping cannot regress. +func TestCmdSlingInlineBeadRigScopedBdProvider(t *testing.T) { + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "bd") + + cityDir, rigDir := setupRigScopedBdCity(t) + calls := installCaptureBdRunner(t) + + t.Chdir(cityDir) + + var stdout, stderr bytes.Buffer + code := cmdSling([]string{"frontend/worker", "ship feature"}, false, false, true, "", nil, "", true, false, "", false, false, false, "", "", &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stderr: %s", code, stderr.String()) + } + + create := firstBdCreate(t, *calls) + wantBeadsDir := filepath.Join(rigDir, ".beads") + if got := create.Env["BEADS_DIR"]; got != wantBeadsDir { + t.Fatalf("bd create BEADS_DIR = %q, want %q (rig-scoped); all calls: %v", got, wantBeadsDir, *calls) + } + if got := create.Env["GC_RIG_ROOT"]; got != rigDir { + t.Fatalf("bd create GC_RIG_ROOT = %q, want %q", got, rigDir) + } + if got := create.Env["GC_RIG"]; got != "frontend" { + t.Fatalf("bd create GC_RIG = %q, want %q", got, "frontend") + } + if got := create.Dir; got != rigDir { + t.Fatalf("bd create dir = %q, want %q", got, rigDir) + } +} + +// Reporter's exact #200 repro: CWD=rig, bare target resolves to +// rig-scoped agent via currentRigContext, and the inline bead must +// still land in the rig store. +func TestCmdSlingInlineBeadBareTargetFromRigCwdBdProvider(t *testing.T) { + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "bd") + + _, rigDir := setupRigScopedBdCity(t) + calls := installCaptureBdRunner(t) + + t.Chdir(rigDir) + + var stdout, stderr bytes.Buffer + code := cmdSling([]string{"worker", "ship feature"}, false, false, true, "", nil, "", true, false, "", false, false, false, "", "", &stdout, &stderr) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stderr: %s", code, stderr.String()) + } + + create := firstBdCreate(t, *calls) + wantBeadsDir := filepath.Join(rigDir, ".beads") + if got := create.Env["BEADS_DIR"]; got != wantBeadsDir { + t.Fatalf("bd create BEADS_DIR = %q, want %q (rig-scoped). Bare target %q from rig cwd must land in the rig store; all calls: %v", + got, wantBeadsDir, "worker", *calls) + } + // Mirror the env-surface assertions from the qualified-target + // variant so a regression that sets BEADS_DIR correctly but drops + // GC_RIG/GC_RIG_ROOT via the currentRigContext path still fails + // loudly. + if got := create.Env["GC_RIG_ROOT"]; got != rigDir { + t.Fatalf("bd create GC_RIG_ROOT = %q, want %q", got, rigDir) + } + if got := create.Env["GC_RIG"]; got != "frontend" { + t.Fatalf("bd create GC_RIG = %q, want %q", got, "frontend") + } + if got := create.Dir; got != rigDir { + t.Fatalf("bd create dir = %q, want %q", got, rigDir) + } +} + func TestCmdSlingRefusesMissingBead(t *testing.T) { // A bead-ID-shaped argument that doesn't resolve in the store must // cause sling to error out — otherwise a fabricated / typo'd ID @@ -1133,8 +1387,75 @@ func TestCmdSlingDryRunPreviewsInlineText(t *testing.T) { } } +// TestCmdSlingDryRunInlineTextHasNoFalsePositivePreCheck verifies that +// inline-text dry-runs print a "Would create new task bead" hint and +// suppress the Pre-check ✓ line (which would be vacuously true for a +// bead that does not exist yet). +func TestCmdSlingDryRunInlineTextHasNoFalsePositivePreCheck(t *testing.T) { + cityDir := setupCmdSlingBeadExistsFixture(t) + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"frontend/worker", "write docs"}, + false, false, false, + "", nil, "", + true, false, "", + false, false, true, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling dry-run returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + out := stdout.String() + if strings.Contains(out, "has no existing molecule/wisp children ✓") { + t.Fatalf("dry-run stdout still emits false-positive Pre-check ✓ for inline text:\n%s", out) + } + if !strings.Contains(out, "Would create new task bead") { + t.Fatalf("dry-run stdout missing inline-text creation hint:\n%s", out) + } + // Cook/route preview commands must use a placeholder rather than + // the inline title: the live path creates a bead first and uses + // the new ID, so showing "write docs" as the bead-id arg would + // describe a command that wouldn't actually run. + if strings.Contains(out, "--on=write docs") || strings.Contains(out, "--on='write docs'") { + t.Fatalf("dry-run stdout uses inline title as bead ID in --on=...:\n%s", out) + } + if !strings.Contains(out, "") { + t.Fatalf("dry-run stdout missing placeholder:\n%s", out) + } + // Pre-existing footer must still be present. + if !strings.Contains(out, "No side effects executed (--dry-run).") { + t.Fatalf("dry-run stdout missing dry-run footer:\n%s", out) + } + + // Sanity: city/frontend stores must remain empty (no bead created). + for _, dir := range []string{cityDir, filepath.Join(cityDir, "frontend")} { + store, err := openStoreAtForCity(dir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(%s): %v", dir, err) + } + bs, err := store.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("List(%s): %v", dir, err) + } + if len(bs) != 0 { + t.Fatalf("store %s has %d beads after dry-run, want 0: %#v", dir, len(bs), bs) + } + } +} + +func mustResolveInlineBeadAction(t *testing.T, cfg *config.City, beadOrFormula string, dryRun bool, store beads.Store) (bool, bool) { + t.Helper() + create, inlineText, err := resolveInlineBeadAction(cfg, beadOrFormula, dryRun, store) + if err != nil { + t.Fatalf("resolveInlineBeadAction: %v", err) + } + return create, inlineText +} + func TestResolveInlineBeadActionDryRunInlineTextDoesNotProbeStore(t *testing.T) { - create, inlineText := resolveInlineBeadAction(&config.City{}, "write docs", true) + create, inlineText := mustResolveInlineBeadAction(t, &config.City{}, "write docs", true, nil) if create { t.Fatal("create = true, want false during dry-run") } @@ -1144,7 +1465,7 @@ func TestResolveInlineBeadActionDryRunInlineTextDoesNotProbeStore(t *testing.T) } func TestResolveInlineBeadActionWhitespaceInlineTextDoesNotProbeStore(t *testing.T) { - create, inlineText := resolveInlineBeadAction(&config.City{}, "write docs", false) + create, inlineText := mustResolveInlineBeadAction(t, &config.City{}, "write docs", false, nil) if !create { t.Fatal("create = false, want true for whitespace inline text") } @@ -1154,7 +1475,7 @@ func TestResolveInlineBeadActionWhitespaceInlineTextDoesNotProbeStore(t *testing } func TestResolveInlineBeadActionSingleTokenInlineTextDoesNotProbeStore(t *testing.T) { - create, inlineText := resolveInlineBeadAction(&config.City{}, "docs", false) + create, inlineText := mustResolveInlineBeadAction(t, &config.City{}, "docs", false, nil) if !create { t.Fatal("create = false, want true for single-token inline text") } @@ -1164,7 +1485,7 @@ func TestResolveInlineBeadActionSingleTokenInlineTextDoesNotProbeStore(t *testin } func TestResolveInlineBeadActionBeadIDDoesNotProbeStore(t *testing.T) { - create, inlineText := resolveInlineBeadAction(&config.City{}, "FE-123", false) + create, inlineText := mustResolveInlineBeadAction(t, &config.City{}, "FE-123", false, nil) if create { t.Fatal("create = true, want false for bead ID") } @@ -1173,13 +1494,56 @@ func TestResolveInlineBeadActionBeadIDDoesNotProbeStore(t *testing.T) { } } +func TestResolveInlineBeadActionHyphenatedRigPrefixIsBeadID(t *testing.T) { + // Bead IDs whose configured rig prefix contains a hyphen + // (agent-diagnostics-hnn from rig "agent-diagnostics") must + // classify as bead IDs, not inline text. + cfg := &config.City{ + Rigs: []config.Rig{ + {Name: "agent-diagnostics", Path: "/tmp/agent-diag", Prefix: "agent-diagnostics"}, + }, + } + + create, inlineText := mustResolveInlineBeadAction(t, cfg, "agent-diagnostics-hnn", false, nil) + if create { + t.Fatal("create = true, want false for configured hyphenated bead ID") + } + if inlineText { + t.Fatal("inlineText = true, want false outside dry-run") + } + + create, inlineText = mustResolveInlineBeadAction(t, cfg, "agent-diagnostics-hnn", true, nil) + if create { + t.Fatal("create = true, want false during dry-run") + } + if inlineText { + t.Fatal("inlineText = true, want false for configured bead ID even in dry-run") + } +} + +func TestResolveInlineBeadActionUnknownHyphenatedTextStillCreates(t *testing.T) { + // Inline text shaped like "-" with no store must + // still create an inline task bead. Only inputs that match a CONFIGURED + // rig prefix are protected from the auto-create branch (without a store). + cfg := &config.City{ + Rigs: []config.Rig{{Name: "fe", Path: "/fe", Prefix: "fe"}}, + } + create, inlineText := mustResolveInlineBeadAction(t, cfg, "code-review-please", false, nil) + if !create { + t.Fatal("create = false, want true for non-configured hyphenated text") + } + if inlineText { + t.Fatal("inlineText = true, want false outside dry-run") + } +} + func TestResolveInlineBeadActionConfiguredAlphaSuffixIsBeadID(t *testing.T) { cfg := &config.City{ Workspace: config.Workspace{Name: "test", Prefix: "HQ"}, Rigs: []config.Rig{{Name: "frontend", Path: "/tmp/frontend", Prefix: "FE"}}, } - create, inlineText := resolveInlineBeadAction(cfg, "FE-hello", false) + create, inlineText := mustResolveInlineBeadAction(t, cfg, "FE-hello", false, nil) if create { t.Fatal("create = true, want false for configured bead ID with all-alpha suffix") } @@ -1187,7 +1551,7 @@ func TestResolveInlineBeadActionConfiguredAlphaSuffixIsBeadID(t *testing.T) { t.Fatal("inlineText = true, want false outside dry-run") } - create, inlineText = resolveInlineBeadAction(cfg, "FE-a1pha", false) + create, inlineText = mustResolveInlineBeadAction(t, cfg, "FE-a1pha", false, nil) if create { t.Fatal("create = true, want false for configured bead ID with digit") } @@ -1196,11 +1560,73 @@ func TestResolveInlineBeadActionConfiguredAlphaSuffixIsBeadID(t *testing.T) { } } +func TestResolveInlineBeadActionMultiDashStoreHitIsBeadID(t *testing.T) { + // A multi-dash ID that fails the suffix heuristic but exists in the store + // must classify as a bead ID, not inline text. + cfg := &config.City{ + Rigs: []config.Rig{{Name: "fo", Path: "/tmp/fo", Prefix: "fo"}}, + } + store := seededStore("fo-spawn-storm") + + create, inlineText := mustResolveInlineBeadAction(t, cfg, "fo-spawn-storm", false, store) + if create { + t.Fatal("create = true, want false — bead exists in store") + } + if inlineText { + t.Fatal("inlineText = true, want false outside dry-run") + } + + create, inlineText = mustResolveInlineBeadAction(t, cfg, "fo-spawn-storm", true, store) + if create { + t.Fatal("create = true, want false during dry-run") + } + if inlineText { + t.Fatal("inlineText = true, want false — bead exists in store") + } +} + +func TestResolveInlineBeadActionMultiDashStoreMissStillCreates(t *testing.T) { + // A multi-dash ID absent from the store falls through to inline-text + // creation — the caller will auto-create a bead from the text. + cfg := &config.City{ + Rigs: []config.Rig{{Name: "fo", Path: "/tmp/fo", Prefix: "fo"}}, + } + store := seededStore() // empty + + create, inlineText := mustResolveInlineBeadAction(t, cfg, "fo-typo-not-real", false, store) + if !create { + t.Fatal("create = false, want true for unknown multi-dash text") + } + if inlineText { + t.Fatal("inlineText = true, want false outside dry-run") + } +} + +func TestResolveInlineBeadActionMultiDashStoreErrorSurfaces(t *testing.T) { + cfg := &config.City{ + Rigs: []config.Rig{{Name: "fo", Path: "/tmp/fo", Prefix: "fo"}}, + } + store := &getErrStore{Store: beads.NewMemStore(), err: fmt.Errorf("lookup failed")} + + _, _, err := resolveInlineBeadAction(cfg, "fo-spawn-storm", false, store) + if err == nil { + t.Fatal("resolveInlineBeadAction error = nil, want lookup failure") + } + if !strings.Contains(err.Error(), "lookup failed") { + t.Fatalf("resolveInlineBeadAction error = %q, want lookup failure", err) + } +} + func TestCmdSlingConfiguredPrefixAllAlphaExistingBeadUsesPrefixStore(t *testing.T) { configureIsolatedRuntimeEnv(t) t.Setenv("GC_BEADS", "file") cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") frontendDir := filepath.Join(cityDir, "frontend") ordersDir := filepath.Join(cityDir, "orders") for _, dir := range []string{frontendDir, ordersDir} { @@ -1287,6 +1713,203 @@ dir = "orders" } } +// TestCmdSlingHyphenatedRigPrefixExistingBeadDoesNotOrphan verifies +// that an existing bead in a rig whose configured prefix contains a +// hyphen ("agent-diagnostics-hnn" in rig "agent-diagnostics") routes +// to the rig store without auto-creating a city orphan. +func TestCmdSlingHyphenatedRigPrefixExistingBeadDoesNotOrphan(t *testing.T) { + beadID := "agent-diagnostics-hnn" + cityDir, rigDir, _ := setupCmdSlingHyphenatedRigPrefixBeadFixture(t, beadID, "agent-diagnostics") + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"agent-diagnostics/worker", beadID}, + false, false, true, + "", nil, "", + true, false, "", + true, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + // The pre-fix bug printed a "Created gc-NNN — \"agent-diagnostics-hnn\"" + // line because the live path took the auto-create-text-bead branch. + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("orphan auto-create regression: stdout = %q", stdout.String()) + } + + assertHyphenatedRigBeadRoutedWithoutInlineOrphan(t, cityDir, rigDir, beadID, "agent-diagnostics/worker") +} + +func TestCmdSlingHyphenatedRigPrefixMultiDashExistingBeadDoesNotOrphan(t *testing.T) { + beadID := "agent-diagnostics-spawn-storm" + cityDir, rigDir, _ := setupCmdSlingHyphenatedRigPrefixBeadFixture(t, beadID, "agent-diagnostics") + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"agent-diagnostics/worker", beadID}, + false, false, true, + "", nil, "", + true, false, "", + true, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("orphan auto-create regression: stdout = %q", stdout.String()) + } + + assertHyphenatedRigBeadRoutedWithoutInlineOrphan(t, cityDir, rigDir, beadID, "agent-diagnostics/worker") +} + +func TestCmdSlingOneArgHyphenatedPrefixMultiDashExistingBeadUsesDefaultTarget(t *testing.T) { + beadID := "agent-diagnostics-spawn-storm" + cityDir, rigDir, _ := setupCmdSlingHyphenatedRigPrefixBeadFixture(t, beadID, "agent-diagnostics") + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{beadID}, + false, false, false, + "", nil, "", + true, false, "", + false, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("orphan auto-create regression: stdout = %q", stdout.String()) + } + + assertHyphenatedRigBeadRoutedWithoutInlineOrphan(t, cityDir, rigDir, beadID, "agent-diagnostics/worker") +} + +func TestCmdSlingCrossRigHyphenatedPrefixMultiDashExistingBeadUsesPrefixStore(t *testing.T) { + beadID := "agent-diagnostics-spawn-storm" + cityDir, rigDir, otherDir := setupCmdSlingHyphenatedRigPrefixBeadFixture(t, beadID, "other") + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"other/worker", beadID}, + false, false, true, + "", nil, "", + true, false, "", + true, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("stdout = %q, want existing bead route without inline creation", stdout.String()) + } + + assertHyphenatedRigBeadRoutedWithoutInlineOrphan(t, cityDir, rigDir, beadID, "other/worker") + assertStoreHasNoBeadTitle(t, cityDir, otherDir, beadID) +} + +func setupCmdSlingHyphenatedRigPrefixBeadFixture(t *testing.T, beadID, agentDir string) (cityDir, rigDir, otherDir string) { + t.Helper() + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "file") + + cityDir = t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + rigDir = filepath.Join(cityDir, "agent-diagnostics") + otherDir = filepath.Join(cityDir, "other") + for _, dir := range []string{rigDir, otherDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", dir, err) + } + } + if err := ensureScopedFileStoreLayout(cityDir); err != nil { + t.Fatalf("ensureScopedFileStoreLayout: %v", err) + } + for _, dir := range []string{cityDir, rigDir, otherDir} { + if err := ensurePersistedScopeLocalFileStore(dir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(%s): %v", dir, err) + } + } + writeTestFileStoreBeads(t, rigDir, []beads.Bead{{ + ID: beadID, + Title: "existing diagnostics work", + Type: "task", + Status: "open", + Metadata: map[string]string{}, + }}) + cityToml := fmt.Sprintf(`[workspace] +name = "demo" + +[[rigs]] +name = "agent-diagnostics" +path = "agent-diagnostics" +prefix = "agent-diagnostics" +default_sling_target = "agent-diagnostics/worker" + +[[rigs]] +name = "other" +path = "other" +prefix = "OT" + +[[agent]] +name = "worker" +dir = %q +`, agentDir) + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Chdir(cityDir) + return cityDir, rigDir, otherDir +} + +func assertHyphenatedRigBeadRoutedWithoutInlineOrphan(t *testing.T, cityDir, rigDir, beadID, wantTarget string) { + t.Helper() + + rigStore, err := openStoreAtForCity(rigDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(rig): %v", err) + } + routed, err := rigStore.Get(beadID) + if err != nil { + t.Fatalf("rigStore.Get(%s): %v", beadID, err) + } + if routed.Metadata["gc.routed_to"] != wantTarget { + t.Fatalf("rig bead gc.routed_to = %q, want %s (routing must land on the existing bead, not an orphan)", routed.Metadata["gc.routed_to"], wantTarget) + } + + // City store must NOT contain a stray bead from the auto-create path. + assertStoreHasNoBeadTitle(t, cityDir, cityDir, beadID) +} + +func assertStoreHasNoBeadTitle(t *testing.T, cityDir, storeDir, beadTitle string) { + t.Helper() + store, err := openStoreAtForCity(storeDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(%s): %v", storeDir, err) + } + storeBeads, err := store.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("store.List(%s): %v", storeDir, err) + } + for _, b := range storeBeads { + if b.Title == beadTitle { + t.Fatalf("store %s has orphan bead %q (title %q): inline-text auto-create fired for a known-rig bead ID", storeDir, b.ID, b.Title) + } + } +} + func TestCmdSlingConfiguredPrefixAllAlphaExistingBeadUsesSelectedPrefixStore(t *testing.T) { cityDir, frontendDir := setupCmdSlingConfiguredPrefixAllAlphaFrontendFixture(t, false, true) @@ -1366,6 +1989,11 @@ func setupCmdSlingConfiguredPrefixAllAlphaFrontendFixture(t *testing.T, defaultT t.Setenv("GC_BEADS", "file") cityDir = t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") frontendDir = filepath.Join(cityDir, "frontend") if err := os.MkdirAll(frontendDir, 0o755); err != nil { t.Fatalf("MkdirAll(frontend): %v", err) @@ -1425,34 +2053,295 @@ func writeTestFileStoreBeads(t *testing.T, scopeRoot string, stored []beads.Bead } } -func TestCmdSlingForceBypassesMissingBeadCheck(t *testing.T) { - // --force must bypass the bead-existence check. The call may still - // fail further downstream (we don't assert a success exit here), but - // stderr must not contain the "not found" guard message. - setupCmdSlingBeadExistsFixture(t) +func TestCmdSlingForceBypassesMissingBeadCheck(t *testing.T) { + // --force must bypass the bead-existence check. The call may still + // fail further downstream (we don't assert a success exit here), but + // stderr must not contain the "not found" guard message. + setupCmdSlingBeadExistsFixture(t) + + var stdout, stderr bytes.Buffer + _ = cmdSling( + []string{"frontend/worker", "FE-ghost1"}, + false, false, true, // force=true + "", nil, "", + true, false, "", + false, false, false, + "", "", + &stdout, &stderr, + ) + got := stderr.String() + if strings.Contains(got, "not found in store") { + t.Errorf("--force did not bypass bead-existence check; stderr: %s", got) + } +} + +func TestCmdSlingForceMissingBeadPrintsAutoConvoyWarning(t *testing.T) { + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "file") + + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatalf("MkdirAll(rig): %v", err) + } + if err := ensureScopedFileStoreLayout(cityDir); err != nil { + t.Fatalf("ensureScopedFileStoreLayout: %v", err) + } + for _, dir := range []string{cityDir, rigDir} { + if err := ensurePersistedScopeLocalFileStore(dir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(%s): %v", dir, err) + } + } + cityToml := `[workspace] +name = "demo" + +[[rigs]] +name = "frontend" +path = "frontend" +prefix = "FE" + +[[agent]] +name = "worker" +dir = "frontend" +sling_query = "true" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + t.Chdir(cityDir) + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"frontend/worker", "FE-ghost1"}, + false, false, true, + "", nil, "", + false, false, "", + true, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "forced dispatch skipped missing-bead validation") { + t.Fatalf("stderr = %q, want forced missing-bead auto-convoy warning", stderr.String()) + } +} + +func TestCmdSlingAcceptsExistingBead(t *testing.T) { + // When a bead-ID-shaped argument IS present in the store, the new + // existence check must not fire. This test only asserts the check + // does not trip — it doesn't assert sling completes successfully, + // since downstream routing has its own gates (cross-rig, etc.) + // that are out of scope for this change. + cityDir := setupCmdSlingBeadExistsFixture(t) + rigDir := filepath.Join(cityDir, "frontend") + + rigStore, err := openStoreAtForCity(rigDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(rig): %v", err) + } + seeded, err := rigStore.Create(beads.Bead{Title: "real work", Type: "task"}) + if err != nil { + t.Fatalf("seeding bead: %v", err) + } + + var stdout, stderr bytes.Buffer + _ = cmdSling( + []string{"frontend/worker", seeded.ID}, + false, false, false, // force=false; existence check should pass naturally + "", nil, "", + true, false, "", + false, false, false, + "", "", + &stdout, &stderr, + ) + if strings.Contains(stderr.String(), "not found in store") { + t.Errorf("existence check incorrectly tripped on a real bead; stderr: %s", stderr.String()) + } +} + +func TestCmdSlingMultiDashBeadIDRoutesExistingBead(t *testing.T) { + // gc sling target fo-spawn-storm must route the existing bead and must + // not create a new inline bead, when "fo-spawn-storm" exists in the store. + cityDir, rigDir := setupCmdSlingMultiDashBeadFixture(t, true) + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"foundations/worker", "fo-spawn-storm"}, + false, false, false, + "", nil, "", + true, false, "", + false, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Errorf("created new inline bead instead of routing existing one; stdout=%s stderr=%s", stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "found existing bead") { + t.Errorf("stderr = %q, want existing-bead routing breadcrumb", stderr.String()) + } + + rigStore, err := openStoreAtForCity(rigDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(rig): %v", err) + } + routed, err := rigStore.Get("fo-spawn-storm") + if err != nil { + t.Fatalf("rigStore.Get(fo-spawn-storm): %v", err) + } + if routed.Metadata["gc.routed_to"] != "foundations/worker" { + t.Fatalf("rig bead gc.routed_to = %q, want foundations/worker", routed.Metadata["gc.routed_to"]) + } + all, err := rigStore.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("rigStore.List: %v", err) + } + if len(all) != 1 { + t.Fatalf("rig store bead count = %d, want 1: %#v", len(all), all) + } +} + +func TestCmdSlingOneArgMultiDashExistingBeadUsesDefaultTarget(t *testing.T) { + cityDir, rigDir := setupCmdSlingMultiDashBeadFixture(t, true) + + var stdout, stderr bytes.Buffer + code := cmdSling( + []string{"fo-spawn-storm"}, + false, false, false, + "", nil, "", + true, false, "", + false, false, false, + "", "", + &stdout, &stderr, + ) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("stdout = %q, want existing bead route without inline creation", stdout.String()) + } + if !strings.Contains(stderr.String(), "found existing bead") { + t.Errorf("stderr = %q, want existing-bead routing breadcrumb", stderr.String()) + } + + rigStore, err := openStoreAtForCity(rigDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(rig): %v", err) + } + routed, err := rigStore.Get("fo-spawn-storm") + if err != nil { + t.Fatalf("rigStore.Get(fo-spawn-storm): %v", err) + } + if routed.Metadata["gc.routed_to"] != "foundations/worker" { + t.Fatalf("rig bead gc.routed_to = %q, want foundations/worker", routed.Metadata["gc.routed_to"]) + } + all, err := rigStore.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("rigStore.List: %v", err) + } + if len(all) != 1 { + t.Fatalf("rig store bead count = %d, want 1: %#v", len(all), all) + } +} + +func TestCmdSlingCrossRigMultiDashExistingBeadUsesPrefixStore(t *testing.T) { + cityDir, rigDir := setupCmdSlingMultiDashBeadFixture(t, false) + ordersDir := filepath.Join(cityDir, "orders") + if err := os.MkdirAll(ordersDir, 0o755); err != nil { + t.Fatalf("MkdirAll(orders): %v", err) + } + if err := ensurePersistedScopeLocalFileStore(ordersDir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(orders): %v", err) + } + cityToml := `[workspace] +name = "demo" + +[[rigs]] +name = "foundations" +path = "foundations" +prefix = "fo" + +[[rigs]] +name = "orders" +path = "orders" +prefix = "od" + +[[agent]] +name = "worker" +dir = "orders" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } var stdout, stderr bytes.Buffer - _ = cmdSling( - []string{"frontend/worker", "FE-ghost1"}, - false, false, true, // force=true + code := cmdSling( + []string{"orders/worker", "fo-spawn-storm"}, + false, false, true, "", nil, "", true, false, "", - false, false, false, + true, false, false, "", "", &stdout, &stderr, ) - got := stderr.String() - if strings.Contains(got, "not found in store") { - t.Errorf("--force did not bypass bead-existence check; stderr: %s", got) + if code != 0 { + t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("stdout = %q, want existing bead route without inline creation", stdout.String()) + } + if !strings.Contains(stderr.String(), "found existing bead") { + t.Errorf("stderr = %q, want existing-bead routing breadcrumb", stderr.String()) + } + + rigStore, err := openStoreAtForCity(rigDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(rig): %v", err) + } + routed, err := rigStore.Get("fo-spawn-storm") + if err != nil { + t.Fatalf("rigStore.Get(fo-spawn-storm): %v", err) + } + if routed.Metadata["gc.routed_to"] != "orders/worker" { + t.Fatalf("rig bead gc.routed_to = %q, want orders/worker", routed.Metadata["gc.routed_to"]) + } + all, err := rigStore.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("rigStore.List: %v", err) + } + if len(all) != 1 { + t.Fatalf("rig store bead count = %d, want 1: %#v", len(all), all) + } + + ordersStore, err := openStoreAtForCity(ordersDir, cityDir) + if err != nil { + t.Fatalf("openStoreAtForCity(orders): %v", err) + } + ordersBeads, err := ordersStore.List(beads.ListQuery{AllowScan: true}) + if err != nil { + t.Fatalf("ordersStore.List: %v", err) + } + if len(ordersBeads) != 0 { + t.Fatalf("orders store bead count = %d, want 0: %#v", len(ordersBeads), ordersBeads) } } -func TestCmdSlingForceMissingBeadPrintsAutoConvoyWarning(t *testing.T) { +func TestCmdSlingUnderscoredPrefixMultiDashExistingBeadUsesPrefixStore(t *testing.T) { configureIsolatedRuntimeEnv(t) t.Setenv("GC_BEADS", "file") cityDir := t.TempDir() - rigDir := filepath.Join(cityDir, "frontend") + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + rigDir := filepath.Join(cityDir, "live-docs") if err := os.MkdirAll(rigDir, 0o755); err != nil { t.Fatalf("MkdirAll(rig): %v", err) } @@ -1464,18 +2353,25 @@ func TestCmdSlingForceMissingBeadPrintsAutoConvoyWarning(t *testing.T) { t.Fatalf("ensurePersistedScopeLocalFileStore(%s): %v", dir, err) } } + const beadID = "live_docs-spawn-storm" + writeTestFileStoreBeads(t, rigDir, []beads.Bead{{ + ID: beadID, + Title: "spawn storm bead", + Type: "task", + Status: "open", + Metadata: map[string]string{}, + }}) cityToml := `[workspace] name = "demo" [[rigs]] -name = "frontend" -path = "frontend" -prefix = "FE" +name = "live_docs" +path = "live-docs" +prefix = "live_docs" [[agent]] name = "worker" -dir = "frontend" -sling_query = "true" +dir = "live_docs" ` if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { t.Fatalf("WriteFile(city.toml): %v", err) @@ -1484,53 +2380,88 @@ sling_query = "true" var stdout, stderr bytes.Buffer code := cmdSling( - []string{"frontend/worker", "FE-ghost1"}, - false, false, true, + []string{"live_docs/worker", beadID}, + false, false, false, "", nil, "", - false, false, "", - true, false, false, + true, false, "", + false, false, false, "", "", &stdout, &stderr, ) if code != 0 { t.Fatalf("cmdSling returned %d, want 0; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } - if !strings.Contains(stderr.String(), "forced dispatch skipped missing-bead validation") { - t.Fatalf("stderr = %q, want forced missing-bead auto-convoy warning", stderr.String()) + if strings.Contains(stdout.String(), "Created ") { + t.Fatalf("stdout = %q, want existing bead route without inline creation", stdout.String()) } -} - -func TestCmdSlingAcceptsExistingBead(t *testing.T) { - // When a bead-ID-shaped argument IS present in the store, the new - // existence check must not fire. This test only asserts the check - // does not trip — it doesn't assert sling completes successfully, - // since downstream routing has its own gates (cross-rig, etc.) - // that are out of scope for this change. - cityDir := setupCmdSlingBeadExistsFixture(t) - rigDir := filepath.Join(cityDir, "frontend") rigStore, err := openStoreAtForCity(rigDir, cityDir) if err != nil { t.Fatalf("openStoreAtForCity(rig): %v", err) } - seeded, err := rigStore.Create(beads.Bead{Title: "real work", Type: "task"}) + routed, err := rigStore.Get(beadID) if err != nil { - t.Fatalf("seeding bead: %v", err) + t.Fatalf("rigStore.Get(%s): %v", beadID, err) + } + if routed.Metadata["gc.routed_to"] != "live_docs/worker" { + t.Fatalf("rig bead gc.routed_to = %q, want live_docs/worker", routed.Metadata["gc.routed_to"]) } - var stdout, stderr bytes.Buffer - _ = cmdSling( - []string{"frontend/worker", seeded.ID}, - false, false, false, // force=false; existence check should pass naturally - "", nil, "", - true, false, "", - false, false, false, - "", "", - &stdout, &stderr, - ) - if strings.Contains(stderr.String(), "not found in store") { - t.Errorf("existence check incorrectly tripped on a real bead; stderr: %s", stderr.String()) + assertStoreHasNoBeadTitle(t, cityDir, cityDir, beadID) +} + +func setupCmdSlingMultiDashBeadFixture(t *testing.T, defaultTarget bool) (cityDir, rigDir string) { + t.Helper() + configureIsolatedRuntimeEnv(t) + t.Setenv("GC_BEADS", "file") + + cityDir = t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + rigDir = filepath.Join(cityDir, "foundations") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatalf("MkdirAll(rig): %v", err) + } + if err := ensureScopedFileStoreLayout(cityDir); err != nil { + t.Fatalf("ensureScopedFileStoreLayout: %v", err) + } + for _, dir := range []string{cityDir, rigDir} { + if err := ensurePersistedScopeLocalFileStore(dir); err != nil { + t.Fatalf("ensurePersistedScopeLocalFileStore(%s): %v", dir, err) + } + } + writeTestFileStoreBeads(t, rigDir, []beads.Bead{{ + ID: "fo-spawn-storm", + Title: "spawn storm bead", + Type: "task", + Status: "open", + Metadata: map[string]string{}, + }}) + defaultTargetLine := "" + if defaultTarget { + defaultTargetLine = "default_sling_target = \"foundations/worker\"\n" + } + cityToml := `[workspace] +name = "demo" + +[[rigs]] +name = "foundations" +path = "foundations" +prefix = "fo" +` + defaultTargetLine + ` + +[[agent]] +name = "worker" +dir = "foundations" +` + if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) } + t.Chdir(cityDir) + return cityDir, rigDir } func TestCmdSlingRefusesMissingConfiguredFallbackBeadID(t *testing.T) { @@ -1538,6 +2469,11 @@ func TestCmdSlingRefusesMissingConfiguredFallbackBeadID(t *testing.T) { t.Setenv("GC_BEADS", "file") cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") rigDir := filepath.Join(cityDir, "orders") if err := os.MkdirAll(rigDir, 0o755); err != nil { t.Fatalf("MkdirAll(rig): %v", err) @@ -2376,6 +3312,113 @@ func TestOnFormulaAttachesAndRoutes(t *testing.T) { } } +func TestOnRootOnlyFormulaKeepsAttachedWispPrivate(t *testing.T) { + runner := newFakeRunner() + sp := runtime.NewFake() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "root-only.formula.toml"), []byte(` +formula = "root-only" +description = "Private attached root" +version = 1 +`), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + FormulaLayers: config.FormulaLayers{City: []string{dir}}, + } + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + deps.Store = beads.NewMemStoreFrom(1, []beads.Bead{ + {ID: "BL-42", Title: "Work", Type: "task", Status: "open"}, + }, nil) + opts := testOpts(a, "BL-42") + opts.OnFormula = "root-only" + code := doSling(opts, deps, deps.Store, stdout, stderr) + + if code != 0 { + t.Fatalf("doSling returned %d, want 0; stderr: %s", code, stderr.String()) + } + source, err := deps.Store.Get("BL-42") + if err != nil { + t.Fatalf("store.Get(BL-42): %v", err) + } + if source.Metadata["gc.routed_to"] != "mayor" { + t.Errorf("source gc.routed_to = %q, want mayor", source.Metadata["gc.routed_to"]) + } + rootID := source.Metadata["molecule_id"] + if rootID == "" { + t.Fatal("source bead missing molecule_id") + } + root, err := deps.Store.Get(rootID) + if err != nil { + t.Fatalf("store.Get(%s): %v", rootID, err) + } + if root.Type != "molecule" { + t.Fatalf("attached root type = %q, want molecule", root.Type) + } + if root.Metadata["gc.kind"] == "wisp" { + t.Fatalf("attached root leaked gc.kind=wisp metadata: %+v", root.Metadata) + } + ready, err := deps.Store.Ready() + if err != nil { + t.Fatalf("Ready: %v", err) + } + for _, bead := range ready { + if bead.ID == rootID { + t.Fatalf("attached wisp root %s appeared in Ready(): %+v", rootID, ready) + } + } +} + +func TestFormulaRootOnlyRoutesRunnableWispRoot(t *testing.T) { + runner := newFakeRunner() + sp := runtime.NewFake() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "root-only.formula.toml"), []byte(` +formula = "root-only" +description = "Standalone root" +version = 1 +`), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + FormulaLayers: config.FormulaLayers{City: []string{dir}}, + } + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "root-only") + opts.IsFormula = true + code := doSling(opts, deps, deps.Store, stdout, stderr) + + if code != 0 { + t.Fatalf("doSling returned %d, want 0; stderr: %s", code, stderr.String()) + } + root, err := deps.Store.Get("gc-1") + if err != nil { + t.Fatalf("store.Get(gc-1): %v", err) + } + if root.Type != "task" { + t.Fatalf("root type = %q, want task", root.Type) + } + if root.Metadata["gc.kind"] != "wisp" { + t.Fatalf("root gc.kind = %q, want wisp", root.Metadata["gc.kind"]) + } + if root.Metadata["gc.routed_to"] != "mayor" { + t.Fatalf("root gc.routed_to = %q, want mayor", root.Metadata["gc.routed_to"]) + } + ready, err := deps.Store.Ready() + if err != nil { + t.Fatalf("Ready: %v", err) + } + if len(ready) != 1 || ready[0].ID != root.ID { + t.Fatalf("Ready() = %+v, want only routed root %s", ready, root.ID) + } +} + func TestOnFormulaCopiesSourcePriorityToCreatedBeads(t *testing.T) { runner := newFakeRunner() sp := runtime.NewFake() @@ -2548,8 +3591,8 @@ title = "Do work" if bead.Assignee != config.ControlDispatcherAgentName { t.Fatalf("workflow-finalize assignee = %q, want %q", bead.Assignee, config.ControlDispatcherAgentName) } - if bead.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("workflow-finalize gc.routed_to = %q, want %q", bead.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if got := bead.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("workflow-finalize gc.routed_to = %q, want empty direct dispatcher assignee", got) } if bead.Metadata[graphExecutionRouteMetaKey] != "mayor" { t.Fatalf("workflow-finalize execution route = %q, want mayor", bead.Metadata[graphExecutionRouteMetaKey]) @@ -2871,6 +3914,33 @@ func TestResolveSlingStoreRootUsesPrefixRigForConfiguredAllAlphaBeadID(t *testin } } +func TestResolveSlingStoreRootHonorsHyphenatedRigPrefix(t *testing.T) { + // A rig whose configured prefix itself contains a hyphen must + // receive its own beads — the longest configured prefix wins + // over a shorter prefix that also matches the bead-ID head. + cityPath := filepath.Join(t.TempDir(), "city") + cfg := &config.City{ + Rigs: []config.Rig{ + {Name: "agent", Path: filepath.Join("rigs", "agent"), Prefix: "agent"}, + {Name: "agent-diagnostics", Path: filepath.Join("rigs", "agent-diag"), Prefix: "agent-diagnostics"}, + }, + } + + got := resolveSlingStoreRoot(cfg, cityPath, "agent-diagnostics-hnn", config.Agent{Dir: "agent"}) + want := filepath.Join(cityPath, "rigs", "agent-diag") + if got != want { + t.Fatalf("resolveSlingStoreRoot(agent-diagnostics-hnn) = %q, want %q (longest configured prefix should win)", got, want) + } + + // Sanity check: a bead under the shorter "agent" prefix still resolves + // to that rig. + got = resolveSlingStoreRoot(cfg, cityPath, "agent-x1", config.Agent{Dir: "agent-diagnostics"}) + want = filepath.Join(cityPath, "rigs", "agent") + if got != want { + t.Fatalf("resolveSlingStoreRoot(agent-x1) = %q, want %q", got, want) + } +} + func TestResolveSlingStoreRootUsesCityRootForHQPrefix(t *testing.T) { cityPath := filepath.Join(t.TempDir(), "city") cfg := &config.City{ @@ -5658,6 +6728,68 @@ func TestBuildSlingFormulaVarsPreservesExplicitValues(t *testing.T) { } } +func TestBuildSlingFormulaVarsSeedsRoutingNamespace(t *testing.T) { + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + deps, _, _ := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + + vars := buildSlingFormulaVars("mol-polecat-work", "HW-42", nil, config.Agent{ + Name: "polecat", + Dir: "hw", + BindingName: "gastown", + }, deps) + + if got, ok := findVarValue(vars, "rig_name"); !ok || got != "hw" { + t.Fatalf("rig_name var = %q, %v; want hw, true", got, ok) + } + if got, ok := findVarValue(vars, "binding_name"); !ok || got != "gastown" { + t.Fatalf("binding_name var = %q, %v; want gastown, true", got, ok) + } + if got, ok := findVarValue(vars, "binding_prefix"); !ok || got != "gastown." { + t.Fatalf("binding_prefix var = %q, %v; want gastown., true", got, ok) + } +} + +func TestBuildSlingFormulaVarsPreservesExplicitRoutingNamespace(t *testing.T) { + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + deps, _, _ := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + + vars := buildSlingFormulaVars("mol-polecat-work", "HW-42", []string{ + "rig_name=override-rig", + "binding_name=override-binding", + "binding_prefix=override.", + }, config.Agent{ + Name: "polecat", + Dir: "hw", + BindingName: "gastown", + }, deps) + + if got, ok := findVarValue(vars, "rig_name"); !ok || got != "override-rig" { + t.Fatalf("rig_name var = %q, %v; want override-rig, true", got, ok) + } + if got, ok := findVarValue(vars, "binding_name"); !ok || got != "override-binding" { + t.Fatalf("binding_name var = %q, %v; want override-binding, true", got, ok) + } + if got, ok := findVarValue(vars, "binding_prefix"); !ok || got != "override." { + t.Fatalf("binding_prefix var = %q, %v; want override., true", got, ok) + } +} + +func TestBuildSlingFormulaVarsSeedsEmptyRoutingNamespaceForUnboundAgent(t *testing.T) { + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + deps, _, _ := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + + vars := buildSlingFormulaVars("mol-deacon-patrol", "CITY-42", nil, config.Agent{ + Name: "deacon", + }, deps) + + for _, key := range []string{"rig_name", "binding_name", "binding_prefix"} { + got, ok := findVarValue(vars, key) + if !ok || got != "" { + t.Fatalf("%s var = %q, %v; want empty string, true", key, got, ok) + } + } +} + func TestBeadMetadataTargetStopsOnParentCycle(t *testing.T) { store := &recordingStore{ Store: beads.NewMemStore(), diff --git a/cmd/gc/cmd_start.go b/cmd/gc/cmd_start.go index 189f13fea8..40cb1f80d8 100644 --- a/cmd/gc/cmd_start.go +++ b/cmd/gc/cmd_start.go @@ -579,32 +579,59 @@ func doStartStandalone(args []string, controllerMode bool, stdout, stderr io.Wri // Beads won't be persisted, but the reconciler still manages lifecycle. oneShotStore = beads.NewMemStore() } + rigStores := buildStandaloneRigStores(cfg, cityPath, stderr) // One-shot bead reconciliation: same code path as the daemon. + sessionQueryPartial := false sessionBeads, err := loadSessionBeadSnapshot(oneShotStore) if err != nil { fmt.Fprintf(stderr, "gc start: loading session beads: %v\n", err) //nolint:errcheck sessionBeads = nil + sessionQueryPartial = true } - dsResult := buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, oneShotStore, nil, sessionBeads, nil, stderr) + dsResult := buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, oneShotStore, rigStores, sessionBeads, nil, stderr) + dsResult.SessionQueryPartial = dsResult.SessionQueryPartial || sessionQueryPartial ds := dsResult.State cfgNames := configuredSessionNamesWithSnapshot(cfg, cityName, sessionBeads) - _, sessionBeads = syncSessionBeadsWithSnapshot( - cityPath, oneShotStore, ds, sp, cfgNames, cfg, clock.Real{}, stderr, true, sessionBeads, + _, sessionBeads = syncSessionBeadsWithSnapshotAndRigStores( + cityPath, oneShotStore, rigStores, ds, sp, cfgNames, cfg, clock.Real{}, stderr, true, sessionBeads, ) open := sessionBeads.Open() + if released := releaseOrphanedPoolAssignmentsWhenSnapshotsComplete(oneShotStore, cfg, cityPath, open, dsResult, rigStores); len(released) > 0 { + for _, r := range released { + fmt.Fprintf(stderr, "released orphaned pool work: %s\n", r.ID) //nolint:errcheck + } + // Standalone start has no follow-up patrol tick, so after reopening + // orphaned pool work we must immediately rebuild demand and sync once + // more so replacement session beads can be materialized in this run. + dsResult = buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, oneShotStore, rigStores, sessionBeads, nil, stderr) + ds = dsResult.State + cfgNames = configuredSessionNamesWithSnapshot(cfg, cityName, sessionBeads) + _, sessionBeads = syncSessionBeadsWithSnapshotAndRigStores( + cityPath, oneShotStore, rigStores, ds, sp, cfgNames, cfg, clock.Real{}, stderr, true, sessionBeads, + ) + open = sessionBeads.Open() + } + dt := newDrainTracker() - poolDesired := PoolDesiredCounts(ComputePoolDesiredStates( - cfg, nil, sessionBeads.Open(), dsResult.ScaleCheckCounts)) + poolWorkBeads := filterAssignedWorkBeadsForPoolDemand(cfg, cityPath, open, dsResult.AssignedWorkBeads, dsResult.AssignedWorkStoreRefs) + poolDesired := retainScaleCheckPartialPoolDesired( + PoolDesiredCounts(ComputePoolDesiredStates( + cfg, poolWorkBeads, open, dsResult.ScaleCheckCounts)), + sessionBeads, + dsResult.PoolScaleCheckPartialTemplates, + ) if poolDesired == nil { poolDesired = make(map[string]int) } mergeNamedSessionDemand(poolDesired, dsResult.NamedSessionDemand, cfg) - reconcileSessionBeadsAtPath( + awakeAssignedWorkBeads := filterAssignedWorkBeadsForSessionWake(cfg, cityPath, open, dsResult.AssignedWorkBeads, dsResult.AssignedWorkStoreRefs) + reconcileSessionBeadsAtPathWithNamedDemand( sigCtx, cityPath, open, ds, cfgNames, cfg, sp, oneShotStore, - nil, nil, nil, nil, dt, poolDesired, - dsResult.StoreQueryPartial, + nil, awakeAssignedWorkBeads, rigStores, nil, dt, poolDesired, + dsResult.NamedSessionDemand, + dsResult.snapshotQueryPartial(), nil, cityName, nil, clock.Real{}, recorder, cfg.Session.StartupTimeoutDuration(), 0, stdout, stderr, @@ -616,10 +643,12 @@ func doStartStandalone(args []string, controllerMode bool, stdout, stderr io.Wri fmt.Fprintf(stderr, "gc start: loading session beads: %v\n", err) //nolint:errcheck sessionBeads = nil } - dsResult = buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, oneShotStore, nil, sessionBeads, nil, stderr) + dsResult = buildDesiredStateWithSessionBeads(cityName, cityPath, beaconTime, cfg, sp, oneShotStore, rigStores, sessionBeads, nil, stderr) ds = dsResult.State cfgNames = configuredSessionNamesWithSnapshot(cfg, cityName, sessionBeads) - syncSessionBeadsWithSnapshot(cityPath, oneShotStore, ds, sp, cfgNames, cfg, clock.Real{}, stderr, false, sessionBeads) + syncSessionBeadsWithSnapshotAndRigStores( + cityPath, oneShotStore, rigStores, ds, sp, cfgNames, cfg, clock.Real{}, stderr, false, sessionBeads, + ) fmt.Fprintln(stdout, "City started.") //nolint:errcheck // best-effort stdout return 0 diff --git a/cmd/gc/cmd_start_test.go b/cmd/gc/cmd_start_test.go index 10a049f53e..b66a33cb73 100644 --- a/cmd/gc/cmd_start_test.go +++ b/cmd/gc/cmd_start_test.go @@ -123,6 +123,95 @@ func TestStandaloneBuildAgentsFnWithSessionBeads_UsesRigStoresForAssignedWork(t } } +func TestReleaseOrphanedPoolAssignmentsWhenSnapshotsComplete_PartialSkipsCompleteReleases(t *testing.T) { + store := beads.NewMemStore() + work, err := store.Create(beads.Bead{ + ID: "ga-live", + Title: "live assigned work from partial snapshot", + Type: "task", + Assignee: "worker-session", + Metadata: map[string]string{ + "gc.routed_to": "worker", + }, + }) + if err != nil { + t.Fatalf("Create work bead: %v", err) + } + inProgress := "in_progress" + if err := store.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("mark work in_progress: %v", err) + } + work.Status = inProgress + + released := releaseOrphanedPoolAssignmentsWhenSnapshotsComplete( + store, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, + "", + nil, + DesiredStateResult{ + AssignedWorkBeads: []beads.Bead{work}, + AssignedWorkStores: []beads.Store{store}, + StoreQueryPartial: true, + }, + nil, + ) + if len(released) != 0 { + t.Fatalf("released %d work bead(s) from a partial snapshot, want none", len(released)) + } + got, err := store.Get(work.ID) + if err != nil { + t.Fatalf("Get work after partial one-shot release: %v", err) + } + if got.Status != "in_progress" || got.Assignee != "worker-session" { + t.Fatalf("partial one-shot snapshot released work: status=%q assignee=%q", got.Status, got.Assignee) + } + + released = releaseOrphanedPoolAssignmentsWhenSnapshotsComplete( + store, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, + "", + nil, + DesiredStateResult{ + AssignedWorkBeads: []beads.Bead{work}, + AssignedWorkStores: []beads.Store{store}, + SessionQueryPartial: true, + }, + nil, + ) + if len(released) != 0 { + t.Fatalf("released %d work bead(s) from a partial session snapshot, want none", len(released)) + } + got, err = store.Get(work.ID) + if err != nil { + t.Fatalf("Get work after partial session snapshot release: %v", err) + } + if got.Status != "in_progress" || got.Assignee != "worker-session" { + t.Fatalf("partial session snapshot released work: status=%q assignee=%q", got.Status, got.Assignee) + } + + released = releaseOrphanedPoolAssignmentsWhenSnapshotsComplete( + store, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}}}, + "", + nil, + DesiredStateResult{ + AssignedWorkBeads: []beads.Bead{work}, + AssignedWorkStores: []beads.Store{store}, + }, + nil, + ) + if len(released) != 1 { + t.Fatalf("complete one-shot snapshot released %d work bead(s), want 1", len(released)) + } + got, err = store.Get(work.ID) + if err != nil { + t.Fatalf("Get work after complete one-shot release: %v", err) + } + if got.Status != "open" || got.Assignee != "" { + t.Fatalf("complete one-shot snapshot did not release orphaned work: status=%q assignee=%q", got.Status, got.Assignee) + } +} + func TestMergeEnvOverrideOrder(t *testing.T) { a := map[string]string{"KEY": "first", "A": "a"} b := map[string]string{"KEY": "second", "B": "b"} diff --git a/cmd/gc/cmd_status.go b/cmd/gc/cmd_status.go index f44a92fb59..b1b9bd9e39 100644 --- a/cmd/gc/cmd_status.go +++ b/cmd/gc/cmd_status.go @@ -26,6 +26,7 @@ func newRigStatusCmd(stdout, stderr io.Writer) *cobra.Command { } return nil }, + ValidArgsFunction: completeRigNames, } } @@ -75,27 +76,30 @@ func cmdRigStatus(args []string, stdout, stderr io.Writer) int { } cityName := loadedCityName(cfg, cityPath) - sp := newSessionProvider() + var store beads.Store + if cityPath != "" { + if opened, err := openCityStoreAt(cityPath); err == nil { + store = opened + } + } + statusSnapshot := loadStatusSessionSnapshot(store) + sp := newStatusSessionProviderForCityWithSnapshot(cfg, cityPath, statusSnapshot) dops := newDrainOps(sp) - return doRigStatus(sp, dops, rig, rigAgents, cityPath, cityName, cfg.Workspace.SessionTemplate, stdout, stderr) + return doRigStatusWithStoreAndSnapshot(sp, dops, rig, rigAgents, cityPath, cityName, cfg.Workspace.SessionTemplate, cfg, store, statusSnapshot, stdout, stderr) } -// doRigStatus prints rig info and per-agent running state. -func doRigStatus( +func doRigStatusWithStoreAndSnapshot( sp runtime.Provider, dops drainOps, rig config.Rig, agents []config.Agent, cityPath, cityName, sessionTemplate string, + cfg *config.City, + store beads.Store, + statusSnapshot *sessionBeadSnapshot, stdout, stderr io.Writer, ) int { - _ = stderr // reserved for future error reporting - var store beads.Store - if cityPath != "" { - if opened, err := openCityStoreAt(cityPath); err == nil { - store = opened - } - } + registerStatusProviderACPRoutes(sp, statusSnapshot, cityName, cfg) suspStr := "no" if rig.Suspended { @@ -110,15 +114,15 @@ func doRigStatus( for _, a := range agents { sp0 := scaleParamsFor(&a) if !a.SupportsInstanceExpansion() { - sn := cliSessionName(cityPath, cityName, a.QualifiedName(), sessionTemplate) - obs := observeSessionTargetWithWarning("gc rig status", cityPath, store, sp, nil, sn, stderr) - status := agentStatusLine(obs.Running, dops, sn, a.Suspended || obs.Suspended) + target := statusObservationTargetForIdentity(statusSnapshot, cityName, a.QualifiedName(), sessionTemplate) + obs := observeSessionTargetWithWarning("gc rig status", cityPath, store, sp, cfg, target, stderr) + status := agentStatusLine(obs.Running, dops, target.runtimeSessionName, a.Suspended || obs.Suspended) fmt.Fprintf(stdout, " %-12s%s\n", a.QualifiedName(), status) //nolint:errcheck // best-effort stdout } else { for _, qualifiedInstance := range discoverPoolInstances(a.Name, a.Dir, sp0, &a, cityName, sessionTemplate, sp) { - sn := cliSessionName(cityPath, cityName, qualifiedInstance, sessionTemplate) - obs := observeSessionTargetWithWarning("gc rig status", cityPath, store, sp, nil, sn, stderr) - status := agentStatusLine(obs.Running, dops, sn, a.Suspended || obs.Suspended) + target := statusObservationTargetForIdentity(statusSnapshot, cityName, qualifiedInstance, sessionTemplate) + obs := observeSessionTargetWithWarning("gc rig status", cityPath, store, sp, cfg, target, stderr) + status := agentStatusLine(obs.Running, dops, target.runtimeSessionName, a.Suspended || obs.Suspended) fmt.Fprintf(stdout, " %-12s%s\n", qualifiedInstance, status) //nolint:errcheck // best-effort stdout } } diff --git a/cmd/gc/cmd_status_test.go b/cmd/gc/cmd_status_test.go index 698bcc1570..bc95c4951b 100644 --- a/cmd/gc/cmd_status_test.go +++ b/cmd/gc/cmd_status_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "io" "strings" "testing" @@ -17,6 +18,24 @@ import ( // doRigStatus tests // --------------------------------------------------------------------------- +func runDoRigStatus( + sp runtime.Provider, + dops drainOps, + rig config.Rig, + agents []config.Agent, + cityPath string, + stdout, stderr io.Writer, +) int { + var store beads.Store + if cityPath != "" { + if opened, err := openCityStoreAt(cityPath); err == nil { + store = opened + } + } + statusSnapshot := loadStatusSessionSnapshot(store) + return doRigStatusWithStoreAndSnapshot(sp, dops, rig, agents, cityPath, "city", "", nil, store, statusSnapshot, stdout, stderr) +} + func TestDoRigStatus(t *testing.T) { sp := runtime.NewFake() if err := sp.Start(context.Background(), "frontend--polecat", runtime.Config{Command: "echo"}); err != nil { @@ -32,7 +51,7 @@ func TestDoRigStatus(t *testing.T) { } var stdout, stderr bytes.Buffer - code := doRigStatus(sp, dops, rig, agents, "", "city", "", &stdout, &stderr) + code := runDoRigStatus(sp, dops, rig, agents, "", &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) } @@ -67,7 +86,7 @@ func TestDoRigStatusSuspendedRig(t *testing.T) { } var stdout, stderr bytes.Buffer - code := doRigStatus(sp, dops, rig, agents, "", "city", "", &stdout, &stderr) + code := runDoRigStatus(sp, dops, rig, agents, "", &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, want 0", code) } @@ -91,7 +110,7 @@ func TestDoRigStatusWithDraining(t *testing.T) { } var stdout, stderr bytes.Buffer - code := doRigStatus(sp, dops, rig, agents, "", "city", "", &stdout, &stderr) + code := runDoRigStatus(sp, dops, rig, agents, "", &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, want 0", code) } @@ -113,7 +132,7 @@ func TestDoRigStatusSuspendedAgent(t *testing.T) { } var stdout, stderr bytes.Buffer - code := doRigStatus(sp, dops, rig, agents, "", "city", "", &stdout, &stderr) + code := runDoRigStatus(sp, dops, rig, agents, "", &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, want 0", code) } @@ -140,7 +159,7 @@ func TestDoRigStatusReportsObservationErrors(t *testing.T) { } var stdout, stderr bytes.Buffer - code := doRigStatus(sp, dops, rig, agents, "/tmp/city", "city", "", &stdout, &stderr) + code := runDoRigStatus(sp, dops, rig, agents, "/tmp/city", &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, want 0; stderr: %s", code, stderr.String()) } diff --git a/cmd/gc/cmd_stop.go b/cmd/gc/cmd_stop.go index e4d87bd96f..982c2e3e04 100644 --- a/cmd/gc/cmd_stop.go +++ b/cmd/gc/cmd_stop.go @@ -98,7 +98,7 @@ func cmdStop(args []string, stdout, stderr io.Writer) int { desired[sn] = true } else { // Pool agent: resolve runtime session names from beads first, then legacy discovery. - for _, ref := range resolvePoolSessionRefs(store, a.Name, a.Dir, sp0, &a, cityName, st, sp, stderr) { + for _, ref := range resolvePoolSessionRefs(store, cfg, a.Name, a.Dir, sp0, &a, cityName, st, sp, stderr) { sessionNames = append(sessionNames, ref.sessionName) desired[ref.sessionName] = true } @@ -239,10 +239,35 @@ func waitForStandaloneControllerStop(cityPath string, timeout time.Duration) err func doStop(sessionNames []string, sp runtime.Provider, cfg *config.City, store beads.Store, timeout time.Duration, rec events.Recorder, stdout, stderr io.Writer, ) int { + visible := map[string]bool{} + if sp != nil { + names, err := sp.ListRunning("") + partialList := runtime.IsPartialListError(err) + if err != nil && !partialList { + fmt.Fprintf(stderr, "gc stop: listing sessions: %v\n", err) //nolint:errcheck // best-effort stderr + names = nil + } + if partialList { + fmt.Fprintf(stderr, "gc stop: listing sessions partially failed: %v\n", err) //nolint:errcheck // best-effort stderr + } + for _, name := range names { + if name = strings.TrimSpace(name); name != "" { + visible[name] = true + } + } + } var running []string for _, sn := range sessionNames { + sn = strings.TrimSpace(sn) + if sn == "" { + continue + } if alive, err := workerSessionTargetRunningWithConfig("", store, sp, cfg, sn); err == nil && alive { running = append(running, sn) + continue + } + if visible[sn] { + running = append(running, sn) } } gracefulStopAll(running, sp, timeout, rec, cfg, store, stdout, stderr) diff --git a/cmd/gc/cmd_supervisor.go b/cmd/gc/cmd_supervisor.go index b9d762eb4c..7fe23beb01 100644 --- a/cmd/gc/cmd_supervisor.go +++ b/cmd/gc/cmd_supervisor.go @@ -3,7 +3,6 @@ package main import ( "bufio" "context" - "encoding/json" "errors" "fmt" "io" @@ -183,6 +182,97 @@ type reconcileRequest struct { done chan struct{} } +type supervisorShutdownMode int32 + +const ( + supervisorShutdownNone supervisorShutdownMode = iota + supervisorShutdownPreserveSessions + supervisorShutdownDestructive +) + +const supervisorPreserveSessionsOnSignalEnv = "GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL" + +var supervisorShutdownSettleDelay = 50 * time.Millisecond + +var supervisorSignalNotify = signal.Notify + +func supervisorPreserveSessionsOnSignal() bool { + return os.Getenv(supervisorPreserveSessionsOnSignalEnv) == "1" +} + +func supervisorShutdownModeForSignal(sig os.Signal) supervisorShutdownMode { + if sig == syscall.SIGTERM && supervisorPreserveSessionsOnSignal() { + return supervisorShutdownPreserveSessions + } + return supervisorShutdownDestructive +} + +type supervisorShutdownController struct { + mode atomic.Int32 + destructiveRequested atomic.Bool + destructiveOnce sync.Once + destructiveCh chan struct{} +} + +func newSupervisorShutdownController() *supervisorShutdownController { + return &supervisorShutdownController{destructiveCh: make(chan struct{})} +} + +func supervisorSignalLoop(sigCh <-chan os.Signal, done <-chan struct{}, requestShutdown func(supervisorShutdownMode), requestReconcile func()) { + for { + select { + case sig := <-sigCh: + if sig == nil { + continue + } + if sig == syscall.SIGHUP { + requestReconcile() + continue + } + requestShutdown(supervisorShutdownModeForSignal(sig)) + case <-done: + return + } + } +} + +func (c *supervisorShutdownController) request(mode supervisorShutdownMode) { + if mode == supervisorShutdownDestructive { + c.destructiveRequested.Store(true) + c.mode.Store(int32(supervisorShutdownDestructive)) + c.destructiveOnce.Do(func() { + if c.destructiveCh != nil { + close(c.destructiveCh) + } + }) + return + } + if mode == supervisorShutdownPreserveSessions { + c.mode.CompareAndSwap(int32(supervisorShutdownNone), int32(supervisorShutdownPreserveSessions)) + } +} + +func (c *supervisorShutdownController) preservesSessions() bool { + if c.destructiveRequested.Load() { + return false + } + return supervisorShutdownMode(c.mode.Load()) == supervisorShutdownPreserveSessions +} + +func (c *supervisorShutdownController) preservesSessionsAfterSettle(timeout time.Duration) bool { + if !c.preservesSessions() || timeout <= 0 { + return c.preservesSessions() + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.destructiveCh: + return false + case <-timer.C: + return c.preservesSessions() + } +} + var ( supervisorReloadQueueTimeout = 5 * time.Second supervisorReloadWaitTimeout = 5 * time.Minute @@ -211,7 +301,7 @@ func (s *shutdownState) finish(err error) { close(s.done) } -func startSupervisorSocket(sockPath string, cancelFn context.CancelFunc, reconcileCh chan reconcileRequest, shut *shutdownState) (net.Listener, error) { +func startSupervisorSocket(sockPath string, requestShutdown func(supervisorShutdownMode), reconcileCh chan reconcileRequest, shut *shutdownState) (net.Listener, error) { os.Remove(sockPath) //nolint:errcheck // remove stale socket from previous crash lis, err := net.Listen("unix", sockPath) if err != nil { @@ -229,7 +319,7 @@ func startSupervisorSocket(sockPath string, cancelFn context.CancelFunc, reconci fmt.Fprintf(os.Stderr, "gc supervisor: socket accept: %v\n", err) //nolint:errcheck continue } - go handleSupervisorConn(conn, cancelFn, reconcileCh, shut) + go handleSupervisorConn(conn, requestShutdown, reconcileCh, shut) } }() return lis, nil @@ -243,14 +333,14 @@ func startSupervisorSocket(sockPath string, cancelFn context.CancelFunc, reconci // then — if the client keeps the connection open — blocks until shutdown // completes and sends a second line "done:ok\n" or "done:err:\n" // so --wait clients can distinguish clean shutdown from partial failure. -func handleSupervisorConn(conn net.Conn, cancelFn context.CancelFunc, reconcileCh chan reconcileRequest, shut *shutdownState) { +func handleSupervisorConn(conn net.Conn, requestShutdown func(supervisorShutdownMode), reconcileCh chan reconcileRequest, shut *shutdownState) { defer conn.Close() //nolint:errcheck conn.SetReadDeadline(time.Now().Add(60 * time.Second)) //nolint:errcheck scanner := bufio.NewScanner(conn) if scanner.Scan() { switch scanner.Text() { case "stop": - cancelFn() + requestShutdown(supervisorShutdownDestructive) if _, err := conn.Write([]byte("ok\n")); err != nil { return } @@ -370,13 +460,10 @@ func stopSupervisor(stdout, stderr io.Writer) int { // it stops answering. This is the shape tests and shell scripts want: on // return, the supervisor has fully shut down and any failure is visible. // -// It also unloads the platform service (without removing the unit file) so -// launchd/systemd doesn't immediately restart the supervisor. +// It also unloads the platform service (without removing the unit file) after +// the supervisor acknowledges the destructive socket stop, so launchd/systemd +// will not restart it when the process exits. func stopSupervisorWithWait(stdout, stderr io.Writer, wait bool, waitTimeout time.Duration) int { - // Unload the platform service first so the service manager doesn't - // restart the supervisor after we send the stop command. - unloadSupervisorService() - sockPath, _ := runningSupervisorSocket() if sockPath == "" { fmt.Fprintln(stderr, "gc supervisor stop: supervisor is not running") //nolint:errcheck @@ -397,6 +484,7 @@ func stopSupervisorWithWait(stdout, stderr io.Writer, wait bool, waitTimeout tim return 1 } fmt.Fprintln(stdout, "Supervisor stopping...") //nolint:errcheck + unloadSupervisorService() if !wait { return 0 } @@ -629,6 +717,47 @@ func stopManagedCity(mc *managedCity, cityPath string, stderr io.Writer) error { return stopErr } +func stopManagedCityPreservingSessions(mc *managedCity, _ string, stderr io.Writer) error { + if mc == nil { + return nil + } + if mc.cr != nil { + mc.cr.preserveSessionsOnShutdown() + } + mc.cancel() + timeout := managedCityStopTimeout(mc) + var stopErr error + waitForRuntimeShutdown := timeout <= 0 + if timeout > 0 { + select { + case <-mc.done: + case <-time.After(timeout): + fmt.Fprintf(stderr, "gc supervisor: city '%s' did not exit within %s after preserve-mode cancel\n", mc.name, timeout) //nolint:errcheck + stopErr = fmt.Errorf("city %q did not exit within %s after preserve-mode cancel", mc.name, timeout) + waitForRuntimeShutdown = true + } + } + if waitForRuntimeShutdown && mc.cr != nil { + func() { + defer func() { recover() }() //nolint:errcheck + mc.cr.shutdown() + }() + if timeout > 0 { + select { + case <-mc.done: + stopErr = nil + case <-time.After(timeout): + fmt.Fprintf(stderr, "gc supervisor: city '%s' did not exit within %s after preserve-mode shutdown wait\n", mc.name, timeout) //nolint:errcheck + stopErr = fmt.Errorf("city %q did not exit within %s after preserve-mode shutdown wait", mc.name, timeout) + } + } + } + if mc.closer != nil { + mc.closer.Close() //nolint:errcheck + } + return stopErr +} + // runSupervisor is the main supervisor loop. It acquires the lock, // starts a control socket, reads the registry, starts CityRuntimes, // and runs until canceled. @@ -647,35 +776,29 @@ func runSupervisor(stdout, stderr io.Writer) int { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + shutdownCtl := newSupervisorShutdownController() + requestShutdown := func(mode supervisorShutdownMode) { + shutdownCtl.request(mode) + cancel() + } // Reconcile channel — triggers immediate reconciliation from SIGHUP // or the "reload" socket command. reconcileCh := make(chan reconcileRequest, 1) // Signal handler: SIGINT/SIGTERM → shutdown, SIGHUP → immediate reconcile. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + sigCh := make(chan os.Signal, 2) + supervisorSignalNotify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(sigCh) - go func() { - for { - select { - case sig := <-sigCh: - if sig == syscall.SIGHUP { - fmt.Fprintln(stderr, "SIGHUP received, triggering reconciliation...") //nolint:errcheck - select { - case reconcileCh <- reconcileRequest{}: - default: // reconcile already pending - } - continue - } - // SIGINT/SIGTERM → shutdown. - cancel() - return - case <-ctx.Done(): - return - } + shutdownSignalsDone := make(chan struct{}) + defer close(shutdownSignalsDone) + go supervisorSignalLoop(sigCh, shutdownSignalsDone, requestShutdown, func() { + fmt.Fprintln(stderr, "SIGHUP received, triggering reconciliation...") //nolint:errcheck + select { + case reconcileCh <- reconcileRequest{}: + default: // reconcile already pending } - }() + }) // Load supervisor config. supCfg, err := supervisor.LoadConfig(supervisor.ConfigPath()) @@ -685,10 +808,19 @@ func runSupervisor(stdout, stderr io.Writer) int { } reg := supervisor.NewRegistry(supervisor.RegistryPath()) + if err := cleanupSupervisorWorkspaceServicesForSupervisorStart(supervisor.DefaultHome()); err != nil { + fmt.Fprintf(stderr, "gc supervisor: workspace-service startup cleanup: %v\n", err) //nolint:errcheck + return 1 + } // Track managed cities via atomic-snapshot registry. API reads are // lock-free (atomic pointer load); mutations go through citiesMu. registry := newCityRegistry() + supEvPath := filepath.Join(supervisor.RuntimeDir(), "events.jsonl") + if supFR, supErr := events.NewFileRecorder(supEvPath, stderr); supErr == nil { + registry.SetSupervisorRecorder(supFR) + defer supFR.Close() //nolint:errcheck + } // Start API server with city-namespaced routing (Phase 2). startedAt := time.Now() @@ -699,7 +831,12 @@ func runSupervisor(stdout, stderr io.Writer) int { if readOnly { fmt.Fprintf(stderr, "gc supervisor: binding to %s — mutation endpoints disabled (non-localhost)\n", bind) //nolint:errcheck } - apiMux := api.NewSupervisorMux(registry, NewInitializer(), readOnly, version, startedAt) + cityInitSvc, err := newCityInitService() + if err != nil { + fmt.Fprintf(stderr, "gc supervisor: %v\n", err) //nolint:errcheck + return 1 + } + apiMux := api.NewSupervisorMux(registry, cityInitSvc, readOnly, version, startedAt) pprofSrv, pprofErr := api.StartPprof("") if pprofErr != nil { @@ -738,7 +875,7 @@ func runSupervisor(stdout, stderr io.Writer) int { return 1 } shut := newShutdownState() - lis, err := startSupervisorSocket(sockPath, cancel, reconcileCh, shut) + lis, err := startSupervisorSocket(sockPath, requestShutdown, reconcileCh, shut) if err != nil { fmt.Fprintf(stderr, "gc supervisor: %v\n", err) //nolint:errcheck return 1 @@ -823,14 +960,27 @@ func runSupervisor(stdout, stderr io.Writer) int { delete(cities, k) } }) + preserveSessions := shutdownCtl.preservesSessionsAfterSettle(supervisorShutdownSettleDelay) var stopFailures []string for name, mc := range toStop { - fmt.Fprintf(stdout, "Stopping city '%s'...\n", name) //nolint:errcheck - if err := stopManagedCity(mc, name, stderr); err != nil { + if preserveSessions { + fmt.Fprintf(stdout, "Preserving city '%s' sessions for re-adoption...\n", name) //nolint:errcheck + } else { + fmt.Fprintf(stdout, "Stopping city '%s'...\n", name) //nolint:errcheck + } + stopFn := stopManagedCity + if preserveSessions { + stopFn = stopManagedCityPreservingSessions + } + if err := stopFn(mc, name, stderr); err != nil { stopFailures = append(stopFailures, fmt.Sprintf("%s: %s", name, err.Error())) fmt.Fprintf(stdout, "City '%s' stop reported error (see stderr).\n", name) //nolint:errcheck } else { - fmt.Fprintf(stdout, "City '%s' stopped.\n", name) //nolint:errcheck + if preserveSessions { + fmt.Fprintf(stdout, "City '%s' preserved.\n", name) //nolint:errcheck + } else { + fmt.Fprintf(stdout, "City '%s' stopped.\n", name) //nolint:errcheck + } } } var shutErr error @@ -860,8 +1010,11 @@ type initFailRecord struct { backoff time.Time configMod time.Time // mtime of city.toml at last failure lastError string // last error message for user-facing feedback + dirAbsent int // consecutive failures where the city directory is gone } +const staleCityDirAbsentThreshold = 3 + // reconcileCities compares the registry against running cities and // starts/stops as needed. All state access goes through the cityRegistry. func reconcileCities( @@ -927,25 +1080,20 @@ func reconcileCities( // subscribers see the event via the running-provider path. // Best-effort: a failure to open the recorder just means // subscribers learn via GET /v0/cities instead. - evType := events.CityUnregistered - var payload []byte - if stopErr == nil { - fmt.Fprintf(stdout, "City '%s' stopped.\n", cityName) //nolint:errcheck - p, _ := json.Marshal(api.CityUnregisteredPayload{Name: cityName, Path: path}) - payload = p - } else { - evType = events.CityUnregisterFailed - p, _ := json.Marshal(api.CityUnregisterFailedPayload{Name: cityName, Path: path, Error: stopErr.Error()}) - payload = p + reqID, hasReqID, consumeErr := cr.ConsumePendingRequestID(path) + if consumeErr != nil { + fmt.Fprintf(stderr, "gc supervisor: city '%s': consume pending request_id for city.unregister completion event failed (path=%s): %v\n", cityName, path, consumeErr) //nolint:errcheck } - if fr, frErr := events.NewFileRecorder(filepath.Join(path, ".gc", "events.jsonl"), stderr); frErr == nil { - fr.Record(events.Event{ - Type: evType, - Actor: "gc", - Subject: cityName, - Payload: payload, - }) - fr.Close() //nolint:errcheck // best-effort + if !hasReqID { + fmt.Fprintf(stderr, "gc supervisor: city '%s': no pending request_id for city.unregister completion event (path=%s)\n", cityName, path) //nolint:errcheck + } + if supRec := cr.SupervisorEventRecorder(); supRec != nil && hasReqID { + emitCityUnregisterTerminalEvent(supRec, reqID, cityName, path, stopErr) + if stopErr == nil { + fmt.Fprintf(stdout, "City '%s' stopped.\n", cityName) //nolint:errcheck + } + } else if stopErr == nil { + fmt.Fprintf(stdout, "City '%s' stopped.\n", cityName) //nolint:errcheck } } @@ -1039,6 +1187,45 @@ func reconcileCities( continue } + // Auto-unregister cities whose directory no longer exists. If the + // directory has been absent for staleCityDirAbsentThreshold + // consecutive reconciliation cycles, remove the registration so + // the supervisor stops retrying. This catches leftover registrations + // from test runs or tutorials where the directory was cleaned up + // but the city was never unregistered. + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + var absentCount int + cr.BatchUpdate(func( + _ map[string]*managedCity, + _ map[string]cityInitProgress, + initFailures map[string]*initFailRecord, + _ map[string]*panicRecord, + ) { + ifrec := initFailures[path] + if ifrec == nil { + ifrec = &initFailRecord{} + initFailures[path] = ifrec + } + ifrec.dirAbsent++ + absentCount = ifrec.dirAbsent + }) + if absentCount >= staleCityDirAbsentThreshold { + fmt.Fprintf(stderr, "gc supervisor: city '%s': directory %s absent for %d cycles, auto-unregistering\n", name, path, absentCount) //nolint:errcheck + if unregErr := reg.Unregister(path); unregErr != nil { + fmt.Fprintf(stderr, "gc supervisor: city '%s': auto-unregister failed: %v\n", name, unregErr) //nolint:errcheck + } + cr.BatchUpdate(func( + _ map[string]*managedCity, + _ map[string]cityInitProgress, + initFailures map[string]*initFailRecord, + _ map[string]*panicRecord, + ) { + delete(initFailures, path) + }) + } + continue + } + // Init failure backoff: skip cities whose init failed recently, // unless the config file has been modified (user may have fixed it). tomlPath := filepath.Join(path, "city.toml") @@ -1092,6 +1279,7 @@ func reconcileCities( initFailures[path] = ifrec } ifrec.count++ + ifrec.dirAbsent = 0 exp := ifrec.count - 1 if exp > 5 { exp = 5 @@ -1108,6 +1296,7 @@ func reconcileCities( } if err := ensureLegacyNamedPacksCached(path); err != nil { + emitPendingCityCreateFailure(cr, path, name, "pack_cache_failed", err, stderr) recordInitFailure(name, fmt.Sprintf("fetching packs: %v", err)) continue } @@ -1116,6 +1305,7 @@ func reconcileCities( // System packs are appended as extra includes for normal pack expansion. cfg, prov, loadErr := loadSupervisorCityConfig(path) if loadErr != nil { + emitPendingCityCreateFailure(cr, path, name, "city_config_failed", loadErr, stderr) recordInitFailure(name, loadErr.Error()) continue } @@ -1159,28 +1349,7 @@ func reconcileCities( ) { delete(initStatus, path) }) - // Emit city.init_failed to the city's event file so - // clients watching /v0/events/stream observe async - // failure signal without polling. Best-effort: if the - // file recorder can't open (e.g. .gc/ missing or - // permissions), fall through to recordInitFailure which - // surfaces the error via /v0/cities. - evPath := filepath.Join(path, ".gc", "events.jsonl") - if fr, frErr := events.NewFileRecorder(evPath, stderr); frErr == nil { - if payload, mErr := json.Marshal(api.CityInitFailedPayload{ - Name: cityName, - Path: path, - Error: err.Error(), - }); mErr == nil { - fr.Record(events.Event{ - Type: events.CityInitFailed, - Actor: "gc", - Subject: cityName, - Payload: payload, - }) - } - fr.Close() //nolint:errcheck // best-effort - } + emitPendingCityCreateFailure(cr, path, cityName, "city_init_failed", err, stderr) recordInitFailure(cityName, fmt.Sprintf("init: %v", err)) continue } @@ -1229,6 +1398,7 @@ func reconcileCities( ) { delete(initStatus, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "session_provider_failed", spErr, stderr) recordInitFailure(cityName, fmt.Sprintf("session provider: %v", spErr)) continue } @@ -1245,6 +1415,7 @@ func reconcileCities( ) { delete(initStatus, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "agent_image_check_failed", err, stderr) recordInitFailure(cityName, err.Error()) continue } @@ -1299,6 +1470,7 @@ func reconcileCities( cr.UpdateCallback(path, func(m *managedCity) { m.started = true }) + emitPendingCityCreateResult(cr, path, cityName, stderr) }, OnStatus: func(status string) { cr.UpdateCallback(path, func(m *managedCity) { @@ -1311,6 +1483,7 @@ func reconcileCities( }) return nil }); err != nil { + emitPendingCityCreateFailure(cr, path, cityName, "city_runtime_failed", err, stderr) recordInitFailure(cityName, fmt.Sprintf("city runtime: %v", err)) continue } @@ -1322,6 +1495,7 @@ func reconcileCities( cs = newControllerState(cityCtx, cfg, sp, eventProv, cityName, path) return nil }); err != nil { + emitPendingCityCreateFailure(cr, path, cityName, "controller_state_failed", err, stderr) recordInitFailure(cityName, fmt.Sprintf("controller state: %v", err)) continue } @@ -1337,6 +1511,7 @@ func reconcileCities( runPoolOnBoot(cfg, path, shellRunHook, stderr) return nil }); err != nil { + emitPendingCityCreateFailure(cr, path, cityName, "pool_on_boot_failed", err, stderr) recordInitFailure(cityName, fmt.Sprintf("pool on_boot: %v", err)) continue } @@ -1380,6 +1555,7 @@ func reconcileCities( ) { delete(cities, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "controller_lock_failed", lockErr, stderr) recordInitFailure(cityName, fmt.Sprintf("controller lock: %v", lockErr)) continue } @@ -1404,6 +1580,7 @@ func reconcileCities( ) { delete(cities, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "controller_socket_failed", lisErr, stderr) recordInitFailure(cityName, fmt.Sprintf("controller socket: %v", lisErr)) continue } @@ -1429,6 +1606,7 @@ func reconcileCities( ) { delete(cities, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "controller_token_failed", tokenErr, stderr) recordInitFailure(cityName, fmt.Sprintf("controller token: %v", tokenErr)) continue } @@ -1451,6 +1629,7 @@ func reconcileCities( ) { delete(cities, path) }) + emitPendingCityCreateFailure(cr, path, cityName, "controller_token_write_failed", err, stderr) recordInitFailure(cityName, fmt.Sprintf("controller token write: %v", err)) continue } @@ -1476,6 +1655,20 @@ func reconcileCities( defer func() { if r := recover(); r != nil { fmt.Fprintf(stderr, "gc supervisor: city '%s' panicked: %v\n", n, r) //nolint:errcheck + reqID, hasReqID, consumeErr := cr.ConsumePendingRequestID(p) + if consumeErr != nil { + fmt.Fprintf(stderr, "gc supervisor: city '%s': consume pending request_id for city.create panic event failed (path=%s): %v\n", n, p, consumeErr) //nolint:errcheck + } + if hasReqID { + if supRec := cr.SupervisorEventRecorder(); supRec != nil { + api.EmitTypedEvent(supRec, events.RequestFailed, n, api.RequestFailedPayload{ + RequestID: reqID, + Operation: api.RequestOperationCityCreate, + ErrorCode: "internal_error", + ErrorMessage: fmt.Sprintf("panic: %v", r), + }) + } + } // Gracefully stop agents so they aren't orphaned. // Wrap in recovery to prevent nested panic from crashing // the entire supervisor. @@ -1558,24 +1751,60 @@ func reconcileCities( }(cityName, path, fr, lis, sockPath, sockInfo, lock) rec.Record(events.Event{Type: events.ControllerStarted, Actor: "gc"}) - // Signal city.ready on the supervisor event bus so clients - // that POST /v0/city and subscribe to /v0/events/stream - // observe completion without polling. Handler returned 202 - // synchronously; this event is the async completion signal. - readyPayload, readyErr := json.Marshal(api.CityReadyPayload{Name: cityName, Path: path}) - if readyErr == nil { - rec.Record(events.Event{ - Type: events.CityReady, - Actor: "gc", - Subject: cityName, - Payload: readyPayload, - }) - } telemetry.RecordControllerLifecycle(context.Background(), "started") fmt.Fprintf(stdout, "Launching city '%s' (%s)\n", cityName, path) //nolint:errcheck } } +func emitPendingCityCreateResult(cr *cityRegistry, path, cityName string, stderr io.Writer) { + reqID, hasReqID, consumeErr := cr.ConsumePendingRequestID(path) + if consumeErr != nil { + fmt.Fprintf(stderr, "gc supervisor: city '%s': consume pending request_id for city.create completion event failed (path=%s): %v\n", cityName, path, consumeErr) //nolint:errcheck + } + if supRec := cr.SupervisorEventRecorder(); supRec != nil && hasReqID { + api.EmitTypedEvent(supRec, events.RequestResultCityCreate, cityName, api.CityCreateSucceededPayload{ + RequestID: reqID, + Name: cityName, + Path: path, + }) + } +} + +func emitPendingCityCreateFailure(cr *cityRegistry, path, cityName, errorCode string, err error, stderr io.Writer) { + reqID, hasReqID, consumeErr := cr.ConsumePendingRequestID(path) + if consumeErr != nil { + fmt.Fprintf(stderr, "gc supervisor: city '%s': consume pending request_id for city.create failure event failed (path=%s): %v\n", cityName, path, consumeErr) //nolint:errcheck + } + if !hasReqID { + return + } + if supRec := cr.SupervisorEventRecorder(); supRec != nil { + api.EmitTypedEvent(supRec, events.RequestFailed, cityName, api.RequestFailedPayload{ + RequestID: reqID, + Operation: api.RequestOperationCityCreate, + ErrorCode: errorCode, + ErrorMessage: err.Error(), + }) + } +} + +func emitCityUnregisterTerminalEvent(rec events.Recorder, requestID, cityName, path string, stopErr error) { + if stopErr == nil { + api.EmitTypedEvent(rec, events.RequestResultCityUnregister, cityName, api.CityUnregisterSucceededPayload{ + RequestID: requestID, + Name: cityName, + Path: path, + }) + return + } + api.EmitTypedEvent(rec, events.RequestFailed, cityName, api.RequestFailedPayload{ + RequestID: requestID, + Operation: api.RequestOperationCityUnregister, + ErrorCode: "city_unregister_failed", + ErrorMessage: stopErr.Error(), + }) +} + var supervisorLoadWarningSeen sync.Map func emitSupervisorLoadCityConfigWarnings(w io.Writer, cityPath string, prov *config.Provenance) { @@ -1613,10 +1842,10 @@ func publishManagedCity(cr *cityRegistry, path string, mc *managedCity) bool { alreadyRunning = true return } - // The controller state and per-city API are fully wired at this point. - // Mark the city started before the first reconcile so slow bead scans - // don't keep supervisor startup and API availability blocked. - mc.started = true + // The controller state and per-city API are wired at this point, but + // initial reconciliation has not yet materialized startup session + // beads. Keep the city in startup status until CityRuntime.OnStarted + // runs after that reconciliation completes. mc.status = "starting_agents" cities[path] = mc delete(initStatus, path) diff --git a/cmd/gc/cmd_supervisor_city.go b/cmd/gc/cmd_supervisor_city.go index 0be16f7fbf..0ae99b77cd 100644 --- a/cmd/gc/cmd_supervisor_city.go +++ b/cmd/gc/cmd_supervisor_city.go @@ -275,18 +275,23 @@ func registerCityForAPI(cityPath, nameOverride string) error { // socket without waiting for the reply. Used by registerCityForAPI // so the async POST /v0/city handler doesn't block on the // reconciler tick. -func reloadSupervisorNoWait() { +func reloadSupervisorNoWait() error { sockPath, _ := runningSupervisorSocket() if sockPath == "" { - return + return errors.New("supervisor is not running; start it with 'gc supervisor start'") } conn, err := net.DialTimeout("unix", sockPath, 2*time.Second) if err != nil { - return + return fmt.Errorf("connecting to supervisor reload socket: %w", err) } defer conn.Close() //nolint:errcheck // best-effort - _ = conn.SetWriteDeadline(time.Now().Add(1 * time.Second)) - _, _ = conn.Write([]byte("reload\n")) + if err := conn.SetWriteDeadline(time.Now().Add(1 * time.Second)); err != nil { + return fmt.Errorf("setting supervisor reload deadline: %w", err) + } + if _, err := conn.Write([]byte("reload\n")); err != nil { + return fmt.Errorf("writing supervisor reload command: %w", err) + } + return nil } func retrySupervisorCityStartAfterControllerLock(cityPath string, stdout, stderr io.Writer, startErr error) (bool, error) { diff --git a/cmd/gc/cmd_supervisor_city_test.go b/cmd/gc/cmd_supervisor_city_test.go index a31e434a44..bd9d2cc90a 100644 --- a/cmd/gc/cmd_supervisor_city_test.go +++ b/cmd/gc/cmd_supervisor_city_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "io" "net" "os" @@ -12,6 +13,7 @@ import ( "testing" "time" + "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" @@ -283,6 +285,7 @@ func TestRegisterCityWithSupervisorFailsFastWhenSupervisorStopsDuringWait(t *tes } aliveChecks := 0 + var waitStarted time.Time withSupervisorTestHooks( t, func(_, _ io.Writer) int { return 0 }, @@ -290,6 +293,7 @@ func TestRegisterCityWithSupervisorFailsFastWhenSupervisorStopsDuringWait(t *tes func() int { aliveChecks++ if aliveChecks <= 1 { + waitStarted = time.Now() return 4242 } return 0 @@ -300,7 +304,6 @@ func TestRegisterCityWithSupervisorFailsFastWhenSupervisorStopsDuringWait(t *tes ) var stdout, stderr bytes.Buffer - started := time.Now() code := registerCityWithSupervisor(cityPath, &stdout, &stderr, "gc register", true) if code != 1 { t.Fatalf("registerCityWithSupervisor code = %d, want 1", code) @@ -308,7 +311,10 @@ func TestRegisterCityWithSupervisorFailsFastWhenSupervisorStopsDuringWait(t *tes if !strings.Contains(stderr.String(), "supervisor stopped before city became ready") { t.Fatalf("stderr = %q, want supervisor-stopped message", stderr.String()) } - if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + if waitStarted.IsZero() { + t.Fatal("supervisor wait path was not reached") + } + if elapsed := time.Since(waitStarted); elapsed > 250*time.Millisecond { t.Fatalf("registerCityWithSupervisor took %v, want fast failure when supervisor stops", elapsed) } if !strings.Contains(stderr.String(), "keeping registration") { @@ -1148,7 +1154,12 @@ func TestReconcileCitiesUnregisterEventUsesManagedCityName(t *testing.T) { done := make(chan struct{}) close(done) + supRec := events.NewFake() registry := newCityRegistry() + registry.SetSupervisorRecorder(supRec) + if err := registry.StorePendingRequestID(cityPath, "req-test-unregister"); err != nil { + t.Fatal(err) + } registry.Add(cityPath, &managedCity{ name: "effective-city", started: true, @@ -1160,32 +1171,144 @@ func TestReconcileCitiesUnregisterEventUsesManagedCityName(t *testing.T) { var stdout, stderr bytes.Buffer reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) - recorded, err := events.ReadAll(filepath.Join(cityPath, ".gc", "events.jsonl")) - if err != nil { - t.Fatalf("ReadAll(events): %v", err) - } + recorded := supRec.Events if len(recorded) != 1 { - t.Fatalf("recorded %d events, want 1", len(recorded)) + t.Fatalf("recorded %d supervisor events, want 1", len(recorded)) } got := recorded[0] - if got.Type != events.CityUnregistered { - t.Fatalf("event.Type = %q, want %q", got.Type, events.CityUnregistered) + if got.Type != events.RequestResultCityUnregister { + t.Fatalf("event.Type = %q, want %q", got.Type, events.RequestResultCityUnregister) } if got.Subject != "effective-city" { t.Fatalf("event.Subject = %q, want effective-city", got.Subject) } - var payload struct { - Name string `json:"name"` - Path string `json:"path"` - } + var payload api.CityUnregisterSucceededPayload if err := json.Unmarshal(got.Payload, &payload); err != nil { t.Fatalf("json.Unmarshal(payload): %v", err) } if payload.Name != "effective-city" { t.Fatalf("payload.Name = %q, want effective-city", payload.Name) } - if payload.Path != cityPath { - t.Fatalf("payload.Path = %q, want %q", payload.Path, cityPath) + if payload.RequestID != "req-test-unregister" { + t.Fatalf("payload.RequestID = %q, want req-test-unregister", payload.RequestID) + } +} + +func TestEmitCityUnregisterFailureEventUsesManagedCityName(t *testing.T) { + supRec := events.NewFake() + emitCityUnregisterTerminalEvent( + supRec, + "req-test-unregister", + "effective-city", + "/tmp/effective-city", + errors.New("city did not exit"), + ) + + recorded := supRec.Events + if len(recorded) != 1 { + t.Fatalf("recorded %d supervisor events, want 1", len(recorded)) + } + got := recorded[0] + if got.Type != events.RequestFailed { + t.Fatalf("event.Type = %q, want %q", got.Type, events.RequestFailed) + } + if got.Subject != "effective-city" { + t.Fatalf("event.Subject = %q, want effective-city", got.Subject) + } + var payload api.RequestFailedPayload + if err := json.Unmarshal(got.Payload, &payload); err != nil { + t.Fatalf("json.Unmarshal(payload): %v", err) + } + if payload.RequestID != "req-test-unregister" { + t.Fatalf("payload.RequestID = %q, want req-test-unregister", payload.RequestID) + } + if payload.Operation != api.RequestOperationCityUnregister { + t.Fatalf("payload.Operation = %q, want %q", payload.Operation, api.RequestOperationCityUnregister) + } +} + +func TestReconcileCitiesEmitsCityCreateFailureForPendingConfigLoadError(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + + cityPath := filepath.Join(t.TempDir(), "bad-city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace\n"), 0o644); err != nil { + t.Fatal(err) + } + + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + if err := reg.Register(cityPath, "bad-city"); err != nil { + t.Fatal(err) + } + supRec := events.NewFake() + registry := newCityRegistry() + registry.SetSupervisorRecorder(supRec) + if err := registry.StorePendingRequestID(cityPath, "req-test-create"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + + recorded := supRec.Events + if len(recorded) != 1 { + t.Fatalf("recorded %d supervisor events, want 1; stderr=%s", len(recorded), stderr.String()) + } + got := recorded[0] + if got.Type != events.RequestFailed { + t.Fatalf("event.Type = %q, want %q", got.Type, events.RequestFailed) + } + if got.Subject != "bad-city" { + t.Fatalf("event.Subject = %q, want bad-city", got.Subject) + } + var payload api.RequestFailedPayload + if err := json.Unmarshal(got.Payload, &payload); err != nil { + t.Fatalf("json.Unmarshal(payload): %v", err) + } + if payload.RequestID != "req-test-create" { + t.Fatalf("payload.RequestID = %q, want req-test-create", payload.RequestID) + } + if payload.Operation != api.RequestOperationCityCreate { + t.Fatalf("payload.Operation = %q, want %q", payload.Operation, api.RequestOperationCityCreate) + } + if payload.ErrorCode != "city_config_failed" { + t.Fatalf("payload.ErrorCode = %q, want city_config_failed", payload.ErrorCode) + } + if _, ok, err := registry.ConsumePendingRequestID(cityPath); err != nil { + t.Fatal(err) + } else if ok { + t.Fatal("pending request_id survived city create failure") + } +} + +func TestReconcileCitiesUnregisterSkipsRequestResultWithoutPendingRequestID(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + + cityPath := filepath.Join(t.TempDir(), "basename-city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + close(done) + supRec := events.NewFake() + registry := newCityRegistry() + registry.SetSupervisorRecorder(supRec) + registry.Add(cityPath, &managedCity{ + name: "effective-city", + started: true, + cancel: func() {}, + done: done, + }) + + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + var stdout, stderr bytes.Buffer + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + + if len(supRec.Events) != 0 { + t.Fatalf("recorded %d supervisor events without pending request_id, want 0: %#v", len(supRec.Events), supRec.Events) } } @@ -1317,6 +1440,7 @@ func TestCmdStopSupervisorManagedCityReliesOnSupervisorCleanup(t *testing.T) { logFile := filepath.Join(t.TempDir(), "ops.log") script := writeSpyScript(t, logFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) withSupervisorTestHooks( t, @@ -1396,6 +1520,7 @@ func TestReconcileCitiesNameDriftStopsBeadsProvider(t *testing.T) { logFile := filepath.Join(t.TempDir(), "ops.log") script := writeSpyScript(t, logFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) reg := supervisor.NewRegistry(supervisor.RegistryPath()) if err := reg.Register(cityPath, "new-name"); err != nil { @@ -1405,7 +1530,7 @@ func TestReconcileCitiesNameDriftStopsBeadsProvider(t *testing.T) { cfg := config.DefaultCity("old-name") sp := runtime.NewFake() var cityOut, cityErr bytes.Buffer - cr := newCityRuntime(CityRuntimeParams{ + cr := newTestCityRuntime(t, CityRuntimeParams{ CityPath: cityPath, CityName: "old-name", Cfg: &cfg, @@ -1441,6 +1566,7 @@ func TestSupervisorCreatesControllerSocketForManagedCity(t *testing.T) { t.Setenv("GC_HOME", gcHome) cityPath := shortSocketTempDir(t, "gc-supervisor-city-") + cleanupManagedDoltTestCity(t, cityPath) if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) } @@ -1452,6 +1578,7 @@ func TestSupervisorCreatesControllerSocketForManagedCity(t *testing.T) { logFile := filepath.Join(t.TempDir(), "ops.log") script := writeSpyScript(t, logFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) reg := supervisor.NewRegistry(supervisor.RegistryPath()) if err := reg.Register(cityPath, "test-city"); err != nil { @@ -1690,7 +1817,108 @@ func TestReconcileCitiesSkipsCityAlreadyInitializing(t *testing.T) { }) } -func TestPublishManagedCityMarksRunningBeforeInitialReconcile(t *testing.T) { +func TestReconcileCitiesAutoUnregistersAbsentDirectory(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + missingPath := filepath.Join(t.TempDir(), "gone-city") + if err := reg.Register(missingPath, "gone-city"); err != nil { + t.Fatal(err) + } + + registry := newCityRegistry() + var stdout, stderr bytes.Buffer + + for i := 0; i < staleCityDirAbsentThreshold; i++ { + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + } + + entries, err := reg.List() + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Path == missingPath { + t.Fatalf("city %q should have been auto-unregistered after %d cycles, but is still registered", missingPath, staleCityDirAbsentThreshold) + } + } + if !strings.Contains(stderr.String(), "auto-unregistering") { + t.Fatalf("stderr should mention auto-unregistering, got: %s", stderr.String()) + } +} + +func TestReconcileCitiesDoesNotUnregisterBeforeThreshold(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + missingPath := filepath.Join(t.TempDir(), "gone-city") + if err := reg.Register(missingPath, "gone-city"); err != nil { + t.Fatal(err) + } + + registry := newCityRegistry() + var stdout, stderr bytes.Buffer + + for i := 0; i < staleCityDirAbsentThreshold-1; i++ { + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + } + + entries, err := reg.List() + if err != nil { + t.Fatal(err) + } + var found bool + for _, e := range entries { + if e.Path == missingPath { + found = true + } + } + if !found { + t.Fatalf("city %q should still be registered after %d cycles (threshold is %d)", missingPath, staleCityDirAbsentThreshold-1, staleCityDirAbsentThreshold) + } +} + +func TestReconcileCitiesResetsAbsentCounterWhenDirectoryReappears(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + cityPath := filepath.Join(t.TempDir(), "flaky-city") + if err := reg.Register(cityPath, "flaky-city"); err != nil { + t.Fatal(err) + } + + registry := newCityRegistry() + var stdout, stderr bytes.Buffer + + for i := 0; i < staleCityDirAbsentThreshold-1; i++ { + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + } + + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + reconcileCities(reg, registry, supervisor.PublicationConfig{}, &stdout, &stderr) + + var dirAbsent int + registry.ReadCallback(func( + _ map[string]*managedCity, + _ map[string]cityInitProgress, + initFailures map[string]*initFailRecord, + _ map[string]*panicRecord, + ) { + if rec := initFailures[cityPath]; rec != nil { + dirAbsent = rec.dirAbsent + } + }) + if dirAbsent != 0 { + t.Fatalf("dirAbsent = %d after directory reappeared, want 0", dirAbsent) + } +} + +func TestPublishManagedCityWaitsForInitialReconcileBeforeRunning(t *testing.T) { registry := newCityRegistry() cityPath := "/tmp/bright-lights" cs := &controllerState{} @@ -1718,14 +1946,14 @@ func TestPublishManagedCityMarksRunningBeforeInitialReconcile(t *testing.T) { if len(cities) != 1 { t.Fatalf("ListCities() returned %d cities, want 1", len(cities)) } - if !cities[0].Running { - t.Fatalf("city Running = false, want true: %+v", cities[0]) + if cities[0].Running { + t.Fatalf("city Running = true before startup reconcile: %+v", cities[0]) } - if cities[0].Status != "" { - t.Fatalf("city Status = %q, want empty once published", cities[0].Status) + if cities[0].Status != "starting_agents" { + t.Fatalf("city Status = %q, want starting_agents while startup reconcile runs", cities[0].Status) } - if got := registry.CityState("bright-lights"); got != cs { - t.Fatalf("CityState() = %#v, want controller state", got) + if got := registry.CityState("bright-lights"); got != nil { + t.Fatalf("CityState() = %#v before startup reconcile, want nil", got) } registry.ReadCallback(func( diff --git a/cmd/gc/cmd_supervisor_lifecycle.go b/cmd/gc/cmd_supervisor_lifecycle.go index 8ee7a962e7..5f75a8dfa1 100644 --- a/cmd/gc/cmd_supervisor_lifecycle.go +++ b/cmd/gc/cmd_supervisor_lifecycle.go @@ -18,33 +18,324 @@ import ( "sort" "strconv" "strings" + "syscall" "text/template" "time" + "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/searchpath" "github.com/gastownhall/gascity/internal/supervisor" "github.com/spf13/cobra" ) var ( - ensureSupervisorRunningHook = ensureSupervisorRunning - reloadSupervisorHook = reloadSupervisor - supervisorAliveHook = supervisorAlive - supervisorReadyTimeout = 15 * time.Second - supervisorReadyPollInterval = 100 * time.Millisecond - supervisorLaunchctlRun = func(args ...string) error { + ensureSupervisorRunningHook = ensureSupervisorRunning + reloadSupervisorHook = reloadSupervisor + supervisorAliveHook = supervisorAlive + supervisorReadyTimeout = 15 * time.Second + supervisorReadyPollInterval = 100 * time.Millisecond + supervisorSystemdWarmRefreshStopTimeout = 5 * time.Second + supervisorSystemdWarmRefreshPollInterval = 100 * time.Millisecond + supervisorLaunchctlRun = func(args ...string) error { return exec.Command("launchctl", args...).Run() } + supervisorLaunchdActive = func(label string) bool { + out, err := exec.Command("launchctl", "print", supervisorLaunchdServiceTarget(label)).Output() + return err == nil && launchdPrintReportsRunning(out) + } supervisorSystemctlRun = func(args ...string) error { return exec.Command("systemctl", args...).Run() } supervisorSystemctlActive = func(service string) bool { return exec.Command("systemctl", "--user", "is-active", "--quiet", service).Run() == nil } + supervisorRunningPreserveSignalReady = runningSupervisorPreserveSignalReady + supervisorProcRoot = "/proc" + supervisorProcReadDir = os.ReadDir + supervisorProcReadFile = os.ReadFile + supervisorGetpgid = syscall.Getpgid + supervisorGetpgrp = syscall.Getpgrp + supervisorKill = syscall.Kill + supervisorProcessGroupPollPeriod = 20 * time.Millisecond + supervisorRuntimeGOOS = goruntime.GOOS + supervisorWorkspaceServiceCleanupWarnings io.Writer = os.Stderr ) const supervisorServiceFileMode os.FileMode = 0o600 +type supervisorWorkspaceServiceProcess struct { + pid int + pgid int + name string +} + +type supervisorWorkspaceServiceCleanupScope struct { + gcHome string + cityPaths map[string]string +} + +func launchdPrintReportsRunning(out []byte) bool { + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 3 && fields[0] == "state" && fields[1] == "=" && fields[2] == "running" { + return true + } + } + return false +} + +func cleanupSupervisorWorkspaceServicesForWarmRefresh(gcHome string) error { + scope, err := supervisorWorkspaceServiceCleanupScopeFromRegistry(gcHome) + if err != nil { + return err + } + return cleanupSupervisorWorkspaceServices(scope) +} + +func cleanupSupervisorWorkspaceServicesForSupervisorStart(gcHome string) error { + scope, err := supervisorWorkspaceServiceCleanupScopeFromRegistry(gcHome) + if err != nil { + return err + } + if supervisorRuntimeGOOS != "linux" { + if len(scope.cityPaths) > 0 { + warnSupervisorWorkspaceServiceCleanup("gc supervisor: workspace-service startup cleanup is not available on %s; after a non-graceful supervisor exit, stale workspace-service processes may keep sockets bound. Registered workspace-service roots: %s. Stop stale processes whose environment includes GC_SERVICE_STATE_ROOT under those roots, then restart those cities.\n", supervisorRuntimeGOOS, strings.Join(supervisorWorkspaceServiceStateRoots(scope), ", ")) + } + return nil + } + if err := cleanupSupervisorWorkspaceServices(scope); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + return nil +} + +func warnSupervisorWorkspaceServiceCleanup(format string, args ...any) { + if supervisorWorkspaceServiceCleanupWarnings == nil { + return + } + fmt.Fprintf(supervisorWorkspaceServiceCleanupWarnings, format, args...) //nolint:errcheck // best-effort operator diagnostic +} + +func supervisorWorkspaceServiceStateRoots(scope supervisorWorkspaceServiceCleanupScope) []string { + roots := make([]string, 0, len(scope.cityPaths)) + for cityPath := range scope.cityPaths { + roots = append(roots, citylayout.RuntimeServicesDir(cityPath)) + } + sort.Strings(roots) + return roots +} + +func cleanupSupervisorWorkspaceServices(scope supervisorWorkspaceServiceCleanupScope) error { + procs, err := findSupervisorWorkspaceServiceProcesses(scope) + if err != nil { + return err + } + var errs []error + for _, proc := range procs { + if err := terminateProcessGroup(proc.pgid, 2*time.Second); err != nil { + errs = append(errs, fmt.Errorf("stopping workspace service %q pid %d pgid %d: %w", proc.name, proc.pid, proc.pgid, err)) + } + } + return errors.Join(errs...) +} + +func supervisorWorkspaceServiceCleanupScopeFromRegistry(gcHome string) (supervisorWorkspaceServiceCleanupScope, error) { + scope := supervisorWorkspaceServiceCleanupScope{ + gcHome: normalizePathForCompare(strings.TrimSpace(gcHome)), + cityPaths: make(map[string]string), + } + if scope.gcHome == "" { + return scope, errors.New("missing GC_HOME for workspace-service cleanup") + } + entries, err := supervisor.NewRegistry(supervisor.RegistryPath()).List() + if err != nil { + return scope, fmt.Errorf("reading supervisor registry for workspace-service cleanup: %w", err) + } + for _, entry := range entries { + cityPath := normalizePathForCompare(strings.TrimSpace(entry.Path)) + if cityPath == "" { + continue + } + scope.cityPaths[cityPath] = cityPath + } + return scope, nil +} + +func findSupervisorWorkspaceServiceProcesses(scope supervisorWorkspaceServiceCleanupScope) ([]supervisorWorkspaceServiceProcess, error) { + if strings.TrimSpace(scope.gcHome) == "" { + return nil, errors.New("missing GC_HOME for workspace-service cleanup") + } + if len(scope.cityPaths) == 0 { + return nil, nil + } + entries, err := supervisorProcReadDir(supervisorProcRoot) + if err != nil { + return nil, fmt.Errorf("reading /proc: %w", err) + } + seenPGID := make(map[int]supervisorWorkspaceServiceProcess) + var errs []error + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + env, err := supervisorProcReadFile(filepath.Join(supervisorProcRoot, entry.Name(), "environ")) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + continue + } + continue + } + envMap := supervisorProcessEnvMap(env) + if !supervisorWorkspaceServiceCandidateOwnedByScope(scope, envMap) { + continue + } + pgid, err := supervisorGetpgid(pid) + if err != nil { + if errors.Is(err, syscall.ESRCH) { + continue + } + errs = append(errs, fmt.Errorf("workspace service %q pid %d pgid: %w", envMap["GC_SERVICE_NAME"], pid, err)) + continue + } + confirmedEnv, err := supervisorProcReadFile(filepath.Join(supervisorProcRoot, entry.Name(), "environ")) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + continue + } + continue + } + confirmedEnvMap := supervisorProcessEnvMap(confirmedEnv) + if !supervisorWorkspaceServiceCandidateOwnedByScope(scope, confirmedEnvMap) || + !sameSupervisorWorkspaceServiceCandidate(envMap, confirmedEnvMap) { + continue + } + if pgid <= 1 || pgid == supervisorGetpgrp() { + warnSupervisorWorkspaceServiceCleanup("gc supervisor: skipping workspace service %q pid %d with unsafe process group %d; leaving it running\n", envMap["GC_SERVICE_NAME"], pid, pgid) + continue + } + if _, ok := seenPGID[pgid]; !ok { + seenPGID[pgid] = supervisorWorkspaceServiceProcess{ + pid: pid, + pgid: pgid, + name: envMap["GC_SERVICE_NAME"], + } + } + } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + procs := make([]supervisorWorkspaceServiceProcess, 0, len(seenPGID)) + for _, proc := range seenPGID { + procs = append(procs, proc) + } + sort.Slice(procs, func(i, j int) bool { + return procs[i].pgid < procs[j].pgid + }) + return procs, nil +} + +func supervisorWorkspaceServiceCandidateOwnedByScope(scope supervisorWorkspaceServiceCleanupScope, envMap map[string]string) bool { + if envMap["GC_SERVICE_SOCKET"] == "" || envMap["GC_SERVICE_NAME"] == "" || envMap["GC_SERVICE_STATE_ROOT"] == "" { + return false + } + return supervisorWorkspaceServiceOwnedByScope(scope, envMap) +} + +func sameSupervisorWorkspaceServiceCandidate(before, after map[string]string) bool { + for _, key := range []string{ + "GC_HOME", + "GC_CITY_PATH", + "GC_SERVICE_NAME", + "GC_SERVICE_STATE_ROOT", + "GC_SERVICE_SOCKET", + } { + if before[key] != after[key] { + return false + } + } + return true +} + +func supervisorWorkspaceServiceOwnedByScope(scope supervisorWorkspaceServiceCleanupScope, envMap map[string]string) bool { + envHome := normalizePathForCompare(strings.TrimSpace(envMap["GC_HOME"])) + if envHome == "" || envHome != scope.gcHome { + return false + } + cityPath := normalizePathForCompare(strings.TrimSpace(envMap["GC_CITY_PATH"])) + if cityPath == "" { + return false + } + cityPath, ok := scope.cityPaths[cityPath] + if !ok { + return false + } + stateRoot := strings.TrimSpace(envMap["GC_SERVICE_STATE_ROOT"]) + if stateRoot == "" { + return false + } + return pathWithinOrSame(stateRoot, citylayout.RuntimeServicesDir(cityPath)) +} + +func supervisorProcessEnvMap(data []byte) map[string]string { + env := make(map[string]string) + for _, item := range bytes.Split(data, []byte{0}) { + if len(item) == 0 { + continue + } + key, value, ok := bytes.Cut(item, []byte("=")) + if !ok { + continue + } + env[string(key)] = string(value) + } + return env +} + +func terminateProcessGroup(pgid int, timeout time.Duration) error { + if pgid <= 1 || pgid == supervisorGetpgrp() { + return fmt.Errorf("refusing to signal unsafe process group %d", pgid) + } + if err := supervisorKill(-pgid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + if err := waitForProcessGroupExit(pgid, timeout); err == nil { + return nil + } + if err := supervisorKill(-pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return waitForProcessGroupExit(pgid, timeout) +} + +func waitForProcessGroupExit(pgid int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if !processGroupAlive(pgid) { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("process group %d did not exit within %s", pgid, timeout) + } + time.Sleep(supervisorProcessGroupPollPeriod) + } +} + +func processGroupAlive(pgid int) bool { + if pgid <= 0 { + return false + } + err := supervisorKill(-pgid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + func newSupervisorRunCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ Use: "run", @@ -299,8 +590,12 @@ func newSupervisorUninstallCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ Use: "uninstall", Short: "Remove the platform service", - Long: `Remove the platform service and stop the machine-wide supervisor.`, - Args: cobra.NoArgs, + Long: `Remove the platform service and stop the machine-wide supervisor. + +On systemd, uninstall refuses to remove an active unit when the supervisor +control socket is unavailable. Start the supervisor first so it can re-adopt +preserved sessions, then retry uninstall.`, + Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { if doSupervisorUninstall(stdout, stderr) != 0 { return errExit @@ -408,9 +703,10 @@ var providerCredentialEnvPrefixes = []string{ } var supervisorServiceFixedEnvKeys = map[string]bool{ - "GC_HOME": true, - "PATH": true, - "XDG_RUNTIME_DIR": true, + "GC_HOME": true, + supervisorPreserveSessionsOnSignalEnv: true, + "PATH": true, + "XDG_RUNTIME_DIR": true, } func supervisorServiceExtraEnv() []supervisorServiceEnvVar { @@ -543,6 +839,8 @@ const supervisorLaunchdTemplate = ` {{end}} PATH {{xmlesc .Path}} + GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL + 1 {{range .ExtraEnv}} {{xmlesc .Name}} {{xmlesc .Value}} @@ -557,6 +855,11 @@ Description=Gas City machine supervisor [Service] Type=simple +# Signal only the main supervisor PID on stop. The systemd default +# (control-group) would cascade SIGTERM to tmux servers spawned by +# 'gc supervisor run' that live in this cgroup, killing one-per-bead +# session conversation history. The reconciler re-adopts tmux on start. +KillMode=process ExecStart={{.GCPath}} supervisor run Restart=always RestartSec=5s @@ -565,6 +868,7 @@ StandardError=append:{{.LogPath}} Environment=GC_HOME="{{.GCHome}}" {{if .XDGRuntimeDir}}Environment=XDG_RUNTIME_DIR="{{.XDGRuntimeDir}}" {{end}}Environment=PATH="{{.Path}}" +Environment=GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL="1" {{range .ExtraEnv}}Environment={{systemdenv .Name .Value}} {{end}} @@ -613,6 +917,48 @@ func supervisorLaunchdPlistPath() string { return filepath.Join(home, "Library", "LaunchAgents", supervisorLaunchdLabel()+".plist") } +func supervisorLaunchdServiceTarget(label string) string { + if label == "" { + label = supervisorLaunchdLabel() + } + return "gui/" + strconv.Itoa(os.Getuid()) + "/" + label +} + +func loadAndStartSupervisorLaunchd(path, label string) error { + if err := supervisorLaunchctlRun("load", path); err != nil { + return fmt.Errorf("load %s: %w", path, err) + } + target := supervisorLaunchdServiceTarget(label) + if err := supervisorLaunchctlRun("enable", target); err != nil { + return fmt.Errorf("enable %s: %w", target, err) + } + if err := supervisorLaunchctlRun("kickstart", "-p", target); err != nil { + return fmt.Errorf("kickstart -p %s: %w", target, err) + } + return nil +} + +func loadAndStartSupervisorLaunchdForRollback(path, label string, stderr io.Writer) error { + if err := supervisorLaunchctlRun("load", path); err != nil { + return fmt.Errorf("load %s: %w", path, err) + } + target := supervisorLaunchdServiceTarget(label) + if err := supervisorLaunchctlRun("enable", target); err != nil { + warnSupervisorLaunchdRollback(stderr, "enable %s: %v", target, err) + } + if err := supervisorLaunchctlRun("kickstart", "-p", target); err != nil { + warnSupervisorLaunchdRollback(stderr, "kickstart -p %s: %v", target, err) + } + return nil +} + +func warnSupervisorLaunchdRollback(stderr io.Writer, format string, args ...any) { + if stderr == nil { + return + } + fmt.Fprintf(stderr, "gc supervisor install: warning: restoring launchd service: "+format+"\n", args...) //nolint:errcheck // best-effort stderr +} + func legacySupervisorLaunchdPlistPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, "Library", "LaunchAgents", defaultSupervisorLaunchdLabel+".plist") @@ -786,6 +1132,7 @@ func unloadLegacySupervisorLaunchd(remove bool) error { } _ = supervisorLaunchctlRun("unload", path) if remove { + _ = supervisorLaunchctlRun("disable", supervisorLaunchdServiceTarget(defaultSupervisorLaunchdLabel)) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing legacy plist %s: %w", path, err) } @@ -808,26 +1155,26 @@ func unloadLegacySupervisorSystemd(remove bool) error { return nil } -func rollbackNewSupervisorLaunchdInstall(path string, restoreLegacy bool) error { +func rollbackNewSupervisorLaunchdInstall(path string, restoreLegacy bool, stderr io.Writer) error { var errs []error _ = supervisorLaunchctlRun("unload", path) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { errs = append(errs, fmt.Errorf("removing failed plist %s during rollback: %w", path, err)) } if restoreLegacy { - if err := supervisorLaunchctlRun("load", legacySupervisorLaunchdPlistPath()); err != nil { + if err := loadAndStartSupervisorLaunchdForRollback(legacySupervisorLaunchdPlistPath(), defaultSupervisorLaunchdLabel, stderr); err != nil { errs = append(errs, fmt.Errorf("restoring legacy plist %s: %w", legacySupervisorLaunchdPlistPath(), err)) } } return errors.Join(errs...) } -func restorePreviousSupervisorLaunchdInstall(path string, previousContent []byte) error { +func restorePreviousSupervisorLaunchdInstall(path string, previousContent []byte, stderr io.Writer) error { var errs []error _ = supervisorLaunchctlRun("unload", path) if err := writeSupervisorServiceFile(path, previousContent); err != nil { errs = append(errs, fmt.Errorf("restoring previous plist %s: %w", path, err)) - } else if err := supervisorLaunchctlRun("load", path); err != nil { + } else if err := loadAndStartSupervisorLaunchdForRollback(path, supervisorLaunchdLabel(), stderr); err != nil { errs = append(errs, fmt.Errorf("reloading previous plist %s: %w", path, err)) } return errors.Join(errs...) @@ -874,6 +1221,10 @@ func restorePreviousSupervisorSystemdInstall(path, service string, previousConte return errors.Join(errs...) } +func warnSupervisorSystemdWarmRefreshPreservedUnit(stderr io.Writer, service string) { + fmt.Fprintf(stderr, "gc supervisor install: leaving refreshed systemd unit %s in place after warm-refresh failure; not restoring the previous unit because it may lack KillMode=process. Resolve the error, then run 'systemctl --user start %s' or rerun 'gc supervisor install'.\n", service, service) //nolint:errcheck // best-effort stderr +} + func installSupervisorLaunchd(data *supervisorServiceData, stdout, stderr io.Writer) int { content, err := renderSupervisorTemplate(supervisorLaunchdTemplate, data) if err != nil { @@ -903,17 +1254,17 @@ func installSupervisorLaunchd(data *supervisorServiceData, stdout, stderr io.Wri } _ = supervisorLaunchctlRun("unload", path) - if err := supervisorLaunchctlRun("load", path); err != nil { + if err := loadAndStartSupervisorLaunchd(path, data.LaunchdLabel); err != nil { var rollbackErr error if hadCurrent { - rollbackErr = restorePreviousSupervisorLaunchdInstall(path, existing) + rollbackErr = restorePreviousSupervisorLaunchdInstall(path, existing, stderr) } else { - rollbackErr = rollbackNewSupervisorLaunchdInstall(path, legacyPresent) + rollbackErr = rollbackNewSupervisorLaunchdInstall(path, legacyPresent, stderr) } if rollbackErr != nil { - fmt.Fprintf(stderr, "gc supervisor install: rollback after launchctl load failure: %v\n", rollbackErr) //nolint:errcheck // best-effort stderr + fmt.Fprintf(stderr, "gc supervisor install: rollback after launchctl failure: %v\n", rollbackErr) //nolint:errcheck // best-effort stderr } - fmt.Fprintf(stderr, "gc supervisor install: launchctl load: %v\n", err) //nolint:errcheck // best-effort stderr + fmt.Fprintf(stderr, "gc supervisor install: launchctl %v\n", err) //nolint:errcheck // best-effort stderr return 1 } if err := unloadLegacySupervisorLaunchd(true); err != nil { @@ -926,7 +1277,17 @@ func installSupervisorLaunchd(data *supervisorServiceData, stdout, stderr io.Wri func uninstallSupervisorLaunchd(_ *supervisorServiceData, stdout, stderr io.Writer) int { path := supervisorLaunchdPlistPath() + active := supervisorLaunchdActive(supervisorLaunchdLabel()) + if sockPath, _ := runningSupervisorSocket(); sockPath != "" { + if code := stopSupervisorWithWait(stdout, stderr, true, 30*time.Second); code != 0 { + return code + } + } else if active { + fmt.Fprintf(stderr, "gc supervisor uninstall: launchd service %s is active but the control socket is unavailable; run 'gc supervisor start' to re-adopt sessions, then retry uninstall\n", supervisorLaunchdLabel()) //nolint:errcheck // best-effort stderr + return 1 + } _ = supervisorLaunchctlRun("unload", path) + _ = supervisorLaunchctlRun("disable", supervisorLaunchdServiceTarget(supervisorLaunchdLabel())) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { fmt.Fprintf(stderr, "gc supervisor uninstall: removing plist: %v\n", err) //nolint:errcheck // best-effort stderr return 1 @@ -939,6 +1300,54 @@ func uninstallSupervisorLaunchd(_ *supervisorServiceData, stdout, stderr io.Writ return 0 } +func waitSupervisorSystemdInactive(service string, timeout time.Duration) bool { + if !supervisorSystemctlActive(service) { + return true + } + if timeout <= 0 { + return false + } + poll := supervisorSystemdWarmRefreshPollInterval + if poll <= 0 { + poll = time.Millisecond + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(poll) + if !supervisorSystemctlActive(service) { + return true + } + } + return !supervisorSystemctlActive(service) +} + +func runningSupervisorPreserveSignalReady() (int, bool, error) { + _, pid := runningSupervisorSocket() + if pid <= 0 { + return 0, false, errors.New("active supervisor control socket is unavailable") + } + env, err := supervisorProcReadFile(filepath.Join(supervisorProcRoot, strconv.Itoa(pid), "environ")) + if err != nil { + return pid, false, fmt.Errorf("reading active supervisor pid %d environment: %w", pid, err) + } + return pid, supervisorProcessEnvMap(env)[supervisorPreserveSessionsOnSignalEnv] == "1", nil +} + +func stopSupervisorSystemdForWarmRefresh(service string) ([]string, error) { + termArgs := []string{"--user", "kill", "--kill-who=main", "--signal=SIGTERM", service} + if err := supervisorSystemctlRun(termArgs...); err != nil { + return termArgs, err + } + if waitSupervisorSystemdInactive(service, supervisorSystemdWarmRefreshStopTimeout) { + return termArgs, nil + } + killArgs := []string{"--user", "kill", "--kill-who=main", "--signal=SIGKILL", service} + if err := supervisorSystemctlRun(killArgs...); err != nil { + return killArgs, err + } + return killArgs, nil +} + func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Writer) int { content, err := renderSupervisorTemplate(supervisorSystemdTemplate, data) if err != nil { @@ -960,6 +1369,18 @@ func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Wri return 1 } contentChanged := string(existing) != content + active := supervisorSystemctlActive(service) + if contentChanged && active { + pid, ready, err := supervisorRunningPreserveSignalReady() + if err != nil { + fmt.Fprintf(stderr, "gc supervisor install: cannot verify active supervisor preserve-mode readiness: %v. Refusing systemd warm refresh because signaling an older supervisor can stop managed sessions. Stop or drain agents intentionally with 'gc supervisor stop --wait', then rerun 'gc supervisor install'.\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + if !ready { + fmt.Fprintf(stderr, "gc supervisor install: active supervisor pid %d does not have %s=1. Refusing systemd warm refresh because this first post-upgrade install would stop managed sessions. Stop or drain agents intentionally with 'gc supervisor stop --wait', then rerun 'gc supervisor install'.\n", pid, supervisorPreserveSessionsOnSignalEnv) //nolint:errcheck // best-effort stderr + return 1 + } + } if err := writeSupervisorServiceFile(path, []byte(content)); err != nil { fmt.Fprintf(stderr, "gc supervisor install: writing unit: %v\n", err) //nolint:errcheck // best-effort stderr return 1 @@ -988,9 +1409,9 @@ func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Wri return 1 } - if contentChanged && supervisorSystemctlActive(service) { - args := []string{"--user", "restart", service} - if err := supervisorSystemctlRun(args...); err != nil { + if contentChanged && active { + stopArgs, err := stopSupervisorSystemdForWarmRefresh(service) + if err != nil { var rollbackErr error if hadCurrent { rollbackErr = restorePreviousSupervisorSystemdInstall(path, service, existing, true) @@ -998,12 +1419,24 @@ func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Wri rollbackErr = rollbackNewSupervisorSystemdInstall(path, service, legacyPresent) } if rollbackErr != nil { - fmt.Fprintf(stderr, "gc supervisor install: rollback after systemctl %s failure: %v\n", strings.Join(args, " "), rollbackErr) //nolint:errcheck // best-effort stderr + fmt.Fprintf(stderr, "gc supervisor install: rollback after systemctl %s failure: %v\n", strings.Join(stopArgs, " "), rollbackErr) //nolint:errcheck // best-effort stderr } - fmt.Fprintf(stderr, "gc supervisor install: systemctl %s: %v\n", strings.Join(args, " "), err) //nolint:errcheck // best-effort stderr + fmt.Fprintf(stderr, "gc supervisor install: systemctl %s: %v\n", strings.Join(stopArgs, " "), err) //nolint:errcheck // best-effort stderr + return 1 + } + if err := cleanupSupervisorWorkspaceServicesForWarmRefresh(data.GCHome); err != nil { + warnSupervisorSystemdWarmRefreshPreservedUnit(stderr, service) + fmt.Fprintf(stderr, "gc supervisor install: workspace-service cleanup after systemctl %s: %v\n", strings.Join(stopArgs, " "), err) //nolint:errcheck // best-effort stderr return 1 } - } else if !supervisorSystemctlActive(service) { + _ = supervisorSystemctlRun("--user", "reset-failed", service) + startArgs := []string{"--user", "start", service} + if err := supervisorSystemctlRun(startArgs...); err != nil { + warnSupervisorSystemdWarmRefreshPreservedUnit(stderr, service) + fmt.Fprintf(stderr, "gc supervisor install: systemctl %s: %v\n", strings.Join(startArgs, " "), err) //nolint:errcheck // best-effort stderr + return 1 + } + } else if !active { args := []string{"--user", "start", service} if err := supervisorSystemctlRun(args...); err != nil { var rollbackErr error @@ -1032,6 +1465,16 @@ func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Wri func uninstallSupervisorSystemd(_ *supervisorServiceData, stdout, stderr io.Writer) int { path := supervisorSystemdServicePath() service := supervisorSystemdServiceName() + active := supervisorSystemctlActive(service) + if active { + if sockPath, _ := runningSupervisorSocket(); sockPath == "" { + fmt.Fprintf(stderr, "gc supervisor uninstall: systemd service %s is active but the control socket is unavailable; run 'gc supervisor start' to re-adopt sessions, then retry uninstall\n", service) //nolint:errcheck // best-effort stderr + return 1 + } + if code := stopSupervisorWithWait(stdout, stderr, true, 30*time.Second); code != 0 { + return code + } + } _ = supervisorSystemctlRun("--user", "stop", service) _ = supervisorSystemctlRun("--user", "disable", service) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { diff --git a/cmd/gc/cmd_supervisor_test.go b/cmd/gc/cmd_supervisor_test.go index 51e5731991..31a1a41ba8 100644 --- a/cmd/gc/cmd_supervisor_test.go +++ b/cmd/gc/cmd_supervisor_test.go @@ -3,10 +3,12 @@ package main import ( "bufio" "bytes" + "context" "errors" "io" "net" "os" + "os/exec" "os/user" "path/filepath" goruntime "runtime" @@ -14,13 +16,16 @@ import ( "strconv" "strings" "sync" + "syscall" "testing" "time" + "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/supervisor" + "github.com/gastownhall/gascity/internal/workspacesvc" ) type closerSpy struct { @@ -32,6 +37,115 @@ func (c *closerSpy) Close() error { return nil } +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +type workspaceServiceSentinel struct { + pgid int +} + +func stubSupervisorRunningPreserveSignalReady(t *testing.T, ready bool) { + t.Helper() + old := supervisorRunningPreserveSignalReady + supervisorRunningPreserveSignalReady = func() (int, bool, error) { + return 4242, ready, nil + } + t.Cleanup(func() { + supervisorRunningPreserveSignalReady = old + }) +} + +func startWorkspaceServiceSentinel(t *testing.T, gcHome, cityPath, serviceName string) workspaceServiceSentinel { + t.Helper() + stateRoot := filepath.Join(cityPath, ".gc", "services", serviceName) + socketPath := filepath.Join(t.TempDir(), serviceName+".sock") + cmd := exec.Command("sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done") + cmd.Env = append(os.Environ(), + "GC_HOME="+gcHome, + "GC_CITY_PATH="+cityPath, + "GC_SERVICE_NAME="+serviceName, + "GC_SERVICE_STATE_ROOT="+stateRoot, + "GC_SERVICE_SOCKET="+socketPath, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatalf("Start workspace-service sentinel %q: %v", serviceName, err) + } + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + t.Fatalf("Getpgid(%d): %v", cmd.Process.Pid, err) + } + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + t.Cleanup(func() { + if processGroupAlive(pgid) { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + select { + case <-waitCh: + case <-time.After(time.Second): + t.Logf("workspace-service sentinel pgid %d did not exit before cleanup timeout", pgid) + } + }) + if !processGroupAlive(pgid) { + t.Fatalf("workspace-service sentinel pgid %d is not alive", pgid) + } + return workspaceServiceSentinel{pgid: pgid} +} + +func writeSupervisorProcEnv(t *testing.T, procRoot string, pid int, env map[string]string) { + t.Helper() + dir := filepath.Join(procRoot, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", dir, err) + } + var data []byte + for key, value := range env { + data = append(data, (key + "=" + value)...) + data = append(data, 0) + } + if err := os.WriteFile(filepath.Join(dir, "environ"), data, 0o644); err != nil { + t.Fatalf("WriteFile(environ): %v", err) + } +} + +func setSupervisorProcTestHooks(t *testing.T, procRoot string, getpgid func(int) (int, error)) { + t.Helper() + oldRoot := supervisorProcRoot + oldReadDir := supervisorProcReadDir + oldReadFile := supervisorProcReadFile + oldGetpgid := supervisorGetpgid + oldGetpgrp := supervisorGetpgrp + supervisorProcRoot = procRoot + supervisorProcReadDir = os.ReadDir + supervisorProcReadFile = os.ReadFile + supervisorGetpgid = getpgid + supervisorGetpgrp = func() int { return 4242 } + t.Cleanup(func() { + supervisorProcRoot = oldRoot + supervisorProcReadDir = oldReadDir + supervisorProcReadFile = oldReadFile + supervisorGetpgid = oldGetpgid + supervisorGetpgrp = oldGetpgrp + }) +} + func startTestSupervisorSocket(t *testing.T, sockPath string, handler func(string) string) { t.Helper() if err := os.MkdirAll(filepath.Dir(sockPath), 0o700); err != nil { @@ -228,6 +342,8 @@ func TestRenderSupervisorLaunchdTemplate(t *testing.T) { "sk-&<"'>", "OPENAI_API_KEY", "sk-openai-123", + "GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL", + "1", } { if !strings.Contains(content, check) { t.Fatalf("launchd template missing %q", check) @@ -235,6 +351,27 @@ func TestRenderSupervisorLaunchdTemplate(t *testing.T) { } } +func TestRenderSupervisorLaunchdTemplateUsesPreserveEnvFromData(t *testing.T) { + content, err := renderSupervisorTemplate(supervisorLaunchdTemplate, &supervisorServiceData{ + GCPath: "/usr/local/bin/gc", + LogPath: "/home/user/.gc/supervisor.log", + GCHome: "/home/user/.gc", + LaunchdLabel: defaultSupervisorLaunchdLabel, + Path: "/usr/local/bin:/usr/bin:/bin", + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL", + "1", + } { + if !strings.Contains(content, want) { + t.Fatalf("launchd template missing preserve env %q:\n%s", want, content) + } + } +} + func TestRenderSupervisorSystemdTemplate(t *testing.T) { data := &supervisorServiceData{ GCPath: "/usr/local/bin/gc", @@ -256,6 +393,8 @@ func TestRenderSupervisorSystemdTemplate(t *testing.T) { for _, check := range []string{ "[Service]", + `KillMode=process`, + `Environment=GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL="1"`, `ExecStart=/usr/local/bin/gc supervisor run`, `StandardOutput=append:/home/user/.gc/supervisor.log`, `Environment=GC_HOME="/home/user/.gc"`, @@ -268,6 +407,63 @@ func TestRenderSupervisorSystemdTemplate(t *testing.T) { t.Fatalf("systemd template missing %q", check) } } + wantBlock := "[Service]\nType=simple\n# Signal only the main supervisor PID on stop. The systemd default\n" + + "# (control-group) would cascade SIGTERM to tmux servers spawned by\n" + + "# 'gc supervisor run' that live in this cgroup, killing one-per-bead\n" + + "# session conversation history. The reconciler re-adopts tmux on start.\n" + + "KillMode=process\nExecStart=/usr/local/bin/gc supervisor run\n" + if !strings.Contains(content, wantBlock) { + t.Fatalf("systemd template missing ordered KillMode=process block under [Service]; got:\n%s", content) + } +} + +func TestRenderSupervisorSystemdTemplateUsesPreserveEnvFromData(t *testing.T) { + content, err := renderSupervisorTemplate(supervisorSystemdTemplate, &supervisorServiceData{ + GCPath: "/usr/local/bin/gc", + LogPath: "/home/user/.gc/supervisor.log", + GCHome: "/home/user/.gc", + Path: "/usr/local/bin:/usr/bin:/bin", + }) + if err != nil { + t.Fatal(err) + } + want := `Environment=GC_SUPERVISOR_PRESERVE_SESSIONS_ON_SIGNAL="1"` + if !strings.Contains(content, want) { + t.Fatalf("systemd template missing preserve env %q:\n%s", want, content) + } +} + +func TestBuildSupervisorServiceDataTreatsPreserveSignalEnvAsFixed(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", filepath.Join(homeDir, ".gc")) + t.Setenv("PATH", "/usr/local/bin:/usr/bin:/bin") + t.Setenv("GC_SUPERVISOR_ENV", supervisorPreserveSessionsOnSignalEnv) + t.Setenv(supervisorPreserveSessionsOnSignalEnv, "0") + + data, err := buildSupervisorServiceData() + if err != nil { + t.Fatalf("buildSupervisorServiceData: %v", err) + } + if got := supervisorServiceEnvMap(data.ExtraEnv); got[supervisorPreserveSessionsOnSignalEnv] != "" { + t.Fatalf("ExtraEnv[%s] = %q, want omitted fixed value (all env: %#v)", supervisorPreserveSessionsOnSignalEnv, got[supervisorPreserveSessionsOnSignalEnv], got) + } + + launchdContent, err := renderSupervisorTemplate(supervisorLaunchdTemplate, data) + if err != nil { + t.Fatal(err) + } + if count := strings.Count(launchdContent, supervisorPreserveSessionsOnSignalEnv); count != 1 { + t.Fatalf("launchd preserve env occurrences = %d, want 1:\n%s", count, launchdContent) + } + + systemdContent, err := renderSupervisorTemplate(supervisorSystemdTemplate, data) + if err != nil { + t.Fatal(err) + } + if count := strings.Count(systemdContent, supervisorPreserveSessionsOnSignalEnv); count != 1 { + t.Fatalf("systemd preserve env occurrences = %d, want 1:\n%s", count, systemdContent) + } } func TestBuildSupervisorServiceDataIncludesProviderEnv(t *testing.T) { @@ -569,6 +765,37 @@ func TestSupervisorServiceSuffixDoesNotFallBackWhenBasenameSanitizesEmpty(t *tes } } +func TestLaunchdPrintReportsRunningAnchorsStateLine(t *testing.T) { + tests := []struct { + name string + out string + want bool + }{ + { + name: "top-level running state", + out: "gui/501/com.gascity.supervisor = {\n\tstate = running\n\tprogram = /usr/local/bin/gc\n}\n", + want: true, + }, + { + name: "stopped state with nested running text", + out: "gui/501/com.gascity.supervisor = {\n\tstate = waiting\n\tlast exit code = 0\n\tpath = /tmp/state = running.log\n}\n", + want: false, + }, + { + name: "running suffix is not a state token", + out: "gui/501/com.gascity.supervisor = {\n\tstate = running-old\n}\n", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := launchdPrintReportsRunning([]byte(tt.out)); got != tt.want { + t.Fatalf("launchdPrintReportsRunning() = %v, want %v for output:\n%s", got, tt.want, tt.out) + } + }) + } +} + func TestSupervisorInstallUnsupportedOS(t *testing.T) { if goruntime.GOOS == "darwin" || goruntime.GOOS == "linux" { t.Skip("unsupported-os test only applies outside darwin/linux") @@ -582,7 +809,7 @@ func TestSupervisorInstallUnsupportedOS(t *testing.T) { } } -func TestInstallSupervisorSystemdRestartsWhenUnitChangesAndServiceActive(t *testing.T) { +func TestInstallSupervisorSystemdWarmRefreshGracefullySignalsMainPIDWhenUnitChangesAndServiceActive(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } @@ -609,12 +836,22 @@ func TestInstallSupervisorSystemdRestartsWhenUnitChangesAndServiceActive(t *test oldActive := supervisorSystemctlActive var calls []string supervisorSystemctlRun = func(args ...string) error { - calls = append(calls, strings.Join(args, " ")) + call := strings.Join(args, " ") + calls = append(calls, call) return nil } supervisorSystemctlActive = func(service string) bool { - return service == "gascity-supervisor.service" + if service != "gascity-supervisor.service" { + return false + } + for _, call := range calls { + if call == "--user kill --kill-who=main --signal=SIGTERM "+service { + return false + } + } + return true } + stubSupervisorRunningPreserveSignalReady(t, true) t.Cleanup(func() { supervisorSystemctlRun = oldRun supervisorSystemctlActive = oldActive @@ -628,14 +865,19 @@ func TestInstallSupervisorSystemdRestartsWhenUnitChangesAndServiceActive(t *test for _, want := range []string{ "--user daemon-reload", "--user enable gascity-supervisor.service", - "--user restart gascity-supervisor.service", + "--user kill --kill-who=main --signal=SIGTERM gascity-supervisor.service", + "--user reset-failed gascity-supervisor.service", + "--user start gascity-supervisor.service", } { if !strings.Contains(joined, want) { t.Fatalf("systemctl calls = %v, want %q", calls, want) } } - if strings.Contains(joined, "--user start gascity-supervisor.service") { - t.Fatalf("systemctl calls = %v, should restart instead of start when unit changes under an active service", calls) + if strings.Contains(joined, "--user restart gascity-supervisor.service") { + t.Fatalf("systemctl calls = %v, should signal the old main PID before starting the refreshed unit", calls) + } + if strings.Contains(joined, "--signal=SIGKILL") { + t.Fatalf("systemctl calls = %v, should not hard-kill after graceful warm-refresh stop succeeds", calls) } info, err := os.Stat(path) if err != nil { @@ -646,7 +888,7 @@ func TestInstallSupervisorSystemdRestartsWhenUnitChangesAndServiceActive(t *test } } -func TestInstallSupervisorSystemdWritesPrivateUnitFile(t *testing.T) { +func TestInstallSupervisorSystemdWarmRefreshRefusesActivePrePreserveSupervisor(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } @@ -655,42 +897,59 @@ func TestInstallSupervisorSystemdWritesPrivateUnitFile(t *testing.T) { t.Setenv("GC_HOME", filepath.Join(homeDir, ".gc")) data := &supervisorServiceData{ - GCPath: "/tmp/gc-new", - LogPath: "/tmp/gc-home/supervisor.log", - GCHome: "/tmp/gc-home", - Path: "/usr/local/bin:/usr/bin:/bin", - ExtraEnv: []supervisorServiceEnvVar{ - {Name: "OPENAI_API_KEY", Value: "sk-openai-123"}, - }, + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: "/tmp/gc-home", + XDGRuntimeDir: "/tmp/gc-run", + Path: "/usr/local/bin:/usr/bin:/bin", + } + path := supervisorSystemdServicePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + previous := []byte("old unit\n") + if err := os.WriteFile(path, previous, 0o644); err != nil { + t.Fatal(err) } oldRun := supervisorSystemctlRun oldActive := supervisorSystemctlActive - supervisorSystemctlRun = func(_ ...string) error { + var calls []string + supervisorSystemctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) return nil } - supervisorSystemctlActive = func(_ string) bool { - return false + supervisorSystemctlActive = func(service string) bool { + return service == "gascity-supervisor.service" } + stubSupervisorRunningPreserveSignalReady(t, false) t.Cleanup(func() { supervisorSystemctlRun = oldRun supervisorSystemctlActive = oldActive }) var stdout, stderr bytes.Buffer - if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { - t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 1 { + t.Fatalf("installSupervisorSystemd code = %d, want 1; stderr=%q", code, stderr.String()) } - info, err := os.Stat(supervisorSystemdServicePath()) + if len(calls) != 0 { + t.Fatalf("systemctl calls = %v, want none before preserve-mode migration guard passes", calls) + } + gotContent, err := os.ReadFile(path) if err != nil { - t.Fatalf("Stat(%q): %v", supervisorSystemdServicePath(), err) + t.Fatalf("ReadFile(%q): %v", path, err) } - if got := info.Mode().Perm(); got != 0o600 { - t.Fatalf("systemd unit mode = %03o, want 600", got) + if !bytes.Equal(gotContent, previous) { + t.Fatalf("unit content changed despite guarded warm refresh: got %q want %q", gotContent, previous) + } + for _, want := range []string{"does not have " + supervisorPreserveSessionsOnSignalEnv, "gc supervisor stop --wait"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } } } -func TestInstallSupervisorSystemdStartsInactiveService(t *testing.T) { +func TestInstallSupervisorSystemdWarmRefreshFallsBackToKillWhenGracefulSignalDoesNotStop(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } @@ -705,20 +964,33 @@ func TestInstallSupervisorSystemdStartsInactiveService(t *testing.T) { XDGRuntimeDir: "/tmp/gc-run", Path: "/usr/local/bin:/usr/bin:/bin", } + path := supervisorSystemdServicePath() + service := supervisorSystemdServiceName() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("old unit\n"), 0o644); err != nil { + t.Fatal(err) + } oldRun := supervisorSystemctlRun oldActive := supervisorSystemctlActive + oldTimeout := supervisorSystemdWarmRefreshStopTimeout + oldPoll := supervisorSystemdWarmRefreshPollInterval var calls []string supervisorSystemctlRun = func(args ...string) error { calls = append(calls, strings.Join(args, " ")) return nil } - supervisorSystemctlActive = func(_ string) bool { - return false - } + supervisorSystemctlActive = func(string) bool { return true } + stubSupervisorRunningPreserveSignalReady(t, true) + supervisorSystemdWarmRefreshStopTimeout = time.Millisecond + supervisorSystemdWarmRefreshPollInterval = time.Millisecond t.Cleanup(func() { supervisorSystemctlRun = oldRun supervisorSystemctlActive = oldActive + supervisorSystemdWarmRefreshStopTimeout = oldTimeout + supervisorSystemdWarmRefreshPollInterval = oldPoll }) var stdout, stderr bytes.Buffer @@ -726,41 +998,123 @@ func TestInstallSupervisorSystemdStartsInactiveService(t *testing.T) { t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) } joined := strings.Join(calls, "\n") - if !strings.Contains(joined, "--user start gascity-supervisor.service") { - t.Fatalf("systemctl calls = %v, want start for inactive service", calls) - } - if strings.Contains(joined, "--user restart gascity-supervisor.service") { - t.Fatalf("systemctl calls = %v, should not restart inactive service", calls) + for _, want := range []string{ + "--user kill --kill-who=main --signal=SIGTERM " + service, + "--user kill --kill-who=main --signal=SIGKILL " + service, + "--user reset-failed " + service, + "--user start " + service, + } { + if !strings.Contains(joined, want) { + t.Fatalf("systemctl calls = %v, want %q", calls, want) + } } } -func TestInstallSupervisorSystemdUsesIsolatedUnitNameForIsolatedGCHome(t *testing.T) { +func TestInstallSupervisorSystemdWarmRefreshStopsWorkspaceServicesBeforeStart(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "gc-home") t.Setenv("HOME", homeDir) - isolatedHome := filepath.Join(t.TempDir(), "isolated-home") - t.Setenv("GC_HOME", isolatedHome) + t.Setenv("GC_HOME", gcHome) data := &supervisorServiceData{ GCPath: "/tmp/gc-new", - LogPath: filepath.Join(isolatedHome, "supervisor.log"), - GCHome: isolatedHome, - XDGRuntimeDir: "", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: gcHome, + XDGRuntimeDir: "/tmp/gc-run", Path: "/usr/local/bin:/usr/bin:/bin", } + path := supervisorSystemdServicePath() + unitName := supervisorSystemdServiceName() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("old unit\n"), 0o644); err != nil { + t.Fatal(err) + } + + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(cityPath, "bright-lights"); err != nil { + t.Fatalf("Register(%q): %v", cityPath, err) + } + stateRoot := filepath.Join(cityPath, ".gc", "services", "bridge") + socketPath := filepath.Join(t.TempDir(), "bridge.sock") + cmd := exec.Command("sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done") + cmd.Env = append(os.Environ(), + "GC_HOME="+gcHome, + "GC_CITY_PATH="+cityPath, + "GC_SERVICE_NAME=bridge", + "GC_SERVICE_STATE_ROOT="+stateRoot, + "GC_SERVICE_SOCKET="+socketPath, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatalf("Start workspace-service sentinel: %v", err) + } + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + t.Fatalf("Getpgid(%d): %v", cmd.Process.Pid, err) + } + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + t.Cleanup(func() { + if processGroupAlive(pgid) { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + select { + case <-waitCh: + case <-time.After(time.Second): + t.Logf("workspace-service sentinel pgid %d did not exit before cleanup timeout", pgid) + } + }) + if !processGroupAlive(pgid) { + t.Fatalf("workspace-service sentinel pgid %d is not alive before warm refresh", pgid) + } + scope, err := supervisorWorkspaceServiceCleanupScopeFromRegistry(gcHome) + if err != nil { + t.Fatalf("supervisorWorkspaceServiceCleanupScopeFromRegistry: %v", err) + } + procs, err := findSupervisorWorkspaceServiceProcesses(scope) + if err != nil { + t.Fatalf("findSupervisorWorkspaceServiceProcesses: %v", err) + } + if !slices.ContainsFunc(procs, func(proc supervisorWorkspaceServiceProcess) bool { return proc.pgid == pgid }) { + t.Fatalf("workspace-service discovery procs = %#v, want pgid %d", procs, pgid) + } oldRun := supervisorSystemctlRun oldActive := supervisorSystemctlActive - var calls []string + var ( + calls []string + startBeforeCleanup bool + ) supervisorSystemctlRun = func(args ...string) error { - calls = append(calls, strings.Join(args, " ")) + call := strings.Join(args, " ") + calls = append(calls, call) + if call == "--user start "+unitName && processGroupAlive(pgid) { + startBeforeCleanup = true + } return nil } - supervisorSystemctlActive = func(_ string) bool { - return false + supervisorSystemctlActive = func(service string) bool { + if service != unitName { + return false + } + for _, call := range calls { + if call == "--user kill --kill-who=main --signal=SIGTERM "+unitName { + return false + } + } + return true } + stubSupervisorRunningPreserveSignalReady(t, true) t.Cleanup(func() { supervisorSystemctlRun = oldRun supervisorSystemctlActive = oldActive @@ -770,62 +1124,653 @@ func TestInstallSupervisorSystemdUsesIsolatedUnitNameForIsolatedGCHome(t *testin if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) } - - wantName := supervisorSystemdServiceName() - if wantName == defaultSupervisorSystemdUnit { - t.Fatalf("supervisorSystemdServiceName() = %q, want isolated unit name", wantName) - } - if !strings.HasPrefix(wantName, "gascity-supervisor-isolated-home-") { - t.Fatalf("supervisorSystemdServiceName() = %q, want isolated-home-prefixed name", wantName) - } - wantPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", wantName) - if _, err := os.Stat(wantPath); err != nil { - t.Fatalf("Stat(%q): %v", wantPath, err) - } - defaultPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", "gascity-supervisor.service") - if _, err := os.Stat(defaultPath); !os.IsNotExist(err) { - t.Fatalf("default systemd unit %q should stay absent; err=%v", defaultPath, err) - } - - joined := strings.Join(calls, "\n") - for _, want := range []string{ - "--user enable " + wantName, - "--user start " + wantName, - } { - if !strings.Contains(joined, want) { - t.Fatalf("systemctl calls = %v, want %q", calls, want) - } + if startBeforeCleanup { + t.Fatalf("systemctl start ran before workspace-service pgid %d was stopped; calls=%v", pgid, calls) } - if strings.Contains(joined, "gascity-supervisor.service") { - t.Fatalf("systemctl calls = %v, should not target the default unit when GC_HOME is isolated", calls) + if err := waitForProcessGroupExit(pgid, time.Second); err != nil { + t.Fatalf("workspace-service cleanup: %v", err) } } -func TestUnloadSupervisorServiceSkipsDefaultUnitForIsolatedGCHome(t *testing.T) { +func TestInstallSupervisorSystemdWarmRefreshLeavesUnregisteredWorkspaceServices(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "gc-home") t.Setenv("HOME", homeDir) - t.Setenv("GC_HOME", filepath.Join(t.TempDir(), "isolated-home")) - logFile := installFakeSystemctl(t) + t.Setenv("GC_HOME", gcHome) - defaultPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", "gascity-supervisor.service") - if err := os.MkdirAll(filepath.Dir(defaultPath), 0o755); err != nil { + registeredCity := filepath.Join(t.TempDir(), "registered-city") + unregisteredCity := filepath.Join(t.TempDir(), "unregistered-city") + if err := os.MkdirAll(registeredCity, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(defaultPath, []byte("[Unit]\nDescription=test\n"), 0o644); err != nil { + if err := os.MkdirAll(unregisteredCity, 0o755); err != nil { t.Fatal(err) } - - unloadSupervisorService() - - if got := strings.TrimSpace(readCommandLog(t, logFile)); got != "" { - t.Fatalf("unloadSupervisorService invoked systemctl for default unit under isolated GC_HOME: %q", got) + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(registeredCity, "registered-city"); err != nil { + t.Fatalf("Register(%q): %v", registeredCity, err) } -} -func TestUnloadSupervisorServiceUsesIsolatedUnitWhenPresent(t *testing.T) { + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: gcHome, + XDGRuntimeDir: "/tmp/gc-run", + Path: "/usr/local/bin:/usr/bin:/bin", + } + path := supervisorSystemdServicePath() + unitName := supervisorSystemdServiceName() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("old unit\n"), 0o644); err != nil { + t.Fatal(err) + } + + registered := startWorkspaceServiceSentinel(t, gcHome, registeredCity, "bridge") + unregistered := startWorkspaceServiceSentinel(t, gcHome, unregisteredCity, "other-bridge") + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + supervisorSystemctlRun = func(_ ...string) error { return nil } + supervisorSystemctlActive = func(service string) bool { + return service == unitName + } + stubSupervisorRunningPreserveSignalReady(t, true) + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { + t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + } + if err := waitForProcessGroupExit(registered.pgid, time.Second); err != nil { + t.Fatalf("registered workspace-service cleanup: %v", err) + } + if !processGroupAlive(unregistered.pgid) { + t.Fatalf("unregistered workspace-service pgid %d was stopped by warm-refresh cleanup", unregistered.pgid) + } +} + +func TestCleanupSupervisorWorkspaceServicesForSupervisorStartSkipsMissingProc(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "gc-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(cityPath, "bright-lights"); err != nil { + t.Fatalf("Register(%q): %v", cityPath, err) + } + + oldRoot := supervisorProcRoot + oldReadDir := supervisorProcReadDir + supervisorProcRoot = filepath.Join(t.TempDir(), "missing-proc") + supervisorProcReadDir = os.ReadDir + t.Cleanup(func() { + supervisorProcRoot = oldRoot + supervisorProcReadDir = oldReadDir + }) + + if err := cleanupSupervisorWorkspaceServicesForSupervisorStart(gcHome); err != nil { + t.Fatalf("cleanupSupervisorWorkspaceServicesForSupervisorStart: %v", err) + } +} + +func TestCleanupSupervisorWorkspaceServicesForSupervisorStartWarnsWhenProcCleanupUnsupported(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "gc-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(cityPath, "bright-lights"); err != nil { + t.Fatalf("Register(%q): %v", cityPath, err) + } + + oldGOOS := supervisorRuntimeGOOS + oldWarnings := supervisorWorkspaceServiceCleanupWarnings + var warnings bytes.Buffer + supervisorRuntimeGOOS = "darwin" + supervisorWorkspaceServiceCleanupWarnings = &warnings + t.Cleanup(func() { + supervisorRuntimeGOOS = oldGOOS + supervisorWorkspaceServiceCleanupWarnings = oldWarnings + }) + + if err := cleanupSupervisorWorkspaceServicesForSupervisorStart(gcHome); err != nil { + t.Fatalf("cleanupSupervisorWorkspaceServicesForSupervisorStart: %v", err) + } + if got := warnings.String(); !strings.Contains(got, "workspace-service startup cleanup is not available on darwin") || + !strings.Contains(got, citylayout.RuntimeServicesDir(cityPath)) || + !strings.Contains(got, "GC_SERVICE_STATE_ROOT") { + t.Fatalf("cleanup warning = %q, want macOS operator guidance", got) + } +} + +func TestFindSupervisorWorkspaceServiceProcessesFiltersOwnershipAndRequiredEnv(t *testing.T) { + gcHome := filepath.Join(t.TempDir(), "gc-home") + otherHome := filepath.Join(t.TempDir(), "other-home") + cityPath := filepath.Join(t.TempDir(), "city") + otherCity := filepath.Join(t.TempDir(), "other-city") + serviceRoot := filepath.Join(cityPath, ".gc", "services", "bridge") + procRoot := t.TempDir() + baseEnv := map[string]string{ + "GC_HOME": gcHome, + "GC_CITY_PATH": cityPath, + "GC_SERVICE_NAME": "bridge", + "GC_SERVICE_STATE_ROOT": serviceRoot, + "GC_SERVICE_SOCKET": filepath.Join(t.TempDir(), "bridge.sock"), + "GC_CITY_RUNTIME_DIR": filepath.Join(cityPath, ".gc", "runtime"), + "GC_SERVICE_RUN_ROOT": filepath.Join(serviceRoot, "run"), + "GC_SERVICE_URL_PREFIX": "/svc/bridge", + "GC_SERVICE_VISIBILITY": "private", + "GC_PUBLISHED_SERVICES": filepath.Join(cityPath, ".gc", "services", ".published"), + "GC_PUBLISHED_SERVICES_DIR": filepath.Join(cityPath, ".gc", "services", ".published"), + } + writeSupervisorProcEnv(t, procRoot, 101, baseEnv) + missingSocket := map[string]string{} + for k, v := range baseEnv { + missingSocket[k] = v + } + delete(missingSocket, "GC_SERVICE_SOCKET") + writeSupervisorProcEnv(t, procRoot, 102, missingSocket) + otherHomeEnv := map[string]string{} + for k, v := range baseEnv { + otherHomeEnv[k] = v + } + otherHomeEnv["GC_HOME"] = otherHome + writeSupervisorProcEnv(t, procRoot, 103, otherHomeEnv) + otherCityEnv := map[string]string{} + for k, v := range baseEnv { + otherCityEnv[k] = v + } + otherCityEnv["GC_CITY_PATH"] = otherCity + otherCityEnv["GC_SERVICE_STATE_ROOT"] = filepath.Join(otherCity, ".gc", "services", "bridge") + writeSupervisorProcEnv(t, procRoot, 104, otherCityEnv) + outsideStateEnv := map[string]string{} + for k, v := range baseEnv { + outsideStateEnv[k] = v + } + outsideStateEnv["GC_SERVICE_STATE_ROOT"] = filepath.Join(cityPath, ".gc", "not-services", "bridge") + writeSupervisorProcEnv(t, procRoot, 105, outsideStateEnv) + + setSupervisorProcTestHooks(t, procRoot, func(pid int) (int, error) { + return pid + 1000, nil + }) + scope := supervisorWorkspaceServiceCleanupScope{ + gcHome: normalizePathForCompare(gcHome), + cityPaths: map[string]string{ + normalizePathForCompare(cityPath): normalizePathForCompare(cityPath), + }, + } + procs, err := findSupervisorWorkspaceServiceProcesses(scope) + if err != nil { + t.Fatalf("findSupervisorWorkspaceServiceProcesses: %v", err) + } + if len(procs) != 1 || procs[0].pid != 101 || procs[0].pgid != 1101 { + t.Fatalf("procs = %#v, want only owned pid 101", procs) + } +} + +func TestFindSupervisorWorkspaceServiceProcessesSkipsUnsafeAndVanished(t *testing.T) { + gcHome := filepath.Join(t.TempDir(), "gc-home") + cityPath := filepath.Join(t.TempDir(), "city") + procRoot := t.TempDir() + for _, pid := range []int{201, 202, 203, 204} { + writeSupervisorProcEnv(t, procRoot, pid, map[string]string{ + "GC_HOME": gcHome, + "GC_CITY_PATH": cityPath, + "GC_SERVICE_NAME": "bridge", + "GC_SERVICE_STATE_ROOT": filepath.Join(cityPath, ".gc", "services", "bridge"), + "GC_SERVICE_SOCKET": filepath.Join(t.TempDir(), "bridge.sock"), + }) + } + setSupervisorProcTestHooks(t, procRoot, func(pid int) (int, error) { + switch pid { + case 201: + return 0, syscall.ESRCH + case 202: + return 1, nil + case 203: + return 4242, nil + case 204: + return 5204, nil + default: + return pid + 1000, nil + } + }) + scope := supervisorWorkspaceServiceCleanupScope{ + gcHome: normalizePathForCompare(gcHome), + cityPaths: map[string]string{ + normalizePathForCompare(cityPath): normalizePathForCompare(cityPath), + }, + } + oldWarnings := supervisorWorkspaceServiceCleanupWarnings + var warnings bytes.Buffer + supervisorWorkspaceServiceCleanupWarnings = &warnings + t.Cleanup(func() { + supervisorWorkspaceServiceCleanupWarnings = oldWarnings + }) + + procs, err := findSupervisorWorkspaceServiceProcesses(scope) + if err != nil { + t.Fatalf("findSupervisorWorkspaceServiceProcesses: %v", err) + } + if len(procs) != 1 || procs[0].pid != 204 || procs[0].pgid != 5204 { + t.Fatalf("procs = %#v, want only safe pid 204", procs) + } + if got := warnings.String(); !strings.Contains(got, "unsafe process group 1") || !strings.Contains(got, "unsafe process group 4242") { + t.Fatalf("warnings = %q, want unsafe process group diagnostics", got) + } +} + +func TestTerminateProcessGroupTreatsESRCHAsAlreadyStopped(t *testing.T) { + oldKill := supervisorKill + oldPoll := supervisorProcessGroupPollPeriod + supervisorKill = func(_ int, _ syscall.Signal) error { + return syscall.ESRCH + } + supervisorProcessGroupPollPeriod = time.Millisecond + t.Cleanup(func() { + supervisorKill = oldKill + supervisorProcessGroupPollPeriod = oldPoll + }) + + if err := terminateProcessGroup(999999, time.Millisecond); err != nil { + t.Fatalf("terminateProcessGroup ESRCH = %v, want nil", err) + } +} + +func TestTerminateProcessGroupRefusesCurrentProcessGroup(t *testing.T) { + if err := terminateProcessGroup(syscall.Getpgrp(), time.Millisecond); err == nil { + t.Fatal("terminateProcessGroup current process group error = nil, want refusal") + } +} + +func TestInstallSupervisorSystemdWarmRefreshPreservesNewUnitWhenStartFails(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", filepath.Join(homeDir, ".gc")) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: "/tmp/gc-home", + XDGRuntimeDir: "/tmp/gc-run", + Path: "/usr/local/bin:/usr/bin:/bin", + } + path := supervisorSystemdServicePath() + unitName := supervisorSystemdServiceName() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + previous := []byte("old unit\n") + if err := os.WriteFile(path, previous, 0o644); err != nil { + t.Fatal(err) + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + var ( + calls []string + startCalls int + ) + supervisorSystemctlRun = func(args ...string) error { + call := strings.Join(args, " ") + calls = append(calls, call) + if call == "--user start "+unitName { + startCalls++ + if startCalls == 1 { + return errors.New("start failed") + } + } + return nil + } + supervisorSystemctlActive = func(service string) bool { + if service != unitName { + return false + } + for _, call := range calls { + if call == "--user kill --kill-who=main --signal=SIGTERM "+unitName { + return false + } + } + return true + } + stubSupervisorRunningPreserveSignalReady(t, true) + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 1 { + t.Fatalf("installSupervisorSystemd code = %d, want 1; stderr=%q", code, stderr.String()) + } + gotContent, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + if bytes.Equal(gotContent, previous) || !bytes.Contains(gotContent, []byte("KillMode=process")) { + t.Fatalf("unit after failed warm refresh = %q, want refreshed unit with KillMode=process", gotContent) + } + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "--user kill --kill-who=main --signal=SIGTERM " + unitName, + "--user reset-failed " + unitName, + "--user start " + unitName, + } { + if !strings.Contains(joined, want) { + t.Fatalf("systemctl calls = %v, want %q", calls, want) + } + } + if strings.Contains(joined, "--user stop "+unitName) { + t.Fatalf("systemctl calls = %v, should not stop and restart a previous unit after failed warm refresh", calls) + } + if startCalls != 1 { + t.Fatalf("systemctl start calls = %d, want only failed refresh start; calls=%v", startCalls, calls) + } + if !strings.Contains(stderr.String(), "systemctl --user start "+unitName+": start failed") { + t.Fatalf("stderr = %q, want failed refresh start", stderr.String()) + } + if !strings.Contains(stderr.String(), "leaving refreshed systemd unit") { + t.Fatalf("stderr = %q, want refreshed-unit rollback guidance", stderr.String()) + } +} + +func TestInstallSupervisorSystemdWarmRefreshPreservesNewUnitWhenCleanupFails(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "gc-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: gcHome, + XDGRuntimeDir: "/tmp/gc-run", + Path: "/usr/local/bin:/usr/bin:/bin", + } + path := supervisorSystemdServicePath() + unitName := supervisorSystemdServiceName() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + previous := []byte("old unit\n") + if err := os.WriteFile(path, previous, 0o644); err != nil { + t.Fatal(err) + } + + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(cityPath, "bright-lights"); err != nil { + t.Fatalf("Register(%q): %v", cityPath, err) + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + oldReadDir := supervisorProcReadDir + var ( + calls []string + startCalls int + ) + supervisorSystemctlRun = func(args ...string) error { + call := strings.Join(args, " ") + calls = append(calls, call) + if call == "--user start "+unitName { + startCalls++ + } + return nil + } + supervisorSystemctlActive = func(service string) bool { + if service != unitName { + return false + } + for _, call := range calls { + if call == "--user kill --kill-who=main --signal=SIGTERM "+unitName { + return false + } + } + return true + } + stubSupervisorRunningPreserveSignalReady(t, true) + supervisorProcReadDir = func(string) ([]os.DirEntry, error) { + return nil, errors.New("proc scan failed") + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + supervisorProcReadDir = oldReadDir + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 1 { + t.Fatalf("installSupervisorSystemd code = %d, want 1; stderr=%q", code, stderr.String()) + } + gotContent, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + if bytes.Equal(gotContent, previous) || !bytes.Contains(gotContent, []byte("KillMode=process")) { + t.Fatalf("unit after failed cleanup = %q, want refreshed unit with KillMode=process", gotContent) + } + joined := strings.Join(calls, "\n") + if !strings.Contains(joined, "--user kill --kill-who=main --signal=SIGTERM "+unitName) { + t.Fatalf("systemctl calls = %v, want warm-refresh graceful signal", calls) + } + if strings.Contains(joined, "--user stop "+unitName) { + t.Fatalf("systemctl calls = %v, should not stop and restart a previous unit after failed cleanup", calls) + } + if startCalls != 0 { + t.Fatalf("systemctl start calls = %d, want no start after cleanup failure; calls=%v", startCalls, calls) + } + if !strings.Contains(stderr.String(), "workspace-service cleanup after systemctl --user kill") { + t.Fatalf("stderr = %q, want cleanup failure", stderr.String()) + } + if !strings.Contains(stderr.String(), "leaving refreshed systemd unit") { + t.Fatalf("stderr = %q, want refreshed-unit rollback guidance", stderr.String()) + } +} + +func TestInstallSupervisorSystemdWritesPrivateUnitFile(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", filepath.Join(homeDir, ".gc")) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: "/tmp/gc-home", + Path: "/usr/local/bin:/usr/bin:/bin", + ExtraEnv: []supervisorServiceEnvVar{ + {Name: "OPENAI_API_KEY", Value: "sk-openai-123"}, + }, + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + supervisorSystemctlRun = func(_ ...string) error { + return nil + } + supervisorSystemctlActive = func(_ string) bool { + return false + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { + t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + } + info, err := os.Stat(supervisorSystemdServicePath()) + if err != nil { + t.Fatalf("Stat(%q): %v", supervisorSystemdServicePath(), err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("systemd unit mode = %03o, want 600", got) + } +} + +func TestInstallSupervisorSystemdStartsInactiveService(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", filepath.Join(homeDir, ".gc")) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: "/tmp/gc-home/supervisor.log", + GCHome: "/tmp/gc-home", + XDGRuntimeDir: "/tmp/gc-run", + Path: "/usr/local/bin:/usr/bin:/bin", + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + var calls []string + supervisorSystemctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + supervisorSystemctlActive = func(_ string) bool { + return false + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { + t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + } + joined := strings.Join(calls, "\n") + if !strings.Contains(joined, "--user start gascity-supervisor.service") { + t.Fatalf("systemctl calls = %v, want start for inactive service", calls) + } + if strings.Contains(joined, "--user restart gascity-supervisor.service") { + t.Fatalf("systemctl calls = %v, should not restart inactive service", calls) + } +} + +func TestInstallSupervisorSystemdUsesIsolatedUnitNameForIsolatedGCHome(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + isolatedHome := filepath.Join(t.TempDir(), "isolated-home") + t.Setenv("GC_HOME", isolatedHome) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: filepath.Join(isolatedHome, "supervisor.log"), + GCHome: isolatedHome, + XDGRuntimeDir: "", + Path: "/usr/local/bin:/usr/bin:/bin", + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + var calls []string + supervisorSystemctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + supervisorSystemctlActive = func(_ string) bool { + return false + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorSystemd(data, &stdout, &stderr); code != 0 { + t.Fatalf("installSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + } + + wantName := supervisorSystemdServiceName() + if wantName == defaultSupervisorSystemdUnit { + t.Fatalf("supervisorSystemdServiceName() = %q, want isolated unit name", wantName) + } + if !strings.HasPrefix(wantName, "gascity-supervisor-isolated-home-") { + t.Fatalf("supervisorSystemdServiceName() = %q, want isolated-home-prefixed name", wantName) + } + wantPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", wantName) + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("Stat(%q): %v", wantPath, err) + } + defaultPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", "gascity-supervisor.service") + if _, err := os.Stat(defaultPath); !os.IsNotExist(err) { + t.Fatalf("default systemd unit %q should stay absent; err=%v", defaultPath, err) + } + + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "--user enable " + wantName, + "--user start " + wantName, + } { + if !strings.Contains(joined, want) { + t.Fatalf("systemctl calls = %v, want %q", calls, want) + } + } + if strings.Contains(joined, "gascity-supervisor.service") { + t.Fatalf("systemctl calls = %v, should not target the default unit when GC_HOME is isolated", calls) + } +} + +func TestUnloadSupervisorServiceSkipsDefaultUnitForIsolatedGCHome(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", filepath.Join(t.TempDir(), "isolated-home")) + logFile := installFakeSystemctl(t) + + defaultPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", "gascity-supervisor.service") + if err := os.MkdirAll(filepath.Dir(defaultPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(defaultPath, []byte("[Unit]\nDescription=test\n"), 0o644); err != nil { + t.Fatal(err) + } + + unloadSupervisorService() + + if got := strings.TrimSpace(readCommandLog(t, logFile)); got != "" { + t.Fatalf("unloadSupervisorService invoked systemctl for default unit under isolated GC_HOME: %q", got) + } +} + +func TestUnloadSupervisorServiceUsesIsolatedUnitWhenPresent(t *testing.T) { if goruntime.GOOS != "linux" { t.Skip("systemd path only applies on linux") } @@ -1406,10 +2351,144 @@ func TestUninstallSupervisorSystemdIgnoresLegacyStopDisableFailures(t *testing.T if code := uninstallSupervisorSystemd(&supervisorServiceData{}, &stdout, &stderr); code != 0 { t.Fatalf("uninstallSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) } - for _, path := range []string{currentPath, legacyPath} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("systemd unit %q should be removed despite legacy stop/disable failures; err=%v", path, err) - } + for _, path := range []string{currentPath, legacyPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("systemd unit %q should be removed despite legacy stop/disable failures; err=%v", path, err) + } + } +} + +func TestUninstallSupervisorSystemdRefusesActiveServiceWithoutControlSocket(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + gcHome := shortTempDir(t, "gc-home-") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + currentPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", supervisorSystemdServiceName()) + if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", filepath.Dir(currentPath), err) + } + if err := os.WriteFile(currentPath, []byte("current unit\n"), 0o600); err != nil { + t.Fatalf("WriteFile(%q): %v", currentPath, err) + } + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + var calls []string + supervisorSystemctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + supervisorSystemctlActive = func(service string) bool { + return service == supervisorSystemdServiceName() + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := uninstallSupervisorSystemd(&supervisorServiceData{}, &stdout, &stderr); code != 1 { + t.Fatalf("uninstallSupervisorSystemd code = %d, want 1; stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(currentPath); err != nil { + t.Fatalf("active systemd unit %q should remain after guarded uninstall; err=%v", currentPath, err) + } + if len(calls) != 0 { + t.Fatalf("systemctl calls = %v, want none when active service has no control socket", calls) + } + for _, want := range []string{"control socket is unavailable", "gc supervisor start"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } + } +} + +func TestUninstallSupervisorSystemdUsesControlSocketWhenServiceActive(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + homeDir := t.TempDir() + gcHome := shortTempDir(t, "gc-home-") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + currentPath := filepath.Join(homeDir, ".local", "share", "systemd", "user", supervisorSystemdServiceName()) + if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", filepath.Dir(currentPath), err) + } + if err := os.WriteFile(currentPath, []byte("current unit\n"), 0o600); err != nil { + t.Fatalf("WriteFile(%q): %v", currentPath, err) + } + + var ( + mu sync.Mutex + socketStopSeen bool + stopped bool + systemctlStopBeforeSocket bool + systemctlDisableCurrentHit bool + ) + sockPath := supervisorSocketPath() + startTestSupervisorSocket(t, sockPath, func(cmd string) string { + mu.Lock() + defer mu.Unlock() + switch cmd { + case "ping": + if stopped { + return "" + } + return "4242\n" + case "stop": + socketStopSeen = true + stopped = true + return "ok\ndone:ok\n" + } + return "" + }) + + oldRun := supervisorSystemctlRun + oldActive := supervisorSystemctlActive + supervisorSystemctlRun = func(args ...string) error { + mu.Lock() + defer mu.Unlock() + if len(args) >= 3 && args[1] == "stop" && args[2] == supervisorSystemdServiceName() && !socketStopSeen { + systemctlStopBeforeSocket = true + } + if len(args) >= 3 && args[1] == "disable" && args[2] == supervisorSystemdServiceName() { + systemctlDisableCurrentHit = true + } + return nil + } + supervisorSystemctlActive = func(service string) bool { + return service == supervisorSystemdServiceName() + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + supervisorSystemctlActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := uninstallSupervisorSystemd(&supervisorServiceData{}, &stdout, &stderr); code != 0 { + t.Fatalf("uninstallSupervisorSystemd code = %d, want 0; stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(currentPath); !os.IsNotExist(err) { + t.Fatalf("systemd unit %q should be removed; err=%v", currentPath, err) + } + mu.Lock() + defer mu.Unlock() + if systemctlStopBeforeSocket { + t.Fatal("uninstall stopped the systemd unit before requesting destructive socket shutdown") + } + if !socketStopSeen { + t.Fatal("uninstall did not request shutdown through the supervisor control socket") + } + if !systemctlDisableCurrentHit { + t.Fatal("uninstall did not disable the current systemd unit") } } @@ -1515,6 +2594,54 @@ func TestInstallSupervisorLaunchdWritesPrivatePlist(t *testing.T) { } } +func TestInstallSupervisorLaunchdEnablesAndKickstartsLoadedService(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "isolated-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + label := supervisorLaunchdLabel() + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + LaunchdLabel: label, + Path: "/usr/local/bin:/usr/bin:/bin", + } + + oldRun := supervisorLaunchctlRun + var calls []string + supervisorLaunchctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + t.Cleanup(func() { + supervisorLaunchctlRun = oldRun + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorLaunchd(data, &stdout, &stderr); code != 0 { + t.Fatalf("installSupervisorLaunchd code = %d, want 0; stderr=%q", code, stderr.String()) + } + + path := supervisorLaunchdPlistPath() + target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + label + wantSequence := []string{ + "unload " + path, + "load " + path, + "enable " + target, + "kickstart -p " + target, + } + last := -1 + for _, want := range wantSequence { + idx := slices.Index(calls[last+1:], want) + if idx < 0 { + t.Fatalf("launchctl calls = %v, want %q after index %d", calls, want, last) + } + last += idx + 1 + } +} + func TestInstallSupervisorLaunchdIgnoresLegacyUnloadFailures(t *testing.T) { homeDir := t.TempDir() gcHome := filepath.Join(t.TempDir(), "isolated-home") @@ -1630,6 +2757,162 @@ func TestInstallSupervisorLaunchdKeepsLegacyPlistWhenNewServiceFails(t *testing. "unload " + legacyPath, "load " + currentPath, "load " + legacyPath, + "enable gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel, + "kickstart -p gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel, + } { + if !strings.Contains(joined, want) { + t.Fatalf("launchctl calls = %v, want %q", calls, want) + } + } +} + +func TestInstallSupervisorLaunchdRestoresLegacyPlistWhenEnableFails(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "isolated-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + legacyPath := legacySupervisorLaunchdPlistPath() + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatal(err) + } + legacyContent, err := renderSupervisorTemplate(supervisorLaunchdTemplate, &supervisorServiceData{ + GCPath: "/tmp/gc-legacy", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + LaunchdLabel: defaultSupervisorLaunchdLabel, + Path: "/usr/local/bin:/usr/bin:/bin", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPath, []byte(legacyContent), 0o644); err != nil { + t.Fatal(err) + } + + label := supervisorLaunchdLabel() + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + XDGRuntimeDir: "", + LaunchdLabel: label, + Path: "/usr/local/bin:/usr/bin:/bin", + } + + currentPath := filepath.Join(homeDir, "Library", "LaunchAgents", label+".plist") + currentTarget := "gui/" + strconv.Itoa(os.Getuid()) + "/" + label + legacyTarget := "gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel + oldRun := supervisorLaunchctlRun + var calls []string + supervisorLaunchctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + if len(args) == 2 && args[0] == "enable" && args[1] == currentTarget { + return errors.New("new plist failed to enable") + } + if len(args) == 3 && args[0] == "kickstart" && args[2] == legacyTarget { + return errors.New("legacy plist failed to restart") + } + return nil + } + t.Cleanup(func() { + supervisorLaunchctlRun = oldRun + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorLaunchd(data, &stdout, &stderr); code != 1 { + t.Fatalf("installSupervisorLaunchd code = %d, want 1; stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(currentPath); !os.IsNotExist(err) { + t.Fatalf("new launchd plist %q should be removed during rollback; err=%v", currentPath, err) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy launchd plist %q should remain after failed install; err=%v", legacyPath, err) + } + if strings.Contains(stderr.String(), "rollback after launchctl failure") { + t.Fatalf("stderr = %q, want rollback restart failure to be warning-only", stderr.String()) + } + if !strings.Contains(stderr.String(), "warning: restoring launchd service: kickstart -p "+legacyTarget) { + t.Fatalf("stderr = %q, want warning for best-effort legacy restart", stderr.String()) + } + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "enable " + currentTarget, + "load " + legacyPath, + "enable " + legacyTarget, + "kickstart -p " + legacyTarget, + } { + if !strings.Contains(joined, want) { + t.Fatalf("launchctl calls = %v, want %q", calls, want) + } + } +} + +func TestInstallSupervisorLaunchdRestoresLegacyPlistWhenKickstartFails(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "isolated-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + legacyPath := legacySupervisorLaunchdPlistPath() + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatal(err) + } + legacyContent, err := renderSupervisorTemplate(supervisorLaunchdTemplate, &supervisorServiceData{ + GCPath: "/tmp/gc-legacy", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + LaunchdLabel: defaultSupervisorLaunchdLabel, + Path: "/usr/local/bin:/usr/bin:/bin", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPath, []byte(legacyContent), 0o644); err != nil { + t.Fatal(err) + } + + label := supervisorLaunchdLabel() + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + XDGRuntimeDir: "", + LaunchdLabel: label, + Path: "/usr/local/bin:/usr/bin:/bin", + } + + currentPath := filepath.Join(homeDir, "Library", "LaunchAgents", label+".plist") + currentTarget := "gui/" + strconv.Itoa(os.Getuid()) + "/" + label + oldRun := supervisorLaunchctlRun + var calls []string + supervisorLaunchctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + if len(args) == 3 && args[0] == "kickstart" && args[2] == currentTarget { + return errors.New("new plist failed to start") + } + return nil + } + t.Cleanup(func() { + supervisorLaunchctlRun = oldRun + }) + + var stdout, stderr bytes.Buffer + if code := installSupervisorLaunchd(data, &stdout, &stderr); code != 1 { + t.Fatalf("installSupervisorLaunchd code = %d, want 1; stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(currentPath); !os.IsNotExist(err) { + t.Fatalf("new launchd plist %q should be removed during rollback; err=%v", currentPath, err) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy launchd plist %q should remain after failed install; err=%v", legacyPath, err) + } + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "kickstart -p " + currentTarget, + "load " + legacyPath, + "enable gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel, + "kickstart -p gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel, } { if !strings.Contains(joined, want) { t.Fatalf("launchctl calls = %v, want %q", calls, want) @@ -1661,6 +2944,8 @@ func TestInstallSupervisorLaunchdRestoresPreviousCurrentPlistWhenUpdateFails(t * Path: "/usr/local/bin:/usr/bin:/bin", } + label := supervisorLaunchdLabel() + target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + label oldRun := supervisorLaunchctlRun var calls []string loadCalls := 0 @@ -1699,6 +2984,17 @@ func TestInstallSupervisorLaunchdRestoresPreviousCurrentPlistWhenUpdateFails(t * if loadCalls != 2 { t.Fatalf("launchctl load call count = %d, want 2 (failed install + rollback restore); calls=%v", loadCalls, calls) } + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "unload " + currentPath, + "load " + currentPath, + "enable " + target, + "kickstart -p " + target, + } { + if !strings.Contains(joined, want) { + t.Fatalf("launchctl calls = %v, want %q", calls, want) + } + } } func TestUninstallSupervisorLaunchdRemovesMatchingLegacyDefaultPlistForIsolatedGCHome(t *testing.T) { @@ -1743,21 +3039,156 @@ func TestUninstallSupervisorLaunchdRemovesMatchingLegacyDefaultPlistForIsolatedG }) var stdout, stderr bytes.Buffer - if code := uninstallSupervisorLaunchd(&supervisorServiceData{}, &stdout, &stderr); code != 0 { - t.Fatalf("uninstallSupervisorLaunchd code = %d, want 0; stderr=%q", code, stderr.String()) + if code := uninstallSupervisorLaunchd(&supervisorServiceData{}, &stdout, &stderr); code != 0 { + t.Fatalf("uninstallSupervisorLaunchd code = %d, want 0; stderr=%q", code, stderr.String()) + } + for _, path := range []string{currentPath, legacyPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("launchd plist %q should be removed; err=%v", path, err) + } + } + joined := strings.Join(calls, "\n") + for _, want := range []string{ + "unload " + currentPath, + "disable gui/" + strconv.Itoa(os.Getuid()) + "/" + supervisorLaunchdLabel(), + "unload " + legacyPath, + "disable gui/" + strconv.Itoa(os.Getuid()) + "/" + defaultSupervisorLaunchdLabel, + } { + if !strings.Contains(joined, want) { + t.Fatalf("launchctl calls = %v, want %q", calls, want) + } + } +} + +func TestUninstallSupervisorLaunchdUsesControlSocketWhenSupervisorRunning(t *testing.T) { + homeDir := t.TempDir() + gcHome := shortTempDir(t, "gc-home-") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + currentPath := filepath.Join(homeDir, "Library", "LaunchAgents", supervisorLaunchdLabel()+".plist") + if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil { + t.Fatal(err) + } + content, err := renderSupervisorTemplate(supervisorLaunchdTemplate, &supervisorServiceData{ + GCPath: "/tmp/gc-current", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + LaunchdLabel: supervisorLaunchdLabel(), + Path: "/usr/local/bin:/usr/bin:/bin", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(currentPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + var ( + mu sync.Mutex + socketStopSeen bool + stopped bool + unloadBeforeSocket bool + launchdDisableSeen bool + ) + sockPath := supervisorSocketPath() + startTestSupervisorSocket(t, sockPath, func(cmd string) string { + mu.Lock() + defer mu.Unlock() + switch cmd { + case "ping": + if stopped { + return "" + } + return "4242\n" + case "stop": + socketStopSeen = true + stopped = true + return "ok\ndone:ok\n" + } + return "" + }) + + oldRun := supervisorLaunchctlRun + supervisorLaunchctlRun = func(args ...string) error { + mu.Lock() + defer mu.Unlock() + if len(args) == 2 && args[0] == "unload" && args[1] == currentPath && !socketStopSeen { + unloadBeforeSocket = true + } + if len(args) == 2 && args[0] == "disable" && args[1] == supervisorLaunchdServiceTarget(supervisorLaunchdLabel()) { + launchdDisableSeen = true + } + return nil + } + t.Cleanup(func() { + supervisorLaunchctlRun = oldRun + }) + + var stdout, stderr bytes.Buffer + if code := uninstallSupervisorLaunchd(&supervisorServiceData{}, &stdout, &stderr); code != 0 { + t.Fatalf("uninstallSupervisorLaunchd code = %d, want 0; stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(currentPath); !os.IsNotExist(err) { + t.Fatalf("launchd plist %q should be removed; err=%v", currentPath, err) + } + mu.Lock() + defer mu.Unlock() + if unloadBeforeSocket { + t.Fatal("launchd uninstall unloaded the service before requesting destructive socket shutdown") + } + if !socketStopSeen { + t.Fatal("launchd uninstall did not request shutdown through the supervisor control socket") + } + if !launchdDisableSeen { + t.Fatal("launchd uninstall did not disable the current launchd service") + } +} + +func TestUninstallSupervisorLaunchdRefusesActiveServiceWithoutControlSocket(t *testing.T) { + homeDir := t.TempDir() + gcHome := shortTempDir(t, "gc-home-") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + currentPath := filepath.Join(homeDir, "Library", "LaunchAgents", supervisorLaunchdLabel()+".plist") + if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(currentPath, []byte("current plist\n"), 0o600); err != nil { + t.Fatal(err) + } + + oldRun := supervisorLaunchctlRun + oldActive := supervisorLaunchdActive + var calls []string + supervisorLaunchctlRun = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + supervisorLaunchdActive = func(label string) bool { + return label == supervisorLaunchdLabel() + } + t.Cleanup(func() { + supervisorLaunchctlRun = oldRun + supervisorLaunchdActive = oldActive + }) + + var stdout, stderr bytes.Buffer + if code := uninstallSupervisorLaunchd(&supervisorServiceData{}, &stdout, &stderr); code != 1 { + t.Fatalf("uninstallSupervisorLaunchd code = %d, want 1; stderr=%q", code, stderr.String()) } - for _, path := range []string{currentPath, legacyPath} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("launchd plist %q should be removed; err=%v", path, err) - } + if _, err := os.Stat(currentPath); err != nil { + t.Fatalf("active launchd plist %q should remain after guarded uninstall; err=%v", currentPath, err) } - joined := strings.Join(calls, "\n") - for _, want := range []string{ - "unload " + currentPath, - "unload " + legacyPath, - } { - if !strings.Contains(joined, want) { - t.Fatalf("launchctl calls = %v, want %q", calls, want) + if len(calls) != 0 { + t.Fatalf("launchctl calls = %v, want none when active service has no control socket", calls) + } + for _, want := range []string{"launchd service", "control socket is unavailable", "gc supervisor start"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) } } } @@ -2000,6 +3431,83 @@ func TestRunSupervisorRejectsSupervisorOnFallbackSocket(t *testing.T) { } } +func TestRunSupervisorSIGTERMPreservesSessionsEndToEnd(t *testing.T) { + gcHome := shortTempDir(t, "gc-home-") + runtimeDir := shortTempDir(t, "gc-run-") + t.Setenv("HOME", filepath.Dir(gcHome)) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + t.Setenv("GC_BEADS", "file") + t.Setenv(supervisorPreserveSessionsOnSignalEnv, "1") + + if err := os.WriteFile(supervisor.ConfigPath(), []byte("[supervisor]\nport = "+freeLoopbackPort(t)+"\npatrol_interval = \"10m\"\n"), 0o644); err != nil { + t.Fatal(err) + } + cityPath := filepath.Join(t.TempDir(), "bright-lights") + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte("[workspace]\nname = \"bright-lights\"\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) + if err := supervisor.NewRegistry(supervisor.RegistryPath()).Register(cityPath, "bright-lights"); err != nil { + t.Fatal(err) + } + + sigChReady := make(chan chan<- os.Signal, 1) + oldSignalNotify := supervisorSignalNotify + supervisorSignalNotify = func(c chan<- os.Signal, _ ...os.Signal) { + sigChReady <- c + } + t.Cleanup(func() { + supervisorSignalNotify = oldSignalNotify + }) + + var stdout, stderr lockedBuffer + done := make(chan int, 1) + go func() { + done <- runSupervisor(&stdout, &stderr) + }() + + var sigCh chan<- os.Signal + select { + case sigCh = <-sigChReady: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for supervisor signal hook; stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(stdout.String(), "Launching city 'bright-lights'") { + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(stdout.String(), "Launching city 'bright-lights'") { + t.Fatalf("timed out waiting for city launch; stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + sigCh <- syscall.SIGTERM + + select { + case code := <-done: + if code != 0 { + t.Fatalf("runSupervisor code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + case <-time.After(5 * time.Second): + t.Fatalf("runSupervisor did not exit after SIGTERM; stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + got := stdout.String() + for _, want := range []string{ + "Preserving city '" + cityPath + "' sessions for re-adoption...", + "Preserving agent sessions for supervisor re-adoption.", + "City '" + cityPath + "' preserved.", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout = %q, want %q; stderr=%q", got, want, stderr.String()) + } + } + if strings.Contains(got, "Stopping city 'bright-lights'") { + t.Fatalf("stdout = %q, should use preserve-mode shutdown for SIGTERM", got) + } +} + func TestRunSupervisorFailsWhenAPIPortUnavailable(t *testing.T) { t.Setenv("GC_HOME", t.TempDir()) t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) @@ -2276,6 +3784,7 @@ func TestStopManagedCityForcesCleanupAfterTimeout(t *testing.T) { logFile := filepath.Join(t.TempDir(), "ops.log") script := writeSpyScript(t, logFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) closer := &closerSpy{} mc := &managedCity{ @@ -2326,6 +3835,7 @@ func TestStopManagedCityDoesNotUseStartupOrDriftTimeouts(t *testing.T) { logFile := filepath.Join(t.TempDir(), "ops.log") script := writeSpyScript(t, logFile) t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) closer := &closerSpy{} mc := &managedCity{ @@ -2368,6 +3878,423 @@ func TestStopManagedCityDoesNotUseStartupOrDriftTimeouts(t *testing.T) { assertSingleStopWithBenignNoise(t, ops) } +func TestCityRuntimeShutdownPreservesSessionsWhenRequested(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "agent-one", runtime.Config{}); err != nil { + t.Fatalf("Start(agent-one): %v", err) + } + cr := &CityRuntime{ + cfg: &config.City{ + Daemon: config.DaemonConfig{ShutdownTimeout: "20ms"}, + }, + sp: sp, + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, + } + cr.preserveSessionsOnShutdown() + + cr.shutdown() + + running, err := sp.ListRunning("") + if err != nil { + t.Fatalf("ListRunning: %v", err) + } + if !slices.Contains(running, "agent-one") { + t.Fatalf("running sessions = %v, want agent-one preserved", running) + } + for _, call := range sp.Calls { + if call.Method == "Interrupt" || call.Method == "Stop" { + t.Fatalf("preserve-mode shutdown called %s for %q; calls=%v", call.Method, call.Name, sp.Calls) + } + } +} + +func TestCityRuntimeShutdownPreserveModeRecordsTrace(t *testing.T) { + cityPath := t.TempDir() + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "bright-lights", + cfg: &config.City{ + Daemon: config.DaemonConfig{ShutdownTimeout: "20ms"}, + }, + sp: runtime.NewFake(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, + trace: newSessionReconcilerTraceManager(cityPath, "bright-lights", io.Discard), + } + cr.preserveSessionsOnShutdown() + + cr.shutdown() + + records, err := ReadTraceRecords(traceCityRuntimeDir(cityPath), TraceFilter{}) + if err != nil { + t.Fatalf("ReadTraceRecords: %v", err) + } + if !slices.ContainsFunc(records, func(record SessionReconcilerTraceRecord) bool { + return record.RecordType == TraceRecordCycleResult && + record.Fields["mode"] == "preserve_sessions" && + record.Fields["city_name"] == "bright-lights" && + record.Fields["reason"] == "supervisor_shutdown_preserve_mode" + }) { + t.Fatalf("trace records missing preserve shutdown cycle result: %#v", records) + } +} + +func TestStopManagedCityPreservingSessionsSkipsBeadsProviderShutdown(t *testing.T) { + cityPath := t.TempDir() + logFile := filepath.Join(t.TempDir(), "ops.log") + script := writeSpyScript(t, logFile) + t.Setenv("GC_BEADS", "exec:"+script) + t.Setenv("GC_BEADS_SCOPE_ROOT", cityPath) + + closer := &closerSpy{} + done := make(chan struct{}) + canceled := false + mc := &managedCity{ + name: "bright-lights", + cancel: func() { + canceled = true + close(done) + }, + done: done, + closer: closer, + cr: &CityRuntime{ + cfg: &config.City{ + Daemon: config.DaemonConfig{ShutdownTimeout: "20ms"}, + }, + sp: runtime.NewFake(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, + }, + } + + if err := stopManagedCityPreservingSessions(mc, cityPath, io.Discard); err != nil { + t.Fatalf("stopManagedCityPreservingSessions: %v", err) + } + if !canceled { + t.Fatal("expected city context to be canceled so the CityRuntime goroutine can exit") + } + if !closer.closed { + t.Fatal("expected recorder closer to be closed after preserve-mode teardown") + } + if ops := readOpLog(t, logFile); len(ops) != 0 { + t.Fatalf("beads provider ops = %v, want none in preserve mode", ops) + } +} + +func TestStopManagedCityPreservingSessionsWaitsForRuntimeShutdownOnTimeout(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("proxy process service shutdown uses process groups on linux") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not in PATH") + } + cityPath := t.TempDir() + serviceScript := filepath.Join(t.TempDir(), "service.sh") + if err := os.WriteFile(serviceScript, []byte(`#!/usr/bin/env python3 +import os +import signal +import socket +import sys + +sock_path = os.environ["GC_SERVICE_SOCKET"] +try: + os.unlink(sock_path) +except FileNotFoundError: + pass + +def stop(_signum, _frame): + sys.exit(0) + +signal.signal(signal.SIGTERM, stop) +listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +listener.bind(sock_path) +listener.listen(1) +while True: + conn, _ = listener.accept() + conn.close() +`), 0o755); err != nil { + t.Fatalf("WriteFile(service script): %v", err) + } + var runtimeStdout bytes.Buffer + cr := &CityRuntime{ + cityPath: cityPath, + cityName: "bright-lights", + cfg: &config.City{ + Daemon: config.DaemonConfig{ShutdownTimeout: "20ms"}, + Services: []config.Service{{ + Name: "bridge", + Kind: "proxy_process", + Process: config.ServiceProcessConfig{ + Command: []string{serviceScript}, + }, + }}, + }, + sp: runtime.NewFake(), + rec: events.Discard, + stdout: &runtimeStdout, + stderr: io.Discard, + } + cr.svc = workspacesvc.NewManager(&serviceRuntime{cr: cr}) + if err := cr.svc.Reload(); err != nil { + t.Fatalf("service Reload: %v", err) + } + status, ok := cr.svc.Get("bridge") + if !ok { + t.Fatal("service bridge missing after Reload") + } + if status.LocalState != "ready" { + t.Fatalf("service bridge local_state = %q, want ready; status=%#v", status.LocalState, status) + } + + mc := &managedCity{ + name: "bright-lights", + cancel: func() {}, + done: make(chan struct{}), + cr: cr, + } + + err := stopManagedCityPreservingSessions(mc, cityPath, io.Discard) + if err == nil { + t.Fatal("stopManagedCityPreservingSessions error = nil, want timeout error") + } + status, ok = cr.svc.Get("bridge") + if !ok { + t.Fatal("service bridge missing after preserve-mode shutdown wait") + } + if status.LocalState != "stopped" { + t.Fatalf("service bridge local_state = %q, want stopped after preserve-mode shutdown wait; status=%#v", status.LocalState, status) + } + if !strings.Contains(runtimeStdout.String(), "Preserving agent sessions for supervisor re-adoption.") { + t.Fatalf("runtime stdout = %q, want preserve-mode shutdown message", runtimeStdout.String()) + } +} + +func TestShutdownSupervisorCitiesPreserveSessions(t *testing.T) { + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "agent-one", runtime.Config{}); err != nil { + t.Fatalf("Start(agent-one): %v", err) + } + done := make(chan struct{}) + mc := &managedCity{ + name: "bright-lights", + cancel: func() { + close(done) + }, + done: done, + cr: &CityRuntime{ + cfg: &config.City{Daemon: config.DaemonConfig{ShutdownTimeout: "20ms"}}, + sp: sp, rec: events.Discard, stdout: io.Discard, stderr: io.Discard, + }, + } + if err := stopManagedCityPreservingSessions(mc, t.TempDir(), io.Discard); err != nil { + t.Fatalf("stopManagedCityPreservingSessions: %v", err) + } + mc.cr.shutdown() + running, err := sp.ListRunning("") + if err != nil { + t.Fatalf("ListRunning: %v", err) + } + if !slices.Contains(running, "agent-one") { + t.Fatalf("running sessions = %v, want agent-one preserved", running) + } +} + +func TestSupervisorShutdownControllerDestructiveRequestIsSticky(t *testing.T) { + tests := []struct { + name string + requests []supervisorShutdownMode + want bool + }{ + {name: "no request", want: false}, + {name: "preserve only", requests: []supervisorShutdownMode{supervisorShutdownPreserveSessions}, want: true}, + {name: "destructive only", requests: []supervisorShutdownMode{supervisorShutdownDestructive}, want: false}, + {name: "destructive then preserve", requests: []supervisorShutdownMode{supervisorShutdownDestructive, supervisorShutdownPreserveSessions}, want: false}, + {name: "preserve then destructive", requests: []supervisorShutdownMode{supervisorShutdownPreserveSessions, supervisorShutdownDestructive}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl := newSupervisorShutdownController() + for _, req := range tt.requests { + ctl.request(req) + } + if got := ctl.preservesSessions(); got != tt.want { + t.Fatalf("preservesSessions() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSupervisorShutdownControllerSettlesLateDestructiveRequest(t *testing.T) { + ctl := newSupervisorShutdownController() + ctl.request(supervisorShutdownPreserveSessions) + + go func() { + time.Sleep(10 * time.Millisecond) + ctl.request(supervisorShutdownDestructive) + }() + + if got := ctl.preservesSessionsAfterSettle(200 * time.Millisecond); got { + t.Fatal("preservesSessionsAfterSettle() = true, want false after late destructive request") + } +} + +func TestSupervisorSignalLoopKeepsLateDestructiveEscalationUntilShutdownDone(t *testing.T) { + t.Setenv(supervisorPreserveSessionsOnSignalEnv, "1") + sigCh := make(chan os.Signal, 2) + done := make(chan struct{}) + shutdownStarted := make(chan struct{}) + var shutdownStartedOnce sync.Once + ctl := newSupervisorShutdownController() + + go supervisorSignalLoop(sigCh, done, func(mode supervisorShutdownMode) { + ctl.request(mode) + shutdownStartedOnce.Do(func() { close(shutdownStarted) }) + }, func() {}) + + sigCh <- syscall.SIGTERM + select { + case <-shutdownStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for preserve shutdown request") + } + sigCh <- syscall.SIGINT + defer close(done) + + if got := ctl.preservesSessionsAfterSettle(200 * time.Millisecond); got { + t.Fatal("preservesSessionsAfterSettle() = true, want false after late SIGINT escalation") + } +} + +func TestSupervisorShutdownModeForSignalPreservesOnlySIGTERMWhenConfigured(t *testing.T) { + t.Setenv(supervisorPreserveSessionsOnSignalEnv, "1") + if got := supervisorShutdownModeForSignal(syscall.SIGTERM); got != supervisorShutdownPreserveSessions { + t.Fatalf("SIGTERM shutdown mode = %v, want preserve", got) + } + if got := supervisorShutdownModeForSignal(syscall.SIGINT); got != supervisorShutdownDestructive { + t.Fatalf("SIGINT shutdown mode = %v, want destructive", got) + } +} + +func TestStopSupervisorWithWaitStopsSystemdServiceAfterAckBeforeDone(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("systemd path only applies on linux") + } + gcHome := shortTempDir(t, "gc-home-") + runtimeDir := shortTempDir(t, "gc-run-") + t.Setenv("HOME", filepath.Dir(gcHome)) + t.Setenv("GC_HOME", gcHome) + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + + unitPath := supervisorSystemdServicePath() + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", filepath.Dir(unitPath), err) + } + if err := os.WriteFile(unitPath, []byte("unit\n"), 0o600); err != nil { + t.Fatalf("WriteFile(%q): %v", unitPath, err) + } + + var ( + mu sync.Mutex + stopped bool + serviceStopBeforeAck bool + doneSentBeforeService bool + serviceStopSeen bool + serviceStopOnce sync.Once + ) + ackSent := make(chan struct{}) + serviceStopped := make(chan struct{}) + oldRun := supervisorSystemctlRun + supervisorSystemctlRun = func(args ...string) error { + mu.Lock() + if len(args) >= 3 && args[1] == "stop" && args[2] == supervisorSystemdServiceName() { + select { + case <-ackSent: + default: + serviceStopBeforeAck = true + } + serviceStopSeen = true + serviceStopOnce.Do(func() { close(serviceStopped) }) + } + mu.Unlock() + return nil + } + t.Cleanup(func() { + supervisorSystemctlRun = oldRun + }) + + sockPath := supervisorSocketPath() + if err := os.MkdirAll(filepath.Dir(sockPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%q): %v", filepath.Dir(sockPath), err) + } + lis, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("Listen(unix, %q): %v", sockPath, err) + } + t.Cleanup(func() { + lis.Close() //nolint:errcheck + os.Remove(sockPath) //nolint:errcheck + }) + go func() { + for { + conn, err := lis.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() //nolint:errcheck + r := bufio.NewReader(conn) + line, err := r.ReadString('\n') + if err != nil { + return + } + switch strings.TrimSpace(line) { + case "ping": + mu.Lock() + defer mu.Unlock() + if stopped { + return + } + io.WriteString(conn, "4242\n") //nolint:errcheck + case "stop": + mu.Lock() + stopped = true + mu.Unlock() + io.WriteString(conn, "ok\n") //nolint:errcheck + close(ackSent) + select { + case <-serviceStopped: + case <-time.After(200 * time.Millisecond): + mu.Lock() + doneSentBeforeService = true + mu.Unlock() + } + io.WriteString(conn, "done:ok\n") //nolint:errcheck + } + }(conn) + } + }() + + var stdout, stderr bytes.Buffer + if code := stopSupervisorWithWait(&stdout, &stderr, true, time.Second); code != 0 { + t.Fatalf("stopSupervisorWithWait code = %d, want 0; stderr=%q", code, stderr.String()) + } + mu.Lock() + defer mu.Unlock() + if serviceStopBeforeAck { + t.Fatal("platform service was stopped before the supervisor acknowledged the destructive socket stop") + } + if doneSentBeforeService { + t.Fatal("supervisor reported done:ok before systemd stop was requested") + } + if !serviceStopSeen { + t.Fatal("systemd service was not stopped after the supervisor acknowledged the destructive socket stop") + } +} + // TestStopSupervisorWithWaitBlocksUntilSocketStops exercises the --wait // path of `gc supervisor stop`. The fake socket answers "ping" with a PID // (so supervisorAliveAtPath keeps returning alive) for ~200ms after the diff --git a/cmd/gc/cmd_wait.go b/cmd/gc/cmd_wait.go index 093b3017d9..8134365b27 100644 --- a/cmd/gc/cmd_wait.go +++ b/cmd/gc/cmd_wait.go @@ -62,6 +62,7 @@ func newSessionWaitCmd(stdout, stderr io.Writer) *cobra.Command { } return nil }, + ValidArgsFunction: completeSessionIDs, } cmd.Flags().StringSliceVar(&depIDs, "on-beads", nil, "bead IDs to watch") cmd.Flags().BoolVar(&matchAny, "any", false, "wake when any watched bead closes (default: all)") @@ -560,10 +561,21 @@ func prepareWaitWakeState(store beads.Store, now time.Time) (map[string]bool, er } func prepareWaitWakeStateForCity(cityPath string, store beads.Store, now time.Time) (map[string]bool, error) { + return prepareWaitWakeStateForCityWithSnapshot(cityPath, store, now, nil) +} + +func prepareWaitWakeStateForCityWithSnapshot(cityPath string, store beads.Store, now time.Time, sessionBeads *sessionBeadSnapshot) (map[string]bool, error) { waits, err := loadWaitBeads(store) if err != nil { return nil, err } + if sessionBeads == nil { + var err error + sessionBeads, err = loadSessionBeadSnapshot(store) + if err != nil { + return nil, err + } + } readyWaitSet := make(map[string]bool) for _, wait := range waits { state := wait.Metadata["state"] @@ -574,9 +586,20 @@ func prepareWaitWakeStateForCity(cityPath string, store beads.Store, now time.Ti if isWaitTerminal(state) { continue } - sessionBead, err := store.Get(sessionID) - if err != nil { - continue + sessionBead, ok := sessionBeads.FindByID(sessionID) + if !ok { + if wait.Metadata["registered_epoch"] != "" { + var found bool + sessionBead, found, err = lookupSessionBeadByID(store, sessionID) + if err != nil { + return nil, err + } + if !found { + continue + } + } else { + continue + } } if epoch := wait.Metadata["registered_epoch"]; epoch != "" && sessionBead.Metadata["continuation_epoch"] != "" && epoch != sessionBead.Metadata["continuation_epoch"] { if err := setWaitTerminalState(store, wait.ID, map[string]string{ @@ -591,6 +614,9 @@ func prepareWaitWakeStateForCity(cityPath string, store beads.Store, now time.Ti } continue } + if !ok { + continue + } if expiresAt := wait.Metadata["expires_at"]; expiresAt != "" { if ts, err := time.Parse(time.RFC3339, expiresAt); err == nil && !ts.After(now) { if err := setWaitTerminalState(store, wait.ID, map[string]string{ @@ -652,11 +678,39 @@ func prepareWaitWakeStateForCity(cityPath string, store beads.Store, now time.Ti return readyWaitSet, nil } -func dispatchReadyWaitNudges(cityPath string, store beads.Store, sp runtime.Provider, now time.Time) error { +func lookupSessionBeadByID(store beads.Store, id string) (beads.Bead, bool, error) { + if store == nil || strings.TrimSpace(id) == "" { + return beads.Bead{}, false, nil + } + bead, err := store.Get(id) + if err != nil { + if errors.Is(err, beads.ErrNotFound) { + return beads.Bead{}, false, nil + } + return beads.Bead{}, false, err + } + if !sessionpkg.IsSessionBeadOrRepairable(bead) { + return beads.Bead{}, false, nil + } + return bead, true, nil +} + +func dispatchReadyWaitNudges(cityPath string, store beads.Store, _ runtime.Provider, now time.Time) error { + return dispatchReadyWaitNudgesWithSnapshot(cityPath, store, now, nil) +} + +func dispatchReadyWaitNudgesWithSnapshot(cityPath string, store beads.Store, now time.Time, sessionBeads *sessionBeadSnapshot) error { waits, err := loadWaitBeads(store) if err != nil { return err } + if sessionBeads == nil { + var err error + sessionBeads, err = loadSessionBeadSnapshot(store) + if err != nil { + return err + } + } for _, wait := range waits { if wait.Metadata["state"] != waitStateReady { continue @@ -665,12 +719,11 @@ func dispatchReadyWaitNudges(cityPath string, store beads.Store, sp runtime.Prov if sessionID == "" { continue } - sessionBead, err := store.Get(sessionID) - if err != nil { + sessionBead, ok := sessionBeads.FindByID(sessionID) + if !ok { continue } - running, err := workerSessionTargetRunningWithConfig(cityPath, store, sp, nil, sessionID) - if err != nil || !running { + if !cachedSessionCanReceiveWaitNudge(sessionBead) { continue } nudgeID := waitNudgeID(wait) @@ -711,6 +764,15 @@ func dispatchReadyWaitNudges(cityPath string, store beads.Store, sp runtime.Prov return nil } +func cachedSessionCanReceiveWaitNudge(sessionBead beads.Bead) bool { + switch sessionpkg.State(strings.TrimSpace(sessionBead.Metadata["state"])) { + case "", sessionpkg.StateActive, sessionpkg.StateAwake: + return true + default: + return false + } +} + func finalizeReadyWaitFromNudge(store beads.Store, wait beads.Bead, now time.Time) (bool, error) { nudgeID := wait.Metadata["nudge_id"] if nudgeID == "" { diff --git a/cmd/gc/cmd_wait_test.go b/cmd/gc/cmd_wait_test.go index 5a52123a6d..8e5838175a 100644 --- a/cmd/gc/cmd_wait_test.go +++ b/cmd/gc/cmd_wait_test.go @@ -29,6 +29,11 @@ type waitNudgeMetadataFailStore struct { *beads.MemStore } +type waitGetSpyStore struct { + beads.Store + getIDs []string +} + func (s waitNudgeMetadataFailStore) SetMetadata(id, key, value string) error { if key == "nudge_id" { return errors.New("set nudge id failed") @@ -36,6 +41,11 @@ func (s waitNudgeMetadataFailStore) SetMetadata(id, key, value string) error { return s.MemStore.SetMetadata(id, key, value) } +func (s *waitGetSpyStore) Get(id string) (beads.Bead, error) { + s.getIDs = append(s.getIDs, id) + return s.Store.Get(id) +} + var ( waitTestRealBDPathOnce sync.Once waitTestRealBDCached string @@ -74,21 +84,16 @@ func waitTestRealBDPath(t *testing.T) string { t.Helper() skipSlowCmdGCTest(t, "requires a managed bd lifecycle city; run make test-cmd-gc-process for full coverage") waitTestRealBDPathOnce.Do(func() { - for _, dir := range filepath.SplitList(os.Getenv("PATH")) { - if strings.TrimSpace(dir) == "" { - continue - } - candidate := filepath.Join(dir, "bd") - info, err := os.Stat(candidate) - if err != nil || info.IsDir() { - continue - } - cmd := exec.Command(candidate, "init", "--help") - out, err := cmd.CombinedOutput() - if err == nil || !strings.Contains(string(out), `unknown subcommand "init"`) { - waitTestRealBDCached = candidate - return - } + candidate, err := findPreferredBinary("bd") + if err != nil { + waitTestRealBDErr = errors.New("bd with init not installed") + return + } + cmd := exec.Command(candidate, "init", "--help") + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), `unknown subcommand "init"`) { + waitTestRealBDCached = candidate + return } waitTestRealBDErr = errors.New("bd with init not installed") }) @@ -432,6 +437,150 @@ func TestPrepareWaitWakeState_FinalizesFromNudge(t *testing.T) { } } +func TestPrepareWaitWakeState_UsesTargetedLookupForMissingSessionEpoch(t *testing.T) { + base := beads.NewMemStore() + store := &waitGetSpyStore{Store: base} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker", + "agent_name": "worker", + "continuation_epoch": "1", + "state": string(sessionpkg.StateActive), + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if err := store.Close(sessionBead.ID); err != nil { + t.Fatalf("close session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: waitBeadType, + Labels: []string{waitBeadLabel, "session:" + sessionBead.ID}, + Metadata: map[string]string{ + "session_id": sessionBead.ID, + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + "registered_epoch": "1", + }, + }); err != nil { + t.Fatalf("create wait bead: %v", err) + } + + readyWaitSet, err := prepareWaitWakeState(store, time.Now().UTC()) + if err != nil { + t.Fatalf("prepareWaitWakeState: %v", err) + } + if len(readyWaitSet) != 0 { + t.Fatalf("readyWaitSet = %#v, want empty for non-open session", readyWaitSet) + } + if len(store.getIDs) != 1 || store.getIDs[0] != sessionBead.ID { + t.Fatalf("Get IDs = %v, want targeted lookup for %s", store.getIDs, sessionBead.ID) + } +} + +func TestPrepareWaitWakeState_SkipsMissingOpenSessionWithoutEpochLookup(t *testing.T) { + base := beads.NewMemStore() + store := &waitGetSpyStore{Store: base} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker", + "agent_name": "worker", + "state": string(sessionpkg.StateActive), + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if err := store.Close(sessionBead.ID); err != nil { + t.Fatalf("close session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: waitBeadType, + Labels: []string{waitBeadLabel, "session:" + sessionBead.ID}, + Metadata: map[string]string{ + "session_id": sessionBead.ID, + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + }, + }); err != nil { + t.Fatalf("create wait bead: %v", err) + } + + readyWaitSet, err := prepareWaitWakeState(store, time.Now().UTC()) + if err != nil { + t.Fatalf("prepareWaitWakeState: %v", err) + } + if len(readyWaitSet) != 0 { + t.Fatalf("readyWaitSet = %#v, want empty for non-open session", readyWaitSet) + } + if len(store.getIDs) != 0 { + t.Fatalf("Get IDs = %v, want no closed-session lookup without an epoch", store.getIDs) + } +} + +func TestPrepareWaitWakeState_CancelsStaleEpochWaitForClosedSession(t *testing.T) { + base := beads.NewMemStore() + store := &waitGetSpyStore{Store: base} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker", + "agent_name": "worker", + "continuation_epoch": "2", + "state": string(sessionpkg.StateActive), + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if err := store.Close(sessionBead.ID); err != nil { + t.Fatalf("close session bead: %v", err) + } + waitBead, err := store.Create(beads.Bead{ + Type: waitBeadType, + Labels: []string{waitBeadLabel, "session:" + sessionBead.ID}, + Metadata: map[string]string{ + "session_id": sessionBead.ID, + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + "registered_epoch": "1", + }, + }) + if err != nil { + t.Fatalf("create wait bead: %v", err) + } + + readyWaitSet, err := prepareWaitWakeState(store, time.Now().UTC()) + if err != nil { + t.Fatalf("prepareWaitWakeState: %v", err) + } + if len(readyWaitSet) != 0 { + t.Fatalf("readyWaitSet = %#v, want empty after stale wait cancellation", readyWaitSet) + } + updated, err := store.Get(waitBead.ID) + if err != nil { + t.Fatalf("store.Get(wait): %v", err) + } + if got := updated.Metadata["state"]; got != waitStateCanceled { + t.Fatalf("wait state = %q, want %q", got, waitStateCanceled) + } + if got := updated.Metadata["last_error"]; got != "continuation-stale" { + t.Fatalf("last_error = %q, want continuation-stale", got) + } + if updated.Status != "closed" { + t.Fatalf("wait status = %q, want closed", updated.Status) + } +} + func TestDepsWaitReady_IgnoresEmptyDependencyEntries(t *testing.T) { store := beads.NewMemStore() dep, err := store.Create(beads.Bead{Title: "dep"}) @@ -738,6 +887,111 @@ func TestDispatchReadyWaitNudges_EnqueuesDeterministicNudge(t *testing.T) { } } +func TestDispatchReadyWaitNudges_UsesOpenSessionSnapshotInsteadOfWorkerRunningCheck(t *testing.T) { + t.Setenv("GC_BEADS", "file") + dir := t.TempDir() + base := beads.NewMemStore() + store := &waitGetSpyStore{Store: base} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker", + "agent_name": "worker", + "template": "worker", + "continuation_epoch": "1", + "state": string(sessionpkg.StateActive), + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: waitBeadType, + Labels: []string{waitBeadLabel, "session:" + sessionBead.ID}, + Description: "Continue after review closes.", + Metadata: map[string]string{ + "session_id": sessionBead.ID, + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + "dep_ids": "gc-1", + "dep_mode": "all", + "registered_epoch": "1", + "delivery_attempt": "1", + }, + }); err != nil { + t.Fatalf("create wait bead: %v", err) + } + sp := runtime.NewFake() + + if err := dispatchReadyWaitNudges(dir, store, sp, time.Now().UTC()); err != nil { + t.Fatalf("dispatchReadyWaitNudges: %v", err) + } + for _, id := range store.getIDs { + if id == sessionBead.ID { + t.Fatalf("dispatch used Get for session %s instead of the open-session snapshot; getIDs=%v", sessionBead.ID, store.getIDs) + } + } + for _, call := range sp.Calls { + switch call.Method { + case "IsRunning", "ProcessAlive", "IsAttached", "GetLastActivity", "GetMeta": + t.Fatalf("dispatch should trust cached session state, saw provider call %#v", call) + } + } +} + +func TestDispatchReadyWaitNudges_SkipsClosedSessionWithoutBackingGet(t *testing.T) { + t.Setenv("GC_BEADS", "file") + dir := t.TempDir() + base := beads.NewMemStore() + store := &waitGetSpyStore{Store: base} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker", + "agent_name": "worker", + "template": "worker", + "continuation_epoch": "1", + "state": string(sessionpkg.StateActive), + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if err := store.Close(sessionBead.ID); err != nil { + t.Fatalf("close session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: waitBeadType, + Labels: []string{waitBeadLabel, "session:" + sessionBead.ID}, + Metadata: map[string]string{ + "session_id": sessionBead.ID, + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + "registered_epoch": "1", + "delivery_attempt": "1", + }, + }); err != nil { + t.Fatalf("create wait bead: %v", err) + } + sp := runtime.NewFake() + + if err := dispatchReadyWaitNudges(dir, store, sp, time.Now().UTC()); err != nil { + t.Fatalf("dispatchReadyWaitNudges: %v", err) + } + for _, id := range store.getIDs { + if id == sessionBead.ID { + t.Fatalf("dispatch used Get for closed session %s; getIDs=%v", sessionBead.ID, store.getIDs) + } + } + if len(sp.Calls) != 0 { + t.Fatalf("dispatch should not query provider for a session absent from the open-session snapshot, calls=%#v", sp.Calls) + } +} + func TestDispatchReadyWaitNudges_StartsCodexPoller(t *testing.T) { t.Setenv("GC_BEADS", "file") dir := t.TempDir() @@ -1106,7 +1360,7 @@ func TestCmdSessionWait_AllowsRigDependencyBeads(t *testing.T) { if err := rigStore.Close(dep.ID); err != nil { t.Fatalf("close rig dep bead: %v", err) } - if got := beadPrefix(dep.ID); got != "fe" { + if got := beadPrefix(nil, dep.ID); got != "fe" { t.Fatalf("rig dep prefix = %q, want %q", got, "fe") } @@ -1182,7 +1436,7 @@ func TestPrepareWaitWakeState_ResolvesRigDependencyBeads(t *testing.T) { if err := rigStore.Close(dep.ID); err != nil { t.Fatalf("close rig dep bead: %v", err) } - if got := beadPrefix(dep.ID); got != "fe" { + if got := beadPrefix(nil, dep.ID); got != "fe" { t.Fatalf("rig dep prefix = %q, want %q", got, "fe") } cityStore, err = openCityStoreAt(cityPath) diff --git a/cmd/gc/completion.go b/cmd/gc/completion.go new file mode 100644 index 0000000000..fd06865e63 --- /dev/null +++ b/cmd/gc/completion.go @@ -0,0 +1,275 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/fsys" + "github.com/gastownhall/gascity/internal/orders" + "github.com/gastownhall/gascity/internal/session" + "github.com/spf13/cobra" +) + +// Tab completion is load-bearing: these functions are called on every +// keystroke after . They must be fast and never write to the terminal, +// since any stderr output would appear as garbage under the user's prompt. +// All errors are swallowed; a failed completion returns an empty candidate +// list with ShellCompDirectiveNoFileComp so the shell doesn't fall back to +// filename completion. + +// completeSessionIDs completes session IDs and aliases for commands whose +// first positional argument is a session ID-or-alias. +func completeSessionIDs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + sessions := loadSessionsForCompletion() + candidates := make([]string, 0, len(sessions)*2) + for _, s := range sessions { + desc := sessionCompletionDescription(s) + if strings.HasPrefix(s.ID, toComplete) { + candidates = append(candidates, s.ID+"\t"+desc) + } + if s.Alias != "" && s.Alias != s.ID && strings.HasPrefix(s.Alias, toComplete) { + candidates = append(candidates, s.Alias+"\t"+desc) + } + } + return candidates, cobra.ShellCompDirectiveNoFileComp +} + +// completeRigNames completes rig names for commands whose first positional +// is a rig name. +func completeRigNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return rigNameCandidates(toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeRigFlagNames completes rig names for --rig flags. Flag completion +// must ignore existing positional args; a user often completes --rig after +// typing the command's required positional. +func completeRigFlagNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return rigNameCandidates(toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeOrderNames completes order names for commands whose first +// positional is an order name. +func completeOrderNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + aa := loadOrdersForCompletion() + candidates := make([]string, 0, len(aa)) + for _, o := range aa { + if !strings.HasPrefix(o.Name, toComplete) { + continue + } + candidates = append(candidates, o.Name+"\t"+orderCompletionDescription(o)) + } + return candidates, cobra.ShellCompDirectiveNoFileComp +} + +// quietDefaultLogger runs fn with the default log.Logger's output redirected +// to io.Discard, then restores it. Needed because some internal paths (e.g., +// orders discovery) write migration warnings via log.Printf, which would +// corrupt the terminal during tab completion. This helper is intended only for +// one-shot completion paths; it is not safe against concurrent log writer +// mutation. +func quietDefaultLogger(fn func()) { + orig := log.Default().Writer() + log.SetOutput(io.Discard) + defer log.SetOutput(orig) + fn() +} + +// rigNameCandidates returns rig names with path descriptions as cobra +// completion entries. +func rigNameCandidates(toComplete string) []string { + var candidates []string + quietDefaultLogger(func() { + cityPath, err := resolveCityForCompletionContext(false) + if err != nil { + return + } + cfg, err := loadCityConfigFS(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"), io.Discard) + if err != nil { + return + } + resolveRigPaths(cityPath, cfg.Rigs) + candidates = make([]string, 0, len(cfg.Rigs)) + for i := range cfg.Rigs { + name := cfg.Rigs[i].Name + if !strings.HasPrefix(name, toComplete) { + continue + } + desc := cfg.Rigs[i].Path + if cfg.Rigs[i].Suspended { + desc += " (suspended)" + } + candidates = append(candidates, name+"\t"+desc) + } + }) + return candidates +} + +func resolveCityForCompletion() (string, error) { + return resolveCityForCompletionContext(true) +} + +func resolveCityForCompletionContext(honorRigFlag bool) (string, error) { + if city := strings.TrimSpace(cityFlag); city != "" { + return validateCityPath(city) + } + if honorRigFlag { + if rig := strings.TrimSpace(rigFlag); rig != "" { + ctx, err := resolveRigForCompletion(rig) + if err != nil { + return "", err + } + return ctx.CityPath, nil + } + } + if cityPath, ok := resolveExplicitCityPathEnv(); ok { + return cityPath, nil + } + if cityPath, ok := resolveCityPathFromGCDir(); ok { + return cityPath, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + if ctx, ok := lookupRigFromCwd(cwd); ok { + return ctx.CityPath, nil + } + return findCity(cwd) +} + +func resolveRigForCompletion(nameOrPath string) (resolvedContext, error) { + matches, _, err := registeredRigBindingsByName(nameOrPath, false) + if err != nil { + return resolvedContext{}, err + } + if len(matches) > 0 { + return resolveRigBindingMatches(nameOrPath, matches) + } + + abs, err := filepath.Abs(nameOrPath) + if err != nil { + return resolvedContext{}, err + } + matches, _, err = registeredRigBindingsByPath(abs, false) + if err != nil { + return resolvedContext{}, err + } + if len(matches) > 0 { + return resolveRigBindingMatches(abs, matches) + } + return resolvedContext{}, os.ErrNotExist +} + +func loadOrdersForCompletion() []orders.Order { + var aa []orders.Order + quietDefaultLogger(func() { + cityPath, err := resolveCityForCompletion() + if err != nil { + return + } + cfg, err := loadCityConfig(cityPath, io.Discard) + if err != nil { + return + } + var code int + aa, code = loadAllOrders(cityPath, cfg, io.Discard, "gc completion") + if code != 0 { + aa = nil + } + }) + return aa +} + +// loadSessionsForCompletion returns session info without triggering the +// slow live-state and attachment checks performed by the non-JSON path of +// `gc session list`. This mirrors the JSON-path of cmdSessionList. +func loadSessionsForCompletion() []session.Info { + var sessions []session.Info + quietDefaultLogger(func() { + cityPath, err := resolveCityForCompletion() + if err != nil { + return + } + store, err := openCityStoreAt(cityPath) + if err != nil { + return + } + cfg, err := loadCityConfig(cityPath, io.Discard) + if err != nil { + return + } + providerCtx := sessionProviderContextForCity(cfg, cityPath, os.Getenv("GC_SESSION")) + allSessionBeads, err := store.List(beads.ListQuery{ + Label: session.LabelSession, + Sort: beads.SortCreatedDesc, + }) + if err != nil { + return + } + sessionBeads := newSessionBeadSnapshot(allSessionBeads) + sp, err := newSessionProviderFromContextWithError(providerCtx, sessionBeads) + if err != nil { + return + } + catalog, err := workerSessionCatalogWithConfig("", store, sp, providerCtx.cfg) + if err != nil { + return + } + sessions = catalog.ListFullFromBeads(allSessionBeads, "", "").Sessions + }) + return sessions +} + +// sessionCompletionDescription formats a session as "alias (state)" or +// "template (state)" when no alias is set. Title is omitted to keep the +// zsh completion menu scannable. +func sessionCompletionDescription(s session.Info) string { + target := s.Alias + if target == "" { + target = s.Template + } + if target == "" { + target = "-" + } + state := string(s.State) + if state == "" { + state = "closed" + } + return target + " (" + state + ")" +} + +// orderCompletionDescription formats an order as ", " where +// type is "formula" or "exec" and timing is interval/schedule/event. +func orderCompletionDescription(o orders.Order) string { + typ := "formula" + if o.IsExec() { + typ = "exec" + } + timing := o.Interval + if timing == "" { + timing = o.Schedule + } + if timing == "" { + timing = o.On + } + if timing == "" { + timing = "-" + } + if o.Rig != "" { + return typ + ", " + timing + " (rig: " + o.Rig + ")" + } + return typ + ", " + timing +} diff --git a/cmd/gc/completion_test.go b/cmd/gc/completion_test.go new file mode 100644 index 0000000000..a59fbd796a --- /dev/null +++ b/cmd/gc/completion_test.go @@ -0,0 +1,461 @@ +package main + +import ( + "bytes" + "errors" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/orders" + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" + "github.com/spf13/cobra" +) + +func TestCompleteSessionIDs_EarlyExitOnExtraArgs(t *testing.T) { + // When the positional is already satisfied, the completer must return no + // candidates and must not attempt to open the city store — otherwise it + // would error out or emit noise for every keystroke after the ID is typed. + got, dir := completeSessionIDs(nil, []string{"gc-42"}, "anything") + if len(got) != 0 { + t.Errorf("expected no candidates with args set, got %v", got) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive, got %v", dir) + } +} + +func TestCompleteRigNames_EarlyExitOnExtraArgs(t *testing.T) { + got, dir := completeRigNames(nil, []string{"myrig"}, "x") + if len(got) != 0 { + t.Errorf("expected no candidates, got %v", got) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive, got %v", dir) + } +} + +func TestCompleteOrderNames_EarlyExitOnExtraArgs(t *testing.T) { + got, dir := completeOrderNames(nil, []string{"some-order"}, "x") + if len(got) != 0 { + t.Errorf("expected no candidates, got %v", got) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive, got %v", dir) + } +} + +func TestSessionCompletionDescription(t *testing.T) { + cases := []struct { + name string + in session.Info + want string + }{ + {"alias + state", session.Info{Alias: "mayor", State: session.State("asleep")}, "mayor (asleep)"}, + {"template fallback", session.Info{Template: "gascity/claude", State: session.State("active")}, "gascity/claude (active)"}, + {"empty state renders as closed", session.Info{Alias: "a"}, "a (closed)"}, + {"no alias and no template", session.Info{State: session.State("suspended")}, "- (suspended)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sessionCompletionDescription(tc.in) + if got != tc.want { + t.Errorf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestOrderCompletionDescription(t *testing.T) { + cases := []struct { + name string + in orders.Order + want string + }{ + {"formula + interval", orders.Order{Formula: "f", Interval: "5m"}, "formula, 5m"}, + {"exec + schedule", orders.Order{Exec: "s", Schedule: "0 0 * * *"}, "exec, 0 0 * * *"}, + {"formula + event", orders.Order{Formula: "f", On: "bead.closed"}, "formula, bead.closed"}, + {"rig scoped", orders.Order{Formula: "f", Interval: "5m", Rig: "frontend"}, "formula, 5m (rig: frontend)"}, + {"no timing", orders.Order{Formula: "f"}, "formula, -"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := orderCompletionDescription(tc.in) + if got != tc.want { + t.Errorf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestQuietDefaultLogger_RestoresOutput(t *testing.T) { + // The default logger's writer must be restored after fn returns, even if + // fn panics or writes to it — otherwise a single noisy completion call + // would leave the logger silenced for the rest of the process. + origWriter := log.Default().Writer() + t.Cleanup(func() { log.SetOutput(origWriter) }) + + var before bytes.Buffer + log.SetOutput(&before) + + quietDefaultLogger(func() { + log.Print("silenced") + }) + if strings.Contains(before.String(), "silenced") { + t.Errorf("expected log output to be suppressed inside quietDefaultLogger, got %q", before.String()) + } + + log.Print("audible") + if !strings.Contains(before.String(), "audible") { + t.Errorf("expected log output restored after quietDefaultLogger, got %q", before.String()) + } +} + +func TestResolveCityForCompletion_UsesExplicitRigBindingOutsideCity(t *testing.T) { + gcHome := t.TempDir() + cityPath := t.TempDir() + rigDir := filepath.Join(cityPath, "rigs", "frontend") + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GC_HOME", gcHome) + registerRigBindingForResolution(t, gcHome, cityPath, "completion-city", "frontend", rigDir) + + isolateCompletionContext(t, "") + rigFlag = "frontend" + t.Chdir(t.TempDir()) + + got, err := resolveCityForCompletion() + if err != nil { + t.Fatalf("resolveCityForCompletion: %v", err) + } + if !samePath(got, cityPath) { + t.Fatalf("city path = %q, want %q", got, cityPath) + } +} + +func TestRigNameCandidates_LoadsAndFilters(t *testing.T) { + // Integration check for the rig source-of-truth — exercises resolveCity + // (via t.Chdir into a temp city), loadCityConfigFS, and the prefix filter. + cityPath := t.TempDir() + cityToml := "[workspace]\nname = \"my-city\"\n\n[[rigs]]\nname = \"alpha\"\npath = \"/tmp/alpha\"\n\n[[rigs]]\nname = \"beta\"\npath = \"/tmp/beta\"\n" + writeCompletionCity(t, cityPath, cityToml) + isolateCompletionContext(t, "") + t.Chdir(cityPath) + t.Setenv("GC_RIG", "ambient-rig-from-agent-session") + t.Setenv("GC_RIG_ROOT", "/does/not/matter") + + got := rigNameCandidates("") + if len(got) != 2 { + t.Fatalf("expected 2 rig candidates, got %d: %v", len(got), got) + } + names := make([]string, len(got)) + for i, c := range got { + names[i] = strings.SplitN(c, "\t", 2)[0] + } + for _, want := range []string{"alpha", "beta"} { + if !slicesContains(names, want) { + t.Errorf("missing candidate %q in %v", want, names) + } + } + if slicesContains(names, "my-city") { + t.Errorf("synthetic HQ candidate should not be offered for rig arguments: %v", names) + } + + // Prefix filter. + got = rigNameCandidates("al") + if len(got) != 1 || !strings.HasPrefix(got[0], "alpha\t") { + t.Errorf("expected only alpha candidate for prefix 'al', got %v", got) + } +} + +func TestCompleteRigFlagNames_IgnoresPositionalArgs(t *testing.T) { + cityPath := t.TempDir() + writeCompletionCity(t, cityPath, "[workspace]\nname = \"my-city\"\n\n[[rigs]]\nname = \"alpha\"\npath = \"/tmp/alpha\"\n\n[[rigs]]\nname = \"beta\"\npath = \"/tmp/beta\"\n") + isolateCompletionContext(t, cityPath) + + for _, cmd := range []*cobra.Command{ + newOrderShowCmd(os.Stdout, os.Stderr), + newOrderRunCmd(os.Stdout, os.Stderr), + } { + complete, ok := cmd.GetFlagCompletionFunc("rig") + if !ok { + t.Fatalf("%s missing --rig completion function", cmd.Name()) + } + got, dir := complete(cmd, []string{"existing-order"}, "a") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("%s --rig directive = %v, want NoFileComp", cmd.Name(), dir) + } + if len(got) != 1 || !strings.HasPrefix(got[0], "alpha\t") { + t.Errorf("%s --rig completion with positional args = %v, want alpha", cmd.Name(), got) + } + } +} + +func TestCompleteOrderNames_LoadsOrders(t *testing.T) { + cityPath := t.TempDir() + writeCompletionCity(t, cityPath, "[workspace]\nname = \"orders-city\"\n") + isolateCompletionContext(t, cityPath) + if err := os.MkdirAll(filepath.Join(cityPath, "orders"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, "orders", "digest.toml"), []byte(` +[order] +formula = "mol-digest" +trigger = "cron" +schedule = "*/5 * * * *" +`), 0o644); err != nil { + t.Fatal(err) + } + + got, dir := completeOrderNames(nil, nil, "di") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", dir) + } + if len(got) != 1 || got[0] != "digest\tformula, */5 * * * *" { + t.Fatalf("order candidates = %v, want digest with cron description", got) + } +} + +func TestCompleteOrderNames_SuppressesConfigPackWarnings(t *testing.T) { + cityPath := t.TempDir() + writeCompletionCity(t, cityPath, `[workspace] +name = "orders-city" +includes = ["packs/missing"] +`) + isolateCompletionContext(t, cityPath) + + origWriter := log.Default().Writer() + t.Cleanup(func() { log.SetOutput(origWriter) }) + var logs bytes.Buffer + log.SetOutput(&logs) + + _, dir := completeOrderNames(nil, nil, "") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", dir) + } + if logs.Len() != 0 { + t.Fatalf("completion wrote default logger output: %q", logs.String()) + } +} + +func TestCompleteSessionIDs_LoadsBeadBackedSessions(t *testing.T) { + cityPath := t.TempDir() + writeCompletionCity(t, cityPath, `[workspace] +name = "sessions-city" + +[session] +provider = "fake" + +[beads] +provider = "file" +`) + isolateCompletionContext(t, cityPath) + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt(%q): %v", cityPath, err) + } + created, err := store.Create(beads.Bead{ + Title: "worker", + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "worker", + "session_name": "sessions-city--worker", + "state": "asleep", + "template": "codex", + }, + }) + if err != nil { + t.Fatalf("store.Create(session): %v", err) + } + + got, dir := completeSessionIDs(nil, nil, "") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", dir) + } + names := completionCandidateNames(got) + if !slicesContains(names, created.ID) { + t.Errorf("session ID candidate %q missing from %v", created.ID, got) + } + if !slicesContains(names, "worker") { + t.Errorf("session alias candidate missing from %v", got) + } + if !slicesContains(got, "worker\tworker (asleep)") { + t.Errorf("session alias description missing from %v", got) + } +} + +func TestLoadSessionsForCompletion_SwallowsProviderConstructionError(t *testing.T) { + cityPath := t.TempDir() + writeCompletionCity(t, cityPath, `[workspace] +name = "sessions-city" + +[session] +provider = "fake" + +[beads] +provider = "file" + +[providers.opencode] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" + +[[agent]] +name = "worker" +provider = "opencode" +session = "acp" +`) + isolateCompletionContext(t, cityPath) + store, err := openCityStoreAt(cityPath) + if err != nil { + t.Fatalf("openCityStoreAt(%q): %v", cityPath, err) + } + if _, err := store.Create(beads.Bead{ + Title: "worker", + Type: session.BeadType, + Labels: []string{session.LabelSession}, + }); err != nil { + t.Fatalf("store.Create(session): %v", err) + } + oldBuild := buildSessionProviderByName + t.Cleanup(func() { buildSessionProviderByName = oldBuild }) + buildSessionProviderByName = func(name string, sc config.SessionConfig, cityName, cityPath string) (runtime.Provider, error) { + if name == "acp" { + return nil, errors.New("provider unavailable") + } + return oldBuild(name, sc, cityName, cityPath) + } + + got := loadSessionsForCompletion() + if len(got) != 0 { + t.Fatalf("sessions = %v, want none after provider construction failure", got) + } +} + +func TestCompleteOrderNames_DistinguishesSameNameRigOrders(t *testing.T) { + cityPath := t.TempDir() + sidecarPackDir := filepath.Join(cityPath, "packs", "sidecar") + for _, dir := range []string{ + filepath.Join(cityPath, ".gc"), + filepath.Join(cityPath, "rigs", "frontend"), + filepath.Join(cityPath, "rigs", "backend"), + filepath.Join(sidecarPackDir, "formulas"), + filepath.Join(sidecarPackDir, "orders"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + writeFile(t, filepath.Join(cityPath, "pack.toml"), ` +[pack] +name = "orders-city" +schema = 2 +`) + writeFile(t, filepath.Join(cityPath, "city.toml"), ` +[workspace] +name = "orders-city" + +[[rigs]] +name = "frontend" +path = "rigs/frontend" + +[rigs.imports.sidecar] +source = "./packs/sidecar" + +[[rigs]] +name = "backend" +path = "rigs/backend" + +[rigs.imports.sidecar] +source = "./packs/sidecar" +`) + writeFile(t, filepath.Join(sidecarPackDir, "pack.toml"), ` +[pack] +name = "sidecar" +schema = 2 +`) + writeFile(t, filepath.Join(sidecarPackDir, "orders", "digest.toml"), ` +[order] +formula = "mol-digest" +trigger = "cooldown" +interval = "5m" +`) + isolateCompletionContext(t, cityPath) + + got, dir := completeOrderNames(nil, nil, "dig") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", dir) + } + for _, want := range []string{ + "digest\tformula, 5m (rig: backend)", + "digest\tformula, 5m (rig: frontend)", + } { + if !slicesContains(got, want) { + t.Errorf("missing candidate %q in %v", want, got) + } + } +} + +func isolateCompletionContext(t *testing.T, cityPath string) { + t.Helper() + origCity, origRig := cityFlag, rigFlag + cityFlag, rigFlag = "", "" + t.Cleanup(func() { + cityFlag, rigFlag = origCity, origRig + }) + for _, key := range []string{ + "GC_BEADS", + "GC_BEADS_SCOPE_ROOT", + "GC_CITY", + "GC_CITY_PATH", + "GC_CITY_ROOT", + "GC_DIR", + "GC_RIG", + "GC_RIG_ROOT", + "GC_SESSION", + } { + t.Setenv(key, "") + } + if cityPath != "" { + t.Setenv("GC_CITY", cityPath) + t.Setenv("GC_CITY_PATH", cityPath) + } +} + +func writeCompletionCity(t *testing.T, cityPath, cityToml string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte(cityToml), 0o644); err != nil { + t.Fatal(err) + } +} + +func completionCandidateNames(candidates []string) []string { + names := make([]string, len(candidates)) + for i, c := range candidates { + names[i] = strings.SplitN(c, "\t", 2)[0] + } + return names +} + +func slicesContains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} diff --git a/cmd/gc/compute_awake_bridge.go b/cmd/gc/compute_awake_bridge.go index c3995c6542..3e00068b2a 100644 --- a/cmd/gc/compute_awake_bridge.go +++ b/cmd/gc/compute_awake_bridge.go @@ -18,6 +18,7 @@ func buildAwakeInputFromReconciler( cfg *config.City, sessionBeads []beads.Bead, poolDesired map[string]int, + namedSessionDemand map[string]bool, workSet map[string]bool, readyWaitSet map[string]bool, assignedWorkBeads []beads.Bead, @@ -26,14 +27,15 @@ func buildAwakeInputFromReconciler( clk time.Time, ) AwakeInput { input := AwakeInput{ - ScaleCheckCounts: poolDesired, - WorkSet: workSet, - ReadyWaitSet: readyWaitSet, - RunningSessions: make(map[string]bool), - AttachedSessions: make(map[string]bool), - PendingSessions: make(map[string]bool), - ChatIdleTimeout: cfg.ChatSessions.IdleTimeoutDuration(), - Now: clk, + ScaleCheckCounts: poolDesired, + NamedSessionDemand: cloneBoolMap(namedSessionDemand), + WorkSet: workSet, + ReadyWaitSet: readyWaitSet, + RunningSessions: make(map[string]bool), + AttachedSessions: make(map[string]bool), + PendingSessions: make(map[string]bool), + ChatIdleTimeout: cfg.ChatSessions.IdleTimeoutDuration(), + Now: clk, } // Agents @@ -149,7 +151,7 @@ func awakeSetToWakeEvals(decisions map[string]AwakeDecision, sessionBeads []Awak reasons = []WakeReason{WakePin} case "wait-ready": reasons = []WakeReason{WakeWait} - case "assigned-work", "work-query": + case "assigned-work", "named-demand", "work-query": reasons = []WakeReason{WakeWork} default: reasons = []WakeReason{WakeConfig} @@ -157,12 +159,24 @@ func awakeSetToWakeEvals(decisions map[string]AwakeDecision, sessionBeads []Awak } evals[bead.ID] = wakeEvaluation{ Reasons: reasons, + Reason: d.Reason, ConfigSuppressed: d.Reason == "idle-sleep", } } return evals } +func cloneBoolMap(source map[string]bool) map[string]bool { + if source == nil { + return nil + } + out := make(map[string]bool, len(source)) + for key, value := range source { + out[key] = value + } + return out +} + func parseSleepDuration(s string) time.Duration { if s == "" || s == "off" { return 0 diff --git a/cmd/gc/compute_awake_bridge_test.go b/cmd/gc/compute_awake_bridge_test.go index 3a13c01f11..16ab3c8b68 100644 --- a/cmd/gc/compute_awake_bridge_test.go +++ b/cmd/gc/compute_awake_bridge_test.go @@ -29,6 +29,7 @@ func TestBuildAwakeInputFromReconcilerUsesLifecycleProjectionForCompatibilitySta nil, nil, nil, + nil, now, ) @@ -66,6 +67,7 @@ func TestBuildAwakeInputFromReconcilerPopulatesPendingInteractions(t *testing.T) nil, nil, nil, + nil, []wakeTarget{{session: &session, alive: true}}, sp, now, @@ -80,3 +82,132 @@ func TestBuildAwakeInputFromReconcilerPopulatesPendingInteractions(t *testing.T) t.Fatalf("decision = %+v, want pending wake", got) } } + +func TestAwakeSetToWakeEvalsPreservesDecisionReason(t *testing.T) { + evals := awakeSetToWakeEvals( + map[string]AwakeDecision{ + "s-worker": {ShouldWake: true, Reason: "assigned-work"}, + }, + []AwakeSessionBead{{ + ID: "mc-session-1", + SessionName: "s-worker", + }}, + ) + + got := evals["mc-session-1"] + if got.Reason != "assigned-work" { + t.Fatalf("Reason = %q, want assigned-work", got.Reason) + } + if !containsWakeReason(got.Reasons, WakeWork) { + t.Fatalf("Reasons = %v, want WakeWork", got.Reasons) + } +} + +func TestBuildAwakeInputFromReconcilerCarriesNamedSessionDemand(t *testing.T) { + now := time.Now().UTC() + cfg := &config.City{ + Agents: []config.Agent{{Name: "worker"}}, + NamedSessions: []config.NamedSession{ + {Name: "primary", Template: "worker", Mode: "on_demand"}, + }, + } + sessionBead := beads.Bead{ + ID: "mc-session-1", + Status: "open", + Type: "session", + Metadata: map[string]string{ + "state": "asleep", + "session_name": "primary", + "template": "worker", + "configured_named_identity": "primary", + "configured_named_mode": "on_demand", + }, + } + + input := buildAwakeInputFromReconciler( + cfg, + []beads.Bead{sessionBead}, + map[string]int{"worker": 1}, + map[string]bool{"primary": true}, + nil, + nil, + nil, + nil, + runtime.NewFake(), + now, + ) + + if !input.NamedSessionDemand["primary"] { + t.Fatalf("NamedSessionDemand[primary] = false, want true") + } + decisions := ComputeAwakeSet(input) + got := decisions["primary"] + if !got.ShouldWake || got.Reason != "named-demand" { + t.Fatalf("decision = %+v, want named-demand wake", got) + } +} + +// TestBuildAwakeInputFromReconcilerNamedAlwaysPostChurnRewakes pins the +// contract for a mode=always named session that was put to sleep after churn: +// if named-session metadata survives, the next awake-set pass must re-wake it. +func TestBuildAwakeInputFromReconcilerNamedAlwaysPostChurnRewakes(t *testing.T) { + now := time.Now().UTC() + cfg := &config.City{ + Agents: []config.Agent{{Name: "worker"}}, + NamedSessions: []config.NamedSession{ + {Name: "worker", Template: "worker", Mode: "always"}, + }, + } + postChurnBead := beads.Bead{ + ID: "mc-session-1", + Status: "open", + Type: "session", + Metadata: map[string]string{ + "state": "asleep", + "sleep_reason": "", + "state_reason": "creation_complete", + "last_woke_at": "", + "wake_attempts": "0", + "churn_count": "1", + "session_key": "", + "continuation_reset_pending": "", + "pending_create_claim": "", + "pin_awake": "", + "session_name": "worker", + "template": "worker", + "configured_named_identity": "worker", + "configured_named_mode": "always", + }, + } + + input := buildAwakeInputFromReconciler( + cfg, + []beads.Bead{postChurnBead}, + nil, nil, nil, nil, nil, nil, + runtime.NewFake(), + now, + ) + + if len(input.SessionBeads) != 1 { + t.Fatalf("SessionBeads length = %d, want 1", len(input.SessionBeads)) + } + bead := input.SessionBeads[0] + if bead.NamedIdentity != "worker" { + t.Errorf("projected NamedIdentity = %q, want worker (configured_named_identity should survive churn)", bead.NamedIdentity) + } + if bead.State != "asleep" { + t.Errorf("projected State = %q, want asleep", bead.State) + } + + decisions := ComputeAwakeSet(input) + got, ok := decisions["worker"] + if !ok { + t.Fatal("decision for 'worker' missing from awake set") + } + if !got.ShouldWake { + t.Fatalf("post-churn named-always session should wake; got decision = %+v", got) + } + if got.Reason != "named-always" { + t.Errorf("wake reason = %q, want named-always", got.Reason) + } +} diff --git a/cmd/gc/compute_awake_set.go b/cmd/gc/compute_awake_set.go index 8fc5234ace..7e3b4b1712 100644 --- a/cmd/gc/compute_awake_set.go +++ b/cmd/gc/compute_awake_set.go @@ -16,18 +16,19 @@ const defaultOnDemandIdleTimeout = 5 * time.Minute // should be awake. All external I/O (shell commands, tmux checks, store // queries) happens before this function is called. type AwakeInput struct { - Agents []AwakeAgent - NamedSessions []AwakeNamedSession - SessionBeads []AwakeSessionBead - WorkBeads []AwakeWorkBead - ScaleCheckCounts map[string]int // agent template → desired count - WorkSet map[string]bool // agent template → work_query found pending work - RunningSessions map[string]bool // session name → tmux exists - AttachedSessions map[string]bool // session name → user attached - PendingSessions map[string]bool // session name → pending interaction - ReadyWaitSet map[string]bool // session bead ID → durable wait is ready - ChatIdleTimeout time.Duration // global idle timeout for manual/chat sessions (0 = disabled) - Now time.Time + Agents []AwakeAgent + NamedSessions []AwakeNamedSession + SessionBeads []AwakeSessionBead + WorkBeads []AwakeWorkBead + ScaleCheckCounts map[string]int // agent template → scale_check count + NamedSessionDemand map[string]bool // named-session identity → routed/assigned work demand + WorkSet map[string]bool // agent template → work_query found pending work + RunningSessions map[string]bool // session name → tmux exists + AttachedSessions map[string]bool // session name → user attached + PendingSessions map[string]bool // session name → pending interaction + ReadyWaitSet map[string]bool // session bead ID → durable wait is ready + ChatIdleTimeout time.Duration // global idle timeout for manual/chat sessions (0 = disabled) + Now time.Time } // AwakeAgent represents an [[agent]] config entry. @@ -99,6 +100,7 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { // compatible wake causes (pending create, named-always, assigned work) may // still reuse the same bead. desired := make(map[string]string) // sessionName → reason + concreteAssignedWork := make(map[string]bool) // Newly created beads that still carry a controller create claim must be // launched at least once, even if the work signal that materialized them @@ -126,10 +128,22 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { desired[ns.Identity] = "named-always" } case "on_demand": - // On-demand named sessions materialize from direct targeting, - // direct concrete ownership, dependencies, binding continuity, - // and pinning. Generic scale_check demand belongs to ephemeral - // capacity, not named identity materialization. + // On-demand named sessions wake only from named demand that was + // resolved by the desired-state pass, not generic template demand. + if !input.NamedSessionDemand[ns.Identity] { + continue + } + if agent, ok := agentsByName[ns.Template]; ok && agent.Suspended { + continue + } + if sn := findNamedSessionName(input.SessionBeads, ns.Identity); sn != "" { + bead := findBeadBySessionName(input.SessionBeads, sn) + if bead != nil && !bead.DependencyOnly && !bead.Drained && bead.State != "closed" { + desired[sn] = "named-demand" + } + } else { + desired[ns.Identity] = "named-demand" + } } } @@ -143,18 +157,25 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { continue } active := collectActiveBeads(input.SessionBeads, template) - for i, bead := range active { - if i >= count { + filled := 0 + for _, bead := range active { + if filled >= count { break } + if sessionHasConcreteAssignedWork(input.WorkBeads, bead) { + continue + } desired[bead.SessionName] = "scaled:demand" + filled++ } creating := collectCreatingBeads(input.SessionBeads, template) - filled := len(active) for _, bead := range creating { if filled >= count { break } + if sessionHasConcreteAssignedWork(input.WorkBeads, bead) { + continue + } desired[bead.SessionName] = "scaled:creating" filled++ } @@ -206,9 +227,6 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { if bead.State == "closed" { continue } - if _, already := desired[bead.SessionName]; already { - continue - } if agent, ok := agentsByName[bead.Template]; ok && agent.Suspended { continue } @@ -217,7 +235,12 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { if assignee == "" || (wb.Status != "open" && wb.Status != "in_progress") { continue } - if assignee == bead.ID || assignee == bead.SessionName || (bead.NamedIdentity != "" && assignee == bead.NamedIdentity) { + if assignee == bead.ID || assignee == bead.SessionName { + desired[bead.SessionName] = "assigned-work" + concreteAssignedWork[bead.SessionName] = true + break + } + if bead.NamedIdentity != "" && assignee == bead.NamedIdentity { desired[bead.SessionName] = "assigned-work" break } @@ -299,7 +322,7 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { case isOnDemandSession(input.NamedSessions, bead): idleTimeout = defaultOnDemandIdleTimeout } - if idleTimeout > 0 && input.Now.Sub(bead.IdleSince) >= idleTimeout { + if idleTimeout > 0 && input.Now.Sub(bead.IdleSince) >= idleTimeout && !concreteAssignedWork[name] { decision.ShouldWake = false decision.Reason = "idle-sleep" } @@ -367,6 +390,19 @@ func collectActiveBeads(beads []AwakeSessionBead, template string) []AwakeSessio return result } +func sessionHasConcreteAssignedWork(workBeads []AwakeWorkBead, bead AwakeSessionBead) bool { + for _, wb := range workBeads { + assignee := strings.TrimSpace(wb.Assignee) + if assignee == "" || (wb.Status != "open" && wb.Status != "in_progress") { + continue + } + if assignee == bead.ID || assignee == bead.SessionName { + return true + } + } + return false +} + func isOnDemandSession(named []AwakeNamedSession, bead AwakeSessionBead) bool { if bead.NamedIdentity == "" { return false diff --git a/cmd/gc/compute_awake_set_test.go b/cmd/gc/compute_awake_set_test.go index f312f26890..3d7959a215 100644 --- a/cmd/gc/compute_awake_set_test.go +++ b/cmd/gc/compute_awake_set_test.go @@ -1,6 +1,7 @@ package main import ( + "strconv" "testing" "time" @@ -181,6 +182,30 @@ func TestNamedOnDemand_ExactNamedIdentityAssigneeWakes(t *testing.T) { assertReason(t, result, "hello-world--refinery", "assigned-work") } +func TestNamedOnDemand_NamedSessionDemandWakesExistingIdentity(t *testing.T) { + result := ComputeAwakeSet(AwakeInput{ + Agents: []AwakeAgent{{QualifiedName: "hello-world/refinery"}}, + NamedSessions: []AwakeNamedSession{{Identity: "hello-world/refinery", Template: "hello-world/refinery", Mode: "on_demand"}}, + SessionBeads: []AwakeSessionBead{{ID: "mc-1", SessionName: "hello-world--refinery", Template: "hello-world/refinery", State: "asleep", NamedIdentity: "hello-world/refinery"}}, + NamedSessionDemand: map[string]bool{"hello-world/refinery": true}, + Now: now, + }) + assertAwake(t, result, "hello-world--refinery") + assertReason(t, result, "hello-world--refinery", "named-demand") +} + +func TestNamedOnDemand_NamedSessionDemandWakesSingletonTemplateResolvedIdentity(t *testing.T) { + result := ComputeAwakeSet(AwakeInput{ + Agents: []AwakeAgent{{QualifiedName: "worker"}}, + NamedSessions: []AwakeNamedSession{{Identity: "primary", Template: "worker", Mode: "on_demand"}}, + SessionBeads: []AwakeSessionBead{{ID: "mc-1", SessionName: "primary", Template: "worker", State: "asleep", NamedIdentity: "primary"}}, + NamedSessionDemand: map[string]bool{"primary": true}, + Now: now, + }) + assertAwake(t, result, "primary") + assertReason(t, result, "primary", "named-demand") +} + func TestNamedOnDemand_PendingCreateWakesWithoutDemand(t *testing.T) { result := ComputeAwakeSet(AwakeInput{ Agents: []AwakeAgent{{QualifiedName: "hello-world/refinery"}}, @@ -319,6 +344,48 @@ func TestScaled_Demand2_OneActive(t *testing.T) { assertAsleep(t, result, "polecat-mc-2") // asleep ephemerals not reused } +func TestScaled_NewDemandDoesNotUseActiveAssignedSessions(t *testing.T) { + result := ComputeAwakeSet(AwakeInput{ + Agents: []AwakeAgent{{QualifiedName: "hello-world/polecat"}}, + SessionBeads: []AwakeSessionBead{ + {ID: "mc-assigned-1", SessionName: "polecat-assigned-1", Template: "hello-world/polecat", State: "active"}, + {ID: "mc-assigned-2", SessionName: "polecat-assigned-2", Template: "hello-world/polecat", State: "active"}, + {ID: "mc-assigned-3", SessionName: "polecat-assigned-3", Template: "hello-world/polecat", State: "active"}, + {ID: "mc-assigned-4", SessionName: "polecat-assigned-4", Template: "hello-world/polecat", State: "active"}, + {ID: "mc-assigned-5", SessionName: "polecat-assigned-5", Template: "hello-world/polecat", State: "active"}, + {ID: "mc-new-1", SessionName: "polecat-new-1", Template: "hello-world/polecat", State: "creating"}, + {ID: "mc-new-2", SessionName: "polecat-new-2", Template: "hello-world/polecat", State: "creating"}, + {ID: "mc-new-3", SessionName: "polecat-new-3", Template: "hello-world/polecat", State: "creating"}, + {ID: "mc-new-4", SessionName: "polecat-new-4", Template: "hello-world/polecat", State: "creating"}, + {ID: "mc-new-5", SessionName: "polecat-new-5", Template: "hello-world/polecat", State: "creating"}, + }, + WorkBeads: []AwakeWorkBead{ + {ID: "w-assigned-1", Assignee: "mc-assigned-1", Status: "in_progress"}, + {ID: "w-assigned-2", Assignee: "mc-assigned-2", Status: "in_progress"}, + {ID: "w-assigned-3", Assignee: "mc-assigned-3", Status: "in_progress"}, + {ID: "w-assigned-4", Assignee: "mc-assigned-4", Status: "in_progress"}, + {ID: "w-assigned-5", Assignee: "mc-assigned-5", Status: "in_progress"}, + }, + ScaleCheckCounts: map[string]int{"hello-world/polecat": 5}, + RunningSessions: map[string]bool{ + "polecat-assigned-1": true, + "polecat-assigned-2": true, + "polecat-assigned-3": true, + "polecat-assigned-4": true, + "polecat-assigned-5": true, + }, + Now: now, + }) + + for i := 1; i <= 5; i++ { + suffix := strconv.Itoa(i) + assertAwake(t, result, "polecat-assigned-"+suffix) + assertReason(t, result, "polecat-assigned-"+suffix, "assigned-work") + assertAwake(t, result, "polecat-new-"+suffix) + assertReason(t, result, "polecat-new-"+suffix, "scaled:creating") + } +} + func TestScaled_Demand1_TwoActive(t *testing.T) { result := ComputeAwakeSet(AwakeInput{ Agents: []AwakeAgent{{QualifiedName: "hello-world/polecat"}}, @@ -960,6 +1027,24 @@ func TestRegression_AsleepEphemeralWithAssignedWork_WakesViaAssignedWork(t *test } } +func TestRegression_ConcreteAssignedWorkSuppressesIdleSleep(t *testing.T) { + result := ComputeAwakeSet(AwakeInput{ + Agents: []AwakeAgent{{QualifiedName: "hello-world/polecat", SleepAfterIdle: 2 * time.Hour}}, + SessionBeads: []AwakeSessionBead{ + { + ID: "mc-sctve", SessionName: "polecat-mc-sctve", Template: "hello-world/polecat", State: "active", + IdleSince: now.Add(-3 * time.Hour), + }, + }, + WorkBeads: []AwakeWorkBead{{ID: "hw-8lb", Assignee: "polecat-mc-sctve", Status: "in_progress"}}, + ScaleCheckCounts: map[string]int{"hello-world/polecat": 1}, + RunningSessions: map[string]bool{"polecat-mc-sctve": true}, + Now: now, + }) + assertAwake(t, result, "polecat-mc-sctve") + assertReason(t, result, "polecat-mc-sctve", "assigned-work") +} + // --------------------------------------------------------------------------- // WorkSet — work_query demand signal (defense-in-depth alongside ScaleCheck) // --------------------------------------------------------------------------- diff --git a/cmd/gc/controller.go b/cmd/gc/controller.go index d71e2afbc4..c534c9c7d3 100644 --- a/cmd/gc/controller.go +++ b/cmd/gc/controller.go @@ -27,6 +27,7 @@ import ( "github.com/gastownhall/gascity/internal/convergence" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/fsys" + "github.com/gastownhall/gascity/internal/pathutil" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/supervisor" "github.com/gastownhall/gascity/internal/telemetry" @@ -68,7 +69,20 @@ func (e controllerCommandError) Is(target error) bool { (target == errControllerUnresponsive && e.unresponsive) } -const controllerSocketPathLimit = 100 +const ( + controllerSocketPathLimit = 100 + sessionCircuitResetCommandPrefix = "session-circuit-reset:" +) + +type sessionCircuitResetRequest struct { + Identity string `json:"identity"` + SessionID string `json:"session_id,omitempty"` +} + +type sessionCircuitResetReply struct { + Outcome string `json:"outcome"` + Error string `json:"error,omitempty"` +} // controllerSocketPath returns the Unix socket path for controller commands. // It preserves the legacy .gc/controller.sock location for short city paths, @@ -187,6 +201,8 @@ func handleControllerConn( default: } conn.Write([]byte("ok\n")) //nolint:errcheck // best-effort ack + case strings.HasPrefix(line, sessionCircuitResetCommandPrefix): + handleSessionCircuitResetSocketCmd(conn, cityPath, line[len(sessionCircuitResetCommandPrefix):]) case strings.HasPrefix(line, "converge:"): handleConvergeSocketCmd(conn, line[len("converge:"):], convergenceReqCh) case strings.HasPrefix(line, "trace-arm:"): @@ -209,6 +225,113 @@ func handleControllerConn( } } +func handleSessionCircuitResetSocketCmd(conn net.Conn, cityPath, payload string) { + var req sessionCircuitResetRequest + if err := json.Unmarshal([]byte(payload), &req); err != nil { + writeJSONLine(conn, sessionCircuitResetReply{ + Outcome: "failed", + Error: fmt.Sprintf("invalid session circuit reset request: %v", err), + }) + return + } + identity := strings.TrimSpace(req.Identity) + if identity == "" { + writeJSONLine(conn, sessionCircuitResetReply{ + Outcome: "failed", + Error: "identity is required", + }) + return + } + sessionID := strings.TrimSpace(req.SessionID) + if sessionID == "" { + writeJSONLine(conn, sessionCircuitResetReply{ + Outcome: "failed", + Error: "session_id is required; upgrade gc to clear persisted session circuit breaker metadata", + }) + return + } + store, err := openCityStoreAt(cityPath) + if err != nil { + writeJSONLine(conn, sessionCircuitResetReply{ + Outcome: "failed", + Error: fmt.Sprintf("opening city store: %v", err), + }) + return + } + if err := resetSessionCircuitBreakerState(store, sessionID, identity, defaultSessionCircuitBreaker()); err != nil { + writeJSONLine(conn, sessionCircuitResetReply{ + Outcome: "failed", + Error: err.Error(), + }) + return + } + writeJSONLine(conn, sessionCircuitResetReply{Outcome: "ok"}) +} + +func resetSessionCircuitBreakerState(store beads.Store, sessionID string, identity string, cb *sessionCircuitBreaker) error { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil + } + if cb == nil { + cb = defaultSessionCircuitBreaker() + } + if err := loadPersistedSessionCircuitResetGeneration(store, sessionID, identity, cb); err != nil { + return err + } + initialSnapshot := cb.snapshotIdentity(identity) + if strings.TrimSpace(sessionID) == "" { + cb.Reset(identity) + return nil + } + if err := resetAndClearSessionCircuitBreakerState(store, sessionID, identity, cb, initialSnapshot); err != nil { + return err + } + // The second cycle invalidates an OPEN persist that may race through + // the first clear window. If the second clear fails, restore the pre-reset + // snapshot so the controller never leaves memory CLOSED while storage still + // says OPEN. TestResetSessionCircuitBreakerStateClearsRacingOpenPersist + // guards this from being collapsed into a single reset. + return resetAndClearSessionCircuitBreakerState(store, sessionID, identity, cb, initialSnapshot) +} + +func resetAndClearSessionCircuitBreakerState(store beads.Store, sessionID string, identity string, cb *sessionCircuitBreaker, restoreSnapshot sessionCircuitBreakerIdentitySnapshot) error { + resetGeneration := cb.Reset(identity) + if err := clearPersistedSessionCircuitBreakerMetadata(store, sessionID, resetGeneration); err != nil { + cb.restoreIdentity(identity, restoreSnapshot) + // Restore the pre-reset snapshot rather than the just-reset one so a + // durable clear failure cannot strand the breaker CLOSED in memory. + return err + } + return nil +} + +func resetSessionCircuitBreakerOnController(cityPath, sessionID, identity string) error { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil + } + payload, err := json.Marshal(sessionCircuitResetRequest{Identity: identity, SessionID: sessionID}) + if err != nil { + return fmt.Errorf("encoding session circuit reset request: %w", err) + } + resp, err := sendControllerCommand(cityPath, sessionCircuitResetCommandPrefix+string(payload)) + if err != nil { + return err + } + var reply sessionCircuitResetReply + if err := json.Unmarshal(resp, &reply); err != nil { + return fmt.Errorf("decoding session circuit reset reply: %w", err) + } + if reply.Outcome != "ok" { + if reply.Error != "" { + return fmt.Errorf("%s", reply.Error) + } + return fmt.Errorf("session circuit reset failed") + } + return nil +} + func handleReloadSocketCmd(conn net.Conn, payload string, ch chan reloadRequest) { if ch == nil { writeJSONLine(conn, reloadControlReply{ @@ -572,16 +695,7 @@ func (r *configWatchRegistrar) isConventionRootCreate(path string) bool { } func pathIsWithin(root, path string) bool { - root = normalizePathForCompare(root) - path = normalizePathForCompare(path) - if samePath(root, path) { - return true - } - rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) - if err != nil { - return false - } - return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + return pathutil.PathWithin(root, path) } func isConventionDiscoveryDirName(base string) bool { @@ -773,7 +887,11 @@ func tryReloadConfig(tomlPath, lockedWorkspaceName, cityRoot string) (*reloadRes return nil, fmt.Errorf("fetching packs: %w", err) } - newCfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath, extraConfigFiles...) + allIncludes, err := cityConfigIncludesWithBuiltinPacks(cityRoot, extraConfigFiles...) + if err != nil { + return nil, err + } + newCfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, tomlPath, allIncludes...) if err != nil { return nil, fmt.Errorf("parsing city.toml: %w", err) } @@ -893,11 +1011,17 @@ func gracefulStopAll( running, _ = workerSessionTargetRunningWithConfig("", nil, sp, nil, name) } if !running { + if err := sp.Stop(name); err != nil && !runtime.IsSessionGone(err) { + fmt.Fprintf(stderr, "cleaning exited agent '%s': %v\n", name, err) //nolint:errcheck // best-effort stderr + } fmt.Fprintf(stdout, "Agent '%s' exited gracefully\n", name) //nolint:errcheck // best-effort stdout subject := name if target, ok := targetByName[name]; ok && target.subject != "" { subject = target.subject } + if target, ok := targetByName[name]; ok && cityStopSessionMarked(store, target.sessionID) { + markCityStopSessionAsAsleep(store, target.sessionID, stderr) + } rec.Record(events.Event{ Type: events.SessionStopped, Actor: "gc", Subject: subject, }) diff --git a/cmd/gc/controller_test.go b/cmd/gc/controller_test.go index 5de9d63bfb..3b35528ad4 100644 --- a/cmd/gc/controller_test.go +++ b/cmd/gc/controller_test.go @@ -1,8 +1,11 @@ package main import ( + "bufio" "bytes" "context" + "encoding/json" + "errors" "net" "os" "path/filepath" @@ -13,6 +16,7 @@ import ( "time" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" @@ -378,6 +382,7 @@ func TestSendControllerCommandWithTimeoutsTimesOutOnRead(t *testing.T) { // writeCityTOML is a test helper that writes a city.toml with the given agents. func writeCityTOML(t *testing.T, dir string, cityName string, agentNames ...string) string { t.Helper() + clearInheritedBeadsEnv(t) tomlPath := filepath.Join(dir, "city.toml") var buf bytes.Buffer buf.WriteString("[workspace]\nname = " + `"` + cityName + `"` + "\n\n") @@ -394,10 +399,12 @@ func writeCityTOML(t *testing.T, dir string, cityName string, agentNames ...stri func writeControllerNamedSessionCityTOML(t *testing.T, dir, cityName, mode, idleTimeout string) string { t.Helper() + clearInheritedBeadsEnv(t) tomlPath := filepath.Join(dir, "city.toml") var buf bytes.Buffer buf.WriteString("[workspace]\nname = " + `"` + cityName + `"` + "\n\n") buf.WriteString("[beads]\nprovider = \"file\"\n\n") + buf.WriteString("[daemon]\nshutdown_timeout = \"100ms\"\n\n") buf.WriteString("[[agent]]\nname = \"mayor\"\nstart_command = \"echo hello\"\n") if idleTimeout != "" { buf.WriteString("idle_timeout = " + `"` + idleTimeout + `"` + "\n") @@ -493,12 +500,12 @@ func TestControllerReloadsConfig(t *testing.T) { deadline = time.After(1500 * time.Millisecond) for { names, _ := lastAgentNames.Load().([]string) - if len(names) == 2 && names[0] == "mayor" && names[1] == "worker" { + if containsAgentNames(names, "mayor", "worker") { break } select { case <-deadline: - t.Errorf("expected [mayor worker], got %v", names) + t.Errorf("expected mayor and worker, got %v", names) return default: time.Sleep(10 * time.Millisecond) @@ -564,10 +571,13 @@ func TestControllerReloadsConfigImmediatelyOnWatchEvent(t *testing.T) { time.Sleep(5 * time.Millisecond) } + before := reconcileCount.Load() writeCityTOML(t, dir, "test", "mayor", "worker") + // Wait for "Config reloaded" AND at least one reconcile after + // the reload so that buildFn has run with the new config. deadline := time.After(5 * time.Second) - for !strings.Contains(stdout.String(), "Config reloaded") { + for !strings.Contains(stdout.String(), "Config reloaded") || reconcileCount.Load() <= before { select { case <-deadline: t.Fatalf("timed out waiting for immediate config reload; reconciles=%d stdout=%q stderr=%q", @@ -577,9 +587,19 @@ func TestControllerReloadsConfigImmediatelyOnWatchEvent(t *testing.T) { } } - names, _ := lastAgentNames.Load().([]string) - if len(names) != 2 || names[0] != "mayor" || names[1] != "worker" { - t.Errorf("expected [mayor worker], got %v", names) + deadline = time.After(5 * time.Second) + for { + names, _ := lastAgentNames.Load().([]string) + if containsAgentNames(names, "mayor", "worker") { + break + } + select { + case <-deadline: + t.Errorf("expected mayor and worker, got %v", names) + return + default: + time.Sleep(10 * time.Millisecond) + } } } @@ -642,15 +662,21 @@ func TestControllerReloadsConventionDiscoveredAgentOnWatchEvent(t *testing.T) { t.Fatalf("revision did not change after convention-discovered agent was added: %s", result.Revision) } - var names []string + found := false for _, a := range result.Cfg.Agents { - if a.Implicit { - continue + if !a.Implicit && a.Name == "noreen" { + found = true + break } - names = append(names, a.Name) } - if len(names) != 1 || names[0] != "noreen" { - t.Fatalf("reloaded agent names = %v, want [noreen]", names) + if !found { + var names []string + for _, a := range result.Cfg.Agents { + if !a.Implicit { + names = append(names, a.Name) + } + } + t.Fatalf("reloaded agents = %v, want noreen among them", names) } } @@ -814,6 +840,70 @@ func TestWatchConfigDirs_CityRootDoesNotWatchUnrelatedNestedSubdir(t *testing.T) } } +func TestWatchConfigDirs_CityRootIgnoresRuntimeTraceWrites(t *testing.T) { + old := debounceDelay + debounceDelay = 5 * time.Millisecond + t.Cleanup(func() { debounceDelay = old }) + + dir := t.TempDir() + traceDir := citylayout.RuntimeDataDir(dir) + if err := os.MkdirAll(traceDir, 0o755); err != nil { + t.Fatalf("MkdirAll runtime dir: %v", err) + } + traceFile := filepath.Join(traceDir, "control-dispatcher-trace.log") + if err := os.WriteFile(traceFile, []byte("first\n"), 0o644); err != nil { + t.Fatalf("seed runtime trace: %v", err) + } + legacyTraceFile := filepath.Join(dir, "control-dispatcher-trace.log") + + if !shouldIgnoreConfigWatchEvent(traceFile) { + t.Fatalf("shouldIgnoreConfigWatchEvent(%q) = false, want true", traceFile) + } + if shouldIgnoreConfigWatchEvent(legacyTraceFile) { + t.Fatalf("shouldIgnoreConfigWatchEvent(%q) = true, want false", legacyTraceFile) + } + + var dirty atomic.Bool + pokeCh := make(chan struct{}, 1) + var stderr bytes.Buffer + cleanup := watchConfigTargets([]config.WatchTarget{{Path: dir, DiscoverConventions: true}}, &dirty, pokeCh, &stderr) + defer cleanup() + + select { + case <-pokeCh: + default: + } + dirty.Store(false) + + for i, body := range []string{"second\n", "third\n", "fourth\n"} { + if err := os.WriteFile(traceFile, []byte(body), 0o644); err != nil { + t.Fatalf("rewrite runtime trace #%d: %v", i+1, err) + } + select { + case <-pokeCh: + t.Fatalf("unexpected watcher poke after runtime trace write #%d; stderr=%q", i+1, stderr.String()) + case <-time.After(250 * time.Millisecond): + } + if dirty.Load() { + t.Fatalf("dirty flag set after runtime trace write #%d; stderr=%q", i+1, stderr.String()) + } + } + + dirty.Store(false) + if err := os.WriteFile(legacyTraceFile, []byte("legacy\n"), 0o644); err != nil { + t.Fatalf("write legacy city-root trace: %v", err) + } + + select { + case <-pokeCh: + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for watcher poke after legacy city-root trace write; stderr=%q", stderr.String()) + } + if !dirty.Load() { + t.Fatalf("dirty flag not set after legacy city-root trace write; stderr=%q", stderr.String()) + } +} + func TestWatchConfigDirs_SymlinkSeedDirWatchesNestedPreExistingDir(t *testing.T) { old := debounceDelay debounceDelay = 5 * time.Millisecond @@ -1061,8 +1151,11 @@ func TestControllerReloadsNamedSessionModeAndAppliesIdleTimeout(t *testing.T) { } buildFn := func(c *config.City, _ runtime.Provider, _ beads.Store) DesiredStateResult { - if len(c.Agents) > 0 { - lastIdleTimeout.Store(c.Agents[0].IdleTimeout) + for _, agent := range c.Agents { + if agent.Name == "mayor" { + lastIdleTimeout.Store(agent.IdleTimeout) + break + } } ds := make(map[string]TemplateParams) for _, a := range c.Agents { @@ -1234,6 +1327,470 @@ func TestHandleControllerConnControlDispatcher(t *testing.T) { } } +func TestHandleSessionCircuitResetSocketCmd(t *testing.T) { + tests := []struct { + name string + payload string + wantOutcome string + wantError string + }{ + { + name: "invalid json", + payload: `{"identity":`, + wantOutcome: "failed", + wantError: "invalid session circuit reset request", + }, + { + name: "empty identity", + payload: `{"identity":" "}`, + wantOutcome: "failed", + wantError: "identity is required", + }, + { + name: "missing session id", + payload: `{"identity":"rig-a/session-a"}`, + wantOutcome: "failed", + wantError: "session_id is required", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + server, client := net.Pipe() + defer client.Close() //nolint:errcheck + + done := make(chan struct{}) + go func() { + handleSessionCircuitResetSocketCmd(server, t.TempDir(), tc.payload) + close(done) + }() + + reply := readSessionCircuitResetSocketReply(t, client) + if reply.Outcome != tc.wantOutcome { + t.Fatalf("reply.Outcome = %q, want %q", reply.Outcome, tc.wantOutcome) + } + if tc.wantError != "" && !strings.Contains(reply.Error, tc.wantError) { + t.Fatalf("reply.Error = %q, want containing %q", reply.Error, tc.wantError) + } + <-done + }) + } +} + +func TestResetSessionCircuitBreakerStateResetsMemoryBeforeClearingMetadata(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + for i := 0; i < 6; i++ { + cb.RecordRestart(identity, t0.Add(time.Duration(i)*time.Minute)) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("precondition: breaker should be open") + } + + store := &metadataCallbackStore{ + Store: beads.NewMemStore(), + beforeBatch: func() { + if cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Error("breaker was still open while persisted metadata was being cleared") + } + }, + } + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-01T12:00:00Z"]`, + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + if err := resetSessionCircuitBreakerState(store, session.ID, identity, cb); err != nil { + t.Fatalf("resetSessionCircuitBreakerState: %v", err) + } + if cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("breaker should be closed after reset") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + assertSessionCircuitStateMetadataCleared(t, updated.Metadata) + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "2" { + t.Fatalf("%s = %q, want 2", sessionCircuitResetGenerationMetadata, got) + } +} + +func TestResetSessionCircuitBreakerStateClearsRacingOpenPersist(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + for i := 0; i < 6; i++ { + cb.RecordRestart(identity, t0.Add(time.Duration(i)*time.Minute)) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("precondition: breaker should be open") + } + + store := &blockingOpenMetadataBatchStore{ + Store: beads.NewMemStore(), + entered: make(chan struct{}), + release: make(chan struct{}), + cleared: make(chan struct{}), + } + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + persistErr := make(chan error, 1) + go func() { + persistErr <- persistSessionCircuitBreakerMetadata(store, &session, cb, identity, t0.Add(6*time.Minute)) + }() + + select { + case <-store.entered: + case <-time.After(2 * time.Second): + t.Fatal("persist did not reach blocked OPEN metadata write") + } + + resetErr := make(chan error, 1) + go func() { + resetErr <- resetSessionCircuitBreakerState(store, session.ID, identity, cb) + }() + + select { + case <-store.cleared: + case <-time.After(50 * time.Millisecond): + } + + close(store.release) + if err := <-persistErr; err != nil { + t.Fatalf("persistSessionCircuitBreakerMetadata: %v", err) + } + if err := <-resetErr; err != nil { + t.Fatalf("resetSessionCircuitBreakerState: %v", err) + } + if cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("breaker should be closed after racing persist and reset") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + assertSessionCircuitStateMetadataCleared(t, updated.Metadata) + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "2" { + t.Fatalf("%s = %q, want 2 after racing persist", sessionCircuitResetGenerationMetadata, got) + } +} + +func TestResetSessionCircuitBreakerStateRestoresOpenStateOnMetadataClearFailure(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + for i := 0; i < 6; i++ { + cb.RecordRestart(identity, t0.Add(time.Duration(i)*time.Minute)) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("precondition: breaker should be open") + } + + store := &failingClearMetadataStore{Store: beads.NewMemStore()} + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-01T12:00:00Z"]`, + sessionCircuitLastRestartMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenedAtMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenRestartCountMetadata: "6", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + err = resetSessionCircuitBreakerState(store, session.ID, identity, cb) + if err == nil { + t.Fatal("resetSessionCircuitBreakerState: expected clear failure") + } + if !strings.Contains(err.Error(), "injected clear failure") { + t.Fatalf("resetSessionCircuitBreakerState error = %v, want injected failure", err) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("breaker should remain open after failed durable clear") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got := updated.Metadata[sessionCircuitStateMetadata]; got != circuitOpen.String() { + t.Fatalf("%s = %q, want %q", sessionCircuitStateMetadata, got, circuitOpen.String()) + } + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "" { + t.Fatalf("%s = %q, want unchanged", sessionCircuitResetGenerationMetadata, got) + } +} + +func TestResetSessionCircuitBreakerStateRestoresOpenStateOnRacingSecondClearFailure(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + for i := 0; i < 6; i++ { + cb.RecordRestart(identity, t0.Add(time.Duration(i)*time.Minute)) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("precondition: breaker should be open") + } + + store := &failingNthClearMetadataStore{Store: beads.NewMemStore(), failOn: 2} + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-01T12:00:00Z"]`, + sessionCircuitLastRestartMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenedAtMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenRestartCountMetadata: "6", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + err = resetSessionCircuitBreakerState(store, session.ID, identity, cb) + if err == nil { + t.Fatal("resetSessionCircuitBreakerState: expected racing clear failure") + } + if !strings.Contains(err.Error(), "injected clear failure") { + t.Fatalf("resetSessionCircuitBreakerState error = %v, want injected failure", err) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("breaker should remain open after failed racing clear") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got := updated.Metadata[sessionCircuitStateMetadata]; got != "" { + t.Fatalf("%s = %q, want cleared durable metadata", sessionCircuitStateMetadata, got) + } + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "1" { + t.Fatalf("%s = %q, want first reset generation preserved", sessionCircuitResetGenerationMetadata, got) + } +} + +func TestResetSessionCircuitBreakerStateRejectsStaleRestoreSnapshot(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + for i := 0; i < 6; i++ { + cb.RecordRestart(identity, t0.Add(time.Duration(i)*time.Minute)) + } + if !cb.IsOpen(identity, t0.Add(6*time.Minute)) { + t.Fatal("precondition: breaker should be open") + } + + store := beads.NewMemStore() + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-01T12:00:00Z"]`, + sessionCircuitLastRestartMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenedAtMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenRestartCountMetadata: "6", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + staleSnapshot := make(map[string]string, len(session.Metadata)) + for k, v := range session.Metadata { + staleSnapshot[k] = v + } + + if err := resetSessionCircuitBreakerState(store, session.ID, identity, cb); err != nil { + t.Fatalf("resetSessionCircuitBreakerState: %v", err) + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "2" { + t.Fatalf("%s = %q, want 2", sessionCircuitResetGenerationMetadata, got) + } + if reset, err := cb.restoreFromMetadata(identity, staleSnapshot, t0.Add(7*time.Minute)); err != nil || reset { + t.Fatalf("restoreFromMetadata stale reset=%v err=%v", reset, err) + } + if cb.IsOpen(identity, t0.Add(7*time.Minute)) { + t.Fatal("stale pre-reset metadata should not reopen breaker after reset") + } +} + +func TestResetSessionCircuitBreakerStateRejectsHigherGenerationStaleRestoreSnapshot(t *testing.T) { + t0 := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + const identity = "rig-a/session-a" + cb := breakerAt(30*time.Minute, 5) + store := beads.NewMemStore() + session, err := store.Create(beads.Bead{ + Title: "session-a", + Type: sessionBeadType, + Metadata: map[string]string{ + namedSessionIdentityMetadata: identity, + sessionCircuitStateMetadata: circuitOpen.String(), + sessionCircuitRestartsMetadata: `["2026-04-01T12:00:00Z"]`, + sessionCircuitLastRestartMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenedAtMetadata: t0.Format(time.RFC3339Nano), + sessionCircuitOpenRestartCountMetadata: "6", + sessionCircuitResetGenerationMetadata: "3", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + staleSnapshot := make(map[string]string, len(session.Metadata)) + for k, v := range session.Metadata { + staleSnapshot[k] = v + } + + if err := resetSessionCircuitBreakerState(store, session.ID, identity, cb); err != nil { + t.Fatalf("resetSessionCircuitBreakerState: %v", err) + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got := updated.Metadata[sessionCircuitResetGenerationMetadata]; got != "5" { + t.Fatalf("%s = %q, want 5", sessionCircuitResetGenerationMetadata, got) + } + if reset, err := cb.restoreFromMetadata(identity, staleSnapshot, t0.Add(7*time.Minute)); err != nil || reset { + t.Fatalf("restoreFromMetadata stale reset=%v err=%v", reset, err) + } + if cb.IsOpen(identity, t0.Add(7*time.Minute)) { + t.Fatal("higher-generation stale pre-reset metadata should not reopen breaker after reset") + } +} + +type metadataCallbackStore struct { + beads.Store + beforeBatch func() +} + +func (s *metadataCallbackStore) SetMetadataBatch(id string, kvs map[string]string) error { + if s.beforeBatch != nil { + s.beforeBatch() + } + return s.Store.SetMetadataBatch(id, kvs) +} + +type blockingOpenMetadataBatchStore struct { + beads.Store + entered chan struct{} + release chan struct{} + cleared chan struct{} + once sync.Once +} + +func (s *blockingOpenMetadataBatchStore) SetMetadataBatch(id string, kvs map[string]string) error { + if kvs[sessionCircuitStateMetadata] == circuitOpen.String() { + s.once.Do(func() { close(s.entered) }) + <-s.release + } + if sessionCircuitStateMetadataAllCleared(kvs) { + select { + case <-s.cleared: + default: + close(s.cleared) + } + } + return s.Store.SetMetadataBatch(id, kvs) +} + +type failingClearMetadataStore struct { + beads.Store +} + +func (s *failingClearMetadataStore) SetMetadataBatch(id string, kvs map[string]string) error { + if sessionCircuitStateMetadataAllCleared(kvs) { + return errors.New("injected clear failure") + } + return s.Store.SetMetadataBatch(id, kvs) +} + +type failingNthClearMetadataStore struct { + beads.Store + failOn int + calls int +} + +func (s *failingNthClearMetadataStore) SetMetadataBatch(id string, kvs map[string]string) error { + if sessionCircuitStateMetadataAllCleared(kvs) { + s.calls++ + if s.calls == s.failOn { + return errors.New("injected clear failure") + } + } + return s.Store.SetMetadataBatch(id, kvs) +} + +func assertSessionCircuitStateMetadataCleared(t *testing.T, kvs map[string]string) { + t.Helper() + for _, key := range sessionCircuitMetadataKeys { + if key == sessionCircuitResetGenerationMetadata { + continue + } + if kvs[key] != "" { + t.Fatalf("%s = %q, want cleared", key, kvs[key]) + } + } +} + +func sessionCircuitStateMetadataAllCleared(kvs map[string]string) bool { + for _, key := range sessionCircuitMetadataKeys { + if key == sessionCircuitResetGenerationMetadata { + continue + } + if kvs[key] != "" { + return false + } + } + return true +} + +func readSessionCircuitResetSocketReply(t *testing.T, conn net.Conn) sessionCircuitResetReply { + t.Helper() + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + t.Fatalf("read reply: %v", err) + } + t.Fatal("read reply: connection closed") + } + var reply sessionCircuitResetReply + if err := json.Unmarshal(scanner.Bytes(), &reply); err != nil { + t.Fatalf("decode reply: %v", err) + } + return reply +} + func TestControllerReloadInvalidConfig(t *testing.T) { old := debounceDelay debounceDelay = 5 * time.Millisecond @@ -1279,13 +1836,12 @@ func TestControllerReloadInvalidConfig(t *testing.T) { t.Fatal(err) } - // Wait for a tick to process the bad config. - target := reconcileCount.Load() + 2 deadline := time.After(3 * time.Second) - for reconcileCount.Load() < target { + for !strings.Contains(stderr.String(), "config reload") { select { case <-deadline: - t.Fatal("timed out waiting for tick after invalid config") + t.Fatalf("timed out waiting for invalid config reload; reconciles=%d stdout=%q stderr=%q", + reconcileCount.Load(), stdout.String(), stderr.String()) default: time.Sleep(10 * time.Millisecond) } @@ -1308,6 +1864,7 @@ func TestControllerReloadCityNameChange(t *testing.T) { t.Cleanup(func() { debounceDelay = old }) dir := shortSocketTempDir(t, "gc-rename-") + cleanupManagedDoltTestCity(t, dir) tomlPath := writeCityTOML(t, dir, "test", "mayor") cfg, err := config.Load(osFS{}, tomlPath) @@ -1455,7 +2012,8 @@ func TestControllerReloadCommandReloadsConfigImmediately(t *testing.T) { } } - writeCityTOML(t, dir, "test", "mayor", "worker") + expectedAgentNames := []string{"mayor", "worker"} + writeCityTOML(t, dir, "test", expectedAgentNames...) before := reconcileCount.Load() resp, err := sendControllerCommand(dir, "reload") @@ -1466,22 +2024,45 @@ func TestControllerReloadCommandReloadsConfigImmediately(t *testing.T) { t.Fatalf("reload response = %q, want %q", string(resp), "ok") } + agentNamesMatch := func(names []string) bool { + return containsAgentNames(names, expectedAgentNames...) + } + + var names []string deadline = time.After(1500 * time.Millisecond) - for reconcileCount.Load() <= before || !strings.Contains(stdout.String(), "Config reloaded") { + for { + names, _ = lastAgentNames.Load().([]string) + if reconcileCount.Load() > before && + strings.Contains(stdout.String(), "Config reloaded") && + agentNamesMatch(names) { + break + } select { case <-deadline: - t.Fatalf("timed out waiting for reload command to apply config; reconciles=%d stdout=%q stderr=%q", reconcileCount.Load(), stdout.String(), stderr.String()) + t.Fatalf("timed out waiting for reload command to apply config; reconciles=%d agents=%v stdout=%q stderr=%q", reconcileCount.Load(), names, stdout.String(), stderr.String()) default: time.Sleep(10 * time.Millisecond) } } - names, _ := lastAgentNames.Load().([]string) - if len(names) != 2 || names[0] != "mayor" || names[1] != "worker" { - t.Fatalf("expected [mayor worker], got %v", names) + if !agentNamesMatch(names) { + t.Fatalf("expected %v, got %v", expectedAgentNames, names) } } +func containsAgentNames(got []string, want ...string) bool { + seen := make(map[string]bool, len(got)) + for _, name := range got { + seen[name] = true + } + for _, name := range want { + if !seen[name] { + return false + } + } + return true +} + func TestControllerPokeTriggersImmediate(t *testing.T) { sp := runtime.NewFake() @@ -1597,4 +2178,53 @@ func (osFS) Lstat(name string) (os.FileInfo, error) { return os.Ls func (osFS) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } func (osFS) Remove(name string) error { return os.Remove(name) } -func (osFS) Chmod(name string, mode os.FileMode) error { return os.Chmod(name, mode) } + +// TestTryReloadConfig_IncludesBuiltinPackOrders verifies that the controller's +// config reload path includes builtin pack formula layers so the order +// dispatcher sees orders from all embedded packs (core, maintenance, bd, dolt). +// Regression test for gc-4624: dolt pack orders never fired because +// tryReloadConfig did not pass builtinPackIncludes to LoadWithIncludes. +func TestTryReloadConfig_IncludesBuiltinPackOrders(t *testing.T) { + configureTestDoltIdentityEnv(t) + t.Setenv("GC_BEADS", "") + + dir := shortSocketTempDir(t, "gc-reload-orders-") + tomlPath := filepath.Join(dir, "city.toml") + if err := os.WriteFile(tomlPath, []byte("[workspace]\nname = \"test\"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "pack.toml"), []byte("[pack]\nname = \"test\"\nschema = 1\n"), 0o644); err != nil { + t.Fatalf("WriteFile(pack.toml): %v", err) + } + + result, err := tryReloadConfig(tomlPath, "test", dir) + if err != nil { + t.Fatalf("tryReloadConfig() error = %v", err) + } + + var stderr bytes.Buffer + aa, err := scanAllOrders(dir, result.Cfg, &stderr, "test") + if err != nil { + t.Fatalf("scanAllOrders: %v", err) + } + + names := make(map[string]bool, len(aa)) + for _, a := range aa { + names[a.Name] = true + } + + // Maintenance pack orders (always included). + for _, want := range []string{"gate-sweep", "wisp-compact"} { + if !names[want] { + t.Errorf("missing maintenance order %q; got %v", want, names) + } + } + // Dolt pack orders (included transitively via bd pack). + for _, want := range []string{"dolt-health", "dolt-gc-nudge", "dolt-remotes-patrol"} { + if !names[want] { + t.Errorf("missing dolt order %q; got %v", want, names) + } + } +} + +func (osFS) Chmod(name string, mode os.FileMode) error { return os.Chmod(name, mode) } diff --git a/cmd/gc/dashboard/handler.go b/cmd/gc/dashboard/handler.go index 08e9186e13..2f89e17629 100644 --- a/cmd/gc/dashboard/handler.go +++ b/cmd/gc/dashboard/handler.go @@ -107,13 +107,38 @@ func handleClientLog(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() //nolint:errcheck - var entry clientLogEntry - if err := json.NewDecoder(io.LimitReader(r.Body, maxClientLogBody)).Decode(&entry); err != nil { - log.Printf("dashboard: client log decode failed from %s: %v", r.RemoteAddr, err) - http.Error(w, "invalid client log payload", http.StatusBadRequest) + raw, err := io.ReadAll(io.LimitReader(r.Body, maxClientLogBody)) + if err != nil { + http.Error(w, "read body failed", http.StatusBadRequest) return } + var entries []clientLogEntry + if len(raw) > 0 && raw[0] == '[' { + if err := json.Unmarshal(raw, &entries); err != nil { + log.Printf("dashboard: client log batch decode failed from %s: %v", r.RemoteAddr, err) + http.Error(w, "invalid client log payload", http.StatusBadRequest) + return + } + } else { + var entry clientLogEntry + if err := json.Unmarshal(raw, &entry); err != nil { + log.Printf("dashboard: client log decode failed from %s: %v", r.RemoteAddr, err) + http.Error(w, "invalid client log payload", http.StatusBadRequest) + return + } + entries = []clientLogEntry{entry} + } + + ua := r.UserAgent() + for i := range entries { + logClientEntry(&entries[i], ua) + } + + w.WriteHeader(http.StatusNoContent) +} + +func logClientEntry(entry *clientLogEntry, ua string) { level := strings.TrimSpace(entry.Level) if level == "" { level = "info" @@ -123,27 +148,17 @@ func handleClientLog(w http.ResponseWriter, r *http.Request) { scope = "client" } if strings.TrimSpace(entry.Message) == "" { - http.Error(w, "missing client log message", http.StatusBadRequest) return } ts := strings.TrimSpace(entry.TS) if ts == "" { ts = time.Now().UTC().Format(time.RFC3339Nano) } - log.Printf( "dashboard: client[%s] ts=%s scope=%s city=%q url=%q msg=%q details=%s ua=%q", - level, - ts, - scope, - entry.City, - entry.URL, - entry.Message, - rawJSONDetails(entry.Details), - r.UserAgent(), + level, ts, scope, entry.City, entry.URL, entry.Message, + rawJSONDetails(entry.Details), ua, ) - - w.WriteHeader(http.StatusNoContent) } // injectSupervisorURL rewrites the `` diff --git a/cmd/gc/dashboard/handler_test.go b/cmd/gc/dashboard/handler_test.go index b1b1f46a4c..439ef6d66d 100644 --- a/cmd/gc/dashboard/handler_test.go +++ b/cmd/gc/dashboard/handler_test.go @@ -132,3 +132,38 @@ func TestStaticHandlerAcceptsClientLogs(t *testing.T) { t.Fatalf("client log output missing details: %s", logs.String()) } } + +func TestStaticHandlerAcceptsClientLogBatches(t *testing.T) { + h, err := NewStaticHandler("http://127.0.0.1:8372") + if err != nil { + t.Fatalf("NewStaticHandler: %v", err) + } + + var logs bytes.Buffer + oldWriter := log.Writer() + oldFlags := log.Flags() + log.SetOutput(&logs) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(oldWriter) + log.SetFlags(oldFlags) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/__client-log", strings.NewReader(`[ + {"level":"warn","scope":"sse","message":"refresh delayed","details":{"pending":2}}, + {"level":"error","scope":"api","message":"request failed","details":{"status":500}} + ]`)) + req.Header.Set("Content-Type", "application/json") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("POST /__client-log: %d %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(logs.String(), `client[warn]`) || !strings.Contains(logs.String(), `scope=sse`) { + t.Fatalf("client log batch missing warn entry: %s", logs.String()) + } + if !strings.Contains(logs.String(), `client[error]`) || !strings.Contains(logs.String(), `scope=api`) { + t.Fatalf("client log batch missing error entry: %s", logs.String()) + } +} diff --git a/cmd/gc/dashboard/web/dist/dashboard.css b/cmd/gc/dashboard/web/dist/dashboard.css index bd14f6ba46..9eb7cd7506 100644 --- a/cmd/gc/dashboard/web/dist/dashboard.css +++ b/cmd/gc/dashboard/web/dist/dashboard.css @@ -1,3 +1,4 @@ + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } :root { --bg-dark: #0f1419; --bg-card: #1a1f26; @@ -2170,7 +2171,7 @@ background: var(--green); } - /* Mayor status banner */ + /* Selected scope banner */ .scope-banner { display: flex; align-items: center; @@ -2182,16 +2183,6 @@ border: 1px solid var(--border); } - .scope-banner.attached { - border-color: var(--green); - background: rgba(166, 209, 137, 0.08); - } - - .scope-banner.detached { - border-color: var(--text-muted); - opacity: 0.7; - } - .scope-info { display: flex; align-items: center; diff --git a/cmd/gc/dashboard/web/dist/dashboard.js b/cmd/gc/dashboard/web/dist/dashboard.js index ec9931ec7b..d805b4ad1a 100644 --- a/cmd/gc/dashboard/web/dist/dashboard.js +++ b/cmd/gc/dashboard/web/dist/dashboard.js @@ -1,6 +1,6 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))a(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&a(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();const kn=/\{[^{}]+\}/g,Nn=()=>{var e,t;return typeof process=="object"&&Number.parseInt((t=(e=process==null?void 0:process.versions)==null?void 0:e.node)==null?void 0:t.substring(0,2))>=18&&process.versions.undici};function $n(){return Math.random().toString(36).slice(2,11)}function Ln(e){let{baseUrl:t="",Request:n=globalThis.Request,fetch:a=globalThis.fetch,querySerializer:r,bodySerializer:i,headers:o,requestInitExt:l=void 0,...d}={...e};l=Nn()?l:void 0,t=$t(t);const p=[];async function f(u,y){const{baseUrl:m,fetch:h=a,Request:w=n,headers:E,params:b={},parseAs:C="json",querySerializer:N,bodySerializer:I=i??Tn,body:M,...$}=y||{};let O=t;m&&(O=$t(m)??t);let A=typeof r=="function"?r:kt(r);N&&(A=typeof N=="function"?N:kt({...typeof r=="object"?r:{},...N}));const fe=M===void 0?void 0:I(M,Nt(o,E,b.header)),Ze=Nt(fe===void 0||fe instanceof FormData?{}:{"Content-Type":"application/json"},o,E,b.header),et={redirect:"follow",...d,...$,body:fe,headers:Ze};let te,pe,G=new n(An(u,{baseUrl:O,params:b,querySerializer:A}),et),x;for(const R in $)R in G||(G[R]=$[R]);if(p.length){te=$n(),pe=Object.freeze({baseUrl:O,fetch:h,parseAs:C,querySerializer:A,bodySerializer:I});for(const R of p)if(R&&typeof R=="object"&&typeof R.onRequest=="function"){const q=await R.onRequest({request:G,schemaPath:u,params:b,options:pe,id:te});if(q)if(q instanceof n)G=q;else if(q instanceof Response){x=q;break}else throw new Error("onRequest: must return new Request() or Response() when modifying the request")}}if(!x){try{x=await h(G,l)}catch(R){let q=R;if(p.length)for(let _=p.length-1;_>=0;_--){const ne=p[_];if(ne&&typeof ne=="object"&&typeof ne.onError=="function"){const Ce=await ne.onError({request:G,error:q,schemaPath:u,params:b,options:pe,id:te});if(Ce){if(Ce instanceof Response){q=void 0,x=Ce;break}if(Ce instanceof Error){q=Ce;continue}throw new Error("onError: must return new Response() or instance of Error")}}}if(q)throw q}if(p.length)for(let R=p.length-1;R>=0;R--){const q=p[R];if(q&&typeof q=="object"&&typeof q.onResponse=="function"){const _=await q.onResponse({request:G,response:x,schemaPath:u,params:b,options:pe,id:te});if(_){if(!(_ instanceof Response))throw new Error("onResponse: must return new Response() when modifying the response");x=_}}}}if(x.status===204||G.method==="HEAD"||x.headers.get("Content-Length")==="0")return x.ok?{data:void 0,response:x}:{error:void 0,response:x};if(x.ok)return C==="stream"?{data:x.body,response:x}:{data:await x[C](),response:x};let ye=await x.text();try{ye=JSON.parse(ye)}catch{}return{error:ye,response:x}}return{request(u,y,m){return f(y,{...m,method:u.toUpperCase()})},GET(u,y){return f(u,{...y,method:"GET"})},PUT(u,y){return f(u,{...y,method:"PUT"})},POST(u,y){return f(u,{...y,method:"POST"})},DELETE(u,y){return f(u,{...y,method:"DELETE"})},OPTIONS(u,y){return f(u,{...y,method:"OPTIONS"})},HEAD(u,y){return f(u,{...y,method:"HEAD"})},PATCH(u,y){return f(u,{...y,method:"PATCH"})},TRACE(u,y){return f(u,{...y,method:"TRACE"})},use(...u){for(const y of u)if(y){if(typeof y!="object"||!("onRequest"in y||"onResponse"in y||"onError"in y))throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`");p.push(y)}},eject(...u){for(const y of u){const m=p.indexOf(y);m!==-1&&p.splice(m,1)}}}}function He(e,t,n){if(t==null)return"";if(typeof t=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${e}=${(n==null?void 0:n.allowReserved)===!0?t:encodeURIComponent(t)}`}function jt(e,t,n){if(!t||typeof t!="object")return"";const a=[],r={simple:",",label:".",matrix:";"}[n.style]||"&";if(n.style!=="deepObject"&&n.explode===!1){for(const l in t)a.push(l,n.allowReserved===!0?t[l]:encodeURIComponent(t[l]));const o=a.join(",");switch(n.style){case"form":return`${e}=${o}`;case"label":return`.${o}`;case"matrix":return`;${e}=${o}`;default:return o}}for(const o in t){const l=n.style==="deepObject"?`${e}[${o}]`:o;a.push(He(l,t[o],n))}const i=a.join(r);return n.style==="label"||n.style==="matrix"?`${r}${i}`:i}function It(e,t,n){if(!Array.isArray(t))return"";if(n.explode===!1){const i={form:",",spaceDelimited:"%20",pipeDelimited:"|"}[n.style]||",",o=(n.allowReserved===!0?t:t.map(l=>encodeURIComponent(l))).join(i);switch(n.style){case"simple":return o;case"label":return`.${o}`;case"matrix":return`;${e}=${o}`;default:return`${e}=${o}`}}const a={simple:",",label:".",matrix:";"}[n.style]||"&",r=[];for(const i of t)n.style==="simple"||n.style==="label"?r.push(n.allowReserved===!0?i:encodeURIComponent(i)):r.push(He(e,i,n));return n.style==="label"||n.style==="matrix"?`${a}${r.join(a)}`:r.join(a)}function kt(e){return function(n){const a=[];if(n&&typeof n=="object")for(const r in n){const i=n[r];if(i!=null){if(Array.isArray(i)){if(i.length===0)continue;a.push(It(r,i,{style:"form",explode:!0,...e==null?void 0:e.array,allowReserved:(e==null?void 0:e.allowReserved)||!1}));continue}if(typeof i=="object"){a.push(jt(r,i,{style:"deepObject",explode:!0,...e==null?void 0:e.object,allowReserved:(e==null?void 0:e.allowReserved)||!1}));continue}a.push(He(r,i,e))}}return a.join("&")}}function xn(e,t){let n=e;for(const a of e.match(kn)??[]){let r=a.substring(1,a.length-1),i=!1,o="simple";if(r.endsWith("*")&&(i=!0,r=r.substring(0,r.length-1)),r.startsWith(".")?(o="label",r=r.substring(1)):r.startsWith(";")&&(o="matrix",r=r.substring(1)),!t||t[r]===void 0||t[r]===null)continue;const l=t[r];if(Array.isArray(l)){n=n.replace(a,It(r,l,{style:o,explode:i}));continue}if(typeof l=="object"){n=n.replace(a,jt(r,l,{style:o,explode:i}));continue}if(o==="matrix"){n=n.replace(a,`;${He(r,l)}`);continue}n=n.replace(a,o==="label"?`.${encodeURIComponent(l)}`:encodeURIComponent(l))}return n}function Tn(e,t){return e instanceof FormData?e:t&&(t.get instanceof Function?t.get("Content-Type")??t.get("content-type"):t["Content-Type"]??t["content-type"])==="application/x-www-form-urlencoded"?new URLSearchParams(e).toString():JSON.stringify(e)}function An(e,t){var r;let n=`${t.baseUrl}${e}`;(r=t.params)!=null&&r.path&&(n=xn(n,t.params.path));let a=t.querySerializer(t.params.query??{});return a.startsWith("?")&&(a=a.substring(1)),a&&(n+=`?${a}`),n}function Nt(...e){const t=new Headers;for(const n of e){if(!n||typeof n!="object")continue;const a=n instanceof Headers?n.entries():Object.entries(n);for(const[r,i]of a)if(i===null)t.delete(r);else if(Array.isArray(i))for(const o of i)t.append(r,o);else i!==void 0&&t.set(r,i)}return t}function $t(e){return e.endsWith("/")?e.substring(0,e.length-1):e}const Rn={bodySerializer:e=>JSON.stringify(e,(t,n)=>typeof n=="bigint"?n.toString():n)};function On({onRequest:e,onSseError:t,onSseEvent:n,responseTransformer:a,responseValidator:r,sseDefaultRetryDelay:i,sseMaxRetryAttempts:o,sseMaxRetryDelay:l,sseSleepFn:d,url:p,...f}){let u;const y=d??(w=>new Promise(E=>setTimeout(E,w)));return{stream:async function*(){let w=i??3e3,E=0;const b=f.signal??new AbortController().signal;for(;!b.aborted;){E++;const C=f.headers instanceof Headers?f.headers:new Headers(f.headers);u!==void 0&&C.set("Last-Event-ID",u);try{const N={redirect:"follow",...f,body:f.serializedBody,headers:C,signal:b};let I=new Request(p,N);e&&(I=await e(p,N));const $=await(f.fetch??globalThis.fetch)(I);if(!$.ok)throw new Error(`SSE failed: ${$.status} ${$.statusText}`);if(!$.body)throw new Error("No body in SSE response");const O=$.body.pipeThrough(new TextDecoderStream).getReader();let A="";const fe=()=>{try{O.cancel()}catch{}};b.addEventListener("abort",fe);try{for(;;){const{done:Ze,value:et}=await O.read();if(Ze)break;A+=et,A=A.replace(/\r\n?/g,` -`);const te=A.split(` +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))a(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&a(o)}).observe(document,{childList:!0,subtree:!0});function n(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(s){if(s.ep)return;s.ep=!0;const i=n(s);fetch(s.href,i)}})();const Mn=/\{[^{}]+\}/g,Un=()=>{var e,t;return typeof process=="object"&&Number.parseInt((t=(e=process==null?void 0:process.versions)==null?void 0:e.node)==null?void 0:t.substring(0,2))>=18&&process.versions.undici};function Dn(){return Math.random().toString(36).slice(2,11)}function Wn(e){let{baseUrl:t="",Request:n=globalThis.Request,fetch:a=globalThis.fetch,querySerializer:s,bodySerializer:i,headers:o,requestInitExt:l=void 0,...u}={...e};l=Un()?l:void 0,t=Ot(t);const y=[];async function f(d,p){const{baseUrl:m,fetch:h=a,Request:v=n,headers:C,params:b={},parseAs:N="json",querySerializer:k,bodySerializer:P=i??Gn,body:M,...x}=p||{};let q=t;m&&(q=Ot(m)??t);let R=typeof s=="function"?s:Rt(s);k&&(R=typeof k=="function"?k:Rt({...typeof s=="object"?s:{},...k}));const Y=M===void 0?void 0:P(M,qt(o,C,b.header)),me=qt(Y===void 0||Y instanceof FormData?{}:{"Content-Type":"application/json"},o,C,b.header),ge={redirect:"follow",...u,...x,body:Y,headers:me};let V,X,W=new n(Fn(d,{baseUrl:q,params:b,querySerializer:R}),ge),T;for(const L in x)L in W||(W[L]=x[L]);if(y.length){V=Dn(),X=Object.freeze({baseUrl:q,fetch:h,parseAs:N,querySerializer:R,bodySerializer:P});for(const L of y)if(L&&typeof L=="object"&&typeof L.onRequest=="function"){const _=await L.onRequest({request:W,schemaPath:d,params:b,options:X,id:V});if(_)if(_ instanceof n)W=_;else if(_ instanceof Response){T=_;break}else throw new Error("onRequest: must return new Request() or Response() when modifying the request")}}if(!T){try{T=await h(W,l)}catch(L){let _=L;if(y.length)for(let j=y.length-1;j>=0;j--){const re=y[j];if(re&&typeof re=="object"&&typeof re.onError=="function"){const Ne=await re.onError({request:W,error:_,schemaPath:d,params:b,options:X,id:V});if(Ne){if(Ne instanceof Response){_=void 0,T=Ne;break}if(Ne instanceof Error){_=Ne;continue}throw new Error("onError: must return new Response() or instance of Error")}}}if(_)throw _}if(y.length)for(let L=y.length-1;L>=0;L--){const _=y[L];if(_&&typeof _=="object"&&typeof _.onResponse=="function"){const j=await _.onResponse({request:W,response:T,schemaPath:d,params:b,options:X,id:V});if(j){if(!(j instanceof Response))throw new Error("onResponse: must return new Response() when modifying the response");T=j}}}}if(T.status===204||W.method==="HEAD"||T.headers.get("Content-Length")==="0")return T.ok?{data:void 0,response:T}:{error:void 0,response:T};if(T.ok)return N==="stream"?{data:T.body,response:T}:{data:await T[N](),response:T};let $=await T.text();try{$=JSON.parse($)}catch{}return{error:$,response:T}}return{request(d,p,m){return f(p,{...m,method:d.toUpperCase()})},GET(d,p){return f(d,{...p,method:"GET"})},PUT(d,p){return f(d,{...p,method:"PUT"})},POST(d,p){return f(d,{...p,method:"POST"})},DELETE(d,p){return f(d,{...p,method:"DELETE"})},OPTIONS(d,p){return f(d,{...p,method:"OPTIONS"})},HEAD(d,p){return f(d,{...p,method:"HEAD"})},PATCH(d,p){return f(d,{...p,method:"PATCH"})},TRACE(d,p){return f(d,{...p,method:"TRACE"})},use(...d){for(const p of d)if(p){if(typeof p!="object"||!("onRequest"in p||"onResponse"in p||"onError"in p))throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`");y.push(p)}},eject(...d){for(const p of d){const m=y.indexOf(p);m!==-1&&y.splice(m,1)}}}}function Ze(e,t,n){if(t==null)return"";if(typeof t=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${e}=${(n==null?void 0:n.allowReserved)===!0?t:encodeURIComponent(t)}`}function Ft(e,t,n){if(!t||typeof t!="object")return"";const a=[],s={simple:",",label:".",matrix:";"}[n.style]||"&";if(n.style!=="deepObject"&&n.explode===!1){for(const l in t)a.push(l,n.allowReserved===!0?t[l]:encodeURIComponent(t[l]));const o=a.join(",");switch(n.style){case"form":return`${e}=${o}`;case"label":return`.${o}`;case"matrix":return`;${e}=${o}`;default:return o}}for(const o in t){const l=n.style==="deepObject"?`${e}[${o}]`:o;a.push(Ze(l,t[o],n))}const i=a.join(s);return n.style==="label"||n.style==="matrix"?`${s}${i}`:i}function Ht(e,t,n){if(!Array.isArray(t))return"";if(n.explode===!1){const i={form:",",spaceDelimited:"%20",pipeDelimited:"|"}[n.style]||",",o=(n.allowReserved===!0?t:t.map(l=>encodeURIComponent(l))).join(i);switch(n.style){case"simple":return o;case"label":return`.${o}`;case"matrix":return`;${e}=${o}`;default:return`${e}=${o}`}}const a={simple:",",label:".",matrix:";"}[n.style]||"&",s=[];for(const i of t)n.style==="simple"||n.style==="label"?s.push(n.allowReserved===!0?i:encodeURIComponent(i)):s.push(Ze(e,i,n));return n.style==="label"||n.style==="matrix"?`${a}${s.join(a)}`:s.join(a)}function Rt(e){return function(n){const a=[];if(n&&typeof n=="object")for(const s in n){const i=n[s];if(i!=null){if(Array.isArray(i)){if(i.length===0)continue;a.push(Ht(s,i,{style:"form",explode:!0,...e==null?void 0:e.array,allowReserved:(e==null?void 0:e.allowReserved)||!1}));continue}if(typeof i=="object"){a.push(Ft(s,i,{style:"deepObject",explode:!0,...e==null?void 0:e.object,allowReserved:(e==null?void 0:e.allowReserved)||!1}));continue}a.push(Ze(s,i,e))}}return a.join("&")}}function zn(e,t){let n=e;for(const a of e.match(Mn)??[]){let s=a.substring(1,a.length-1),i=!1,o="simple";if(s.endsWith("*")&&(i=!0,s=s.substring(0,s.length-1)),s.startsWith(".")?(o="label",s=s.substring(1)):s.startsWith(";")&&(o="matrix",s=s.substring(1)),!t||t[s]===void 0||t[s]===null)continue;const l=t[s];if(Array.isArray(l)){n=n.replace(a,Ht(s,l,{style:o,explode:i}));continue}if(typeof l=="object"){n=n.replace(a,Ft(s,l,{style:o,explode:i}));continue}if(o==="matrix"){n=n.replace(a,`;${Ze(s,l)}`);continue}n=n.replace(a,o==="label"?`.${encodeURIComponent(l)}`:encodeURIComponent(l))}return n}function Gn(e,t){return e instanceof FormData?e:t&&(t.get instanceof Function?t.get("Content-Type")??t.get("content-type"):t["Content-Type"]??t["content-type"])==="application/x-www-form-urlencoded"?new URLSearchParams(e).toString():JSON.stringify(e)}function Fn(e,t){var s;let n=`${t.baseUrl}${e}`;(s=t.params)!=null&&s.path&&(n=zn(n,t.params.path));let a=t.querySerializer(t.params.query??{});return a.startsWith("?")&&(a=a.substring(1)),a&&(n+=`?${a}`),n}function qt(...e){const t=new Headers;for(const n of e){if(!n||typeof n!="object")continue;const a=n instanceof Headers?n.entries():Object.entries(n);for(const[s,i]of a)if(i===null)t.delete(s);else if(Array.isArray(i))for(const o of i)t.append(s,o);else i!==void 0&&t.set(s,i)}return t}function Ot(e){return e.endsWith("/")?e.substring(0,e.length-1):e}const Hn={bodySerializer:e=>JSON.stringify(e,(t,n)=>typeof n=="bigint"?n.toString():n)};function Jn({onRequest:e,onSseError:t,onSseEvent:n,responseTransformer:a,responseValidator:s,sseDefaultRetryDelay:i,sseMaxRetryAttempts:o,sseMaxRetryDelay:l,sseSleepFn:u,url:y,...f}){let d;const p=u??(v=>new Promise(C=>setTimeout(C,v)));return{stream:async function*(){let v=i??3e3,C=0;const b=f.signal??new AbortController().signal;for(;!b.aborted;){C++;const N=f.headers instanceof Headers?f.headers:new Headers(f.headers);d!==void 0&&N.set("Last-Event-ID",d);try{const k={redirect:"follow",...f,body:f.serializedBody,headers:N,signal:b};let P=new Request(y,k);e&&(P=await e(y,k));const x=await(f.fetch??globalThis.fetch)(P);if(!x.ok)throw new Error(`SSE failed: ${x.status} ${x.statusText}`);if(!x.body)throw new Error("No body in SSE response");const q=x.body.pipeThrough(new TextDecoderStream).getReader();let R="";const Y=()=>{try{q.cancel()}catch{}};b.addEventListener("abort",Y);try{for(;;){const{done:me,value:ge}=await q.read();if(me)break;R+=ge,R=R.replace(/\r\n?/g,` +`);const V=R.split(` -`);A=te.pop()??"";for(const pe of te){const G=pe.split(` -`),x=[];let ye;for(const _ of G)if(_.startsWith("data:"))x.push(_.replace(/^data:\s*/,""));else if(_.startsWith("event:"))ye=_.replace(/^event:\s*/,"");else if(_.startsWith("id:"))u=_.replace(/^id:\s*/,"");else if(_.startsWith("retry:")){const ne=Number.parseInt(_.replace(/^retry:\s*/,""),10);Number.isNaN(ne)||(w=ne)}let R,q=!1;if(x.length){const _=x.join(` -`);try{R=JSON.parse(_),q=!0}catch{R=_}}q&&(r&&await r(R),a&&(R=await a(R))),n==null||n({data:R,event:ye,id:u,retry:w}),x.length&&(yield R)}}}finally{b.removeEventListener("abort",fe),O.releaseLock()}break}catch(N){if(t==null||t(N),o!==void 0&&E>=o)break;const I=Math.min(w*2**(E-1),l??3e4);await y(I)}}}()}}const qn=e=>{switch(e){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},_n=e=>{switch(e){case"form":return",";case"pipeDelimited":return"|";case"spaceDelimited":return"%20";default:return","}},Pn=e=>{switch(e){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},Bt=({allowReserved:e,explode:t,name:n,style:a,value:r})=>{if(!t){const l=(e?r:r.map(d=>encodeURIComponent(d))).join(_n(a));switch(a){case"label":return`.${l}`;case"matrix":return`;${n}=${l}`;case"simple":return l;default:return`${n}=${l}`}}const i=qn(a),o=r.map(l=>a==="label"||a==="simple"?e?l:encodeURIComponent(l):Je({allowReserved:e,name:n,value:l})).join(i);return a==="label"||a==="matrix"?i+o:o},Je=({allowReserved:e,name:t,value:n})=>{if(n==null)return"";if(typeof n=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${t}=${e?n:encodeURIComponent(n)}`},Mt=({allowReserved:e,explode:t,name:n,style:a,value:r,valueOnly:i})=>{if(r instanceof Date)return i?r.toISOString():`${n}=${r.toISOString()}`;if(a!=="deepObject"&&!t){let d=[];Object.entries(r).forEach(([f,u])=>{d=[...d,f,e?u:encodeURIComponent(u)]});const p=d.join(",");switch(a){case"form":return`${n}=${p}`;case"label":return`.${p}`;case"matrix":return`;${n}=${p}`;default:return p}}const o=Pn(a),l=Object.entries(r).map(([d,p])=>Je({allowReserved:e,name:a==="deepObject"?`${n}[${d}]`:d,value:p})).join(o);return a==="label"||a==="matrix"?o+l:l},jn=/\{[^{}]+\}/g,In=({path:e,url:t})=>{let n=t;const a=t.match(jn);if(a)for(const r of a){let i=!1,o=r.substring(1,r.length-1),l="simple";o.endsWith("*")&&(i=!0,o=o.substring(0,o.length-1)),o.startsWith(".")?(o=o.substring(1),l="label"):o.startsWith(";")&&(o=o.substring(1),l="matrix");const d=e[o];if(d==null)continue;if(Array.isArray(d)){n=n.replace(r,Bt({explode:i,name:o,style:l,value:d}));continue}if(typeof d=="object"){n=n.replace(r,Mt({explode:i,name:o,style:l,value:d,valueOnly:!0}));continue}if(l==="matrix"){n=n.replace(r,`;${Je({name:o,value:d})}`);continue}const p=encodeURIComponent(l==="label"?`.${d}`:d);n=n.replace(r,p)}return n},Bn=({baseUrl:e,path:t,query:n,querySerializer:a,url:r})=>{const i=r.startsWith("/")?r:`/${r}`;let o=(e??"")+i;t&&(o=In({path:t,url:o}));let l=n?a(n):"";return l.startsWith("?")&&(l=l.substring(1)),l&&(o+=`?${l}`),o};function Lt(e){const t=e.body!==void 0;if(t&&e.bodySerializer)return"serializedBody"in e?e.serializedBody!==void 0&&e.serializedBody!==""?e.serializedBody:null:e.body!==""?e.body:null;if(t)return e.body}const Mn=async(e,t)=>{const n=typeof t=="function"?await t(e):t;if(n)return e.scheme==="bearer"?`Bearer ${n}`:e.scheme==="basic"?`Basic ${btoa(n)}`:n},Ut=({parameters:e={},...t}={})=>a=>{const r=[];if(a&&typeof a=="object")for(const i in a){const o=a[i];if(o==null)continue;const l=e[i]||t;if(Array.isArray(o)){const d=Bt({allowReserved:l.allowReserved,explode:!0,name:i,style:"form",value:o,...l.array});d&&r.push(d)}else if(typeof o=="object"){const d=Mt({allowReserved:l.allowReserved,explode:!0,name:i,style:"deepObject",value:o,...l.object});d&&r.push(d)}else{const d=Je({allowReserved:l.allowReserved,name:i,value:o});d&&r.push(d)}}return r.join("&")},Un=e=>{var n;if(!e)return"stream";const t=(n=e.split(";")[0])==null?void 0:n.trim();if(t){if(t.startsWith("application/json")||t.endsWith("+json"))return"json";if(t==="multipart/form-data")return"formData";if(["application/","audio/","image/","video/"].some(a=>t.startsWith(a)))return"blob";if(t.startsWith("text/"))return"text"}},Dn=(e,t)=>{var n,a;return t?!!(e.headers.has(t)||(n=e.query)!=null&&n[t]||(a=e.headers.get("Cookie"))!=null&&a.includes(`${t}=`)):!1},zn=async({security:e,...t})=>{for(const n of e){if(Dn(t,n.name))continue;const a=await Mn(n,t.auth);if(!a)continue;const r=n.name??"Authorization";switch(n.in){case"query":t.query||(t.query={}),t.query[r]=a;break;case"cookie":t.headers.append("Cookie",`${r}=${a}`);break;case"header":default:t.headers.set(r,a);break}}},xt=e=>Bn({baseUrl:e.baseUrl,path:e.path,query:e.query,querySerializer:typeof e.querySerializer=="function"?e.querySerializer:Ut(e.querySerializer),url:e.url}),Tt=(e,t)=>{var a;const n={...e,...t};return(a=n.baseUrl)!=null&&a.endsWith("/")&&(n.baseUrl=n.baseUrl.substring(0,n.baseUrl.length-1)),n.headers=Dt(e.headers,t.headers),n},Wn=e=>{const t=[];return e.forEach((n,a)=>{t.push([a,n])}),t},Dt=(...e)=>{const t=new Headers;for(const n of e){if(!n)continue;const a=n instanceof Headers?Wn(n):Object.entries(n);for(const[r,i]of a)if(i===null)t.delete(r);else if(Array.isArray(i))for(const o of i)t.append(r,o);else i!==void 0&&t.set(r,typeof i=="object"?JSON.stringify(i):i)}return t};class tt{constructor(){this.fns=[]}clear(){this.fns=[]}eject(t){const n=this.getInterceptorIndex(t);this.fns[n]&&(this.fns[n]=null)}exists(t){const n=this.getInterceptorIndex(t);return!!this.fns[n]}getInterceptorIndex(t){return typeof t=="number"?this.fns[t]?t:-1:this.fns.indexOf(t)}update(t,n){const a=this.getInterceptorIndex(t);return this.fns[a]?(this.fns[a]=n,t):!1}use(t){return this.fns.push(t),this.fns.length-1}}const Gn=()=>({error:new tt,request:new tt,response:new tt}),Fn=Ut({allowReserved:!1,array:{explode:!0,style:"form"},object:{explode:!0,style:"deepObject"}}),Hn={"Content-Type":"application/json"},zt=(e={})=>({...Rn,headers:Hn,parseAs:"auto",querySerializer:Fn,...e}),Jn=(e={})=>{let t=Tt(zt(),e);const n=()=>({...t}),a=f=>(t=Tt(t,f),n()),r=Gn(),i=async f=>{const u={...t,...f,fetch:f.fetch??t.fetch??globalThis.fetch,headers:Dt(t.headers,f.headers),serializedBody:void 0};u.security&&await zn({...u,security:u.security}),u.requestValidator&&await u.requestValidator(u),u.body!==void 0&&u.bodySerializer&&(u.serializedBody=u.bodySerializer(u.body)),(u.body===void 0||u.serializedBody==="")&&u.headers.delete("Content-Type");const y=u,m=xt(y);return{opts:y,url:m}},o=async f=>{const{opts:u,url:y}=await i(f),m={redirect:"follow",...u,body:Lt(u)};let h=new Request(y,m);for(const $ of r.request.fns)$&&(h=await $(h,u));const w=u.fetch;let E;try{E=await w(h)}catch($){let O=$;for(const A of r.error.fns)A&&(O=await A($,void 0,h,u));if(O=O||{},u.throwOnError)throw O;return u.responseStyle==="data"?void 0:{error:O,request:h,response:void 0}}for(const $ of r.response.fns)$&&(E=await $(E,h,u));const b={request:h,response:E};if(E.ok){const $=(u.parseAs==="auto"?Un(E.headers.get("Content-Type")):u.parseAs)??"json";if(E.status===204||E.headers.get("Content-Length")==="0"){let A;switch($){case"arrayBuffer":case"blob":case"text":A=await E[$]();break;case"formData":A=new FormData;break;case"stream":A=E.body;break;case"json":default:A={};break}return u.responseStyle==="data"?A:{data:A,...b}}let O;switch($){case"arrayBuffer":case"blob":case"formData":case"text":O=await E[$]();break;case"json":{const A=await E.text();O=A?JSON.parse(A):{};break}case"stream":return u.responseStyle==="data"?E.body:{data:E.body,...b}}return $==="json"&&(u.responseValidator&&await u.responseValidator(O),u.responseTransformer&&(O=await u.responseTransformer(O))),u.responseStyle==="data"?O:{data:O,...b}}const C=await E.text();let N;try{N=JSON.parse(C)}catch{}const I=N??C;let M=I;for(const $ of r.error.fns)$&&(M=await $(I,E,h,u));if(M=M||{},u.throwOnError)throw M;return u.responseStyle==="data"?void 0:{error:M,...b}},l=f=>u=>o({...u,method:f}),d=f=>async u=>{const{opts:y,url:m}=await i(u);return On({...y,body:y.body,headers:y.headers,method:f,onRequest:async(h,w)=>{let E=new Request(h,w);for(const b of r.request.fns)b&&(E=await b(E,y));return E},serializedBody:Lt(y),url:m})};return{buildUrl:f=>xt({...t,...f}),connect:l("CONNECT"),delete:l("DELETE"),get:l("GET"),getConfig:n,head:l("HEAD"),interceptors:r,options:l("OPTIONS"),patch:l("PATCH"),post:l("POST"),put:l("PUT"),request:o,setConfig:a,sse:{connect:d("CONNECT"),delete:d("DELETE"),get:d("GET"),head:d("HEAD"),options:d("OPTIONS"),patch:d("PATCH"),post:d("POST"),put:d("PUT"),trace:d("TRACE")},trace:l("TRACE")}},le=Jn(zt()),Wt={debug:console.debug.bind(console),error:console.error.bind(console),info:console.info.bind(console),log:console.log.bind(console),warn:console.warn.bind(console)};let At=!1;function Vn(){At||typeof window>"u"||(At=!0,ke("debug","debug"),ke("info","info"),ke("warn","warn"),ke("error","error"),ke("log","info"),window.addEventListener("error",e=>{oe("window","Unhandled error",{colno:e.colno,error:e.error,filename:e.filename,lineno:e.lineno,message:e.message})}),window.addEventListener("unhandledrejection",e=>{oe("window","Unhandled promise rejection",{reason:e.reason})}))}function he(e,t,n){Ke("debug",e,t,n)}function J(e,t,n){Ke("info",e,t,n)}function Ve(e,t,n){Ke("warn",e,t,n)}function oe(e,t,n){Ke("error",e,t,n)}function Ke(e,t,n,a){const r=Gt(e,t,n,a);Wt[e](`[dashboard][${t}] ${n}`,We(a)),Ft(r)}function ke(e,t){const n=Wt[e];console[e]=(...a)=>{n(...a),Ft(Gt(t,"console",Qn(a),a.length>1?a.slice(1):a[0]))}}function Gt(e,t,n,a){return{city:Kn(),details:a===void 0?void 0:We(a),level:e,message:n,scope:t,ts:new Date().toISOString(),url:typeof window>"u"?"":window.location.href}}function Kn(){return typeof window>"u"?"":(new URLSearchParams(window.location.search).get("city")??"").trim()}function Qn(e){if(e.length===0)return"console event";const[t]=e;return typeof t=="string"&&t.trim()!==""?t:t instanceof Error?t.message:"console event"}function Ft(e){const t=JSON.stringify(e);if(typeof navigator<"u"&&typeof navigator.sendBeacon=="function"){const n=new Blob([t],{type:"application/json"});if(navigator.sendBeacon("/__client-log",n))return}fetch("/__client-log",{body:t,credentials:"same-origin",headers:{"Content-Type":"application/json"},keepalive:!0,method:"POST"}).catch(()=>{})}function We(e,t=0,n=new WeakSet){if(e==null)return e??null;if(typeof e=="string")return e.length>2e3?`${e.slice(0,1999)}…`:e;if(typeof e=="number"||typeof e=="boolean")return e;if(e instanceof Error)return{message:e.message,name:e.name,stack:e.stack};if(typeof e=="function")return`[function ${e.name||"anonymous"}]`;if(t>=4)return"[max-depth]";if(Array.isArray(e))return e.slice(0,20).map(a=>We(a,t+1,n));if(typeof e=="object"){if(n.has(e))return"[circular]";n.add(e);const a={};for(const[r,i]of Object.entries(e).slice(0,40))a[r]=We(i,t+1,n);return a}return String(e)}const ft=["cities","status","supervisor","crew","issues","mail","convoys","activity","admin","options"];let Ge=Vt(window.location.search),pt=[];const ze=new Set(ft);function Xn(){return Ge}function yt(){return Ge=Vt(window.location.search),Ge}function se(...e){e.forEach(t=>ze.add(t))}function mt(){se(...ft)}function Yn(e=!1){if(e)return ze.clear(),new Set(ft);const t=new Set(ze);return ze.clear(),t}function Zn(e){pt=e.map(t=>({error:t.error,name:t.name,path:t.path,phasesCompleted:[...t.phasesCompleted??[]],running:t.running,status:t.status}))}function Ht(){return pt.map(e=>({error:e.error,name:e.name,path:e.path,phasesCompleted:[...e.phasesCompleted],running:e.running,status:e.status}))}function Jt(){const e=Ge;if(e==="")return{kind:"supervisor"};const t=pt.find(n=>n.name===e);return t?t.running?{kind:"running",city:t}:{kind:"not-running",city:t}:{kind:"unknown",name:e}}function ea(e){if(e){if(e.startsWith("session.")||e.startsWith("agent.")){se("status","crew","options");return}if(e.startsWith("bead.")){se("status","issues","convoys","admin","options");return}if(e.startsWith("mail.")){se("status","mail","options");return}if(e.startsWith("convoy.")){se("status","convoys");return}if(e.startsWith("city.")){se("cities","status","supervisor");return}if(e.startsWith("service.")||e.startsWith("provider.")||e.startsWith("rig.")){se("admin");return}}}function Vt(e){return(new URLSearchParams(e).get("city")??"").trim()}function Kt(){const e=document.querySelector('meta[name="supervisor-url"]');return((e==null?void 0:e.content)??"").replace(/\/+$/,"")}function S(){return Xn()}const T={"X-GC-Request":"true"},g=Ln({baseUrl:Kt(),headers:T});le.setConfig({baseUrl:Kt(),headers:T});g.use({async onError({error:e,request:t,schemaPath:n}){return oe("api","Request failed",{error:e,method:t.method,schemaPath:n,url:t.url}),e instanceof Error?e:new Error(String(e))},async onRequest({params:e,request:t,schemaPath:n}){he("api","Request start",{method:t.method,params:e,schemaPath:n,url:t.url})},async onResponse({request:e,response:t,schemaPath:n}){const a={method:e.method,ok:t.ok,schemaPath:n,status:t.status,url:e.url};if(!t.ok||t.status>=400){Ve("api","Request response",a);return}he("api","Request response",a)}});function s(e,t={},n=[]){const a=document.createElement(e);for(const[r,i]of Object.entries(t))i===void 0||i===!1||(i===!0?a.setAttribute(r,""):a.setAttribute(r,String(i)));for(const r of n)r!=null&&a.append(typeof r=="string"?document.createTextNode(r):r);return a}function k(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function c(e){return document.getElementById(e)}async function ta(){const e=c("city-tabs");if(!e)return;const{data:t,error:n}=await g.GET("/v0/cities");!n&&(t!=null&&t.items)&&Zn(t.items.map(l=>({error:l.error??void 0,name:l.name??"",path:l.path??void 0,phasesCompleted:l.phases_completed??[],running:l.running===!0,status:l.status??void 0})));const a=Ht();if(n||a.length===0)return;const r=S();k(e);const i=s("nav",{class:"city-tabs"}),o=window.location.pathname||"/";i.append(s("a",{href:o,class:`city-tab${r===""?" active":""}`},[s("span",{class:"city-dot running"})," Supervisor"]));for(const l of a){const d=l.running,p=l.name===r,f=s("a",{href:`${o}?city=${encodeURIComponent(l.name)}`,class:`city-tab${p?" active":""}${d?"":" stopped"}`},[s("span",{class:`city-dot${d?" running":""}`}),` ${l.name}`]);i.append(f)}e.append(i)}function gt(e,t=new Date){if(!e)return"";const n=new Date(e);if(isNaN(n.getTime()))return"";const a=Math.max(0,t.getTime()-n.getTime()),r=Math.floor(a/1e3);if(r<60)return`${r}s ago`;const i=Math.floor(r/60);if(i<60)return`${i}m ago`;const o=Math.floor(i/60);return o<24?`${o}h ago`:`${Math.floor(o/24)}d ago`}const Qt=300*1e3,na=600*1e3;function D(e){if(!e)return"—";const t=new Date(e);if(Number.isNaN(t.getTime()))return"—";const n=new Date,a=t.getFullYear()===n.getFullYear()?{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}:{month:"short",day:"numeric",year:"numeric",hour:"numeric",minute:"2-digit"};return t.toLocaleString(void 0,a)}function _e(e){if(!e)return{display:"unknown",colorClass:"unknown"};const t=new Date(e);if(Number.isNaN(t.getTime()))return{display:"unknown",colorClass:"unknown"};const n=Math.max(0,Date.now()-t.getTime()),a=gt(e).replace(" ago","");return n=3?`${t[t.length-1]} (${t[0]}/${t[1]})`:`${t[0]}/${t[t.length-1]}`}function aa(e){return!e||!e.includes("/")?"":e.split("/",1)[0]??""}function sa(e){return e.startsWith("agent.")||e.startsWith("session.")?"agent":e.startsWith("bead.")||e.startsWith("convoy.")||e.startsWith("order.")?"work":e.startsWith("mail.")?"comms":"system"}function ra(e){return{"session.started":"▶","session.ended":"■","session.crashed":"☠","session.suspended":"⏸","session.woke":"▶","agent.message":"💬","agent.output":"📝","agent.tool_call":"🛠","agent.tool_result":"✅","agent.error":"⚠","bead.created":"📿","bead.updated":"📝","bead.closed":"✅","convoy.created":"🚚","convoy.closed":"✅","mail.delivered":"📬","mail.read":"📨"}[e]??"📋"}function ia(e,t,n,a){const r=B(t);switch(e){case"session.started":return`${B(n)} started`;case"session.ended":return`${B(n)} ended`;case"session.crashed":return`${B(n)} crashed`;case"session.suspended":return`${B(n)} suspended`;case"session.woke":return`${B(n)} woke`;case"bead.created":return`${r} created bead ${n??""}`.trim();case"bead.updated":return`${r} updated bead ${n??""}`.trim();case"bead.closed":return`${r} closed bead ${n??""}`.trim();case"mail.delivered":return`${r} delivered mail`;case"mail.read":return`${r} read mail`;case"convoy.created":return`${r} created convoy ${n??""}`.trim();case"convoy.closed":return`${r} closed convoy ${n??""}`.trim();default:return a??n??e}}function Qe(e,t){return e?e.length<=t?e:`${e.slice(0,t-1)}…`:""}function ee(e){return typeof e!="number"||Number.isNaN(e)||e<=0?4:e}function Xt(e){switch(ee(e)){case 1:return"badge-red";case 2:return"badge-orange";case 3:return"badge-yellow";default:return"badge-muted"}}function ce(e){switch((e??"").toLowerCase()){case"open":case"running":case"ready":case"working":return"badge-green";case"in_progress":case"pending":case"stale":case"warning":return"badge-yellow";case"closed":case"stopped":return"badge-muted";case"error":case"failed":case"stuck":return"badge-red";default:return"badge-blue"}}async function oa(){var w,E,b;const e=S(),t=c("status-banner");if(!t)return;if(!e){await ca(t);return}da();const[n,a,r,i]=await Promise.all([g.GET("/v0/city/{cityName}/status",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:e},query:{state:"active",peek:!0}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open",limit:500}}}),g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},query:{limit:200}}})]);if(n.error||!n.data){k(t),t.append(s("div",{class:"banner-error"},[`Status unavailable for ${e}`]));return}const o=((w=a.data)==null?void 0:w.items)??[],l=((E=r.data)==null?void 0:E.items)??[],d=((b=i.data)==null?void 0:b.items)??[];la(e,o);const p=o.filter(C=>!C.pool||!C.running||!C.last_active?!1:Date.now()-new Date(C.last_active).getTime()>=1800*1e3).length,f=l.filter(C=>C.assignee&&C.status!=="closed").length,u=l.filter(C=>ee(C.priority)<=2).length,y=o.filter(C=>!C.running).length,m=s("div",{class:"summary-stats"},[H(n.data.agents.running,"Agents"),H(n.data.work.in_progress,"Assigned"),H(n.data.work.open,"Beads"),H(d.length,"Convoys"),H(n.data.mail.unread,"Unread")]),h=s("div",{class:"summary-alerts"});Q(h,p>0,"alert-red",`${p} stuck`),Q(h,f>0,"alert-yellow",`${f} assigned`),Q(h,u>0,"alert-red",`${u} P1/P2`),Q(h,y>0,"alert-red",`${y} dead`),h.childNodes.length||h.append(s("span",{class:"alert-item alert-green"},["All clear"])),k(t),t.append(m,h)}async function ca(e){var u,y;ua();const[t,n]=await Promise.all([g.GET("/health"),g.GET("/v0/cities")]),a=t.data,r=((u=n.data)==null?void 0:u.items)??[],i=(a==null?void 0:a.cities_total)??r.length,o=(a==null?void 0:a.cities_running)??r.filter(m=>m.running===!0).length,l=Math.max(i-o,0),d=r.filter(m=>!!m.error).length;if(k(e),t.error&&n.error){e.append(s("div",{class:"banner-error"},["Supervisor status unavailable"]));return}const p=s("div",{class:"summary-stats"},[H(i,"🏙️ Cities"),H(o,"🟢 Running"),H(l,"⏸ Stopped"),H(fa(a==null?void 0:a.uptime_sec),"⏱ Uptime")]),f=s("div",{class:"summary-alerts"});Q(f,i===0,"alert-yellow","No registered cities"),Q(f,l>0,"alert-yellow",`${l} ${l===1?"city":"cities"} not running`),Q(f,d>0,"alert-red",`${d} ${d===1?"city":"cities"} reporting errors`),Q(f,!!(a!=null&&a.startup&&!a.startup.ready),"alert-yellow",`⏳ Startup: ${((y=a==null?void 0:a.startup)==null?void 0:y.phase)||"starting"}`),f.childNodes.length||f.append(s("span",{class:"alert-item alert-green"},["✓ Supervisor ready"])),e.append(p,f)}function H(e,t){return s("div",{class:"stat"},[s("span",{class:"stat-value"},[String(e??0)]),s("span",{class:"stat-label"},[t])])}function Q(e,t,n,a){t&&e.append(s("span",{class:`alert-item ${n}`},[a]))}function la(e,t){const n=c("scope-banner"),a=c("scope-badge"),r=c("scope-status");if(!n||!a||!r)return;const i=t.find(l=>!l.rig&&!l.pool);if(!i){n.classList.remove("attached"),n.classList.add("detached"),a.className="badge badge-muted",a.textContent="Detached",k(r),r.append(K("Scope",e),K("Overseer","none"));return}n.classList.remove("attached","detached"),n.classList.add(i.attached?"attached":"detached"),a.className=`badge ${i.attached?"badge-green":"badge-muted"}`,a.textContent=i.attached?"Attached":"Detached",k(r);const o=i.last_active?Date.now()-new Date(i.last_active).getTime()(e.client??le).sse.get({url:"/v0/city/{cityName}/events/stream",...e}),ya=e=>(e.client??le).sse.get({url:"/v0/city/{cityName}/session/{id}/stream",...e}),ma=e=>((e==null?void 0:e.client)??le).sse.get({url:"/v0/events/stream",...e});let re=0,rt=null;function ga(e){rt=e}function Yt(e){re=Math.max(0,e),document.body.dataset.pauseRefresh=re>0?"true":"false"}function F(){Yt(re+1)}function j(){const e=re>0;if(Yt(re-1),e&&re===0&&rt)try{rt()}catch(t){oe("ui","popPause listener threw",{error:String(t)})}}function bt(){return re>0}function Rt(e,t){const n=c("output-panel"),a=c("output-panel-cmd"),r=c("output-panel-content");!n||!a||!r||(a.textContent=e,r.textContent=t,n.classList.add("open"))}function Zt(){var e;(e=c("output-panel"))==null||e.classList.remove("open")}function v(e,t,n){const a=c("toast-container");if(!a)return;const r=document.createElement("div");r.className=`toast toast-${e}`,r.innerHTML=`${Ot(t)}
${Ot(n)}
`,a.append(r);const i=e==="error"?9e3:5e3;window.requestAnimationFrame(()=>{r.classList.add("show")}),window.setTimeout(()=>{r.classList.remove("show"),window.setTimeout(()=>{r.remove()},300)},i)}function P(e,t,n="Unexpected dashboard error"){const a=t instanceof Error?t.message:n;oe("ui",e,{error:t,fallbackMessage:n,message:a}),v("error",e,a)}function ba(){var e,t;document.addEventListener("click",n=>{const a=n.target,r=a==null?void 0:a.closest(".collapse-btn");if(r){const p=r.closest(".panel");p==null||p.classList.toggle("collapsed");return}const i=a==null?void 0:a.closest(".expand-btn");if(!i)return;const o=i.closest(".panel");if(!o)return;const l=o.classList.contains("expanded"),d=!!document.querySelector(".panel.expanded");if(document.querySelectorAll(".panel.expanded").forEach(p=>{p.classList.remove("expanded");const f=p.querySelector(".expand-btn");f&&(f.textContent="Expand")}),l){j();return}o.classList.add("expanded"),i.textContent="✕ Close",d||F()}),document.addEventListener("keydown",n=>{if(n.key!=="Escape")return;const a=document.querySelector(".panel.expanded");if(a){a.classList.remove("expanded");const r=a.querySelector(".expand-btn");r&&(r.textContent="Expand"),j()}}),(e=c("output-close-btn"))==null||e.addEventListener("click",()=>Zt()),(t=c("output-copy-btn"))==null||t.addEventListener("click",async()=>{var a;const n=((a=c("output-panel-content"))==null?void 0:a.textContent)??"";try{await navigator.clipboard.writeText(n),v("success","Copied","Output copied to clipboard")}catch{v("error","Copy failed","Clipboard write was rejected")}})}function Ot(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function en(e){return typeof e=="object"&&e!==null}function tn(e){return en(e)&&typeof e.timestamp=="string"}function nn(e){return en(e)&&typeof e.actor=="string"&&typeof e.seq=="number"&&typeof e.ts=="string"&&typeof e.type=="string"}function ha(e){return nn(e)}function va(e){return nn(e)&&typeof e.city=="string"}const qt=[1e3,2e3,4e3,8e3,15e3],wa=15e3;function an(e){return e{var o;let r=0,i=!1;for(;!n.signal.aborted;){try{const{stream:d}=await ma({client:le,signal:n.signal,onSseEvent:p=>{var u;r=0,i=!1,(u=t==null?void 0:t.onStatus)==null||u.call(t,"live");const f=p.event??"tagged_event";if(f==="heartbeat"){if(!tn(p.data)){P("Invalid supervisor heartbeat frame",p);return}e({event:"heartbeat",id:p.id,data:p.data});return}if(f==="tagged_event"){if(!va(p.data)){P("Invalid supervisor event frame",p);return}e({event:"tagged_event",id:p.id,data:p.data});return}P(`Unexpected supervisor SSE event: ${f}`,p)}});for await(const p of d);if(n.signal.aborted)break}catch(d){if(n.signal.aborted)return;i||(P("Supervisor event stream failed",d),i=!0)}(o=t==null?void 0:t.onStatus)==null||o.call(t,"reconnecting");const l=an(r);r+=1,await sn(l,n.signal)}})(),{close:()=>n.abort()}}function Ea(e,t,n){var r;const a=new AbortController;return(r=n==null?void 0:n.onStatus)==null||r.call(n,"connecting"),(async()=>{var l;let i=0,o=!1;for(;!a.signal.aborted;){try{const{stream:p}=await pa({client:le,path:{cityName:e},signal:a.signal,onSseEvent:f=>{var m;i=0,o=!1,(m=n==null?void 0:n.onStatus)==null||m.call(n,"live");const u=f.event??"event",y=f.id!==void 0?String(f.id):void 0;if(u==="heartbeat"){if(!tn(f.data)){P("Invalid city heartbeat frame",f);return}t({event:"heartbeat",id:y,data:f.data});return}if(u==="event"){if(!ha(f.data)){P("Invalid city event frame",f);return}t({event:"event",id:y,data:f.data});return}P(`Unexpected city SSE event: ${u}`,f)}});for await(const f of p);if(a.signal.aborted)break}catch(p){if(a.signal.aborted)return;o||(P("City event stream failed",p),o=!0)}(l=n==null?void 0:n.onStatus)==null||l.call(n,"reconnecting");const d=an(i);i+=1,await sn(d,a.signal)}})(),{close:()=>a.abort()}}async function sn(e,t){if(!t.aborted)return new Promise(n=>{const a=setTimeout(()=>{t.removeEventListener("abort",r),n()},e),r=()=>{clearTimeout(a),t.removeEventListener("abort",r),n()};t.addEventListener("abort",r)})}function Ca(e,t,n){const a=new AbortController;return(async()=>{try{const{stream:r}=await ya({client:le,path:{cityName:e,id:t},signal:a.signal,onSseEvent:i=>{if(i.data===void 0){P("Session frame missing data",i);return}n({id:i.id!==void 0?String(i.id):void 0,type:i.event??"message",data:i.data})}});for await(const i of r);}catch(r){a.signal.aborted||P("Session stream failed",r)}})(),{close:()=>a.abort()}}function ka(e){return e.event==="heartbeat"?"heartbeat":e.data.type}let Te=null,ge="",X="",Pe=0;async function Na(){const e=S();if(!e){$a();return}const t=c("crew-loading"),n=c("crew-table"),a=c("crew-empty"),r=c("crew-tbody"),i=c("rigged-body"),o=c("pooled-body");if(!t||!n||!a||!r||!i||!o)return;it("No crew configured"),t.style.display="block",n.style.display="none",a.style.display="none",k(r);const{data:l,error:d}=await g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:e},query:{state:"active",peek:!0}}});if(d||!(l!=null&&l.items)){t.textContent="Failed to load crew",ve(i,"No rigged agents"),ve(o,"No pooled agents");return}const p=l.items,f=await Promise.all(p.map(async m=>{var w;return!!((w=(await g.GET("/v0/city/{cityName}/session/{id}/pending",{params:{path:{cityName:e,id:m.id}}})).data)!=null&&w.pending)})),u=new Map;await Promise.all(p.map(async m=>{var w;if(!m.active_bead||u.has(m.active_bead))return;const h=await g.GET("/v0/city/{cityName}/bead/{id}",{params:{path:{cityName:e,id:m.active_bead}}});u.set(m.active_bead,(w=h.data)!=null&&w.id?h.data.title??h.data.id:m.active_bead)}));const y=p;y.forEach((m,h)=>{const w=La(m,f[h]??!1),E=m.active_bead?Qe(u.get(m.active_bead)??m.active_bead,24):"—",b=s("tr",{},[s("td",{},[m.template]),s("td",{},[m.rig??"city"]),s("td",{},[s("span",{class:`badge ${ce(w)}`},[w])]),s("td",{},[E]),s("td",{class:_e(m.last_active).colorClass?`activity-${_e(m.last_active).colorClass}`:""},[s("span",{class:"activity-dot"}),` ${_e(m.last_active).display}`]),s("td",{},[s("span",{class:`badge ${m.attached?"badge-green":"badge-muted"}`},[m.attached?"Attached":"Detached"])]),s("td",{},[xa(m.template)," ",rn(m.id,m.template)])]);r.append(b)}),c("crew-count").textContent=String(y.length),t.style.display="none",y.length>0?n.style.display="table":(it("No crew configured"),a.style.display="block"),Ta(p,u),Aa(p)}function $a(){const e=c("crew-loading"),t=c("crew-table"),n=c("crew-empty"),a=c("crew-tbody"),r=c("rigged-body"),i=c("pooled-body");!e||!t||!n||!a||!r||!i||(je(),c("crew-count").textContent="0",c("rigged-count").textContent="0",c("pooled-count").textContent="0",e.style.display="none",t.style.display="none",n.style.display="block",it("Select a city to view crew"),k(a),ve(r,"Select a city to view rigged agents"),ve(i,"Select a city to view pooled agents"))}function it(e){var t,n;(n=(t=c("crew-empty"))==null?void 0:t.querySelector("p"))==null||n.replaceChildren(document.createTextNode(e))}function La(e,t){return t?"questions":e.active_bead?"spinning":e.running?"idle":"finished"}function xa(e){const t=s("button",{class:"attach-btn",type:"button"},["📎 Attach"]);return t.addEventListener("click",async()=>{const n=`gc agent attach ${e}`;try{await navigator.clipboard.writeText(n),v("success","Attach command copied",n)}catch{v("error","Copy failed",n)}}),t}function rn(e,t){const n=s("button",{class:"agent-log-link",type:"button","data-session-id":e},[t]);return n.addEventListener("click",()=>{Oa(e,t)}),n}function Ta(e,t){const n=c("rigged-body"),a=c("rigged-count");if(!n||!a)return;const r=e.filter(o=>o.rig&&o.pool);if(a.textContent=String(r.length),r.length===0){ve(n,"No rigged agents");return}const i=s("tbody");r.forEach(o=>{const l=_e(o.last_active),d=o.active_bead?l.colorClass==="red"?"Stuck":l.colorClass==="yellow"?"Stale":"Working":"Idle";i.append(s("tr",{class:`rigged-${d.toLowerCase()}`},[s("td",{},[rn(o.id,o.template)]),s("td",{},[s("span",{class:"badge badge-muted"},[o.pool??"pool"])]),s("td",{},[o.rig??"city"]),s("td",{class:"rigged-issue"},[o.active_bead?`${o.active_bead} ${t.get(o.active_bead)??""}`.trim():"—"]),s("td",{},[s("span",{class:`badge ${ce(d)}`},[d])]),s("td",{class:`activity-${l.colorClass}`},[s("span",{class:"activity-dot"}),` ${l.display}`])]))}),k(n),n.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Agent"]),s("th",{},["Pool"]),s("th",{},["Rig"]),s("th",{},["Working On"]),s("th",{},["Status"]),s("th",{},["Activity"])])]),i]))}function Aa(e){const t=c("pooled-body"),n=c("pooled-count");if(!t||!n)return;const a=e.filter(i=>!i.rig&&i.pool);if(n.textContent=String(a.length),a.length===0){ve(t,"No pooled agents");return}const r=s("tbody");a.forEach(i=>{r.append(s("tr",{},[s("td",{},[i.template]),s("td",{},[s("span",{class:`badge ${i.active_bead?"badge-yellow":"badge-green"}`},[i.active_bead?"Working":"Idle"])]),s("td",{class:"status-hint"},[Qe(i.last_output,80)||"—"]),s("td",{},[D(i.last_active)])]))}),k(t),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Agent"]),s("th",{},["State"]),s("th",{},["Work"]),s("th",{},["Activity"])])]),r]))}function ve(e,t){k(e),e.append(s("div",{class:"empty-state"},[s("p",{},[t])]))}function Ra(){var e,t;(e=c("log-drawer-close-btn"))==null||e.addEventListener("click",()=>je()),(t=c("log-drawer-older-btn"))==null||t.addEventListener("click",()=>{he("crew","Load older transcript clicked",{hasCursor:X!=="",sessionID:ge}),!(!ge||!X)&&cn(ge,!0)})}async function Oa(e,t){const n=c("agent-log-drawer"),a=c("log-drawer-agent-name"),r=c("log-drawer-messages"),i=c("log-drawer-loading");if(!n||!a||!r||!i)return;if(ge===e&&n.style.display!=="none"){je();return}je(),ge=e,X="",Pe=0,a.textContent=t,k(r),r.append(i),i.style.display="block",n.style.display="block",F(),await cn(e,!1);const o=S();o&&(Te=Ca(o,e,l=>qa(l)))}function je(){Te==null||Te.close(),Te=null,ge="",X="";const e=c("agent-log-drawer");e&&e.style.display!=="none"&&(e.style.display="none",j())}function on(){je()}async function cn(e,t){var p,f,u,y,m;const n=S(),a=c("log-drawer-messages"),r=c("log-drawer-loading"),i=c("log-drawer-older-btn"),o=c("log-drawer-count");if(!n||!a||!r||!i||!o)return;r.style.display="block";const l=await g.GET("/v0/city/{cityName}/session/{id}/transcript",{params:{path:{cityName:n,id:e},query:{tail:String(t?50:25),before:t?X:void 0}}});if(r.style.display="none",l.error||!l.data){v("error","Transcript failed",((p=l.error)==null?void 0:p.detail)??"Could not load transcript");return}const d=document.createDocumentFragment();for(const h of l.data.turns??[])d.append(ln(h.role,h.text,h.timestamp)),Pe+=1;t?a.prepend(d):(k(a),a.append(d)),a.append(r),r.style.display="none",o.textContent=String(Pe),X=((f=l.data.pagination)==null?void 0:f.truncated_before_message)??"",i.style.display=(u=l.data.pagination)!=null&&u.has_older_messages&&X?"inline-flex":"none",he("crew","Transcript loaded",{hasOlderMessages:((y=l.data.pagination)==null?void 0:y.has_older_messages)??!1,nextBeforeCursor:X,prepend:t,sessionID:e,turnCount:((m=l.data.turns)==null?void 0:m.length)??0})}function qa(e){var r;const t=c("log-drawer-messages");if(!t)return;const n=e.data;if(e.type!=="message"||!((r=n==null?void 0:n.data)!=null&&r.message))return;t.append(ln(n.data.message.role??"agent",n.data.message.text??"",n.data.message.timestamp)),Pe+=1,c("log-drawer-count").textContent=String(Pe);const a=c("log-drawer-body");a&&(a.scrollTop=a.scrollHeight)}function ln(e,t,n){return s("div",{class:"log-msg"},[s("div",{class:"log-msg-header"},[s("span",{class:`log-msg-type log-msg-type-${_a(e)}`},[e]),s("span",{class:"log-msg-time"},[D(n)])]),s("div",{class:"log-msg-body"},[t])])}function _a(e){switch((e??"").toLowerCase()){case"assistant":case"agent":return"assistant";case"system":return"system";case"result":return"result";default:return"user"}}const Pa=3e4,ot=new Map,Ae=new Map;async function Xe(e=!1){const t=S(),n=Date.now(),a=ot.get(t);if(!e&&a&&n-a.fetchedAt(ot.set(t,o),Ae.delete(t),o)).catch(o=>{throw Ae.delete(t),o});return Ae.set(t,i),i}async function ja(e){var l,d,p,f,u,y,m,h,w,E,b,C;const t={agents:[],rigs:[],sessions:[],beads:[],mail:[],fetchedAt:Date.now()};if(!e)return t;const[n,a,r,i]=await Promise.all([g.GET("/v0/city/{cityName}/config",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open"}}}),g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:e}}})]);n.error&&Ve("options","Config options request failed",{city:e,detail:n.error.detail??null});const o=(((l=n.data)==null?void 0:l.agents)??[]).map(N=>({id:N.name??"",label:N.name??"",recipient:N.name??""})).filter(N=>N.recipient!=="");return he("options","Fetched options",{agentOptions:o.map(N=>N.recipient),beads:((p=(d=r.data)==null?void 0:d.items)==null?void 0:p.length)??0,city:e,configAgents:((u=(f=n.data)==null?void 0:f.agents)==null?void 0:u.length)??0,mail:((m=(y=i.data)==null?void 0:y.items)==null?void 0:m.length)??0,rigs:((w=(h=a.data)==null?void 0:h.items)==null?void 0:w.length)??0}),{agents:[...new Set(o.map(N=>N.recipient))].sort(),rigs:(((E=a.data)==null?void 0:E.items)??[]).map(N=>N.name??"").filter(Boolean),sessions:o,beads:(((b=r.data)==null?void 0:b.items)??[]).map(N=>({id:N.id??"",title:N.title??""})),mail:(((C=i.data)==null?void 0:C.items)??[]).map(N=>({id:N.id??"",subject:N.subject??""})),fetchedAt:Date.now()}}function Ia(){ot.clear(),Ae.clear()}let Re=null,Oe=null;function Ba(){var e,t,n,a,r,i,o,l,d,p;(e=c("action-modal-close-btn"))==null||e.addEventListener("click",()=>Ne(null)),(t=c("action-modal-cancel-btn"))==null||t.addEventListener("click",()=>Ne(null)),(a=(n=c("action-modal"))==null?void 0:n.querySelector(".modal-backdrop"))==null||a.addEventListener("click",()=>Ne(null)),(r=c("action-form"))==null||r.addEventListener("submit",f=>{var h,w,E;f.preventDefault();const u=((h=c("action-bead-id"))==null?void 0:h.value.trim())??"",y=((w=c("action-target"))==null?void 0:w.value.trim())??"",m=((E=c("action-rig"))==null?void 0:E.value.trim())??"";!u||!y||Ne({beadID:u,rig:m,target:y})}),(i=c("confirm-modal-close-btn"))==null||i.addEventListener("click",()=>$e(!1)),(o=c("confirm-modal-cancel-btn"))==null||o.addEventListener("click",()=>$e(!1)),(l=c("confirm-modal-confirm-btn"))==null||l.addEventListener("click",()=>$e(!0)),(p=(d=c("confirm-modal"))==null?void 0:d.querySelector(".modal-backdrop"))==null||p.addEventListener("click",()=>$e(!1)),document.addEventListener("keydown",f=>{if(f.key==="Escape"){if(we("action-modal")){Ne(null);return}we("confirm-modal")&&$e(!1)}})}async function ht(e){const t=c("action-modal"),n=c("action-form"),a=c("action-modal-title"),r=c("action-modal-submit-btn"),i=c("action-bead-group"),o=c("action-bead-id"),l=c("action-bead-hint"),d=c("action-target"),p=c("action-target-label"),f=c("action-rig-group"),u=c("action-rig"),y=c("action-modal-help"),m=c("action-target-list"),h=c("action-rig-list");if(!t||!n||!a||!r||!i||!o||!l||!d||!p||!f||!u||!y||!m||!h)return P("Action modal unavailable",new Error("missing action modal DOM")),null;const w=await Xe();return _t(m,w.agents),_t(h,w.rigs),a.textContent=e.title,r.textContent=Ua(e.mode),p.textContent=e.mode==="reassign"?"Assignee":"Target agent or pool",y.textContent=Da(e.mode),o.value=e.beadID??"",o.readOnly=!!e.beadID,i.classList.toggle("readonly",o.readOnly),l.textContent=e.beadLabel??"",d.value=e.initialTarget??"",u.value=e.initialRig??"",f.hidden=e.mode==="reassign",u.disabled=e.mode==="reassign",we("action-modal")||F(),t.style.display="flex",window.setTimeout(()=>{if(e.beadID){d.focus();return}o.focus()},0),new Promise(E=>{Re=E})}async function Ma(e){const t=c("confirm-modal"),n=c("confirm-modal-title"),a=c("confirm-modal-body"),r=c("confirm-modal-confirm-btn");return!t||!n||!a||!r?(P("Confirm modal unavailable",new Error("missing confirm modal DOM")),!1):(n.textContent=e.title,a.textContent=e.body,r.textContent=e.confirmLabel,we("confirm-modal")||F(),t.style.display="flex",new Promise(i=>{Oe=i}))}function _t(e,t){k(e),t.forEach(n=>{e.append(s("option",{value:n}))})}function Ua(e){switch(e){case"assign":return"Assign";case"reassign":return"Reassign";default:return"Sling"}}function Da(e){switch(e){case"assign":return"Launch a bead directly to a target, with an optional rig override.";case"reassign":return"Pick a new assignee from the active city sessions or type one manually.";default:return"Dispatch this bead to a target, with an optional rig constraint."}}function Ne(e){const t=c("action-modal"),n=c("action-form");if(!t||!n)return;const a=we("action-modal");t.style.display="none",n.reset(),c("action-rig").disabled=!1,c("action-bead-id").readOnly=!1,a&&j(),Re==null||Re(e),Re=null}function $e(e){const t=c("confirm-modal");if(!t)return;const n=we("confirm-modal");t.style.display="none",n&&j(),Oe==null||Oe(e),Oe=null}function we(e){var t;return((t=c(e))==null?void 0:t.style.display)==="flex"}let Fe=[],ct="ready",Se="all",Ye="";async function de(){var o,l,d,p;const e=S(),t=c("issues-list");if(!t)return;if(!e){za();return}const[n,a,r]=await Promise.all([g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}}),Xe(!0)]);if(n.error&&a.error||!((o=n.data)!=null&&o.items)&&!((l=a.data)!=null&&l.items)){k(t),t.append(s("div",{class:"panel-error"},["Could not load beads."]));return}Fe=[...((d=n.data)==null?void 0:d.items)??[],...((p=a.data)==null?void 0:p.items)??[]].filter(f=>!Wa(f)).sort((f,u)=>{const y=ee(f.priority),m=ee(u.priority);return y!==m?y-m:(u.created_at??"").localeCompare(f.created_at??"")}),c("issues-count").textContent=String(Fe.length);const i=c("rig-filter-tabs");i&&(k(i),i.append(lt("all",Se==="all")),r.rigs.forEach(f=>i.append(lt(f,Se===f)))),vt()}function za(){const e=c("issues-list"),t=c("rig-filter-tabs"),n=c("issue-detail");if(!e||!t||!n)return;me();const a=n.style.display==="block";n.style.display="none",e.style.display="block",k(e),e.append(s("div",{class:"empty-state"},[s("p",{},["Select a city to view beads"])])),k(t),Se="all",Ye="",Fe=[],t.append(lt("all",!0)),c("issues-count").textContent="0",a&&j()}function vt(){const e=c("issues-list");if(!e)return;k(e);const t=Fe.filter(a=>{const r=a.assignee?"progress":"ready",i=ct==="all"||ct===r,o=Se==="all"||nt(a)===Se;return i&&o});if(t.length===0){e.append(s("div",{class:"empty-state"},[s("p",{},["No beads"])]));return}const n=s("tbody");t.forEach(a=>{const r=s("tr",{class:`issue-row priority-${ee(a.priority)}`,"data-issue-id":a.id??"","data-status":a.assignee?"progress":"ready","data-rig":nt(a)},[s("td",{},[s("span",{class:`badge ${Xt(a.priority)}`},[`P${ee(a.priority)}`])]),s("td",{},[s("span",{class:"issue-id"},[a.id??""])]),s("td",{class:"issue-title"},[Qe(a.title??a.id??"",80)]),s("td",{class:"issue-rig"},[nt(a)]),s("td",{class:"issue-status"},[a.assignee?s("span",{class:"badge badge-blue",title:a.assignee},[a.assignee]):s("span",{class:"badge badge-green"},["Ready"])]),s("td",{class:"issue-age"},[D(a.created_at)]),s("td",{},[ts(a.id??"")])]);r.addEventListener("click",i=>{i.target.closest(".sling-btn")||a.id&&ue(a.id)}),n.append(r)}),e.append(s("table",{id:"work-table"},[s("thead",{},[s("tr",{},[s("th",{},["Pri"]),s("th",{},["ID"]),s("th",{},["Title"]),s("th",{},["Rig"]),s("th",{},["Status"]),s("th",{},["Age"]),s("th",{},["Actions"])])]),n]))}function lt(e,t){const n=s("button",{class:`rig-btn${t?" active":""}`,"data-rig":e},[e==="all"?"All":e]);return n.addEventListener("click",()=>{Se=e,document.querySelectorAll(".rig-btn").forEach(a=>a.classList.remove("active")),n.classList.add("active"),vt()}),n}function nt(e){var t;return((t=e.id)==null?void 0:t.split("-")[0])??"city"}function Wa(e){return(e.issue_type??"").toLowerCase()==="convoy"?!0:(e.labels??[]).some(t=>t.startsWith("gc:queue")||t.startsWith("gc:message"))}function Ga(){var e,t,n,a,r,i,o;document.querySelectorAll(".tab-btn").forEach(l=>{l.addEventListener("click",d=>{const p=d.currentTarget;ct=p.dataset.tab??"ready",document.querySelectorAll(".tab-btn").forEach(f=>f.classList.remove("active")),p.classList.add("active"),vt()})}),(e=c("new-issue-btn"))==null||e.addEventListener("click",()=>dn()),(t=c("issue-modal-close-btn"))==null||t.addEventListener("click",()=>me()),(n=c("issue-modal-cancel-btn"))==null||n.addEventListener("click",()=>me()),(r=(a=c("issue-modal"))==null?void 0:a.querySelector(".modal-backdrop"))==null||r.addEventListener("click",()=>me()),(i=c("issue-form"))==null||i.addEventListener("submit",l=>{l.preventDefault(),Fa()}),(o=c("issue-back-btn"))==null||o.addEventListener("click",()=>Qa()),document.addEventListener("keydown",l=>{var d;l.key==="Escape"&&((d=c("issue-modal"))==null?void 0:d.style.display)==="block"&&me()})}function dn(){var t,n,a;if(!S()){v("info","No city selected","Select a city to create a bead");return}const e=c("issue-modal");e&&(e.style.display!=="block"&&F(),e.style.display="block",(n=(t=c("issues-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),(a=c("issue-title"))==null||a.focus())}function me(){var n;const e=c("issue-modal");if(!e)return;const t=e.style.display==="block";e.style.display="none",(n=c("issue-form"))==null||n.reset(),t&&j()}async function Fa(){var r,i,o;const e=((r=c("issue-title"))==null?void 0:r.value.trim())??"",t=((i=c("issue-description"))==null?void 0:i.value.trim())??"",n=Number(((o=c("issue-priority"))==null?void 0:o.value)??"2");if(!e)return;const a=await ns({title:e,description:t,priority:n});if(!a.ok){v("error","Create failed",a.error??"Could not create issue");return}v("success","Issue created",e),me(),await de()}async function ue(e){var l,d,p;const t=S();if(!t)return;Ye=e,((l=c("issue-detail"))==null?void 0:l.style.display)!=="block"&&F(),c("issues-list").style.display="none",c("issue-detail").style.display="block";const[n,a,r]=await Promise.all([g.GET("/v0/city/{cityName}/bead/{id}",{params:{path:{cityName:t,id:e}}}),g.GET("/v0/city/{cityName}/bead/{id}/deps",{params:{path:{cityName:t,id:e}}}),Xe()]);if(n.error||!n.data){v("error","Issue failed",((d=n.error)==null?void 0:d.detail)??"Could not load bead");return}const i=n.data;c("issue-detail-id").textContent=i.id??e,c("issue-detail-title-text").textContent=i.title??e,c("issue-detail-description").textContent=i.description||"(no description)";const o=c("issue-detail-priority");o.className=`badge ${Xt(i.priority)}`,o.textContent=`P${ee(i.priority)}`,c("issue-detail-status").textContent=i.status??"open",c("issue-detail-status").className=`issue-status ${i.status??"open"}`,c("issue-detail-type").textContent=i.issue_type?`Type: ${i.issue_type}`:"",c("issue-detail-owner").textContent=i.assignee?`Owner: ${i.assignee}`:"Owner: unassigned",c("issue-detail-created").textContent=i.created_at?`Created: ${D(i.created_at)}`:"",Ja(i,r.agents),Ha(((p=a.data)==null?void 0:p.children)??[])}function Ha(e){const t=c("issue-detail-deps"),n=c("issue-detail-depends-on"),a=c("issue-detail-blocks-section"),r=c("issue-detail-blocks");if(!(!t||!n||!a||!r)){if(k(n),k(r),e.length===0){t.style.display="none",a.style.display="none";return}t.style.display="block",e.forEach(i=>{const o=s("span",{class:"issue-dep-item","data-issue-id":i.id??""},[`→ ${i.id??""}`]);o.addEventListener("click",()=>{i.id&&ue(i.id)}),n.append(o)}),a.style.display="none"}}function Ja(e,t){const n=c("issue-detail-actions");if(!n||!e.id)return;k(n);const a=s("div",{class:"issue-actions-bar"}),r=e.status==="closed"?at("↺ Reopen","reopen",()=>void Ya(e.id)):at("✓ Close","close",()=>void Xa(e.id));a.append(r),e.status!=="closed"&&a.append(at("🚚 Sling","sling",()=>void un(e.id)));const i=s("div",{class:"issue-action-group"},[s("label",{class:"issue-action-label"},["Priority"]),Va(e.id,e.priority)]),o=s("div",{class:"issue-action-group"},[s("label",{class:"issue-action-label"},["Assign"]),Ka(e.id,e.assignee,t)]);n.append(a,i,o)}function at(e,t,n){const a=s("button",{class:`issue-action-btn ${t}`,type:"button"},[e]);return a.addEventListener("click",n),a}function Va(e,t){const n=s("select",{class:"issue-action-select",id:"issue-action-priority"});return[1,2,3,4].forEach(a=>{const r=s("option",{value:a,selected:ee(t)===a},[`P${a}`]);n.append(r)}),n.addEventListener("change",()=>{Za(e,Number(n.value))}),n}function Ka(e,t,n){const a=s("select",{class:"issue-action-select",id:"issue-action-assignee"});return a.append(s("option",{value:""},["Unassigned"])),n.forEach(r=>{a.append(s("option",{value:r,selected:t===r},[r]))}),a.addEventListener("change",()=>{es(e,a.value)}),a}function Qa(){const e=c("issue-detail"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("issues-list").style.display="block",Ye="",t&&j()}async function Xa(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/close",{params:{path:{cityName:t,id:e},header:T}});if(n.error){v("error","Close failed",n.error.detail??"Could not close issue");return}v("success","Closed",e),await de(),await ue(e)}async function Ya(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/reopen",{params:{path:{cityName:t,id:e},header:T}});if(n.error){v("error","Reopen failed",n.error.detail??"Could not reopen issue");return}v("success","Reopened",e),await de(),await ue(e)}async function Za(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/update",{params:{path:{cityName:n,id:e},header:T},body:{priority:t}});if(a.error){v("error","Priority failed",a.error.detail??"Could not update priority");return}v("success","Priority updated",`${e} → P${t}`),await de(),await ue(e)}async function es(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:n,id:e},header:T},body:{assignee:t}});if(a.error){v("error","Assign failed",a.error.detail??"Could not update assignee");return}v("success","Assignment updated",t||"Unassigned"),await de(),await ue(e)}async function un(e){const t=S();if(!t)return;const n=await ht({beadID:e,beadLabel:e,mode:"sling",title:"Sling Bead"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/sling",{params:{path:{cityName:t},header:T},body:{bead:e,target:n.target,rig:n.rig||void 0}});if(a.error){v("error","Sling failed",a.error.detail??"Could not sling issue");return}v("success","Work assigned",`${e} → ${n.target}`),await de(),Ye===e&&await ue(e)}function ts(e){const t=s("button",{class:"sling-btn",type:"button","data-bead-id":e},["Sling"]);return t.addEventListener("click",n=>{n.stopPropagation(),un(e)}),t}async function ns(e){const t=S();if(!t)return{ok:!1,error:"no city selected"};const{error:n}=await g.POST("/v0/city/{cityName}/beads",{params:{path:{cityName:t},header:T},body:{title:e.title,description:e.description,rig:e.rig,priority:e.priority,assignee:e.assignee}});return n?{ok:!1,error:n.detail??n.title??"create failed"}:{ok:!0}}let U="inbox",qe=[],L=null;async function Ue(){const e=S(),t=c("mail-loading"),n=c("mail-threads"),a=c("mail-empty"),r=c("mail-all");if(!t||!n||!a||!r)return;if(!e){as();return}wt("No mail in inbox"),t.style.display="block",n.style.display="none",a.style.display="none";const{data:i,error:o}=await g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:e},query:{status:"all",limit:200}}});if(t.style.display="none",o||!(i!=null&&i.items)){k(n),n.append(s("div",{class:"panel-error"},["Could not load mail."])),n.style.display="block";return}qe=[...i.items].sort((l,d)=>(d.created_at??"").localeCompare(l.created_at??"")),c("mail-count").textContent=String(qe.length),ss(qe),rs(qe),cs()}function as(){const e=c("mail-loading"),t=c("mail-threads"),n=c("mail-empty"),a=c("mail-all");if(!e||!t||!n||!a)return;ie()?(z(U),j()):z(U),L=null,qe=[],c("mail-count").textContent="0",e.style.display="none",k(t),k(a),t.style.display="none",wt("Select a city to view mail"),n.style.display=U==="inbox"?"block":"none",a.append(s("div",{class:"empty-state"},[s("p",{},["Select a city to view mail traffic"])]))}function wt(e){var t,n;(n=(t=c("mail-empty"))==null?void 0:t.querySelector("p"))==null||n.replaceChildren(document.createTextNode(e))}function ss(e){const t=c("mail-threads"),n=c("mail-empty");if(!t||!n)return;const a=ms(e);if(k(t),a.length===0){t.style.display="none",wt("No mail in inbox"),n.style.display="block";return}n.style.display="none",a.forEach(r=>{const i=r.messages[r.messages.length-1],o=(i.body??"").trim().slice(0,60),l=s("div",{class:`mail-thread${r.unreadCount>0?" mail-thread-unread":""}`},[s("div",{class:"mail-thread-header"},[s("div",{class:"mail-thread-left"},[s("span",{class:"mail-from"},[B(i.from)])]),s("div",{class:"mail-thread-center"},[s("span",{class:"mail-subject"},[r.subject||"(no subject)"]),o?s("span",{class:"mail-thread-preview"},[` — ${o}`]):null]),s("div",{class:"mail-thread-right"},[s("span",{class:"mail-time"},[gt(i.created_at)]),r.unreadCount>0?s("span",{class:"badge badge-unread"},[`${r.unreadCount} unread`]):null])])]);l.addEventListener("click",()=>{is(r.id)}),t.append(l)}),t.style.display=U==="inbox"?"block":"none"}function rs(e){const t=c("mail-all");if(!t)return;if(k(t),e.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No mail traffic"])]));return}const n=s("tbody");e.forEach(a=>{const r=s("tr",{class:`mail-row${a.read?"":" mail-unread"}`},[s("td",{class:"mail-from"},[B(a.from)]),s("td",{class:"mail-to"},[B(a.to)]),s("td",{},[s("span",{class:"mail-subject"},[a.subject??"(no subject)"])]),s("td",{class:"mail-time"},[D(a.created_at)])]);r.addEventListener("click",()=>{a.id&&os(a.id)}),n.append(r)}),t.append(s("table",{class:"mail-all-table"},[s("thead",{},[s("tr",{},[s("th",{},["From"]),s("th",{},["To"]),s("th",{},["Subject"]),s("th",{},["Time"])])]),n])),t.style.display=U==="all"?"block":"none"}async function is(e){var i,o;const t=S();if(!t)return;const n=await g.GET("/v0/city/{cityName}/mail/thread/{id}",{params:{path:{cityName:t,id:e}}});if(n.error||!((i=n.data)!=null&&i.items)||n.data.items.length===0){v("error","Thread failed",((o=n.error)==null?void 0:o.detail)??"Could not load mail thread");return}const a=n.data.items,r=a[a.length-1]??a[0];L=r,fn(r,a)}async function os(e){var a;const t=S();if(!t)return;const n=await g.GET("/v0/city/{cityName}/mail/{id}",{params:{path:{cityName:t,id:e}}});if(n.error||!n.data){v("error","Message failed",((a=n.error)==null?void 0:a.detail)??"Could not load message");return}L=n.data,await g.POST("/v0/city/{cityName}/mail/{id}/read",{params:{path:{cityName:t,id:e},header:T}}),L.read=!0,fn(L,[L]),Ue()}function fn(e,t){const n=ie();c("mail-detail-subject").textContent=e.subject??"(no subject)",c("mail-detail-from").textContent=B(e.from),c("mail-detail-time").textContent=D(e.created_at);const a=c("mail-detail-body");a&&(k(a),t.forEach((r,i)=>{i>0&&a.append(s("hr")),a.append(s("div",{class:"mail-thread-msg-header"},[s("span",{class:"mail-from"},[B(r.from)]),s("span",{class:"mail-time"},[D(r.created_at)])]),s("div",{class:"mail-thread-msg-subject"},[r.subject??"(no subject)"]),s("pre",{},[r.body??""]))})),pn(),z("detail"),yn("mail-detail"),n||F()}function z(e){const t=c("mail-list"),n=c("mail-all"),a=c("mail-detail"),r=c("mail-compose");!t||!n||!a||!r||(t.style.display=e==="inbox"?"block":"none",n.style.display=e==="all"?"block":"none",a.style.display=e==="detail"?"block":"none",r.style.display=e==="compose"?"block":"none")}function cs(){var e,t;((e=c("mail-compose"))==null?void 0:e.style.display)==="block"||((t=c("mail-detail"))==null?void 0:t.style.display)==="block"||z(U)}function ls(){var e,t,n,a,r,i,o,l;document.querySelectorAll(".mail-tab").forEach(d=>{d.addEventListener("click",p=>{const f=p.currentTarget;U=f.dataset.tab??"inbox",document.querySelectorAll(".mail-tab").forEach(u=>u.classList.remove("active")),f.classList.add("active"),z(U)})}),(e=c("mail-back-btn"))==null||e.addEventListener("click",()=>{const d=ie();z(U),L=null,d&&j()}),(t=c("compose-mail-btn"))==null||t.addEventListener("click",()=>{dt()}),(n=c("compose-back-btn"))==null||n.addEventListener("click",()=>{const d=!!L,p=ie();z(d?"detail":U),p&&!d&&j()}),(a=c("compose-cancel-btn"))==null||a.addEventListener("click",()=>{const d=ie();z(U),d&&j()}),(r=c("mail-reply-btn"))==null||r.addEventListener("click",()=>{L!=null&&L.id&&dt(L)}),(i=c("mail-send-btn"))==null||i.addEventListener("click",()=>{ds()}),(o=c("mail-archive-btn"))==null||o.addEventListener("click",()=>{L!=null&&L.id&&us(L.id)}),(l=c("mail-toggle-unread-btn"))==null||l.addEventListener("click",()=>{L!=null&&L.id&&fs(L)})}async function dt(e){if(!S()){v("info","No city selected","Select a city to compose mail"),Ve("mail","Compose blocked without city",{replyTo:(e==null?void 0:e.id)??null});return}const t=c("compose-to");if(!t)return;const n=ie();k(t),t.append(s("option",{value:""},["Select recipient…"]));try{const a=await Xe();a.sessions.forEach(r=>{t.append(s("option",{value:r.recipient},[r.label]))}),J("mail","Compose options loaded",{city:S(),recipients:a.sessions.length,replyTo:(e==null?void 0:e.id)??null})}catch(a){oe("mail","Compose options failed",{city:S(),error:a}),P("Mail options failed",a,"Could not load recipients")}c("compose-subject").value=e?ps(e.subject??""):"",c("compose-body").value="",c("compose-reply-to").value=(e==null?void 0:e.id)??"",c("mail-compose-title").textContent=e?"Reply":"New Message",e!=null&&e.from&&(ys(t,e.from),t.value=e.from),z("compose"),yn("compose-subject"),J("mail","Compose form opened",{city:S(),replyTo:(e==null?void 0:e.id)??null,selectedRecipient:t.value||null}),n||F()}async function ds(){var l,d,p,f;const e=S();if(!e)return;const t=((l=c("compose-to"))==null?void 0:l.value)??"",n=((d=c("compose-subject"))==null?void 0:d.value.trim())??"",a=((p=c("compose-body"))==null?void 0:p.value)??"",r=((f=c("compose-reply-to"))==null?void 0:f.value)??"";if(!t||!n){v("error","Missing fields","Recipient and subject are required"),Ve("mail","Send blocked by missing fields",{bodyLength:a.length,city:e,subject:n,to:t});return}J("mail","Send requested",{bodyLength:a.length,city:e,replyTo:r||null,subject:n,to:t});const i=r?await g.POST("/v0/city/{cityName}/mail/{id}/reply",{params:{path:{cityName:e,id:r},header:T},body:{body:a,subject:n}}):await g.POST("/v0/city/{cityName}/mail",{params:{path:{cityName:e},header:T},body:{to:t,subject:n,body:a,from:"dashboard"}});if(i.error){oe("mail","Send failed",{bodyLength:a.length,city:e,error:i.error,replyTo:r||null,subject:n,to:t}),v("error","Send failed",i.error.detail??"Could not send message");return}J("mail","Send succeeded",{bodyLength:a.length,city:e,replyTo:r||null,subject:n,to:t}),v("success","Message sent",n);const o=ie();z("inbox"),L=null,o&&j(),await Ue()}async function us(e){var r;const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/mail/{id}/archive",{params:{path:{cityName:t,id:e},header:T}});if(n.error){v("error","Archive failed",n.error.detail??"Could not archive message");return}v("success","Archived",e);const a=((r=c("mail-detail"))==null?void 0:r.style.display)==="block";z(U),L=null,a&&j(),await Ue()}async function fs(e){const t=S();if(!t||!e.id)return;const n=e.read?"/v0/city/{cityName}/mail/{id}/mark-unread":"/v0/city/{cityName}/mail/{id}/read",a=await g.POST(n,{params:{path:{cityName:t,id:e.id},header:T}});if(a.error){v("error","Update failed",a.error.detail??"Could not update message");return}e.read=!e.read,L={...e},pn(),v("success","Updated",e.subject??e.id),await Ue()}function pn(){const e=c("mail-toggle-unread-btn");e&&(e.textContent=L!=null&&L.read?"Mark unread":"Mark read")}function ie(){var e,t;return((e=c("mail-detail"))==null?void 0:e.style.display)==="block"||((t=c("mail-compose"))==null?void 0:t.style.display)==="block"}function ps(e){return e?e.toLowerCase().startsWith("re:")?e:`Re: ${e}`:"Re:"}function ys(e,t){!t||[...e.options].some(n=>n.value===t)||e.append(s("option",{value:t},[t]))}function yn(e){var t,n;(n=(t=c("mail-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),window.setTimeout(()=>{var a;(a=c(e))==null||a.focus()},0)}function ms(e){const t=new Map;e.forEach(i=>{i.id&&t.set(i.id,i)});function n(i){let o=i;const l=new Set;for(;o.reply_to&&o.id&&!l.has(o.id);){l.add(o.id);const d=t.get(o.reply_to);if(!d)break;o=d}return o.thread_id??o.id??Math.random().toString(36)}const a=new Map;e.forEach(i=>{const o=n(i),l=a.get(o)??{id:o,messages:[],subject:i.subject??"",unreadCount:0};l.messages.push(i),i.read||(l.unreadCount+=1),!l.subject&&i.subject&&(l.subject=i.subject),a.set(o,l)});const r=[...a.values()];return r.forEach(i=>{i.messages.sort((o,l)=>(o.created_at??"").localeCompare(l.created_at??""))}),r.sort((i,o)=>{var p,f;const l=((p=i.messages[i.messages.length-1])==null?void 0:p.created_at)??"";return(((f=o.messages[o.messages.length-1])==null?void 0:f.created_at)??"").localeCompare(l)}),r}let be="";async function St(){var o;const e=S(),t=c("convoy-list");if(!t)return;if(!e){gs();return}const n=await g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},query:{limit:200}}});if(n.error||!((o=n.data)!=null&&o.items)){k(t),t.append(s("div",{class:"panel-error"},["Could not load convoys."]));return}const r=(await Promise.all(n.data.items.map(async l=>bs(e,l.id??"")))).filter(l=>l!==null);if(c("convoy-count").textContent=String(r.length),k(t),r.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No active convoys"])]));return}const i=s("tbody");r.forEach(l=>{const d=s("tr",{class:"convoy-row","data-convoy-id":l.id},[s("td",{},[s("span",{class:`badge ${ce(mn(l))}`},[hs(l)])]),s("td",{},[s("span",{class:"convoy-id"},[l.id]),l.title?s("div",{class:"convoy-title"},[l.title]):null,l.assignees.length?s("div",{class:"convoy-assignees"},l.assignees.map(p=>s("span",{class:"assignee-chip"},[p]))):null]),s("td",{class:"convoy-progress-cell"},[s("div",{class:"convoy-progress-header"},[s("span",{class:"convoy-progress-fraction"},[`${l.closed}/${l.total}`]),l.total>0?s("span",{class:"convoy-progress-pct"},[`${l.progressPct}%`]):null]),l.total>0?s("div",{class:"progress-bar"},[s("div",{class:"progress-fill",style:`width: ${l.progressPct}%;`})]):null]),s("td",{class:"convoy-work-cell"},[s("div",{class:"convoy-work-breakdown"},[l.ready>0?s("span",{class:"work-chip work-ready"},[`${l.ready} ready`]):null,l.inProgress>0?s("span",{class:"work-chip work-inprogress"},[`${l.inProgress} active`]):null,l.closed===l.total&&l.total>0?s("span",{class:"work-chip work-done"},["all done"]):null])]),s("td",{class:`activity-${l.lastActivity.colorClass}`},[s("span",{class:"activity-dot"}),` ${l.lastActivity.display}`])]);d.addEventListener("click",()=>{bn(l.id)}),i.append(d)}),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Status"]),s("th",{},["Convoy"]),s("th",{},["Progress"]),s("th",{},["Work"]),s("th",{},["Activity"])])]),i]))}function gs(){const e=c("convoy-list"),t=c("convoy-detail"),n=c("convoy-create-form");if(!e||!t||!n)return;const a=t.style.display==="block"||n.style.display==="block";be="",c("convoy-count").textContent="0",t.style.display="none",n.style.display="none",c("convoy-add-issue-form").style.display="none",e.style.display="block",k(e),e.append(s("div",{class:"empty-state"},[s("p",{},["Select a city to view convoys"])])),a&&j()}async function bs(e,t){var f,u,y,m;if(!t)return null;const n=await g.GET("/v0/city/{cityName}/convoy/{id}",{params:{path:{cityName:e,id:t}}});if(n.error||!n.data)return null;const a=n.data.children??[],r=new Set;let i=0,o=0,l="";a.forEach(h=>{(h.status??"").toLowerCase()!=="closed"&&(h.assignee?(o+=1,r.add(h.assignee)):i+=1),l=[l,h.created_at??""].sort().slice(-1)[0]??l});const d=((f=n.data.progress)==null?void 0:f.total)??a.length,p=((u=n.data.progress)==null?void 0:u.closed)??a.filter(h=>h.status==="closed").length;return{id:t,title:((y=n.data.convoy)==null?void 0:y.title)??t,status:(m=n.data.convoy)==null?void 0:m.status,progressPct:d>0?Math.round(p/d*100):0,total:d,closed:p,ready:i,inProgress:o,assignees:[...r].sort(),lastActivity:_e(l)}}function mn(e){return e.total>0&&e.closed===e.total?"done":e.inProgress>0?"active":e.ready>0?"waiting":e.status??"open"}function hs(e){switch(mn(e)){case"done":return"✓ Done";case"active":return"Active";case"waiting":return"Waiting";default:return e.status??"Open"}}function vs(){var e,t,n,a,r,i,o,l;(e=c("new-convoy-btn"))==null||e.addEventListener("click",()=>{gn()}),(t=c("convoy-back-btn"))==null||t.addEventListener("click",()=>ws()),(n=c("convoy-create-back-btn"))==null||n.addEventListener("click",()=>ut()),(a=c("convoy-create-cancel-btn"))==null||a.addEventListener("click",()=>ut()),(r=c("convoy-create-submit-btn"))==null||r.addEventListener("click",()=>{Ss()}),(i=c("convoy-add-issue-btn"))==null||i.addEventListener("click",()=>{c("convoy-add-issue-form").style.display="flex"}),(o=c("convoy-add-issue-cancel"))==null||o.addEventListener("click",()=>{c("convoy-add-issue-form").style.display="none"}),(l=c("convoy-add-issue-submit"))==null||l.addEventListener("click",()=>{Es()})}function gn(){var n;if(!S()){v("info","No city selected","Select a city to create a convoy");return}const e=c("convoy-create-form"),t=(e==null?void 0:e.style.display)==="block";be="",c("convoy-list").style.display="none",c("convoy-detail").style.display="none",e.style.display="block",c("convoy-create-name").value="",c("convoy-create-issues").value="",t||F(),hn("convoy-create-name"),(n=c("convoy-create-name"))==null||n.focus()}async function bn(e){var l,d,p,f,u,y,m,h;const t=S();if(!t)return;be=e,((l=c("convoy-detail"))==null?void 0:l.style.display)!=="block"&&F(),c("convoy-list").style.display="none",c("convoy-create-form").style.display="none",c("convoy-detail").style.display="block",hn("convoy-detail"),c("convoy-detail-id").textContent=e,c("convoy-detail-title").textContent=`Convoy: ${e}`,c("convoy-issues-loading").style.display="block",c("convoy-issues-table").style.display="none",c("convoy-issues-empty").style.display="none",c("convoy-add-issue-form").style.display="none";const n=await g.GET("/v0/city/{cityName}/convoy/{id}",{params:{path:{cityName:t,id:e}}});if(c("convoy-issues-loading").style.display="none",n.error||!n.data){c("convoy-issues-empty").style.display="block",c("convoy-issues-empty").querySelector("p").textContent=((d=n.error)==null?void 0:d.detail)??"Failed to load convoy";return}const a=((p=n.data.progress)==null?void 0:p.total)??((f=n.data.children)==null?void 0:f.length)??0,r=((u=n.data.progress)==null?void 0:u.closed)??((y=n.data.children)==null?void 0:y.filter(w=>w.status==="closed").length)??0;c("convoy-detail-status").className=`badge ${ce(((m=n.data.convoy)==null?void 0:m.status)??"open")}`,c("convoy-detail-status").textContent=((h=n.data.convoy)==null?void 0:h.status)??"open",c("convoy-detail-progress").textContent=`${r}/${a}`;const i=c("convoy-issues-tbody");if(!i)return;k(i);const o=n.data.children??[];if(o.length===0){c("convoy-issues-empty").style.display="block";return}o.forEach(w=>{const E=w.assignee?w.assignee:w.status==="closed"?"done":"ready";i.append(s("tr",{},[s("td",{class:"convoy-issue-status"},[s("span",{class:`badge ${ce(w.status)}`},[w.status??"unknown"])]),s("td",{},[s("span",{class:"issue-id"},[w.id??""])]),s("td",{class:"issue-title"},[w.title??w.id??""]),s("td",{},[w.assignee?s("span",{class:"badge badge-blue"},[w.assignee]):s("span",{class:"badge badge-muted"},["Unassigned"])]),s("td",{},[E])]))}),c("convoy-issues-table").style.display="table"}function ws(){const e=c("convoy-detail"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("convoy-list").style.display="block",t&&j()}function ut(){const e=c("convoy-create-form"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("convoy-list").style.display="block",t&&j()}async function Ss(){var r,i;const e=S();if(!e)return;const t=((r=c("convoy-create-name"))==null?void 0:r.value.trim())??"",n=(((i=c("convoy-create-issues"))==null?void 0:i.value)??"").split(/\s+/).map(o=>o.trim()).filter(Boolean);if(!t){v("error","Missing name","Convoy name is required");return}const a=await g.POST("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},header:T},body:{title:t,items:n}});if(a.error){v("error","Create failed",a.error.detail??"Could not create convoy");return}v("success","Convoy created",t),ut(),await St()}async function Es(){const e=S();if(!e||!be)return;const t=c("convoy-add-issue-input"),n=(t==null?void 0:t.value.trim())??"";if(!n)return;const a=await g.POST("/v0/city/{cityName}/convoy/{id}/add",{params:{path:{cityName:e,id:be},header:T},body:{items:[n]}});if(a.error){v("error","Add failed",a.error.detail??"Could not add issue");return}t&&(t.value=""),c("convoy-add-issue-form").style.display="none",v("success","Issue added",n),await bn(be),await St()}function hn(e){var t,n;(n=(t=c("convoy-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),window.setTimeout(()=>{var a;(a=c(e))==null||a.focus()},0)}const Cs=150,W=[];let Y=null,Ie="all",Be="all",Me="all";async function ks(e){W.splice(0,W.length,...wn(e)),Z()}async function Ns(){var a;const e=S(),n=(((a=(e?await g.GET("/v0/city/{cityName}/events",{params:{path:{cityName:e},query:{since:"1h",limit:100}}}):await g.GET("/v0/events",{params:{query:{since:"1h"}}})).data)==null?void 0:a.items)??[]).map(r=>Rs(r)).filter(r=>r!==null);await ks(n)}function $s(e,t){const n=S();Y==null||Y.close();const a=t?{onStatus:t}:void 0;Y=(n?i=>Ea(n,i,a):i=>Sa(i,a))(i=>{const o=En(i);e==null||e(i,o);const l=As(i);if(l){if(W.some(d=>d.id===l.id)){he("activity","Duplicate stream event ignored",{id:l.id,type:l.type});return}W.splice(0,W.length,...wn([l,...W])),Z()}})}function Ls(){Y==null||Y.close(),Y=null}function Z(){Ts();const e=c("activity-feed");if(!e)return;k(e);const t=W.filter(a=>!(Ie!=="all"&&a.category!==Ie||Be!=="all"&&a.rig!==Be||Me!=="all"&&a.actor!==Me));if(c("activity-count").textContent=String(W.length),t.length===0){e.append(s("div",{class:"empty-state"},[s("p",{},["No recent activity"])]));return}const n=s("div",{class:"tl-timeline",id:"activity-timeline"});t.forEach(a=>{n.append(s("div",{class:`tl-entry ${Ps(a.category)}`,"data-category":a.category,"data-rig":a.rig,"data-agent":a.actor??"","data-type":a.type,"data-ts":a.ts},[s("div",{class:"tl-rail"},[s("span",{class:"tl-time"},[gt(a.ts)]),s("span",{class:"tl-node"})]),s("div",{class:"tl-content"},[s("div",{class:"tl-header"},[s("span",{class:"tl-icon"},[ra(a.type)]),s("span",{class:"tl-summary"},[ia(a.type,a.actor,a.subject,a.message)])]),s("div",{class:"tl-meta"},[a.actor?s("span",{class:"tl-badge tl-badge-agent"},[B(a.actor)]):null,a.rig?s("span",{class:"tl-badge tl-badge-rig"},[a.rig]):null,s("span",{class:"tl-badge tl-badge-type"},[a.type])])])]))}),e.append(n)}function xs(){var e,t;document.addEventListener("click",n=>{var r;const a=(r=n.target)==null?void 0:r.closest(".tl-filter-btn");a&&(Ie=a.dataset.value??"all",document.querySelectorAll(".tl-filter-btn").forEach(i=>i.classList.remove("active")),a.classList.add("active"),Z())}),(e=c("tl-rig-filter"))==null||e.addEventListener("change",n=>{Be=n.currentTarget.value,Z()}),(t=c("tl-agent-filter"))==null||t.addEventListener("change",n=>{Me=n.currentTarget.value,Z()})}function Ts(){const e=c("activity-filters");if(!e||(k(e),W.length===0))return;const t=[...new Set(W.map(i=>i.rig).filter(Boolean))].sort(),n=[...new Set(W.map(i=>i.actor).filter(Boolean))].sort(),a=s("select",{class:"tl-filter-select",id:"tl-rig-filter"});a.append(s("option",{value:"all"},["All rigs"])),t.forEach(i=>a.append(s("option",{value:i,selected:i===Be},[i]))),a.addEventListener("change",()=>{Be=a.value,Z()});const r=s("select",{class:"tl-filter-select",id:"tl-agent-filter"});r.append(s("option",{value:"all"},["All agents"])),n.forEach(i=>r.append(s("option",{value:i,selected:i===Me},[B(i)]))),r.addEventListener("change",()=>{Me=r.value,Z()}),e.append(s("div",{class:"tl-filters"},[s("div",{class:"tl-filter-group"},[s("label",{},["Category:"]),Le("all","All"),Le("agent","Agent"),Le("work","Work"),Le("comms","Comms"),Le("system","System")]),s("div",{class:"tl-filter-group"},[s("label",{},["Rig:"]),a]),s("div",{class:"tl-filter-group"},[s("label",{},["Agent:"]),r])]))}function Le(e,t){const n=s("button",{class:`tl-filter-btn${Ie===e?" active":""}`,"data-filter":"category","data-value":e,type:"button"},[t]);return n.addEventListener("click",()=>{Ie=e,Z()}),n}function As(e){return e.event==="heartbeat"?null:vn(e.data,e.id)}function Rs(e){return vn(e)}function vn(e,t){if(!e.type)return null;const n=Sn(e)??S(),a=typeof e.seq=="number"?e.seq:0;return{id:_s(e,t),type:e.type,category:sa(e.type),actor:e.actor||void 0,subject:e.subject||void 0,message:e.message||void 0,ts:e.ts,scope:n,seq:a,rig:aa(e.actor)||"city"in e&&e.city||""}}function wn(e){const t=new Map;return e.forEach(n=>{t.has(n.id)||t.set(n.id,n)}),[...t.values()].sort(Os).slice(0,Cs)}function Os(e,t){const n=qs(e.ts,t.ts);if(n!==0)return n;const a=e.scope.localeCompare(t.scope);if(a!==0)return a;const r=t.seq-e.seq;if(r!==0)return r;const i=e.type.localeCompare(t.type);if(i!==0)return i;const o=(e.actor??"").localeCompare(t.actor??"");return o!==0?o:(e.subject??"").localeCompare(t.subject??"")}function qs(e,t){const n=Number.isNaN(Date.parse(e))?0:Date.parse(e);return(Number.isNaN(Date.parse(t))?0:Date.parse(t))-n}function Sn(e){if("city"in e&&typeof e.city=="string"&&e.city!=="")return e.city}function _s(e,t){const n=Sn(e)??S();if(typeof e.seq=="number"&&e.seq>0)return`${n}:${e.seq}`;const a=[e.type,e.ts,e.actor??"",e.subject??"",e.message??"",t??""].join(":");return`${n}:${a}`}function En(e){return ka(e)}function Ps(e){switch(e){case"agent":return"activity-agent";case"work":return"activity-work";case"comms":return"activity-comms";default:return"activity-system"}}async function V(){var o,l,d,p,f,u;const e=S();if(!e){js();return}const[t,n,a,r,i]=await Promise.all([g.GET("/v0/city/{cityName}/services",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:e},query:{git:!0}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{label:"gc:escalation",status:"open",limit:200}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{label:"gc:queue",limit:200}}})]);Bs(((o=t.data)==null?void 0:o.items)??null,(l=t.error)==null?void 0:l.detail),Ms(((d=n.data)==null?void 0:d.items)??null),Us(((p=a.data)==null?void 0:p.items)??null),Ds(((f=r.data)==null?void 0:f.items)??null),zs(((u=i.data)==null?void 0:u.items)??null)}function js(){xe("services-body","services-count","Select a city to view services"),xe("rigs-body","rigs-count","Select a city to view rigs"),xe("escalations-body","escalations-count","Select a city to view escalations"),xe("assigned-body","assigned-count","Select a city to view assigned work"),xe("queues-body","queues-count","Select a city to view queues"),c("clear-assigned-btn").style.display="none"}function Is(){var e,t;(e=c("open-assign-btn"))==null||e.addEventListener("click",()=>{Cn()}),(t=c("clear-assigned-btn"))==null||t.addEventListener("click",()=>{Fs()})}function Bs(e,t){const n=c("services-body"),a=c("services-count");if(!n||!a)return;if(k(n),t){a.textContent="n/a",n.append(s("div",{class:"empty-state"},[s("p",{},[t])]));return}const r=e??[];if(a.textContent=String(r.length),r.length===0){n.append(s("div",{class:"empty-state"},[s("p",{},["No workspace services"])]));return}const i=s("tbody");r.forEach(o=>{const l=s("button",{class:"esc-btn",type:"button"},["Restart"]);l.addEventListener("click",()=>{Js(o.service_name)}),i.append(s("tr",{},[s("td",{},[s("strong",{},[o.service_name])]),s("td",{},[o.kind??"—"]),s("td",{},[s("span",{class:`badge ${ce(o.state??o.publication_state)}`},[o.state??o.publication_state??"unknown"])]),s("td",{},[o.local_state]),s("td",{},[l])]))}),n.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Name"]),s("th",{},["Kind"]),s("th",{},["Service"]),s("th",{},["Local"]),s("th",{},["Actions"])])]),i]))}function Ms(e){const t=c("rigs-body"),n=c("rigs-count");if(!t||!n)return;k(t);const a=e??[];if(n.textContent=String(a.length),a.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No rigs configured"])]));return}const r=s("tbody");a.forEach(i=>{var d;const o=s("button",{class:"esc-btn",type:"button"},[i.suspended?"Resume":"Suspend"]);o.addEventListener("click",()=>{Pt(i.name,i.suspended?"resume":"suspend")});const l=s("button",{class:"esc-btn",type:"button"},["Restart"]);l.addEventListener("click",()=>{Pt(i.name,"restart")}),r.append(s("tr",{},[s("td",{},[s("span",{class:"rig-name"},[i.name])]),s("td",{},[String(i.agent_count-i.running_count)]),s("td",{},[String(i.running_count)]),s("td",{},[(d=i.git)!=null&&d.branch?`${i.git.branch}${i.git.clean?"":"*"}`:"—"]),s("td",{},[D(i.last_activity)]),s("td",{},[o," ",l])]))}),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Name"]),s("th",{},["Idle"]),s("th",{},["Running"]),s("th",{},["Git"]),s("th",{},["Activity"]),s("th",{},["Actions"])])]),r]))}function Us(e){const t=c("escalations-body"),n=c("escalations-count");if(!t||!n)return;k(t);const a=(e??[]).sort((i,o)=>(i.created_at??"").localeCompare(o.created_at??""));if(n.textContent=String(a.length),a.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No escalations"])]));return}const r=s("tbody");a.forEach(i=>{const o=Ws(i.labels??[]),l=(i.labels??[]).includes("acked"),d=s("button",{class:"esc-btn esc-ack-btn",type:"button"},["👍 Ack"]);d.addEventListener("click",()=>{Vs(i)});const p=s("button",{class:"esc-btn esc-resolve-btn",type:"button"},["✓ Resolve"]);p.addEventListener("click",()=>{i.id&&Ks(i.id)});const f=s("button",{class:"esc-btn esc-reassign-btn",type:"button"},["↻ Reassign"]);f.addEventListener("click",()=>{i.id&&Qs(i.id)}),r.append(s("tr",{class:"escalation-row","data-escalation-id":i.id??""},[s("td",{},[s("span",{class:`badge ${Gs(o)}`},[o.toUpperCase()])]),s("td",{},[i.title??i.id??"",l?s("span",{class:"badge badge-cyan",style:"margin-left: 4px;"},["ACK"]):null]),s("td",{},[B(i.assignee)]),s("td",{},[D(i.created_at)]),s("td",{class:"escalation-actions"},[l?null:d,p,f])]))}),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Severity"]),s("th",{},["Issue"]),s("th",{},["From"]),s("th",{},["Age"]),s("th",{},["Actions"])])]),r]))}function Ds(e){const t=c("assigned-body"),n=c("assigned-count"),a=c("clear-assigned-btn");if(!t||!n||!a)return;k(t);const r=(e??[]).filter(o=>o.assignee);if(n.textContent=String(r.length),a.style.display=r.length>0?"inline-flex":"none",r.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No assigned work"])]));return}const i=s("tbody");r.forEach(o=>{const l=s("button",{class:"unassign-btn",type:"button"},["Unassign"]);l.addEventListener("click",()=>{o.id&&Hs(o.id)}),i.append(s("tr",{},[s("td",{},[s("span",{class:"assigned-id"},[o.id??""])]),s("td",{class:"assigned-title"},[Qe(o.title??"",80)]),s("td",{class:"assigned-agent"},[B(o.assignee)]),s("td",{class:"assigned-age"},[D(o.created_at)]),s("td",{},[l])]))}),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Bead"]),s("th",{},["Title"]),s("th",{},["Agent"]),s("th",{},["Since"]),s("th",{},[""])])]),i]))}function zs(e){const t=c("queues-body"),n=c("queues-count");if(!t||!n)return;k(t);const a=e??[];if(n.textContent=String(a.length),a.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No queues"])]));return}const r=s("tbody");a.forEach(i=>{r.append(s("tr",{},[s("td",{},[i.title??i.id??"queue"]),s("td",{},[i.id??"—"]),s("td",{},[s("span",{class:`badge ${ce(i.status)}`},[i.status??"open"])]),s("td",{},[B(i.assignee)]),s("td",{},[D(i.created_at)])]))}),t.append(s("table",{},[s("thead",{},[s("tr",{},[s("th",{},["Queue"]),s("th",{},["Bead"]),s("th",{},["Status"]),s("th",{},["Assignee"]),s("th",{},["Created"])])]),r]))}function xe(e,t,n){const a=c(e),r=c(t);!a||!r||(k(a),r.textContent="0",a.append(s("div",{class:"empty-state"},[s("p",{},[n])])))}function Ws(e){for(const t of e)if(t.startsWith("severity:"))return t.slice(9);return"medium"}function Gs(e){switch(e){case"critical":return"badge-red";case"high":return"badge-orange";case"low":return"badge-muted";default:return"badge-yellow"}}async function Cn(e=""){const t=S();if(!t)return;const n=await ht({beadID:e||void 0,beadLabel:e||void 0,mode:"assign",title:"Assign Work"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/sling",{params:{path:{cityName:t},header:T},body:{bead:n.beadID,target:n.target,rig:n.rig||void 0}});if(a.error){v("error","Assign failed",a.error.detail??"Could not assign bead");return}v("success","Assigned",`${n.beadID} → ${n.target}`),await V()}async function Fs(){var r;const e=S();if(!e)return;const n=(((r=(await g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}})).data)==null?void 0:r.items)??[]).filter(i=>i.assignee);if(n.length===0){v("info","Nothing to clear","No assigned work");return}await Ma({body:`Unassign ${n.length} active ${n.length===1?"bead":"beads"}?`,confirmLabel:"Unassign All",title:"Clear Assignments"})&&(await Promise.all(n.map(i=>g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:e,id:i.id??""},header:T},body:{assignee:""}}))),v("success","Cleared",`${n.length} assignments removed`),await V())}async function Hs(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:t,id:e},header:T},body:{assignee:""}});if(n.error){v("error","Unassign failed",n.error.detail??"Could not unassign bead");return}v("success","Unassigned",e),await V()}async function Js(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/service/{name}/restart",{params:{path:{cityName:t,name:e},header:T}});if(n.error){v("error","Service failed",n.error.detail??"Could not restart service");return}v("success","Service restarted",e),await V()}async function Pt(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/rig/{name}/{action}",{params:{path:{cityName:n,name:e,action:t},header:T}});if(a.error){v("error","Rig action failed",a.error.detail??`Could not ${t} ${e}`);return}v("success","Rig updated",`${e}: ${t}`),await V()}async function Vs(e){const t=S();if(!t||!e.id)return;const n=Array.from(new Set([...e.labels??[],"acked"])),a=await g.POST("/v0/city/{cityName}/bead/{id}/update",{params:{path:{cityName:t,id:e.id},header:T},body:{labels:n}});if(a.error){v("error","Ack failed",a.error.detail??"Could not acknowledge escalation");return}v("success","Acknowledged",e.id),await V()}async function Ks(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/close",{params:{path:{cityName:t,id:e},header:T}});if(n.error){v("error","Resolve failed",n.error.detail??"Could not resolve escalation");return}v("success","Resolved",e),await V()}async function Qs(e){const t=S();if(!t)return;const n=await ht({beadID:e,beadLabel:e,mode:"reassign",title:"Reassign Escalation"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:t,id:e},header:T},body:{assignee:n.target}});if(a.error){v("error","Reassign failed",a.error.detail??"Could not reassign escalation");return}v("success","Reassigned",`${e} → ${n.target||"unassigned"}`),await V()}function Xs(e){const t=c("command-palette-overlay"),n=c("command-palette-input"),a=c("command-palette-results"),r=c("open-palette-btn");if(!t||!n||!a||!r)return;const i=t,o=n,l=a,d=r;let p=[],f=[],u=0;function y(){const b=S(),C=async(N,I)=>{const M=await I;Rt(N,JSON.stringify(M,null,2))};return[{name:"refresh",desc:"Refresh all panels",category:"Dashboard",run:()=>e.refreshAll()},{name:"supervisor health",desc:"Show supervisor health JSON",category:"Supervisor",run:()=>C("health",g.GET("/health"))},{name:"city list",desc:"Show managed cities JSON",category:"Supervisor",run:()=>C("cities",g.GET("/v0/cities"))},{name:"global events",desc:"Show recent supervisor events JSON",category:"Supervisor",run:()=>C("events",g.GET("/v0/events",{params:{query:{since:"1h"}}}))},...b?[{name:"new issue",desc:"Open the issue creation modal",category:"Work",run:()=>dn()},{name:"compose mail",desc:"Open the compose mail form",category:"Mail",run:()=>dt()},{name:"new convoy",desc:"Open the convoy creation form",category:"Convoys",run:()=>gn()},{name:"assign work",desc:"Open the assignment modal",category:"Assigned",run:()=>Cn()},{name:"status",desc:"Show current city status JSON",category:"Status",run:()=>C("status",g.GET("/v0/city/{cityName}/status",{params:{path:{cityName:b}}}))},{name:"agent list",desc:"Show current sessions JSON",category:"Status",run:()=>C("sessions",g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:b},query:{state:"active",peek:!0}}}))},{name:"convoy list",desc:"Show current convoys JSON",category:"Convoys",run:()=>C("convoys",g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:b},query:{limit:200}}}))},{name:"mail inbox",desc:"Show current mail JSON",category:"Mail",run:()=>C("mail",g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:b},query:{status:"all",limit:200}}}))},{name:"rig list",desc:"Show rig JSON",category:"Rigs",run:()=>C("rigs",g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:b},query:{git:!0}}}))},{name:"list",desc:"Show open and in-progress beads JSON",category:"Beads",run:async()=>{var M,$;const[N,I]=await Promise.all([g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:b},query:{status:"open",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:b},query:{status:"in_progress",limit:500}}})]);Rt("beads",JSON.stringify({open:((M=N.data)==null?void 0:M.items)??[],in_progress:(($=I.data)==null?void 0:$.items)??[]},null,2))}}]:[],{name:"close output",desc:"Hide the output panel",category:"Dashboard",run:()=>Zt()}].filter(N=>typeof N.run=="function")}function m(){k(l);const b=o.value.trim().toLowerCase();if(p=y(),f=p.filter(C=>b===""||C.name.includes(b)||C.desc.toLowerCase().includes(b)||C.category.toLowerCase().includes(b)),u>=f.length&&(u=0),f.length===0){l.append(s("div",{class:"command-palette-empty"},["No matching commands"]));return}f.forEach((C,N)=>{const I=s("button",{class:`command-item${N===u?" selected":""}`,type:"button"},[s("span",{class:"command-name"},[`gt ${C.name}`]),s("span",{class:"command-desc"},[C.desc]),s("span",{class:"command-category"},[C.category])]);I.addEventListener("click",()=>{E(N)}),l.append(I)})}function h(){i.classList.add("open"),o.value="",u=0,m(),o.focus()}function w(){i.classList.remove("open")}async function E(b){const C=f[b];w(),C&&(J("palette","Execute command",{category:C.category,city:S(),command:C.name}),await C.run())}d.addEventListener("click",()=>h()),i.addEventListener("click",b=>{b.target===i&&w()}),o.addEventListener("input",()=>m()),o.addEventListener("keydown",b=>{if(b.key==="ArrowDown"){u=Math.min(u+1,Math.max(f.length-1,0)),m(),b.preventDefault();return}if(b.key==="ArrowUp"){u=Math.max(u-1,0),m(),b.preventDefault();return}if(b.key==="Enter"){E(u),b.preventDefault();return}b.key==="Escape"&&w()}),document.addEventListener("keydown",b=>{(b.metaKey||b.ctrlKey)&&b.key.toLowerCase()==="k"&&(b.preventDefault(),i.classList.contains("open")?w():h())})}function Ys(){const e=c("supervisor-overview-panel"),t=c("supervisor-overview-body"),n=c("supervisor-city-count");if(!e||!t||!n)return;const a=S()==="";if(e.hidden=!a,!a)return;const r=Ht().sort((o,l)=>o.name.localeCompare(l.name));if(n.textContent=String(r.length),k(t),r.length===0){t.append(s("div",{class:"empty-state"},[s("p",{},["No managed cities available"])]));return}const i=s("tbody");r.forEach(o=>{const l=o.phasesCompleted.length>0?o.phasesCompleted.join(", "):"—",d=s("a",{class:"supervisor-city-link",href:`?city=${encodeURIComponent(o.name)}`},["Open"]);i.append(s("tr",{},[s("td",{},[s("strong",{},[o.name])]),s("td",{},[s("span",{class:`badge ${o.error?"badge-red":o.running?"badge-green":"badge-muted"}`},[o.error?"Error":o.running?"Running":"Stopped"])]),s("td",{},[o.status??"—"]),s("td",{class:"supervisor-city-phases"},[l]),s("td",{class:"supervisor-city-error"},[o.error??"—"]),s("td",{class:"supervisor-city-actions"},[d])]))}),t.append(s("table",{class:"supervisor-city-table"},[s("thead",{},[s("tr",{},[s("th",{},["City"]),s("th",{},["State"]),s("th",{},["Status"]),s("th",{},["Phases"]),s("th",{},["Error"]),s("th",{},[""])])]),i]))}const Zs=["convoy-panel","crew-panel","rigged-panel","mail-panel","escalations-panel","services-panel","rigs-panel","pooled-panel","queues-panel","beads-panel","assigned-panel","agent-log-drawer"];async function er(){bt()||await Ee()}async function tr(){bt()||await Ee().catch(e=>P("Catch-up refresh failed",e))}async function nr(){mt(),await Ee(!0)}function Et(){const e=Jt();if(e.kind==="not-running"||e.kind==="unknown"){Ls(),st("connecting");return}st("connecting"),$s(t=>{const n=En(t);!n||n==="heartbeat"||(ea(n),!bt()&&Ee().catch(a=>P("Refresh failed",a)))},st)}function st(e){const t=Ct("connection-status");if(!t)return;const n={connecting:"Connecting…",live:"Live",reconnecting:"Reconnecting…"};t.replaceChildren(document.createTextNode(n[e])),t.classList.remove("connection-live","connection-connecting","connection-reconnecting"),t.classList.add(`connection-${e}`)}function ar(){ba(),Ba(),Ra(),Ga(),ls(),vs(),xs(),Is(),Xs({refreshAll:er})}async function sr(){Vn(),J("dashboard","Boot start",{city:S(),href:window.location.href}),ar(),ir(),ga(()=>{tr()}),await nr(),Et(),J("dashboard","Boot complete",{city:S(),href:window.location.href})}function Ct(e){return document.getElementById(e)}sr().catch(e=>P("Dashboard boot failed",e));function rr(){const e=S()!=="";cr(e),De("new-convoy-btn",e,"Select a city to create a convoy"),De("new-issue-btn",e,"Select a city to create a bead"),De("compose-mail-btn",e,"Select a city to compose mail"),De("open-assign-btn",e,"Select a city to assign work")}function De(e,t,n){const a=Ct(e);a&&(a.dataset.defaultTitle===void 0&&(a.dataset.defaultTitle=a.title||""),a.disabled=!t,a.title=t?a.dataset.defaultTitle:n)}function ir(){document.addEventListener("click",e=>{var a;const t=(a=e.target)==null?void 0:a.closest("a.city-tab");if(!t)return;const n=t.href;!n||n===window.location.href||(e.preventDefault(),or(n))}),window.addEventListener("popstate",()=>{J("dashboard","Popstate navigation",{href:window.location.href}),on(),yt(),mt(),Ee().catch(e=>P("Refresh failed",e)),Et()})}async function or(e){J("dashboard","Navigate city scope",{nextURL:e}),on(),window.history.pushState({},"",e),yt(),mt(),await Ee(),Et()}function cr(e){Zs.forEach(t=>{const n=Ct(t);if(!n)return;const a=!e&&n.classList.contains("expanded");if(n.hidden=!e,a){n.classList.remove("expanded");const r=n.querySelector(".expand-btn");r&&(r.textContent="Expand"),j()}})}async function Ee(e=!1){yt(),rr();const t=Yn(e);if(t.size===0)return;t.has("options")&&Ia(),t.has("cities")&&await ta().catch(l=>P("City tabs failed",l));const n=[],r=Jt().kind==="running";ae(n,t,"status",()=>oa()),ae(n,t,"activity",()=>Ns()),r&&(ae(n,t,"crew",()=>Na()),ae(n,t,"issues",()=>de()),ae(n,t,"mail",()=>Ue()),ae(n,t,"convoys",()=>St()),ae(n,t,"admin",()=>V()));const o=(await Promise.allSettled(n)).find(l=>l.status==="rejected");o&&P("Panel refresh failed",o.reason),(t.has("supervisor")||t.has("cities"))&&Ys()}function ae(e,t,n,a){t.has(n)&&e.push(a())} +`);R=V.pop()??"";for(const X of V){const W=X.split(` +`),T=[];let $;for(const j of W)if(j.startsWith("data:"))T.push(j.replace(/^data:\s*/,""));else if(j.startsWith("event:"))$=j.replace(/^event:\s*/,"");else if(j.startsWith("id:"))d=j.replace(/^id:\s*/,"");else if(j.startsWith("retry:")){const re=Number.parseInt(j.replace(/^retry:\s*/,""),10);Number.isNaN(re)||(v=re)}let L,_=!1;if(T.length){const j=T.join(` +`);try{L=JSON.parse(j),_=!0}catch{L=j}}_&&(s&&await s(L),a&&(L=await a(L))),n==null||n({data:L,event:$,id:d,retry:v}),T.length&&(yield L)}}}finally{b.removeEventListener("abort",Y),q.releaseLock()}break}catch(k){if(t==null||t(k),o!==void 0&&C>=o)break;const P=Math.min(v*2**(C-1),l??3e4);await p(P)}}}()}}const Vn=e=>{switch(e){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},Kn=e=>{switch(e){case"form":return",";case"pipeDelimited":return"|";case"spaceDelimited":return"%20";default:return","}},Qn=e=>{switch(e){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},Jt=({allowReserved:e,explode:t,name:n,style:a,value:s})=>{if(!t){const l=(e?s:s.map(u=>encodeURIComponent(u))).join(Kn(a));switch(a){case"label":return`.${l}`;case"matrix":return`;${n}=${l}`;case"simple":return l;default:return`${n}=${l}`}}const i=Vn(a),o=s.map(l=>a==="label"||a==="simple"?e?l:encodeURIComponent(l):et({allowReserved:e,name:n,value:l})).join(i);return a==="label"||a==="matrix"?i+o:o},et=({allowReserved:e,name:t,value:n})=>{if(n==null)return"";if(typeof n=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${t}=${e?n:encodeURIComponent(n)}`},Vt=({allowReserved:e,explode:t,name:n,style:a,value:s,valueOnly:i})=>{if(s instanceof Date)return i?s.toISOString():`${n}=${s.toISOString()}`;if(a!=="deepObject"&&!t){let u=[];Object.entries(s).forEach(([f,d])=>{u=[...u,f,e?d:encodeURIComponent(d)]});const y=u.join(",");switch(a){case"form":return`${n}=${y}`;case"label":return`.${y}`;case"matrix":return`;${n}=${y}`;default:return y}}const o=Qn(a),l=Object.entries(s).map(([u,y])=>et({allowReserved:e,name:a==="deepObject"?`${n}[${u}]`:u,value:y})).join(o);return a==="label"||a==="matrix"?o+l:l},Yn=/\{[^{}]+\}/g,Xn=({path:e,url:t})=>{let n=t;const a=t.match(Yn);if(a)for(const s of a){let i=!1,o=s.substring(1,s.length-1),l="simple";o.endsWith("*")&&(i=!0,o=o.substring(0,o.length-1)),o.startsWith(".")?(o=o.substring(1),l="label"):o.startsWith(";")&&(o=o.substring(1),l="matrix");const u=e[o];if(u==null)continue;if(Array.isArray(u)){n=n.replace(s,Jt({explode:i,name:o,style:l,value:u}));continue}if(typeof u=="object"){n=n.replace(s,Vt({explode:i,name:o,style:l,value:u,valueOnly:!0}));continue}if(l==="matrix"){n=n.replace(s,`;${et({name:o,value:u})}`);continue}const y=encodeURIComponent(l==="label"?`.${u}`:u);n=n.replace(s,y)}return n},Zn=({baseUrl:e,path:t,query:n,querySerializer:a,url:s})=>{const i=s.startsWith("/")?s:`/${s}`;let o=(e??"")+i;t&&(o=Xn({path:t,url:o}));let l=n?a(n):"";return l.startsWith("?")&&(l=l.substring(1)),l&&(o+=`?${l}`),o};function Pt(e){const t=e.body!==void 0;if(t&&e.bodySerializer)return"serializedBody"in e?e.serializedBody!==void 0&&e.serializedBody!==""?e.serializedBody:null:e.body!==""?e.body:null;if(t)return e.body}const ea=async(e,t)=>{const n=typeof t=="function"?await t(e):t;if(n)return e.scheme==="bearer"?`Bearer ${n}`:e.scheme==="basic"?`Basic ${btoa(n)}`:n},Kt=({parameters:e={},...t}={})=>a=>{const s=[];if(a&&typeof a=="object")for(const i in a){const o=a[i];if(o==null)continue;const l=e[i]||t;if(Array.isArray(o)){const u=Jt({allowReserved:l.allowReserved,explode:!0,name:i,style:"form",value:o,...l.array});u&&s.push(u)}else if(typeof o=="object"){const u=Vt({allowReserved:l.allowReserved,explode:!0,name:i,style:"deepObject",value:o,...l.object});u&&s.push(u)}else{const u=et({allowReserved:l.allowReserved,name:i,value:o});u&&s.push(u)}}return s.join("&")},ta=e=>{var n;if(!e)return"stream";const t=(n=e.split(";")[0])==null?void 0:n.trim();if(t){if(t.startsWith("application/json")||t.endsWith("+json"))return"json";if(t==="multipart/form-data")return"formData";if(["application/","audio/","image/","video/"].some(a=>t.startsWith(a)))return"blob";if(t.startsWith("text/"))return"text"}},na=(e,t)=>{var n,a;return t?!!(e.headers.has(t)||(n=e.query)!=null&&n[t]||(a=e.headers.get("Cookie"))!=null&&a.includes(`${t}=`)):!1},aa=async({security:e,...t})=>{for(const n of e){if(na(t,n.name))continue;const a=await ea(n,t.auth);if(!a)continue;const s=n.name??"Authorization";switch(n.in){case"query":t.query||(t.query={}),t.query[s]=a;break;case"cookie":t.headers.append("Cookie",`${s}=${a}`);break;case"header":default:t.headers.set(s,a);break}}},_t=e=>Zn({baseUrl:e.baseUrl,path:e.path,query:e.query,querySerializer:typeof e.querySerializer=="function"?e.querySerializer:Kt(e.querySerializer),url:e.url}),jt=(e,t)=>{var a;const n={...e,...t};return(a=n.baseUrl)!=null&&a.endsWith("/")&&(n.baseUrl=n.baseUrl.substring(0,n.baseUrl.length-1)),n.headers=Qt(e.headers,t.headers),n},sa=e=>{const t=[];return e.forEach((n,a)=>{t.push([a,n])}),t},Qt=(...e)=>{const t=new Headers;for(const n of e){if(!n)continue;const a=n instanceof Headers?sa(n):Object.entries(n);for(const[s,i]of a)if(i===null)t.delete(s);else if(Array.isArray(i))for(const o of i)t.append(s,o);else i!==void 0&&t.set(s,typeof i=="object"?JSON.stringify(i):i)}return t};class ot{constructor(){this.fns=[]}clear(){this.fns=[]}eject(t){const n=this.getInterceptorIndex(t);this.fns[n]&&(this.fns[n]=null)}exists(t){const n=this.getInterceptorIndex(t);return!!this.fns[n]}getInterceptorIndex(t){return typeof t=="number"?this.fns[t]?t:-1:this.fns.indexOf(t)}update(t,n){const a=this.getInterceptorIndex(t);return this.fns[a]?(this.fns[a]=n,t):!1}use(t){return this.fns.push(t),this.fns.length-1}}const ra=()=>({error:new ot,request:new ot,response:new ot}),ia=Kt({allowReserved:!1,array:{explode:!0,style:"form"},object:{explode:!0,style:"deepObject"}}),oa={"Content-Type":"application/json"},Yt=(e={})=>({...Hn,headers:oa,parseAs:"auto",querySerializer:ia,...e}),ca=(e={})=>{let t=jt(Yt(),e);const n=()=>({...t}),a=f=>(t=jt(t,f),n()),s=ra(),i=async f=>{const d={...t,...f,fetch:f.fetch??t.fetch??globalThis.fetch,headers:Qt(t.headers,f.headers),serializedBody:void 0};d.security&&await aa({...d,security:d.security}),d.requestValidator&&await d.requestValidator(d),d.body!==void 0&&d.bodySerializer&&(d.serializedBody=d.bodySerializer(d.body)),(d.body===void 0||d.serializedBody==="")&&d.headers.delete("Content-Type");const p=d,m=_t(p);return{opts:p,url:m}},o=async f=>{const{opts:d,url:p}=await i(f),m={redirect:"follow",...d,body:Pt(d)};let h=new Request(p,m);for(const x of s.request.fns)x&&(h=await x(h,d));const v=d.fetch;let C;try{C=await v(h)}catch(x){let q=x;for(const R of s.error.fns)R&&(q=await R(x,void 0,h,d));if(q=q||{},d.throwOnError)throw q;return d.responseStyle==="data"?void 0:{error:q,request:h,response:void 0}}for(const x of s.response.fns)x&&(C=await x(C,h,d));const b={request:h,response:C};if(C.ok){const x=(d.parseAs==="auto"?ta(C.headers.get("Content-Type")):d.parseAs)??"json";if(C.status===204||C.headers.get("Content-Length")==="0"){let R;switch(x){case"arrayBuffer":case"blob":case"text":R=await C[x]();break;case"formData":R=new FormData;break;case"stream":R=C.body;break;case"json":default:R={};break}return d.responseStyle==="data"?R:{data:R,...b}}let q;switch(x){case"arrayBuffer":case"blob":case"formData":case"text":q=await C[x]();break;case"json":{const R=await C.text();q=R?JSON.parse(R):{};break}case"stream":return d.responseStyle==="data"?C.body:{data:C.body,...b}}return x==="json"&&(d.responseValidator&&await d.responseValidator(q),d.responseTransformer&&(q=await d.responseTransformer(q))),d.responseStyle==="data"?q:{data:q,...b}}const N=await C.text();let k;try{k=JSON.parse(N)}catch{}const P=k??N;let M=P;for(const x of s.error.fns)x&&(M=await x(P,C,h,d));if(M=M||{},d.throwOnError)throw M;return d.responseStyle==="data"?void 0:{error:M,...b}},l=f=>d=>o({...d,method:f}),u=f=>async d=>{const{opts:p,url:m}=await i(d);return Jn({...p,body:p.body,headers:p.headers,method:f,onRequest:async(h,v)=>{let C=new Request(h,v);for(const b of s.request.fns)b&&(C=await b(C,p));return C},serializedBody:Pt(p),url:m})};return{buildUrl:f=>_t({...t,...f}),connect:l("CONNECT"),delete:l("DELETE"),get:l("GET"),getConfig:n,head:l("HEAD"),interceptors:s,options:l("OPTIONS"),patch:l("PATCH"),post:l("POST"),put:l("PUT"),request:o,setConfig:a,sse:{connect:u("CONNECT"),delete:u("DELETE"),get:u("GET"),head:u("HEAD"),options:u("OPTIONS"),patch:u("PATCH"),post:u("POST"),put:u("PUT"),trace:u("TRACE")},trace:l("TRACE")}},fe=ca(Yt()),Xt={debug:console.debug.bind(console),error:console.error.bind(console),info:console.info.bind(console),log:console.log.bind(console),warn:console.warn.bind(console)};let It=!1;function la(){It||typeof window>"u"||(It=!0,nt()&&($e("debug","debug"),$e("info","info"),$e("log","info")),$e("warn","warn"),$e("error","error"),window.addEventListener("error",e=>{de("window","Unhandled error",{colno:e.colno,error:e.error,filename:e.filename,lineno:e.lineno,message:e.message})}),window.addEventListener("unhandledrejection",e=>{de("window","Unhandled promise rejection",{reason:e.reason})}))}function Ie(e,t,n){nt()&&tt("debug",e,t,n)}function ee(e,t,n){nt()&&tt("info",e,t,n)}function we(e,t,n){tt("warn",e,t,n)}function de(e,t,n){tt("error",e,t,n)}function tt(e,t,n,a){if((e==="debug"||e==="info")&&!nt())return;const s=Zt(e,t,n,a);Xt[e](`[dashboard][${t}] ${n}`,Qe(a)),en(s)}function nt(){if(typeof window>"u")return!1;const t=(new URLSearchParams(window.location.search).get("debug")??"").toLowerCase();if(t==="1"||t==="true")return!0;try{return window.localStorage.getItem("gc.dashboard.debug")==="true"}catch{return!1}}function $e(e,t){const n=Xt[e];console[e]=(...a)=>{n(...a),en(Zt(t,"console",ua(a),a.length>1?a.slice(1):a[0]))}}function Zt(e,t,n,a){return{city:da(),details:a===void 0?void 0:Qe(a),level:e,message:n,scope:t,ts:new Date().toISOString(),url:typeof window>"u"?"":window.location.href}}function da(){return typeof window>"u"?"":(new URLSearchParams(window.location.search).get("city")??"").trim()}function ua(e){if(e.length===0)return"console event";const[t]=e;return typeof t=="string"&&t.trim()!==""?t:t instanceof Error?t.message:"console event"}function en(e){const t=JSON.stringify(e);if(typeof navigator<"u"&&typeof navigator.sendBeacon=="function"){const n=new Blob([t],{type:"application/json"});if(navigator.sendBeacon("/__client-log",n))return}fetch("/__client-log",{body:t,credentials:"same-origin",headers:{"Content-Type":"application/json"},keepalive:!0,method:"POST"}).catch(()=>{})}function Qe(e,t=0,n=new WeakSet){if(e==null)return e??null;if(typeof e=="string")return e.length>2e3?`${e.slice(0,1999)}…`:e;if(typeof e=="number"||typeof e=="boolean")return e;if(e instanceof Error)return{message:e.message,name:e.name,stack:e.stack};if(typeof e=="function")return`[function ${e.name||"anonymous"}]`;if(t>=4)return"[max-depth]";if(Array.isArray(e))return e.slice(0,20).map(a=>Qe(a,t+1,n));if(typeof e=="object"){if(n.has(e))return"[circular]";n.add(e);const a={};for(const[s,i]of Object.entries(e).slice(0,40))a[s]=Qe(i,t+1,n);return a}return String(e)}const bt=["cities","status","supervisor","crew","issues","mail","convoys","activity","admin","options"];let Be=an(window.location.search),vt=[],Ge=!1;const Ke=new Set(bt);function fa(){return Be}function wt(){return Be=an(window.location.search),Be}function oe(...e){e.forEach(t=>Ke.add(t))}function St(){oe(...bt)}function ya(e=!1){if(e)return Ke.clear(),new Set(bt);const t=new Set(Ke);return Ke.clear(),t}function pa(e){Ge=!0,vt=e.map(t=>({error:t.error,name:t.name,path:t.path,phasesCompleted:[...t.phasesCompleted??[]],running:t.running,status:t.status}))}function tn(){Ge=!1}function nn(){return vt.map(e=>({error:e.error,name:e.name,path:e.path,phasesCompleted:[...e.phasesCompleted],running:e.running,status:e.status}))}function Fe(){const e=Be;if(e==="")return{kind:"supervisor"};if(!Ge)return{kind:"unknown",name:e};const t=vt.find(n=>n.name===e);return t?t.running?{kind:"running",city:t}:{kind:"not-running",city:t}:{kind:"unknown",name:e}}function ma(e=Fe()){return e.kind==="running"?!0:e.kind==="unknown"?!Ge:!1}function Ct(e=Fe()){return e.kind==="not-running"||e.kind==="unknown"&&Ge}function ga(e){if(!e)return!1;const t=Be!=="";return e.startsWith("session.")||e.startsWith("agent.")?t?(oe("status","crew","options"),!0):!1:e.startsWith("bead.")?t?(oe("status","issues"),!0):!1:e.startsWith("mail.")?t?(oe("status","mail"),!0):!1:e.startsWith("convoy.")?t?(oe("status","convoys"),!0):!1:e.startsWith("city.")||e.startsWith("request.result.")||e==="request.failed"?(oe("cities","status","supervisor"),!0):(e.startsWith("service.")||e.startsWith("provider.")||e.startsWith("rig."))&&t?(oe("admin"),!0):!1}function an(e){return(new URLSearchParams(e).get("city")??"").trim()}function sn(){const e=document.querySelector('meta[name="supervisor-url"]');return((e==null?void 0:e.content)??"").replace(/\/+$/,"")}function S(){return fa()}const O={"X-GC-Request":"true"},g=Wn({baseUrl:sn(),headers:O});fe.setConfig({baseUrl:sn(),headers:O});g.use({async onError({error:e,request:t,schemaPath:n}){return de("api","Request failed",{error:e,method:t.method,schemaPath:n,url:t.url}),e instanceof Error?e:new Error(String(e))},async onRequest({params:e,request:t,schemaPath:n}){Ie("api","Request start",{method:t.method,params:e,schemaPath:n,url:t.url})},async onResponse({request:e,response:t,schemaPath:n}){const a={method:e.method,ok:t.ok,schemaPath:n,status:t.status,url:e.url};if(!t.ok||t.status>=400){we("api","Request response",a);return}Ie("api","Request response",a)}});function r(e,t={},n=[]){const a=document.createElement(e);for(const[s,i]of Object.entries(t))i===void 0||i===!1||(i===!0?a.setAttribute(s,""):a.setAttribute(s,String(i)));for(const s of n)s!=null&&a.append(typeof s=="string"?document.createTextNode(s):s);return a}function E(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function c(e){return document.getElementById(e)}async function ha(){const e=c("city-tabs");if(!e)return;const{data:t,error:n}=await g.GET("/v0/cities");!n&&(t!=null&&t.items)?pa(t.items.map(l=>({error:l.error??void 0,name:l.name??"",path:l.path??void 0,phasesCompleted:l.phases_completed??[],running:l.running===!0,status:l.status??void 0}))):tn();const a=nn();if(n||a.length===0)return;const s=S();E(e);const i=r("nav",{class:"city-tabs"}),o=window.location.pathname||"/";i.append(r("a",{href:o,class:`city-tab${s===""?" active":""}`},[r("span",{class:"city-dot running"})," Supervisor"]));for(const l of a){const u=l.running,y=l.name===s,f=r("a",{href:`${o}?city=${encodeURIComponent(l.name)}`,class:`city-tab${y?" active":""}${u?"":" stopped"}`},[r("span",{class:`city-dot${u?" running":""}`}),` ${l.name}`]);i.append(f)}e.append(i)}function Et(e,t=new Date){if(!e)return"";const n=new Date(e);if(isNaN(n.getTime()))return"";const a=Math.max(0,t.getTime()-n.getTime()),s=Math.floor(a/1e3);if(s<60)return`${s}s ago`;const i=Math.floor(s/60);if(i<60)return`${i}m ago`;const o=Math.floor(i/60);return o<24?`${o}h ago`:`${Math.floor(o/24)}d ago`}const rn=300*1e3,ba=600*1e3;function F(e){if(!e)return"—";const t=new Date(e);if(Number.isNaN(t.getTime()))return"—";const n=new Date,a=t.getFullYear()===n.getFullYear()?{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}:{month:"short",day:"numeric",year:"numeric",hour:"numeric",minute:"2-digit"};return t.toLocaleString(void 0,a)}function je(e){if(!e)return{display:"unknown",colorClass:"unknown"};const t=new Date(e);if(Number.isNaN(t.getTime()))return{display:"unknown",colorClass:"unknown"};const n=Math.max(0,Date.now()-t.getTime()),a=Et(e).replace(" ago","");return n=3?`${t[t.length-1]} (${t[0]}/${t[1]})`:`${t[0]}/${t[t.length-1]}`}function va(e){return!e||!e.includes("/")?"":e.split("/",1)[0]??""}function wa(e){return e.startsWith("agent.")||e.startsWith("session.")?"agent":e.startsWith("bead.")||e.startsWith("convoy.")||e.startsWith("order.")?"work":e.startsWith("mail.")?"comms":(e.startsWith("request.result.")||e==="request.failed","system")}function Sa(e){const t={"session.started":"▶","session.ended":"■","session.crashed":"☠","session.suspended":"⏸","session.woke":"▶","agent.message":"💬","agent.output":"📝","agent.tool_call":"🛠","agent.tool_result":"✅","agent.error":"⚠","bead.created":"📿","bead.updated":"📝","bead.closed":"✅","convoy.created":"🚚","convoy.closed":"✅","mail.delivered":"📬","mail.read":"📨","request.failed":"❌"};return e.startsWith("request.result.")?"🔔":t[e]??"📋"}function Ca(e,t,n,a){const s=U(t);switch(e){case"session.started":return`${U(n)} started`;case"session.ended":return`${U(n)} ended`;case"session.crashed":return`${U(n)} crashed`;case"session.suspended":return`${U(n)} suspended`;case"session.woke":return`${U(n)} woke`;case"bead.created":return`${s} created bead ${n??""}`.trim();case"bead.updated":return`${s} updated bead ${n??""}`.trim();case"bead.closed":return`${s} closed bead ${n??""}`.trim();case"mail.delivered":return`${s} delivered mail`;case"mail.read":return`${s} read mail`;case"convoy.created":return`${s} created convoy ${n??""}`.trim();case"convoy.closed":return`${s} closed convoy ${n??""}`.trim();case"request.failed":return a??`${n??"request"} failed`;default:return e.startsWith("request.result.")?a??`${n??"request"} succeeded`:a??n??e}}function at(e,t){return e?e.length<=t?e:`${e.slice(0,t-1)}…`:""}function se(e){return typeof e!="number"||Number.isNaN(e)||e<=0?4:e}function on(e){switch(se(e)){case 1:return"badge-red";case 2:return"badge-orange";case 3:return"badge-yellow";default:return"badge-muted"}}function ue(e){switch((e??"").toLowerCase()){case"open":case"running":case"ready":case"working":return"badge-green";case"in_progress":case"pending":case"stale":case"warning":return"badge-yellow";case"closed":case"stopped":return"badge-muted";case"error":case"failed":case"stuck":return"badge-red";default:return"badge-blue"}}const Bt=1e3;async function Ea(){var Y,me,ge,V,X,W,T;const e=S(),t=c("status-banner");if(!t)return;if(!e){await Na(t);return}const n=Fe();if(Ct(n)){const $=n.kind==="not-running"?n.city.error??n.city.status??"City not running":"City unavailable";cn(e,"Sessions unavailable"),ka(t,$);return}const a=Je("status",e,$=>g.GET("/v0/city/{cityName}/status",{params:{path:{cityName:e}},signal:$})),s=Je("sessions",e,$=>g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:e},query:{state:"active",peek:!0}},signal:$})),i=Je("beads",e,$=>g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open",limit:500}},signal:$})),o=Je("convoys",e,$=>g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},query:{limit:200}},signal:$}));s.then($=>Mt(e,$));const[l,u,y,f]=await Promise.all([a,s,i,o]);if(S()!==e)return;const d=((Y=u.data)==null?void 0:Y.items)??[],p=((me=y.data)==null?void 0:me.items)??[],m=((ge=f.data)==null?void 0:ge.items)??[];Mt(e,u);const h=d.filter($=>!$.pool||!$.running||!$.last_active?!1:Date.now()-new Date($.last_active).getTime()>=1800*1e3).length,v=p.filter($=>$.assignee&&$.status!=="closed").length,C=p.filter($=>se($.priority)<=2).length,b=d.filter($=>!$.running).length,N=!!(l.error||!l.data),k=N||!!(u.error||y.error||f.error),P=((V=l.data)==null?void 0:V.agents.running)??d.filter($=>$.running).length,M=((X=l.data)==null?void 0:X.work.in_progress)??v,x=((W=l.data)==null?void 0:W.work.open)??p.length,q=((T=l.data)==null?void 0:T.mail.unread)??"n/a",R=`${e}|${P}|${M}|${x}|${m.length}|${q}|${h}|${v}|${C}|${b}|${k}|${N}`;if(R!==Ye){Ye=R;const $=r("div",{class:"summary-stats"},[D(P,"Agents"),D(M,"Assigned"),D(x,"Beads"),D(m.length,"Convoys"),D(q,"Unread")]),L=r("div",{class:"summary-alerts"});K(L,N,"alert-yellow","Status API slow"),K(L,k&&!N,"alert-yellow","Partial data"),K(L,h>0,"alert-red",`${h} stuck`),K(L,v>0,"alert-yellow",`${v} assigned`),K(L,C>0,"alert-red",`${C} P1/P2`),K(L,b>0,"alert-red",`${b} dead`),L.childNodes.length||L.append(r("span",{class:"alert-item alert-green"},["All clear"])),E(t),t.append($,L)}}function ka(e,t){Ye="",E(e);const n=r("div",{class:"summary-stats"},[D(0,"Agents"),D(0,"Assigned"),D(0,"Beads"),D(0,"Convoys"),D("n/a","Unread")]),a=r("div",{class:"summary-alerts"},[r("span",{class:"alert-item alert-yellow"},[t])]);e.append(n,a)}async function Je(e,t,n){const a=new AbortController;let s=!1,i;return new Promise(o=>{i=setTimeout(()=>{if(s)return;s=!0;const l=new Error(`${e} request timed out after ${Bt}ms`);a.abort(),we("status","City status dependency timed out",{city:t,label:e}),o({error:l})},Bt),n(a.signal).then(l=>{s||(s=!0,clearTimeout(i),o(l))},l=>{s||(s=!0,clearTimeout(i),we("status","City status dependency failed",{city:t,error:l,label:e}),o({error:l}))})})}async function Na(e){var d,p;xa(),Ye="";const[t,n]=await Promise.all([g.GET("/health"),g.GET("/v0/cities")]);if(S()!=="")return;const a=t.data,s=((d=n.data)==null?void 0:d.items)??[],i=(a==null?void 0:a.cities_total)??s.length,o=(a==null?void 0:a.cities_running)??s.filter(m=>m.running===!0).length,l=Math.max(i-o,0),u=s.filter(m=>!!m.error).length;if(E(e),t.error&&n.error){e.append(r("div",{class:"banner-error"},["Supervisor status unavailable"]));return}const y=r("div",{class:"summary-stats"},[D(i,"🏙️ Cities"),D(o,"🟢 Running"),D(l,"⏸ Stopped"),D(La(a==null?void 0:a.uptime_sec),"⏱ Uptime")]),f=r("div",{class:"summary-alerts"});K(f,i===0,"alert-yellow","No registered cities"),K(f,l>0,"alert-yellow",`${l} ${l===1?"city":"cities"} not running`),K(f,u>0,"alert-red",`${u} ${u===1?"city":"cities"} reporting errors`),K(f,!!(a!=null&&a.startup&&!a.startup.ready),"alert-yellow",`⏳ Startup: ${((p=a==null?void 0:a.startup)==null?void 0:p.phase)||"starting"}`),f.childNodes.length||f.append(r("span",{class:"alert-item alert-green"},["✓ Supervisor ready"])),e.append(y,f)}function D(e,t){return r("div",{class:"stat"},[r("span",{class:"stat-value"},[String(e??0)]),r("span",{class:"stat-label"},[t])])}function K(e,t,n,a){t&&e.append(r("span",{class:`alert-item ${n}`},[a]))}let Ye="";function Mt(e,t){if(S()===e){if(t.error||!t.data){cn(e,"Sessions unavailable");return}$a(e,t.data.items??[])}}function $a(e,t){const n=c("scope-banner"),a=c("scope-badge"),s=c("scope-status");if(!n||!a||!s)return;const i=t.find(l=>l.configured_named_session&&!l.rig)??t.find(l=>!l.rig&&!l.pool);if(!i){n.classList.remove("attached","detached"),a.className="badge badge-cyan",a.textContent="City",E(s),s.append(H("City",e),H("Session","none"));return}n.classList.remove("attached","detached"),a.className="badge badge-cyan",a.textContent="City",E(s);const o=i.last_active?Date.now()-new Date(i.last_active).getTime()(e.client??fe).sse.get({url:"/v0/city/{cityName}/events/stream",...e}),Aa=e=>(e.client??fe).sse.get({url:"/v0/city/{cityName}/session/{id}/stream",...e}),Ra=e=>((e==null?void 0:e.client)??fe).sse.get({url:"/v0/events/stream",...e});let ce=0,ut=null;function qa(e){ut=e}function ln(e){ce=Math.max(0,e),document.body.dataset.pauseRefresh=ce>0?"true":"false"}function Q(){ln(ce+1)}function B(){const e=ce>0;if(ln(ce-1),e&&ce===0&&ut)try{ut()}catch(t){de("ui","popPause listener threw",{error:String(t)})}}function st(){return ce>0}function Ut(e,t){const n=c("output-panel"),a=c("output-panel-cmd"),s=c("output-panel-content");!n||!a||!s||(a.textContent=e,s.textContent=t,n.classList.add("open"))}function dn(){var e;(e=c("output-panel"))==null||e.classList.remove("open")}function w(e,t,n){const a=c("toast-container");if(!a)return;const s=document.createElement("div");s.className=`toast toast-${e}`,s.innerHTML=`${Dt(t)}
${Dt(n)}
`,a.append(s);const i=e==="error"?9e3:5e3;window.requestAnimationFrame(()=>{s.classList.add("show")}),window.setTimeout(()=>{s.classList.remove("show"),window.setTimeout(()=>{s.remove()},300)},i)}function I(e,t,n="Unexpected dashboard error"){const a=t instanceof Error?t.message:n;de("ui",e,{error:t,fallbackMessage:n,message:a}),w("error",e,a)}function Oa(){var e,t;document.addEventListener("click",n=>{const a=n.target,s=a==null?void 0:a.closest(".collapse-btn");if(s){const y=s.closest(".panel");y==null||y.classList.toggle("collapsed");return}const i=a==null?void 0:a.closest(".expand-btn");if(!i)return;const o=i.closest(".panel");if(!o)return;const l=o.classList.contains("expanded"),u=!!document.querySelector(".panel.expanded");if(document.querySelectorAll(".panel.expanded").forEach(y=>{y.classList.remove("expanded");const f=y.querySelector(".expand-btn");f&&(f.textContent="Expand")}),l){B();return}o.classList.add("expanded"),i.textContent="✕ Close",u||Q()}),document.addEventListener("keydown",n=>{if(n.key!=="Escape")return;const a=document.querySelector(".panel.expanded");if(a){a.classList.remove("expanded");const s=a.querySelector(".expand-btn");s&&(s.textContent="Expand"),B()}}),(e=c("output-close-btn"))==null||e.addEventListener("click",()=>dn()),(t=c("output-copy-btn"))==null||t.addEventListener("click",async()=>{var a;const n=((a=c("output-panel-content"))==null?void 0:a.textContent)??"";try{await navigator.clipboard.writeText(n),w("success","Copied","Output copied to clipboard")}catch{w("error","Copy failed","Clipboard write was rejected")}})}function Dt(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function un(e){return typeof e=="object"&&e!==null}function fn(e){return un(e)&&typeof e.timestamp=="string"}function yn(e){return un(e)&&typeof e.actor=="string"&&typeof e.seq=="number"&&typeof e.ts=="string"&&typeof e.type=="string"}function Pa(e){return yn(e)}function _a(e){return yn(e)&&typeof e.city=="string"}const Wt=[1e3,2e3,4e3,8e3,15e3],ja=15e3;function pn(e){return e{var l,u;let i=0,o=!1;for(;!n.signal.aborted;){try{const{stream:f}=await Ra({client:fe,query:a?{after_cursor:a}:void 0,signal:n.signal,onSseEvent:d=>{var h;i=0,o=!1,(h=t==null?void 0:t.onStatus)==null||h.call(t,"live");const p=d.event??"tagged_event",m=d.id!==void 0?String(d.id):void 0;if(m&&(a=m),p==="heartbeat"){if(!fn(d.data)){I("Invalid supervisor heartbeat frame",d);return}e({event:"heartbeat",id:m,data:d.data});return}if(p==="tagged_event"){if(!_a(d.data)){I("Invalid supervisor event frame",d);return}e({event:"tagged_event",id:m,data:d.data});return}I(`Unexpected supervisor SSE event: ${p}`,d)}});(l=t==null?void 0:t.onStatus)==null||l.call(t,"live");for await(const d of f);if(n.signal.aborted)break}catch(f){if(n.signal.aborted)return;o||(I("Supervisor event stream failed",f),o=!0)}(u=t==null?void 0:t.onStatus)==null||u.call(t,"reconnecting");const y=pn(i);i+=1,await mn(y,n.signal)}})(),{close:()=>n.abort()}}function Ba(e,t,n){var i;const a=new AbortController;let s=n==null?void 0:n.afterSeq;return(i=n==null?void 0:n.onStatus)==null||i.call(n,"connecting"),(async()=>{var u,y;let o=0,l=!1;for(;!a.signal.aborted;){try{const{stream:d}=await Ta({client:fe,path:{cityName:e},query:s?{after_seq:s}:void 0,signal:a.signal,onSseEvent:p=>{var v;o=0,l=!1,(v=n==null?void 0:n.onStatus)==null||v.call(n,"live");const m=p.event??"event",h=p.id!==void 0?String(p.id):void 0;if(h&&(s=h),m==="heartbeat"){if(!fn(p.data)){I("Invalid city heartbeat frame",p);return}t({event:"heartbeat",id:h,data:p.data});return}if(m==="event"){if(!Pa(p.data)){I("Invalid city event frame",p);return}t({event:"event",id:h,data:p.data});return}I(`Unexpected city SSE event: ${m}`,p)}});(u=n==null?void 0:n.onStatus)==null||u.call(n,"live");for await(const p of d);if(a.signal.aborted)break}catch(d){if(a.signal.aborted)return;l||(I("City event stream failed",d),l=!0)}(y=n==null?void 0:n.onStatus)==null||y.call(n,"reconnecting");const f=pn(o);o+=1,await mn(f,a.signal)}})(),{close:()=>a.abort()}}async function mn(e,t){if(!t.aborted)return new Promise(n=>{const a=setTimeout(()=>{t.removeEventListener("abort",s),n()},e),s=()=>{clearTimeout(a),t.removeEventListener("abort",s),n()};t.addEventListener("abort",s)})}function Ma(e,t,n){const a=new AbortController;return(async()=>{try{const{stream:s}=await Aa({client:fe,path:{cityName:e,id:t},signal:a.signal,onSseEvent:i=>{if(i.data===void 0){I("Session frame missing data",i);return}n({id:i.id!==void 0?String(i.id):void 0,type:i.event??"message",data:i.data})}});for await(const i of s);}catch(s){a.signal.aborted||I("Session stream failed",s)}})(),{close:()=>a.abort()}}function Ua(e){return e.event==="heartbeat"?"heartbeat":e.data.type}let Re=null,be="",ne="",Me=0;async function Da(){const e=S();if(!e){gn();return}const t=c("crew-loading"),n=c("crew-table"),a=c("crew-empty"),s=c("crew-tbody"),i=c("rigged-body"),o=c("pooled-body");if(!t||!n||!a||!s||!i||!o)return;ft("No crew configured"),t.style.display="block",n.style.display="none",a.style.display="none",E(s);const{data:l,error:u}=await g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:e},query:{state:"active",peek:!0}}});if(u||!(l!=null&&l.items)){t.textContent="Failed to load crew",Se(i,"No rigged agents"),Se(o,"No pooled agents");return}const y=l.items,f=await Promise.all(y.map(async m=>{var v;return!!((v=(await g.GET("/v0/city/{cityName}/session/{id}/pending",{params:{path:{cityName:e,id:m.id}}})).data)!=null&&v.pending)})),d=new Map;await Promise.all(y.map(async m=>{var v;if(!m.active_bead||d.has(m.active_bead))return;const h=await g.GET("/v0/city/{cityName}/bead/{id}",{params:{path:{cityName:e,id:m.active_bead}}});d.set(m.active_bead,(v=h.data)!=null&&v.id?h.data.title??h.data.id:m.active_bead)}));const p=y;p.forEach((m,h)=>{const v=Wa(m,f[h]??!1),C=m.active_bead?at(d.get(m.active_bead)??m.active_bead,24):"—",b=r("tr",{},[r("td",{},[m.template]),r("td",{},[m.rig??"city"]),r("td",{},[r("span",{class:`badge ${ue(v)}`},[v])]),r("td",{},[C]),r("td",{class:je(m.last_active).colorClass?`activity-${je(m.last_active).colorClass}`:""},[r("span",{class:"activity-dot"}),` ${je(m.last_active).display}`]),r("td",{},[r("span",{class:`badge ${m.attached?"badge-green":"badge-muted"}`},[m.attached?"Attached":"Detached"])]),r("td",{},[za(m.template)," ",hn(m.id,m.template)])]);s.append(b)}),c("crew-count").textContent=String(p.length),t.style.display="none",p.length>0?n.style.display="table":(ft("No crew configured"),a.style.display="block"),Ga(y,d),Fa(y)}function gn(){const e=c("crew-loading"),t=c("crew-table"),n=c("crew-empty"),a=c("crew-tbody"),s=c("rigged-body"),i=c("pooled-body");!e||!t||!n||!a||!s||!i||(Ue(),c("crew-count").textContent="0",c("rigged-count").textContent="0",c("pooled-count").textContent="0",e.style.display="none",t.style.display="none",n.style.display="block",ft("Select a city to view crew"),E(a),Se(s,"Select a city to view rigged agents"),Se(i,"Select a city to view pooled agents"))}function ft(e){var t,n;(n=(t=c("crew-empty"))==null?void 0:t.querySelector("p"))==null||n.replaceChildren(document.createTextNode(e))}function Wa(e,t){return t?"questions":e.active_bead?"spinning":e.running?"idle":"finished"}function za(e){const t=r("button",{class:"attach-btn",type:"button"},["📎 Attach"]);return t.addEventListener("click",async()=>{const n=`gc agent attach ${e}`;try{await navigator.clipboard.writeText(n),w("success","Attach command copied",n)}catch{w("error","Copy failed",n)}}),t}function hn(e,t){const n=r("button",{class:"agent-log-link",type:"button","data-session-id":e},[t]);return n.addEventListener("click",()=>{Ja(e,t)}),n}function Ga(e,t){const n=c("rigged-body"),a=c("rigged-count");if(!n||!a)return;const s=e.filter(o=>o.rig&&o.pool);if(a.textContent=String(s.length),s.length===0){Se(n,"No rigged agents");return}const i=r("tbody");s.forEach(o=>{const l=je(o.last_active),u=o.active_bead?l.colorClass==="red"?"Stuck":l.colorClass==="yellow"?"Stale":"Working":"Idle";i.append(r("tr",{class:`rigged-${u.toLowerCase()}`},[r("td",{},[hn(o.id,o.template)]),r("td",{},[r("span",{class:"badge badge-muted"},[o.pool??"pool"])]),r("td",{},[o.rig??"city"]),r("td",{class:"rigged-issue"},[o.active_bead?`${o.active_bead} ${t.get(o.active_bead)??""}`.trim():"—"]),r("td",{},[r("span",{class:`badge ${ue(u)}`},[u])]),r("td",{class:`activity-${l.colorClass}`},[r("span",{class:"activity-dot"}),` ${l.display}`])]))}),E(n),n.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Agent"]),r("th",{},["Pool"]),r("th",{},["Rig"]),r("th",{},["Working On"]),r("th",{},["Status"]),r("th",{},["Activity"])])]),i]))}function Fa(e){const t=c("pooled-body"),n=c("pooled-count");if(!t||!n)return;const a=e.filter(i=>!i.rig&&i.pool);if(n.textContent=String(a.length),a.length===0){Se(t,"No pooled agents");return}const s=r("tbody");a.forEach(i=>{s.append(r("tr",{},[r("td",{},[i.template]),r("td",{},[r("span",{class:`badge ${i.active_bead?"badge-yellow":"badge-green"}`},[i.active_bead?"Working":"Idle"])]),r("td",{class:"status-hint"},[at(i.last_output,80)||"—"]),r("td",{},[F(i.last_active)])]))}),E(t),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Agent"]),r("th",{},["State"]),r("th",{},["Work"]),r("th",{},["Activity"])])]),s]))}function Se(e,t){E(e),e.append(r("div",{class:"empty-state"},[r("p",{},[t])]))}function Ha(){var e,t;(e=c("log-drawer-close-btn"))==null||e.addEventListener("click",()=>Ue()),(t=c("log-drawer-older-btn"))==null||t.addEventListener("click",()=>{Ie("crew","Load older transcript clicked",{hasCursor:ne!=="",sessionID:be}),!(!be||!ne)&&vn(be,!0)})}async function Ja(e,t){const n=c("agent-log-drawer"),a=c("log-drawer-agent-name"),s=c("log-drawer-messages"),i=c("log-drawer-loading");if(!n||!a||!s||!i)return;if(be===e&&n.style.display!=="none"){Ue();return}Ue(),be=e,ne="",Me=0,a.textContent=t,E(s),s.append(i),i.style.display="block",n.style.display="block",Q(),await vn(e,!1);const o=S();o&&(Re=Ma(o,e,l=>Va(l)))}function Ue(){Re==null||Re.close(),Re=null,be="",ne="";const e=c("agent-log-drawer");e&&e.style.display!=="none"&&(e.style.display="none",B())}function bn(){Ue()}async function vn(e,t){var y,f,d,p,m;const n=S(),a=c("log-drawer-messages"),s=c("log-drawer-loading"),i=c("log-drawer-older-btn"),o=c("log-drawer-count");if(!n||!a||!s||!i||!o)return;s.style.display="block";const l=await g.GET("/v0/city/{cityName}/session/{id}/transcript",{params:{path:{cityName:n,id:e},query:{tail:String(t?50:25),before:t?ne:void 0}}});if(s.style.display="none",l.error||!l.data){w("error","Transcript failed",((y=l.error)==null?void 0:y.detail)??"Could not load transcript");return}const u=document.createDocumentFragment();for(const h of l.data.turns??[])u.append(wn(h.role,h.text,h.timestamp)),Me+=1;t?a.prepend(u):(E(a),a.append(u)),a.append(s),s.style.display="none",o.textContent=String(Me),ne=((f=l.data.pagination)==null?void 0:f.truncated_before_message)??"",i.style.display=(d=l.data.pagination)!=null&&d.has_older_messages&&ne?"inline-flex":"none",Ie("crew","Transcript loaded",{hasOlderMessages:((p=l.data.pagination)==null?void 0:p.has_older_messages)??!1,nextBeforeCursor:ne,prepend:t,sessionID:e,turnCount:((m=l.data.turns)==null?void 0:m.length)??0})}function Va(e){var s;const t=c("log-drawer-messages");if(!t)return;const n=e.data;if(e.type!=="message"||!((s=n==null?void 0:n.data)!=null&&s.message))return;t.append(wn(n.data.message.role??"agent",n.data.message.text??"",n.data.message.timestamp)),Me+=1,c("log-drawer-count").textContent=String(Me);const a=c("log-drawer-body");a&&(a.scrollTop=a.scrollHeight)}function wn(e,t,n){return r("div",{class:"log-msg"},[r("div",{class:"log-msg-header"},[r("span",{class:`log-msg-type log-msg-type-${Ka(e)}`},[e]),r("span",{class:"log-msg-time"},[F(n)])]),r("div",{class:"log-msg-body"},[t])])}function Ka(e){switch((e??"").toLowerCase()){case"assistant":case"agent":return"assistant";case"system":return"system";case"result":return"result";default:return"user"}}const Qa=3e4,yt=new Map,qe=new Map;async function rt(e=!1){const t=S(),n=Date.now(),a=yt.get(t);if(!e&&a&&n-a.fetchedAt(yt.set(t,o),qe.delete(t),o)).catch(o=>{throw qe.delete(t),o});return qe.set(t,i),i}async function Ya(e){var l,u,y,f,d,p,m,h,v,C,b,N;const t={agents:[],rigs:[],sessions:[],beads:[],mail:[],fetchedAt:Date.now()};if(!e)return t;const[n,a,s,i]=await Promise.all([g.GET("/v0/city/{cityName}/config",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open"}}}),g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:e}}})]);n.error&&we("options","Config options request failed",{city:e,detail:n.error.detail??null});const o=(((l=n.data)==null?void 0:l.agents)??[]).map(k=>({id:k.name??"",label:k.name??"",recipient:k.name??""})).filter(k=>k.recipient!=="");return Ie("options","Fetched options",{agentOptions:o.map(k=>k.recipient),beads:((y=(u=s.data)==null?void 0:u.items)==null?void 0:y.length)??0,city:e,configAgents:((d=(f=n.data)==null?void 0:f.agents)==null?void 0:d.length)??0,mail:((m=(p=i.data)==null?void 0:p.items)==null?void 0:m.length)??0,rigs:((v=(h=a.data)==null?void 0:h.items)==null?void 0:v.length)??0}),{agents:[...new Set(o.map(k=>k.recipient))].sort(),rigs:(((C=a.data)==null?void 0:C.items)??[]).map(k=>k.name??"").filter(Boolean),sessions:o,beads:(((b=s.data)==null?void 0:b.items)??[]).map(k=>({id:k.id??"",title:k.title??""})),mail:(((N=i.data)==null?void 0:N.items)??[]).map(k=>({id:k.id??"",subject:k.subject??""})),fetchedAt:Date.now()}}function Xa(){yt.clear(),qe.clear()}let Oe=null,Pe=null;function Za(){var e,t,n,a,s,i,o,l,u,y;(e=c("action-modal-close-btn"))==null||e.addEventListener("click",()=>xe(null)),(t=c("action-modal-cancel-btn"))==null||t.addEventListener("click",()=>xe(null)),(a=(n=c("action-modal"))==null?void 0:n.querySelector(".modal-backdrop"))==null||a.addEventListener("click",()=>xe(null)),(s=c("action-form"))==null||s.addEventListener("submit",f=>{var h,v,C;f.preventDefault();const d=((h=c("action-bead-id"))==null?void 0:h.value.trim())??"",p=((v=c("action-target"))==null?void 0:v.value.trim())??"",m=((C=c("action-rig"))==null?void 0:C.value.trim())??"";!d||!p||xe({beadID:d,rig:m,target:p})}),(i=c("confirm-modal-close-btn"))==null||i.addEventListener("click",()=>Le(!1)),(o=c("confirm-modal-cancel-btn"))==null||o.addEventListener("click",()=>Le(!1)),(l=c("confirm-modal-confirm-btn"))==null||l.addEventListener("click",()=>Le(!0)),(y=(u=c("confirm-modal"))==null?void 0:u.querySelector(".modal-backdrop"))==null||y.addEventListener("click",()=>Le(!1)),document.addEventListener("keydown",f=>{if(f.key==="Escape"){if(Ce("action-modal")){xe(null);return}Ce("confirm-modal")&&Le(!1)}})}async function kt(e){const t=c("action-modal"),n=c("action-form"),a=c("action-modal-title"),s=c("action-modal-submit-btn"),i=c("action-bead-group"),o=c("action-bead-id"),l=c("action-bead-hint"),u=c("action-target"),y=c("action-target-label"),f=c("action-rig-group"),d=c("action-rig"),p=c("action-modal-help"),m=c("action-target-list"),h=c("action-rig-list");if(!t||!n||!a||!s||!i||!o||!l||!u||!y||!f||!d||!p||!m||!h)return I("Action modal unavailable",new Error("missing action modal DOM")),null;const v=await rt();return zt(m,v.agents),zt(h,v.rigs),a.textContent=e.title,s.textContent=ts(e.mode),y.textContent=e.mode==="reassign"?"Assignee":"Target agent or pool",p.textContent=ns(e.mode),o.value=e.beadID??"",o.readOnly=!!e.beadID,i.classList.toggle("readonly",o.readOnly),l.textContent=e.beadLabel??"",u.value=e.initialTarget??"",d.value=e.initialRig??"",f.hidden=e.mode==="reassign",d.disabled=e.mode==="reassign",Ce("action-modal")||Q(),t.style.display="flex",window.setTimeout(()=>{if(e.beadID){u.focus();return}o.focus()},0),new Promise(C=>{Oe=C})}async function es(e){const t=c("confirm-modal"),n=c("confirm-modal-title"),a=c("confirm-modal-body"),s=c("confirm-modal-confirm-btn");return!t||!n||!a||!s?(I("Confirm modal unavailable",new Error("missing confirm modal DOM")),!1):(n.textContent=e.title,a.textContent=e.body,s.textContent=e.confirmLabel,Ce("confirm-modal")||Q(),t.style.display="flex",new Promise(i=>{Pe=i}))}function zt(e,t){E(e),t.forEach(n=>{e.append(r("option",{value:n}))})}function ts(e){switch(e){case"assign":return"Assign";case"reassign":return"Reassign";default:return"Sling"}}function ns(e){switch(e){case"assign":return"Launch a bead directly to a target, with an optional rig override.";case"reassign":return"Pick a new assignee from the active city sessions or type one manually.";default:return"Dispatch this bead to a target, with an optional rig constraint."}}function xe(e){const t=c("action-modal"),n=c("action-form");if(!t||!n)return;const a=Ce("action-modal");t.style.display="none",n.reset(),c("action-rig").disabled=!1,c("action-bead-id").readOnly=!1,a&&B(),Oe==null||Oe(e),Oe=null}function Le(e){const t=c("confirm-modal");if(!t)return;const n=Ce("confirm-modal");t.style.display="none",n&&B(),Pe==null||Pe(e),Pe=null}function Ce(e){var t;return((t=c(e))==null?void 0:t.style.display)==="flex"}let Xe=[],pt="ready",Ee="all",it="";async function ye(){var o,l,u,y;const e=S(),t=c("issues-list");if(!t)return;if(!e){Sn();return}const[n,a,s]=await Promise.all([g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"open",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}}),rt()]);if(n.error&&a.error||!((o=n.data)!=null&&o.items)&&!((l=a.data)!=null&&l.items)){E(t),t.append(r("div",{class:"panel-error"},["Could not load beads."]));return}Xe=rs([...((u=n.data)==null?void 0:u.items)??[],...((y=a.data)==null?void 0:y.items)??[]].filter(f=>!ss(f))),c("issues-count").textContent=String(Xe.length);const i=c("rig-filter-tabs");i&&(E(i),i.append(mt("all",Ee==="all")),s.rigs.forEach(f=>i.append(mt(f,Ee===f)))),Nt()}function Sn(){const e=c("issues-list"),t=c("rig-filter-tabs"),n=c("issue-detail");if(!e||!t||!n)return;he();const a=n.style.display==="block";n.style.display="none",e.style.display="block",as(),E(e),e.append(r("div",{class:"empty-state"},[r("p",{},["Select a city to view beads"])])),E(t),Ee="all",it="",Xe=[],t.append(mt("all",!0)),c("issues-count").textContent="0",a&&B()}function as(){var t,n;["issue-detail-id","issue-detail-title-text","issue-detail-description","issue-detail-status","issue-detail-type","issue-detail-owner","issue-detail-created"].forEach(a=>{const s=c(a);s&&(s.textContent="")});const e=c("issue-detail-priority");e&&(e.className="badge",e.textContent=""),["issue-detail-actions","issue-detail-depends-on","issue-detail-blocks"].forEach(a=>{const s=c(a);s&&E(s)}),(t=c("issue-detail-deps"))==null||t.style.setProperty("display","none"),(n=c("issue-detail-blocks-section"))==null||n.style.setProperty("display","none")}function Nt(){const e=c("issues-list");if(!e)return;E(e);const t=Xe.filter(a=>{const s=a.assignee?"progress":"ready",i=pt==="all"||pt===s,o=Ee==="all"||ct(a)===Ee;return i&&o});if(t.length===0){e.append(r("div",{class:"empty-state"},[r("p",{},["No beads"])]));return}const n=r("tbody");t.forEach(a=>{const s=r("tr",{class:`issue-row priority-${se(a.priority)}`,"data-issue-id":a.id??"","data-status":a.assignee?"progress":"ready","data-rig":ct(a)},[r("td",{},[r("span",{class:`badge ${on(a.priority)}`},[`P${se(a.priority)}`])]),r("td",{},[r("span",{class:"issue-id"},[a.id??""])]),r("td",{class:"issue-title"},[at(a.title??a.id??"",80)]),r("td",{class:"issue-rig"},[ct(a)]),r("td",{class:"issue-status"},[a.assignee?r("span",{class:"badge badge-blue",title:a.assignee},[a.assignee]):r("span",{class:"badge badge-green"},["Ready"])]),r("td",{class:"issue-age"},[F(a.created_at)]),r("td",{},[hs(a.id??"")])]);s.addEventListener("click",i=>{i.target.closest(".sling-btn")||a.id&&pe(a.id)}),n.append(s)}),e.append(r("table",{id:"work-table"},[r("thead",{},[r("tr",{},[r("th",{},["Pri"]),r("th",{},["ID"]),r("th",{},["Title"]),r("th",{},["Rig"]),r("th",{},["Status"]),r("th",{},["Age"]),r("th",{},["Actions"])])]),n]))}function mt(e,t){const n=r("button",{class:`rig-btn${t?" active":""}`,"data-rig":e},[e==="all"?"All":e]);return n.addEventListener("click",()=>{Ee=e,document.querySelectorAll(".rig-btn").forEach(a=>a.classList.remove("active")),n.classList.add("active"),Nt()}),n}function ct(e){var t;return((t=e.id)==null?void 0:t.split("-")[0])??"city"}function ss(e){return(e.issue_type??"").toLowerCase()==="convoy"?!0:(e.labels??[]).some(t=>t.startsWith("gc:queue")||t.startsWith("gc:message"))}function rs(e){return[...e].sort((t,n)=>{const a=se(t.priority),s=se(n.priority);return a!==s?a-s:(n.created_at??"").localeCompare(t.created_at??"")})}function is(){var e,t,n,a,s,i,o;document.querySelectorAll(".tab-btn").forEach(l=>{l.addEventListener("click",u=>{const y=u.currentTarget;pt=y.dataset.tab??"ready",document.querySelectorAll(".tab-btn").forEach(f=>f.classList.remove("active")),y.classList.add("active"),Nt()})}),(e=c("new-issue-btn"))==null||e.addEventListener("click",()=>Cn()),(t=c("issue-modal-close-btn"))==null||t.addEventListener("click",()=>he()),(n=c("issue-modal-cancel-btn"))==null||n.addEventListener("click",()=>he()),(s=(a=c("issue-modal"))==null?void 0:a.querySelector(".modal-backdrop"))==null||s.addEventListener("click",()=>he()),(i=c("issue-form"))==null||i.addEventListener("submit",l=>{l.preventDefault(),os()}),(o=c("issue-back-btn"))==null||o.addEventListener("click",()=>fs()),document.addEventListener("keydown",l=>{var u;l.key==="Escape"&&((u=c("issue-modal"))==null?void 0:u.style.display)==="block"&&he()})}function Cn(){var t,n,a;if(!S()){w("info","No city selected","Select a city to create a bead");return}const e=c("issue-modal");e&&(e.style.display!=="block"&&Q(),e.style.display="block",(n=(t=c("issues-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),(a=c("issue-title"))==null||a.focus())}function he(){var n;const e=c("issue-modal");if(!e)return;const t=e.style.display==="block";e.style.display="none",(n=c("issue-form"))==null||n.reset(),t&&B()}async function os(){var s,i,o;const e=((s=c("issue-title"))==null?void 0:s.value.trim())??"",t=((i=c("issue-description"))==null?void 0:i.value.trim())??"",n=Number(((o=c("issue-priority"))==null?void 0:o.value)??"2");if(!e)return;const a=await bs({title:e,description:t,priority:n});if(!a.ok){w("error","Create failed",a.error??"Could not create issue");return}w("success","Issue created",e),he(),await ye()}async function pe(e){var l,u,y;const t=S();if(!t)return;it=e,((l=c("issue-detail"))==null?void 0:l.style.display)!=="block"&&Q(),c("issues-list").style.display="none",c("issue-detail").style.display="block";const[n,a,s]=await Promise.all([g.GET("/v0/city/{cityName}/bead/{id}",{params:{path:{cityName:t,id:e}}}),g.GET("/v0/city/{cityName}/bead/{id}/deps",{params:{path:{cityName:t,id:e}}}),rt()]);if(n.error||!n.data){w("error","Issue failed",((u=n.error)==null?void 0:u.detail)??"Could not load bead");return}const i=n.data;c("issue-detail-id").textContent=i.id??e,c("issue-detail-title-text").textContent=i.title??e,c("issue-detail-description").textContent=i.description||"(no description)";const o=c("issue-detail-priority");o.className=`badge ${on(i.priority)}`,o.textContent=`P${se(i.priority)}`,c("issue-detail-status").textContent=i.status??"open",c("issue-detail-status").className=`issue-status ${i.status??"open"}`,c("issue-detail-type").textContent=i.issue_type?`Type: ${i.issue_type}`:"",c("issue-detail-owner").textContent=i.assignee?`Owner: ${i.assignee}`:"Owner: unassigned",c("issue-detail-created").textContent=i.created_at?`Created: ${F(i.created_at)}`:"",ls(i,s.agents),cs(((y=a.data)==null?void 0:y.children)??[])}function cs(e){const t=c("issue-detail-deps"),n=c("issue-detail-depends-on"),a=c("issue-detail-blocks-section"),s=c("issue-detail-blocks");if(!(!t||!n||!a||!s)){if(E(n),E(s),e.length===0){t.style.display="none",a.style.display="none";return}t.style.display="block",e.forEach(i=>{const o=r("span",{class:"issue-dep-item","data-issue-id":i.id??""},[`→ ${i.id??""}`]);o.addEventListener("click",()=>{i.id&&pe(i.id)}),n.append(o)}),a.style.display="none"}}function ls(e,t){const n=c("issue-detail-actions");if(!n||!e.id)return;E(n);const a=r("div",{class:"issue-actions-bar"}),s=e.status==="closed"?lt("↺ Reopen","reopen",()=>void ps(e.id)):lt("✓ Close","close",()=>void ys(e.id));a.append(s),e.status!=="closed"&&a.append(lt("🚚 Sling","sling",()=>void En(e.id)));const i=r("div",{class:"issue-action-group"},[r("label",{class:"issue-action-label"},["Priority"]),ds(e.id,e.priority)]),o=r("div",{class:"issue-action-group"},[r("label",{class:"issue-action-label"},["Assign"]),us(e.id,e.assignee,t)]);n.append(a,i,o)}function lt(e,t,n){const a=r("button",{class:`issue-action-btn ${t}`,type:"button"},[e]);return a.addEventListener("click",n),a}function ds(e,t){const n=r("select",{class:"issue-action-select",id:"issue-action-priority","aria-label":"Priority"});return[1,2,3,4].forEach(a=>{const s=r("option",{value:a,selected:se(t)===a},[`P${a}`]);n.append(s)}),n.addEventListener("change",()=>{ms(e,Number(n.value))}),n}function us(e,t,n){const a=r("select",{class:"issue-action-select",id:"issue-action-assignee","aria-label":"Assignee"});return a.append(r("option",{value:""},["Unassigned"])),n.forEach(s=>{a.append(r("option",{value:s,selected:t===s},[s]))}),a.addEventListener("change",()=>{gs(e,a.value)}),a}function fs(){const e=c("issue-detail"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("issues-list").style.display="block",it="",t&&B()}async function ys(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/close",{params:{path:{cityName:t,id:e},header:O}});if(n.error){w("error","Close failed",n.error.detail??"Could not close issue");return}w("success","Closed",e),await ye(),await pe(e)}async function ps(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/reopen",{params:{path:{cityName:t,id:e},header:O}});if(n.error){w("error","Reopen failed",n.error.detail??"Could not reopen issue");return}w("success","Reopened",e),await ye(),await pe(e)}async function ms(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/update",{params:{path:{cityName:n,id:e},header:O},body:{priority:t}});if(a.error){w("error","Priority failed",a.error.detail??"Could not update priority");return}w("success","Priority updated",`${e} → P${t}`),await ye(),await pe(e)}async function gs(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:n,id:e},header:O},body:{assignee:t}});if(a.error){w("error","Assign failed",a.error.detail??"Could not update assignee");return}w("success","Assignment updated",t||"Unassigned"),await ye(),await pe(e)}async function En(e){const t=S();if(!t)return;const n=await kt({beadID:e,beadLabel:e,mode:"sling",title:"Sling Bead"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/sling",{params:{path:{cityName:t},header:O},body:{bead:e,target:n.target,rig:n.rig||void 0}});if(a.error){w("error","Sling failed",a.error.detail??"Could not sling issue");return}w("success","Work assigned",`${e} → ${n.target}`),await ye(),it===e&&await pe(e)}function hs(e){const t=r("button",{class:"sling-btn",type:"button","data-bead-id":e},["Sling"]);return t.addEventListener("click",n=>{n.stopPropagation(),En(e)}),t}async function bs(e){const t=S();if(!t)return{ok:!1,error:"no city selected"};const{error:n}=await g.POST("/v0/city/{cityName}/beads",{params:{path:{cityName:t},header:O},body:{title:e.title,description:e.description,rig:e.rig,priority:e.priority,assignee:e.assignee}});return n?{ok:!1,error:n.detail??n.title??"create failed"}:{ok:!0}}let G="inbox",_e=[],A=null;async function He(){const e=S(),t=c("mail-loading"),n=c("mail-threads"),a=c("mail-empty"),s=c("mail-all");if(!t||!n||!a||!s)return;if(!e){kn();return}$t("No mail in inbox"),t.style.display="block",n.style.display="none",a.style.display="none";const{data:i,error:o}=await g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:e},query:{status:"all",limit:200}}});if(t.style.display="none",o||!(i!=null&&i.items)){E(n),n.append(r("div",{class:"panel-error"},["Could not load mail."])),n.style.display="block";return}_e=[...i.items].sort((l,u)=>(u.created_at??"").localeCompare(l.created_at??"")),c("mail-count").textContent=String(_e.length),vs(_e),ws(_e),Es()}function kn(){const e=c("mail-loading"),t=c("mail-threads"),n=c("mail-empty"),a=c("mail-all");if(!e||!t||!n||!a)return;le()?(J(G),B()):J(G),A=null,_e=[],c("mail-count").textContent="0",e.style.display="none",E(t),E(a),t.style.display="none",$t("Select a city to view mail"),n.style.display=G==="inbox"?"block":"none",a.append(r("div",{class:"empty-state"},[r("p",{},["Select a city to view mail traffic"])]))}function $t(e){var t,n;(n=(t=c("mail-empty"))==null?void 0:t.querySelector("p"))==null||n.replaceChildren(document.createTextNode(e))}function vs(e){const t=c("mail-threads"),n=c("mail-empty");if(!t||!n)return;const a=As(e);if(E(t),a.length===0){t.style.display="none",$t("No mail in inbox"),n.style.display="block";return}n.style.display="none",a.forEach(s=>{const i=s.messages[s.messages.length-1],o=(i.body??"").trim().slice(0,60),l=r("div",{class:`mail-thread${s.unreadCount>0?" mail-thread-unread":""}`},[r("div",{class:"mail-thread-header"},[r("div",{class:"mail-thread-left"},[r("span",{class:"mail-from"},[U(i.from)])]),r("div",{class:"mail-thread-center"},[r("span",{class:"mail-subject"},[s.subject||"(no subject)"]),o?r("span",{class:"mail-thread-preview"},[` — ${o}`]):null]),r("div",{class:"mail-thread-right"},[r("span",{class:"mail-time"},[Et(i.created_at)]),s.unreadCount>0?r("span",{class:"badge badge-unread"},[`${s.unreadCount} unread`]):null])])]);l.addEventListener("click",()=>{Ss(s.id)}),t.append(l)}),t.style.display=G==="inbox"?"block":"none"}function ws(e){const t=c("mail-all");if(!t)return;if(E(t),e.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No mail traffic"])]));return}const n=r("tbody");e.forEach(a=>{const s=r("tr",{class:`mail-row${a.read?"":" mail-unread"}`},[r("td",{class:"mail-from"},[U(a.from)]),r("td",{class:"mail-to"},[U(a.to)]),r("td",{},[r("span",{class:"mail-subject"},[a.subject??"(no subject)"])]),r("td",{class:"mail-time"},[F(a.created_at)])]);s.addEventListener("click",()=>{a.id&&Cs(a.id)}),n.append(s)}),t.append(r("table",{class:"mail-all-table"},[r("thead",{},[r("tr",{},[r("th",{},["From"]),r("th",{},["To"]),r("th",{},["Subject"]),r("th",{},["Time"])])]),n])),t.style.display=G==="all"?"block":"none"}async function Ss(e){var i,o;const t=S();if(!t)return;const n=await g.GET("/v0/city/{cityName}/mail/thread/{id}",{params:{path:{cityName:t,id:e}}});if(n.error||!((i=n.data)!=null&&i.items)||n.data.items.length===0){w("error","Thread failed",((o=n.error)==null?void 0:o.detail)??"Could not load mail thread");return}const a=n.data.items,s=a[a.length-1]??a[0];A=s,Nn(s,a)}async function Cs(e){var a;const t=S();if(!t)return;const n=await g.GET("/v0/city/{cityName}/mail/{id}",{params:{path:{cityName:t,id:e}}});if(n.error||!n.data){w("error","Message failed",((a=n.error)==null?void 0:a.detail)??"Could not load message");return}A=n.data,await g.POST("/v0/city/{cityName}/mail/{id}/read",{params:{path:{cityName:t,id:e},header:O}}),A.read=!0,Nn(A,[A]),He()}function Nn(e,t){const n=le();c("mail-detail-subject").textContent=e.subject??"(no subject)",c("mail-detail-from").textContent=U(e.from),c("mail-detail-time").textContent=F(e.created_at);const a=c("mail-detail-body");a&&(E(a),t.forEach((s,i)=>{i>0&&a.append(r("hr")),a.append(r("div",{class:"mail-thread-msg-header"},[r("span",{class:"mail-from"},[U(s.from)]),r("span",{class:"mail-time"},[F(s.created_at)])]),r("div",{class:"mail-thread-msg-subject"},[s.subject??"(no subject)"]),r("pre",{},[s.body??""]))})),$n(),J("detail"),xn("mail-detail"),n||Q()}function J(e){const t=c("mail-list"),n=c("mail-all"),a=c("mail-detail"),s=c("mail-compose");!t||!n||!a||!s||(t.style.display=e==="inbox"?"block":"none",n.style.display=e==="all"?"block":"none",a.style.display=e==="detail"?"block":"none",s.style.display=e==="compose"?"block":"none")}function Es(){var e,t;((e=c("mail-compose"))==null?void 0:e.style.display)==="block"||((t=c("mail-detail"))==null?void 0:t.style.display)==="block"||J(G)}function ks(){var e,t,n,a,s,i,o,l;document.querySelectorAll(".mail-tab").forEach(u=>{u.addEventListener("click",y=>{const f=y.currentTarget;G=f.dataset.tab??"inbox",document.querySelectorAll(".mail-tab").forEach(d=>d.classList.remove("active")),f.classList.add("active"),J(G)})}),(e=c("mail-back-btn"))==null||e.addEventListener("click",()=>{const u=le();J(G),A=null,u&&B()}),(t=c("compose-mail-btn"))==null||t.addEventListener("click",()=>{gt()}),(n=c("compose-back-btn"))==null||n.addEventListener("click",()=>{const u=!!A,y=le();J(u?"detail":G),y&&!u&&B()}),(a=c("compose-cancel-btn"))==null||a.addEventListener("click",()=>{const u=le();J(G),u&&B()}),(s=c("mail-reply-btn"))==null||s.addEventListener("click",()=>{A!=null&&A.id&>(A)}),(i=c("mail-send-btn"))==null||i.addEventListener("click",()=>{Ns()}),(o=c("mail-archive-btn"))==null||o.addEventListener("click",()=>{A!=null&&A.id&&$s(A.id)}),(l=c("mail-toggle-unread-btn"))==null||l.addEventListener("click",()=>{A!=null&&A.id&&xs(A)})}async function gt(e){if(!S()){w("info","No city selected","Select a city to compose mail"),we("mail","Compose blocked without city",{replyTo:(e==null?void 0:e.id)??null});return}const t=c("compose-to");if(!t)return;const n=le();E(t),t.append(r("option",{value:""},["Select recipient…"]));try{const a=await rt();a.sessions.forEach(s=>{t.append(r("option",{value:s.recipient},[s.label]))}),ee("mail","Compose options loaded",{city:S(),recipients:a.sessions.length,replyTo:(e==null?void 0:e.id)??null})}catch(a){de("mail","Compose options failed",{city:S(),error:a}),I("Mail options failed",a,"Could not load recipients")}c("compose-subject").value=e?Ls(e.subject??""):"",c("compose-body").value="",c("compose-reply-to").value=(e==null?void 0:e.id)??"",c("mail-compose-title").textContent=e?"Reply":"New Message",e!=null&&e.from&&(Ts(t,e.from),t.value=e.from),J("compose"),xn("compose-subject"),ee("mail","Compose form opened",{city:S(),replyTo:(e==null?void 0:e.id)??null,selectedRecipient:t.value||null}),n||Q()}async function Ns(){var l,u,y,f;const e=S();if(!e)return;const t=((l=c("compose-to"))==null?void 0:l.value)??"",n=((u=c("compose-subject"))==null?void 0:u.value.trim())??"",a=((y=c("compose-body"))==null?void 0:y.value)??"",s=((f=c("compose-reply-to"))==null?void 0:f.value)??"";if(!t||!n){w("error","Missing fields","Recipient and subject are required"),we("mail","Send blocked by missing fields",{bodyLength:a.length,city:e,subject:n,to:t});return}ee("mail","Send requested",{bodyLength:a.length,city:e,replyTo:s||null,subject:n,to:t});const i=s?await g.POST("/v0/city/{cityName}/mail/{id}/reply",{params:{path:{cityName:e,id:s},header:O},body:{body:a,subject:n}}):await g.POST("/v0/city/{cityName}/mail",{params:{path:{cityName:e},header:O},body:{to:t,subject:n,body:a,from:"dashboard"}});if(i.error){de("mail","Send failed",{bodyLength:a.length,city:e,error:i.error,replyTo:s||null,subject:n,to:t}),w("error","Send failed",i.error.detail??"Could not send message");return}ee("mail","Send succeeded",{bodyLength:a.length,city:e,replyTo:s||null,subject:n,to:t}),w("success","Message sent",n);const o=le();J("inbox"),A=null,o&&B(),await He()}async function $s(e){var s;const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/mail/{id}/archive",{params:{path:{cityName:t,id:e},header:O}});if(n.error){w("error","Archive failed",n.error.detail??"Could not archive message");return}w("success","Archived",e);const a=((s=c("mail-detail"))==null?void 0:s.style.display)==="block";J(G),A=null,a&&B(),await He()}async function xs(e){const t=S();if(!t||!e.id)return;const n=e.read?"/v0/city/{cityName}/mail/{id}/mark-unread":"/v0/city/{cityName}/mail/{id}/read",a=await g.POST(n,{params:{path:{cityName:t,id:e.id},header:O}});if(a.error){w("error","Update failed",a.error.detail??"Could not update message");return}e.read=!e.read,A={...e},$n(),w("success","Updated",e.subject??e.id),await He()}function $n(){const e=c("mail-toggle-unread-btn");e&&(e.textContent=A!=null&&A.read?"Mark unread":"Mark read")}function le(){var e,t;return((e=c("mail-detail"))==null?void 0:e.style.display)==="block"||((t=c("mail-compose"))==null?void 0:t.style.display)==="block"}function Ls(e){return e?e.toLowerCase().startsWith("re:")?e:`Re: ${e}`:"Re:"}function Ts(e,t){!t||[...e.options].some(n=>n.value===t)||e.append(r("option",{value:t},[t]))}function xn(e){var t,n;(n=(t=c("mail-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),window.setTimeout(()=>{var a;(a=c(e))==null||a.focus()},0)}function As(e){const t=new Map;e.forEach(i=>{i.id&&t.set(i.id,i)});function n(i){let o=i;const l=new Set;for(;o.reply_to&&o.id&&!l.has(o.id);){l.add(o.id);const u=t.get(o.reply_to);if(!u)break;o=u}return o.thread_id??o.id??Math.random().toString(36)}const a=new Map;e.forEach(i=>{const o=n(i),l=a.get(o)??{id:o,messages:[],subject:i.subject??"",unreadCount:0};l.messages.push(i),i.read||(l.unreadCount+=1),!l.subject&&i.subject&&(l.subject=i.subject),a.set(o,l)});const s=[...a.values()];return s.forEach(i=>{i.messages.sort((o,l)=>(o.created_at??"").localeCompare(l.created_at??""))}),s.sort((i,o)=>{var y,f;const l=((y=i.messages[i.messages.length-1])==null?void 0:y.created_at)??"";return(((f=o.messages[o.messages.length-1])==null?void 0:f.created_at)??"").localeCompare(l)}),s}let ve="";async function xt(){var o;const e=S(),t=c("convoy-list");if(!t)return;if(!e){Ln();return}const n=await g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},query:{limit:200}}});if(n.error||!((o=n.data)!=null&&o.items)){E(t),t.append(r("div",{class:"panel-error"},["Could not load convoys."]));return}const s=(await Promise.all(n.data.items.map(async l=>Rs(e,l.id??"")))).filter(l=>l!==null);if(c("convoy-count").textContent=String(s.length),E(t),s.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No active convoys"])]));return}const i=r("tbody");s.forEach(l=>{const u=r("tr",{class:"convoy-row","data-convoy-id":l.id},[r("td",{},[r("span",{class:`badge ${ue(Tn(l))}`},[qs(l)])]),r("td",{},[r("span",{class:"convoy-id"},[l.id]),l.title?r("div",{class:"convoy-title"},[l.title]):null,l.assignees.length?r("div",{class:"convoy-assignees"},l.assignees.map(y=>r("span",{class:"assignee-chip"},[y]))):null]),r("td",{class:"convoy-progress-cell"},[r("div",{class:"convoy-progress-header"},[r("span",{class:"convoy-progress-fraction"},[`${l.closed}/${l.total}`]),l.total>0?r("span",{class:"convoy-progress-pct"},[`${l.progressPct}%`]):null]),l.total>0?r("div",{class:"progress-bar"},[r("div",{class:"progress-fill",style:`width: ${l.progressPct}%;`})]):null]),r("td",{class:"convoy-work-cell"},[r("div",{class:"convoy-work-breakdown"},[l.ready>0?r("span",{class:"work-chip work-ready"},[`${l.ready} ready`]):null,l.inProgress>0?r("span",{class:"work-chip work-inprogress"},[`${l.inProgress} active`]):null,l.closed===l.total&&l.total>0?r("span",{class:"work-chip work-done"},["all done"]):null])]),r("td",{class:`activity-${l.lastActivity.colorClass}`},[r("span",{class:"activity-dot"}),` ${l.lastActivity.display}`])]);u.addEventListener("click",()=>{Rn(l.id)}),i.append(u)}),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Status"]),r("th",{},["Convoy"]),r("th",{},["Progress"]),r("th",{},["Work"]),r("th",{},["Activity"])])]),i]))}function Ln(){const e=c("convoy-list"),t=c("convoy-detail"),n=c("convoy-create-form");if(!e||!t||!n)return;const a=t.style.display==="block"||n.style.display==="block";ve="",c("convoy-count").textContent="0",t.style.display="none",n.style.display="none",c("convoy-add-issue-form").style.display="none",e.style.display="block",E(e),e.append(r("div",{class:"empty-state"},[r("p",{},["Select a city to view convoys"])])),a&&B()}async function Rs(e,t){var f,d,p,m;if(!t)return null;const n=await g.GET("/v0/city/{cityName}/convoy/{id}",{params:{path:{cityName:e,id:t}}});if(n.error||!n.data)return null;const a=n.data.children??[],s=new Set;let i=0,o=0,l="";a.forEach(h=>{(h.status??"").toLowerCase()!=="closed"&&(h.assignee?(o+=1,s.add(h.assignee)):i+=1),l=[l,h.created_at??""].sort().slice(-1)[0]??l});const u=((f=n.data.progress)==null?void 0:f.total)??a.length,y=((d=n.data.progress)==null?void 0:d.closed)??a.filter(h=>h.status==="closed").length;return{id:t,title:((p=n.data.convoy)==null?void 0:p.title)??t,status:(m=n.data.convoy)==null?void 0:m.status,progressPct:u>0?Math.round(y/u*100):0,total:u,closed:y,ready:i,inProgress:o,assignees:[...s].sort(),lastActivity:je(l)}}function Tn(e){return e.total>0&&e.closed===e.total?"done":e.inProgress>0?"active":e.ready>0?"waiting":e.status??"open"}function qs(e){switch(Tn(e)){case"done":return"✓ Done";case"active":return"Active";case"waiting":return"Waiting";default:return e.status??"Open"}}function Os(){var e,t,n,a,s,i,o,l;(e=c("new-convoy-btn"))==null||e.addEventListener("click",()=>{An()}),(t=c("convoy-back-btn"))==null||t.addEventListener("click",()=>Ps()),(n=c("convoy-create-back-btn"))==null||n.addEventListener("click",()=>ht()),(a=c("convoy-create-cancel-btn"))==null||a.addEventListener("click",()=>ht()),(s=c("convoy-create-submit-btn"))==null||s.addEventListener("click",()=>{_s()}),(i=c("convoy-add-issue-btn"))==null||i.addEventListener("click",()=>{c("convoy-add-issue-form").style.display="flex"}),(o=c("convoy-add-issue-cancel"))==null||o.addEventListener("click",()=>{c("convoy-add-issue-form").style.display="none"}),(l=c("convoy-add-issue-submit"))==null||l.addEventListener("click",()=>{js()})}function An(){var n;if(!S()){w("info","No city selected","Select a city to create a convoy");return}const e=c("convoy-create-form"),t=(e==null?void 0:e.style.display)==="block";ve="",c("convoy-list").style.display="none",c("convoy-detail").style.display="none",e.style.display="block",c("convoy-create-name").value="",c("convoy-create-issues").value="",t||Q(),qn("convoy-create-name"),(n=c("convoy-create-name"))==null||n.focus()}async function Rn(e){var l,u,y,f,d,p,m,h;const t=S();if(!t)return;ve=e,((l=c("convoy-detail"))==null?void 0:l.style.display)!=="block"&&Q(),c("convoy-list").style.display="none",c("convoy-create-form").style.display="none",c("convoy-detail").style.display="block",qn("convoy-detail"),c("convoy-detail-id").textContent=e,c("convoy-detail-title").textContent=`Convoy: ${e}`,c("convoy-issues-loading").style.display="block",c("convoy-issues-table").style.display="none",c("convoy-issues-empty").style.display="none",c("convoy-add-issue-form").style.display="none";const n=await g.GET("/v0/city/{cityName}/convoy/{id}",{params:{path:{cityName:t,id:e}}});if(c("convoy-issues-loading").style.display="none",n.error||!n.data){c("convoy-issues-empty").style.display="block",c("convoy-issues-empty").querySelector("p").textContent=((u=n.error)==null?void 0:u.detail)??"Failed to load convoy";return}const a=((y=n.data.progress)==null?void 0:y.total)??((f=n.data.children)==null?void 0:f.length)??0,s=((d=n.data.progress)==null?void 0:d.closed)??((p=n.data.children)==null?void 0:p.filter(v=>v.status==="closed").length)??0;c("convoy-detail-status").className=`badge ${ue(((m=n.data.convoy)==null?void 0:m.status)??"open")}`,c("convoy-detail-status").textContent=((h=n.data.convoy)==null?void 0:h.status)??"open",c("convoy-detail-progress").textContent=`${s}/${a}`;const i=c("convoy-issues-tbody");if(!i)return;E(i);const o=n.data.children??[];if(o.length===0){c("convoy-issues-empty").style.display="block";return}o.forEach(v=>{const C=v.assignee?v.assignee:v.status==="closed"?"done":"ready";i.append(r("tr",{},[r("td",{class:"convoy-issue-status"},[r("span",{class:`badge ${ue(v.status)}`},[v.status??"unknown"])]),r("td",{},[r("span",{class:"issue-id"},[v.id??""])]),r("td",{class:"issue-title"},[v.title??v.id??""]),r("td",{},[v.assignee?r("span",{class:"badge badge-blue"},[v.assignee]):r("span",{class:"badge badge-muted"},["Unassigned"])]),r("td",{},[C])]))}),c("convoy-issues-table").style.display="table"}function Ps(){const e=c("convoy-detail"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("convoy-list").style.display="block",t&&B()}function ht(){const e=c("convoy-create-form"),t=(e==null?void 0:e.style.display)==="block";e.style.display="none",c("convoy-list").style.display="block",t&&B()}async function _s(){var s,i;const e=S();if(!e)return;const t=((s=c("convoy-create-name"))==null?void 0:s.value.trim())??"",n=(((i=c("convoy-create-issues"))==null?void 0:i.value)??"").split(/\s+/).map(o=>o.trim()).filter(Boolean);if(!t){w("error","Missing name","Convoy name is required");return}const a=await g.POST("/v0/city/{cityName}/convoys",{params:{path:{cityName:e},header:O},body:{title:t,items:n}});if(a.error){w("error","Create failed",a.error.detail??"Could not create convoy");return}w("success","Convoy created",t),ht(),await xt()}async function js(){const e=S();if(!e||!ve)return;const t=c("convoy-add-issue-input"),n=(t==null?void 0:t.value.trim())??"";if(!n)return;const a=await g.POST("/v0/city/{cityName}/convoy/{id}/add",{params:{path:{cityName:e,id:ve},header:O},body:{items:[n]}});if(a.error){w("error","Add failed",a.error.detail??"Could not add issue");return}t&&(t.value=""),c("convoy-add-issue-form").style.display="none",w("success","Issue added",n),await Rn(ve),await xt()}function qn(e){var t,n;(n=(t=c("convoy-panel"))==null?void 0:t.scrollIntoView)==null||n.call(t,{behavior:"smooth",block:"center"}),window.setTimeout(()=>{var a;(a=c(e))==null||a.focus()},0)}const Is=150,z=[];let ae=null,De="all",We="all",ze="all",Lt={};async function Bs(e){z.splice(0,z.length,...Pn(e)),Z()}async function Ms(){var s,i,o;const e=S();let t=[],n="";if(e)t=((s=(await g.GET("/v0/city/{cityName}/events",{params:{path:{cityName:e},query:{since:"1h",limit:100}}})).data)==null?void 0:s.items)??[];else{const l=await g.GET("/v0/events",{params:{query:{since:"1h"}}});t=((i=l.data)==null?void 0:i.items)??[],n=((o=l.data)==null?void 0:o.event_cursor)??""}const a=t.map(l=>Hs(l)).filter(l=>l!==null);Lt=Ks(t,e,n),await Bs(a)}function Us(){z.splice(0,z.length),Lt={},Z()}function Ds(e,t){const n=S();ae==null||ae.close();const a={...Lt,...t?{onStatus:t}:{}};ae=(n?i=>Ba(n,i,a):i=>Ia(i,a))(i=>{const o=jn(i);e==null||e(i,o);const l=Fs(i);l&&(z.some(u=>u.id===l.id)||(z.splice(0,z.length,...Pn([l,...z])),Z()))})}function Ws(){ae==null||ae.close(),ae=null}function Z(){Gs();const e=c("activity-feed");if(!e)return;E(e);const t=z.filter(a=>!(De!=="all"&&a.category!==De||We!=="all"&&a.rig!==We||ze!=="all"&&a.actor!==ze));if(c("activity-count").textContent=String(z.length),t.length===0){e.append(r("div",{class:"empty-state"},[r("p",{},["No recent activity"])]));return}const n=r("div",{class:"tl-timeline",id:"activity-timeline"});t.forEach(a=>{n.append(r("div",{class:`tl-entry ${Ys(a.category)}`,"data-category":a.category,"data-rig":a.rig,"data-agent":a.actor??"","data-type":a.type,"data-ts":a.ts},[r("div",{class:"tl-rail"},[r("span",{class:"tl-time"},[Et(a.ts)]),r("span",{class:"tl-node"})]),r("div",{class:"tl-content"},[r("div",{class:"tl-header"},[r("span",{class:"tl-icon"},[Sa(a.type)]),r("span",{class:"tl-summary"},[Ca(a.type,a.actor,a.subject,a.message)])]),r("div",{class:"tl-meta"},[a.actor?r("span",{class:"tl-badge tl-badge-agent"},[U(a.actor)]):null,a.rig?r("span",{class:"tl-badge tl-badge-rig"},[a.rig]):null,r("span",{class:"tl-badge tl-badge-type"},[a.type])])])]))}),e.append(n)}function zs(){var e,t;document.addEventListener("click",n=>{var s;const a=(s=n.target)==null?void 0:s.closest(".tl-filter-btn");a&&(De=a.dataset.value??"all",document.querySelectorAll(".tl-filter-btn").forEach(i=>i.classList.remove("active")),a.classList.add("active"),Z())}),(e=c("tl-rig-filter"))==null||e.addEventListener("change",n=>{We=n.currentTarget.value,Z()}),(t=c("tl-agent-filter"))==null||t.addEventListener("change",n=>{ze=n.currentTarget.value,Z()})}function Gs(){const e=c("activity-filters");if(!e||(E(e),z.length===0))return;const t=[...new Set(z.map(i=>i.rig).filter(Boolean))].sort(),n=[...new Set(z.map(i=>i.actor).filter(Boolean))].sort(),a=r("select",{class:"tl-filter-select",id:"tl-rig-filter"});a.append(r("option",{value:"all"},["All rigs"])),t.forEach(i=>a.append(r("option",{value:i,selected:i===We},[i]))),a.addEventListener("change",()=>{We=a.value,Z()});const s=r("select",{class:"tl-filter-select",id:"tl-agent-filter"});s.append(r("option",{value:"all"},["All agents"])),n.forEach(i=>s.append(r("option",{value:i,selected:i===ze},[U(i)]))),s.addEventListener("change",()=>{ze=s.value,Z()}),e.append(r("div",{class:"tl-filters"},[r("div",{class:"tl-filter-group"},[r("label",{},["Category:"]),Te("all","All"),Te("agent","Agent"),Te("work","Work"),Te("comms","Comms"),Te("system","System")]),r("div",{class:"tl-filter-group"},[r("label",{for:"tl-rig-filter"},["Rig:"]),a]),r("div",{class:"tl-filter-group"},[r("label",{for:"tl-agent-filter"},["Agent:"]),s])]))}function Te(e,t){const n=r("button",{class:`tl-filter-btn${De===e?" active":""}`,"data-filter":"category","data-value":e,type:"button"},[t]);return n.addEventListener("click",()=>{De=e,Z()}),n}function Fs(e){return e.event==="heartbeat"?null:On(e.data,e.id)}function Hs(e){return On(e)}function On(e,t){if(!e.type)return null;const n=_n(e)??S(),a=typeof e.seq=="number"?e.seq:0;return{id:Qs(e,t),type:e.type,category:wa(e.type),actor:e.actor||void 0,subject:e.subject||void 0,message:e.message||void 0,ts:e.ts,scope:n,seq:a,rig:va(e.actor)||"city"in e&&e.city||""}}function Pn(e){const t=new Map;return e.forEach(n=>{t.has(n.id)||t.set(n.id,n)}),[...t.values()].sort(Js).slice(0,Is)}function Js(e,t){const n=Vs(e.ts,t.ts);if(n!==0)return n;const a=e.scope.localeCompare(t.scope);if(a!==0)return a;const s=t.seq-e.seq;if(s!==0)return s;const i=e.type.localeCompare(t.type);if(i!==0)return i;const o=(e.actor??"").localeCompare(t.actor??"");return o!==0?o:(e.subject??"").localeCompare(t.subject??"")}function Vs(e,t){const n=Number.isNaN(Date.parse(e))?0:Date.parse(e);return(Number.isNaN(Date.parse(t))?0:Date.parse(t))-n}function _n(e){if("city"in e&&typeof e.city=="string"&&e.city!=="")return e.city}function Ks(e,t,n=""){if(t){const s=e.reduce((i,o)=>Math.max(i,o.seq??0),0);return s>0?{afterSeq:String(s)}:{}}const a=n.trim();return a?{afterCursor:a}:{}}function Qs(e,t){const n=_n(e)??S();if(typeof e.seq=="number"&&e.seq>0)return`${n}:${e.seq}`;const a=[e.type,e.ts,e.actor??"",e.subject??"",e.message??"",t??""].join(":");return`${n}:${a}`}function jn(e){return Ua(e)}function Ys(e){switch(e){case"agent":return"activity-agent";case"work":return"activity-work";case"comms":return"activity-comms";default:return"activity-system"}}async function te(){var o,l,u,y,f,d;const e=S();if(!e){In();return}const[t,n,a,s,i]=await Promise.all([g.GET("/v0/city/{cityName}/services",{params:{path:{cityName:e}}}),g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:e},query:{git:!0}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{label:"gc:escalation",status:"open",limit:200}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{label:"gc:queue",limit:200}}})]);Zs(((o=t.data)==null?void 0:o.items)??null,(l=t.error)==null?void 0:l.detail),er(((u=n.data)==null?void 0:u.items)??null),tr(((y=a.data)==null?void 0:y.items)??null),nr(((f=s.data)==null?void 0:f.items)??null),ar(((d=i.data)==null?void 0:d.items)??null)}function In(){Ae("services-body","services-count","Select a city to view services"),Ae("rigs-body","rigs-count","Select a city to view rigs"),Ae("escalations-body","escalations-count","Select a city to view escalations"),Ae("assigned-body","assigned-count","Select a city to view assigned work"),Ae("queues-body","queues-count","Select a city to view queues"),c("clear-assigned-btn").style.display="none"}function Xs(){var e,t;(e=c("open-assign-btn"))==null||e.addEventListener("click",()=>{Bn()}),(t=c("clear-assigned-btn"))==null||t.addEventListener("click",()=>{ir()})}function Zs(e,t){const n=c("services-body"),a=c("services-count");if(!n||!a)return;if(E(n),t){a.textContent="n/a",n.append(r("div",{class:"empty-state"},[r("p",{},[t])]));return}const s=e??[];if(a.textContent=String(s.length),s.length===0){n.append(r("div",{class:"empty-state"},[r("p",{},["No workspace services"])]));return}const i=r("tbody");s.forEach(o=>{const l=r("button",{class:"esc-btn",type:"button"},["Restart"]);l.addEventListener("click",()=>{cr(o.service_name)}),i.append(r("tr",{},[r("td",{},[r("strong",{},[o.service_name])]),r("td",{},[o.kind??"—"]),r("td",{},[r("span",{class:`badge ${ue(o.state??o.publication_state)}`},[o.state??o.publication_state??"unknown"])]),r("td",{},[o.local_state]),r("td",{},[l])]))}),n.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Name"]),r("th",{},["Kind"]),r("th",{},["Service"]),r("th",{},["Local"]),r("th",{},["Actions"])])]),i]))}function er(e){const t=c("rigs-body"),n=c("rigs-count");if(!t||!n)return;E(t);const a=e??[];if(n.textContent=String(a.length),a.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No rigs configured"])]));return}const s=r("tbody");a.forEach(i=>{var u;const o=r("button",{class:"esc-btn",type:"button"},[i.suspended?"Resume":"Suspend"]);o.addEventListener("click",()=>{Gt(i.name,i.suspended?"resume":"suspend")});const l=r("button",{class:"esc-btn",type:"button"},["Restart"]);l.addEventListener("click",()=>{Gt(i.name,"restart")}),s.append(r("tr",{},[r("td",{},[r("span",{class:"rig-name"},[i.name])]),r("td",{},[String(i.agent_count-i.running_count)]),r("td",{},[String(i.running_count)]),r("td",{},[(u=i.git)!=null&&u.branch?`${i.git.branch}${i.git.clean?"":"*"}`:"—"]),r("td",{},[F(i.last_activity)]),r("td",{},[o," ",l])]))}),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Name"]),r("th",{},["Idle"]),r("th",{},["Running"]),r("th",{},["Git"]),r("th",{},["Activity"]),r("th",{},["Actions"])])]),s]))}function tr(e){const t=c("escalations-body"),n=c("escalations-count");if(!t||!n)return;E(t);const a=(e??[]).sort((i,o)=>(i.created_at??"").localeCompare(o.created_at??""));if(n.textContent=String(a.length),a.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No escalations"])]));return}const s=r("tbody");a.forEach(i=>{const o=sr(i.labels??[]),l=(i.labels??[]).includes("acked"),u=r("button",{class:"esc-btn esc-ack-btn",type:"button"},["👍 Ack"]);u.addEventListener("click",()=>{lr(i)});const y=r("button",{class:"esc-btn esc-resolve-btn",type:"button"},["✓ Resolve"]);y.addEventListener("click",()=>{i.id&&dr(i.id)});const f=r("button",{class:"esc-btn esc-reassign-btn",type:"button"},["↻ Reassign"]);f.addEventListener("click",()=>{i.id&&ur(i.id)}),s.append(r("tr",{class:"escalation-row","data-escalation-id":i.id??""},[r("td",{},[r("span",{class:`badge ${rr(o)}`},[o.toUpperCase()])]),r("td",{},[i.title??i.id??"",l?r("span",{class:"badge badge-cyan",style:"margin-left: 4px;"},["ACK"]):null]),r("td",{},[U(i.assignee)]),r("td",{},[F(i.created_at)]),r("td",{class:"escalation-actions"},[l?null:u,y,f])]))}),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Severity"]),r("th",{},["Issue"]),r("th",{},["From"]),r("th",{},["Age"]),r("th",{},["Actions"])])]),s]))}function nr(e){const t=c("assigned-body"),n=c("assigned-count"),a=c("clear-assigned-btn");if(!t||!n||!a)return;E(t);const s=(e??[]).filter(o=>o.assignee);if(n.textContent=String(s.length),a.style.display=s.length>0?"inline-flex":"none",s.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No assigned work"])]));return}const i=r("tbody");s.forEach(o=>{const l=r("button",{class:"unassign-btn",type:"button"},["Unassign"]);l.addEventListener("click",()=>{o.id&&or(o.id)}),i.append(r("tr",{},[r("td",{},[r("span",{class:"assigned-id"},[o.id??""])]),r("td",{class:"assigned-title"},[at(o.title??"",80)]),r("td",{class:"assigned-agent"},[U(o.assignee)]),r("td",{class:"assigned-age"},[F(o.created_at)]),r("td",{},[l])]))}),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Bead"]),r("th",{},["Title"]),r("th",{},["Agent"]),r("th",{},["Since"]),r("th",{},[""])])]),i]))}function ar(e){const t=c("queues-body"),n=c("queues-count");if(!t||!n)return;E(t);const a=e??[];if(n.textContent=String(a.length),a.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No queues"])]));return}const s=r("tbody");a.forEach(i=>{s.append(r("tr",{},[r("td",{},[i.title??i.id??"queue"]),r("td",{},[i.id??"—"]),r("td",{},[r("span",{class:`badge ${ue(i.status)}`},[i.status??"open"])]),r("td",{},[U(i.assignee)]),r("td",{},[F(i.created_at)])]))}),t.append(r("table",{},[r("thead",{},[r("tr",{},[r("th",{},["Queue"]),r("th",{},["Bead"]),r("th",{},["Status"]),r("th",{},["Assignee"]),r("th",{},["Created"])])]),s]))}function Ae(e,t,n){const a=c(e),s=c(t);!a||!s||(E(a),s.textContent="0",a.append(r("div",{class:"empty-state"},[r("p",{},[n])])))}function sr(e){for(const t of e)if(t.startsWith("severity:"))return t.slice(9);return"medium"}function rr(e){switch(e){case"critical":return"badge-red";case"high":return"badge-orange";case"low":return"badge-muted";default:return"badge-yellow"}}async function Bn(e=""){const t=S();if(!t)return;const n=await kt({beadID:e||void 0,beadLabel:e||void 0,mode:"assign",title:"Assign Work"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/sling",{params:{path:{cityName:t},header:O},body:{bead:n.beadID,target:n.target,rig:n.rig||void 0}});if(a.error){w("error","Assign failed",a.error.detail??"Could not assign bead");return}w("success","Assigned",`${n.beadID} → ${n.target}`),await te()}async function ir(){var s;const e=S();if(!e)return;const n=(((s=(await g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:e},query:{status:"in_progress",limit:500}}})).data)==null?void 0:s.items)??[]).filter(i=>i.assignee);if(n.length===0){w("info","Nothing to clear","No assigned work");return}await es({body:`Unassign ${n.length} active ${n.length===1?"bead":"beads"}?`,confirmLabel:"Unassign All",title:"Clear Assignments"})&&(await Promise.all(n.map(i=>g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:e,id:i.id??""},header:O},body:{assignee:""}}))),w("success","Cleared",`${n.length} assignments removed`),await te())}async function or(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:t,id:e},header:O},body:{assignee:""}});if(n.error){w("error","Unassign failed",n.error.detail??"Could not unassign bead");return}w("success","Unassigned",e),await te()}async function cr(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/service/{name}/restart",{params:{path:{cityName:t,name:e},header:O}});if(n.error){w("error","Service failed",n.error.detail??"Could not restart service");return}w("success","Service restarted",e),await te()}async function Gt(e,t){const n=S();if(!n)return;const a=await g.POST("/v0/city/{cityName}/rig/{name}/{action}",{params:{path:{cityName:n,name:e,action:t},header:O}});if(a.error){w("error","Rig action failed",a.error.detail??`Could not ${t} ${e}`);return}w("success","Rig updated",`${e}: ${t}`),await te()}async function lr(e){const t=S();if(!t||!e.id)return;const n=Array.from(new Set([...e.labels??[],"acked"])),a=await g.POST("/v0/city/{cityName}/bead/{id}/update",{params:{path:{cityName:t,id:e.id},header:O},body:{labels:n}});if(a.error){w("error","Ack failed",a.error.detail??"Could not acknowledge escalation");return}w("success","Acknowledged",e.id),await te()}async function dr(e){const t=S();if(!t)return;const n=await g.POST("/v0/city/{cityName}/bead/{id}/close",{params:{path:{cityName:t,id:e},header:O}});if(n.error){w("error","Resolve failed",n.error.detail??"Could not resolve escalation");return}w("success","Resolved",e),await te()}async function ur(e){const t=S();if(!t)return;const n=await kt({beadID:e,beadLabel:e,mode:"reassign",title:"Reassign Escalation"});if(!n)return;const a=await g.POST("/v0/city/{cityName}/bead/{id}/assign",{params:{path:{cityName:t,id:e},header:O},body:{assignee:n.target}});if(a.error){w("error","Reassign failed",a.error.detail??"Could not reassign escalation");return}w("success","Reassigned",`${e} → ${n.target||"unassigned"}`),await te()}function fr(e){const t=c("command-palette-overlay"),n=c("command-palette-input"),a=c("command-palette-results"),s=c("open-palette-btn");if(!t||!n||!a||!s)return;const i=t,o=n,l=a,u=s;let y=[],f=[],d=0;function p(){const b=S(),N=async(k,P)=>{const M=await P;Ut(k,JSON.stringify(M,null,2))};return[{name:"refresh",desc:"Refresh all panels",category:"Dashboard",run:()=>e.refreshAll()},{name:"supervisor health",desc:"Show supervisor health JSON",category:"Supervisor",run:()=>N("health",g.GET("/health"))},{name:"city list",desc:"Show managed cities JSON",category:"Supervisor",run:()=>N("cities",g.GET("/v0/cities"))},{name:"global events",desc:"Show recent supervisor events JSON",category:"Supervisor",run:()=>N("events",g.GET("/v0/events",{params:{query:{since:"1h"}}}))},...b?[{name:"new issue",desc:"Open the issue creation modal",category:"Work",run:()=>Cn()},{name:"compose mail",desc:"Open the compose mail form",category:"Mail",run:()=>gt()},{name:"new convoy",desc:"Open the convoy creation form",category:"Convoys",run:()=>An()},{name:"assign work",desc:"Open the assignment modal",category:"Assigned",run:()=>Bn()},{name:"status",desc:"Show current city status JSON",category:"Status",run:()=>N("status",g.GET("/v0/city/{cityName}/status",{params:{path:{cityName:b}}}))},{name:"agent list",desc:"Show current sessions JSON",category:"Status",run:()=>N("sessions",g.GET("/v0/city/{cityName}/sessions",{params:{path:{cityName:b},query:{state:"active",peek:!0}}}))},{name:"convoy list",desc:"Show current convoys JSON",category:"Convoys",run:()=>N("convoys",g.GET("/v0/city/{cityName}/convoys",{params:{path:{cityName:b},query:{limit:200}}}))},{name:"mail inbox",desc:"Show current mail JSON",category:"Mail",run:()=>N("mail",g.GET("/v0/city/{cityName}/mail",{params:{path:{cityName:b},query:{status:"all",limit:200}}}))},{name:"rig list",desc:"Show rig JSON",category:"Rigs",run:()=>N("rigs",g.GET("/v0/city/{cityName}/rigs",{params:{path:{cityName:b},query:{git:!0}}}))},{name:"list",desc:"Show open and in-progress beads JSON",category:"Beads",run:async()=>{var M,x;const[k,P]=await Promise.all([g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:b},query:{status:"open",limit:500}}}),g.GET("/v0/city/{cityName}/beads",{params:{path:{cityName:b},query:{status:"in_progress",limit:500}}})]);Ut("beads",JSON.stringify({open:((M=k.data)==null?void 0:M.items)??[],in_progress:((x=P.data)==null?void 0:x.items)??[]},null,2))}}]:[],{name:"close output",desc:"Hide the output panel",category:"Dashboard",run:()=>dn()}].filter(k=>typeof k.run=="function")}function m(){E(l);const b=o.value.trim().toLowerCase();if(y=p(),f=y.filter(N=>b===""||N.name.includes(b)||N.desc.toLowerCase().includes(b)||N.category.toLowerCase().includes(b)),d>=f.length&&(d=0),f.length===0){l.append(r("div",{class:"command-palette-empty"},["No matching commands"]));return}f.forEach((N,k)=>{const P=r("button",{class:`command-item${k===d?" selected":""}`,type:"button"},[r("span",{class:"command-name"},[`gt ${N.name}`]),r("span",{class:"command-desc"},[N.desc]),r("span",{class:"command-category"},[N.category])]);P.addEventListener("click",()=>{C(k)}),l.append(P)})}function h(){i.classList.add("open"),o.value="",d=0,m(),o.focus()}function v(){i.classList.remove("open")}async function C(b){const N=f[b];v(),N&&(ee("palette","Execute command",{category:N.category,city:S(),command:N.name}),await N.run())}u.addEventListener("click",()=>h()),i.addEventListener("click",b=>{b.target===i&&v()}),o.addEventListener("input",()=>m()),o.addEventListener("keydown",b=>{if(b.key==="ArrowDown"){d=Math.min(d+1,Math.max(f.length-1,0)),m(),b.preventDefault();return}if(b.key==="ArrowUp"){d=Math.max(d-1,0),m(),b.preventDefault();return}if(b.key==="Enter"){C(d),b.preventDefault();return}b.key==="Escape"&&v()}),document.addEventListener("keydown",b=>{(b.metaKey||b.ctrlKey)&&b.key.toLowerCase()==="k"&&(b.preventDefault(),i.classList.contains("open")?v():h())})}function yr(){const e=c("supervisor-overview-panel"),t=c("supervisor-overview-body"),n=c("supervisor-city-count");if(!e||!t||!n)return;const a=S()==="";if(e.hidden=!a,!a)return;const s=nn().sort((o,l)=>o.name.localeCompare(l.name));if(n.textContent=String(s.length),E(t),s.length===0){t.append(r("div",{class:"empty-state"},[r("p",{},["No managed cities available"])]));return}const i=r("tbody");s.forEach(o=>{const l=o.phasesCompleted.length>0?o.phasesCompleted.join(", "):"—",u=r("a",{class:"supervisor-city-link",href:`?city=${encodeURIComponent(o.name)}`},["Open"]);i.append(r("tr",{},[r("td",{},[r("strong",{},[o.name])]),r("td",{},[r("span",{class:`badge ${o.error?"badge-red":o.running?"badge-green":"badge-muted"}`},[o.error?"Error":o.running?"Running":"Stopped"])]),r("td",{},[o.status??"—"]),r("td",{class:"supervisor-city-phases"},[l]),r("td",{class:"supervisor-city-error"},[o.error??"—"]),r("td",{class:"supervisor-city-actions"},[u])]))}),t.append(r("table",{class:"supervisor-city-table"},[r("thead",{},[r("tr",{},[r("th",{},["City"]),r("th",{},["State"]),r("th",{},["Status"]),r("th",{},["Phases"]),r("th",{},["Error"]),r("th",{},[""])])]),i]))}function pr(e){let t=null,n=!1,a=0,s=!1;async function i(){if(t=null,!e.isPaused()){n=!0,a=Date.now();try{await e.run()}catch(l){e.onError(l)}finally{n=!1}if(!s||e.isPaused()){s=!1;return}s=!1,o()}}function o(){if(t!==null)return;if(n){s=!0;return}const l=e.minIntervalMs??0,u=a>0?Date.now()-a:Number.POSITIVE_INFINITY,y=l>0?Math.max(0,l-u):0;t=setTimeout(()=>{i()},Math.max(e.delayMs,y))}return{schedule:o}}const mr=["convoy-panel","crew-panel","rigged-panel","mail-panel","escalations-panel","services-panel","rigs-panel","pooled-panel","queues-panel","beads-panel","assigned-panel","agent-log-drawer"];async function gr(){st()||await ke()}async function hr(){st()||await ke().catch(e=>I("Catch-up refresh failed",e))}async function br(){St(),await ke(!0)}function Tt(){const e=Fe();if(Ct(e)){Ws(),dt("connecting");return}dt("connecting"),Ds(t=>{const n=jn(t);!n||n==="heartbeat"||!ga(n)||st()||Lr()},dt)}function dt(e){const t=At("connection-status");if(!t)return;const n={connecting:"Connecting…",live:"Live",reconnecting:"Reconnecting…"};t.replaceChildren(document.createTextNode(n[e])),t.classList.remove("connection-live","connection-connecting","connection-reconnecting"),t.classList.add(`connection-${e}`)}function vr(){Oa(),Za(),Ha(),is(),ks(),Os(),zs(),Xs(),fr({refreshAll:gr})}async function wr(){la(),ee("dashboard","Boot start",{city:S(),href:window.location.href}),vr(),Cr(),qa(()=>{hr()}),await br(),Tt(),ee("dashboard","Boot complete",{city:S(),href:window.location.href})}function At(e){return document.getElementById(e)}wr().catch(e=>I("Dashboard boot failed",e));function Sr(e){kr(e),Ve("new-convoy-btn",e,"Select a running city to create a convoy"),Ve("new-issue-btn",e,"Select a running city to create a bead"),Ve("compose-mail-btn",e,"Select a running city to compose mail"),Ve("open-assign-btn",e,"Select a running city to assign work")}function Ve(e,t,n){const a=At(e);a&&(a.dataset.defaultTitle===void 0&&(a.dataset.defaultTitle=a.title||""),a.disabled=!t,a.title=t?a.dataset.defaultTitle:n)}function Cr(){document.addEventListener("click",e=>{var a;const t=(a=e.target)==null?void 0:a.closest("a.city-tab");if(!t)return;const n=t.href;!n||n===window.location.href||(e.preventDefault(),Er(n))}),window.addEventListener("popstate",()=>{ee("dashboard","Popstate navigation",{href:window.location.href}),bn(),wt(),St(),ke().catch(e=>I("Refresh failed",e)),Tt()})}async function Er(e){ee("dashboard","Navigate city scope",{nextURL:e}),bn(),window.history.pushState({},"",e),wt(),St(),await ke(),Tt()}function kr(e){mr.forEach(t=>{const n=At(t);if(!n)return;const a=!e&&n.classList.contains("expanded");if(n.hidden=!e,a){n.classList.remove("expanded");const s=n.querySelector(".expand-btn");s&&(s.textContent="Expand"),B()}})}const Nr=1e3,$r=1e4,xr=pr({delayMs:Nr,isPaused:st,minIntervalMs:$r,onError:e=>I("Refresh failed",e),run:()=>ke()});function Lr(){xr.schedule()}async function ke(e=!1){wt();const t=ya(e);if(t.size===0)return;t.has("options")&&Xa(),t.has("cities")&&await ha().catch(l=>{tn(),I("City tabs failed",l)});const n=[],a=Fe(),s=ma(a);Sr(s),Ct(a)&&Tr(),ie(n,t,"status",()=>Ea()),a.kind==="supervisor"||s?ie(n,t,"activity",()=>Ms()):Us(),s&&(ie(n,t,"crew",()=>Da()),ie(n,t,"issues",()=>ye()),ie(n,t,"mail",()=>He()),ie(n,t,"convoys",()=>xt()),ie(n,t,"admin",()=>te()));const o=(await Promise.allSettled(n)).find(l=>l.status==="rejected");o&&I("Panel refresh failed",o.reason),(t.has("supervisor")||t.has("cities"))&&yr()}function Tr(){Ln(),gn(),Sn(),kn(),In()}function ie(e,t,n,a){t.has(n)&&e.push(a())} diff --git a/cmd/gc/dashboard/web/dist/index.html b/cmd/gc/dashboard/web/dist/index.html index c98a34b726..94ef1d21fd 100644 --- a/cmd/gc/dashboard/web/dist/index.html +++ b/cmd/gc/dashboard/web/dist/index.html @@ -27,10 +27,10 @@
-
+
- City Scope - Idle + Selected Scope + Loading
@@ -75,6 +75,7 @@

Tracked Issues