diff --git a/.github/actions/setup-gascity-macos/action.yml b/.github/actions/setup-gascity-macos/action.yml index 0e3ad0da2..cd861ff16 100644 --- a/.github/actions/setup-gascity-macos/action.yml +++ b/.github/actions/setup-gascity-macos/action.yml @@ -20,6 +20,10 @@ 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) required: false @@ -41,7 +45,7 @@ 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 @@ -108,88 +112,16 @@ 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 diff --git a/.github/actions/setup-gascity-ubuntu/action.yml b/.github/actions/setup-gascity-ubuntu/action.yml index 20e0d2a48..bf1a69eec 100644 --- a/.github/actions/setup-gascity-ubuntu/action.yml +++ b/.github/actions/setup-gascity-ubuntu/action.yml @@ -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/requirements/mcp-agent-mail.in b/.github/requirements/mcp-agent-mail.in new file mode 100644 index 000000000..c86630704 --- /dev/null +++ b/.github/requirements/mcp-agent-mail.in @@ -0,0 +1 @@ +mcp-agent-mail==0.1.0 diff --git a/.github/requirements/mcp-agent-mail.txt b/.github/requirements/mcp-agent-mail.txt new file mode 100644 index 000000000..79b6e3c25 --- /dev/null +++ b/.github/requirements/mcp-agent-mail.txt @@ -0,0 +1,3011 @@ +# 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 +asyncpg==0.31.0 \ + --hash=sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8 \ + --hash=sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be \ + --hash=sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be \ + --hash=sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2 \ + --hash=sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d \ + --hash=sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a \ + --hash=sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7 \ + --hash=sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218 \ + --hash=sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d \ + --hash=sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602 \ + --hash=sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6 \ + --hash=sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab \ + --hash=sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095 \ + --hash=sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5 \ + --hash=sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9 \ + --hash=sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9 \ + --hash=sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c \ + --hash=sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec \ + --hash=sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8 \ + --hash=sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047 \ + --hash=sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e \ + --hash=sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24 \ + --hash=sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31 \ + --hash=sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186 \ + --hash=sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3 \ + --hash=sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61 \ + --hash=sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a \ + --hash=sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1 \ + --hash=sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2 \ + --hash=sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2 \ + --hash=sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540 \ + --hash=sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c \ + --hash=sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8 \ + --hash=sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671 \ + --hash=sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad \ + --hash=sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d \ + --hash=sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298 \ + --hash=sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008 \ + --hash=sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3 \ + --hash=sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20 \ + --hash=sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2 \ + --hash=sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4 \ + --hash=sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109 \ + --hash=sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403 \ + --hash=sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6 \ + --hash=sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a \ + --hash=sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b \ + --hash=sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735 \ + --hash=sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b \ + --hash=sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab \ + --hash=sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e \ + --hash=sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da \ + --hash=sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366 \ + --hash=sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95 \ + --hash=sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d \ + --hash=sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44 \ + --hash=sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696 + # via mcp-agent-mail +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 +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 +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +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==0.1.0 \ + --hash=sha256:9e6b1ddbeb091abc51fd24f752844fe6ef33e7db37b7fd2247fda3f8359f85fc \ + --hash=sha256:f4756b55176537ca9c34502f3f800e2219dedb0eab59312fd62ba45480c465b6 + # 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 + # pytest +pathable==0.5.0 \ + --hash=sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6 \ + --hash=sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1 + # via jsonschema-path +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 +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +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 +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 + # pytest + # rich +pyjwt==2.12.1 \ + --hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ + --hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b + # via mcp +pyperclip==1.11.0 \ + --hash=sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6 \ + --hash=sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273 + # via fastmcp +pytest==9.0.3 \ + --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ + --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c + # via mcp-agent-mail +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 + # 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 000000000..660e2088f --- /dev/null +++ b/.github/scripts/install-bd-archive.sh @@ -0,0 +1,160 @@ +#!/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.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" + 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-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-claude-native.sh b/.github/scripts/install-claude-native.sh new file mode 100755 index 000000000..5b9a6c849 --- /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 000000000..f336d22bb --- /dev/null +++ b/.github/scripts/install-dolt-archive.sh @@ -0,0 +1,160 @@ +#!/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.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" + 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-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/workflows/ci.yml b/.github/workflows/ci.yml index a8ac61c4f..b3556034e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,10 @@ jobs: packs: ${{ steps.filter.outputs.packs }} worker: ${{ steps.filter.outputs.worker }} worker_phase2: ${{ steps.filter.outputs.worker_phase2 }} + 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: | @@ -70,6 +71,14 @@ jobs: - 'internal/runtime/**' - 'internal/config/**' - 'cmd/gc/**' + cmd_gc_process: + - 'go.mod' + - 'go.sum' + - '.github/workflows/**' + - 'Makefile' + - 'cmd/gc/**' + - 'internal/**' + - 'examples/gastown/packs/**' # Always runs: lint, fmt, vet, unit tests, docs, acceptance, coverage. check: @@ -85,34 +94,11 @@ jobs: 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: "true" - name: Install tools run: make install-tools @@ -142,12 +128,45 @@ jobs: run: make spec-ci - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: coverage.txt token: ${{ secrets.CODECOV_TOKEN }} verbose: true + release-config: + name: Release config + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check GoReleaser configuration + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 + with: + version: "~> v2" + args: check + + cmd-gc-process: + name: cmd/gc process suite + needs: changes + if: needs.changes.outputs.cmd_gc_process == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DOLT_VERSION: "1.86.1" + BD_VERSION: "v1.0.0" + 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: Install tools + run: make install-tools + - name: Run cmd/gc process suite + run: make test-cmd-gc-process + # Runs always, but remains non-blocking while integration/provider paths are still stabilizing. integration-shards: name: Integration / ${{ matrix.shard_name }} @@ -195,7 +214,7 @@ jobs: 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 @@ -227,7 +246,7 @@ jobs: 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 @@ -259,7 +278,7 @@ jobs: 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 @@ -399,7 +418,7 @@ jobs: 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 @@ -435,7 +454,7 @@ jobs: 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 @@ -471,7 +490,7 @@ jobs: 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 @@ -799,27 +818,11 @@ jobs: runs-on: ubuntu-latest 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 @@ -843,7 +846,7 @@ jobs: 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: | @@ -873,7 +876,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -885,7 +888,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 @@ -899,7 +902,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -925,7 +928,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod diff --git a/.github/workflows/close-stale-needs.yml b/.github/workflows/close-stale-needs.yml index 35451f7cc..44c4e4235 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 000000000..b3d723c99 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "24 4 * * 1" + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + 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/homebrew-tap-smoke.yml b/.github/workflows/homebrew-tap-smoke.yml index bffbaeba8..e8b8ee492 100644 --- a/.github/workflows/homebrew-tap-smoke.yml +++ b/.github/workflows/homebrew-tap-smoke.yml @@ -34,7 +34,7 @@ jobs: run: brew uninstall --force gascity || true - name: Install gascity from the live tap - run: brew install --build-from-source gastownhall/gascity/gascity + run: brew install gastownhall/gascity/gascity - name: Run formula test block run: brew test gastownhall/gascity/gascity diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9ec6e44a3..5917cccd2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -28,23 +28,11 @@ jobs: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: ./.github/actions/setup-gascity-ubuntu with: - go-version: "1.25.8" - - 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 + dolt-version: ${{ env.DOLT_VERSION }} + bd-version: ${{ env.BD_VERSION }} + install-claude-cli: "true" - name: Validate Synthetic Claude configuration run: | @@ -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 @@ -134,7 +119,7 @@ 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" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -143,8 +128,7 @@ jobs: - 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: | @@ -201,7 +185,7 @@ 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" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -210,8 +194,7 @@ jobs: - 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: | @@ -259,7 +242,7 @@ 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" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -268,8 +251,7 @@ jobs: - 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: | diff --git a/.github/workflows/rc-gate.yml b/.github/workflows/rc-gate.yml index a797a6dfa..bb7f7de8b 100644 --- a/.github/workflows/rc-gate.yml +++ b/.github/workflows/rc-gate.yml @@ -284,7 +284,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 @@ -305,22 +305,13 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 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 + run: .github/scripts/install-bd-archive.sh "${{ env.BD_VERSION }}" --cache - name: Run make test run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f072cce5c..c992d6341 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,25 +4,29 @@ on: push: tags: - "v*" + # Manual dispatch is only for rerunning a release from a v* tag. Publishing + # jobs below are tag-gated and skip branch refs. workflow_dispatch: 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 @@ -39,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: > @@ -48,3 +52,192 @@ jobs: env: 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, attest-release] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Extract version from tag + 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 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ secrets.HOMEBREW_TAP_APP_ID }} + private-key: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} + owner: gastownhall + repositories: homebrew-gascity + permission-contents: write + + - name: Generate and push Homebrew formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ steps.homebrew-token.outputs.token }} + run: | + version="${{ steps.version.outputs.version }}" + tag="v${version}" + base_url="https://github.com/gastownhall/gascity/releases/download/${tag}" + + gh release download "${tag}" --pattern "gascity_${version}_checksums.txt" --dir /tmp + checksums_file="/tmp/gascity_${version}_checksums.txt" + + get_sha256() { + local sha + sha=$(grep "$1" "$checksums_file" | awk '{print $1}') + if [ -z "$sha" ]; then + echo "ERROR: missing checksum for $1" >&2 + exit 1 + fi + echo "$sha" + } + + darwin_arm64_sha=$(get_sha256 "gascity_${version}_darwin_arm64.tar.gz") + darwin_amd64_sha=$(get_sha256 "gascity_${version}_darwin_amd64.tar.gz") + linux_amd64_sha=$(get_sha256 "gascity_${version}_linux_amd64.tar.gz") + linux_arm64_sha=$(get_sha256 "gascity_${version}_linux_arm64.tar.gz") + + cat > /tmp/gascity.rb < # create a new city + gc start # start an existing city + EOS + end + + test do + assert_match version.to_s, shell_output("#{bin}/gc version") + end + end + FORMULA + sed -i 's/^ //' /tmp/gascity.rb + + cd /tmp + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/gastownhall/homebrew-gascity.git" + cp gascity.rb homebrew-gascity/Formula/gascity.rb + cd homebrew-gascity + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/gascity.rb + git commit -m "gascity ${version}" || echo "No changes to commit" + git push diff --git a/.github/workflows/remove-needs-info.yml b/.github/workflows/remove-needs-info.yml index 9d6654001..58233e778 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 f76044e01..189c61ae0 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 4969a6bd2..373e59bcd 100644 --- a/.github/workflows/review-formulas.yml +++ b/.github/workflows/review-formulas.yml @@ -38,7 +38,7 @@ jobs: reason: ${{ steps.gate.outputs.reason }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 id: filter with: filters: | @@ -145,7 +145,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 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..25af175bb --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,43 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "37 5 * * 2" + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + 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 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF results + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + sarif_file: scorecard.sarif + + - name: Upload SARIF artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: openssf-scorecard-sarif + path: scorecard.sarif + retention-days: 5 diff --git a/.github/workflows/triage-label.yml b/.github/workflows/triage-label.yml index fbfcada2e..99c8807ff 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/.goreleaser.yml b/.goreleaser.yml index e34054def..da326bd1e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -15,20 +15,21 @@ 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 replace_existing_artifacts: true -# Homebrew distribution is handled by a hand-authored, source-built formula -# that lives outside this repo (in `gastownhall/homebrew-gascity` and, once -# merged, `Homebrew/homebrew-core`). Removed the autogenerated binary-install -# `brews:` block because: -# - homebrew-core rejects binary-only formulae; it requires a source build. -# - Keeping both a GoReleaser-stamped tap formula and a hand-authored -# core formula guarantees drift between the two sources of truth. -# See RELEASING.md for the new flow. +# Homebrew tap distribution is generated by .github/workflows/release.yml after +# GoReleaser uploads all release archives. The tap formula installs the release +# assets directly; no source build or Go toolchain is required for users. changelog: sort: asc diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd259bd4..e971f3454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ## [1.0.0] - 2026-04-21 diff --git a/Makefile b/Makefile index d3830bf7b..c8b4e8e06 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-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-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 ## build: compile gc binary with version metadata build: @@ -163,15 +164,22 @@ TEST_ENV = env -i \ ## 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 `test-integration-packages` shard. +## `make test-cmd-gc-process` locally and the CI `cmd/gc process suite` job. ## Wrapped in $(TEST_ENV) — see comment above for why. -test: +test: test-fsys-darwin-compile $(TEST_ENV) GC_FAST_UNIT=1 go test ./... +## test-fsys-darwin-compile: cross-compile internal/fsys for macOS so +## unix.Stat_t field-type regressions fail in the default fast test path. +test-fsys-darwin-compile: + @tmp=$$(mktemp -d); \ + trap 'rm -rf "$$tmp"' EXIT; \ + $(TEST_ENV) GOOS=darwin GOARCH=arm64 go test -c -o "$$tmp/fsys.test" ./internal/fsys + ## 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 ./cmd/gc + $(TEST_ENV) GC_FAST_UNIT=0 go test -count=1 -timeout 20m ./cmd/gc ## test-worker-core: run deterministic worker transcript and continuation conformance test-worker-core: @@ -348,8 +356,8 @@ UNIT_COVER_PKGS := $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.Im ## test-cover: run fast unit-test coverage without the integration-tagged package sweep ## The skipped cmd/gc process-backed scenarios remain covered by -## `make test-cmd-gc-process` locally and the CI `test-integration-packages` shard. -test-cover: +## `make test-cmd-gc-process` locally and the CI `cmd/gc process suite` job. +test-cover: test-fsys-darwin-compile $(TEST_ENV) GC_FAST_UNIT=1 go test -timeout 8m -coverprofile=coverage.txt $(UNIT_COVER_PKGS) ## cover: run tests and show coverage report @@ -361,8 +369,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. @@ -376,10 +383,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: @@ -404,7 +424,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/RELEASING.md b/RELEASING.md index e37bab072..19e60dff1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ | Channel | Mechanism | Automatic? | |---------|-----------|------------| | **GitHub Release** | GoReleaser via `release.yml` on tag push | Yes | -| **Homebrew tap** (`gastownhall/gascity`) | GoReleaser `brews:` block writes to the tap on tag push | Yes | +| **Homebrew tap** (`gastownhall/gascity`) | `release.yml` writes an asset-based formula after archives upload | Yes | | **Homebrew core** (`Homebrew/homebrew-core`) | BrewTestBot autobump, once listed | Yes (~3h delay) | The homebrew-core submission is [in progress](https://github.com/Homebrew/homebrew-core). Until it lands and is added to the autobump list, users install via `brew install gastownhall/gascity/gascity`. @@ -45,7 +45,9 @@ 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, creates the GitHub Release with grouped changelog (`feat:` → Features, `fix:` → Bug Fixes, others → Others), and writes the Homebrew tap formula via the `brews:` block in `.goreleaser.yml`. +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. **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'`). @@ -54,13 +56,26 @@ 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 GoReleaser `brews:` block automatically overwrites `Formula/gascity.rb` in the `gastownhall/homebrew-gascity` repo on every tag push, using `HOMEBREW_TAP_TOKEN`. No manual action required. +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. -**This section will change when homebrew-core lands.** The `brews:` block will be removed, the tap will be deprecated, and releases will flow only through the source-built formula in homebrew-core. +The tap formula installs prebuilt release assets, so users do not need Go or a source build: + +```bash +brew install gastownhall/gascity/gascity +``` + +The intended long-term user-facing Homebrew path is homebrew-core: + +```bash +brew install gascity +``` + +Until the core formula lands, the tap is the public install path. After core lands, keep the tap available for emergency updates while normal releases flow through homebrew-core. ## Homebrew core (planned) @@ -80,7 +95,8 @@ 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` | -| `gastownhall/homebrew-gascity/Formula/gascity.rb` | `url` + `sha256` updated | 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 @@ -98,7 +114,7 @@ Check `.github/workflows/release.yml` still matches `tags: v*`. Verify the tag w ### Tap formula not updated -Check `HOMEBREW_TAP_TOKEN` in repo secrets. It needs `contents: write` on `gastownhall/homebrew-gascity`. 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 e9e1db0c3..919ee2b3f 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/cmd/gc/api_state.go b/cmd/gc/api_state.go index 27f4ad522..786d8d418 100644 --- a/cmd/gc/api_state.go +++ b/cmd/gc/api_state.go @@ -51,6 +51,12 @@ 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 that would drop newly bound rigs are + // ignored until the loop observes and applies the same or a newer config. + configMutationPending atomic.Bool } type configMutationSnapshot struct { @@ -130,6 +136,9 @@ func wrapWithCachingStore(ctx context.Context, store beads.Store, ep events.Prov if err := cs.PrimeActive(); err != nil { log.Printf("caching-store: pre-prime failed: %v", err) } + if ctx.Done() == nil { + return cs + } // Full prime runs async — backfills remaining beads for List() // callers (convergence reconcile, sweep, API handlers). go func() { @@ -176,7 +185,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) } @@ -184,7 +193,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:")) @@ -204,7 +213,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) } } @@ -269,6 +278,9 @@ func (cs *controllerState) applyBeadEventToStores(evt events.Event) { // 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. @@ -301,6 +313,119 @@ func (cs *controllerState) update(cfg *config.City, sp runtime.Provider) { cs.mu.Unlock() } +func (cs *controllerState) updateFromRuntime(cfg *config.City, sp runtime.Provider) { + if cs.configMutationPending.Load() && cs.runtimeUpdateDropsPendingRigs(cfg) { + return + } + if cs.configMutationPending.Load() && cs.runtimeUpdateCanReuseCurrentStores(cfg) { + cs.updateConfigAndProviderOnly(cfg, sp) + cs.configMutationPending.Store(false) + return + } + cs.update(cfg, sp) + cs.configMutationPending.Store(false) +} + +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) +} + +type storeTopologyRig struct { + path string + prefix string +} + +func sameStoreTopology(cityPath string, current, next *config.City) bool { + if current == nil || next == nil { + 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. @@ -547,11 +672,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 := initDirIfReady(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 { @@ -584,7 +737,9 @@ func (cs *controllerState) UpdateProvider(name string, patch api.ProviderUpdate) DisplayName: patch.DisplayName, Base: patch.Base, Command: patch.Command, + ACPCommand: patch.ACPCommand, Args: patch.Args, + ACPArgs: patch.ACPArgs, ArgsAppend: patch.ArgsAppend, PromptMode: patch.PromptMode, PromptFlag: patch.PromptFlag, @@ -743,6 +898,7 @@ func (cs *controllerState) mutateAndPoke(mutate func() error) error { } return fmt.Errorf("refreshing updated city config: %w", err) } + cs.configMutationPending.Store(true) if cs.configDirty != nil { cs.configDirty.Store(true) } diff --git a/cmd/gc/api_state_test.go b/cmd/gc/api_state_test.go index f78d033a5..927866acc 100644 --- a/cmd/gc/api_state_test.go +++ b/cmd/gc/api_state_test.go @@ -136,6 +136,87 @@ 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.configMutationPending.Store(true) + + cs.updateFromRuntime(stale, runtime.NewFake()) + + 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()) + + if cs.configMutationPending.Load() { + t.Fatal("pending mutation marker not cleared after matching runtime update") + } +} + +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.configMutationPending.Store(true) + + next := &config.City{ + Workspace: config.Workspace{Name: "city1"}, + Rigs: []config.Rig{{ + Name: "alpha", + Path: rigDir, + Prefix: "al", + }}, + } + cs.updateFromRuntime(next, runtime.NewFake()) + + 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 TestControllerStateCreateRigPokesReconciler(t *testing.T) { t.Setenv("GC_BEADS", "file") @@ -167,6 +248,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") @@ -582,7 +703,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 +1441,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/bd_env.go b/cmd/gc/bd_env.go index 513631772..29fd3a2dc 100644 --- a/cmd/gc/bd_env.go +++ b/cmd/gc/bd_env.go @@ -13,6 +13,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" ) @@ -36,10 +37,13 @@ func bdStoreForCity(dir, cityPath string) *beads.BdStore { // 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 { - env := bdRuntimeEnvForRig(cityPath, cfg, rigDir) - return env - })) + return beads.NewBdStore(rigDir, bdCommandRunnerForRig(cityPath, cfg, rigDir)) +} + +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) { @@ -516,7 +520,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 +573,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/beads_provider_lifecycle.go b/cmd/gc/beads_provider_lifecycle.go index 40a1fbb52..2f086691c 100644 --- a/cmd/gc/beads_provider_lifecycle.go +++ b/cmd/gc/beads_provider_lifecycle.go @@ -428,6 +428,9 @@ func ensureBeadsProvider(cityPath string) error { // Called by gc stop after agents have been terminated. // For exec providers, fires "stop". For file providers, always available. func shutdownBeadsProvider(cityPath string) error { + if cityUsesBdStoreContract(cityPath) && strings.TrimSpace(os.Getenv("GC_DOLT")) == "skip" { + return clearManagedDoltRuntimeStateIfOwned(cityPath) + } provider := beadsProvider(cityPath) if strings.HasPrefix(provider, "exec:") { if providerUsesBdStoreContract(provider) && isExternalDolt(cityPath) { @@ -447,6 +450,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 { @@ -466,7 +476,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) @@ -486,7 +498,14 @@ func initBeadsForDir(cityPath, dir, prefix, doltDatabase string) error { 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"), + }) + return runProviderOpWithEnv(script, env, args...) } target, err := resolveConfiguredExecStoreTarget(cityPath, dir) if err != nil { diff --git a/cmd/gc/beads_provider_lifecycle_test.go b/cmd/gc/beads_provider_lifecycle_test.go index 0cee60408..430288f4a 100644 --- a/cmd/gc/beads_provider_lifecycle_test.go +++ b/cmd/gc/beads_provider_lifecycle_test.go @@ -758,6 +758,31 @@ func TestShutdownBeadsProvider_bd_skip(t *testing.T) { } } +func TestShutdownBeadsProviderBdSkipClearsPublishedRuntimeState(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + MaterializeBuiltinPacks(dir) //nolint:errcheck + if err := writeDoltState(dir, doltRuntimeState{ + Running: true, + PID: os.Getpid(), + Port: 33123, + DataDir: filepath.Join(dir, ".beads", "dolt"), + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltState: %v", err) + } + t.Setenv("GC_BEADS", "bd") + t.Setenv("GC_DOLT", "skip") + if err := shutdownBeadsProvider(dir); err != nil { + t.Fatalf("shutdownBeadsProvider() error = %v", err) + } + if _, err := os.Stat(managedDoltStatePath(dir)); !os.IsNotExist(err) { + t.Fatalf("published dolt runtime state still present, stat err = %v", err) + } +} + func TestCurrentDoltPortPrefersRuntimeState(t *testing.T) { cityDir := t.TempDir() if err := os.MkdirAll(filepath.Join(cityDir, ".gc", "runtime", "packs", "dolt"), 0o755); err != nil { @@ -2061,6 +2086,181 @@ func TestInitBeadsForDir_execPassesCanonicalDoltDatabase(t *testing.T) { } } +// 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") + 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", "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) + if err != nil { + t.Fatalf("read env log: %v", err) + } + parts := strings.Split(strings.TrimSpace(string(data)), "|") + if len(parts) != 2 { + t.Fatalf("env log = %q, want host|beads_dir", string(data)) + } + if got := parts[0]; got != "ambient-dolt" { + t.Fatalf("GC_DOLT_HOST = %q, want ambient-dolt", got) + } + if got, want := parts[1], filepath.Join(rigDir, ".beads"); got != want { + t.Fatalf("BEADS_DIR = %q, want %q", got, want) + } +} + +func TestInitBeadsForDirExecPreventsStrayGitInit(t *testing.T) { + skipSlowCmdGCTest(t, "uses real bd init process behavior; run make test-cmd-gc-process for full coverage") + configureTestDoltIdentityEnv(t) + + findRealBD := func() string { + t.Helper() + for _, dir := range strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)) { + if strings.TrimSpace(dir) == "" { + continue + } + candidate := filepath.Join(dir, "bd") + info, err := os.Stat(candidate) + if err != nil || info.Mode()&0o111 == 0 { + continue + } + helpCmd := exec.Command(candidate, "--help") + helpCmd.Env = sanitizedBaseEnv() + out, err := helpCmd.CombinedOutput() + if err == nil && strings.Contains(string(out), "Initialize bd in the current directory") { + return candidate + } + } + t.Skip("real bd with init support not found in PATH") + return "" + } + bdPath := findRealBD() + + rawDir := t.TempDir() + rawCmd := exec.Command(bdPath, "init", "--quiet", "--server", "--prefix", "raw", "--skip-hooks", "--skip-agents", ".") + rawCmd.Dir = rawDir + rawCmd.Env = sanitizedBaseEnv() + rawOut, err := rawCmd.CombinedOutput() + if err != nil { + t.Fatalf("direct bd init failed: %v\n%s", err, rawOut) + } + if _, err := os.Stat(filepath.Join(rawDir, ".beads")); err != nil { + t.Fatalf("direct bd init did not create .beads: %v", err) + } + if _, err := os.Stat(filepath.Join(rawDir, ".git")); err == nil { + t.Log("direct bd init created .git without BEADS_DIR") + } else if !os.IsNotExist(err) { + t.Fatalf("stat direct bd init .git: %v", err) + } + + cityDir := t.TempDir() + writeMinimalCityToml(t, cityDir) + rigDir := filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + + script := filepath.Join(t.TempDir(), "provider.sh") + content := fmt.Sprintf(`#!/bin/sh +set -eu +op="$1" +shift +case "$op" in + init) + dir="$1" + prefix="$2" + cd "$dir" + exec %q init --quiet --server --prefix "$prefix" --skip-hooks --skip-agents . + ;; + *) + exit 0 + ;; +esac +`, bdPath) + if err := os.WriteFile(script, []byte(content), 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) @@ -3613,6 +3813,7 @@ esac } func TestGcBeadsBdInitBackfillsRepoIDMigrationWhenMetadataExistsWithoutProjectID(t *testing.T) { + skipSlowCmdGCTest(t, "runs the materialized gc-beads-bd init script with GC_BIN helper; run make test-cmd-gc-process for full coverage") cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) @@ -5967,13 +6168,36 @@ func TestGcBeadsBdStartConcurrentWaitPassesRemainingExistingManagedBudget(t *tes t.Fatal(err) } invocationFile := filepath.Join(t.TempDir(), "gc-invocation") + nowFile := filepath.Join(t.TempDir(), "gc-now-ms") fakeGC := filepath.Join(binDir, "gc") fakeGCScript := fmt.Sprintf(`#!/bin/sh set -eu invocation_file=%q +now_file=%q subcmd="$1 $2" shift 2 case "$subcmd" in + "dolt-state now-ms") + if [ -f "$now_file" ]; then + step=$(cat "$now_file") + else + step=0 + fi + case "$step" in + 0) + printf '1000000\n' + printf '1\n' > "$now_file" + ;; + 1) + printf '1000000\n' + printf '2\n' > "$now_file" + ;; + *) + printf '1001000\n' + printf '3\n' > "$now_file" + ;; + esac + ;; "dolt-state runtime-layout") while [ "$#" -gt 0 ]; do case "$1" in @@ -6044,7 +6268,7 @@ case "$subcmd" in exit 64 ;; esac -`, invocationFile, layout.PackStateDir, layout.DataDir, layout.LogFile, layout.StateFile, layout.PIDFile, layout.LockFile, layout.ConfigFile) +`, invocationFile, nowFile, layout.PackStateDir, layout.DataDir, layout.LogFile, layout.StateFile, layout.PIDFile, layout.LockFile, layout.ConfigFile) if err := os.WriteFile(fakeGC, []byte(fakeGCScript), 0o755); err != nil { t.Fatal(err) } @@ -6353,6 +6577,7 @@ func TestGcBeadsBdRecoverHelperPreservesReadOnlyWarning(t *testing.T) { } func TestManagedDoltConfigGoWriterMatchesShellFallbackSemantics(t *testing.T) { + skipSlowCmdGCTest(t, "starts the materialized gc-beads-bd shell fallback; run make test-cmd-gc-process for full coverage") cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) @@ -7763,6 +7988,7 @@ func TestNormalizeCanonicalBdScopeFilesMaterializesMissingMetadata(t *testing.T) } func TestGcBeadsBdStartFallsBackToShellManagedConfigWriterWhenGCBinUnset(t *testing.T) { + skipSlowCmdGCTest(t, "starts the materialized gc-beads-bd shell fallback; run make test-cmd-gc-process for full coverage") cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, ".gc"), 0o755); err != nil { t.Fatal(err) diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index 721f29ddf..1600694fe 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -28,7 +28,7 @@ type DesiredStateResult struct { 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 + 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. @@ -49,10 +49,11 @@ type DesiredStateResult struct { } 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 } func evaluatePendingPools( @@ -80,12 +81,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,7 +110,7 @@ func evaluatePendingPools( "agent_index": agentIndex, }, "") } - }(j, template, agentName, agentIndex, sp, pw.poolDir) + }(j, template, agentName, agentIndex, sp, pw.poolDir, newDemand) } wg.Wait() @@ -110,16 +118,21 @@ func evaluatePendingPools( 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 + 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 } -// 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, @@ -219,7 +232,7 @@ func buildDesiredStateWithSessionBeads( // but generic scale_check/min demand for the backing template still // creates ephemeral capacity through the pool pipeline. poolDir := agentCommandDir(cityPath, &cfg.Agents[i], cfg.Rigs) - pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir}) + pendingPools = append(pendingPools, poolEvalWork{agentIdx: i, sp: sp, poolDir: poolDir, newDemand: store != nil}) continue } @@ -227,11 +240,11 @@ 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])}) + 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 @@ -488,9 +501,8 @@ func refreshDesiredStateWithSessionBeads( // 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, @@ -522,9 +534,10 @@ func collectAssignedWorkBeadsWithStores( var partial bool for _, s := range stores { seen := make(map[string]struct{}) - // In-progress beads with an assignee (active work). + // In-progress beads with an assignee (active work), plus stranded + // unassigned pool work that needs to be reopened. if inProgress, err := s.List(beads.ListQuery{Status: "in_progress", Live: true}); err == nil { - appendAssignedUnique(&result, &resultStores, inProgress, seen, s) + appendInProgressWorkUnique(cfg, &result, &resultStores, inProgress, seen, s) } else { log.Printf("collectAssignedWorkBeads: List(in_progress) failed: %v", err) partial = true @@ -567,27 +580,40 @@ 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, beadList []beads.Bead, seen map[string]struct{}, store beads.Store) { 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, b, seen, store) + } +} + +func appendAssignedUnique(dst *[]beads.Bead, stores *[]beads.Store, beadList []beads.Bead, seen map[string]struct{}, store beads.Store) { + 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, b, seen, store) + } +} + +func appendWorkUnique(dst *[]beads.Bead, stores *[]beads.Store, b beads.Bead, seen map[string]struct{}, store beads.Store) { + // 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) } } @@ -690,10 +716,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 { continue } - if !manualSession && !desiredHasTemplate(desired, template) { + if !manualSession && !desiredHasTemplate(desired, template) && !pendingCreate { continue } } @@ -756,6 +783,16 @@ func discoverSessionBeadsWithRoots( return roots } +func isPendingPoolCreate(b beads.Bead) bool { + if !isPoolManagedSessionBead(b) || strings.TrimSpace(b.Metadata["pending_create_claim"]) != boolMetadata(true) { + return false + } + if strings.TrimSpace(b.Metadata["state"]) != "creating" { + return false + } + return true +} + func realizeDependencyFloors( bp *agentBuildParams, cfg *config.City, diff --git a/cmd/gc/build_desired_state_test.go b/cmd/gc/build_desired_state_test.go index 21c24180f..dafbb78a3 100644 --- a/cmd/gc/build_desired_state_test.go +++ b/cmd/gc/build_desired_state_test.go @@ -536,6 +536,7 @@ func TestBuildDesiredState_RoutedQueueDoesNotCreateOneSessionPerBead(t *testing. } func TestBuildDesiredState_MinZeroDefaultScaleCheckRoutedWorkCreatesPoolSession(t *testing.T) { + skipSlowCmdGCTest(t, "uses real bd subprocesses for routed-work scale checks; run make test-cmd-gc-process for full coverage") bdPath, err := findPreferredBinary("bd", "/home/ubuntu/.local/bin/bd") if err != nil { t.Skip("bd not installed") @@ -1935,6 +1936,57 @@ func TestBuildDesiredState_PendingCreatePoolSessionUsesConcreteBeadIdentity(t *t } } +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": "creating", + }, + }); 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_LegacyAliaslessEphemeralPoolSessionFallsBackToSessionNameIdentity(t *testing.T) { cityPath := t.TempDir() store := beads.NewMemStore() @@ -2207,6 +2259,7 @@ func TestBuildDesiredState_PoolCheckUsesExplicitRigPassword(t *testing.T) { } func TestBuildDesiredState_PoolCheckUsesManagedCityDoltPortWhenRigHasNoOverride(t *testing.T) { + skipSlowCmdGCTest(t, "uses a live managed-dolt port probe for scale_check coverage; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "myrig") diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index 403f4ce0d..b164a3da5 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -68,8 +68,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 chan struct{} + asyncStarts asyncStartTracker + demandSnapshot *runtimeDemandSnapshot convHandler *convergence.Handler // nil until bead store available convStoreAdapter *convergenceStoreAdapter // typed reference; avoids type assertions in tick/reconcile @@ -204,6 +206,7 @@ func newCityRuntime(p CityRuntimeParams) *CityRuntime { poolSessions: p.PoolSessions, poolDeathHandlers: p.PoolDeathHandlers, suspendedNames: suspendedNames, + asyncStartLimiter: make(chan struct{}, defaultMaxParallelStartsPerWave), convergenceReqCh: p.ConvergenceReqCh, reloadReqCh: func() chan reloadRequest { if p.ReloadReqCh != nil { @@ -271,14 +274,17 @@ func (cr *CityRuntime) run(ctx context.Context) { lastProviderName = v } - cityRoot := filepath.Dir(cr.tomlPath) + cityRoot := cr.cityPath + if cityRoot == "" && cr.tomlPath != "" { + cityRoot = filepath.Dir(cr.tomlPath) + } // Enforce restrictive permissions on .gc/ and its subdirectories. enforceGCPermissions(cr.cityPath, cr.stderr) // Open standalone city bead store when controllerState is unavailable. // When controllerState is present, it manages the cached city store. - if cr.cs == nil { + if cr.cs == nil && cityRoot != "" { if store, err := openCityStoreAt(cityRoot); err != nil { fmt.Fprintf(cr.stderr, "%s: city bead store: %v (auto-suspend disabled)\n", cr.logPrefix, err) //nolint:errcheck // best-effort stderr } else { @@ -371,6 +377,11 @@ func (cr *CityRuntime) run(ctx context.Context) { return } + cr.applyStartupConfigReload(ctx, dirty, &lastProviderName, cityRoot) + 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). @@ -665,6 +676,10 @@ 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 @@ -725,11 +740,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()) } @@ -754,6 +764,12 @@ func (cr *CityRuntime) tick( tickCompleted = true } +func (cr *CityRuntime) dispatchOrders(ctx context.Context, cityRoot string) { + if cr.od != nil { + cr.od.dispatch(ctx, cityRoot, time.Now()) + } +} + func (cr *CityRuntime) handleReloadRequest(req *reloadRequest) { if req == nil { return @@ -814,6 +830,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, @@ -1032,7 +1066,7 @@ func (cr *CityRuntime) reloadConfigTraced( cr.serviceStateMu.Unlock() if cr.cs != nil { - cr.cs.update(nextCfg, nextSp) + cr.cs.updateFromRuntime(nextCfg, nextSp) } if cr.svc != nil { if err := cr.svc.Reload(); err != nil { @@ -1150,7 +1184,7 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat } rigStores := cr.rigBeadStores() assignedWorkBeads := result.AssignedWorkBeads - if released := releaseOrphanedPoolAssignments(store, cr.cfg, sessionBeads.Open(), assignedWorkBeads, result.AssignedWorkStores); len(released) > 0 { + if released := releaseOrphanedPoolAssignments(store, cr.cfg, sessionBeads.Open(), assignedWorkBeads, result.AssignedWorkStores, rigStores); len(released) > 0 { for _, r := range released { fmt.Fprintf(cr.stderr, "released orphaned pool work: %s\n", r.ID) //nolint:errcheck } @@ -1199,7 +1233,7 @@ 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 @@ -1298,6 +1332,10 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat 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 { @@ -1312,7 +1350,10 @@ 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 } @@ -1359,6 +1400,41 @@ func (cr *CityRuntime) requestDeferredDrainFollowUpTick() { } } +func (cr *CityRuntime) ensureAsyncStartLimiter() chan struct{} { + if cr.asyncStartLimiter == nil { + cr.asyncStartLimiter = make(chan struct{}, defaultMaxParallelStartsPerWave) + } + 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, @@ -1520,9 +1596,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, @@ -1572,8 +1649,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 } @@ -1790,6 +1867,7 @@ func (cr *CityRuntime) beginTraceCycle(trigger, detail string, sessionBeads *ses // normal shutdown) — only the first call takes effect. func (cr *CityRuntime) shutdown() { cr.shutdownOnce.Do(func() { + cr.waitForAsyncStarts() if cr.trace != nil { _ = cr.trace.Close() } @@ -1807,6 +1885,8 @@ 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, timeout, cr.rec, cr.cfg, store, cr.stdout, cr.stderr) }) } diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index dc731639a..ecc581e3d 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -132,6 +132,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,6 +234,44 @@ func TestCityRuntimeDemandSnapshotReusesStablePatrolDemand(t *testing.T) { } } +type recordingOrderDispatcher struct { + called atomic.Bool +} + +func (r *recordingOrderDispatcher) dispatch(context.Context, string, time.Time) { + r.called.Store(true) +} + +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 TestCityRuntimeDemandSnapshotRefreshesWhenDemandCommandsAreCustom(t *testing.T) { cases := []struct { name string @@ -1216,6 +1293,7 @@ func TestCityRuntimeBeadReconcileTick_SweepRespectsLiveAssignedWork(t *testing.T } func TestCityRuntimeTick_RefreshesManualSessionOverlayAfterSync(t *testing.T) { + skipSlowCmdGCTest(t, "runs a full runtime tick/reconcile path; run make test-cmd-gc-process for full coverage") cityPath := t.TempDir() if err := os.MkdirAll(filepath.Join(cityPath, "prompts"), 0o755); err != nil { t.Fatalf("mkdir prompts: %v", err) @@ -2097,6 +2175,68 @@ func TestCityRuntimeReloadSameRevisionIsNoOp(t *testing.T) { } } +func TestCityRuntimeRunReloadsConfigBeforeStartupReconcile(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) + + 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() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var startupAgentCount atomic.Int32 + cr := newCityRuntime(CityRuntimeParams{ + CityPath: cityPath, + CityName: "test-city", + TomlPath: tomlPath, + ConfigRev: configRev, + Cfg: cfg, + SP: sp, + BuildFn: func(cfg *config.City, _ runtime.Provider, _ beads.Store) DesiredStateResult { + startupAgentCount.Store(int32(len(cfg.Agents))) + cancel() + return DesiredStateResult{State: map[string]TemplateParams{}} + }, + Dops: newDrainOps(sp), + Rec: events.Discard, + Stdout: io.Discard, + Stderr: io.Discard, + }) + cs := newControllerState(context.Background(), cfg, sp, events.NewFake(), "test-city", cityPath) + cs.cityBeadStore = beads.NewMemStore() + cr.setControllerState(cs) + + cr.run(ctx) + + if got := startupAgentCount.Load(); got != 1 { + t.Fatalf("startup saw %d agent(s), want reloaded config with 1 agent", got) + } + if got := cr.cfg.Agents[0].Name; got != "fresh-agent" { + t.Fatalf("reloaded agent = %q, want fresh-agent", got) + } +} + func TestNewCityRuntimeUsesRegisteredAliasForEffectiveIdentity(t *testing.T) { cityPath := t.TempDir() tomlPath := filepath.Join(cityPath, "city.toml") @@ -2447,6 +2587,7 @@ 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{ @@ -2465,6 +2606,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() @@ -2475,6 +2617,9 @@ func TestCityRuntimeRunStopsBeforeStartedWhenCanceledDuringStartup(t *testing.T) if started { t.Fatal("OnStarted called after cancellation") } + if od.called.Load() { + t.Fatal("order dispatcher called before startup completed") + } if strings.Contains(stdout.String(), "City started.") { t.Fatalf("stdout = %q, want no started banner after cancellation", stdout.String()) } diff --git a/cmd/gc/cityinit_impl_test.go b/cmd/gc/cityinit_impl_test.go index 704644559..b941ef99c 100644 --- a/cmd/gc/cityinit_impl_test.go +++ b/cmd/gc/cityinit_impl_test.go @@ -298,6 +298,7 @@ func TestLocalInitializerScaffoldPreservesExistingDirectoryWhenRegisterFails(t * } func TestLocalInitializerInitScaffoldsAndFinalizes(t *testing.T) { + skipSlowCmdGCTest(t, "runs the full local init scaffold/finalize path; run make test-cmd-gc-process for full coverage") configureTestDoltIdentityEnv(t) cityPath := filepath.Join(t.TempDir(), "init-city") diff --git a/cmd/gc/cmd_beads_city_test.go b/cmd/gc/cmd_beads_city_test.go index 59961e126..25f865b92 100644 --- a/cmd/gc/cmd_beads_city_test.go +++ b/cmd/gc/cmd_beads_city_test.go @@ -87,6 +87,7 @@ func TestDoBeadsCityEndpointSupportsExecGcBeadsBdProvider(t *testing.T) { } func TestDoBeadsCityUseExternalWritesVerifiedCityAndInheritedRigs(t *testing.T) { + skipSlowCmdGCTest(t, "exercises managed bd provider transition behavior; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityDir := t.TempDir() @@ -184,6 +185,7 @@ func TestDoBeadsCityUseExternalWritesVerifiedCityAndInheritedRigs(t *testing.T) } func TestDoBeadsCityUseExternalUpdatesIncludedInheritedRigs(t *testing.T) { + skipSlowCmdGCTest(t, "exercises managed bd provider transition behavior; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityDir := t.TempDir() @@ -414,6 +416,7 @@ func TestDoBeadsCityUseExternalStopFailureKeepsExternalConfig(t *testing.T) { } func TestDoBeadsCityUseExternalRewritesCompatRigWithRelativePath(t *testing.T) { + skipSlowCmdGCTest(t, "exercises managed bd provider transition behavior; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityDir := t.TempDir() @@ -509,6 +512,7 @@ func TestDoBeadsCityUseExternalPreservesCompatOnlyExplicitRigs(t *testing.T) { } func TestDoBeadsCityUseExternalAdoptUnverifiedSkipsValidation(t *testing.T) { + skipSlowCmdGCTest(t, "exercises managed bd provider transition behavior; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityDir := t.TempDir() diff --git a/cmd/gc/cmd_commands.go b/cmd/gc/cmd_commands.go index d35201be7..efe478cc9 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_convoy_dispatch.go b/cmd/gc/cmd_convoy_dispatch.go index e727b660a..ddb94c9d7 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,13 +118,47 @@ 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 runControlDispatcherWithStore(cityPath, storePath, store, bead, beadID, stdout, stderr) +} + +func runControlDispatcherWithStore(cityPath, storePath string, store beads.Store, bead beads.Bead, beadID string, stdout, stderr io.Writer) error { + opts := dispatch.ProcessOptions{CityPath: cityPath, StorePath: storePath} opts.Tracef = workflowTracef loadCfg := false switch bead.Metadata["gc.kind"] { @@ -179,43 +214,59 @@ func runControlDispatcher(beadID string, stdout, stderr io.Writer) error { return nil } +func openControlStoreAtForCity(storePath, cityPath string, cfg *config.City) (beads.Store, error) { + if cfg != nil { + for _, rig := range cfg.Rigs { + rigPath := rig.Path + if !filepath.IsAbs(rigPath) { + rigPath = filepath.Join(cityPath, rigPath) + } + if samePath(rigPath, storePath) { + if !scopeUsesManagedBdStoreContract(cityPath, storePath) { + return openStoreAtForCity(storePath, cityPath) + } + return bdStoreForRig(storePath, cityPath, cfg), nil + } + } + } + return openStoreAtForCity(storePath, cityPath) +} + // 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 +459,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 +476,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 +533,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 +552,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 +568,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,34 +606,53 @@ 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 { diff --git a/cmd/gc/cmd_convoy_dispatch_test.go b/cmd/gc/cmd_convoy_dispatch_test.go index ac506f78e..37588d5e4 100644 --- a/cmd/gc/cmd_convoy_dispatch_test.go +++ b/cmd/gc/cmd_convoy_dispatch_test.go @@ -185,8 +185,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 +313,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 { @@ -802,8 +900,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]) @@ -941,10 +1042,8 @@ 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()) + wantQuery := workflowServeWorkQuery(cdAgent) var gotQueries []string var gotDirs []string var gotEnv []map[string]string @@ -965,7 +1064,7 @@ func TestRunWorkflowServeProcessesReadyControlBeadsThenExits(t *testing.T) { sequence = sequence[1:] return next, 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 } @@ -1000,6 +1099,266 @@ func TestRunWorkflowServeProcessesReadyControlBeadsThenExits(t *testing.T) { } } +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) + } + for _, want := range []string{ + `bd ready --assignee="$cand"`, + `bd ready --metadata-field "gc.routed_to=$GC_CONTROL_TARGET" --unassigned`, + `bd 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"}]' + ;; + "ready --assignee=gascity--control-dispatcher --json --limit=20") + printf '[{"id":"ga-ready"}]' + ;; + "ready --metadata-field gc.routed_to=gascity/control-dispatcher --unassigned --json --limit=20") + printf '[{"id":"ga-routed"}]' + ;; + *) + printf '[]' + ;; +esac +`) + if got, want := strings.TrimSpace(out), `[{"id":"ga-ready"}]`; got != want { + t.Fatalf("control query output = %q, want %q", got, want) + } +} + +func TestWorkflowServeControlReadyQueryQuotesMetadataFallbackTarget(t *testing.T) { + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName, Dir: "my rig"}) + out := runWorkflowServeShellQueryForTest(t, query, map[string]string{}, `#!/bin/sh +set -eu +case "$1|$2|$3|$4|$5|$6" in + "ready|--metadata-field|gc.routed_to=my rig/control-dispatcher|--unassigned|--json|--limit=20") + printf '[{"id":"ga-routed"}]' + ;; + *) + printf '[]' + ;; +esac +`) + if got, want := strings.TrimSpace(out), `[{"id":"ga-routed"}]`; got != want { + t.Fatalf("control query output = %q, want %q", got, want) + } +} + +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 + "ready --metadata-field gc.routed_to=gascity/workflow-control --unassigned --json --limit=20") + printf '[{"id":"ga-legacy-route"}]' + ;; + *) + printf '[]' + ;; +esac +`) + if got, want := strings.TrimSpace(out), `[{"id":"ga-legacy-route"}]`; got != want { + t.Fatalf("control query output = %q, want %q", got, want) + } +} + +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 +} + +// 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" + +[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) + + 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 + }) + + 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 + } + return nil, nil + } + + 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("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) + } +} + func TestRunWorkflowServeUsesGCTemplateForSessionContext(t *testing.T) { clearGCEnv(t) cityDir := t.TempDir() @@ -1059,7 +1418,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 +1472,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 +1524,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 +1545,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 +1624,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 +1711,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 +1719,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 +1794,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 +1802,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 +1900,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 +2212,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") } diff --git a/cmd/gc/cmd_dolt_state_test.go b/cmd/gc/cmd_dolt_state_test.go index e5a3a43f4..380eabed2 100644 --- a/cmd/gc/cmd_dolt_state_test.go +++ b/cmd/gc/cmd_dolt_state_test.go @@ -328,6 +328,7 @@ func TestDoltStateAllocatePortCmdReusesLiveProviderState(t *testing.T) { } func TestStartTCPListenerProcessInDirRegistersCleanup(t *testing.T) { + skipSlowCmdGCTest(t, "spawns a TCP listener process and verifies cleanup; run make test-cmd-gc-process for full coverage") port := reserveRandomTCPPort(t) dir := t.TempDir() var proc *exec.Cmd @@ -519,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() @@ -1202,7 +1400,7 @@ func TestDoltStatePreflightCleanCmdRemovesStaleArtifacts(t *testing.T) { } func TestDoltStatePreflightCleanCmdPreservesLiveArtifacts(t *testing.T) { - skipSlowCmdGCTest(t, "spawns managed dolt holder processes; run without -short or via integration packages") + skipSlowCmdGCTest(t, "spawns managed dolt holder processes; run make test-cmd-gc-process for full coverage") if _, err := exec.LookPath("lsof"); err != nil { t.Skip("lsof not installed") } @@ -1249,6 +1447,7 @@ func TestDoltStatePreflightCleanCmdPreservesLiveArtifacts(t *testing.T) { func startTCPListenerProcessInDir(t *testing.T, port int, dir string) *exec.Cmd { t.Helper() + skipSlowCmdGCTest(t, "spawns a TCP listener process to emulate managed dolt; run make test-cmd-gc-process for full coverage") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("MkdirAll(%s): %v", dir, err) } @@ -1297,6 +1496,7 @@ while True: func startLockedDelayedTCPListenerProcessInDir(t *testing.T, lockFile string, port int, dir string, delay time.Duration) *exec.Cmd { t.Helper() + skipSlowCmdGCTest(t, "spawns a delayed TCP listener process to emulate managed dolt recovery; run make test-cmd-gc-process for full coverage") if err := os.MkdirAll(filepath.Dir(lockFile), 0o755); err != nil { t.Fatalf("MkdirAll(%s): %v", filepath.Dir(lockFile), err) } @@ -2131,7 +2331,7 @@ func TestDoltStateStopManagedCmdDoesNotKillImposterPortHolder(t *testing.T) { } func TestDoltStateRecoverManagedCmdReportsReadOnlyAndRestarts(t *testing.T) { - skipSlowCmdGCTest(t, "spawns managed dolt recovery processes; run without -short or via integration packages") + 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 { @@ -2488,7 +2688,7 @@ esac } func TestDoltStateRecoverManagedCmdClearsPublishedStateWhenPreflightCleanupFails(t *testing.T) { - skipSlowCmdGCTest(t, "spawns managed dolt recovery processes; run without -short or via integration packages") + 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 { @@ -2563,7 +2763,7 @@ func TestDoltStateRecoverManagedCmdClearsPublishedStateWhenPreflightCleanupFails } func TestDoltStateRecoverManagedCmdFailsWhenPostStartHealthFails(t *testing.T) { - skipSlowCmdGCTest(t, "spawns managed dolt recovery processes; run without -short or via integration packages") + 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 { diff --git a/cmd/gc/cmd_handoff.go b/cmd/gc/cmd_handoff.go index 5675c6ca6..50a8d1397 100644 --- a/cmd/gc/cmd_handoff.go +++ b/cmd/gc/cmd_handoff.go @@ -155,14 +155,21 @@ func doHandoffWithOutcome(store beads.Store, rec events.Recorder, dops drainOps, if len(args) > 1 { message = args[1] } + metadata, err := mailSenderRouteMetadata(store, sessionAddress) + if err != nil { + fmt.Fprintf(stderr, "gc handoff: resolving sender route: %v\n", err) //nolint:errcheck // best-effort stderr + return handoffOutcome{code: 1} + } + senderDisplay := mailSenderDisplayFromMetadata(sessionAddress, metadata) b, err := store.Create(beads.Bead{ Title: subject, Description: message, Type: "message", Assignee: sessionAddress, - From: sessionAddress, + 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 @@ -170,7 +177,7 @@ func doHandoffWithOutcome(store beads.Store, rec events.Recorder, dops drainOps, } rec.Record(events.Event{ Type: events.MailSent, - Actor: sessionAddress, + Actor: senderDisplay, Subject: b.ID, Message: sessionAddress, Payload: mailEventPayload(nil), @@ -277,6 +284,12 @@ func doHandoffRemote(store beads.Store, rec events.Recorder, sp runtime.Provider if len(args) > 1 { message = args[1] } + metadata, err := mailSenderRouteMetadata(store, sender) + if err != nil { + fmt.Fprintf(stderr, "gc handoff: resolving sender route: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + senderDisplay := mailSenderDisplayFromMetadata(sender, metadata) // Send mail to target. b, err := store.Create(beads.Bead{ @@ -284,8 +297,9 @@ func doHandoffRemote(store beads.Store, rec events.Recorder, sp runtime.Provider Description: message, Type: "message", Assignee: targetAddress, - From: sender, + 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 @@ -293,7 +307,7 @@ func doHandoffRemote(store beads.Store, rec events.Recorder, sp runtime.Provider } rec.Record(events.Event{ Type: events.MailSent, - Actor: sender, + Actor: senderDisplay, Subject: b.ID, Message: targetAddress, Payload: mailEventPayload(nil), diff --git a/cmd/gc/cmd_handoff_test.go b/cmd/gc/cmd_handoff_test.go index 0ceba9e95..d11dc0a99 100644 --- a/cmd/gc/cmd_handoff_test.go +++ b/cmd/gc/cmd_handoff_test.go @@ -549,14 +549,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 +604,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 f40525f99..da8f3e031 100644 --- a/cmd/gc/cmd_hook.go +++ b/cmd/gc/cmd_hook.go @@ -29,11 +29,7 @@ With --inject: wraps output in for hook injection, always exit 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 @@ -47,8 +43,8 @@ With --inject: wraps output in for hook injection, always exit // 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 { @@ -198,9 +194,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 +209,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 @@ -248,7 +242,7 @@ func doHookWithFormat(workQuery, dir string, inject bool, hookFormat string, run if inject { if hasWork { content := formatHookInjectReminder(normalized) - _ = writeProviderHookContext(stdout, hookFormat, content) + _ = writeProviderHookContextForEvent(stdout, hookFormat, "Stop", content) } return 0 // --inject always exits 0 } diff --git a/cmd/gc/cmd_hook_test.go b/cmd/gc/cmd_hook_test.go index 6f39547b8..a5ff60268 100644 --- a/cmd/gc/cmd_hook_test.go +++ b/cmd/gc/cmd_hook_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -116,6 +117,46 @@ func TestHookInjectFormatsOutput(t *testing.T) { } } +func TestHookCommandCodexInjectEmitsSingleStopPayload(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()) + } + + var payload struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout is not a single JSON payload: %v\n%s", err, stdout.String()) + } + if got, want := payload.Decision, "block"; got != want { + t.Fatalf("decision = %q, want %q", got, want) + } + if !strings.Contains(payload.Reason, "hw-1") { + t.Fatalf("reason = %q, want pending work", payload.Reason) + } +} + func TestHookInjectAlwaysExitsZero(t *testing.T) { // Even on command failure, inject mode exits 0. runner := func(string, string) (string, error) { return "", fmt.Errorf("command failed") } @@ -213,7 +254,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 +333,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 +400,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 +457,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 +505,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 +579,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 +643,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 +703,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_mail.go b/cmd/gc/cmd_mail.go index d874525b9..2b4222ccf 100644 --- a/cmd/gc/cmd_mail.go +++ b/cmd/gc/cmd_mail.go @@ -213,7 +213,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 } @@ -442,6 +442,55 @@ type resolvedMailTarget struct { recipients []string } +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) +} + func resolveLiveConfiguredNamedMailTarget(store beads.Store, identifier string) (resolvedMailTarget, bool, error) { identifier = normalizeNamedSessionTarget(identifier) if store == nil || identifier == "" || identifier == "human" || strings.Contains(identifier, "/") { @@ -529,8 +578,8 @@ func resolveMailTargetsForCommand(identifier string, stderr io.Writer, cmdName s if identifier == "" || identifier == "human" { return resolvedMailTarget{display: "human", recipients: []string{"human"}}, true } - if rawTarget, ok := resolveRawMailTargetForStorelessProvider(identifier, stderr, cmdName); ok { - return rawTarget, true + if isStorelessMailProvider() { + return resolveRawMailTargetForStorelessProvider(identifier, stderr, cmdName) } store, code := openCityStore(stderr, cmdName) if store == nil { @@ -689,6 +738,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 +776,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 +851,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 +864,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 } @@ -1016,7 +1074,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 +1129,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 +1264,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 +1314,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 +1333,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), @@ -1433,7 +1514,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 0855c46b9..5ddbc70a1 100644 --- a/cmd/gc/cmd_mail_test.go +++ b/cmd/gc/cmd_mail_test.go @@ -16,6 +16,7 @@ import ( "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/mail" "github.com/gastownhall/gascity/internal/mail/beadmail" + "github.com/gastownhall/gascity/internal/nudgequeue" "github.com/gastownhall/gascity/internal/session" ) @@ -353,6 +354,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 +488,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 +542,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) } @@ -489,6 +577,11 @@ func TestResolveDefaultMailTargetsForCommand_StorelessProviderUsesFirstCandidate t.Setenv("GC_ALIAS", "codeprobe-worker-1") t.Setenv("GC_SESSION_ID", "codeprobe-worker-gc-1941") t.Setenv("GC_AGENT", "codeprobe-worker") + prev := openMailTargetStore + openMailTargetStore = func() (beads.Store, error) { + return nil, fmt.Errorf("not in a city directory") + } + t.Cleanup(func() { openMailTargetStore = prev }) var stderr bytes.Buffer target, ok := resolveDefaultMailTargetsForCommand(&stderr, "gc mail inbox") @@ -568,6 +661,11 @@ func TestDefaultMailIdentityFallsBackToHumanWithoutAliasSessionOrAgent(t *testin func TestResolveMailAddressForCommand_AllowsStorelessMailProvider(t *testing.T) { t.Setenv("GC_MAIL", "fake") + prev := openMailTargetStore + openMailTargetStore = func() (beads.Store, error) { + return nil, fmt.Errorf("not in a city directory") + } + t.Cleanup(func() { openMailTargetStore = prev }) var stderr bytes.Buffer address, ok := resolveMailAddressForCommand("robot", &stderr, "gc mail inbox") @@ -1394,6 +1492,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) { @@ -1828,6 +2166,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) { diff --git a/cmd/gc/cmd_nudge.go b/cmd/gc/cmd_nudge.go index 149b95b4f..d23c86557 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) } @@ -781,10 +788,27 @@ func tryDeliverQueuedNudgesByPoller(target nudgeTarget, store beads.Store, sp ru 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() { + if err != nil { + return false + } + 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 +818,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 } } diff --git a/cmd/gc/cmd_nudge_test.go b/cmd/gc/cmd_nudge_test.go index 07e739b20..8adc3a8a7 100644 --- a/cmd/gc/cmd_nudge_test.go +++ b/cmd/gc/cmd_nudge_test.go @@ -498,6 +498,35 @@ func TestPollerSessionIdleEnoughUsesLastActivityWithoutCapabilityFlag(t *testing } } +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.WaitForIdleErrors["sess-worker"] = nil + target := nudgeTarget{sessionName: "sess-worker"} + + if !pollerSessionIdleEnough(target, nil, fake, 3*time.Second) { + 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, nil, fake, 3*time.Second) { + t.Fatal("pollerSessionIdleEnough = true, want idle wait error to suppress delivery") + } +} + func TestShouldKeepNudgePollerAliveDuringStartupGrace(t *testing.T) { t.Setenv("GC_BEADS", "file") dir := t.TempDir() @@ -757,6 +786,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() diff --git a/cmd/gc/cmd_order.go b/cmd/gc/cmd_order.go index 1a3c7d09a..b05fdd306 100644 --- a/cmd/gc/cmd_order.go +++ b/cmd/gc/cmd_order.go @@ -486,8 +486,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 +520,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,7 +529,7 @@ 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 @@ -574,7 +580,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 +644,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 +686,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 diff --git a/cmd/gc/cmd_order_test.go b/cmd/gc/cmd_order_test.go index 33861e865..77d66c244 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,185 @@ func TestOrderRun(t *testing.T) { } } +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 +1041,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 e13071636..589d69054 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 diff --git a/cmd/gc/cmd_prime_test.go b/cmd/gc/cmd_prime_test.go index df4920bc5..0b46e9cd1 100644 --- a/cmd/gc/cmd_prime_test.go +++ b/cmd/gc/cmd_prime_test.go @@ -428,6 +428,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_rig_endpoint_test.go b/cmd/gc/cmd_rig_endpoint_test.go index c10c0ebcb..a41fb3d8c 100644 --- a/cmd/gc/cmd_rig_endpoint_test.go +++ b/cmd/gc/cmd_rig_endpoint_test.go @@ -1066,7 +1066,7 @@ func TestCanonicalValidationPasswordUsesCredentialsFileOverride(t *testing.T) { } func TestVerifyExternalDoltEndpointRejectsEmptyExternalDoltDatabase(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run make test-cmd-gc-process for full coverage") doltPath, err := exec.LookPath("dolt") if err != nil { t.Skip("dolt not installed") @@ -1167,7 +1167,7 @@ func TestVerifyExternalDoltEndpointRejectsEmptyExternalDoltDatabase(t *testing.T } func TestVerifyExternalDoltEndpointRejectsProjectIdentityMismatch(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run make test-cmd-gc-process for full coverage") doltPath, err := exec.LookPath("dolt") if err != nil { t.Skip("dolt not installed") @@ -1271,7 +1271,7 @@ func TestVerifyExternalDoltEndpointRejectsProjectIdentityMismatch(t *testing.T) } func TestVerifyExternalDoltEndpointRejectsMissingLocalProjectID(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed external dolt endpoint; run make test-cmd-gc-process for full coverage") doltPath, err := exec.LookPath("dolt") if err != nil { t.Skip("dolt not installed") diff --git a/cmd/gc/cmd_session.go b/cmd/gc/cmd_session.go index bf6c7ea6e..fc49ade40 100644 --- a/cmd/gc/cmd_session.go +++ b/cmd/gc/cmd_session.go @@ -170,6 +170,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } + sessionTransport := config.ResolveSessionCreateTransport(found.Session, resolved) requestedAlias, err := session.ValidateAlias(alias) if err != nil { fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr @@ -192,6 +193,10 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, } sp := newSessionProvider() + if err := validateResolvedSessionTransport(resolved, sessionTransport, sp); err != nil { + fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } // Build the work directory. sessionQualifiedName := workdirutil.SessionQualifiedName(cityPath, found, cfg.Rigs, requestedAlias, explicitName) @@ -223,7 +228,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, if err != nil { titleProvider = nil } - sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil) + sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil, sessionTransport) if err != nil { fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr return 1 @@ -245,6 +250,20 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, if resolved.BuiltinAncestor != "" && resolved.BuiltinAncestor != resolved.Name { kindMeta["builtin_ancestor"] = resolved.BuiltinAncestor } + kindMeta, err = newSessionStoredMCPMetadata( + cityPath, + cfg, + alias, + canonicalTemplate, + resolved.Name, + workDir, + sessionTransport, + kindMeta, + ) + if err != nil { + fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } handle, err := newWorkerSessionHandleForResolvedRuntimeWithConfig( cityPath, store, @@ -257,7 +276,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, sessionCommand, found.Provider, workDir, - found.Session, + sessionTransport, resolved, kindMeta, ) @@ -295,8 +314,8 @@ 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, found.Session) { - if found.Session == "acp" && !noAttach { + if !shouldAttachNewSession(noAttach, sessionTransport) { + if sessionTransport == "acp" && !noAttach { fmt.Fprintln(stdout, "Session uses ACP transport; not attaching.") //nolint:errcheck // best-effort stdout } return 0 @@ -328,6 +347,20 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, if resolved.BuiltinAncestor != "" && resolved.BuiltinAncestor != resolved.Name { kindMeta["builtin_ancestor"] = resolved.BuiltinAncestor } + kindMeta, err = newSessionStoredMCPMetadata( + cityPath, + cfg, + alias, + canonicalTemplate, + resolved.Name, + workDir, + sessionTransport, + kindMeta, + ) + if err != nil { + fmt.Fprintf(stderr, "gc session new: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } handle, err := newWorkerSessionHandleForResolvedRuntimeWithConfig( cityPath, store, @@ -340,7 +373,7 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, sessionCommand, found.Provider, workDir, - found.Session, + sessionTransport, resolved, kindMeta, ) @@ -375,8 +408,8 @@ 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, found.Session) { - if found.Session == "acp" && !noAttach { + if !shouldAttachNewSession(noAttach, sessionTransport) { + if sessionTransport == "acp" && !noAttach { fmt.Fprintln(stdout, "Session uses ACP transport; not attaching.") //nolint:errcheck // best-effort stdout } return 0 @@ -390,6 +423,35 @@ func cmdSessionNew(args []string, alias, title, titleHint string, noAttach bool, return 0 } +func newSessionStoredMCPMetadata( + cityPath string, + cfg *config.City, + alias, template, provider, workDir, transport string, + metadata map[string]string, +) (map[string]string, error) { + if strings.TrimSpace(transport) != "acp" { + return metadata, nil + } + mcpServers, err := resolvedRuntimeMCPServersWithConfig( + cityPath, + cfg, + alias, + template, + provider, + workDir, + transport, + metadata, + ) + if err != nil { + return nil, err + } + return session.WithStoredMCPMetadata( + metadata, + firstNonEmptyGCString(metadata[session.MCPIdentityMetadataKey], metadata["agent_name"]), + mcpServers, + ) +} + // maybeAutoTitle runs the auto-title flow for a newly created session. // The provider should already be resolved by the caller. It returns a // channel that is closed when background title generation completes. @@ -401,11 +463,52 @@ func maybeAutoTitle(store beads.Store, beadID, userTitle, titleHint string, prov }) } -func resolvedSessionCommand(cityPath string, resolved *config.ResolvedProvider, optionOverrides map[string]string) (string, error) { +type acpRouteRegistrar interface { + RouteACP(name string) +} + +func validateResolvedSessionTransport(resolved *config.ResolvedProvider, transport string, sp runtime.Provider) error { + transport = strings.TrimSpace(transport) + if transport != "acp" { + return nil + } + providerName := "" + if resolved != nil { + providerName = resolved.Name + if !resolved.SupportsACP { + if providerName == "" { + providerName = transport + } + return fmt.Errorf("provider %q does not support ACP transport", providerName) + } + } + if sessionProviderSupportsACP(sp) { + return nil + } + if providerName == "" { + providerName = transport + } + return fmt.Errorf("provider %q requires ACP transport but the session provider cannot route ACP sessions", providerName) +} + +func sessionProviderSupportsACP(sp runtime.Provider) bool { + if sp == nil { + return false + } + if provider, ok := sp.(runtime.TransportCapabilityProvider); ok { + return provider.SupportsTransport("acp") + } + if _, ok := sp.(acpRouteRegistrar); ok { + return true + } + return false +} + +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") } - launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, optionOverrides) + launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, optionOverrides, transport) if err != nil { return "", fmt.Errorf("resolving provider launch command: %w", err) } diff --git a/cmd/gc/cmd_session_logs_test.go b/cmd/gc/cmd_session_logs_test.go index 29240902a..0a03159f6 100644 --- a/cmd/gc/cmd_session_logs_test.go +++ b/cmd/gc/cmd_session_logs_test.go @@ -306,6 +306,7 @@ func TestResolveSessionLogPathFallsBackWhenSessionKeyFileMissing(t *testing.T) { } func TestResolveStoredSessionLogSource_UniqueWorkDirFallsBackBeyondLatestAlias(t *testing.T) { + skipSlowCmdGCTest(t, "probes provider transcript lookup before workdir fallback; run make test-cmd-gc-process for full coverage") store := beads.NewMemStore() workDir := t.TempDir() searchBase := t.TempDir() diff --git a/cmd/gc/cmd_session_test.go b/cmd/gc/cmd_session_test.go index 83b950d1a..e7be2b480 100644 --- a/cmd/gc/cmd_session_test.go +++ b/cmd/gc/cmd_session_test.go @@ -53,6 +53,24 @@ func (p *attachmentAwareProvider) Respond(_ string, response runtime.Interaction return nil } +type transportCapableSessionProvider struct { + *runtime.Fake +} + +func (p *transportCapableSessionProvider) SupportsTransport(transport string) bool { + return transport == "acp" +} + +type routedRejectingSessionProvider struct { + *runtime.Fake +} + +func (p *routedRejectingSessionProvider) SupportsTransport(string) bool { + return false +} + +func (p *routedRejectingSessionProvider) RouteACP(string) {} + func TestFormatDuration(t *testing.T) { tests := []struct { d time.Duration @@ -372,6 +390,149 @@ func TestCmdSessionNew_PoolTemplateWithoutAliasUsesGeneratedWorkDirIdentity(t *t } } +func TestCmdSessionNew_ACPTemplatePersistsStoredMCPMetadata(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writePoolACPSessionCityTOML(t, cityDir) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`) + + 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, 64) + 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" + } + 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 := cmdSessionNew([]string{"demo/ant"}, "", "", "", true, &stdout, &stderr); code != 0 { + t.Fatalf("cmdSessionNew(acp) = %d, want 0; stderr=%s", code, 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 { + if len(gotCommands) != 3 { + t.Fatalf("controller commands = %v, want ping plus 2 pokes", gotCommands) + } + break + } + gotCommands = append(gotCommands, cmd) + case <-deadline: + t.Fatalf("timed out waiting for controller pokes, got %v", gotCommands) + } + } + + bead := onlySessionBead(t, cityDir) + if got := bead.Metadata[session.MCPIdentityMetadataKey]; got == "" { + t.Fatal("mcp_identity metadata = empty, want persisted identity") + } + if got, want := bead.Metadata[session.MCPIdentityMetadataKey], bead.Metadata["agent_name"]; got != want { + t.Fatalf("mcp_identity = %q, want agent_name %q", got, want) + } + if got := bead.Metadata[session.MCPServersSnapshotMetadataKey]; got == "" { + t.Fatal("mcp_servers_snapshot metadata = empty, want persisted snapshot") + } + + servers, err := session.DecodeMCPServersSnapshot(bead.Metadata[session.MCPServersSnapshotMetadataKey]) + if err != nil { + t.Fatalf("DecodeMCPServersSnapshot: %v", err) + } + if len(servers) != 1 { + t.Fatalf("len(snapshot) = %d, want 1", len(servers)) + } + if got, want := servers[0].Args[0], bead.Metadata[session.MCPIdentityMetadataKey]; got != want { + t.Fatalf("snapshot Args[0] = %q, want %q", got, want) + } + if got, want := servers[0].Args[1], bead.Metadata["work_dir"]; got != want { + t.Fatalf("snapshot Args[1] = %q, want %q", got, want) + } + if got, want := servers[0].Args[2], "demo/ant"; got != want { + t.Fatalf("snapshot Args[2] = %q, want %q", got, want) + } +} + +func TestCmdSessionNew_CustomACPProviderDefaultsAgentSessionToACP(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + 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) + writePoolProviderDefaultACPSessionCityTOML(t, cityDir) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`) + + var stdout, stderr bytes.Buffer + if code := cmdSessionNew([]string{"demo/ant"}, "", "", "", true, &stdout, &stderr); code != 0 { + t.Fatalf("cmdSessionNew(custom provider acp default) = %d, want 0; stderr=%s", code, stderr.String()) + } + + bead := onlySessionBead(t, cityDir) + if got := bead.Metadata["transport"]; got != "acp" { + t.Fatalf("transport = %q, want %q", got, "acp") + } + if got := bead.Metadata[session.MCPServersSnapshotMetadataKey]; got == "" { + t.Fatal("mcp_servers_snapshot metadata = empty, want persisted snapshot") + } +} + func TestCmdSessionNew_PoolTemplateRejectsAliasMatchingConcreteIdentity(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") @@ -1121,6 +1282,85 @@ max_active_sessions = 4 } } +func writePoolACPSessionCityTOML(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" + +[[rigs]] +name = "demo" +path = %q + +[[agent]] +name = "ant" +dir = "demo" +provider = "stub" +session = "acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.stub] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`, rigRoot)) + if err := os.WriteFile(filepath.Join(dir, "city.toml"), data, 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } +} + +func writePoolProviderDefaultACPSessionCityTOML(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" + +[[rigs]] +name = "demo" +path = %q + +[[agent]] +name = "ant" +dir = "demo" +provider = "custom-acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.custom-acp] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`, 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) @@ -1297,7 +1537,7 @@ func TestResolvedSessionCommandIncludesDefaultsAndSettings(t *testing.T) { EffectiveDefaults: config.ComputeEffectiveDefaults(claude.OptionsSchema, claude.OptionDefaults, nil), } - got, err := resolvedSessionCommand(cityPath, resolved, nil) + got, err := resolvedSessionCommand(cityPath, resolved, nil, "") if err != nil { t.Fatalf("resolvedSessionCommand: %v", err) } @@ -1326,7 +1566,7 @@ func TestResolvedSessionCommandAppliesOverridesOverDefaults(t *testing.T) { got, err := resolvedSessionCommand(cityPath, resolved, map[string]string{ "permission_mode": "plan", "effort": "low", - }) + }, "") if err != nil { t.Fatalf("resolvedSessionCommand: %v", err) } @@ -1340,3 +1580,58 @@ func TestResolvedSessionCommandAppliesOverridesOverDefaults(t *testing.T) { t.Fatalf("command %q should include effort=low override", got) } } + +func TestResolvedSessionCommandUsesACPTransportCommand(t *testing.T) { + resolved := &config.ResolvedProvider{ + Name: "opencode", + Command: "/bin/echo", + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + + got, err := resolvedSessionCommand("", resolved, nil, "acp") + if err != nil { + t.Fatalf("resolvedSessionCommand: %v", err) + } + if got != "/bin/echo acp" { + t.Fatalf("command = %q, want %q", got, "/bin/echo acp") + } +} + +func TestValidateResolvedSessionTransportRejectsUnsupportedACPProvider(t *testing.T) { + err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + }, "acp", &transportCapableSessionProvider{Fake: runtime.NewFake()}) + if err == nil || !strings.Contains(err.Error(), "does not support ACP transport") { + t.Fatalf("validateResolvedSessionTransport() error = %v, want provider ACP support error", err) + } +} + +func TestValidateResolvedSessionTransportRejectsUnroutableACPProvider(t *testing.T) { + err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + SupportsACP: true, + }, "acp", runtime.NewFake()) + if err == nil || !strings.Contains(err.Error(), "requires ACP transport") { + t.Fatalf("validateResolvedSessionTransport() error = %v, want ACP routing error", err) + } +} + +func TestValidateResolvedSessionTransportAcceptsRoutedACPProvider(t *testing.T) { + if err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + SupportsACP: true, + }, "acp", &transportCapableSessionProvider{Fake: runtime.NewFake()}); err != nil { + t.Fatalf("validateResolvedSessionTransport() = %v, want nil", err) + } +} + +func TestValidateResolvedSessionTransportRejectsRoutedProviderWhenTransportCapabilityDisablesACP(t *testing.T) { + err := validateResolvedSessionTransport(&config.ResolvedProvider{ + Name: "opencode", + SupportsACP: true, + }, "acp", &routedRejectingSessionProvider{Fake: runtime.NewFake()}) + if err == nil || !strings.Contains(err.Error(), "requires ACP transport") { + t.Fatalf("validateResolvedSessionTransport() error = %v, want ACP routing error", err) + } +} diff --git a/cmd/gc/cmd_sling.go b/cmd/gc/cmd_sling.go index e737ea0cb..13a025390 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" @@ -149,9 +150,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) @@ -782,12 +781,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) { diff --git a/cmd/gc/cmd_sling_test.go b/cmd/gc/cmd_sling_test.go index 80087870b..f6d754f3f 100644 --- a/cmd/gc/cmd_sling_test.go +++ b/cmd/gc/cmd_sling_test.go @@ -503,6 +503,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() @@ -2548,8 +2578,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]) diff --git a/cmd/gc/cmd_start.go b/cmd/gc/cmd_start.go index da1bd1536..6278b9787 100644 --- a/cmd/gc/cmd_start.go +++ b/cmd/gc/cmd_start.go @@ -579,6 +579,7 @@ 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. sessionBeads, err := loadSessionBeadSnapshot(oneShotStore) @@ -586,24 +587,40 @@ 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) - _, 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 := releaseOrphanedPoolAssignments(oneShotStore, cfg, open, dsResult.AssignedWorkBeads, dsResult.AssignedWorkStores, 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)) + cfg, dsResult.AssignedWorkBeads, open, dsResult.ScaleCheckCounts)) if poolDesired == nil { poolDesired = make(map[string]int) } mergeNamedSessionDemand(poolDesired, dsResult.NamedSessionDemand, cfg) reconcileSessionBeadsAtPath( sigCtx, cityPath, open, ds, cfgNames, cfg, sp, oneShotStore, - nil, nil, nil, nil, dt, poolDesired, + nil, dsResult.AssignedWorkBeads, rigStores, nil, dt, poolDesired, dsResult.StoreQueryPartial, nil, cityName, nil, clock.Real{}, recorder, cfg.Session.StartupTimeoutDuration(), 0, @@ -616,10 +633,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 @@ -981,7 +1000,7 @@ func passthroughEnv() map[string]string { } else if home := os.Getenv("HOME"); home != "" { m["XDG_STATE_HOME"] = filepath.Join(home, ".local", "state") } - // Pass through all GC_* and ANTHROPIC_* vars. Agent credentials are + // Pass through GC_* vars and provider credential env. Agent credentials are // included in the global baseline because the SDK cannot know which // agent uses which provider (zero hardcoded roles); the trust boundary // is the managed session itself. @@ -990,7 +1009,7 @@ func passthroughEnv() map[string]string { if !ok || val == "" { continue } - if strings.HasPrefix(key, "GC_") || strings.HasPrefix(key, "ANTHROPIC_") { + if strings.HasPrefix(key, "GC_") || isProviderCredentialEnv(key) { m[key] = val } } diff --git a/cmd/gc/cmd_start_test.go b/cmd/gc/cmd_start_test.go index d3e0ee02e..10a049f53 100644 --- a/cmd/gc/cmd_start_test.go +++ b/cmd/gc/cmd_start_test.go @@ -211,6 +211,32 @@ func TestPassthroughEnvIncludesClaudeAuthContext(t *testing.T) { } } +func TestPassthroughEnvIncludesProviderCredentialEnv(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-123") + t.Setenv("OPENAI_API_KEY", "sk-openai-123") + t.Setenv("OPENAI_BASE_URL", "https://openai.example.test") + t.Setenv("GEMINI_API_KEY", "gemini-123") + t.Setenv("GOOGLE_API_KEY", "google-123") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/google-credentials.json") + t.Setenv("GOOGLE_CLOUD_PROJECT", "gc-project") + + got := passthroughEnv() + + for key, want := range map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-123", + "OPENAI_API_KEY": "sk-openai-123", + "OPENAI_BASE_URL": "https://openai.example.test", + "GEMINI_API_KEY": "gemini-123", + "GOOGLE_API_KEY": "google-123", + "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/google-credentials.json", + "GOOGLE_CLOUD_PROJECT": "gc-project", + } { + if got[key] != want { + t.Errorf("passthroughEnv()[%s] = %q, want %q", key, got[key], want) + } + } +} + func TestPassthroughEnvXDGFallbackFromHOME(t *testing.T) { t.Setenv("HOME", "/tmp/gc-home") // Explicitly unset XDG vars so fallback logic fires. diff --git a/cmd/gc/cmd_stop_test.go b/cmd/gc/cmd_stop_test.go index fe6700e06..fc0643cf5 100644 --- a/cmd/gc/cmd_stop_test.go +++ b/cmd/gc/cmd_stop_test.go @@ -126,6 +126,7 @@ func TestCmdStopWaitsForStandaloneControllerExit(t *testing.T) { } func TestStopCityManagedBeadsProviderIfRunningStopsDefaultBD(t *testing.T) { + skipSlowCmdGCTest(t, "exercises managed bd provider shutdown; run make test-cmd-gc-process for full coverage") t.Setenv("GC_BEADS", "bd") cityDir := t.TempDir() diff --git a/cmd/gc/cmd_supervisor.go b/cmd/gc/cmd_supervisor.go index 7ff88e975..b9d762eb4 100644 --- a/cmd/gc/cmd_supervisor.go +++ b/cmd/gc/cmd_supervisor.go @@ -1210,10 +1210,15 @@ func reconcileCities( var sp runtime.Provider spErr := runPostPrepareStep("creating_session_provider", func() error { - var err error - sp, err = newSessionProviderByName( - effectiveProviderName(cfg.Session.Provider), cfg.Session, cityName, path) - return err + providerName := effectiveProviderName(cfg.Session.Provider) + ctx := sessionProviderContextForCity(cfg, path, providerName) + snapshot := loadProviderSessionSnapshot(ctx) + resolvedSP, err := newSessionProviderFromContextWithError(ctx, snapshot) + if err != nil { + return err + } + sp = resolvedSP + return nil }) if spErr != nil { cr.BatchUpdate(func( diff --git a/cmd/gc/cmd_supervisor_city_test.go b/cmd/gc/cmd_supervisor_city_test.go index fb62b3f89..a31e434a4 100644 --- a/cmd/gc/cmd_supervisor_city_test.go +++ b/cmd/gc/cmd_supervisor_city_test.go @@ -215,7 +215,7 @@ func TestRegisterCityWithSupervisorRetriesControllerLockInitFailure(t *testing.T } func TestRegisterCityWithSupervisorKeepsRegistrationWhenReloadFails(t *testing.T) { - skipSlowCmdGCTest(t, "exercises supervisor registration retry behavior; run without -short for scenario coverage") + skipSlowCmdGCTest(t, "exercises supervisor registration retry behavior; run make test-cmd-gc-process for scenario coverage") gcHome := t.TempDir() t.Setenv("GC_HOME", gcHome) diff --git a/cmd/gc/cmd_supervisor_lifecycle.go b/cmd/gc/cmd_supervisor_lifecycle.go index a4e0c77df..8ee7a962e 100644 --- a/cmd/gc/cmd_supervisor_lifecycle.go +++ b/cmd/gc/cmd_supervisor_lifecycle.go @@ -15,6 +15,7 @@ import ( "path/filepath" "regexp" goruntime "runtime" + "sort" "strconv" "strings" "text/template" @@ -42,6 +43,8 @@ var ( } ) +const supervisorServiceFileMode os.FileMode = 0o600 + func newSupervisorRunCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ Use: "run", @@ -337,6 +340,12 @@ type supervisorServiceData struct { LaunchdLabel string SafeName string Path string + ExtraEnv []supervisorServiceEnvVar +} + +type supervisorServiceEnvVar struct { + Name string + Value string } func buildSupervisorServiceData() (*supervisorServiceData, error) { @@ -358,6 +367,7 @@ func buildSupervisorServiceData() (*supervisorServiceData, error) { LaunchdLabel: supervisorLaunchdLabel(), SafeName: sanitizeServiceName(filepath.Base(home)), Path: searchpath.ExpandPath(homeDir, goruntime.GOOS, os.Getenv("PATH")), + ExtraEnv: supervisorServiceExtraEnv(), }, nil } @@ -368,6 +378,103 @@ func sanitizeServiceName(name string) string { return strings.Trim(name, "-") } +var supervisorServiceEnvNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Keep persistent service-file env narrow. Provider credentials and user +// context need to survive launchd/systemd startup; arbitrary shell state can +// be opted in with GC_SUPERVISOR_ENV. +var supervisorServiceEnvKeys = map[string]bool{ + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": true, + "CLAUDE_CODE_EFFORT_LEVEL": true, + "CLAUDE_CODE_OAUTH_TOKEN": true, + "CLAUDE_CODE_SUBAGENT_MODEL": true, + "CLAUDE_CONFIG_DIR": true, + "HOME": true, + "LANG": true, + "LC_ALL": true, + "LC_CTYPE": true, + "LOGNAME": true, + "SHELL": true, + "USER": true, + "XDG_CONFIG_HOME": true, + "XDG_STATE_HOME": true, +} + +var providerCredentialEnvPrefixes = []string{ + "ANTHROPIC_", + "GEMINI_", + "GOOGLE_", + "OPENAI_", +} + +var supervisorServiceFixedEnvKeys = map[string]bool{ + "GC_HOME": true, + "PATH": true, + "XDG_RUNTIME_DIR": true, +} + +func supervisorServiceExtraEnv() []supervisorServiceEnvVar { + env := make(map[string]string) + for _, entry := range os.Environ() { + key, val, ok := strings.Cut(entry, "=") + if !ok || val == "" || !shouldPersistSupervisorEnv(key) { + continue + } + env[key] = val + } + for _, key := range supervisorServiceExplicitEnvKeys(os.Getenv("GC_SUPERVISOR_ENV")) { + if val := os.Getenv(key); val != "" { + env[key] = val + } + } + + keys := make([]string, 0, len(env)) + for key := range env { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]supervisorServiceEnvVar, 0, len(keys)) + for _, key := range keys { + out = append(out, supervisorServiceEnvVar{Name: key, Value: env[key]}) + } + return out +} + +func shouldPersistSupervisorEnv(key string) bool { + if !supervisorServiceEnvNameRE.MatchString(key) || supervisorServiceFixedEnvKeys[key] { + return false + } + if supervisorServiceEnvKeys[key] { + return true + } + return isProviderCredentialEnv(key) +} + +func isProviderCredentialEnv(key string) bool { + for _, prefix := range providerCredentialEnvPrefixes { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} + +func supervisorServiceExplicitEnvKeys(raw string) []string { + fields := strings.Fields(strings.NewReplacer(",", " ", ";", " ").Replace(raw)) + out := make([]string, 0, len(fields)) + seen := make(map[string]bool, len(fields)) + for _, field := range fields { + key := strings.TrimSpace(field) + if key == "" || seen[key] || !supervisorServiceEnvNameRE.MatchString(key) || supervisorServiceFixedEnvKeys[key] { + continue + } + seen[key] = true + out = append(out, key) + } + sort.Strings(out) + return out +} + const ( defaultSupervisorLaunchdLabel = "com.gascity.supervisor" defaultSupervisorSystemdUnit = "gascity-supervisor.service" @@ -436,6 +543,10 @@ const supervisorLaunchdTemplate = ` {{end}} PATH {{xmlesc .Path}} + {{range .ExtraEnv}} + {{xmlesc .Name}} + {{xmlesc .Value}} + {{end}} @@ -454,6 +565,8 @@ StandardError=append:{{.LogPath}} Environment=GC_HOME="{{.GCHome}}" {{if .XDGRuntimeDir}}Environment=XDG_RUNTIME_DIR="{{.XDGRuntimeDir}}" {{end}}Environment=PATH="{{.Path}}" +{{range .ExtraEnv}}Environment={{systemdenv .Name .Value}} +{{end}} [Install] WantedBy=default.target @@ -464,8 +577,12 @@ func xmlEscape(s string) string { return r.Replace(s) } +func systemdEnv(name, value string) string { + return name + "=" + strconv.Quote(value) +} + func renderSupervisorTemplate(tmplStr string, data *supervisorServiceData) (string, error) { - funcMap := template.FuncMap{"xmlesc": xmlEscape} + funcMap := template.FuncMap{"xmlesc": xmlEscape, "systemdenv": systemdEnv} tmpl, err := template.New("service").Funcs(funcMap).Parse(tmplStr) if err != nil { return "", err @@ -477,6 +594,20 @@ func renderSupervisorTemplate(tmplStr string, data *supervisorServiceData) (stri return buf.String(), nil } +func writeSupervisorServiceFile(path string, content []byte) error { + if _, err := os.Stat(path); err == nil { + if err := os.Chmod(path, supervisorServiceFileMode); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.WriteFile(path, content, supervisorServiceFileMode); err != nil { + return err + } + return os.Chmod(path, supervisorServiceFileMode) +} + func supervisorLaunchdPlistPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, "Library", "LaunchAgents", supervisorLaunchdLabel()+".plist") @@ -694,7 +825,7 @@ func rollbackNewSupervisorLaunchdInstall(path string, restoreLegacy bool) error func restorePreviousSupervisorLaunchdInstall(path string, previousContent []byte) error { var errs []error _ = supervisorLaunchctlRun("unload", path) - if err := os.WriteFile(path, previousContent, 0o644); err != nil { + 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 { errs = append(errs, fmt.Errorf("reloading previous plist %s: %w", path, err)) @@ -725,7 +856,7 @@ func restorePreviousSupervisorSystemdInstall(path, service string, previousConte if restart { _ = supervisorSystemctlRun("--user", "stop", service) } - if err := os.WriteFile(path, previousContent, 0o644); err != nil { + if err := writeSupervisorServiceFile(path, previousContent); err != nil { errs = append(errs, fmt.Errorf("restoring previous unit %s: %w", path, err)) return errors.Join(errs...) } @@ -762,7 +893,7 @@ func installSupervisorLaunchd(data *supervisorServiceData, stdout, stderr io.Wri fmt.Fprintf(stderr, "gc supervisor install: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if err := writeSupervisorServiceFile(path, []byte(content)); err != nil { fmt.Fprintf(stderr, "gc supervisor install: writing plist: %v\n", err) //nolint:errcheck // best-effort stderr return 1 } @@ -829,7 +960,7 @@ func installSupervisorSystemd(data *supervisorServiceData, stdout, stderr io.Wri return 1 } contentChanged := string(existing) != content - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + 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 } diff --git a/cmd/gc/cmd_supervisor_test.go b/cmd/gc/cmd_supervisor_test.go index 4df9fd192..51e573199 100644 --- a/cmd/gc/cmd_supervisor_test.go +++ b/cmd/gc/cmd_supervisor_test.go @@ -203,6 +203,10 @@ func TestRenderSupervisorLaunchdTemplate(t *testing.T) { XDGRuntimeDir: "/tmp/gc-run", LaunchdLabel: defaultSupervisorLaunchdLabel, Path: "/usr/local/bin:/usr/bin:/bin", + ExtraEnv: []supervisorServiceEnvVar{ + {Name: "ANTHROPIC_API_KEY", Value: `sk-&<"'>`}, + {Name: "OPENAI_API_KEY", Value: "sk-openai-123"}, + }, } content, err := renderSupervisorTemplate(supervisorLaunchdTemplate, data) @@ -220,6 +224,10 @@ func TestRenderSupervisorLaunchdTemplate(t *testing.T) { "XDG_RUNTIME_DIR", "/tmp/gc-run", "PATH", + "ANTHROPIC_API_KEY", + "sk-&<"'>", + "OPENAI_API_KEY", + "sk-openai-123", } { if !strings.Contains(content, check) { t.Fatalf("launchd template missing %q", check) @@ -235,6 +243,10 @@ func TestRenderSupervisorSystemdTemplate(t *testing.T) { XDGRuntimeDir: "/tmp/gc-run", LaunchdLabel: defaultSupervisorLaunchdLabel, Path: "/usr/local/bin:/usr/bin:/bin", + ExtraEnv: []supervisorServiceEnvVar{ + {Name: "ANTHROPIC_API_KEY", Value: `sk-"ant"\value`}, + {Name: "OPENAI_API_KEY", Value: "sk-openai-123"}, + }, } content, err := renderSupervisorTemplate(supervisorSystemdTemplate, data) @@ -249,6 +261,8 @@ func TestRenderSupervisorSystemdTemplate(t *testing.T) { `Environment=GC_HOME="/home/user/.gc"`, `Environment=XDG_RUNTIME_DIR="/tmp/gc-run"`, `Environment=PATH="/usr/local/bin:/usr/bin:/bin"`, + `Environment=ANTHROPIC_API_KEY="sk-\"ant\"\\value"`, + `Environment=OPENAI_API_KEY="sk-openai-123"`, } { if !strings.Contains(content, check) { t.Fatalf("systemd template missing %q", check) @@ -256,6 +270,57 @@ func TestRenderSupervisorSystemdTemplate(t *testing.T) { } } +func TestBuildSupervisorServiceDataIncludesProviderEnv(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("XDG_RUNTIME_DIR", "/tmp/gc-run") + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-123") + t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic.example.test") + t.Setenv("OPENAI_API_KEY", "sk-openai-123") + t.Setenv("GEMINI_API_KEY", "gemini-123") + t.Setenv("GOOGLE_CLOUD_PROJECT", "gc-project") + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(homeDir, ".claude")) + t.Setenv("GC_SUPERVISOR_ENV", "CUSTOM_PROVIDER_TOKEN,IGNORED_EMPTY") + t.Setenv("CUSTOM_PROVIDER_TOKEN", "custom-token") + t.Setenv("IGNORED_EMPTY", "") + t.Setenv("UNRELATED_SECRET", "do-not-persist") + + data, err := buildSupervisorServiceData() + if err != nil { + t.Fatalf("buildSupervisorServiceData: %v", err) + } + + got := supervisorServiceEnvMap(data.ExtraEnv) + for key, want := range map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-123", + "ANTHROPIC_BASE_URL": "https://anthropic.example.test", + "OPENAI_API_KEY": "sk-openai-123", + "GEMINI_API_KEY": "gemini-123", + "GOOGLE_CLOUD_PROJECT": "gc-project", + "CLAUDE_CONFIG_DIR": filepath.Join(homeDir, ".claude"), + "CUSTOM_PROVIDER_TOKEN": "custom-token", + } { + if got[key] != want { + t.Fatalf("ExtraEnv[%s] = %q, want %q (all env: %#v)", key, got[key], want, got) + } + } + for _, key := range []string{"GC_HOME", "PATH", "XDG_RUNTIME_DIR", "IGNORED_EMPTY", "UNRELATED_SECRET"} { + if _, ok := got[key]; ok { + t.Fatalf("ExtraEnv should not include %s: %#v", key, got) + } + } +} + +func supervisorServiceEnvMap(vars []supervisorServiceEnvVar) map[string]string { + m := make(map[string]string, len(vars)) + for _, item := range vars { + m[item.Name] = item.Value + } + return m +} + func TestBuildSupervisorServiceDataExpandsUserManagedPath(t *testing.T) { homeDir := t.TempDir() nvmBin := filepath.Join(homeDir, ".nvm", "versions", "node", "v22.14.0", "bin") @@ -572,6 +637,57 @@ func TestInstallSupervisorSystemdRestartsWhenUnitChangesAndServiceActive(t *test 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) } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%q): %v", path, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("systemd unit mode after warm upgrade = %03o, want 600", got) + } +} + +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) { @@ -1187,6 +1303,13 @@ func TestInstallSupervisorSystemdRestoresPreviousCurrentUnitWhenUpdateFails(t *t if !bytes.Equal(gotContent, oldContent) { t.Fatalf("restored systemd unit = %q, want original %q", gotContent, oldContent) } + info, err := os.Stat(currentPath) + if err != nil { + t.Fatalf("Stat(%q): %v", currentPath, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("restored systemd unit mode = %03o, want 600", got) + } if startCalls != 2 { t.Fatalf("systemctl start call count = %d, want 2 (failed install + rollback restore); calls=%v", startCalls, calls) } @@ -1353,6 +1476,45 @@ func TestInstallSupervisorLaunchdRemovesMatchingLegacyDefaultPlistForIsolatedGCH } } +func TestInstallSupervisorLaunchdWritesPrivatePlist(t *testing.T) { + homeDir := t.TempDir() + gcHome := filepath.Join(t.TempDir(), "isolated-home") + t.Setenv("HOME", homeDir) + t.Setenv("GC_HOME", gcHome) + + data := &supervisorServiceData{ + GCPath: "/tmp/gc-new", + LogPath: filepath.Join(gcHome, "supervisor.log"), + GCHome: gcHome, + LaunchdLabel: supervisorLaunchdLabel(), + Path: "/usr/local/bin:/usr/bin:/bin", + ExtraEnv: []supervisorServiceEnvVar{ + {Name: "OPENAI_API_KEY", Value: "sk-openai-123"}, + }, + } + + oldRun := supervisorLaunchctlRun + supervisorLaunchctlRun = func(_ ...string) error { + 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() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%q): %v", path, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("launchd plist mode = %03o, want 600", got) + } +} + func TestInstallSupervisorLaunchdIgnoresLegacyUnloadFailures(t *testing.T) { homeDir := t.TempDir() gcHome := filepath.Join(t.TempDir(), "isolated-home") @@ -1527,6 +1689,13 @@ func TestInstallSupervisorLaunchdRestoresPreviousCurrentPlistWhenUpdateFails(t * if !bytes.Equal(gotContent, oldContent) { t.Fatalf("restored launchd plist = %q, want original %q", gotContent, oldContent) } + info, err := os.Stat(currentPath) + if err != nil { + t.Fatalf("Stat(%q): %v", currentPath, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("restored launchd plist mode = %03o, want 600", got) + } if loadCalls != 2 { t.Fatalf("launchctl load call count = %d, want 2 (failed install + rollback restore); calls=%v", loadCalls, calls) } diff --git a/cmd/gc/cmd_wait.go b/cmd/gc/cmd_wait.go index 093b3017d..c5564c503 100644 --- a/cmd/gc/cmd_wait.go +++ b/cmd/gc/cmd_wait.go @@ -560,10 +560,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 +585,13 @@ 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 anySessionBead, found := sessionBeads.findByIDIncludingClosed(sessionID); found { + sessionBead = anySessionBead + } 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 +606,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 +670,22 @@ 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 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 +694,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 +739,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 387f50fea..7842c814a 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 @@ -72,7 +82,7 @@ func waitTestEnv(overrides map[string]string) []string { func waitTestRealBDPath(t *testing.T) string { t.Helper() - skipSlowCmdGCTest(t, "requires a managed bd lifecycle city; run without -short or via integration packages") + 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) == "" { @@ -432,6 +442,109 @@ func TestPrepareWaitWakeState_FinalizesFromNudge(t *testing.T) { } } +func TestPrepareWaitWakeState_SkipsMissingOpenSessionWithoutBackingGet(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) + } + for _, id := range store.getIDs { + if id == sessionBead.ID { + t.Fatalf("prepare used Get for non-open session %s; getIDs=%v", sessionBead.ID, 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 +851,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() @@ -1270,6 +1488,7 @@ func setupFreshManagedBdWaitTestCity(t *testing.T) (string, string) { func setupManagedBdWaitTestCity(t *testing.T) (string, string) { t.Helper() + skipSlowCmdGCTest(t, "requires a managed bd/dolt lifecycle city; run make test-cmd-gc-process for full coverage") configureIsolatedRuntimeEnv(t) bdPath := waitTestRealBDPath(t) diff --git a/cmd/gc/compute_awake_set.go b/cmd/gc/compute_awake_set.go index 8fc5234ac..0e0c43903 100644 --- a/cmd/gc/compute_awake_set.go +++ b/cmd/gc/compute_awake_set.go @@ -20,7 +20,7 @@ type AwakeInput struct { NamedSessions []AwakeNamedSession SessionBeads []AwakeSessionBead WorkBeads []AwakeWorkBead - ScaleCheckCounts map[string]int // agent template → desired count + ScaleCheckCounts map[string]int // agent template → scale_check 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 diff --git a/cmd/gc/controller.go b/cmd/gc/controller.go index d71e2afbc..78c1ab0ee 100644 --- a/cmd/gc/controller.go +++ b/cmd/gc/controller.go @@ -898,6 +898,9 @@ func gracefulStopAll( 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/dashboard/web/src/generated/client.gen.ts b/cmd/gc/dashboard/web/src/generated/client.gen.ts new file mode 100644 index 000000000..cab3c7019 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client = createClient(createConfig()); diff --git a/cmd/gc/dashboard/web/src/generated/client/client.gen.ts b/cmd/gc/dashboard/web/src/generated/client/client.gen.ts new file mode 100644 index 000000000..9ec9ad887 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/client/client.gen.ts @@ -0,0 +1,298 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, undefined as any, request, opts)) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/cmd/gc/dashboard/web/src/generated/client/index.ts b/cmd/gc/dashboard/web/src/generated/client/index.ts new file mode 100644 index 000000000..b295edeca --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/cmd/gc/dashboard/web/src/generated/client/types.gen.ts b/cmd/gc/dashboard/web/src/generated/client/types.gen.ts new file mode 100644 index 000000000..9813eeaba --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/client/types.gen.ts @@ -0,0 +1,214 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/cmd/gc/dashboard/web/src/generated/client/utils.gen.ts b/cmd/gc/dashboard/web/src/generated/client/utils.gen.ts new file mode 100644 index 000000000..5162192d8 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/cmd/gc/dashboard/web/src/generated/core/auth.gen.ts b/cmd/gc/dashboard/web/src/generated/core/auth.gen.ts new file mode 100644 index 000000000..3ebf99478 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/bodySerializer.gen.ts b/cmd/gc/dashboard/web/src/generated/core/bodySerializer.gen.ts new file mode 100644 index 000000000..67daca60f --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/params.gen.ts b/cmd/gc/dashboard/web/src/generated/core/params.gen.ts new file mode 100644 index 000000000..7955601a5 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/params.gen.ts @@ -0,0 +1,169 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/pathSerializer.gen.ts b/cmd/gc/dashboard/web/src/generated/core/pathSerializer.gen.ts new file mode 100644 index 000000000..994b2848c --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/queryKeySerializer.gen.ts b/cmd/gc/dashboard/web/src/generated/core/queryKeySerializer.gen.ts new file mode 100644 index 000000000..5000df606 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/serverSentEvents.gen.ts b/cmd/gc/dashboard/web/src/generated/core/serverSentEvents.gen.ts new file mode 100644 index 000000000..ddf3c4d13 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/cmd/gc/dashboard/web/src/generated/core/types.gen.ts b/cmd/gc/dashboard/web/src/generated/core/types.gen.ts new file mode 100644 index 000000000..9efe71d4c --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/types.gen.ts @@ -0,0 +1,104 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/cmd/gc/dashboard/web/src/generated/core/utils.gen.ts b/cmd/gc/dashboard/web/src/generated/core/utils.gen.ts new file mode 100644 index 000000000..9a4fec783 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/cmd/gc/dashboard/web/src/generated/index.ts b/cmd/gc/dashboard/web/src/generated/index.ts new file mode 100644 index 000000000..47eed4b63 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { createAgent, createBead, createConvoy, createProvider, createRig, createSession, deleteV0CityByCityNameAgentByBase, deleteV0CityByCityNameAgentByDirByBase, deleteV0CityByCityNameBeadById, deleteV0CityByCityNameConvoyById, deleteV0CityByCityNameExtmsgAdapters, deleteV0CityByCityNameExtmsgParticipants, deleteV0CityByCityNameMailById, deleteV0CityByCityNamePatchesAgentByBase, deleteV0CityByCityNamePatchesAgentByDirByBase, deleteV0CityByCityNamePatchesProviderByName, deleteV0CityByCityNamePatchesRigByName, deleteV0CityByCityNameProviderByName, deleteV0CityByCityNameRigByName, deleteV0CityByCityNameWorkflowByWorkflowId, emitEvent, ensureExtmsgGroup, getHealth, getV0Cities, getV0CityByCityName, getV0CityByCityNameAgentByBase, getV0CityByCityNameAgentByBaseOutput, getV0CityByCityNameAgentByDirByBase, getV0CityByCityNameAgentByDirByBaseOutput, getV0CityByCityNameAgents, getV0CityByCityNameBeadById, getV0CityByCityNameBeadByIdDeps, getV0CityByCityNameBeads, getV0CityByCityNameBeadsGraphByRootId, getV0CityByCityNameBeadsReady, getV0CityByCityNameConfig, getV0CityByCityNameConfigExplain, getV0CityByCityNameConfigValidate, getV0CityByCityNameConvoyById, getV0CityByCityNameConvoyByIdCheck, getV0CityByCityNameConvoys, getV0CityByCityNameEvents, getV0CityByCityNameExtmsgAdapters, getV0CityByCityNameExtmsgBindings, getV0CityByCityNameExtmsgGroups, getV0CityByCityNameExtmsgTranscript, getV0CityByCityNameFormulaByName, getV0CityByCityNameFormulas, getV0CityByCityNameFormulasByName, getV0CityByCityNameFormulasByNameRuns, getV0CityByCityNameFormulasFeed, getV0CityByCityNameHealth, getV0CityByCityNameMail, getV0CityByCityNameMailById, getV0CityByCityNameMailCount, getV0CityByCityNameMailThreadById, getV0CityByCityNameOrderByName, getV0CityByCityNameOrderHistoryByBeadId, getV0CityByCityNameOrders, getV0CityByCityNameOrdersCheck, getV0CityByCityNameOrdersFeed, getV0CityByCityNameOrdersHistory, getV0CityByCityNamePacks, getV0CityByCityNamePatchesAgentByBase, getV0CityByCityNamePatchesAgentByDirByBase, getV0CityByCityNamePatchesAgents, getV0CityByCityNamePatchesProviderByName, getV0CityByCityNamePatchesProviders, getV0CityByCityNamePatchesRigByName, getV0CityByCityNamePatchesRigs, getV0CityByCityNameProviderByName, getV0CityByCityNameProviderReadiness, getV0CityByCityNameProviders, getV0CityByCityNameProvidersPublic, getV0CityByCityNameReadiness, getV0CityByCityNameRigByName, getV0CityByCityNameRigs, getV0CityByCityNameServiceByName, getV0CityByCityNameServices, getV0CityByCityNameSessionById, getV0CityByCityNameSessionByIdAgents, getV0CityByCityNameSessionByIdAgentsByAgentId, getV0CityByCityNameSessionByIdPending, getV0CityByCityNameSessionByIdTranscript, getV0CityByCityNameSessions, getV0CityByCityNameStatus, getV0CityByCityNameWorkflowByWorkflowId, getV0Events, getV0ProviderReadiness, getV0Readiness, type Options, patchV0CityByCityName, patchV0CityByCityNameAgentByBase, patchV0CityByCityNameAgentByDirByBase, patchV0CityByCityNameBeadById, patchV0CityByCityNameProviderByName, patchV0CityByCityNameRigByName, patchV0CityByCityNameSessionById, postV0City, postV0CityByCityNameAgentByBaseByAction, postV0CityByCityNameAgentByDirByBaseByAction, postV0CityByCityNameBeadByIdAssign, postV0CityByCityNameBeadByIdClose, postV0CityByCityNameBeadByIdReopen, postV0CityByCityNameBeadByIdUpdate, postV0CityByCityNameConvoyByIdAdd, postV0CityByCityNameConvoyByIdClose, postV0CityByCityNameConvoyByIdRemove, postV0CityByCityNameExtmsgBind, postV0CityByCityNameExtmsgInbound, postV0CityByCityNameExtmsgOutbound, postV0CityByCityNameExtmsgParticipants, postV0CityByCityNameExtmsgTranscriptAck, postV0CityByCityNameExtmsgUnbind, postV0CityByCityNameFormulasByNamePreview, postV0CityByCityNameMailByIdArchive, postV0CityByCityNameMailByIdMarkUnread, postV0CityByCityNameMailByIdRead, postV0CityByCityNameOrderByNameDisable, postV0CityByCityNameOrderByNameEnable, postV0CityByCityNameRigByNameByAction, postV0CityByCityNameServiceByNameRestart, postV0CityByCityNameSessionByIdClose, postV0CityByCityNameSessionByIdKill, postV0CityByCityNameSessionByIdRename, postV0CityByCityNameSessionByIdStop, postV0CityByCityNameSessionByIdSuspend, postV0CityByCityNameSessionByIdWake, postV0CityByCityNameSling, postV0CityByCityNameUnregister, putV0CityByCityNamePatchesAgents, putV0CityByCityNamePatchesProviders, putV0CityByCityNamePatchesRigs, registerExtmsgAdapter, replyMail, respondSession, sendMail, sendSessionMessage, streamAgentOutput, streamAgentOutputQualified, streamEvents, streamSession, streamSupervisorEvents, submitSession } from './sdk.gen'; +export type { AdapterCapabilities, AdapterEventPayload, AgentCreatedOutputBody, AgentCreateInputBody, AgentMapping, AgentOutputResponse, AgentPatch, AgentPatchSetInputBody, AgentResponse, AgentUpdateInputBody, AgentUpdateQualifiedInputBody, AnnotatedAgentResponse, AnnotatedProviderResponse, Bead, BeadAssignInputBody, BeadCreateInputBody, BeadDepsResponse, BeadEventPayload, BeadGraphResponse, BeadUpdateBody, BindingStatus, BoundEventPayload, CityCreateRequest, CityCreateResponse, CityGetResponse, CityInfo, CityLifecyclePayload, CityPatchInputBody, CityUnregisterResponse, ClientOptions, ConfigAgentResponse, ConfigExplainPatches, ConfigExplainResponse, ConfigPatchesResponse, ConfigResponse, ConfigRigResponse, ConfigValidateOutputBody, ConversationGroupParticipant, ConversationGroupRecord, ConversationKind, ConversationRef, ConversationTranscriptRecord, ConvoyAddInputBody, ConvoyCheckResponse, ConvoyCreateInputBody, ConvoyGetResponse, ConvoyProgress, ConvoyRemoveInputBody, CreateAgentData, CreateAgentError, CreateAgentErrors, CreateAgentResponse, CreateAgentResponses, CreateBeadData, CreateBeadError, CreateBeadErrors, CreateBeadResponse, CreateBeadResponses, CreateConvoyData, CreateConvoyError, CreateConvoyErrors, CreateConvoyResponse, CreateConvoyResponses, CreateProviderData, CreateProviderError, CreateProviderErrors, CreateProviderResponse, CreateProviderResponses, CreateRigData, CreateRigError, CreateRigErrors, CreateRigResponse, CreateRigResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteV0CityByCityNameAgentByBaseData, DeleteV0CityByCityNameAgentByBaseError, DeleteV0CityByCityNameAgentByBaseErrors, DeleteV0CityByCityNameAgentByBaseResponse, DeleteV0CityByCityNameAgentByBaseResponses, DeleteV0CityByCityNameAgentByDirByBaseData, DeleteV0CityByCityNameAgentByDirByBaseError, DeleteV0CityByCityNameAgentByDirByBaseErrors, DeleteV0CityByCityNameAgentByDirByBaseResponse, DeleteV0CityByCityNameAgentByDirByBaseResponses, DeleteV0CityByCityNameBeadByIdData, DeleteV0CityByCityNameBeadByIdError, DeleteV0CityByCityNameBeadByIdErrors, DeleteV0CityByCityNameBeadByIdResponse, DeleteV0CityByCityNameBeadByIdResponses, DeleteV0CityByCityNameConvoyByIdData, DeleteV0CityByCityNameConvoyByIdError, DeleteV0CityByCityNameConvoyByIdErrors, DeleteV0CityByCityNameConvoyByIdResponse, DeleteV0CityByCityNameConvoyByIdResponses, DeleteV0CityByCityNameExtmsgAdaptersData, DeleteV0CityByCityNameExtmsgAdaptersError, DeleteV0CityByCityNameExtmsgAdaptersErrors, DeleteV0CityByCityNameExtmsgAdaptersResponse, DeleteV0CityByCityNameExtmsgAdaptersResponses, DeleteV0CityByCityNameExtmsgParticipantsData, DeleteV0CityByCityNameExtmsgParticipantsError, DeleteV0CityByCityNameExtmsgParticipantsErrors, DeleteV0CityByCityNameExtmsgParticipantsResponse, DeleteV0CityByCityNameExtmsgParticipantsResponses, DeleteV0CityByCityNameMailByIdData, DeleteV0CityByCityNameMailByIdError, DeleteV0CityByCityNameMailByIdErrors, DeleteV0CityByCityNameMailByIdResponse, DeleteV0CityByCityNameMailByIdResponses, DeleteV0CityByCityNamePatchesAgentByBaseData, DeleteV0CityByCityNamePatchesAgentByBaseError, DeleteV0CityByCityNamePatchesAgentByBaseErrors, DeleteV0CityByCityNamePatchesAgentByBaseResponse, DeleteV0CityByCityNamePatchesAgentByBaseResponses, DeleteV0CityByCityNamePatchesAgentByDirByBaseData, DeleteV0CityByCityNamePatchesAgentByDirByBaseError, DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses, DeleteV0CityByCityNamePatchesProviderByNameData, DeleteV0CityByCityNamePatchesProviderByNameError, DeleteV0CityByCityNamePatchesProviderByNameErrors, DeleteV0CityByCityNamePatchesProviderByNameResponse, DeleteV0CityByCityNamePatchesProviderByNameResponses, DeleteV0CityByCityNamePatchesRigByNameData, DeleteV0CityByCityNamePatchesRigByNameError, DeleteV0CityByCityNamePatchesRigByNameErrors, DeleteV0CityByCityNamePatchesRigByNameResponse, DeleteV0CityByCityNamePatchesRigByNameResponses, DeleteV0CityByCityNameProviderByNameData, DeleteV0CityByCityNameProviderByNameError, DeleteV0CityByCityNameProviderByNameErrors, DeleteV0CityByCityNameProviderByNameResponse, DeleteV0CityByCityNameProviderByNameResponses, DeleteV0CityByCityNameRigByNameData, DeleteV0CityByCityNameRigByNameError, DeleteV0CityByCityNameRigByNameErrors, DeleteV0CityByCityNameRigByNameResponse, DeleteV0CityByCityNameRigByNameResponses, DeleteV0CityByCityNameWorkflowByWorkflowIdData, DeleteV0CityByCityNameWorkflowByWorkflowIdError, DeleteV0CityByCityNameWorkflowByWorkflowIdErrors, DeleteV0CityByCityNameWorkflowByWorkflowIdResponse, DeleteV0CityByCityNameWorkflowByWorkflowIdResponses, DeliveryContextRecord, Dep, EmitEventData, EmitEventError, EmitEventErrors, EmitEventResponse, EmitEventResponses, EnsureExtmsgGroupData, EnsureExtmsgGroupError, EnsureExtmsgGroupErrors, EnsureExtmsgGroupResponse, EnsureExtmsgGroupResponses, ErrorDetail, ErrorModel, EventEmitOutputBody, EventEmitRequest, EventPayload, EventStreamEnvelope, ExternalActor, ExternalAttachment, ExternalInboundMessage, ExtmsgAdapterInfo, ExtMsgAdapterRegisterInputBody, ExtMsgAdapterRegisterOutputBody, ExtMsgAdapterUnregisterInputBody, ExtMsgBindInputBody, ExtMsgGroupEnsureInputBody, ExtMsgInboundInputBody, ExtMsgOutboundInputBody, ExtMsgParticipantRemoveInputBody, ExtMsgParticipantUpsertInputBody, ExtMsgTranscriptAckInputBody, ExtMsgUnbindBody, ExtMsgUnbindInputBody, FanoutPolicy, FormulaDetailResponse, FormulaFeedBody, FormulaListBody, FormulaPreviewBody, FormulaPreviewEdgeResponse, FormulaPreviewNodeResponse, FormulaPreviewResponse, FormulaRecentRunResponse, FormulaRunsResponse, FormulaStepResponse, FormulaSummaryResponse, FormulaVarDefResponse, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetV0CitiesData, GetV0CitiesError, GetV0CitiesErrors, GetV0CitiesResponse, GetV0CitiesResponses, GetV0CityByCityNameAgentByBaseData, GetV0CityByCityNameAgentByBaseError, GetV0CityByCityNameAgentByBaseErrors, GetV0CityByCityNameAgentByBaseOutputData, GetV0CityByCityNameAgentByBaseOutputError, GetV0CityByCityNameAgentByBaseOutputErrors, GetV0CityByCityNameAgentByBaseOutputResponse, GetV0CityByCityNameAgentByBaseOutputResponses, GetV0CityByCityNameAgentByBaseResponse, GetV0CityByCityNameAgentByBaseResponses, GetV0CityByCityNameAgentByDirByBaseData, GetV0CityByCityNameAgentByDirByBaseError, GetV0CityByCityNameAgentByDirByBaseErrors, GetV0CityByCityNameAgentByDirByBaseOutputData, GetV0CityByCityNameAgentByDirByBaseOutputError, GetV0CityByCityNameAgentByDirByBaseOutputErrors, GetV0CityByCityNameAgentByDirByBaseOutputResponse, GetV0CityByCityNameAgentByDirByBaseOutputResponses, GetV0CityByCityNameAgentByDirByBaseResponse, GetV0CityByCityNameAgentByDirByBaseResponses, GetV0CityByCityNameAgentsData, GetV0CityByCityNameAgentsError, GetV0CityByCityNameAgentsErrors, GetV0CityByCityNameAgentsResponse, GetV0CityByCityNameAgentsResponses, GetV0CityByCityNameBeadByIdData, GetV0CityByCityNameBeadByIdDepsData, GetV0CityByCityNameBeadByIdDepsError, GetV0CityByCityNameBeadByIdDepsErrors, GetV0CityByCityNameBeadByIdDepsResponse, GetV0CityByCityNameBeadByIdDepsResponses, GetV0CityByCityNameBeadByIdError, GetV0CityByCityNameBeadByIdErrors, GetV0CityByCityNameBeadByIdResponse, GetV0CityByCityNameBeadByIdResponses, GetV0CityByCityNameBeadsData, GetV0CityByCityNameBeadsError, GetV0CityByCityNameBeadsErrors, GetV0CityByCityNameBeadsGraphByRootIdData, GetV0CityByCityNameBeadsGraphByRootIdError, GetV0CityByCityNameBeadsGraphByRootIdErrors, GetV0CityByCityNameBeadsGraphByRootIdResponse, GetV0CityByCityNameBeadsGraphByRootIdResponses, GetV0CityByCityNameBeadsReadyData, GetV0CityByCityNameBeadsReadyError, GetV0CityByCityNameBeadsReadyErrors, GetV0CityByCityNameBeadsReadyResponse, GetV0CityByCityNameBeadsReadyResponses, GetV0CityByCityNameBeadsResponse, GetV0CityByCityNameBeadsResponses, GetV0CityByCityNameConfigData, GetV0CityByCityNameConfigError, GetV0CityByCityNameConfigErrors, GetV0CityByCityNameConfigExplainData, GetV0CityByCityNameConfigExplainError, GetV0CityByCityNameConfigExplainErrors, GetV0CityByCityNameConfigExplainResponse, GetV0CityByCityNameConfigExplainResponses, GetV0CityByCityNameConfigResponse, GetV0CityByCityNameConfigResponses, GetV0CityByCityNameConfigValidateData, GetV0CityByCityNameConfigValidateError, GetV0CityByCityNameConfigValidateErrors, GetV0CityByCityNameConfigValidateResponse, GetV0CityByCityNameConfigValidateResponses, GetV0CityByCityNameConvoyByIdCheckData, GetV0CityByCityNameConvoyByIdCheckError, GetV0CityByCityNameConvoyByIdCheckErrors, GetV0CityByCityNameConvoyByIdCheckResponse, GetV0CityByCityNameConvoyByIdCheckResponses, GetV0CityByCityNameConvoyByIdData, GetV0CityByCityNameConvoyByIdError, GetV0CityByCityNameConvoyByIdErrors, GetV0CityByCityNameConvoyByIdResponse, GetV0CityByCityNameConvoyByIdResponses, GetV0CityByCityNameConvoysData, GetV0CityByCityNameConvoysError, GetV0CityByCityNameConvoysErrors, GetV0CityByCityNameConvoysResponse, GetV0CityByCityNameConvoysResponses, GetV0CityByCityNameData, GetV0CityByCityNameError, GetV0CityByCityNameErrors, GetV0CityByCityNameEventsData, GetV0CityByCityNameEventsError, GetV0CityByCityNameEventsErrors, GetV0CityByCityNameEventsResponse, GetV0CityByCityNameEventsResponses, GetV0CityByCityNameExtmsgAdaptersData, GetV0CityByCityNameExtmsgAdaptersError, GetV0CityByCityNameExtmsgAdaptersErrors, GetV0CityByCityNameExtmsgAdaptersResponse, GetV0CityByCityNameExtmsgAdaptersResponses, GetV0CityByCityNameExtmsgBindingsData, GetV0CityByCityNameExtmsgBindingsError, GetV0CityByCityNameExtmsgBindingsErrors, GetV0CityByCityNameExtmsgBindingsResponse, GetV0CityByCityNameExtmsgBindingsResponses, GetV0CityByCityNameExtmsgGroupsData, GetV0CityByCityNameExtmsgGroupsError, GetV0CityByCityNameExtmsgGroupsErrors, GetV0CityByCityNameExtmsgGroupsResponse, GetV0CityByCityNameExtmsgGroupsResponses, GetV0CityByCityNameExtmsgTranscriptData, GetV0CityByCityNameExtmsgTranscriptError, GetV0CityByCityNameExtmsgTranscriptErrors, GetV0CityByCityNameExtmsgTranscriptResponse, GetV0CityByCityNameExtmsgTranscriptResponses, GetV0CityByCityNameFormulaByNameData, GetV0CityByCityNameFormulaByNameError, GetV0CityByCityNameFormulaByNameErrors, GetV0CityByCityNameFormulaByNameResponse, GetV0CityByCityNameFormulaByNameResponses, GetV0CityByCityNameFormulasByNameData, GetV0CityByCityNameFormulasByNameError, GetV0CityByCityNameFormulasByNameErrors, GetV0CityByCityNameFormulasByNameResponse, GetV0CityByCityNameFormulasByNameResponses, GetV0CityByCityNameFormulasByNameRunsData, GetV0CityByCityNameFormulasByNameRunsError, GetV0CityByCityNameFormulasByNameRunsErrors, GetV0CityByCityNameFormulasByNameRunsResponse, GetV0CityByCityNameFormulasByNameRunsResponses, GetV0CityByCityNameFormulasData, GetV0CityByCityNameFormulasError, GetV0CityByCityNameFormulasErrors, GetV0CityByCityNameFormulasFeedData, GetV0CityByCityNameFormulasFeedError, GetV0CityByCityNameFormulasFeedErrors, GetV0CityByCityNameFormulasFeedResponse, GetV0CityByCityNameFormulasFeedResponses, GetV0CityByCityNameFormulasResponse, GetV0CityByCityNameFormulasResponses, GetV0CityByCityNameHealthData, GetV0CityByCityNameHealthError, GetV0CityByCityNameHealthErrors, GetV0CityByCityNameHealthResponse, GetV0CityByCityNameHealthResponses, GetV0CityByCityNameMailByIdData, GetV0CityByCityNameMailByIdError, GetV0CityByCityNameMailByIdErrors, GetV0CityByCityNameMailByIdResponse, GetV0CityByCityNameMailByIdResponses, GetV0CityByCityNameMailCountData, GetV0CityByCityNameMailCountError, GetV0CityByCityNameMailCountErrors, GetV0CityByCityNameMailCountResponse, GetV0CityByCityNameMailCountResponses, GetV0CityByCityNameMailData, GetV0CityByCityNameMailError, GetV0CityByCityNameMailErrors, GetV0CityByCityNameMailResponse, GetV0CityByCityNameMailResponses, GetV0CityByCityNameMailThreadByIdData, GetV0CityByCityNameMailThreadByIdError, GetV0CityByCityNameMailThreadByIdErrors, GetV0CityByCityNameMailThreadByIdResponse, GetV0CityByCityNameMailThreadByIdResponses, GetV0CityByCityNameOrderByNameData, GetV0CityByCityNameOrderByNameError, GetV0CityByCityNameOrderByNameErrors, GetV0CityByCityNameOrderByNameResponse, GetV0CityByCityNameOrderByNameResponses, GetV0CityByCityNameOrderHistoryByBeadIdData, GetV0CityByCityNameOrderHistoryByBeadIdError, GetV0CityByCityNameOrderHistoryByBeadIdErrors, GetV0CityByCityNameOrderHistoryByBeadIdResponse, GetV0CityByCityNameOrderHistoryByBeadIdResponses, GetV0CityByCityNameOrdersCheckData, GetV0CityByCityNameOrdersCheckError, GetV0CityByCityNameOrdersCheckErrors, GetV0CityByCityNameOrdersCheckResponse, GetV0CityByCityNameOrdersCheckResponses, GetV0CityByCityNameOrdersData, GetV0CityByCityNameOrdersError, GetV0CityByCityNameOrdersErrors, GetV0CityByCityNameOrdersFeedData, GetV0CityByCityNameOrdersFeedError, GetV0CityByCityNameOrdersFeedErrors, GetV0CityByCityNameOrdersFeedResponse, GetV0CityByCityNameOrdersFeedResponses, GetV0CityByCityNameOrdersHistoryData, GetV0CityByCityNameOrdersHistoryError, GetV0CityByCityNameOrdersHistoryErrors, GetV0CityByCityNameOrdersHistoryResponse, GetV0CityByCityNameOrdersHistoryResponses, GetV0CityByCityNameOrdersResponse, GetV0CityByCityNameOrdersResponses, GetV0CityByCityNamePacksData, GetV0CityByCityNamePacksError, GetV0CityByCityNamePacksErrors, GetV0CityByCityNamePacksResponse, GetV0CityByCityNamePacksResponses, GetV0CityByCityNamePatchesAgentByBaseData, GetV0CityByCityNamePatchesAgentByBaseError, GetV0CityByCityNamePatchesAgentByBaseErrors, GetV0CityByCityNamePatchesAgentByBaseResponse, GetV0CityByCityNamePatchesAgentByBaseResponses, GetV0CityByCityNamePatchesAgentByDirByBaseData, GetV0CityByCityNamePatchesAgentByDirByBaseError, GetV0CityByCityNamePatchesAgentByDirByBaseErrors, GetV0CityByCityNamePatchesAgentByDirByBaseResponse, GetV0CityByCityNamePatchesAgentByDirByBaseResponses, GetV0CityByCityNamePatchesAgentsData, GetV0CityByCityNamePatchesAgentsError, GetV0CityByCityNamePatchesAgentsErrors, GetV0CityByCityNamePatchesAgentsResponse, GetV0CityByCityNamePatchesAgentsResponses, GetV0CityByCityNamePatchesProviderByNameData, GetV0CityByCityNamePatchesProviderByNameError, GetV0CityByCityNamePatchesProviderByNameErrors, GetV0CityByCityNamePatchesProviderByNameResponse, GetV0CityByCityNamePatchesProviderByNameResponses, GetV0CityByCityNamePatchesProvidersData, GetV0CityByCityNamePatchesProvidersError, GetV0CityByCityNamePatchesProvidersErrors, GetV0CityByCityNamePatchesProvidersResponse, GetV0CityByCityNamePatchesProvidersResponses, GetV0CityByCityNamePatchesRigByNameData, GetV0CityByCityNamePatchesRigByNameError, GetV0CityByCityNamePatchesRigByNameErrors, GetV0CityByCityNamePatchesRigByNameResponse, GetV0CityByCityNamePatchesRigByNameResponses, GetV0CityByCityNamePatchesRigsData, GetV0CityByCityNamePatchesRigsError, GetV0CityByCityNamePatchesRigsErrors, GetV0CityByCityNamePatchesRigsResponse, GetV0CityByCityNamePatchesRigsResponses, GetV0CityByCityNameProviderByNameData, GetV0CityByCityNameProviderByNameError, GetV0CityByCityNameProviderByNameErrors, GetV0CityByCityNameProviderByNameResponse, GetV0CityByCityNameProviderByNameResponses, GetV0CityByCityNameProviderReadinessData, GetV0CityByCityNameProviderReadinessError, GetV0CityByCityNameProviderReadinessErrors, GetV0CityByCityNameProviderReadinessResponse, GetV0CityByCityNameProviderReadinessResponses, GetV0CityByCityNameProvidersData, GetV0CityByCityNameProvidersError, GetV0CityByCityNameProvidersErrors, GetV0CityByCityNameProvidersPublicData, GetV0CityByCityNameProvidersPublicError, GetV0CityByCityNameProvidersPublicErrors, GetV0CityByCityNameProvidersPublicResponse, GetV0CityByCityNameProvidersPublicResponses, GetV0CityByCityNameProvidersResponse, GetV0CityByCityNameProvidersResponses, GetV0CityByCityNameReadinessData, GetV0CityByCityNameReadinessError, GetV0CityByCityNameReadinessErrors, GetV0CityByCityNameReadinessResponse, GetV0CityByCityNameReadinessResponses, GetV0CityByCityNameResponse, GetV0CityByCityNameResponses, GetV0CityByCityNameRigByNameData, GetV0CityByCityNameRigByNameError, GetV0CityByCityNameRigByNameErrors, GetV0CityByCityNameRigByNameResponse, GetV0CityByCityNameRigByNameResponses, GetV0CityByCityNameRigsData, GetV0CityByCityNameRigsError, GetV0CityByCityNameRigsErrors, GetV0CityByCityNameRigsResponse, GetV0CityByCityNameRigsResponses, GetV0CityByCityNameServiceByNameData, GetV0CityByCityNameServiceByNameError, GetV0CityByCityNameServiceByNameErrors, GetV0CityByCityNameServiceByNameResponse, GetV0CityByCityNameServiceByNameResponses, GetV0CityByCityNameServicesData, GetV0CityByCityNameServicesError, GetV0CityByCityNameServicesErrors, GetV0CityByCityNameServicesResponse, GetV0CityByCityNameServicesResponses, GetV0CityByCityNameSessionByIdAgentsByAgentIdData, GetV0CityByCityNameSessionByIdAgentsByAgentIdError, GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses, GetV0CityByCityNameSessionByIdAgentsData, GetV0CityByCityNameSessionByIdAgentsError, GetV0CityByCityNameSessionByIdAgentsErrors, GetV0CityByCityNameSessionByIdAgentsResponse, GetV0CityByCityNameSessionByIdAgentsResponses, GetV0CityByCityNameSessionByIdData, GetV0CityByCityNameSessionByIdError, GetV0CityByCityNameSessionByIdErrors, GetV0CityByCityNameSessionByIdPendingData, GetV0CityByCityNameSessionByIdPendingError, GetV0CityByCityNameSessionByIdPendingErrors, GetV0CityByCityNameSessionByIdPendingResponse, GetV0CityByCityNameSessionByIdPendingResponses, GetV0CityByCityNameSessionByIdResponse, GetV0CityByCityNameSessionByIdResponses, GetV0CityByCityNameSessionByIdTranscriptData, GetV0CityByCityNameSessionByIdTranscriptError, GetV0CityByCityNameSessionByIdTranscriptErrors, GetV0CityByCityNameSessionByIdTranscriptResponse, GetV0CityByCityNameSessionByIdTranscriptResponses, GetV0CityByCityNameSessionsData, GetV0CityByCityNameSessionsError, GetV0CityByCityNameSessionsErrors, GetV0CityByCityNameSessionsResponse, GetV0CityByCityNameSessionsResponses, GetV0CityByCityNameStatusData, GetV0CityByCityNameStatusError, GetV0CityByCityNameStatusErrors, GetV0CityByCityNameStatusResponse, GetV0CityByCityNameStatusResponses, GetV0CityByCityNameWorkflowByWorkflowIdData, GetV0CityByCityNameWorkflowByWorkflowIdError, GetV0CityByCityNameWorkflowByWorkflowIdErrors, GetV0CityByCityNameWorkflowByWorkflowIdResponse, GetV0CityByCityNameWorkflowByWorkflowIdResponses, GetV0EventsData, GetV0EventsError, GetV0EventsErrors, GetV0EventsResponse, GetV0EventsResponses, GetV0ProviderReadinessData, GetV0ProviderReadinessError, GetV0ProviderReadinessErrors, GetV0ProviderReadinessResponse, GetV0ProviderReadinessResponses, GetV0ReadinessData, GetV0ReadinessError, GetV0ReadinessErrors, GetV0ReadinessResponse, GetV0ReadinessResponses, GitStatus, GroupCreatedEventPayload, GroupRouteDecision, HealthOutputBody, HeartbeatEvent, InboundEventPayload, InboundResult, ListBodyAgentPatch, ListBodyAgentResponse, ListBodyBead, ListBodyConversationTranscriptRecord, ListBodyExtmsgAdapterInfo, ListBodyProviderPatch, ListBodyProviderResponse, ListBodyRigPatch, ListBodyRigResponse, ListBodySessionBindingRecord, ListBodySessionResponse, ListBodyStatus, ListBodyWireEvent, LogicalNode, MailCountOutputBody, MailEventPayload, MailListBody, MailReplyInputBody, MailSendInputBody, Message, MonitorFeedItemResponse, NoPayload, OkResponseBody, OkWithIdResponseBody, OptionChoiceDto, OrderCheckListBody, OrderCheckResponse, OrderHistoryDetailResponse, OrderHistoryEntry, OrderHistoryListBody, OrderListBody, OrderResponse, OrdersFeedBody, OutboundEventPayload, OutboundResult, OutputTurn, PackListBody, PackResponse, PaginationInfo, PatchDeletedResponseBody, PatchOkResponseBody, PatchV0CityByCityNameAgentByBaseData, PatchV0CityByCityNameAgentByBaseError, PatchV0CityByCityNameAgentByBaseErrors, PatchV0CityByCityNameAgentByBaseResponse, PatchV0CityByCityNameAgentByBaseResponses, PatchV0CityByCityNameAgentByDirByBaseData, PatchV0CityByCityNameAgentByDirByBaseError, PatchV0CityByCityNameAgentByDirByBaseErrors, PatchV0CityByCityNameAgentByDirByBaseResponse, PatchV0CityByCityNameAgentByDirByBaseResponses, PatchV0CityByCityNameBeadByIdData, PatchV0CityByCityNameBeadByIdError, PatchV0CityByCityNameBeadByIdErrors, PatchV0CityByCityNameBeadByIdResponse, PatchV0CityByCityNameBeadByIdResponses, PatchV0CityByCityNameData, PatchV0CityByCityNameError, PatchV0CityByCityNameErrors, PatchV0CityByCityNameProviderByNameData, PatchV0CityByCityNameProviderByNameError, PatchV0CityByCityNameProviderByNameErrors, PatchV0CityByCityNameProviderByNameResponse, PatchV0CityByCityNameProviderByNameResponses, PatchV0CityByCityNameResponse, PatchV0CityByCityNameResponses, PatchV0CityByCityNameRigByNameData, PatchV0CityByCityNameRigByNameError, PatchV0CityByCityNameRigByNameErrors, PatchV0CityByCityNameRigByNameResponse, PatchV0CityByCityNameRigByNameResponses, PatchV0CityByCityNameSessionByIdData, PatchV0CityByCityNameSessionByIdError, PatchV0CityByCityNameSessionByIdErrors, PatchV0CityByCityNameSessionByIdResponse, PatchV0CityByCityNameSessionByIdResponses, PendingInteraction, PoolOverride, PostV0CityByCityNameAgentByBaseByActionData, PostV0CityByCityNameAgentByBaseByActionError, PostV0CityByCityNameAgentByBaseByActionErrors, PostV0CityByCityNameAgentByBaseByActionResponse, PostV0CityByCityNameAgentByBaseByActionResponses, PostV0CityByCityNameAgentByDirByBaseByActionData, PostV0CityByCityNameAgentByDirByBaseByActionError, PostV0CityByCityNameAgentByDirByBaseByActionErrors, PostV0CityByCityNameAgentByDirByBaseByActionResponse, PostV0CityByCityNameAgentByDirByBaseByActionResponses, PostV0CityByCityNameBeadByIdAssignData, PostV0CityByCityNameBeadByIdAssignError, PostV0CityByCityNameBeadByIdAssignErrors, PostV0CityByCityNameBeadByIdAssignResponse, PostV0CityByCityNameBeadByIdAssignResponses, PostV0CityByCityNameBeadByIdCloseData, PostV0CityByCityNameBeadByIdCloseError, PostV0CityByCityNameBeadByIdCloseErrors, PostV0CityByCityNameBeadByIdCloseResponse, PostV0CityByCityNameBeadByIdCloseResponses, PostV0CityByCityNameBeadByIdReopenData, PostV0CityByCityNameBeadByIdReopenError, PostV0CityByCityNameBeadByIdReopenErrors, PostV0CityByCityNameBeadByIdReopenResponse, PostV0CityByCityNameBeadByIdReopenResponses, PostV0CityByCityNameBeadByIdUpdateData, PostV0CityByCityNameBeadByIdUpdateError, PostV0CityByCityNameBeadByIdUpdateErrors, PostV0CityByCityNameBeadByIdUpdateResponse, PostV0CityByCityNameBeadByIdUpdateResponses, PostV0CityByCityNameConvoyByIdAddData, PostV0CityByCityNameConvoyByIdAddError, PostV0CityByCityNameConvoyByIdAddErrors, PostV0CityByCityNameConvoyByIdAddResponse, PostV0CityByCityNameConvoyByIdAddResponses, PostV0CityByCityNameConvoyByIdCloseData, PostV0CityByCityNameConvoyByIdCloseError, PostV0CityByCityNameConvoyByIdCloseErrors, PostV0CityByCityNameConvoyByIdCloseResponse, PostV0CityByCityNameConvoyByIdCloseResponses, PostV0CityByCityNameConvoyByIdRemoveData, PostV0CityByCityNameConvoyByIdRemoveError, PostV0CityByCityNameConvoyByIdRemoveErrors, PostV0CityByCityNameConvoyByIdRemoveResponse, PostV0CityByCityNameConvoyByIdRemoveResponses, PostV0CityByCityNameExtmsgBindData, PostV0CityByCityNameExtmsgBindError, PostV0CityByCityNameExtmsgBindErrors, PostV0CityByCityNameExtmsgBindResponse, PostV0CityByCityNameExtmsgBindResponses, PostV0CityByCityNameExtmsgInboundData, PostV0CityByCityNameExtmsgInboundError, PostV0CityByCityNameExtmsgInboundErrors, PostV0CityByCityNameExtmsgInboundResponse, PostV0CityByCityNameExtmsgInboundResponses, PostV0CityByCityNameExtmsgOutboundData, PostV0CityByCityNameExtmsgOutboundError, PostV0CityByCityNameExtmsgOutboundErrors, PostV0CityByCityNameExtmsgOutboundResponse, PostV0CityByCityNameExtmsgOutboundResponses, PostV0CityByCityNameExtmsgParticipantsData, PostV0CityByCityNameExtmsgParticipantsError, PostV0CityByCityNameExtmsgParticipantsErrors, PostV0CityByCityNameExtmsgParticipantsResponse, PostV0CityByCityNameExtmsgParticipantsResponses, PostV0CityByCityNameExtmsgTranscriptAckData, PostV0CityByCityNameExtmsgTranscriptAckError, PostV0CityByCityNameExtmsgTranscriptAckErrors, PostV0CityByCityNameExtmsgTranscriptAckResponse, PostV0CityByCityNameExtmsgTranscriptAckResponses, PostV0CityByCityNameExtmsgUnbindData, PostV0CityByCityNameExtmsgUnbindError, PostV0CityByCityNameExtmsgUnbindErrors, PostV0CityByCityNameExtmsgUnbindResponse, PostV0CityByCityNameExtmsgUnbindResponses, PostV0CityByCityNameFormulasByNamePreviewData, PostV0CityByCityNameFormulasByNamePreviewError, PostV0CityByCityNameFormulasByNamePreviewErrors, PostV0CityByCityNameFormulasByNamePreviewResponse, PostV0CityByCityNameFormulasByNamePreviewResponses, PostV0CityByCityNameMailByIdArchiveData, PostV0CityByCityNameMailByIdArchiveError, PostV0CityByCityNameMailByIdArchiveErrors, PostV0CityByCityNameMailByIdArchiveResponse, PostV0CityByCityNameMailByIdArchiveResponses, PostV0CityByCityNameMailByIdMarkUnreadData, PostV0CityByCityNameMailByIdMarkUnreadError, PostV0CityByCityNameMailByIdMarkUnreadErrors, PostV0CityByCityNameMailByIdMarkUnreadResponse, PostV0CityByCityNameMailByIdMarkUnreadResponses, PostV0CityByCityNameMailByIdReadData, PostV0CityByCityNameMailByIdReadError, PostV0CityByCityNameMailByIdReadErrors, PostV0CityByCityNameMailByIdReadResponse, PostV0CityByCityNameMailByIdReadResponses, PostV0CityByCityNameOrderByNameDisableData, PostV0CityByCityNameOrderByNameDisableError, PostV0CityByCityNameOrderByNameDisableErrors, PostV0CityByCityNameOrderByNameDisableResponse, PostV0CityByCityNameOrderByNameDisableResponses, PostV0CityByCityNameOrderByNameEnableData, PostV0CityByCityNameOrderByNameEnableError, PostV0CityByCityNameOrderByNameEnableErrors, PostV0CityByCityNameOrderByNameEnableResponse, PostV0CityByCityNameOrderByNameEnableResponses, PostV0CityByCityNameRigByNameByActionData, PostV0CityByCityNameRigByNameByActionError, PostV0CityByCityNameRigByNameByActionErrors, PostV0CityByCityNameRigByNameByActionResponse, PostV0CityByCityNameRigByNameByActionResponses, PostV0CityByCityNameServiceByNameRestartData, PostV0CityByCityNameServiceByNameRestartError, PostV0CityByCityNameServiceByNameRestartErrors, PostV0CityByCityNameServiceByNameRestartResponse, PostV0CityByCityNameServiceByNameRestartResponses, PostV0CityByCityNameSessionByIdCloseData, PostV0CityByCityNameSessionByIdCloseError, PostV0CityByCityNameSessionByIdCloseErrors, PostV0CityByCityNameSessionByIdCloseResponse, PostV0CityByCityNameSessionByIdCloseResponses, PostV0CityByCityNameSessionByIdKillData, PostV0CityByCityNameSessionByIdKillError, PostV0CityByCityNameSessionByIdKillErrors, PostV0CityByCityNameSessionByIdKillResponse, PostV0CityByCityNameSessionByIdKillResponses, PostV0CityByCityNameSessionByIdRenameData, PostV0CityByCityNameSessionByIdRenameError, PostV0CityByCityNameSessionByIdRenameErrors, PostV0CityByCityNameSessionByIdRenameResponse, PostV0CityByCityNameSessionByIdRenameResponses, PostV0CityByCityNameSessionByIdStopData, PostV0CityByCityNameSessionByIdStopError, PostV0CityByCityNameSessionByIdStopErrors, PostV0CityByCityNameSessionByIdStopResponse, PostV0CityByCityNameSessionByIdStopResponses, PostV0CityByCityNameSessionByIdSuspendData, PostV0CityByCityNameSessionByIdSuspendError, PostV0CityByCityNameSessionByIdSuspendErrors, PostV0CityByCityNameSessionByIdSuspendResponse, PostV0CityByCityNameSessionByIdSuspendResponses, PostV0CityByCityNameSessionByIdWakeData, PostV0CityByCityNameSessionByIdWakeError, PostV0CityByCityNameSessionByIdWakeErrors, PostV0CityByCityNameSessionByIdWakeResponse, PostV0CityByCityNameSessionByIdWakeResponses, PostV0CityByCityNameSlingData, PostV0CityByCityNameSlingError, PostV0CityByCityNameSlingErrors, PostV0CityByCityNameSlingResponse, PostV0CityByCityNameSlingResponses, PostV0CityByCityNameUnregisterData, PostV0CityByCityNameUnregisterError, PostV0CityByCityNameUnregisterErrors, PostV0CityByCityNameUnregisterResponse, PostV0CityByCityNameUnregisterResponses, PostV0CityData, PostV0CityError, PostV0CityErrors, PostV0CityResponse, PostV0CityResponses, ProviderCreatedOutputBody, ProviderCreateInputBody, ProviderOptionDto, ProviderPatch, ProviderPatchSetInputBody, ProviderPublicListBody, ProviderPublicResponse, ProviderReadiness, ProviderReadinessResponse, ProviderResponse, ProviderSpecJson, ProviderUpdateInputBody, PublishReceipt, PutV0CityByCityNamePatchesAgentsData, PutV0CityByCityNamePatchesAgentsError, PutV0CityByCityNamePatchesAgentsErrors, PutV0CityByCityNamePatchesAgentsResponse, PutV0CityByCityNamePatchesAgentsResponses, PutV0CityByCityNamePatchesProvidersData, PutV0CityByCityNamePatchesProvidersError, PutV0CityByCityNamePatchesProvidersErrors, PutV0CityByCityNamePatchesProvidersResponse, PutV0CityByCityNamePatchesProvidersResponses, PutV0CityByCityNamePatchesRigsData, PutV0CityByCityNamePatchesRigsError, PutV0CityByCityNamePatchesRigsErrors, PutV0CityByCityNamePatchesRigsResponse, PutV0CityByCityNamePatchesRigsResponses, ReadinessItem, ReadinessResponse, RegisterExtmsgAdapterData, RegisterExtmsgAdapterError, RegisterExtmsgAdapterErrors, RegisterExtmsgAdapterResponse, RegisterExtmsgAdapterResponses, ReplyMailData, ReplyMailError, ReplyMailErrors, ReplyMailResponse, ReplyMailResponses, RespondSessionData, RespondSessionError, RespondSessionErrors, RespondSessionResponse, RespondSessionResponses, RigActionBody, RigCreatedOutputBody, RigCreateInputBody, RigPatch, RigPatchSetInputBody, RigResponse, RigUpdateInputBody, ScopeGroup, SendMailData, SendMailError, SendMailErrors, SendMailResponse, SendMailResponses, SendSessionMessageData, SendSessionMessageError, SendSessionMessageErrors, SendSessionMessageResponse, SendSessionMessageResponses, ServiceRestartOutputBody, SessionActivityEvent, SessionAgentGetResponse, SessionAgentListResponse, SessionBindingRecord, SessionCreateBody, SessionInfo, SessionMessageInputBody, SessionMessageOutputBody, SessionPatchBody, SessionPendingResponse, SessionRawMessageFrame, SessionRenameInputBody, SessionRespondInputBody, SessionRespondOutputBody, SessionResponse, SessionStreamCommonEvent, SessionStreamMessageEvent, SessionStreamRawMessageEvent, SessionSubmitInputBody, SessionSubmitOutputBody, SessionTranscriptGetResponse, SlingInputBody, SlingResponse, Status, StatusAgentCounts, StatusBody, StatusMailCounts, StatusRigCounts, StatusWorkCounts, StreamAgentOutputData, StreamAgentOutputError, StreamAgentOutputErrors, StreamAgentOutputQualifiedData, StreamAgentOutputQualifiedError, StreamAgentOutputQualifiedErrors, StreamAgentOutputQualifiedResponse, StreamAgentOutputQualifiedResponses, StreamAgentOutputResponse, StreamAgentOutputResponses, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, StreamSessionData, StreamSessionError, StreamSessionErrors, StreamSessionResponse, StreamSessionResponses, StreamSupervisorEventsData, StreamSupervisorEventsError, StreamSupervisorEventsErrors, StreamSupervisorEventsResponse, StreamSupervisorEventsResponses, SubmissionCapabilities, SubmitIntent, SubmitSessionData, SubmitSessionError, SubmitSessionErrors, SubmitSessionResponse, SubmitSessionResponses, SupervisorCitiesOutputBody, SupervisorEventListOutputBody, SupervisorHealthOutputBody, SupervisorStartup, TaggedEventStreamEnvelope, TranscriptMessageKind, TranscriptProvenance, TypedEventStreamEnvelope, TypedEventStreamEnvelopeBeadClosed, TypedEventStreamEnvelopeBeadCreated, TypedEventStreamEnvelopeBeadUpdated, TypedEventStreamEnvelopeCityCreated, TypedEventStreamEnvelopeCityInitFailed, TypedEventStreamEnvelopeCityReady, TypedEventStreamEnvelopeCityResumed, TypedEventStreamEnvelopeCitySuspended, TypedEventStreamEnvelopeCityUnregistered, TypedEventStreamEnvelopeCityUnregisterFailed, TypedEventStreamEnvelopeCityUnregisterRequested, TypedEventStreamEnvelopeControllerStarted, TypedEventStreamEnvelopeControllerStopped, TypedEventStreamEnvelopeConvoyClosed, TypedEventStreamEnvelopeConvoyCreated, TypedEventStreamEnvelopeExtmsgAdapterAdded, TypedEventStreamEnvelopeExtmsgAdapterRemoved, TypedEventStreamEnvelopeExtmsgBound, TypedEventStreamEnvelopeExtmsgGroupCreated, TypedEventStreamEnvelopeExtmsgInbound, TypedEventStreamEnvelopeExtmsgOutbound, TypedEventStreamEnvelopeExtmsgUnbound, TypedEventStreamEnvelopeMailArchived, TypedEventStreamEnvelopeMailDeleted, TypedEventStreamEnvelopeMailMarkedRead, TypedEventStreamEnvelopeMailMarkedUnread, TypedEventStreamEnvelopeMailRead, TypedEventStreamEnvelopeMailReplied, TypedEventStreamEnvelopeMailSent, TypedEventStreamEnvelopeOrderCompleted, TypedEventStreamEnvelopeOrderFailed, TypedEventStreamEnvelopeOrderFired, TypedEventStreamEnvelopeProviderSwapped, TypedEventStreamEnvelopeSessionCrashed, TypedEventStreamEnvelopeSessionDraining, TypedEventStreamEnvelopeSessionIdleKilled, TypedEventStreamEnvelopeSessionQuarantined, TypedEventStreamEnvelopeSessionStopped, TypedEventStreamEnvelopeSessionSuspended, TypedEventStreamEnvelopeSessionUndrained, TypedEventStreamEnvelopeSessionUpdated, TypedEventStreamEnvelopeSessionWoke, TypedEventStreamEnvelopeWorkerOperation, TypedTaggedEventStreamEnvelope, TypedTaggedEventStreamEnvelopeBeadClosed, TypedTaggedEventStreamEnvelopeBeadCreated, TypedTaggedEventStreamEnvelopeBeadUpdated, TypedTaggedEventStreamEnvelopeCityCreated, TypedTaggedEventStreamEnvelopeCityInitFailed, TypedTaggedEventStreamEnvelopeCityReady, TypedTaggedEventStreamEnvelopeCityResumed, TypedTaggedEventStreamEnvelopeCitySuspended, TypedTaggedEventStreamEnvelopeCityUnregistered, TypedTaggedEventStreamEnvelopeCityUnregisterFailed, TypedTaggedEventStreamEnvelopeCityUnregisterRequested, TypedTaggedEventStreamEnvelopeControllerStarted, TypedTaggedEventStreamEnvelopeControllerStopped, TypedTaggedEventStreamEnvelopeConvoyClosed, TypedTaggedEventStreamEnvelopeConvoyCreated, TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded, TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved, TypedTaggedEventStreamEnvelopeExtmsgBound, TypedTaggedEventStreamEnvelopeExtmsgGroupCreated, TypedTaggedEventStreamEnvelopeExtmsgInbound, TypedTaggedEventStreamEnvelopeExtmsgOutbound, TypedTaggedEventStreamEnvelopeExtmsgUnbound, TypedTaggedEventStreamEnvelopeMailArchived, TypedTaggedEventStreamEnvelopeMailDeleted, TypedTaggedEventStreamEnvelopeMailMarkedRead, TypedTaggedEventStreamEnvelopeMailMarkedUnread, TypedTaggedEventStreamEnvelopeMailRead, TypedTaggedEventStreamEnvelopeMailReplied, TypedTaggedEventStreamEnvelopeMailSent, TypedTaggedEventStreamEnvelopeOrderCompleted, TypedTaggedEventStreamEnvelopeOrderFailed, TypedTaggedEventStreamEnvelopeOrderFired, TypedTaggedEventStreamEnvelopeProviderSwapped, TypedTaggedEventStreamEnvelopeSessionCrashed, TypedTaggedEventStreamEnvelopeSessionDraining, TypedTaggedEventStreamEnvelopeSessionIdleKilled, TypedTaggedEventStreamEnvelopeSessionQuarantined, TypedTaggedEventStreamEnvelopeSessionStopped, TypedTaggedEventStreamEnvelopeSessionSuspended, TypedTaggedEventStreamEnvelopeSessionUndrained, TypedTaggedEventStreamEnvelopeSessionUpdated, TypedTaggedEventStreamEnvelopeSessionWoke, TypedTaggedEventStreamEnvelopeWorkerOperation, UnboundEventPayload, WireEvent, WireTaggedEvent, WorkerOperationEventPayload, WorkflowAttemptSummary, WorkflowBeadResponse, WorkflowDeleteResponse, WorkflowDepResponse, WorkflowEventProjection, WorkflowSnapshotResponse, WorkspaceResponse } from './types.gen'; diff --git a/cmd/gc/dashboard/web/src/generated/schema.d.ts b/cmd/gc/dashboard/web/src/generated/schema.d.ts new file mode 100644 index 000000000..08a9edd7d --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/schema.d.ts @@ -0,0 +1,11538 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get health */ + get: operations["get-health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/cities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 cities */ + get: operations["get-v0-cities"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city */ + post: operations["post-v0-city"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name */ + get: operations["get-v0-city-by-city-name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch v0 city by city name */ + patch: operations["patch-v0-city-by-city-name"]; + trace?: never; + }; + "/v0/city/{cityName}/agent/{base}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name agent by base */ + get: operations["get-v0-city-by-city-name-agent-by-base"]; + put?: never; + post?: never; + /** Delete v0 city by city name agent by base */ + delete: operations["delete-v0-city-by-city-name-agent-by-base"]; + options?: never; + head?: never; + /** Patch v0 city by city name agent by base */ + patch: operations["patch-v0-city-by-city-name-agent-by-base"]; + trace?: never; + }; + "/v0/city/{cityName}/agent/{base}/output": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name agent by base output */ + get: operations["get-v0-city-by-city-name-agent-by-base-output"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agent/{base}/output/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream agent output in real time + * @description Server-Sent Events stream of agent output (session log tail or tmux pane polling). + */ + get: operations["stream-agent-output"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agent/{base}/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name agent by base by action */ + post: operations["post-v0-city-by-city-name-agent-by-base-by-action"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agent/{dir}/{base}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name agent by dir by base */ + get: operations["get-v0-city-by-city-name-agent-by-dir-by-base"]; + put?: never; + post?: never; + /** Delete v0 city by city name agent by dir by base */ + delete: operations["delete-v0-city-by-city-name-agent-by-dir-by-base"]; + options?: never; + head?: never; + /** Patch v0 city by city name agent by dir by base */ + patch: operations["patch-v0-city-by-city-name-agent-by-dir-by-base"]; + trace?: never; + }; + "/v0/city/{cityName}/agent/{dir}/{base}/output": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name agent by dir by base output */ + get: operations["get-v0-city-by-city-name-agent-by-dir-by-base-output"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agent/{dir}/{base}/output/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream agent output in real time (qualified name) + * @description Server-Sent Events stream of agent output for qualified (rig-prefixed) agent names. + */ + get: operations["stream-agent-output-qualified"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agent/{dir}/{base}/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name agent by dir by base by action */ + post: operations["post-v0-city-by-city-name-agent-by-dir-by-base-by-action"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name agents */ + get: operations["get-v0-city-by-city-name-agents"]; + put?: never; + /** Create an agent */ + post: operations["create-agent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name bead by ID */ + get: operations["get-v0-city-by-city-name-bead-by-id"]; + put?: never; + post?: never; + /** Delete v0 city by city name bead by ID */ + delete: operations["delete-v0-city-by-city-name-bead-by-id"]; + options?: never; + head?: never; + /** Patch v0 city by city name bead by ID */ + patch: operations["patch-v0-city-by-city-name-bead-by-id"]; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}/assign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name bead by ID assign */ + post: operations["post-v0-city-by-city-name-bead-by-id-assign"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name bead by ID close */ + post: operations["post-v0-city-by-city-name-bead-by-id-close"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}/deps": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name bead by ID deps */ + get: operations["get-v0-city-by-city-name-bead-by-id-deps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}/reopen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name bead by ID reopen */ + post: operations["post-v0-city-by-city-name-bead-by-id-reopen"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/bead/{id}/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name bead by ID update */ + post: operations["post-v0-city-by-city-name-bead-by-id-update"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/beads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name beads */ + get: operations["get-v0-city-by-city-name-beads"]; + put?: never; + /** Create a bead */ + post: operations["create-bead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/beads/graph/{rootID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name beads graph by root ID */ + get: operations["get-v0-city-by-city-name-beads-graph-by-root-id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/beads/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name beads ready */ + get: operations["get-v0-city-by-city-name-beads-ready"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name config */ + get: operations["get-v0-city-by-city-name-config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/config/explain": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name config explain */ + get: operations["get-v0-city-by-city-name-config-explain"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/config/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name config validate */ + get: operations["get-v0-city-by-city-name-config-validate"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoy/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name convoy by ID */ + get: operations["get-v0-city-by-city-name-convoy-by-id"]; + put?: never; + post?: never; + /** Delete v0 city by city name convoy by ID */ + delete: operations["delete-v0-city-by-city-name-convoy-by-id"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoy/{id}/add": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name convoy by ID add */ + post: operations["post-v0-city-by-city-name-convoy-by-id-add"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoy/{id}/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name convoy by ID check */ + get: operations["get-v0-city-by-city-name-convoy-by-id-check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoy/{id}/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name convoy by ID close */ + post: operations["post-v0-city-by-city-name-convoy-by-id-close"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoy/{id}/remove": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name convoy by ID remove */ + post: operations["post-v0-city-by-city-name-convoy-by-id-remove"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/convoys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name convoys */ + get: operations["get-v0-city-by-city-name-convoys"]; + put?: never; + /** Create a convoy */ + post: operations["create-convoy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name events */ + get: operations["get-v0-city-by-city-name-events"]; + put?: never; + /** Emit an event */ + post: operations["emit-event"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/events/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream city events in real time + * @description Server-Sent Events stream of city events with optional workflow projections. Supports reconnection via Last-Event-ID header or after_seq query param. + */ + get: operations["stream-events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/adapters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name extmsg adapters */ + get: operations["get-v0-city-by-city-name-extmsg-adapters"]; + put?: never; + /** Register an external messaging adapter */ + post: operations["register-extmsg-adapter"]; + /** Delete v0 city by city name extmsg adapters */ + delete: operations["delete-v0-city-by-city-name-extmsg-adapters"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/bind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg bind */ + post: operations["post-v0-city-by-city-name-extmsg-bind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name extmsg bindings */ + get: operations["get-v0-city-by-city-name-extmsg-bindings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name extmsg groups */ + get: operations["get-v0-city-by-city-name-extmsg-groups"]; + put?: never; + /** Ensure an external messaging group exists */ + post: operations["ensure-extmsg-group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/inbound": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg inbound */ + post: operations["post-v0-city-by-city-name-extmsg-inbound"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/outbound": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg outbound */ + post: operations["post-v0-city-by-city-name-extmsg-outbound"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/participants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg participants */ + post: operations["post-v0-city-by-city-name-extmsg-participants"]; + /** Delete v0 city by city name extmsg participants */ + delete: operations["delete-v0-city-by-city-name-extmsg-participants"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/transcript": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name extmsg transcript */ + get: operations["get-v0-city-by-city-name-extmsg-transcript"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/transcript/ack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg transcript ack */ + post: operations["post-v0-city-by-city-name-extmsg-transcript-ack"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/extmsg/unbind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name extmsg unbind */ + post: operations["post-v0-city-by-city-name-extmsg-unbind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formula/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name formula by name */ + get: operations["get-v0-city-by-city-name-formula-by-name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formulas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name formulas */ + get: operations["get-v0-city-by-city-name-formulas"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formulas/feed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name formulas feed */ + get: operations["get-v0-city-by-city-name-formulas-feed"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formulas/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name formulas by name */ + get: operations["get-v0-city-by-city-name-formulas-by-name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formulas/{name}/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name formulas by name preview */ + post: operations["post-v0-city-by-city-name-formulas-by-name-preview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/formulas/{name}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name formulas by name runs */ + get: operations["get-v0-city-by-city-name-formulas-by-name-runs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name health */ + get: operations["get-v0-city-by-city-name-health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name mail */ + get: operations["get-v0-city-by-city-name-mail"]; + put?: never; + /** Send a mail message */ + post: operations["send-mail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name mail count */ + get: operations["get-v0-city-by-city-name-mail-count"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/thread/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name mail thread by ID */ + get: operations["get-v0-city-by-city-name-mail-thread-by-id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name mail by ID */ + get: operations["get-v0-city-by-city-name-mail-by-id"]; + put?: never; + post?: never; + /** Delete v0 city by city name mail by ID */ + delete: operations["delete-v0-city-by-city-name-mail-by-id"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/{id}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name mail by ID archive */ + post: operations["post-v0-city-by-city-name-mail-by-id-archive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/{id}/mark-unread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name mail by ID mark unread */ + post: operations["post-v0-city-by-city-name-mail-by-id-mark-unread"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/{id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name mail by ID read */ + post: operations["post-v0-city-by-city-name-mail-by-id-read"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/mail/{id}/reply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reply to a mail message */ + post: operations["reply-mail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/order/history/{bead_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name order history by bead ID */ + get: operations["get-v0-city-by-city-name-order-history-by-bead-id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/order/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name order by name */ + get: operations["get-v0-city-by-city-name-order-by-name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/order/{name}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name order by name disable */ + post: operations["post-v0-city-by-city-name-order-by-name-disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/order/{name}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name order by name enable */ + post: operations["post-v0-city-by-city-name-order-by-name-enable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name orders */ + get: operations["get-v0-city-by-city-name-orders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/orders/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name orders check */ + get: operations["get-v0-city-by-city-name-orders-check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/orders/feed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name orders feed */ + get: operations["get-v0-city-by-city-name-orders-feed"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/orders/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name orders history */ + get: operations["get-v0-city-by-city-name-orders-history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/packs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name packs */ + get: operations["get-v0-city-by-city-name-packs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/agent/{base}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches agent by base */ + get: operations["get-v0-city-by-city-name-patches-agent-by-base"]; + put?: never; + post?: never; + /** Delete v0 city by city name patches agent by base */ + delete: operations["delete-v0-city-by-city-name-patches-agent-by-base"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/agent/{dir}/{base}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches agent by dir by base */ + get: operations["get-v0-city-by-city-name-patches-agent-by-dir-by-base"]; + put?: never; + post?: never; + /** Delete v0 city by city name patches agent by dir by base */ + delete: operations["delete-v0-city-by-city-name-patches-agent-by-dir-by-base"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches agents */ + get: operations["get-v0-city-by-city-name-patches-agents"]; + /** Put v0 city by city name patches agents */ + put: operations["put-v0-city-by-city-name-patches-agents"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/provider/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches provider by name */ + get: operations["get-v0-city-by-city-name-patches-provider-by-name"]; + put?: never; + post?: never; + /** Delete v0 city by city name patches provider by name */ + delete: operations["delete-v0-city-by-city-name-patches-provider-by-name"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches providers */ + get: operations["get-v0-city-by-city-name-patches-providers"]; + /** Put v0 city by city name patches providers */ + put: operations["put-v0-city-by-city-name-patches-providers"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/rig/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches rig by name */ + get: operations["get-v0-city-by-city-name-patches-rig-by-name"]; + put?: never; + post?: never; + /** Delete v0 city by city name patches rig by name */ + delete: operations["delete-v0-city-by-city-name-patches-rig-by-name"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/patches/rigs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name patches rigs */ + get: operations["get-v0-city-by-city-name-patches-rigs"]; + /** Put v0 city by city name patches rigs */ + put: operations["put-v0-city-by-city-name-patches-rigs"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/provider-readiness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name provider readiness */ + get: operations["get-v0-city-by-city-name-provider-readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/provider/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name provider by name */ + get: operations["get-v0-city-by-city-name-provider-by-name"]; + put?: never; + post?: never; + /** Delete v0 city by city name provider by name */ + delete: operations["delete-v0-city-by-city-name-provider-by-name"]; + options?: never; + head?: never; + /** Patch v0 city by city name provider by name */ + patch: operations["patch-v0-city-by-city-name-provider-by-name"]; + trace?: never; + }; + "/v0/city/{cityName}/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name providers */ + get: operations["get-v0-city-by-city-name-providers"]; + put?: never; + /** Create a provider */ + post: operations["create-provider"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/providers/public": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name providers public */ + get: operations["get-v0-city-by-city-name-providers-public"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/readiness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name readiness */ + get: operations["get-v0-city-by-city-name-readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/rig/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name rig by name */ + get: operations["get-v0-city-by-city-name-rig-by-name"]; + put?: never; + post?: never; + /** Delete v0 city by city name rig by name */ + delete: operations["delete-v0-city-by-city-name-rig-by-name"]; + options?: never; + head?: never; + /** Patch v0 city by city name rig by name */ + patch: operations["patch-v0-city-by-city-name-rig-by-name"]; + trace?: never; + }; + "/v0/city/{cityName}/rig/{name}/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name rig by name by action */ + post: operations["post-v0-city-by-city-name-rig-by-name-by-action"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/rigs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name rigs */ + get: operations["get-v0-city-by-city-name-rigs"]; + put?: never; + /** Create a rig */ + post: operations["create-rig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/service/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name service by name */ + get: operations["get-v0-city-by-city-name-service-by-name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/service/{name}/restart": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name service by name restart */ + post: operations["post-v0-city-by-city-name-service-by-name-restart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/services": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name services */ + get: operations["get-v0-city-by-city-name-services"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name session by ID */ + get: operations["get-v0-city-by-city-name-session-by-id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch v0 city by city name session by ID */ + patch: operations["patch-v0-city-by-city-name-session-by-id"]; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name session by ID agents */ + get: operations["get-v0-city-by-city-name-session-by-id-agents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/agents/{agentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name session by ID agents by agent ID */ + get: operations["get-v0-city-by-city-name-session-by-id-agents-by-agent-id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID close */ + post: operations["post-v0-city-by-city-name-session-by-id-close"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/kill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID kill */ + post: operations["post-v0-city-by-city-name-session-by-id-kill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a message to a session */ + post: operations["send-session-message"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name session by ID pending */ + get: operations["get-v0-city-by-city-name-session-by-id-pending"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID rename */ + post: operations["post-v0-city-by-city-name-session-by-id-rename"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/respond": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Respond to a pending interaction */ + post: operations["respond-session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/stop": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID stop */ + post: operations["post-v0-city-by-city-name-session-by-id-stop"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream session output in real time + * @description Server-Sent Events stream of session transcript updates. Streams turns (conversation format) or raw messages (JSONL format) based on the format query parameter. Emits activity and pending events for tool approval prompts. + */ + get: operations["stream-session"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a message to a session */ + post: operations["submit-session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/suspend": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID suspend */ + post: operations["post-v0-city-by-city-name-session-by-id-suspend"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/transcript": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name session by ID transcript */ + get: operations["get-v0-city-by-city-name-session-by-id-transcript"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/session/{id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name session by ID wake */ + post: operations["post-v0-city-by-city-name-session-by-id-wake"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name sessions */ + get: operations["get-v0-city-by-city-name-sessions"]; + put?: never; + /** Create a session */ + post: operations["create-session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/sling": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name sling */ + post: operations["post-v0-city-by-city-name-sling"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name status */ + get: operations["get-v0-city-by-city-name-status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/unregister": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post v0 city by city name unregister */ + post: operations["post-v0-city-by-city-name-unregister"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/city/{cityName}/workflow/{workflow_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 city by city name workflow by workflow ID */ + get: operations["get-v0-city-by-city-name-workflow-by-workflow-id"]; + put?: never; + post?: never; + /** Delete v0 city by city name workflow by workflow ID */ + delete: operations["delete-v0-city-by-city-name-workflow-by-workflow-id"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 events */ + get: operations["get-v0-events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/events/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream tagged events from all running cities. */ + get: operations["stream-supervisor-events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/provider-readiness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 provider readiness */ + get: operations["get-v0-provider-readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v0/readiness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get v0 readiness */ + get: operations["get-v0-readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AdapterCapabilities: { + /** Format: int64 */ + MaxMessageLength: number; + SupportsAttachments: boolean; + SupportsChildConversations: boolean; + }; + AdapterEventPayload: { + account_id: string; + provider: string; + }; + AgentCreateInputBody: { + /** @description Working directory (rig name). */ + dir?: string; + /** + * @description Agent name. + * @example deacon-1 + */ + name: string; + /** + * @description Provider name. + * @example claude + */ + provider: string; + /** @description Agent scope. */ + scope?: string; + }; + AgentCreatedOutputBody: { + /** @description Created agent name. */ + agent: string; + /** + * @description Operation result. + * @example created + */ + status: string; + }; + AgentMapping: { + agent_id: string; + parent_tool_use_id: string; + }; + AgentOutputResponse: { + agent: string; + format: string; + pagination?: components["schemas"]["PaginationInfo"]; + turns: components["schemas"]["OutputTurn"][] | null; + }; + AgentPatch: { + AppendFragments: string[] | null; + Attach: boolean | null; + DefaultSlingFormula: string | null; + DependsOn: string[] | null; + Dir: string; + Env: { + [key: string]: string; + }; + EnvRemove: string[] | null; + HooksInstalled: boolean | null; + IdleTimeout: string | null; + InjectAssignedSkills: boolean | null; + InjectFragments: string[] | null; + InjectFragmentsAppend: string[] | null; + InstallAgentHooks: string[] | null; + InstallAgentHooksAppend: string[] | null; + MCP: string[] | null; + MCPAppend: string[] | null; + /** Format: int64 */ + MaxActiveSessions: number | null; + /** Format: int64 */ + MinActiveSessions: number | null; + Name: string; + Nudge: string | null; + OptionDefaults: { + [key: string]: string; + }; + OverlayDir: string | null; + Pool: components["schemas"]["PoolOverride"]; + PreStart: string[] | null; + PreStartAppend: string[] | null; + PromptTemplate: string | null; + Provider: string | null; + ResumeCommand: string | null; + ScaleCheck: string | null; + Scope: string | null; + Session: string | null; + SessionLive: string[] | null; + SessionLiveAppend: string[] | null; + SessionSetup: string[] | null; + SessionSetupAppend: string[] | null; + SessionSetupScript: string | null; + Skills: string[] | null; + SkillsAppend: string[] | null; + SleepAfterIdle: string | null; + StartCommand: string | null; + Suspended: boolean | null; + WakeMode: string | null; + WorkDir: string | null; + }; + AgentPatchSetInputBody: { + /** @description Agent directory scope. */ + dir?: string; + /** @description Override environment variables. */ + env?: { + [key: string]: string; + }; + /** @description Agent name. */ + name?: string; + /** @description Override agent scope. */ + scope?: string; + /** @description Override suspended state. */ + suspended?: boolean; + /** @description Override session working directory. */ + work_dir?: string; + }; + AgentResponse: { + active_bead?: string; + activity?: string; + available: boolean; + /** Format: int64 */ + context_pct?: number; + /** Format: int64 */ + context_window?: number; + description?: string; + display_name?: string; + last_output?: string; + model?: string; + name: string; + pool?: string; + provider?: string; + rig?: string; + running: boolean; + session?: components["schemas"]["SessionInfo"]; + state: string; + suspended: boolean; + unavailable_reason?: string; + }; + AgentUpdateInputBody: { + /** @description Provider name. */ + provider?: string; + /** @description Agent scope. */ + scope?: string; + /** @description Whether agent is suspended. */ + suspended?: boolean; + }; + AgentUpdateQualifiedInputBody: { + /** @description Provider name. */ + provider?: string; + /** @description Agent scope. */ + scope?: string; + /** @description Whether agent is suspended. */ + suspended?: boolean; + }; + AnnotatedAgentResponse: { + dir?: string; + is_pool?: boolean; + name: string; + /** @description Agent origin: inline or pack-derived. */ + origin: string; + provider?: string; + scope?: string; + suspended: boolean; + }; + AnnotatedProviderResponse: { + acp_args?: string[]; + acp_command?: string; + args?: string[] | null; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + /** @description Provider origin: builtin, city, or builtin+city. */ + origin: string; + prompt_flag?: string; + prompt_mode?: string; + /** Format: int64 */ + ready_delay_ms?: number; + }; + Bead: { + assignee?: string; + /** Format: date-time */ + created_at: string; + dependencies?: components["schemas"]["Dep"][] | null; + description?: string; + from?: string; + id: string; + issue_type: string; + labels?: string[] | null; + metadata?: { + [key: string]: string; + }; + needs?: string[] | null; + parent?: string; + /** Format: int64 */ + priority?: number; + ref?: string; + status: string; + title: string; + }; + BeadAssignInputBody: { + /** @description Assignee name. */ + assignee?: string; + }; + BeadCreateInputBody: { + /** @description Assigned agent. */ + assignee?: string; + /** @description Bead description. */ + description?: string; + /** @description Bead labels. */ + labels?: string[] | null; + /** @description Metadata key-value pairs to set at create time. */ + metadata?: { + [key: string]: string; + }; + /** @description Parent bead ID. */ + parent?: string; + /** + * Format: int64 + * @description Bead priority. + */ + priority?: number; + /** @description Rig name. */ + rig?: string; + /** @description Bead title. */ + title: string; + /** @description Bead type. */ + type?: string; + }; + BeadDepsResponse: { + children: components["schemas"]["Bead"][] | null; + }; + BeadEventPayload: { + bead: components["schemas"]["Bead"]; + }; + BeadGraphResponse: { + beads: components["schemas"]["Bead"][] | null; + deps: components["schemas"]["WorkflowDepResponse"][] | null; + root: components["schemas"]["Bead"]; + }; + BeadUpdateBody: { + /** @description Assigned agent. */ + assignee?: string; + /** @description Bead description. */ + description?: string; + /** @description Bead labels. */ + labels?: string[] | null; + /** @description Metadata key-value pairs to set. */ + metadata?: { + [key: string]: string; + }; + /** @description Parent bead ID. Use null or an empty string to clear. */ + parent?: string | null; + /** + * Format: int64 + * @description Bead priority. + */ + priority?: number; + /** @description Labels to remove. */ + remove_labels?: string[] | null; + /** @description Bead status. */ + status?: string; + /** @description Bead title. */ + title?: string; + /** @description Bead type. */ + type?: string; + }; + /** + * @description Lifecycle state of a session binding. + * @enum {string} + */ + BindingStatus: "active" | "ended"; + BoundEventPayload: { + conversation_id: string; + provider: string; + session_id: string; + }; + CityCreateRequest: { + /** + * @description Optional bootstrap profile. + * @enum {string} + */ + bootstrap_profile?: "k8s-cell" | "kubernetes" | "kubernetes-cell" | "single-host-compat"; + /** @description Directory to create the city in. Absolute or relative to $HOME. */ + dir: string; + /** @description Provider name for the city's default session template. */ + provider: string; + }; + CityCreateResponse: { + /** @description Resolved city name as persisted in city.toml. Use this to filter the event stream for completion. */ + name: string; + /** @description True when scaffolding + registration succeeded. Does not imply the city is ready yet; watch /v0/events/stream for city.ready. */ + ok: boolean; + /** @description Resolved absolute path of the created city directory. */ + path: string; + }; + CityGetResponse: { + /** Format: int64 */ + agent_count: number; + name: string; + path: string; + provider?: string; + /** Format: int64 */ + rig_count: number; + session_template?: string; + suspended: boolean; + /** Format: int64 */ + uptime_sec: number; + version?: string; + }; + CityInfo: { + error?: string; + name: string; + path: string; + phases_completed?: string[] | null; + running: boolean; + status?: string; + }; + CityLifecyclePayload: { + error?: string; + name: string; + path: string; + phases_completed?: string[] | null; + }; + CityPatchInputBody: { + /** @description Whether the city is suspended. */ + suspended?: boolean; + }; + CityUnregisterResponse: { + /** @description Resolved registry name. Filter the event stream by this to observe completion. */ + name: string; + /** @description True when the registry entry was removed and the supervisor was signaled. Does not imply the city's controller has stopped yet; watch /v0/events/stream for city.unregistered. */ + ok: boolean; + /** @description Resolved absolute city directory. The directory itself is not modified; unregister only affects the supervisor's registry. */ + path: string; + }; + ConfigAgentResponse: { + dir?: string; + is_pool?: boolean; + name: string; + provider?: string; + scope?: string; + suspended: boolean; + }; + ConfigExplainPatches: { + /** Format: int64 */ + agents: number; + /** Format: int64 */ + providers: number; + /** Format: int64 */ + rigs: number; + }; + ConfigExplainResponse: { + agents: components["schemas"]["AnnotatedAgentResponse"][] | null; + patches: components["schemas"]["ConfigExplainPatches"]; + providers: { + [key: string]: components["schemas"]["AnnotatedProviderResponse"]; + }; + }; + ConfigPatchesResponse: { + /** Format: int64 */ + agent_count: number; + /** Format: int64 */ + provider_count: number; + /** Format: int64 */ + rig_count: number; + }; + ConfigResponse: { + agents: components["schemas"]["ConfigAgentResponse"][] | null; + patches?: components["schemas"]["ConfigPatchesResponse"]; + providers?: { + [key: string]: components["schemas"]["ProviderSpecJSON"]; + }; + rigs: components["schemas"]["ConfigRigResponse"][] | null; + workspace: components["schemas"]["WorkspaceResponse"]; + }; + ConfigRigResponse: { + name: string; + path: string; + prefix?: string; + suspended: boolean; + }; + ConfigValidateOutputBody: { + /** @description Validation errors. */ + errors: string[] | null; + /** @description Whether the configuration is valid. */ + valid: boolean; + /** @description Validation warnings. */ + warnings: string[] | null; + }; + ConversationGroupParticipant: { + GroupID: string; + Handle: string; + ID: string; + Metadata: { + [key: string]: string; + }; + Public: boolean; + SessionID: string; + }; + ConversationGroupRecord: { + DefaultHandle: string; + FanoutPolicy: components["schemas"]["FanoutPolicy"]; + ID: string; + LastAddressedHandle: string; + Metadata: { + [key: string]: string; + }; + Mode: string; + RootConversation: components["schemas"]["ConversationRef"]; + /** Format: int64 */ + SchemaVersion: number; + }; + /** + * @description Shape of a conversation. + * @enum {string} + */ + ConversationKind: "dm" | "room" | "thread"; + ConversationRef: { + account_id: string; + conversation_id: string; + kind: components["schemas"]["ConversationKind"]; + parent_conversation_id?: string; + provider: string; + scope_id: string; + }; + ConversationTranscriptRecord: { + Actor: components["schemas"]["ExternalActor"]; + Attachments: components["schemas"]["ExternalAttachment"][] | null; + Conversation: components["schemas"]["ConversationRef"]; + /** Format: date-time */ + CreatedAt: string; + ExplicitTarget: string; + ID: string; + Kind: components["schemas"]["TranscriptMessageKind"]; + Metadata: { + [key: string]: string; + }; + Provenance: components["schemas"]["TranscriptProvenance"]; + ProviderMessageID: string; + ReplyToMessageID: string; + /** Format: int64 */ + SchemaVersion: number; + /** Format: int64 */ + Sequence: number; + SourceSessionID: string; + Text: string; + }; + ConvoyAddInputBody: { + /** @description Bead IDs to add. */ + items?: string[] | null; + }; + ConvoyCheckResponse: { + /** + * Format: int64 + * @description Closed child bead count. + */ + closed: number; + /** @description True when all child beads are closed and total > 0. */ + complete: boolean; + /** @description Convoy ID. */ + convoy_id: string; + /** + * Format: int64 + * @description Total child bead count. + */ + total: number; + }; + ConvoyCreateInputBody: { + /** @description Bead IDs to include. */ + items?: string[] | null; + /** @description Rig name. */ + rig?: string; + /** @description Convoy title. */ + title: string; + }; + ConvoyGetResponse: { + /** @description Direct child beads (non-workflow case). */ + children?: components["schemas"]["Bead"][] | null; + /** @description Simple convoy bead (non-workflow case). */ + convoy?: components["schemas"]["Bead"]; + /** @description Child bead progress (non-workflow case). */ + progress?: components["schemas"]["ConvoyProgress"]; + }; + ConvoyProgress: { + /** + * Format: int64 + * @description Closed child bead count. + */ + closed: number; + /** + * Format: int64 + * @description Total child bead count. + */ + total: number; + }; + ConvoyRemoveInputBody: { + /** @description Bead IDs to remove. */ + items?: string[] | null; + }; + DeliveryContextRecord: { + /** Format: int64 */ + BindingGeneration: number; + Conversation: components["schemas"]["ConversationRef"]; + ID: string; + LastMessageID: string; + /** Format: date-time */ + LastPublishedAt: string; + Metadata: { + [key: string]: string; + }; + /** Format: int64 */ + SchemaVersion: number; + SessionID: string; + SourceSessionID: string; + }; + Dep: { + depends_on_id: string; + issue_id: string; + type: string; + }; + ErrorDetail: { + /** @description Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' */ + location?: string; + /** @description Error message text */ + message?: string; + /** @description The value at the given location */ + value?: unknown; + }; + ErrorModel: { + /** + * @description A human-readable explanation specific to this occurrence of the problem. + * @example Property foo is required but is missing. + */ + detail?: string; + /** @description Optional list of individual error details */ + errors?: components["schemas"]["ErrorDetail"][] | null; + /** + * Format: uri + * @description A URI reference that identifies the specific occurrence of the problem. + * @example https://example.com/error-log/abc123 + */ + instance?: string; + /** + * Format: int64 + * @description HTTP status code + * @example 400 + */ + status?: number; + /** + * @description A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + * @example Bad Request + */ + title?: string; + /** + * Format: uri + * @description A URI reference to human-readable documentation for the error. + * @default about:blank + * @example https://example.com/errors/example + * @example urn:gascity:error:sling-missing-bead + * @example urn:gascity:error:sling-cross-rig + */ + type: string; + }; + EventEmitOutputBody: { + /** + * @description Operation result. + * @example recorded + */ + status: string; + }; + EventEmitRequest: { + /** @description Actor that produced the event. */ + actor: string; + /** @description Event message. */ + message?: string; + /** @description Event subject. */ + subject?: string; + /** @description Event type. */ + type: string; + }; + EventPayload: components["schemas"]["AdapterEventPayload"] | components["schemas"]["BeadEventPayload"] | components["schemas"]["BoundEventPayload"] | components["schemas"]["CityLifecyclePayload"] | components["schemas"]["GroupCreatedEventPayload"] | components["schemas"]["InboundEventPayload"] | components["schemas"]["MailEventPayload"] | components["schemas"]["NoPayload"] | components["schemas"]["OutboundEventPayload"] | components["schemas"]["UnboundEventPayload"] | components["schemas"]["WorkerOperationEventPayload"]; + EventStreamEnvelope: { + actor: string; + message?: string; + payload?: components["schemas"]["EventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + type: string; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + ExtMsgAdapterRegisterInputBody: { + /** @description Account ID. */ + account_id: string; + /** @description Callback URL for outbound messages. */ + callback_url?: string; + /** @description Adapter capabilities. */ + capabilities?: components["schemas"]["AdapterCapabilities"]; + /** @description Adapter display name. */ + name?: string; + /** @description Provider name. */ + provider: string; + }; + ExtMsgAdapterRegisterOutputBody: { + /** @description Account ID. */ + account_id: string; + /** @description Adapter name. */ + name: string; + /** @description Provider name. */ + provider: string; + /** + * @description Operation result. + * @example registered + */ + status: string; + }; + ExtMsgAdapterUnregisterInputBody: { + /** @description Account ID. */ + account_id: string; + /** @description Provider name. */ + provider: string; + }; + ExtMsgBindInputBody: { + /** @description Conversation to bind. */ + conversation?: components["schemas"]["ConversationRef"]; + /** @description Optional binding metadata. */ + metadata?: { + [key: string]: string; + }; + /** @description Session ID to bind. */ + session_id: string; + }; + ExtMsgGroupEnsureInputBody: { + /** @description Default handle for the group. */ + default_handle?: string; + /** @description Group metadata. */ + metadata?: { + [key: string]: string; + }; + /** @description Group mode (launcher, etc.). */ + mode?: string; + /** @description Root conversation reference. */ + root_conversation?: components["schemas"]["ConversationRef"]; + }; + ExtMsgInboundInputBody: { + /** @description Account ID for raw payloads (required when message is absent). */ + account_id?: string; + /** @description Pre-normalized inbound message. */ + message?: components["schemas"]["ExternalInboundMessage"]; + /** @description Raw payload bytes. */ + payload?: string; + /** @description Provider name for raw payloads (required when message is absent). */ + provider?: string; + }; + ExtMsgOutboundInputBody: { + /** @description Target conversation. */ + conversation?: components["schemas"]["ConversationRef"]; + /** @description Idempotency key. */ + idempotency_key?: string; + /** @description Message ID to reply to. */ + reply_to_message_id?: string; + /** @description Session ID. */ + session_id: string; + /** @description Message text. */ + text?: string; + }; + ExtMsgParticipantRemoveInputBody: { + /** @description Group ID. */ + group_id: string; + /** @description Participant handle. */ + handle: string; + }; + ExtMsgParticipantUpsertInputBody: { + /** @description Group ID. */ + group_id: string; + /** @description Participant handle. */ + handle: string; + /** @description Participant metadata. */ + metadata?: { + [key: string]: string; + }; + /** @description Whether participant is public. */ + public?: boolean; + /** @description Session ID. */ + session_id: string; + }; + ExtMsgTranscriptAckInputBody: { + /** @description Conversation to acknowledge. */ + conversation?: components["schemas"]["ConversationRef"]; + /** + * Format: int64 + * @description Sequence number to acknowledge up to. + */ + sequence?: number; + /** @description Session ID. */ + session_id: string; + }; + ExtMsgUnbindBody: { + /** @description Bindings that were removed. */ + unbound: components["schemas"]["SessionBindingRecord"][] | null; + }; + ExtMsgUnbindInputBody: { + /** @description Conversation to unbind (nil = all). */ + conversation?: components["schemas"]["ConversationRef"]; + /** @description Session ID to unbind. */ + session_id: string; + }; + ExternalActor: { + display_name: string; + id: string; + is_bot: boolean; + }; + ExternalAttachment: { + mime_type: string; + provider_id: string; + url: string; + }; + ExternalInboundMessage: { + actor: components["schemas"]["ExternalActor"]; + attachments?: components["schemas"]["ExternalAttachment"][] | null; + conversation: components["schemas"]["ConversationRef"]; + dedup_key?: string; + explicit_target?: string; + provider_message_id: string; + /** Format: date-time */ + received_at: string; + reply_to_message_id?: string; + text: string; + }; + ExtmsgAdapterInfo: { + /** @description Adapter account ID. */ + account_id: string; + /** @description Adapter display name. */ + name: string; + /** @description Adapter provider key. */ + provider: string; + }; + FanoutPolicy: { + AllowUntargetedPublication: boolean; + Enabled: boolean; + /** Format: int64 */ + MaxPeerTriggeredPublishes: number; + /** Format: int64 */ + MaxTotalPeerDeliveries: number; + }; + FormulaDetailResponse: { + deps: components["schemas"]["FormulaPreviewEdgeResponse"][] | null; + description: string; + name: string; + preview: components["schemas"]["FormulaPreviewResponse"]; + steps: components["schemas"]["FormulaStepResponse"][] | null; + var_defs: components["schemas"]["FormulaVarDefResponse"][] | null; + version: string; + }; + FormulaFeedBody: { + items: components["schemas"]["MonitorFeedItemResponse"][] | null; + partial: boolean; + partial_errors?: string[] | null; + }; + FormulaListBody: { + /** @description Formula summaries. */ + items: components["schemas"]["FormulaSummaryResponse"][] | null; + /** @description Whether the list is partial. */ + partial: boolean; + }; + FormulaPreviewBody: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Target agent for preview compilation. */ + target: string; + /** @description Variable name-to-value overrides applied to the compiled preview. */ + vars?: { + [key: string]: string; + }; + }; + FormulaPreviewEdgeResponse: { + from: string; + kind?: string; + to: string; + }; + FormulaPreviewNodeResponse: { + id: string; + kind: string; + scope_ref?: string; + title: string; + }; + FormulaPreviewResponse: { + edges: components["schemas"]["FormulaPreviewEdgeResponse"][] | null; + nodes: components["schemas"]["FormulaPreviewNodeResponse"][] | null; + }; + FormulaRecentRunResponse: { + started_at: string; + status: string; + target: string; + updated_at: string; + workflow_id: string; + }; + FormulaRunsResponse: { + formula: string; + partial: boolean; + partial_errors?: string[] | null; + recent_runs: components["schemas"]["FormulaRecentRunResponse"][] | null; + /** Format: int64 */ + run_count: number; + }; + FormulaStepResponse: { + assignee?: string; + id: string; + kind: string; + labels?: string[] | null; + metadata?: { + [key: string]: string; + }; + title: string; + type?: string; + }; + FormulaSummaryResponse: { + description: string; + name: string; + recent_runs: components["schemas"]["FormulaRecentRunResponse"][] | null; + /** Format: int64 */ + run_count: number; + var_defs: components["schemas"]["FormulaVarDefResponse"][] | null; + version: string; + }; + FormulaVarDefResponse: { + default?: unknown; + description?: string; + enum?: string[] | null; + name: string; + pattern?: string; + required?: boolean; + type: string; + }; + GitStatus: { + /** Format: int64 */ + ahead: number; + /** Format: int64 */ + behind: number; + branch: string; + /** Format: int64 */ + changed_files: number; + clean: boolean; + }; + GroupCreatedEventPayload: { + conversation_id: string; + mode: string; + provider: string; + }; + GroupRouteDecision: { + Match: string; + TargetSessionID: string; + UpdateCursor: boolean; + }; + HealthOutputBody: { + /** @description City name. */ + city?: string; + /** + * @description Health status. + * @example ok + */ + status: string; + /** + * Format: int64 + * @description Server uptime in seconds. + */ + uptime_sec: number; + /** @description Server version. */ + version?: string; + }; + HeartbeatEvent: { + /** @description ISO 8601 timestamp when the heartbeat was sent. */ + timestamp: string; + }; + InboundEventPayload: { + actor: string; + conversation_id: string; + provider: string; + target_session: string; + }; + InboundResult: { + Binding: components["schemas"]["SessionBindingRecord"]; + GroupRoute: components["schemas"]["GroupRouteDecision"]; + Message: components["schemas"]["ExternalInboundMessage"]; + TargetSessionID: string; + TranscriptEntry: components["schemas"]["ConversationTranscriptRecord"]; + }; + ListBodyAgentPatch: { + /** @description The list of items. */ + items: components["schemas"]["AgentPatch"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyAgentResponse: { + /** @description The list of items. */ + items: components["schemas"]["AgentResponse"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyBead: { + /** @description The list of items. */ + items: components["schemas"]["Bead"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyConversationTranscriptRecord: { + /** @description The list of items. */ + items: components["schemas"]["ConversationTranscriptRecord"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyExtmsgAdapterInfo: { + /** @description The list of items. */ + items: components["schemas"]["ExtmsgAdapterInfo"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyProviderPatch: { + /** @description The list of items. */ + items: components["schemas"]["ProviderPatch"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyProviderResponse: { + /** @description The list of items. */ + items: components["schemas"]["ProviderResponse"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyRigPatch: { + /** @description The list of items. */ + items: components["schemas"]["RigPatch"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyRigResponse: { + /** @description The list of items. */ + items: components["schemas"]["RigResponse"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodySessionBindingRecord: { + /** @description The list of items. */ + items: components["schemas"]["SessionBindingRecord"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodySessionResponse: { + /** @description The list of items. */ + items: components["schemas"]["SessionResponse"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyStatus: { + /** @description The list of items. */ + items: components["schemas"]["Status"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + ListBodyWireEvent: { + /** @description The list of items. */ + items: components["schemas"]["WireEvent"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more backends failed and the list is incomplete. */ + partial?: boolean; + /** @description Human-readable errors from backends that failed during aggregation. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of items matching the query. + */ + total: number; + }; + LogicalNode: Record; + MailCountOutputBody: { + /** @description True when one or more rig providers failed and the counts are not authoritative. */ + partial?: boolean; + /** @description Per-provider errors when partial is true. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total message count. + */ + total: number; + /** + * Format: int64 + * @description Unread message count. + */ + unread: number; + }; + MailEventPayload: { + message?: components["schemas"]["Message"]; + rig: string; + }; + MailListBody: { + /** @description The list of messages. */ + items: components["schemas"]["Message"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** @description True when one or more rig providers failed and the list is not authoritative. */ + partial?: boolean; + /** @description Per-provider errors when partial is true. */ + partial_errors?: string[] | null; + /** + * Format: int64 + * @description Total number of messages matching the query. + */ + total: number; + }; + MailReplyInputBody: { + /** @description Reply body. */ + body?: string; + /** @description Sender name. */ + from?: string; + /** @description Reply subject. */ + subject?: string; + }; + MailSendInputBody: { + /** @description Message body. */ + body?: string; + /** @description Sender name. */ + from?: string; + /** @description Rig name. */ + rig?: string; + /** @description Message subject. */ + subject: string; + /** @description Recipient name. */ + to: string; + }; + Message: { + body: string; + cc?: string[] | null; + /** Format: date-time */ + created_at: string; + from: string; + id: string; + /** Format: int64 */ + priority?: number; + read: boolean; + reply_to?: string; + rig?: string; + subject: string; + thread_id?: string; + to: string; + }; + MonitorFeedItemResponse: { + attached_bead_id?: string; + bead_id?: string; + detail_available?: boolean; + id: string; + logical_bead_id?: string; + root_bead_id?: string; + root_store_ref?: string; + run_detail_available?: boolean; + scope_kind: string; + scope_ref: string; + started_at: string; + status: string; + store_ref?: string; + target: string; + title: string; + type: string; + updated_at: string; + workflow_id?: string; + }; + NoPayload: Record; + OKResponseBody: { + /** + * @description Operation result. + * @example ok + */ + status: string; + }; + OKWithIDResponseBody: { + /** @description Resource ID. */ + id?: string; + /** + * @description Operation result. + * @example ok + */ + status: string; + }; + OptionChoiceDTO: { + label: string; + value: string; + }; + OrderCheckListBody: { + /** @description Order trigger evaluations. */ + checks: components["schemas"]["OrderCheckResponse"][] | null; + }; + OrderCheckResponse: { + due: boolean; + last_run?: string; + last_run_outcome?: string; + name: string; + reason: string; + rig?: string; + scoped_name: string; + }; + OrderHistoryDetailResponse: { + bead_id: string; + created_at: string; + labels: string[] | null; + output: string; + store_ref: string; + }; + OrderHistoryEntry: { + bead_id: string; + capture_output: boolean; + created_at: string; + duration_ms?: string; + error?: string; + exit_code?: string; + has_output: boolean; + labels: string[] | null; + name: string; + rig?: string; + scoped_name: string; + signal?: string; + store_ref: string; + wisp_root_id?: string; + }; + OrderHistoryListBody: { + /** @description Order history entries. */ + entries: components["schemas"]["OrderHistoryEntry"][] | null; + }; + OrderListBody: { + /** @description Registered orders. */ + orders: components["schemas"]["OrderResponse"][] | null; + }; + OrderResponse: { + capture_output: boolean; + check?: string; + description?: string; + enabled: boolean; + exec?: string; + formula?: string; + /** @deprecated */ + gate?: string; + interval?: string; + name: string; + on?: string; + pool?: string; + rig?: string; + schedule?: string; + scoped_name: string; + timeout?: string; + /** Format: int64 */ + timeout_ms: number; + trigger?: string; + type: string; + }; + OrdersFeedBody: { + items: components["schemas"]["MonitorFeedItemResponse"][] | null; + partial: boolean; + partial_errors?: string[] | null; + }; + OutboundEventPayload: { + conversation_id: string; + message_id: string; + provider: string; + session: string; + }; + OutboundResult: { + DeliveryContext: components["schemas"]["DeliveryContextRecord"]; + Receipt: components["schemas"]["PublishReceipt"]; + TranscriptEntry: components["schemas"]["ConversationTranscriptRecord"]; + }; + OutputTurn: { + role: string; + text: string; + timestamp?: string; + }; + PackListBody: { + /** @description Registered packs. */ + packs: components["schemas"]["PackResponse"][] | null; + }; + PackResponse: { + name: string; + path?: string; + ref?: string; + source?: string; + }; + PaginationInfo: { + has_older_messages: boolean; + /** Format: int64 */ + returned_message_count: number; + /** Format: int64 */ + total_compactions: number; + /** Format: int64 */ + total_message_count: number; + truncated_before_message?: string; + }; + PatchDeletedResponseBody: { + /** @description Agent patch qualified name. */ + agent_patch?: string; + /** @description Provider patch name. */ + provider_patch?: string; + /** @description Rig patch name. */ + rig_patch?: string; + /** + * @description Operation result. + * @example deleted + */ + status: string; + }; + PatchOKResponseBody: { + /** @description Agent patch qualified name. */ + agent_patch?: string; + /** @description Provider patch name. */ + provider_patch?: string; + /** @description Rig patch name. */ + rig_patch?: string; + /** + * @description Operation result. + * @example ok + */ + status: string; + }; + PendingInteraction: { + kind: string; + metadata?: { + [key: string]: string; + }; + options?: string[] | null; + prompt?: string; + request_id: string; + }; + PoolOverride: { + Check: string | null; + DrainTimeout: string | null; + /** Format: int64 */ + Max: number | null; + /** Format: int64 */ + Min: number | null; + OnBoot: string | null; + OnDeath: string | null; + }; + ProviderCreateInputBody: { + /** @description ACP transport command arguments override. */ + acp_args?: string[] | null; + /** @description ACP transport command binary override. */ + acp_command?: string; + /** @description Command arguments. */ + args?: string[] | null; + /** @description Arguments appended after inherited/base args. */ + args_append?: string[] | null; + /** @description Optional provider base for inheritance. */ + base?: string; + /** @description Provider command binary. Omit for base-only descendants. */ + command?: string; + /** @description Human-readable display name. */ + display_name?: string; + /** @description Environment variables. */ + env?: { + [key: string]: string; + }; + /** @description Provider name. */ + name: string; + /** @description Options schema merge mode across inheritance chain. */ + options_schema_merge?: string; + /** @description Flag for prompt delivery. */ + prompt_flag?: string; + /** @description Prompt delivery mode. */ + prompt_mode?: string; + /** + * Format: int64 + * @description Milliseconds to wait before probing readiness. + */ + ready_delay_ms?: number; + }; + ProviderCreatedOutputBody: { + /** @description Created provider name. */ + provider: string; + /** + * @description Operation result. + * @example created + */ + status: string; + }; + ProviderOptionDTO: { + choices: components["schemas"]["OptionChoiceDTO"][] | null; + default: string; + key: string; + label: string; + type: string; + }; + ProviderPatch: { + ACPArgs: string[] | null; + ACPCommand: string | null; + Args: string[] | null; + ArgsAppend: string[] | null; + Base: string | null; + Command: string | null; + Env: { + [key: string]: string; + }; + EnvRemove: string[] | null; + Name: string; + OptionsSchemaMerge: string | null; + PromptFlag: string | null; + PromptMode: string | null; + /** Format: int64 */ + ReadyDelayMs: number | null; + Replace: boolean; + }; + ProviderPatchSetInputBody: { + /** @description Override ACP transport command arguments. */ + acp_args?: string[] | null; + /** @description Override ACP transport command binary. */ + acp_command?: string; + /** @description Override command arguments. */ + args?: string[] | null; + /** @description Override command binary. */ + command?: string; + /** @description Override environment variables. */ + env?: { + [key: string]: string; + }; + /** @description Provider name. */ + name?: string; + /** @description Override prompt flag. */ + prompt_flag?: string; + /** @description Override prompt delivery mode. */ + prompt_mode?: string; + /** + * Format: int64 + * @description Override ready delay in milliseconds. + */ + ready_delay_ms?: number; + }; + ProviderPublicListBody: { + /** @description The list of browser-safe provider summaries. */ + items: components["schemas"]["ProviderPublicResponse"][] | null; + /** @description Cursor for the next page of results. */ + next_cursor?: string; + /** + * Format: int64 + * @description Total number of providers in the list. + */ + total: number; + }; + ProviderPublicResponse: { + builtin: boolean; + city_level: boolean; + display_name?: string; + effective_defaults?: { + [key: string]: string; + }; + name: string; + options_schema?: components["schemas"]["ProviderOptionDTO"][] | null; + }; + ProviderReadiness: { + detail?: string; + display_name: string; + status: string; + }; + ProviderReadinessResponse: { + providers: { + [key: string]: components["schemas"]["ProviderReadiness"]; + }; + }; + ProviderResponse: { + acp_args?: string[]; + acp_command?: string; + args?: string[] | null; + builtin: boolean; + city_level: boolean; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + name: string; + prompt_flag?: string; + prompt_mode?: string; + /** Format: int64 */ + ready_delay_ms?: number; + }; + ProviderSpecJSON: { + acp_args?: string[]; + acp_command?: string; + args?: string[] | null; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + prompt_flag?: string; + prompt_mode?: string; + /** Format: int64 */ + ready_delay_ms?: number; + }; + ProviderUpdateInputBody: { + /** @description ACP transport command arguments override. */ + acp_args?: string[] | null; + /** @description ACP transport command binary override. */ + acp_command?: string; + /** @description Command arguments. */ + args?: string[] | null; + /** @description Arguments appended after inherited/base args. */ + args_append?: string[] | null; + /** @description Provider base for inheritance. */ + base?: string; + /** @description Provider command binary. */ + command?: string; + /** @description Human-readable display name. */ + display_name?: string; + /** @description Environment variables. */ + env?: { + [key: string]: string; + }; + /** @description Options schema merge mode across inheritance chain. */ + options_schema_merge?: string; + /** @description Flag for prompt delivery. */ + prompt_flag?: string; + /** @description Prompt delivery mode. */ + prompt_mode?: string; + /** + * Format: int64 + * @description Milliseconds to wait before probing readiness. + */ + ready_delay_ms?: number; + }; + PublishReceipt: { + Conversation: components["schemas"]["ConversationRef"]; + Delivered: boolean; + FailureKind: string; + MessageID: string; + Metadata: { + [key: string]: string; + }; + /** Format: int64 */ + RetryAfter: number; + }; + ReadinessItem: { + detail?: string; + display_name: string; + kind: string; + name: string; + status: string; + }; + ReadinessResponse: { + items: { + [key: string]: components["schemas"]["ReadinessItem"]; + }; + }; + RigActionBody: { + /** @description Action that was performed. */ + action: string; + /** @description Agents that failed to stop (restart only). */ + failed?: string[] | null; + /** @description Agents that were killed (restart only). */ + killed?: string[] | null; + /** @description Rig name. */ + rig: string; + /** + * @description Operation result (ok, partial, failed). + * @example ok + */ + status: string; + }; + RigCreateInputBody: { + /** @description Rig name. */ + name: string; + /** @description Filesystem path. */ + path: string; + /** @description Session name prefix. */ + prefix?: string; + }; + RigCreatedOutputBody: { + /** @description Created rig name. */ + rig: string; + /** + * @description Operation result. + * @example created + */ + status: string; + }; + RigPatch: { + Name: string; + Path: string | null; + Prefix: string | null; + Suspended: boolean | null; + }; + RigPatchSetInputBody: { + /** @description Rig name. */ + name?: string; + /** @description Override filesystem path. */ + path?: string; + /** @description Override bead ID prefix. */ + prefix?: string; + /** @description Override suspended state. */ + suspended?: boolean; + }; + RigResponse: { + /** Format: int64 */ + agent_count: number; + git?: components["schemas"]["GitStatus"]; + /** Format: date-time */ + last_activity?: string; + name: string; + path: string; + prefix?: string; + /** Format: int64 */ + running_count: number; + suspended: boolean; + }; + RigUpdateInputBody: { + /** @description Filesystem path. */ + path?: string; + /** @description Session name prefix. */ + prefix?: string; + /** @description Whether rig is suspended. */ + suspended?: boolean; + }; + ScopeGroup: Record; + ServiceRestartOutputBody: { + /** + * @description Action performed. + * @example restart + */ + action: string; + /** @description Service name. */ + service: string; + /** + * @description Operation result. + * @example ok + */ + status: string; + }; + SessionActivityEvent: { + /** + * @description Session activity state: 'idle' or 'in-turn'. + * @example idle + */ + activity: string; + }; + SessionAgentGetResponse: { + messages: unknown[] | null; + status?: string; + }; + SessionAgentListResponse: { + agents: components["schemas"]["AgentMapping"][] | null; + }; + SessionBindingRecord: { + /** Format: int64 */ + BindingGeneration: number; + /** Format: date-time */ + BoundAt: string; + Conversation: components["schemas"]["ConversationRef"]; + /** Format: date-time */ + ExpiresAt: string | null; + ID: string; + Metadata: { + [key: string]: string; + }; + /** Format: int64 */ + SchemaVersion: number; + SessionID: string; + Status: components["schemas"]["BindingStatus"]; + }; + SessionCreateBody: { + /** @description Optional session alias. */ + alias?: string; + /** @description Create session asynchronously (agent only). */ + async?: boolean; + /** @description Session target kind: agent or provider. */ + kind?: string; + /** @description Initial message to send to the session. */ + message?: string; + /** @description Agent or provider name. */ + name?: string; + /** @description Provider/agent option overrides. */ + options?: { + [key: string]: string; + }; + /** @description Opaque project context identifier. */ + project_id?: string; + /** @description Deprecated: use alias. */ + session_name?: string; + /** @description Session title. */ + title?: string; + }; + SessionInfo: { + attached: boolean; + /** Format: date-time */ + last_activity?: string; + name: string; + }; + SessionMessageInputBody: { + /** @description Message text to send. */ + message: string; + }; + SessionMessageOutputBody: { + /** @description Session ID. */ + id: string; + /** + * @description Operation result. + * @example accepted + */ + status: string; + }; + SessionPatchBody: { + /** @description Session alias. Empty string clears the alias. */ + alias?: string; + /** @description Session title. If provided, must be non-empty. */ + title?: string; + }; + SessionPendingResponse: { + pending?: components["schemas"]["PendingInteraction"]; + supported: boolean; + }; + /** + * Session raw transcript frame + * @description Provider-native transcript frame. Gas City forwards the exact JSON the provider wrote to its session log, so the shape is provider-specific and can be any JSON value. The producing provider is identified by the Provider field on the enclosing envelope; consumers dispatch per-provider frame parsing keyed by that identifier. + */ + SessionRawMessageFrame: unknown; + SessionRenameInputBody: { + /** @description New session title. */ + title: string; + }; + SessionRespondInputBody: { + /** @description Response action (e.g. allow, deny). */ + action: string; + /** @description Optional response metadata. */ + metadata?: { + [key: string]: string; + }; + /** @description Pending interaction request ID (optional). */ + request_id?: string; + /** @description Optional response text. */ + text?: string; + }; + SessionRespondOutputBody: { + /** @description Session ID. */ + id: string; + /** + * @description Operation result. + * @example accepted + */ + status: string; + }; + SessionResponse: { + active_bead?: string; + activity?: string; + alias?: string; + attached: boolean; + configured_named_session?: boolean; + /** Format: int64 */ + context_pct?: number; + /** Format: int64 */ + context_window?: number; + created_at: string; + display_name?: string; + id: string; + kind?: string; + last_active?: string; + last_output?: string; + metadata?: { + [key: string]: string; + }; + model?: string; + options?: { + [key: string]: string; + }; + pool?: string; + provider: string; + reason?: string; + rig?: string; + running: boolean; + session_name: string; + state: string; + submission_capabilities?: components["schemas"]["SubmissionCapabilities"]; + template: string; + title: string; + }; + /** + * Session stream lifecycle event + * @description Non-message events emitted on the session SSE stream: activity transitions, pending interactions, and keepalive heartbeats. The concrete variant is identified by the SSE event name. + */ + SessionStreamCommonEvent: components["schemas"]["SessionActivityEvent"] | components["schemas"]["PendingInteraction"] | components["schemas"]["HeartbeatEvent"]; + SessionStreamMessageEvent: { + format: string; + id: string; + pagination?: components["schemas"]["PaginationInfo"]; + /** @description Producing provider identifier (claude, codex, gemini, open-code, etc.). */ + provider: string; + template: string; + turns: components["schemas"]["OutputTurn"][] | null; + }; + SessionStreamRawMessageEvent: { + format: string; + id: string; + /** @description Provider-native transcript frames, emitted verbatim as the provider wrote them. */ + messages: components["schemas"]["SessionRawMessageFrame"][] | null; + pagination?: components["schemas"]["PaginationInfo"]; + /** @description Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing. */ + provider: string; + template: string; + }; + SessionSubmitInputBody: { + /** + * @description Submit intent; empty defaults to "default". + * @enum {unknown} + */ + intent?: components["schemas"]["SubmitIntent"]; + /** @description Message text to submit. */ + message: string; + }; + SessionSubmitOutputBody: { + /** @description Session ID. */ + id: string; + /** @description Resolved submit intent. */ + intent: string; + /** @description Whether the message was queued. */ + queued: boolean; + /** + * @description Operation result. + * @example accepted + */ + status: string; + }; + SessionTranscriptGetResponse: { + /** @description conversation, text, or raw. */ + format: string; + id: string; + /** @description Populated for raw format; provider-native frames emitted verbatim as the provider wrote them. */ + messages?: components["schemas"]["SessionRawMessageFrame"][] | null; + pagination?: components["schemas"]["PaginationInfo"]; + /** @description Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing. */ + provider: string; + template: string; + /** @description Populated for conversation/text formats. */ + turns?: components["schemas"]["OutputTurn"][] | null; + }; + SlingInputBody: { + /** @description Bead ID to attach a formula to. */ + attached_bead_id?: string; + /** @description Bead ID to sling. */ + bead?: string; + /** @description Bypass cross-rig guards; for direct bead routes, also bypass missing-bead validation. Formula-backed graph routes may replace existing live workflow roots but still require the source bead to exist. */ + force?: boolean; + /** @description Formula name for workflow launch. */ + formula?: string; + /** @description Rig name. */ + rig?: string; + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Target agent or pool. */ + target: string; + /** @description Workflow title. */ + title?: string; + /** @description Formula variables. */ + vars?: { + [key: string]: string; + }; + }; + SlingResponse: { + attached_bead_id?: string; + bead?: string; + formula?: string; + mode?: string; + root_bead_id?: string; + status: string; + target: string; + warnings?: string[] | null; + workflow_id?: string; + }; + Status: { + allow_websockets?: boolean; + hostname?: string; + kind?: string; + local_state: string; + mount_path: string; + publication_state: string; + publish_mode: string; + reason?: string; + service_name: string; + state?: string; + state_root: string; + /** Format: date-time */ + updated_at: string; + url?: string; + visibility?: string; + workflow_contract?: string; + }; + StatusAgentCounts: { + /** + * Format: int64 + * @description Number of quarantined agents. + */ + quarantined: number; + /** + * Format: int64 + * @description Number of running agents. + */ + running: number; + /** + * Format: int64 + * @description Number of suspended agents. + */ + suspended: number; + /** + * Format: int64 + * @description Total number of agents. + */ + total: number; + }; + StatusBody: { + /** + * Format: int64 + * @description Total agent count (deprecated, use agents.total). + */ + agent_count: number; + /** @description Agent state counts. */ + agents: components["schemas"]["StatusAgentCounts"]; + /** @description Mail counts. */ + mail: components["schemas"]["StatusMailCounts"]; + /** @description City name. */ + name: string; + /** @description City directory path. */ + path: string; + /** + * Format: int64 + * @description Total rig count (deprecated, use rigs.total). + */ + rig_count: number; + /** @description Rig state counts. */ + rigs: components["schemas"]["StatusRigCounts"]; + /** + * Format: int64 + * @description Number of running agent processes. + */ + running: number; + /** @description Whether the city is suspended. */ + suspended: boolean; + /** + * Format: int64 + * @description Server uptime in seconds. + */ + uptime_sec: number; + /** @description Server version. */ + version?: string; + /** @description Work item counts. */ + work: components["schemas"]["StatusWorkCounts"]; + }; + StatusMailCounts: { + /** + * Format: int64 + * @description Total number of messages. + */ + total: number; + /** + * Format: int64 + * @description Number of unread messages. + */ + unread: number; + }; + StatusRigCounts: { + /** + * Format: int64 + * @description Number of suspended rigs. + */ + suspended: number; + /** + * Format: int64 + * @description Total number of rigs. + */ + total: number; + }; + StatusWorkCounts: { + /** + * Format: int64 + * @description Number of in-progress work items. + */ + in_progress: number; + /** + * Format: int64 + * @description Number of open work items. + */ + open: number; + /** + * Format: int64 + * @description Number of ready work items. + */ + ready: number; + }; + SubmissionCapabilities: { + supports_follow_up: boolean; + supports_interrupt_now: boolean; + }; + /** + * @description Semantic delivery choice for a user message on a session submit request. + * @enum {string} + */ + SubmitIntent: "default" | "follow_up" | "interrupt_now"; + SupervisorCitiesOutputBody: { + /** @description Managed cities with status info. */ + items: components["schemas"]["CityInfo"][] | null; + /** + * Format: int64 + * @description Total count. + */ + total: number; + }; + SupervisorEventListOutputBody: { + items: components["schemas"]["WireTaggedEvent"][] | null; + /** Format: int64 */ + total: number; + }; + SupervisorHealthOutputBody: { + /** + * Format: int64 + * @description Cities currently running. + */ + cities_running: number; + /** + * Format: int64 + * @description Total managed cities. + */ + cities_total: number; + /** @description First-city startup info for single-city deployments. */ + startup?: components["schemas"]["SupervisorStartup"]; + /** @description Health status ("ok"). */ + status: string; + /** + * Format: int64 + * @description Supervisor uptime in seconds. + */ + uptime_sec: number; + /** @description Supervisor version. */ + version: string; + }; + SupervisorStartup: { + /** @description Current phase (when not ready). */ + phase?: string; + /** @description Phases completed so far. */ + phases_completed?: string[] | null; + /** @description True when the city is running. */ + ready: boolean; + }; + TaggedEventStreamEnvelope: { + actor: string; + city: string; + message?: string; + payload?: components["schemas"]["EventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + type: string; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** + * @description Direction of a transcript entry. + * @enum {string} + */ + TranscriptMessageKind: "inbound" | "outbound"; + /** + * @description Provenance of a transcript entry (freshly observed vs. replayed from persisted history). + * @enum {string} + */ + TranscriptProvenance: "live" | "hydrated"; + /** + * Typed city event stream envelope + * @description Discriminated union of city event stream envelopes. Each variant constrains the envelope type and payload schema together. + */ + TypedEventStreamEnvelope: components["schemas"]["TypedEventStreamEnvelopeBeadClosed"] | components["schemas"]["TypedEventStreamEnvelopeBeadCreated"] | components["schemas"]["TypedEventStreamEnvelopeBeadUpdated"] | components["schemas"]["TypedEventStreamEnvelopeCityCreated"] | components["schemas"]["TypedEventStreamEnvelopeCityInitFailed"] | components["schemas"]["TypedEventStreamEnvelopeCityReady"] | components["schemas"]["TypedEventStreamEnvelopeCityResumed"] | components["schemas"]["TypedEventStreamEnvelopeCitySuspended"] | components["schemas"]["TypedEventStreamEnvelopeCityUnregisterFailed"] | components["schemas"]["TypedEventStreamEnvelopeCityUnregisterRequested"] | components["schemas"]["TypedEventStreamEnvelopeCityUnregistered"] | components["schemas"]["TypedEventStreamEnvelopeControllerStarted"] | components["schemas"]["TypedEventStreamEnvelopeControllerStopped"] | components["schemas"]["TypedEventStreamEnvelopeConvoyClosed"] | components["schemas"]["TypedEventStreamEnvelopeConvoyCreated"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgAdapterAdded"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgAdapterRemoved"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgBound"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgGroupCreated"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgInbound"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgOutbound"] | components["schemas"]["TypedEventStreamEnvelopeExtmsgUnbound"] | components["schemas"]["TypedEventStreamEnvelopeMailArchived"] | components["schemas"]["TypedEventStreamEnvelopeMailDeleted"] | components["schemas"]["TypedEventStreamEnvelopeMailMarkedRead"] | components["schemas"]["TypedEventStreamEnvelopeMailMarkedUnread"] | components["schemas"]["TypedEventStreamEnvelopeMailRead"] | components["schemas"]["TypedEventStreamEnvelopeMailReplied"] | components["schemas"]["TypedEventStreamEnvelopeMailSent"] | components["schemas"]["TypedEventStreamEnvelopeOrderCompleted"] | components["schemas"]["TypedEventStreamEnvelopeOrderFailed"] | components["schemas"]["TypedEventStreamEnvelopeOrderFired"] | components["schemas"]["TypedEventStreamEnvelopeProviderSwapped"] | components["schemas"]["TypedEventStreamEnvelopeSessionCrashed"] | components["schemas"]["TypedEventStreamEnvelopeSessionDraining"] | components["schemas"]["TypedEventStreamEnvelopeSessionIdleKilled"] | components["schemas"]["TypedEventStreamEnvelopeSessionQuarantined"] | components["schemas"]["TypedEventStreamEnvelopeSessionStopped"] | components["schemas"]["TypedEventStreamEnvelopeSessionSuspended"] | components["schemas"]["TypedEventStreamEnvelopeSessionUndrained"] | components["schemas"]["TypedEventStreamEnvelopeSessionUpdated"] | components["schemas"]["TypedEventStreamEnvelopeSessionWoke"] | components["schemas"]["TypedEventStreamEnvelopeWorkerOperation"]; + /** TypedEventStreamEnvelope bead.closed */ + TypedEventStreamEnvelopeBeadClosed: { + actor: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.closed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope bead.created */ + TypedEventStreamEnvelopeBeadCreated: { + actor: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope bead.updated */ + TypedEventStreamEnvelopeBeadUpdated: { + actor: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.updated"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.created */ + TypedEventStreamEnvelopeCityCreated: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.init_failed */ + TypedEventStreamEnvelopeCityInitFailed: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.init_failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.ready */ + TypedEventStreamEnvelopeCityReady: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.ready"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.resumed */ + TypedEventStreamEnvelopeCityResumed: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.resumed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.suspended */ + TypedEventStreamEnvelopeCitySuspended: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.suspended"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.unregister_failed */ + TypedEventStreamEnvelopeCityUnregisterFailed: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregister_failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.unregister_requested */ + TypedEventStreamEnvelopeCityUnregisterRequested: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregister_requested"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope city.unregistered */ + TypedEventStreamEnvelopeCityUnregistered: { + actor: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregistered"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope controller.started */ + TypedEventStreamEnvelopeControllerStarted: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "controller.started"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope controller.stopped */ + TypedEventStreamEnvelopeControllerStopped: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "controller.stopped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope convoy.closed */ + TypedEventStreamEnvelopeConvoyClosed: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "convoy.closed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope convoy.created */ + TypedEventStreamEnvelopeConvoyCreated: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "convoy.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.adapter_added */ + TypedEventStreamEnvelopeExtmsgAdapterAdded: { + actor: string; + message?: string; + payload: components["schemas"]["AdapterEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.adapter_added"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.adapter_removed */ + TypedEventStreamEnvelopeExtmsgAdapterRemoved: { + actor: string; + message?: string; + payload: components["schemas"]["AdapterEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.adapter_removed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.bound */ + TypedEventStreamEnvelopeExtmsgBound: { + actor: string; + message?: string; + payload: components["schemas"]["BoundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.bound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.group_created */ + TypedEventStreamEnvelopeExtmsgGroupCreated: { + actor: string; + message?: string; + payload: components["schemas"]["GroupCreatedEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.group_created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.inbound */ + TypedEventStreamEnvelopeExtmsgInbound: { + actor: string; + message?: string; + payload: components["schemas"]["InboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.inbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.outbound */ + TypedEventStreamEnvelopeExtmsgOutbound: { + actor: string; + message?: string; + payload: components["schemas"]["OutboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.outbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope extmsg.unbound */ + TypedEventStreamEnvelopeExtmsgUnbound: { + actor: string; + message?: string; + payload: components["schemas"]["UnboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.unbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.archived */ + TypedEventStreamEnvelopeMailArchived: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.archived"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.deleted */ + TypedEventStreamEnvelopeMailDeleted: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.deleted"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.marked_read */ + TypedEventStreamEnvelopeMailMarkedRead: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.marked_read"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.marked_unread */ + TypedEventStreamEnvelopeMailMarkedUnread: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.marked_unread"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.read */ + TypedEventStreamEnvelopeMailRead: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.read"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.replied */ + TypedEventStreamEnvelopeMailReplied: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.replied"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope mail.sent */ + TypedEventStreamEnvelopeMailSent: { + actor: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.sent"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope order.completed */ + TypedEventStreamEnvelopeOrderCompleted: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.completed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope order.failed */ + TypedEventStreamEnvelopeOrderFailed: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope order.fired */ + TypedEventStreamEnvelopeOrderFired: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.fired"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope provider.swapped */ + TypedEventStreamEnvelopeProviderSwapped: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "provider.swapped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.crashed */ + TypedEventStreamEnvelopeSessionCrashed: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.crashed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.draining */ + TypedEventStreamEnvelopeSessionDraining: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.draining"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.idle_killed */ + TypedEventStreamEnvelopeSessionIdleKilled: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.idle_killed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.quarantined */ + TypedEventStreamEnvelopeSessionQuarantined: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.quarantined"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.stopped */ + TypedEventStreamEnvelopeSessionStopped: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.stopped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.suspended */ + TypedEventStreamEnvelopeSessionSuspended: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.suspended"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.undrained */ + TypedEventStreamEnvelopeSessionUndrained: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.undrained"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.updated */ + TypedEventStreamEnvelopeSessionUpdated: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.updated"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope session.woke */ + TypedEventStreamEnvelopeSessionWoke: { + actor: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.woke"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedEventStreamEnvelope worker.operation */ + TypedEventStreamEnvelopeWorkerOperation: { + actor: string; + message?: string; + payload: components["schemas"]["WorkerOperationEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "worker.operation"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** + * Typed supervisor event stream envelope + * @description Discriminated union of supervisor event stream envelopes. Each variant constrains the envelope type and payload schema together and includes the source city. + */ + TypedTaggedEventStreamEnvelope: components["schemas"]["TypedTaggedEventStreamEnvelopeBeadClosed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeBeadCreated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeBeadUpdated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityCreated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityInitFailed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityReady"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityResumed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCitySuspended"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityUnregisterFailed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityUnregisterRequested"] | components["schemas"]["TypedTaggedEventStreamEnvelopeCityUnregistered"] | components["schemas"]["TypedTaggedEventStreamEnvelopeControllerStarted"] | components["schemas"]["TypedTaggedEventStreamEnvelopeControllerStopped"] | components["schemas"]["TypedTaggedEventStreamEnvelopeConvoyClosed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeConvoyCreated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgBound"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgGroupCreated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgInbound"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgOutbound"] | components["schemas"]["TypedTaggedEventStreamEnvelopeExtmsgUnbound"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailArchived"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailDeleted"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailMarkedRead"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailMarkedUnread"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailRead"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailReplied"] | components["schemas"]["TypedTaggedEventStreamEnvelopeMailSent"] | components["schemas"]["TypedTaggedEventStreamEnvelopeOrderCompleted"] | components["schemas"]["TypedTaggedEventStreamEnvelopeOrderFailed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeOrderFired"] | components["schemas"]["TypedTaggedEventStreamEnvelopeProviderSwapped"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionCrashed"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionDraining"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionIdleKilled"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionQuarantined"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionStopped"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionSuspended"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionUndrained"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionUpdated"] | components["schemas"]["TypedTaggedEventStreamEnvelopeSessionWoke"] | components["schemas"]["TypedTaggedEventStreamEnvelopeWorkerOperation"]; + /** TypedTaggedEventStreamEnvelope bead.closed */ + TypedTaggedEventStreamEnvelopeBeadClosed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.closed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope bead.created */ + TypedTaggedEventStreamEnvelopeBeadCreated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope bead.updated */ + TypedTaggedEventStreamEnvelopeBeadUpdated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["BeadEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bead.updated"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.created */ + TypedTaggedEventStreamEnvelopeCityCreated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.init_failed */ + TypedTaggedEventStreamEnvelopeCityInitFailed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.init_failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.ready */ + TypedTaggedEventStreamEnvelopeCityReady: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.ready"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.resumed */ + TypedTaggedEventStreamEnvelopeCityResumed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.resumed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.suspended */ + TypedTaggedEventStreamEnvelopeCitySuspended: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.suspended"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.unregister_failed */ + TypedTaggedEventStreamEnvelopeCityUnregisterFailed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregister_failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.unregister_requested */ + TypedTaggedEventStreamEnvelopeCityUnregisterRequested: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregister_requested"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope city.unregistered */ + TypedTaggedEventStreamEnvelopeCityUnregistered: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["CityLifecyclePayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "city.unregistered"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope controller.started */ + TypedTaggedEventStreamEnvelopeControllerStarted: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "controller.started"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope controller.stopped */ + TypedTaggedEventStreamEnvelopeControllerStopped: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "controller.stopped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope convoy.closed */ + TypedTaggedEventStreamEnvelopeConvoyClosed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "convoy.closed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope convoy.created */ + TypedTaggedEventStreamEnvelopeConvoyCreated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "convoy.created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.adapter_added */ + TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["AdapterEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.adapter_added"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.adapter_removed */ + TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["AdapterEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.adapter_removed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.bound */ + TypedTaggedEventStreamEnvelopeExtmsgBound: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["BoundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.bound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.group_created */ + TypedTaggedEventStreamEnvelopeExtmsgGroupCreated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["GroupCreatedEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.group_created"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.inbound */ + TypedTaggedEventStreamEnvelopeExtmsgInbound: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["InboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.inbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.outbound */ + TypedTaggedEventStreamEnvelopeExtmsgOutbound: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["OutboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.outbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope extmsg.unbound */ + TypedTaggedEventStreamEnvelopeExtmsgUnbound: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["UnboundEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "extmsg.unbound"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.archived */ + TypedTaggedEventStreamEnvelopeMailArchived: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.archived"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.deleted */ + TypedTaggedEventStreamEnvelopeMailDeleted: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.deleted"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.marked_read */ + TypedTaggedEventStreamEnvelopeMailMarkedRead: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.marked_read"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.marked_unread */ + TypedTaggedEventStreamEnvelopeMailMarkedUnread: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.marked_unread"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.read */ + TypedTaggedEventStreamEnvelopeMailRead: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.read"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.replied */ + TypedTaggedEventStreamEnvelopeMailReplied: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.replied"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope mail.sent */ + TypedTaggedEventStreamEnvelopeMailSent: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["MailEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "mail.sent"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope order.completed */ + TypedTaggedEventStreamEnvelopeOrderCompleted: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.completed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope order.failed */ + TypedTaggedEventStreamEnvelopeOrderFailed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.failed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope order.fired */ + TypedTaggedEventStreamEnvelopeOrderFired: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "order.fired"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope provider.swapped */ + TypedTaggedEventStreamEnvelopeProviderSwapped: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "provider.swapped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.crashed */ + TypedTaggedEventStreamEnvelopeSessionCrashed: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.crashed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.draining */ + TypedTaggedEventStreamEnvelopeSessionDraining: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.draining"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.idle_killed */ + TypedTaggedEventStreamEnvelopeSessionIdleKilled: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.idle_killed"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.quarantined */ + TypedTaggedEventStreamEnvelopeSessionQuarantined: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.quarantined"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.stopped */ + TypedTaggedEventStreamEnvelopeSessionStopped: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.stopped"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.suspended */ + TypedTaggedEventStreamEnvelopeSessionSuspended: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.suspended"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.undrained */ + TypedTaggedEventStreamEnvelopeSessionUndrained: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.undrained"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.updated */ + TypedTaggedEventStreamEnvelopeSessionUpdated: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.updated"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope session.woke */ + TypedTaggedEventStreamEnvelopeSessionWoke: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["NoPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "session.woke"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + /** TypedTaggedEventStreamEnvelope worker.operation */ + TypedTaggedEventStreamEnvelopeWorkerOperation: { + actor: string; + city: string; + message?: string; + payload: components["schemas"]["WorkerOperationEventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "worker.operation"; + workflow?: components["schemas"]["WorkflowEventProjection"]; + }; + UnboundEventPayload: { + /** Format: int64 */ + count: number; + session_id: string; + }; + WireEvent: { + actor: string; + message?: string; + payload?: components["schemas"]["EventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + type: string; + }; + WireTaggedEvent: { + actor: string; + city: string; + message?: string; + payload?: components["schemas"]["EventPayload"]; + /** Format: int64 */ + seq: number; + subject?: string; + /** Format: date-time */ + ts: string; + type: string; + }; + WorkerOperationEventPayload: { + delivered?: boolean; + /** Format: int64 */ + duration_ms: number; + error?: string; + /** Format: date-time */ + finished_at: string; + op_id: string; + operation: string; + provider?: string; + queued?: boolean; + result: string; + session_id?: string; + session_name?: string; + /** Format: date-time */ + started_at: string; + template?: string; + transport?: string; + }; + WorkflowAttemptSummary: { + /** Format: int64 */ + active_attempt: number; + /** Format: int64 */ + attempt_count: number; + /** Format: int64 */ + max_attempts?: number; + }; + WorkflowBeadResponse: { + assignee?: string; + /** Format: int64 */ + attempt?: number; + id: string; + kind: string; + logical_bead_id?: string; + metadata: { + [key: string]: string; + }; + scope_ref?: string; + status: string; + step_ref?: string; + title: string; + }; + WorkflowDeleteResponse: { + /** + * Format: int64 + * @description Number of beads closed. + */ + closed: number; + /** + * Format: int64 + * @description Number of beads deleted. + */ + deleted: number; + /** @description True when one or more teardown steps failed; Closed/Deleted still reflect what succeeded. */ + partial?: boolean; + /** @description Human-readable errors from failed teardown steps. */ + partial_errors?: string[] | null; + /** @description Workflow ID. */ + workflow_id: string; + }; + WorkflowDepResponse: { + from: string; + kind?: string; + to: string; + }; + WorkflowEventProjection: { + attempt_summary?: components["schemas"]["WorkflowAttemptSummary"]; + bead: components["schemas"]["WorkflowBeadResponse"]; + changed_fields: string[] | null; + /** Format: int64 */ + event_seq: number; + event_ts: string; + event_type: string; + logical_node_id: string; + requires_resync?: boolean; + root_bead_id: string; + root_store_ref: string; + scope_kind: string; + scope_ref: string; + type: string; + watch_generation: string; + workflow_id: string; + /** Format: int64 */ + workflow_seq: number; + }; + WorkflowSnapshotResponse: { + beads: components["schemas"]["WorkflowBeadResponse"][] | null; + deps: components["schemas"]["WorkflowDepResponse"][] | null; + logical_edges: components["schemas"]["WorkflowDepResponse"][] | null; + logical_nodes: components["schemas"]["LogicalNode"][] | null; + partial: boolean; + resolved_root_store: string; + root_bead_id: string; + root_store_ref: string; + scope_groups: components["schemas"]["ScopeGroup"][] | null; + scope_kind: string; + scope_ref: string; + /** Format: int64 */ + snapshot_event_seq?: number; + /** Format: int64 */ + snapshot_version: number; + stores_scanned: string[] | null; + workflow_id: string; + }; + WorkspaceResponse: { + declared_name?: string; + declared_prefix?: string; + name: string; + prefix?: string; + provider?: string; + session_template?: string; + suspended: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: { + /** @description Opaque per-response identifier assigned by the server for log correlation. Every response carries this header. */ + "X-GC-Request-Id": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "get-health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SupervisorHealthOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-cities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SupervisorCitiesOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CityCreateRequest"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CityCreateResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CityGetResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CityPatchInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-agent-by-base": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent name (unqualified, no rig). */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-agent-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent name (unqualified). */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-agent-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent name (unqualified). */ + base: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentUpdateInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-agent-by-base-output": { + parameters: { + query?: { + /** @description Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. */ + tail?: string; + /** @description Message UUID cursor for loading older messages. */ + before?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentOutputResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "stream-agent-output": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Agent runtime status at the time streaming began. Emitted as "stopped" when the agent is not running (the stream then serves replayed transcript from the session log). */ + "GC-Agent-Status"?: string; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "text/event-stream": ({ + data: components["schemas"]["HeartbeatEvent"]; + /** + * @description The event name. + * @constant + */ + event: "heartbeat"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["AgentOutputResponse"]; + /** + * @description The event name. + * @constant + */ + event: "turn"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + })[]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-agent-by-base-by-action": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent name (unqualified). */ + base: string; + /** @description Action to perform. */ + action: "suspend" | "resume"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-agent-by-dir-by-base": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-agent-by-dir-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-agent-by-dir-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentUpdateQualifiedInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-agent-by-dir-by-base-output": { + parameters: { + query?: { + /** @description Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. */ + tail?: string; + /** @description Message UUID cursor for loading older messages. */ + before?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentOutputResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "stream-agent-output-qualified": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Agent runtime status at the time streaming began. Emitted as "stopped" when the agent is not running (the stream then serves replayed transcript from the session log). */ + "GC-Agent-Status"?: string; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "text/event-stream": ({ + data: components["schemas"]["HeartbeatEvent"]; + /** + * @description The event name. + * @constant + */ + event: "heartbeat"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["AgentOutputResponse"]; + /** + * @description The event name. + * @constant + */ + event: "turn"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + })[]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-agent-by-dir-by-base-by-action": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + /** @description Action to perform. */ + action: "suspend" | "resume"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-agents": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Filter by pool name. */ + pool?: string; + /** @description Filter by rig name. */ + rig?: string; + /** @description Filter by running state. Omit to return all agents. */ + running?: "true" | "false"; + /** @description Include last output preview. */ + peek?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyAgentResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-agent": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentCreatedOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-bead-by-id": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Bead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-bead-by-id": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-bead-by-id": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BeadUpdateBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-bead-by-id-assign": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BeadAssignInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-bead-by-id-close": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-bead-by-id-deps": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BeadDepsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-bead-by-id-reopen": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-bead-by-id-update": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BeadUpdateBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-beads": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Pagination cursor from a previous response's next_cursor field. */ + cursor?: string; + /** @description Maximum number of results to return. 0 = server default. */ + limit?: number; + /** @description Filter by bead status. */ + status?: string; + /** @description Filter by bead type. */ + type?: string; + /** @description Filter by label. */ + label?: string; + /** @description Filter by assignee. */ + assignee?: string; + /** @description Filter by rig. */ + rig?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyBead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-bead": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + /** @description Idempotency key for safe retries. */ + "Idempotency-Key"?: string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BeadCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Bead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-beads-graph-by-root-id": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Root bead ID for the graph. */ + rootID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BeadGraphResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-beads-ready": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyBead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-config": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-config-explain": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigExplainResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-config-validate": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigValidateOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-convoy-by-id": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConvoyGetResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-convoy-by-id": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-convoy-by-id-add": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConvoyAddInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-convoy-by-id-check": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConvoyCheckResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-convoy-by-id-close": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-convoy-by-id-remove": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Convoy ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConvoyRemoveInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-convoys": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Pagination cursor from a previous response's next_cursor field. */ + cursor?: string; + /** @description Maximum number of results to return. 0 = server default. */ + limit?: number; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyBead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-convoy": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConvoyCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Bead"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-events": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Pagination cursor from a previous response's next_cursor field. */ + cursor?: string; + /** @description Maximum number of results to return. 0 = server default. */ + limit?: number; + /** @description Filter by event type. */ + type?: string; + /** @description Filter by actor. */ + actor?: string; + /** @description Filter events since duration ago (Go duration string, e.g. 5m). */ + since?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyWireEvent"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "emit-event": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventEmitRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventEmitOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "stream-events": { + parameters: { + query?: { + /** @description Reconnect position: only deliver events after this sequence number. */ + after_seq?: string; + }; + header?: { + /** @description SSE reconnect position from the last received event ID. */ + "Last-Event-ID"?: string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "text/event-stream": ({ + data: components["schemas"]["TypedEventStreamEnvelope"]; + /** + * @description The event name. + * @constant + */ + event: "event"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["HeartbeatEvent"]; + /** + * @description The event name. + * @constant + */ + event: "heartbeat"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + })[]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-extmsg-adapters": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyExtmsgAdapterInfo"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "register-extmsg-adapter": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgAdapterRegisterInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExtMsgAdapterRegisterOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-extmsg-adapters": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgAdapterUnregisterInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-bind": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgBindInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionBindingRecord"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-extmsg-bindings": { + parameters: { + query?: { + /** @description Session ID to list bindings for. */ + session_id?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodySessionBindingRecord"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-extmsg-groups": { + parameters: { + query?: { + /** @description Scope ID. */ + scope_id?: string; + /** @description Provider name. */ + provider?: string; + /** @description Account ID. */ + account_id?: string; + /** @description Conversation ID. */ + conversation_id?: string; + /** @description Conversation kind. */ + kind?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationGroupRecord"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "ensure-extmsg-group": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgGroupEnsureInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationGroupRecord"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-inbound": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgInboundInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InboundResult"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-outbound": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgOutboundInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OutboundResult"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-participants": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgParticipantUpsertInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationGroupParticipant"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-extmsg-participants": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgParticipantRemoveInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-extmsg-transcript": { + parameters: { + query?: { + /** @description Scope ID. */ + scope_id?: string; + /** @description Provider name. */ + provider?: string; + /** @description Account ID. */ + account_id?: string; + /** @description Conversation ID. */ + conversation_id?: string; + /** @description Parent conversation ID. */ + parent_conversation_id?: string; + /** @description Conversation kind. */ + kind?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyConversationTranscriptRecord"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-transcript-ack": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgTranscriptAckInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-extmsg-unbind": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExtMsgUnbindInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExtMsgUnbindBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-formula-by-name": { + parameters: { + query: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Target agent for preview compilation. */ + target: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Formula name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaDetailResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-formulas": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-formulas-feed": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Maximum number of feed items to return. 0 = default. */ + limit?: number; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaFeedBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-formulas-by-name": { + parameters: { + query: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Target agent for preview compilation. */ + target: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Formula name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaDetailResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-formulas-by-name-preview": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Formula name. */ + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FormulaPreviewBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaDetailResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-formulas-by-name-runs": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Maximum number of recent runs to return. 0 = default. */ + limit?: number; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Formula name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FormulaRunsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-health": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-mail": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Pagination cursor from a previous response's next_cursor field. */ + cursor?: string; + /** @description Maximum number of results to return. 0 = server default. */ + limit?: number; + /** @description Filter by agent name. */ + agent?: string; + /** @description Filter by status (unread, all). */ + status?: string; + /** @description Filter by rig name. */ + rig?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MailListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "send-mail": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + /** @description Idempotency key for safe retries. */ + "Idempotency-Key"?: string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MailSendInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Message"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-mail-count": { + parameters: { + query?: { + /** @description Filter by agent name. */ + agent?: string; + /** @description Filter by rig name. */ + rig?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MailCountOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-mail-thread-by-id": { + parameters: { + query?: { + /** @description Filter by rig. */ + rig?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Thread ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MailListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-mail-by-id": { + parameters: { + query?: { + /** @description Rig hint for O(1) lookup. */ + rig?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Message"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-mail-by-id": { + parameters: { + query?: { + /** @description Rig hint. */ + rig?: string; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-mail-by-id-archive": { + parameters: { + query?: { + /** @description Rig hint. */ + rig?: string; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-mail-by-id-mark-unread": { + parameters: { + query?: { + /** @description Rig hint. */ + rig?: string; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-mail-by-id-read": { + parameters: { + query?: { + /** @description Rig hint. */ + rig?: string; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "reply-mail": { + parameters: { + query?: { + /** @description Rig hint. */ + rig?: string; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Message ID. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MailReplyInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Message"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-order-history-by-bead-id": { + parameters: { + query?: { + /** @description Store reference for disambiguating store-local bead IDs. */ + store_ref?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Bead ID for the order run. */ + bead_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrderHistoryDetailResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-order-by-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Order name or scoped name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrderResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-order-by-name-disable": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Order name or scoped name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-order-by-name-enable": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Order name or scoped name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-orders": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrderListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-orders-check": { + parameters: { + query?: { + /** @description Bypass cached order-check responses and cached order history. */ + fresh?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrderCheckListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-orders-feed": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Maximum number of feed items to return. */ + limit?: number; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrdersFeedBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-orders-history": { + parameters: { + query: { + /** @description Scoped order name. */ + scoped_name: string; + /** @description Maximum number of history entries. 0 = default. */ + limit?: number; + /** @description Return entries before this RFC3339 timestamp. */ + before?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrderHistoryListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-packs": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PackListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-agent-by-base": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent patch name (unqualified). */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-patches-agent-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent patch name (unqualified). */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchDeletedResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-agent-by-dir-by-base": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-patches-agent-by-dir-by-base": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Agent directory (rig name). */ + dir: string; + /** @description Agent base name. */ + base: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchDeletedResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-agents": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyAgentPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "put-v0-city-by-city-name-patches-agents": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentPatchSetInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchOKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-provider-by-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Provider patch name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-patches-provider-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Provider patch name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchDeletedResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-providers": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyProviderPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "put-v0-city-by-city-name-patches-providers": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderPatchSetInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchOKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-rig-by-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig patch name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RigPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-patches-rig-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig patch name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchDeletedResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-patches-rigs": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyRigPatch"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "put-v0-city-by-city-name-patches-rigs": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RigPatchSetInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PatchOKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-provider-readiness": { + parameters: { + query?: { + /** @description Comma-separated provider names to check (default: claude,codex,gemini). */ + providers?: string; + /** @description Force fresh probe, bypassing cache. */ + fresh?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderReadinessResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-provider-by-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Provider name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-provider-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Provider name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-provider-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Provider name. */ + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderUpdateInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-providers": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyProviderResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-provider": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderCreatedOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-providers-public": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderPublicListBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-readiness": { + parameters: { + query?: { + /** @description Comma-separated readiness items to check (default: claude,codex,gemini,github_cli). */ + items?: string; + /** @description Force fresh probe, bypassing cache. */ + fresh?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-rig-by-name": { + parameters: { + query?: { + /** @description Include git status. */ + git?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RigResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-rig-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-rig-by-name": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig name. */ + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RigUpdateInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-rig-by-name-by-action": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Rig name. */ + name: string; + /** @description Action to perform (suspend, resume, restart). */ + action: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RigActionBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-rigs": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + /** @description Include git status. */ + git?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyRigResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-rig": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RigCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RigCreatedOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-service-by-name": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Service name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Status"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-service-by-name-restart": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Service name. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServiceRestartOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-services": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodyStatus"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-session-by-id": { + parameters: { + query?: { + /** @description Include last output preview. */ + peek?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "patch-v0-city-by-city-name-session-by-id": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionPatchBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-session-by-id-agents": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionAgentListResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-session-by-id-agents-by-agent-id": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + /** @description Subagent ID within the session. */ + agentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionAgentGetResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-close": { + parameters: { + query?: { + /** @description Permanently delete bead after closing. */ + delete?: boolean; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-kill": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKWithIDResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "send-session-message": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionMessageInputBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionMessageOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-session-by-id-pending": { + parameters: { + query?: never; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionPendingResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-rename": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionRenameInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "respond-session": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionRespondInputBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionRespondOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-stop": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKWithIDResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "stream-session": { + parameters: { + query?: { + /** @description Transcript format: conversation (default) or raw. */ + format?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Session state at the time streaming began (e.g. active, closed). */ + "GC-Session-State"?: string; + /** @description Runtime status at the time streaming began. Emitted as "stopped" when the session's underlying process is not running. */ + "GC-Session-Status"?: string; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "text/event-stream": ({ + data: components["schemas"]["SessionActivityEvent"]; + /** + * @description The event name. + * @constant + */ + event: "activity"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["HeartbeatEvent"]; + /** + * @description The event name. + * @constant + */ + event: "heartbeat"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["SessionStreamRawMessageEvent"]; + /** + * @description The event name. + * @constant + */ + event?: "message"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["PendingInteraction"]; + /** + * @description The event name. + * @constant + */ + event: "pending"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["SessionStreamMessageEvent"]; + /** + * @description The event name. + * @constant + */ + event: "turn"; + /** @description The event ID. */ + id?: number; + /** @description The retry time in milliseconds. */ + retry?: number; + })[]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "submit-session": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionSubmitInputBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionSubmitOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-suspend": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-session-by-id-transcript": { + parameters: { + query?: { + /** @description Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. */ + tail?: string; + /** @description Transcript format: conversation (default) or raw. */ + format?: string; + /** @description Pagination cursor: return entries before this UUID. */ + before?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionTranscriptGetResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-session-by-id-wake": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Session ID, alias, or runtime session_name. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OKWithIDResponseBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-sessions": { + parameters: { + query?: { + /** @description Pagination cursor from a previous response's next_cursor field. */ + cursor?: string; + /** @description Maximum number of results to return. 0 = server default. */ + limit?: number; + /** @description Filter by session state (e.g. active, closed). */ + state?: string; + /** @description Filter by session template (agent qualified name). */ + template?: string; + /** @description Include last output preview. */ + peek?: boolean; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBodySessionResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-session": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionCreateBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-sling": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SlingInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SlingResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-status": { + parameters: { + query?: { + /** @description Event sequence number; when provided, blocks until a newer event arrives. */ + index?: string; + /** @description How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. */ + wait?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "post-v0-city-by-city-name-unregister": { + parameters: { + query?: never; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description Supervisor-registered city name. */ + cityName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CityUnregisterResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-city-by-city-name-workflow-by-workflow-id": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + }; + header?: never; + path: { + /** @description City name. */ + cityName: string; + /** @description Workflow (convoy) ID. */ + workflow_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Index"?: number; + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowSnapshotResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-v0-city-by-city-name-workflow-by-workflow-id": { + parameters: { + query?: { + /** @description Scope kind (city or rig). */ + scope_kind?: string; + /** @description Scope reference. */ + scope_ref?: string; + /** @description Permanently delete beads from store. */ + delete?: boolean; + }; + header: { + /** @description Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. */ + "X-GC-Request": string; + }; + path: { + /** @description City name. */ + cityName: string; + /** @description Workflow (convoy) ID. */ + workflow_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDeleteResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-events": { + parameters: { + query?: { + /** @description Filter by event type. */ + type?: string; + /** @description Filter by actor. */ + actor?: string; + /** @description Filter to events within the last Go duration (e.g. "5m"). */ + since?: string; + /** @description Maximum number of trailing events to return. 0 = no limit. Used by 'gc events --seq' to compute the head cursor cheaply. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SupervisorEventListOutputBody"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "stream-supervisor-events": { + parameters: { + query?: { + /** @description Alternative to Last-Event-ID for browsers that can't set custom headers. */ + after_cursor?: string; + }; + header?: { + /** @description Reconnect cursor (composite per-city cursor). */ + "Last-Event-ID"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "text/event-stream": ({ + data: components["schemas"]["HeartbeatEvent"]; + /** + * @description The event name. + * @constant + */ + event: "heartbeat"; + /** @description The event ID (composite cursor). */ + id?: string; + /** @description The retry time in milliseconds. */ + retry?: number; + } | { + data: components["schemas"]["TypedTaggedEventStreamEnvelope"]; + /** + * @description The event name. + * @constant + */ + event: "tagged_event"; + /** @description The event ID (composite cursor). */ + id?: string; + /** @description The retry time in milliseconds. */ + retry?: number; + })[]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-provider-readiness": { + parameters: { + query?: { + /** @description Comma-separated list of providers to probe. */ + providers?: string; + /** @description Force fresh probe, bypassing cache. */ + fresh?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderReadinessResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-v0-readiness": { + parameters: { + query?: { + /** @description Comma-separated list of readiness items to check. */ + items?: string; + /** @description Force fresh probe, bypassing cache. */ + fresh?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + "X-GC-Request-Id": components["headers"]["X-GC-Request-Id"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; +} diff --git a/cmd/gc/dashboard/web/src/generated/sdk.gen.ts b/cmd/gc/dashboard/web/src/generated/sdk.gen.ts new file mode 100644 index 000000000..be654f696 --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/sdk.gen.ts @@ -0,0 +1,1022 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { CreateAgentData, CreateAgentErrors, CreateAgentResponses, CreateBeadData, CreateBeadErrors, CreateBeadResponses, CreateConvoyData, CreateConvoyErrors, CreateConvoyResponses, CreateProviderData, CreateProviderErrors, CreateProviderResponses, CreateRigData, CreateRigErrors, CreateRigResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteV0CityByCityNameAgentByBaseData, DeleteV0CityByCityNameAgentByBaseErrors, DeleteV0CityByCityNameAgentByBaseResponses, DeleteV0CityByCityNameAgentByDirByBaseData, DeleteV0CityByCityNameAgentByDirByBaseErrors, DeleteV0CityByCityNameAgentByDirByBaseResponses, DeleteV0CityByCityNameBeadByIdData, DeleteV0CityByCityNameBeadByIdErrors, DeleteV0CityByCityNameBeadByIdResponses, DeleteV0CityByCityNameConvoyByIdData, DeleteV0CityByCityNameConvoyByIdErrors, DeleteV0CityByCityNameConvoyByIdResponses, DeleteV0CityByCityNameExtmsgAdaptersData, DeleteV0CityByCityNameExtmsgAdaptersErrors, DeleteV0CityByCityNameExtmsgAdaptersResponses, DeleteV0CityByCityNameExtmsgParticipantsData, DeleteV0CityByCityNameExtmsgParticipantsErrors, DeleteV0CityByCityNameExtmsgParticipantsResponses, DeleteV0CityByCityNameMailByIdData, DeleteV0CityByCityNameMailByIdErrors, DeleteV0CityByCityNameMailByIdResponses, DeleteV0CityByCityNamePatchesAgentByBaseData, DeleteV0CityByCityNamePatchesAgentByBaseErrors, DeleteV0CityByCityNamePatchesAgentByBaseResponses, DeleteV0CityByCityNamePatchesAgentByDirByBaseData, DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses, DeleteV0CityByCityNamePatchesProviderByNameData, DeleteV0CityByCityNamePatchesProviderByNameErrors, DeleteV0CityByCityNamePatchesProviderByNameResponses, DeleteV0CityByCityNamePatchesRigByNameData, DeleteV0CityByCityNamePatchesRigByNameErrors, DeleteV0CityByCityNamePatchesRigByNameResponses, DeleteV0CityByCityNameProviderByNameData, DeleteV0CityByCityNameProviderByNameErrors, DeleteV0CityByCityNameProviderByNameResponses, DeleteV0CityByCityNameRigByNameData, DeleteV0CityByCityNameRigByNameErrors, DeleteV0CityByCityNameRigByNameResponses, DeleteV0CityByCityNameWorkflowByWorkflowIdData, DeleteV0CityByCityNameWorkflowByWorkflowIdErrors, DeleteV0CityByCityNameWorkflowByWorkflowIdResponses, EmitEventData, EmitEventErrors, EmitEventResponses, EnsureExtmsgGroupData, EnsureExtmsgGroupErrors, EnsureExtmsgGroupResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetV0CitiesData, GetV0CitiesErrors, GetV0CitiesResponses, GetV0CityByCityNameAgentByBaseData, GetV0CityByCityNameAgentByBaseErrors, GetV0CityByCityNameAgentByBaseOutputData, GetV0CityByCityNameAgentByBaseOutputErrors, GetV0CityByCityNameAgentByBaseOutputResponses, GetV0CityByCityNameAgentByBaseResponses, GetV0CityByCityNameAgentByDirByBaseData, GetV0CityByCityNameAgentByDirByBaseErrors, GetV0CityByCityNameAgentByDirByBaseOutputData, GetV0CityByCityNameAgentByDirByBaseOutputErrors, GetV0CityByCityNameAgentByDirByBaseOutputResponses, GetV0CityByCityNameAgentByDirByBaseResponses, GetV0CityByCityNameAgentsData, GetV0CityByCityNameAgentsErrors, GetV0CityByCityNameAgentsResponses, GetV0CityByCityNameBeadByIdData, GetV0CityByCityNameBeadByIdDepsData, GetV0CityByCityNameBeadByIdDepsErrors, GetV0CityByCityNameBeadByIdDepsResponses, GetV0CityByCityNameBeadByIdErrors, GetV0CityByCityNameBeadByIdResponses, GetV0CityByCityNameBeadsData, GetV0CityByCityNameBeadsErrors, GetV0CityByCityNameBeadsGraphByRootIdData, GetV0CityByCityNameBeadsGraphByRootIdErrors, GetV0CityByCityNameBeadsGraphByRootIdResponses, GetV0CityByCityNameBeadsReadyData, GetV0CityByCityNameBeadsReadyErrors, GetV0CityByCityNameBeadsReadyResponses, GetV0CityByCityNameBeadsResponses, GetV0CityByCityNameConfigData, GetV0CityByCityNameConfigErrors, GetV0CityByCityNameConfigExplainData, GetV0CityByCityNameConfigExplainErrors, GetV0CityByCityNameConfigExplainResponses, GetV0CityByCityNameConfigResponses, GetV0CityByCityNameConfigValidateData, GetV0CityByCityNameConfigValidateErrors, GetV0CityByCityNameConfigValidateResponses, GetV0CityByCityNameConvoyByIdCheckData, GetV0CityByCityNameConvoyByIdCheckErrors, GetV0CityByCityNameConvoyByIdCheckResponses, GetV0CityByCityNameConvoyByIdData, GetV0CityByCityNameConvoyByIdErrors, GetV0CityByCityNameConvoyByIdResponses, GetV0CityByCityNameConvoysData, GetV0CityByCityNameConvoysErrors, GetV0CityByCityNameConvoysResponses, GetV0CityByCityNameData, GetV0CityByCityNameErrors, GetV0CityByCityNameEventsData, GetV0CityByCityNameEventsErrors, GetV0CityByCityNameEventsResponses, GetV0CityByCityNameExtmsgAdaptersData, GetV0CityByCityNameExtmsgAdaptersErrors, GetV0CityByCityNameExtmsgAdaptersResponses, GetV0CityByCityNameExtmsgBindingsData, GetV0CityByCityNameExtmsgBindingsErrors, GetV0CityByCityNameExtmsgBindingsResponses, GetV0CityByCityNameExtmsgGroupsData, GetV0CityByCityNameExtmsgGroupsErrors, GetV0CityByCityNameExtmsgGroupsResponses, GetV0CityByCityNameExtmsgTranscriptData, GetV0CityByCityNameExtmsgTranscriptErrors, GetV0CityByCityNameExtmsgTranscriptResponses, GetV0CityByCityNameFormulaByNameData, GetV0CityByCityNameFormulaByNameErrors, GetV0CityByCityNameFormulaByNameResponses, GetV0CityByCityNameFormulasByNameData, GetV0CityByCityNameFormulasByNameErrors, GetV0CityByCityNameFormulasByNameResponses, GetV0CityByCityNameFormulasByNameRunsData, GetV0CityByCityNameFormulasByNameRunsErrors, GetV0CityByCityNameFormulasByNameRunsResponses, GetV0CityByCityNameFormulasData, GetV0CityByCityNameFormulasErrors, GetV0CityByCityNameFormulasFeedData, GetV0CityByCityNameFormulasFeedErrors, GetV0CityByCityNameFormulasFeedResponses, GetV0CityByCityNameFormulasResponses, GetV0CityByCityNameHealthData, GetV0CityByCityNameHealthErrors, GetV0CityByCityNameHealthResponses, GetV0CityByCityNameMailByIdData, GetV0CityByCityNameMailByIdErrors, GetV0CityByCityNameMailByIdResponses, GetV0CityByCityNameMailCountData, GetV0CityByCityNameMailCountErrors, GetV0CityByCityNameMailCountResponses, GetV0CityByCityNameMailData, GetV0CityByCityNameMailErrors, GetV0CityByCityNameMailResponses, GetV0CityByCityNameMailThreadByIdData, GetV0CityByCityNameMailThreadByIdErrors, GetV0CityByCityNameMailThreadByIdResponses, GetV0CityByCityNameOrderByNameData, GetV0CityByCityNameOrderByNameErrors, GetV0CityByCityNameOrderByNameResponses, GetV0CityByCityNameOrderHistoryByBeadIdData, GetV0CityByCityNameOrderHistoryByBeadIdErrors, GetV0CityByCityNameOrderHistoryByBeadIdResponses, GetV0CityByCityNameOrdersCheckData, GetV0CityByCityNameOrdersCheckErrors, GetV0CityByCityNameOrdersCheckResponses, GetV0CityByCityNameOrdersData, GetV0CityByCityNameOrdersErrors, GetV0CityByCityNameOrdersFeedData, GetV0CityByCityNameOrdersFeedErrors, GetV0CityByCityNameOrdersFeedResponses, GetV0CityByCityNameOrdersHistoryData, GetV0CityByCityNameOrdersHistoryErrors, GetV0CityByCityNameOrdersHistoryResponses, GetV0CityByCityNameOrdersResponses, GetV0CityByCityNamePacksData, GetV0CityByCityNamePacksErrors, GetV0CityByCityNamePacksResponses, GetV0CityByCityNamePatchesAgentByBaseData, GetV0CityByCityNamePatchesAgentByBaseErrors, GetV0CityByCityNamePatchesAgentByBaseResponses, GetV0CityByCityNamePatchesAgentByDirByBaseData, GetV0CityByCityNamePatchesAgentByDirByBaseErrors, GetV0CityByCityNamePatchesAgentByDirByBaseResponses, GetV0CityByCityNamePatchesAgentsData, GetV0CityByCityNamePatchesAgentsErrors, GetV0CityByCityNamePatchesAgentsResponses, GetV0CityByCityNamePatchesProviderByNameData, GetV0CityByCityNamePatchesProviderByNameErrors, GetV0CityByCityNamePatchesProviderByNameResponses, GetV0CityByCityNamePatchesProvidersData, GetV0CityByCityNamePatchesProvidersErrors, GetV0CityByCityNamePatchesProvidersResponses, GetV0CityByCityNamePatchesRigByNameData, GetV0CityByCityNamePatchesRigByNameErrors, GetV0CityByCityNamePatchesRigByNameResponses, GetV0CityByCityNamePatchesRigsData, GetV0CityByCityNamePatchesRigsErrors, GetV0CityByCityNamePatchesRigsResponses, GetV0CityByCityNameProviderByNameData, GetV0CityByCityNameProviderByNameErrors, GetV0CityByCityNameProviderByNameResponses, GetV0CityByCityNameProviderReadinessData, GetV0CityByCityNameProviderReadinessErrors, GetV0CityByCityNameProviderReadinessResponses, GetV0CityByCityNameProvidersData, GetV0CityByCityNameProvidersErrors, GetV0CityByCityNameProvidersPublicData, GetV0CityByCityNameProvidersPublicErrors, GetV0CityByCityNameProvidersPublicResponses, GetV0CityByCityNameProvidersResponses, GetV0CityByCityNameReadinessData, GetV0CityByCityNameReadinessErrors, GetV0CityByCityNameReadinessResponses, GetV0CityByCityNameResponses, GetV0CityByCityNameRigByNameData, GetV0CityByCityNameRigByNameErrors, GetV0CityByCityNameRigByNameResponses, GetV0CityByCityNameRigsData, GetV0CityByCityNameRigsErrors, GetV0CityByCityNameRigsResponses, GetV0CityByCityNameServiceByNameData, GetV0CityByCityNameServiceByNameErrors, GetV0CityByCityNameServiceByNameResponses, GetV0CityByCityNameServicesData, GetV0CityByCityNameServicesErrors, GetV0CityByCityNameServicesResponses, GetV0CityByCityNameSessionByIdAgentsByAgentIdData, GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses, GetV0CityByCityNameSessionByIdAgentsData, GetV0CityByCityNameSessionByIdAgentsErrors, GetV0CityByCityNameSessionByIdAgentsResponses, GetV0CityByCityNameSessionByIdData, GetV0CityByCityNameSessionByIdErrors, GetV0CityByCityNameSessionByIdPendingData, GetV0CityByCityNameSessionByIdPendingErrors, GetV0CityByCityNameSessionByIdPendingResponses, GetV0CityByCityNameSessionByIdResponses, GetV0CityByCityNameSessionByIdTranscriptData, GetV0CityByCityNameSessionByIdTranscriptErrors, GetV0CityByCityNameSessionByIdTranscriptResponses, GetV0CityByCityNameSessionsData, GetV0CityByCityNameSessionsErrors, GetV0CityByCityNameSessionsResponses, GetV0CityByCityNameStatusData, GetV0CityByCityNameStatusErrors, GetV0CityByCityNameStatusResponses, GetV0CityByCityNameWorkflowByWorkflowIdData, GetV0CityByCityNameWorkflowByWorkflowIdErrors, GetV0CityByCityNameWorkflowByWorkflowIdResponses, GetV0EventsData, GetV0EventsErrors, GetV0EventsResponses, GetV0ProviderReadinessData, GetV0ProviderReadinessErrors, GetV0ProviderReadinessResponses, GetV0ReadinessData, GetV0ReadinessErrors, GetV0ReadinessResponses, PatchV0CityByCityNameAgentByBaseData, PatchV0CityByCityNameAgentByBaseErrors, PatchV0CityByCityNameAgentByBaseResponses, PatchV0CityByCityNameAgentByDirByBaseData, PatchV0CityByCityNameAgentByDirByBaseErrors, PatchV0CityByCityNameAgentByDirByBaseResponses, PatchV0CityByCityNameBeadByIdData, PatchV0CityByCityNameBeadByIdErrors, PatchV0CityByCityNameBeadByIdResponses, PatchV0CityByCityNameData, PatchV0CityByCityNameErrors, PatchV0CityByCityNameProviderByNameData, PatchV0CityByCityNameProviderByNameErrors, PatchV0CityByCityNameProviderByNameResponses, PatchV0CityByCityNameResponses, PatchV0CityByCityNameRigByNameData, PatchV0CityByCityNameRigByNameErrors, PatchV0CityByCityNameRigByNameResponses, PatchV0CityByCityNameSessionByIdData, PatchV0CityByCityNameSessionByIdErrors, PatchV0CityByCityNameSessionByIdResponses, PostV0CityByCityNameAgentByBaseByActionData, PostV0CityByCityNameAgentByBaseByActionErrors, PostV0CityByCityNameAgentByBaseByActionResponses, PostV0CityByCityNameAgentByDirByBaseByActionData, PostV0CityByCityNameAgentByDirByBaseByActionErrors, PostV0CityByCityNameAgentByDirByBaseByActionResponses, PostV0CityByCityNameBeadByIdAssignData, PostV0CityByCityNameBeadByIdAssignErrors, PostV0CityByCityNameBeadByIdAssignResponses, PostV0CityByCityNameBeadByIdCloseData, PostV0CityByCityNameBeadByIdCloseErrors, PostV0CityByCityNameBeadByIdCloseResponses, PostV0CityByCityNameBeadByIdReopenData, PostV0CityByCityNameBeadByIdReopenErrors, PostV0CityByCityNameBeadByIdReopenResponses, PostV0CityByCityNameBeadByIdUpdateData, PostV0CityByCityNameBeadByIdUpdateErrors, PostV0CityByCityNameBeadByIdUpdateResponses, PostV0CityByCityNameConvoyByIdAddData, PostV0CityByCityNameConvoyByIdAddErrors, PostV0CityByCityNameConvoyByIdAddResponses, PostV0CityByCityNameConvoyByIdCloseData, PostV0CityByCityNameConvoyByIdCloseErrors, PostV0CityByCityNameConvoyByIdCloseResponses, PostV0CityByCityNameConvoyByIdRemoveData, PostV0CityByCityNameConvoyByIdRemoveErrors, PostV0CityByCityNameConvoyByIdRemoveResponses, PostV0CityByCityNameExtmsgBindData, PostV0CityByCityNameExtmsgBindErrors, PostV0CityByCityNameExtmsgBindResponses, PostV0CityByCityNameExtmsgInboundData, PostV0CityByCityNameExtmsgInboundErrors, PostV0CityByCityNameExtmsgInboundResponses, PostV0CityByCityNameExtmsgOutboundData, PostV0CityByCityNameExtmsgOutboundErrors, PostV0CityByCityNameExtmsgOutboundResponses, PostV0CityByCityNameExtmsgParticipantsData, PostV0CityByCityNameExtmsgParticipantsErrors, PostV0CityByCityNameExtmsgParticipantsResponses, PostV0CityByCityNameExtmsgTranscriptAckData, PostV0CityByCityNameExtmsgTranscriptAckErrors, PostV0CityByCityNameExtmsgTranscriptAckResponses, PostV0CityByCityNameExtmsgUnbindData, PostV0CityByCityNameExtmsgUnbindErrors, PostV0CityByCityNameExtmsgUnbindResponses, PostV0CityByCityNameFormulasByNamePreviewData, PostV0CityByCityNameFormulasByNamePreviewErrors, PostV0CityByCityNameFormulasByNamePreviewResponses, PostV0CityByCityNameMailByIdArchiveData, PostV0CityByCityNameMailByIdArchiveErrors, PostV0CityByCityNameMailByIdArchiveResponses, PostV0CityByCityNameMailByIdMarkUnreadData, PostV0CityByCityNameMailByIdMarkUnreadErrors, PostV0CityByCityNameMailByIdMarkUnreadResponses, PostV0CityByCityNameMailByIdReadData, PostV0CityByCityNameMailByIdReadErrors, PostV0CityByCityNameMailByIdReadResponses, PostV0CityByCityNameOrderByNameDisableData, PostV0CityByCityNameOrderByNameDisableErrors, PostV0CityByCityNameOrderByNameDisableResponses, PostV0CityByCityNameOrderByNameEnableData, PostV0CityByCityNameOrderByNameEnableErrors, PostV0CityByCityNameOrderByNameEnableResponses, PostV0CityByCityNameRigByNameByActionData, PostV0CityByCityNameRigByNameByActionErrors, PostV0CityByCityNameRigByNameByActionResponses, PostV0CityByCityNameServiceByNameRestartData, PostV0CityByCityNameServiceByNameRestartErrors, PostV0CityByCityNameServiceByNameRestartResponses, PostV0CityByCityNameSessionByIdCloseData, PostV0CityByCityNameSessionByIdCloseErrors, PostV0CityByCityNameSessionByIdCloseResponses, PostV0CityByCityNameSessionByIdKillData, PostV0CityByCityNameSessionByIdKillErrors, PostV0CityByCityNameSessionByIdKillResponses, PostV0CityByCityNameSessionByIdRenameData, PostV0CityByCityNameSessionByIdRenameErrors, PostV0CityByCityNameSessionByIdRenameResponses, PostV0CityByCityNameSessionByIdStopData, PostV0CityByCityNameSessionByIdStopErrors, PostV0CityByCityNameSessionByIdStopResponses, PostV0CityByCityNameSessionByIdSuspendData, PostV0CityByCityNameSessionByIdSuspendErrors, PostV0CityByCityNameSessionByIdSuspendResponses, PostV0CityByCityNameSessionByIdWakeData, PostV0CityByCityNameSessionByIdWakeErrors, PostV0CityByCityNameSessionByIdWakeResponses, PostV0CityByCityNameSlingData, PostV0CityByCityNameSlingErrors, PostV0CityByCityNameSlingResponses, PostV0CityByCityNameUnregisterData, PostV0CityByCityNameUnregisterErrors, PostV0CityByCityNameUnregisterResponses, PostV0CityData, PostV0CityErrors, PostV0CityResponses, PutV0CityByCityNamePatchesAgentsData, PutV0CityByCityNamePatchesAgentsErrors, PutV0CityByCityNamePatchesAgentsResponses, PutV0CityByCityNamePatchesProvidersData, PutV0CityByCityNamePatchesProvidersErrors, PutV0CityByCityNamePatchesProvidersResponses, PutV0CityByCityNamePatchesRigsData, PutV0CityByCityNamePatchesRigsErrors, PutV0CityByCityNamePatchesRigsResponses, RegisterExtmsgAdapterData, RegisterExtmsgAdapterErrors, RegisterExtmsgAdapterResponses, ReplyMailData, ReplyMailErrors, ReplyMailResponses, RespondSessionData, RespondSessionErrors, RespondSessionResponses, SendMailData, SendMailErrors, SendMailResponses, SendSessionMessageData, SendSessionMessageErrors, SendSessionMessageResponses, StreamAgentOutputData, StreamAgentOutputErrors, StreamAgentOutputQualifiedData, StreamAgentOutputQualifiedErrors, StreamAgentOutputQualifiedResponse, StreamAgentOutputQualifiedResponses, StreamAgentOutputResponse, StreamAgentOutputResponses, StreamEventsData, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, StreamSessionData, StreamSessionErrors, StreamSessionResponse, StreamSessionResponses, StreamSupervisorEventsData, StreamSupervisorEventsErrors, StreamSupervisorEventsResponse, StreamSupervisorEventsResponses, SubmitSessionData, SubmitSessionErrors, SubmitSessionResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Get health + */ +export const getHealth = (options?: Options) => (options?.client ?? client).get({ url: '/health', ...options }); + +/** + * Get v0 cities + */ +export const getV0Cities = (options?: Options) => (options?.client ?? client).get({ url: '/v0/cities', ...options }); + +/** + * Post v0 city + */ +export const postV0City = (options: Options) => (options.client ?? client).post({ + url: '/v0/city', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name + */ +export const getV0CityByCityName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}', ...options }); + +/** + * Patch v0 city by city name + */ +export const patchV0CityByCityName = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete v0 city by city name agent by base + */ +export const deleteV0CityByCityNameAgentByBase = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/agent/{base}', ...options }); + +/** + * Get v0 city by city name agent by base + */ +export const getV0CityByCityNameAgentByBase = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/agent/{base}', ...options }); + +/** + * Patch v0 city by city name agent by base + */ +export const patchV0CityByCityNameAgentByBase = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/agent/{base}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name agent by base output + */ +export const getV0CityByCityNameAgentByBaseOutput = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/agent/{base}/output', ...options }); + +/** + * Stream agent output in real time + * + * Server-Sent Events stream of agent output (session log tail or tmux pane polling). + */ +export const streamAgentOutput = (options: Options) => (options.client ?? client).sse.get({ url: '/v0/city/{cityName}/agent/{base}/output/stream', ...options }); + +/** + * Post v0 city by city name agent by base by action + */ +export const postV0CityByCityNameAgentByBaseByAction = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/agent/{base}/{action}', ...options }); + +/** + * Delete v0 city by city name agent by dir by base + */ +export const deleteV0CityByCityNameAgentByDirByBase = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/agent/{dir}/{base}', ...options }); + +/** + * Get v0 city by city name agent by dir by base + */ +export const getV0CityByCityNameAgentByDirByBase = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/agent/{dir}/{base}', ...options }); + +/** + * Patch v0 city by city name agent by dir by base + */ +export const patchV0CityByCityNameAgentByDirByBase = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/agent/{dir}/{base}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name agent by dir by base output + */ +export const getV0CityByCityNameAgentByDirByBaseOutput = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/agent/{dir}/{base}/output', ...options }); + +/** + * Stream agent output in real time (qualified name) + * + * Server-Sent Events stream of agent output for qualified (rig-prefixed) agent names. + */ +export const streamAgentOutputQualified = (options: Options) => (options.client ?? client).sse.get({ url: '/v0/city/{cityName}/agent/{dir}/{base}/output/stream', ...options }); + +/** + * Post v0 city by city name agent by dir by base by action + */ +export const postV0CityByCityNameAgentByDirByBaseByAction = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/agent/{dir}/{base}/{action}', ...options }); + +/** + * Get v0 city by city name agents + */ +export const getV0CityByCityNameAgents = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/agents', ...options }); + +/** + * Create an agent + */ +export const createAgent = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/agents', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete v0 city by city name bead by ID + */ +export const deleteV0CityByCityNameBeadById = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/bead/{id}', ...options }); + +/** + * Get v0 city by city name bead by ID + */ +export const getV0CityByCityNameBeadById = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/bead/{id}', ...options }); + +/** + * Patch v0 city by city name bead by ID + */ +export const patchV0CityByCityNameBeadById = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/bead/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name bead by ID assign + */ +export const postV0CityByCityNameBeadByIdAssign = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/bead/{id}/assign', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name bead by ID close + */ +export const postV0CityByCityNameBeadByIdClose = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/bead/{id}/close', ...options }); + +/** + * Get v0 city by city name bead by ID deps + */ +export const getV0CityByCityNameBeadByIdDeps = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/bead/{id}/deps', ...options }); + +/** + * Post v0 city by city name bead by ID reopen + */ +export const postV0CityByCityNameBeadByIdReopen = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/bead/{id}/reopen', ...options }); + +/** + * Post v0 city by city name bead by ID update + */ +export const postV0CityByCityNameBeadByIdUpdate = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/bead/{id}/update', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name beads + */ +export const getV0CityByCityNameBeads = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/beads', ...options }); + +/** + * Create a bead + */ +export const createBead = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/beads', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name beads graph by root ID + */ +export const getV0CityByCityNameBeadsGraphByRootId = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/beads/graph/{rootID}', ...options }); + +/** + * Get v0 city by city name beads ready + */ +export const getV0CityByCityNameBeadsReady = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/beads/ready', ...options }); + +/** + * Get v0 city by city name config + */ +export const getV0CityByCityNameConfig = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/config', ...options }); + +/** + * Get v0 city by city name config explain + */ +export const getV0CityByCityNameConfigExplain = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/config/explain', ...options }); + +/** + * Get v0 city by city name config validate + */ +export const getV0CityByCityNameConfigValidate = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/config/validate', ...options }); + +/** + * Delete v0 city by city name convoy by ID + */ +export const deleteV0CityByCityNameConvoyById = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/convoy/{id}', ...options }); + +/** + * Get v0 city by city name convoy by ID + */ +export const getV0CityByCityNameConvoyById = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/convoy/{id}', ...options }); + +/** + * Post v0 city by city name convoy by ID add + */ +export const postV0CityByCityNameConvoyByIdAdd = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/convoy/{id}/add', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name convoy by ID check + */ +export const getV0CityByCityNameConvoyByIdCheck = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/convoy/{id}/check', ...options }); + +/** + * Post v0 city by city name convoy by ID close + */ +export const postV0CityByCityNameConvoyByIdClose = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/convoy/{id}/close', ...options }); + +/** + * Post v0 city by city name convoy by ID remove + */ +export const postV0CityByCityNameConvoyByIdRemove = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/convoy/{id}/remove', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name convoys + */ +export const getV0CityByCityNameConvoys = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/convoys', ...options }); + +/** + * Create a convoy + */ +export const createConvoy = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/convoys', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name events + */ +export const getV0CityByCityNameEvents = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/events', ...options }); + +/** + * Emit an event + */ +export const emitEvent = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/events', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Stream city events in real time + * + * Server-Sent Events stream of city events with optional workflow projections. Supports reconnection via Last-Event-ID header or after_seq query param. + */ +export const streamEvents = (options: Options) => (options.client ?? client).sse.get({ url: '/v0/city/{cityName}/events/stream', ...options }); + +/** + * Delete v0 city by city name extmsg adapters + */ +export const deleteV0CityByCityNameExtmsgAdapters = (options: Options) => (options.client ?? client).delete({ + url: '/v0/city/{cityName}/extmsg/adapters', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name extmsg adapters + */ +export const getV0CityByCityNameExtmsgAdapters = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/extmsg/adapters', ...options }); + +/** + * Register an external messaging adapter + */ +export const registerExtmsgAdapter = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/adapters', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name extmsg bind + */ +export const postV0CityByCityNameExtmsgBind = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/bind', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name extmsg bindings + */ +export const getV0CityByCityNameExtmsgBindings = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/extmsg/bindings', ...options }); + +/** + * Get v0 city by city name extmsg groups + */ +export const getV0CityByCityNameExtmsgGroups = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/extmsg/groups', ...options }); + +/** + * Ensure an external messaging group exists + */ +export const ensureExtmsgGroup = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/groups', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name extmsg inbound + */ +export const postV0CityByCityNameExtmsgInbound = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/inbound', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name extmsg outbound + */ +export const postV0CityByCityNameExtmsgOutbound = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/outbound', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete v0 city by city name extmsg participants + */ +export const deleteV0CityByCityNameExtmsgParticipants = (options: Options) => (options.client ?? client).delete({ + url: '/v0/city/{cityName}/extmsg/participants', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name extmsg participants + */ +export const postV0CityByCityNameExtmsgParticipants = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/participants', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name extmsg transcript + */ +export const getV0CityByCityNameExtmsgTranscript = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/extmsg/transcript', ...options }); + +/** + * Post v0 city by city name extmsg transcript ack + */ +export const postV0CityByCityNameExtmsgTranscriptAck = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/transcript/ack', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name extmsg unbind + */ +export const postV0CityByCityNameExtmsgUnbind = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/extmsg/unbind', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name formula by name + */ +export const getV0CityByCityNameFormulaByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/formula/{name}', ...options }); + +/** + * Get v0 city by city name formulas + */ +export const getV0CityByCityNameFormulas = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/formulas', ...options }); + +/** + * Get v0 city by city name formulas feed + */ +export const getV0CityByCityNameFormulasFeed = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/formulas/feed', ...options }); + +/** + * Get v0 city by city name formulas by name + */ +export const getV0CityByCityNameFormulasByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/formulas/{name}', ...options }); + +/** + * Post v0 city by city name formulas by name preview + */ +export const postV0CityByCityNameFormulasByNamePreview = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/formulas/{name}/preview', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name formulas by name runs + */ +export const getV0CityByCityNameFormulasByNameRuns = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/formulas/{name}/runs', ...options }); + +/** + * Get v0 city by city name health + */ +export const getV0CityByCityNameHealth = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/health', ...options }); + +/** + * Get v0 city by city name mail + */ +export const getV0CityByCityNameMail = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/mail', ...options }); + +/** + * Send a mail message + */ +export const sendMail = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/mail', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name mail count + */ +export const getV0CityByCityNameMailCount = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/mail/count', ...options }); + +/** + * Get v0 city by city name mail thread by ID + */ +export const getV0CityByCityNameMailThreadById = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/mail/thread/{id}', ...options }); + +/** + * Delete v0 city by city name mail by ID + */ +export const deleteV0CityByCityNameMailById = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/mail/{id}', ...options }); + +/** + * Get v0 city by city name mail by ID + */ +export const getV0CityByCityNameMailById = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/mail/{id}', ...options }); + +/** + * Post v0 city by city name mail by ID archive + */ +export const postV0CityByCityNameMailByIdArchive = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/mail/{id}/archive', ...options }); + +/** + * Post v0 city by city name mail by ID mark unread + */ +export const postV0CityByCityNameMailByIdMarkUnread = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/mail/{id}/mark-unread', ...options }); + +/** + * Post v0 city by city name mail by ID read + */ +export const postV0CityByCityNameMailByIdRead = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/mail/{id}/read', ...options }); + +/** + * Reply to a mail message + */ +export const replyMail = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/mail/{id}/reply', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name order history by bead ID + */ +export const getV0CityByCityNameOrderHistoryByBeadId = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/order/history/{bead_id}', ...options }); + +/** + * Get v0 city by city name order by name + */ +export const getV0CityByCityNameOrderByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/order/{name}', ...options }); + +/** + * Post v0 city by city name order by name disable + */ +export const postV0CityByCityNameOrderByNameDisable = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/order/{name}/disable', ...options }); + +/** + * Post v0 city by city name order by name enable + */ +export const postV0CityByCityNameOrderByNameEnable = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/order/{name}/enable', ...options }); + +/** + * Get v0 city by city name orders + */ +export const getV0CityByCityNameOrders = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/orders', ...options }); + +/** + * Get v0 city by city name orders check + */ +export const getV0CityByCityNameOrdersCheck = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/orders/check', ...options }); + +/** + * Get v0 city by city name orders feed + */ +export const getV0CityByCityNameOrdersFeed = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/orders/feed', ...options }); + +/** + * Get v0 city by city name orders history + */ +export const getV0CityByCityNameOrdersHistory = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/orders/history', ...options }); + +/** + * Get v0 city by city name packs + */ +export const getV0CityByCityNamePacks = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/packs', ...options }); + +/** + * Delete v0 city by city name patches agent by base + */ +export const deleteV0CityByCityNamePatchesAgentByBase = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/patches/agent/{base}', ...options }); + +/** + * Get v0 city by city name patches agent by base + */ +export const getV0CityByCityNamePatchesAgentByBase = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/agent/{base}', ...options }); + +/** + * Delete v0 city by city name patches agent by dir by base + */ +export const deleteV0CityByCityNamePatchesAgentByDirByBase = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/patches/agent/{dir}/{base}', ...options }); + +/** + * Get v0 city by city name patches agent by dir by base + */ +export const getV0CityByCityNamePatchesAgentByDirByBase = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/agent/{dir}/{base}', ...options }); + +/** + * Get v0 city by city name patches agents + */ +export const getV0CityByCityNamePatchesAgents = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/agents', ...options }); + +/** + * Put v0 city by city name patches agents + */ +export const putV0CityByCityNamePatchesAgents = (options: Options) => (options.client ?? client).put({ + url: '/v0/city/{cityName}/patches/agents', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete v0 city by city name patches provider by name + */ +export const deleteV0CityByCityNamePatchesProviderByName = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/patches/provider/{name}', ...options }); + +/** + * Get v0 city by city name patches provider by name + */ +export const getV0CityByCityNamePatchesProviderByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/provider/{name}', ...options }); + +/** + * Get v0 city by city name patches providers + */ +export const getV0CityByCityNamePatchesProviders = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/providers', ...options }); + +/** + * Put v0 city by city name patches providers + */ +export const putV0CityByCityNamePatchesProviders = (options: Options) => (options.client ?? client).put({ + url: '/v0/city/{cityName}/patches/providers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete v0 city by city name patches rig by name + */ +export const deleteV0CityByCityNamePatchesRigByName = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/patches/rig/{name}', ...options }); + +/** + * Get v0 city by city name patches rig by name + */ +export const getV0CityByCityNamePatchesRigByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/rig/{name}', ...options }); + +/** + * Get v0 city by city name patches rigs + */ +export const getV0CityByCityNamePatchesRigs = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/patches/rigs', ...options }); + +/** + * Put v0 city by city name patches rigs + */ +export const putV0CityByCityNamePatchesRigs = (options: Options) => (options.client ?? client).put({ + url: '/v0/city/{cityName}/patches/rigs', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name provider readiness + */ +export const getV0CityByCityNameProviderReadiness = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/provider-readiness', ...options }); + +/** + * Delete v0 city by city name provider by name + */ +export const deleteV0CityByCityNameProviderByName = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/provider/{name}', ...options }); + +/** + * Get v0 city by city name provider by name + */ +export const getV0CityByCityNameProviderByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/provider/{name}', ...options }); + +/** + * Patch v0 city by city name provider by name + */ +export const patchV0CityByCityNameProviderByName = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/provider/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name providers + */ +export const getV0CityByCityNameProviders = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/providers', ...options }); + +/** + * Create a provider + */ +export const createProvider = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/providers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name providers public + */ +export const getV0CityByCityNameProvidersPublic = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/providers/public', ...options }); + +/** + * Get v0 city by city name readiness + */ +export const getV0CityByCityNameReadiness = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/readiness', ...options }); + +/** + * Delete v0 city by city name rig by name + */ +export const deleteV0CityByCityNameRigByName = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/rig/{name}', ...options }); + +/** + * Get v0 city by city name rig by name + */ +export const getV0CityByCityNameRigByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/rig/{name}', ...options }); + +/** + * Patch v0 city by city name rig by name + */ +export const patchV0CityByCityNameRigByName = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/rig/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name rig by name by action + */ +export const postV0CityByCityNameRigByNameByAction = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/rig/{name}/{action}', ...options }); + +/** + * Get v0 city by city name rigs + */ +export const getV0CityByCityNameRigs = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/rigs', ...options }); + +/** + * Create a rig + */ +export const createRig = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/rigs', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name service by name + */ +export const getV0CityByCityNameServiceByName = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/service/{name}', ...options }); + +/** + * Post v0 city by city name service by name restart + */ +export const postV0CityByCityNameServiceByNameRestart = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/service/{name}/restart', ...options }); + +/** + * Get v0 city by city name services + */ +export const getV0CityByCityNameServices = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/services', ...options }); + +/** + * Get v0 city by city name session by ID + */ +export const getV0CityByCityNameSessionById = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/session/{id}', ...options }); + +/** + * Patch v0 city by city name session by ID + */ +export const patchV0CityByCityNameSessionById = (options: Options) => (options.client ?? client).patch({ + url: '/v0/city/{cityName}/session/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name session by ID agents + */ +export const getV0CityByCityNameSessionByIdAgents = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/session/{id}/agents', ...options }); + +/** + * Get v0 city by city name session by ID agents by agent ID + */ +export const getV0CityByCityNameSessionByIdAgentsByAgentId = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/session/{id}/agents/{agentId}', ...options }); + +/** + * Post v0 city by city name session by ID close + */ +export const postV0CityByCityNameSessionByIdClose = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/session/{id}/close', ...options }); + +/** + * Post v0 city by city name session by ID kill + */ +export const postV0CityByCityNameSessionByIdKill = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/session/{id}/kill', ...options }); + +/** + * Send a message to a session + */ +export const sendSessionMessage = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/session/{id}/messages', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name session by ID pending + */ +export const getV0CityByCityNameSessionByIdPending = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/session/{id}/pending', ...options }); + +/** + * Post v0 city by city name session by ID rename + */ +export const postV0CityByCityNameSessionByIdRename = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/session/{id}/rename', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Respond to a pending interaction + */ +export const respondSession = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/session/{id}/respond', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name session by ID stop + */ +export const postV0CityByCityNameSessionByIdStop = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/session/{id}/stop', ...options }); + +/** + * Stream session output in real time + * + * Server-Sent Events stream of session transcript updates. Streams turns (conversation format) or raw messages (JSONL format) based on the format query parameter. Emits activity and pending events for tool approval prompts. + */ +export const streamSession = (options: Options) => (options.client ?? client).sse.get({ url: '/v0/city/{cityName}/session/{id}/stream', ...options }); + +/** + * Submit a message to a session + */ +export const submitSession = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/session/{id}/submit', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name session by ID suspend + */ +export const postV0CityByCityNameSessionByIdSuspend = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/session/{id}/suspend', ...options }); + +/** + * Get v0 city by city name session by ID transcript + */ +export const getV0CityByCityNameSessionByIdTranscript = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/session/{id}/transcript', ...options }); + +/** + * Post v0 city by city name session by ID wake + */ +export const postV0CityByCityNameSessionByIdWake = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/session/{id}/wake', ...options }); + +/** + * Get v0 city by city name sessions + */ +export const getV0CityByCityNameSessions = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/sessions', ...options }); + +/** + * Create a session + */ +export const createSession = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/sessions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post v0 city by city name sling + */ +export const postV0CityByCityNameSling = (options: Options) => (options.client ?? client).post({ + url: '/v0/city/{cityName}/sling', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get v0 city by city name status + */ +export const getV0CityByCityNameStatus = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/status', ...options }); + +/** + * Post v0 city by city name unregister + */ +export const postV0CityByCityNameUnregister = (options: Options) => (options.client ?? client).post({ url: '/v0/city/{cityName}/unregister', ...options }); + +/** + * Delete v0 city by city name workflow by workflow ID + */ +export const deleteV0CityByCityNameWorkflowByWorkflowId = (options: Options) => (options.client ?? client).delete({ url: '/v0/city/{cityName}/workflow/{workflow_id}', ...options }); + +/** + * Get v0 city by city name workflow by workflow ID + */ +export const getV0CityByCityNameWorkflowByWorkflowId = (options: Options) => (options.client ?? client).get({ url: '/v0/city/{cityName}/workflow/{workflow_id}', ...options }); + +/** + * Get v0 events + */ +export const getV0Events = (options?: Options) => (options?.client ?? client).get({ url: '/v0/events', ...options }); + +/** + * Stream tagged events from all running cities. + */ +export const streamSupervisorEvents = (options?: Options) => (options?.client ?? client).sse.get({ url: '/v0/events/stream', ...options }); + +/** + * Get v0 provider readiness + */ +export const getV0ProviderReadiness = (options?: Options) => (options?.client ?? client).get({ url: '/v0/provider-readiness', ...options }); + +/** + * Get v0 readiness + */ +export const getV0Readiness = (options?: Options) => (options?.client ?? client).get({ url: '/v0/readiness', ...options }); diff --git a/cmd/gc/dashboard/web/src/generated/types.gen.ts b/cmd/gc/dashboard/web/src/generated/types.gen.ts new file mode 100644 index 000000000..5b755c30b --- /dev/null +++ b/cmd/gc/dashboard/web/src/generated/types.gen.ts @@ -0,0 +1,10074 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +export type AdapterCapabilities = { + MaxMessageLength: number; + SupportsAttachments: boolean; + SupportsChildConversations: boolean; +}; + +export type AdapterEventPayload = { + account_id: string; + provider: string; +}; + +export type AgentCreateInputBody = { + /** + * Working directory (rig name). + */ + dir?: string; + /** + * Agent name. + */ + name: string; + /** + * Provider name. + */ + provider: string; + /** + * Agent scope. + */ + scope?: string; +}; + +export type AgentCreatedOutputBody = { + /** + * Created agent name. + */ + agent: string; + /** + * Operation result. + */ + status: string; +}; + +export type AgentMapping = { + agent_id: string; + parent_tool_use_id: string; +}; + +export type AgentOutputResponse = { + agent: string; + format: string; + pagination?: PaginationInfo; + turns: Array | null; +}; + +export type AgentPatch = { + AppendFragments: Array | null; + Attach: boolean | null; + DefaultSlingFormula: string | null; + DependsOn: Array | null; + Dir: string; + Env: { + [key: string]: string; + }; + EnvRemove: Array | null; + HooksInstalled: boolean | null; + IdleTimeout: string | null; + InjectAssignedSkills: boolean | null; + InjectFragments: Array | null; + InjectFragmentsAppend: Array | null; + InstallAgentHooks: Array | null; + InstallAgentHooksAppend: Array | null; + MCP: Array | null; + MCPAppend: Array | null; + MaxActiveSessions: number | null; + MinActiveSessions: number | null; + Name: string; + Nudge: string | null; + OptionDefaults: { + [key: string]: string; + }; + OverlayDir: string | null; + Pool: PoolOverride; + PreStart: Array | null; + PreStartAppend: Array | null; + PromptTemplate: string | null; + Provider: string | null; + ResumeCommand: string | null; + ScaleCheck: string | null; + Scope: string | null; + Session: string | null; + SessionLive: Array | null; + SessionLiveAppend: Array | null; + SessionSetup: Array | null; + SessionSetupAppend: Array | null; + SessionSetupScript: string | null; + Skills: Array | null; + SkillsAppend: Array | null; + SleepAfterIdle: string | null; + StartCommand: string | null; + Suspended: boolean | null; + WakeMode: string | null; + WorkDir: string | null; +}; + +export type AgentPatchSetInputBody = { + /** + * Agent directory scope. + */ + dir?: string; + /** + * Override environment variables. + */ + env?: { + [key: string]: string; + }; + /** + * Agent name. + */ + name?: string; + /** + * Override agent scope. + */ + scope?: string; + /** + * Override suspended state. + */ + suspended?: boolean; + /** + * Override session working directory. + */ + work_dir?: string; +}; + +export type AgentResponse = { + active_bead?: string; + activity?: string; + available: boolean; + context_pct?: number; + context_window?: number; + description?: string; + display_name?: string; + last_output?: string; + model?: string; + name: string; + pool?: string; + provider?: string; + rig?: string; + running: boolean; + session?: SessionInfo; + state: string; + suspended: boolean; + unavailable_reason?: string; +}; + +export type AgentUpdateInputBody = { + /** + * Provider name. + */ + provider?: string; + /** + * Agent scope. + */ + scope?: string; + /** + * Whether agent is suspended. + */ + suspended?: boolean; +}; + +export type AgentUpdateQualifiedInputBody = { + /** + * Provider name. + */ + provider?: string; + /** + * Agent scope. + */ + scope?: string; + /** + * Whether agent is suspended. + */ + suspended?: boolean; +}; + +export type AnnotatedAgentResponse = { + dir?: string; + is_pool?: boolean; + name: string; + /** + * Agent origin: inline or pack-derived. + */ + origin: string; + provider?: string; + scope?: string; + suspended: boolean; +}; + +export type AnnotatedProviderResponse = { + acp_args?: Array; + acp_command?: string; + args?: Array | null; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + /** + * Provider origin: builtin, city, or builtin+city. + */ + origin: string; + prompt_flag?: string; + prompt_mode?: string; + ready_delay_ms?: number; +}; + +export type Bead = { + assignee?: string; + created_at: string; + dependencies?: Array | null; + description?: string; + from?: string; + id: string; + issue_type: string; + labels?: Array | null; + metadata?: { + [key: string]: string; + }; + needs?: Array | null; + parent?: string; + priority?: number; + ref?: string; + status: string; + title: string; +}; + +export type BeadAssignInputBody = { + /** + * Assignee name. + */ + assignee?: string; +}; + +export type BeadCreateInputBody = { + /** + * Assigned agent. + */ + assignee?: string; + /** + * Bead description. + */ + description?: string; + /** + * Bead labels. + */ + labels?: Array | null; + /** + * Metadata key-value pairs to set at create time. + */ + metadata?: { + [key: string]: string; + }; + /** + * Parent bead ID. + */ + parent?: string; + /** + * Bead priority. + */ + priority?: number; + /** + * Rig name. + */ + rig?: string; + /** + * Bead title. + */ + title: string; + /** + * Bead type. + */ + type?: string; +}; + +export type BeadDepsResponse = { + children: Array | null; +}; + +export type BeadEventPayload = { + bead: Bead; +}; + +export type BeadGraphResponse = { + beads: Array | null; + deps: Array | null; + root: Bead; +}; + +export type BeadUpdateBody = { + /** + * Assigned agent. + */ + assignee?: string; + /** + * Bead description. + */ + description?: string; + /** + * Bead labels. + */ + labels?: Array | null; + /** + * Metadata key-value pairs to set. + */ + metadata?: { + [key: string]: string; + }; + /** + * Parent bead ID. Use null or an empty string to clear. + */ + parent?: string | null; + /** + * Bead priority. + */ + priority?: number; + /** + * Labels to remove. + */ + remove_labels?: Array | null; + /** + * Bead status. + */ + status?: string; + /** + * Bead title. + */ + title?: string; + /** + * Bead type. + */ + type?: string; +}; + +/** + * Lifecycle state of a session binding. + */ +export type BindingStatus = 'active' | 'ended'; + +export type BoundEventPayload = { + conversation_id: string; + provider: string; + session_id: string; +}; + +export type CityCreateRequest = { + /** + * Optional bootstrap profile. + */ + bootstrap_profile?: 'k8s-cell' | 'kubernetes' | 'kubernetes-cell' | 'single-host-compat'; + /** + * Directory to create the city in. Absolute or relative to $HOME. + */ + dir: string; + /** + * Provider name for the city's default session template. + */ + provider: string; +}; + +export type CityCreateResponse = { + /** + * Resolved city name as persisted in city.toml. Use this to filter the event stream for completion. + */ + name: string; + /** + * True when scaffolding + registration succeeded. Does not imply the city is ready yet; watch /v0/events/stream for city.ready. + */ + ok: boolean; + /** + * Resolved absolute path of the created city directory. + */ + path: string; +}; + +export type CityGetResponse = { + agent_count: number; + name: string; + path: string; + provider?: string; + rig_count: number; + session_template?: string; + suspended: boolean; + uptime_sec: number; + version?: string; +}; + +export type CityInfo = { + error?: string; + name: string; + path: string; + phases_completed?: Array | null; + running: boolean; + status?: string; +}; + +export type CityLifecyclePayload = { + error?: string; + name: string; + path: string; + phases_completed?: Array | null; +}; + +export type CityPatchInputBody = { + /** + * Whether the city is suspended. + */ + suspended?: boolean; +}; + +export type CityUnregisterResponse = { + /** + * Resolved registry name. Filter the event stream by this to observe completion. + */ + name: string; + /** + * True when the registry entry was removed and the supervisor was signaled. Does not imply the city's controller has stopped yet; watch /v0/events/stream for city.unregistered. + */ + ok: boolean; + /** + * Resolved absolute city directory. The directory itself is not modified; unregister only affects the supervisor's registry. + */ + path: string; +}; + +export type ConfigAgentResponse = { + dir?: string; + is_pool?: boolean; + name: string; + provider?: string; + scope?: string; + suspended: boolean; +}; + +export type ConfigExplainPatches = { + agents: number; + providers: number; + rigs: number; +}; + +export type ConfigExplainResponse = { + agents: Array | null; + patches: ConfigExplainPatches; + providers: { + [key: string]: AnnotatedProviderResponse; + }; +}; + +export type ConfigPatchesResponse = { + agent_count: number; + provider_count: number; + rig_count: number; +}; + +export type ConfigResponse = { + agents: Array | null; + patches?: ConfigPatchesResponse; + providers?: { + [key: string]: ProviderSpecJson; + }; + rigs: Array | null; + workspace: WorkspaceResponse; +}; + +export type ConfigRigResponse = { + name: string; + path: string; + prefix?: string; + suspended: boolean; +}; + +export type ConfigValidateOutputBody = { + /** + * Validation errors. + */ + errors: Array | null; + /** + * Whether the configuration is valid. + */ + valid: boolean; + /** + * Validation warnings. + */ + warnings: Array | null; +}; + +export type ConversationGroupParticipant = { + GroupID: string; + Handle: string; + ID: string; + Metadata: { + [key: string]: string; + }; + Public: boolean; + SessionID: string; +}; + +export type ConversationGroupRecord = { + DefaultHandle: string; + FanoutPolicy: FanoutPolicy; + ID: string; + LastAddressedHandle: string; + Metadata: { + [key: string]: string; + }; + Mode: string; + RootConversation: ConversationRef; + SchemaVersion: number; +}; + +/** + * Shape of a conversation. + */ +export type ConversationKind = 'dm' | 'room' | 'thread'; + +export type ConversationRef = { + account_id: string; + conversation_id: string; + kind: ConversationKind; + parent_conversation_id?: string; + provider: string; + scope_id: string; +}; + +export type ConversationTranscriptRecord = { + Actor: ExternalActor; + Attachments: Array | null; + Conversation: ConversationRef; + CreatedAt: string; + ExplicitTarget: string; + ID: string; + Kind: TranscriptMessageKind; + Metadata: { + [key: string]: string; + }; + Provenance: TranscriptProvenance; + ProviderMessageID: string; + ReplyToMessageID: string; + SchemaVersion: number; + Sequence: number; + SourceSessionID: string; + Text: string; +}; + +export type ConvoyAddInputBody = { + /** + * Bead IDs to add. + */ + items?: Array | null; +}; + +export type ConvoyCheckResponse = { + /** + * Closed child bead count. + */ + closed: number; + /** + * True when all child beads are closed and total > 0. + */ + complete: boolean; + /** + * Convoy ID. + */ + convoy_id: string; + /** + * Total child bead count. + */ + total: number; +}; + +export type ConvoyCreateInputBody = { + /** + * Bead IDs to include. + */ + items?: Array | null; + /** + * Rig name. + */ + rig?: string; + /** + * Convoy title. + */ + title: string; +}; + +export type ConvoyGetResponse = { + /** + * Direct child beads (non-workflow case). + */ + children?: Array | null; + /** + * Simple convoy bead (non-workflow case). + */ + convoy?: Bead; + /** + * Child bead progress (non-workflow case). + */ + progress?: ConvoyProgress; +}; + +export type ConvoyProgress = { + /** + * Closed child bead count. + */ + closed: number; + /** + * Total child bead count. + */ + total: number; +}; + +export type ConvoyRemoveInputBody = { + /** + * Bead IDs to remove. + */ + items?: Array | null; +}; + +export type DeliveryContextRecord = { + BindingGeneration: number; + Conversation: ConversationRef; + ID: string; + LastMessageID: string; + LastPublishedAt: string; + Metadata: { + [key: string]: string; + }; + SchemaVersion: number; + SessionID: string; + SourceSessionID: string; +}; + +export type Dep = { + depends_on_id: string; + issue_id: string; + type: string; +}; + +export type ErrorDetail = { + /** + * Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + */ + location?: string; + /** + * Error message text + */ + message?: string; + /** + * The value at the given location + */ + value?: unknown; +}; + +export type ErrorModel = { + /** + * A human-readable explanation specific to this occurrence of the problem. + */ + detail?: string; + /** + * Optional list of individual error details + */ + errors?: Array | null; + /** + * A URI reference that identifies the specific occurrence of the problem. + */ + instance?: string; + /** + * HTTP status code + */ + status?: number; + /** + * A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + */ + title?: string; + /** + * A URI reference to human-readable documentation for the error. + */ + type?: string; +}; + +export type EventEmitOutputBody = { + /** + * Operation result. + */ + status: string; +}; + +export type EventEmitRequest = { + /** + * Actor that produced the event. + */ + actor: string; + /** + * Event message. + */ + message?: string; + /** + * Event subject. + */ + subject?: string; + /** + * Event type. + */ + type: string; +}; + +export type EventPayload = AdapterEventPayload | BeadEventPayload | BoundEventPayload | CityLifecyclePayload | GroupCreatedEventPayload | InboundEventPayload | MailEventPayload | NoPayload | OutboundEventPayload | UnboundEventPayload | WorkerOperationEventPayload; + +export type EventStreamEnvelope = { + actor: string; + message?: string; + payload?: EventPayload; + seq: number; + subject?: string; + ts: string; + type: string; + workflow?: WorkflowEventProjection; +}; + +export type ExtMsgAdapterRegisterInputBody = { + /** + * Account ID. + */ + account_id: string; + /** + * Callback URL for outbound messages. + */ + callback_url?: string; + /** + * Adapter capabilities. + */ + capabilities?: AdapterCapabilities; + /** + * Adapter display name. + */ + name?: string; + /** + * Provider name. + */ + provider: string; +}; + +export type ExtMsgAdapterRegisterOutputBody = { + /** + * Account ID. + */ + account_id: string; + /** + * Adapter name. + */ + name: string; + /** + * Provider name. + */ + provider: string; + /** + * Operation result. + */ + status: string; +}; + +export type ExtMsgAdapterUnregisterInputBody = { + /** + * Account ID. + */ + account_id: string; + /** + * Provider name. + */ + provider: string; +}; + +export type ExtMsgBindInputBody = { + /** + * Conversation to bind. + */ + conversation?: ConversationRef; + /** + * Optional binding metadata. + */ + metadata?: { + [key: string]: string; + }; + /** + * Session ID to bind. + */ + session_id: string; +}; + +export type ExtMsgGroupEnsureInputBody = { + /** + * Default handle for the group. + */ + default_handle?: string; + /** + * Group metadata. + */ + metadata?: { + [key: string]: string; + }; + /** + * Group mode (launcher, etc.). + */ + mode?: string; + /** + * Root conversation reference. + */ + root_conversation?: ConversationRef; +}; + +export type ExtMsgInboundInputBody = { + /** + * Account ID for raw payloads (required when message is absent). + */ + account_id?: string; + /** + * Pre-normalized inbound message. + */ + message?: ExternalInboundMessage; + /** + * Raw payload bytes. + */ + payload?: string; + /** + * Provider name for raw payloads (required when message is absent). + */ + provider?: string; +}; + +export type ExtMsgOutboundInputBody = { + /** + * Target conversation. + */ + conversation?: ConversationRef; + /** + * Idempotency key. + */ + idempotency_key?: string; + /** + * Message ID to reply to. + */ + reply_to_message_id?: string; + /** + * Session ID. + */ + session_id: string; + /** + * Message text. + */ + text?: string; +}; + +export type ExtMsgParticipantRemoveInputBody = { + /** + * Group ID. + */ + group_id: string; + /** + * Participant handle. + */ + handle: string; +}; + +export type ExtMsgParticipantUpsertInputBody = { + /** + * Group ID. + */ + group_id: string; + /** + * Participant handle. + */ + handle: string; + /** + * Participant metadata. + */ + metadata?: { + [key: string]: string; + }; + /** + * Whether participant is public. + */ + public?: boolean; + /** + * Session ID. + */ + session_id: string; +}; + +export type ExtMsgTranscriptAckInputBody = { + /** + * Conversation to acknowledge. + */ + conversation?: ConversationRef; + /** + * Sequence number to acknowledge up to. + */ + sequence?: number; + /** + * Session ID. + */ + session_id: string; +}; + +export type ExtMsgUnbindBody = { + /** + * Bindings that were removed. + */ + unbound: Array | null; +}; + +export type ExtMsgUnbindInputBody = { + /** + * Conversation to unbind (nil = all). + */ + conversation?: ConversationRef; + /** + * Session ID to unbind. + */ + session_id: string; +}; + +export type ExternalActor = { + display_name: string; + id: string; + is_bot: boolean; +}; + +export type ExternalAttachment = { + mime_type: string; + provider_id: string; + url: string; +}; + +export type ExternalInboundMessage = { + actor: ExternalActor; + attachments?: Array | null; + conversation: ConversationRef; + dedup_key?: string; + explicit_target?: string; + provider_message_id: string; + received_at: string; + reply_to_message_id?: string; + text: string; +}; + +export type ExtmsgAdapterInfo = { + /** + * Adapter account ID. + */ + account_id: string; + /** + * Adapter display name. + */ + name: string; + /** + * Adapter provider key. + */ + provider: string; +}; + +export type FanoutPolicy = { + AllowUntargetedPublication: boolean; + Enabled: boolean; + MaxPeerTriggeredPublishes: number; + MaxTotalPeerDeliveries: number; +}; + +export type FormulaDetailResponse = { + deps: Array | null; + description: string; + name: string; + preview: FormulaPreviewResponse; + steps: Array | null; + var_defs: Array | null; + version: string; +}; + +export type FormulaFeedBody = { + items: Array | null; + partial: boolean; + partial_errors?: Array | null; +}; + +export type FormulaListBody = { + /** + * Formula summaries. + */ + items: Array | null; + /** + * Whether the list is partial. + */ + partial: boolean; +}; + +export type FormulaPreviewBody = { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Target agent for preview compilation. + */ + target: string; + /** + * Variable name-to-value overrides applied to the compiled preview. + */ + vars?: { + [key: string]: string; + }; +}; + +export type FormulaPreviewEdgeResponse = { + from: string; + kind?: string; + to: string; +}; + +export type FormulaPreviewNodeResponse = { + id: string; + kind: string; + scope_ref?: string; + title: string; +}; + +export type FormulaPreviewResponse = { + edges: Array | null; + nodes: Array | null; +}; + +export type FormulaRecentRunResponse = { + started_at: string; + status: string; + target: string; + updated_at: string; + workflow_id: string; +}; + +export type FormulaRunsResponse = { + formula: string; + partial: boolean; + partial_errors?: Array | null; + recent_runs: Array | null; + run_count: number; +}; + +export type FormulaStepResponse = { + assignee?: string; + id: string; + kind: string; + labels?: Array | null; + metadata?: { + [key: string]: string; + }; + title: string; + type?: string; +}; + +export type FormulaSummaryResponse = { + description: string; + name: string; + recent_runs: Array | null; + run_count: number; + var_defs: Array | null; + version: string; +}; + +export type FormulaVarDefResponse = { + default?: unknown; + description?: string; + enum?: Array | null; + name: string; + pattern?: string; + required?: boolean; + type: string; +}; + +export type GitStatus = { + ahead: number; + behind: number; + branch: string; + changed_files: number; + clean: boolean; +}; + +export type GroupCreatedEventPayload = { + conversation_id: string; + mode: string; + provider: string; +}; + +export type GroupRouteDecision = { + Match: string; + TargetSessionID: string; + UpdateCursor: boolean; +}; + +export type HealthOutputBody = { + /** + * City name. + */ + city?: string; + /** + * Health status. + */ + status: string; + /** + * Server uptime in seconds. + */ + uptime_sec: number; + /** + * Server version. + */ + version?: string; +}; + +export type HeartbeatEvent = { + /** + * ISO 8601 timestamp when the heartbeat was sent. + */ + timestamp: string; +}; + +export type InboundEventPayload = { + actor: string; + conversation_id: string; + provider: string; + target_session: string; +}; + +export type InboundResult = { + Binding: SessionBindingRecord; + GroupRoute: GroupRouteDecision; + Message: ExternalInboundMessage; + TargetSessionID: string; + TranscriptEntry: ConversationTranscriptRecord; +}; + +export type ListBodyAgentPatch = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyAgentResponse = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyBead = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyConversationTranscriptRecord = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyExtmsgAdapterInfo = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyProviderPatch = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyProviderResponse = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyRigPatch = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyRigResponse = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodySessionBindingRecord = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodySessionResponse = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyStatus = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type ListBodyWireEvent = { + /** + * The list of items. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more backends failed and the list is incomplete. + */ + partial?: boolean; + /** + * Human-readable errors from backends that failed during aggregation. + */ + partial_errors?: Array | null; + /** + * Total number of items matching the query. + */ + total: number; +}; + +export type LogicalNode = { + [key: string]: never; +}; + +export type MailCountOutputBody = { + /** + * True when one or more rig providers failed and the counts are not authoritative. + */ + partial?: boolean; + /** + * Per-provider errors when partial is true. + */ + partial_errors?: Array | null; + /** + * Total message count. + */ + total: number; + /** + * Unread message count. + */ + unread: number; +}; + +export type MailEventPayload = { + message?: Message; + rig: string; +}; + +export type MailListBody = { + /** + * The list of messages. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * True when one or more rig providers failed and the list is not authoritative. + */ + partial?: boolean; + /** + * Per-provider errors when partial is true. + */ + partial_errors?: Array | null; + /** + * Total number of messages matching the query. + */ + total: number; +}; + +export type MailReplyInputBody = { + /** + * Reply body. + */ + body?: string; + /** + * Sender name. + */ + from?: string; + /** + * Reply subject. + */ + subject?: string; +}; + +export type MailSendInputBody = { + /** + * Message body. + */ + body?: string; + /** + * Sender name. + */ + from?: string; + /** + * Rig name. + */ + rig?: string; + /** + * Message subject. + */ + subject: string; + /** + * Recipient name. + */ + to: string; +}; + +export type Message = { + body: string; + cc?: Array | null; + created_at: string; + from: string; + id: string; + priority?: number; + read: boolean; + reply_to?: string; + rig?: string; + subject: string; + thread_id?: string; + to: string; +}; + +export type MonitorFeedItemResponse = { + attached_bead_id?: string; + bead_id?: string; + detail_available?: boolean; + id: string; + logical_bead_id?: string; + root_bead_id?: string; + root_store_ref?: string; + run_detail_available?: boolean; + scope_kind: string; + scope_ref: string; + started_at: string; + status: string; + store_ref?: string; + target: string; + title: string; + type: string; + updated_at: string; + workflow_id?: string; +}; + +export type NoPayload = { + [key: string]: never; +}; + +export type OkResponseBody = { + /** + * Operation result. + */ + status: string; +}; + +export type OkWithIdResponseBody = { + /** + * Resource ID. + */ + id?: string; + /** + * Operation result. + */ + status: string; +}; + +export type OptionChoiceDto = { + label: string; + value: string; +}; + +export type OrderCheckListBody = { + /** + * Order trigger evaluations. + */ + checks: Array | null; +}; + +export type OrderCheckResponse = { + due: boolean; + last_run?: string; + last_run_outcome?: string; + name: string; + reason: string; + rig?: string; + scoped_name: string; +}; + +export type OrderHistoryDetailResponse = { + bead_id: string; + created_at: string; + labels: Array | null; + output: string; + store_ref: string; +}; + +export type OrderHistoryEntry = { + bead_id: string; + capture_output: boolean; + created_at: string; + duration_ms?: string; + error?: string; + exit_code?: string; + has_output: boolean; + labels: Array | null; + name: string; + rig?: string; + scoped_name: string; + signal?: string; + store_ref: string; + wisp_root_id?: string; +}; + +export type OrderHistoryListBody = { + /** + * Order history entries. + */ + entries: Array | null; +}; + +export type OrderListBody = { + /** + * Registered orders. + */ + orders: Array | null; +}; + +export type OrderResponse = { + capture_output: boolean; + check?: string; + description?: string; + enabled: boolean; + exec?: string; + formula?: string; + /** + * @deprecated + */ + gate?: string; + interval?: string; + name: string; + on?: string; + pool?: string; + rig?: string; + schedule?: string; + scoped_name: string; + timeout?: string; + timeout_ms: number; + trigger?: string; + type: string; +}; + +export type OrdersFeedBody = { + items: Array | null; + partial: boolean; + partial_errors?: Array | null; +}; + +export type OutboundEventPayload = { + conversation_id: string; + message_id: string; + provider: string; + session: string; +}; + +export type OutboundResult = { + DeliveryContext: DeliveryContextRecord; + Receipt: PublishReceipt; + TranscriptEntry: ConversationTranscriptRecord; +}; + +export type OutputTurn = { + role: string; + text: string; + timestamp?: string; +}; + +export type PackListBody = { + /** + * Registered packs. + */ + packs: Array | null; +}; + +export type PackResponse = { + name: string; + path?: string; + ref?: string; + source?: string; +}; + +export type PaginationInfo = { + has_older_messages: boolean; + returned_message_count: number; + total_compactions: number; + total_message_count: number; + truncated_before_message?: string; +}; + +export type PatchDeletedResponseBody = { + /** + * Agent patch qualified name. + */ + agent_patch?: string; + /** + * Provider patch name. + */ + provider_patch?: string; + /** + * Rig patch name. + */ + rig_patch?: string; + /** + * Operation result. + */ + status: string; +}; + +export type PatchOkResponseBody = { + /** + * Agent patch qualified name. + */ + agent_patch?: string; + /** + * Provider patch name. + */ + provider_patch?: string; + /** + * Rig patch name. + */ + rig_patch?: string; + /** + * Operation result. + */ + status: string; +}; + +export type PendingInteraction = { + kind: string; + metadata?: { + [key: string]: string; + }; + options?: Array | null; + prompt?: string; + request_id: string; +}; + +export type PoolOverride = { + Check: string | null; + DrainTimeout: string | null; + Max: number | null; + Min: number | null; + OnBoot: string | null; + OnDeath: string | null; +}; + +export type ProviderCreateInputBody = { + /** + * ACP transport command arguments override. + */ + acp_args?: Array | null; + /** + * ACP transport command binary override. + */ + acp_command?: string; + /** + * Command arguments. + */ + args?: Array | null; + /** + * Arguments appended after inherited/base args. + */ + args_append?: Array | null; + /** + * Optional provider base for inheritance. + */ + base?: string; + /** + * Provider command binary. Omit for base-only descendants. + */ + command?: string; + /** + * Human-readable display name. + */ + display_name?: string; + /** + * Environment variables. + */ + env?: { + [key: string]: string; + }; + /** + * Provider name. + */ + name: string; + /** + * Options schema merge mode across inheritance chain. + */ + options_schema_merge?: string; + /** + * Flag for prompt delivery. + */ + prompt_flag?: string; + /** + * Prompt delivery mode. + */ + prompt_mode?: string; + /** + * Milliseconds to wait before probing readiness. + */ + ready_delay_ms?: number; +}; + +export type ProviderCreatedOutputBody = { + /** + * Created provider name. + */ + provider: string; + /** + * Operation result. + */ + status: string; +}; + +export type ProviderOptionDto = { + choices: Array | null; + default: string; + key: string; + label: string; + type: string; +}; + +export type ProviderPatch = { + ACPArgs: Array | null; + ACPCommand: string | null; + Args: Array | null; + ArgsAppend: Array | null; + Base: string | null; + Command: string | null; + Env: { + [key: string]: string; + }; + EnvRemove: Array | null; + Name: string; + OptionsSchemaMerge: string | null; + PromptFlag: string | null; + PromptMode: string | null; + ReadyDelayMs: number | null; + Replace: boolean; +}; + +export type ProviderPatchSetInputBody = { + /** + * Override ACP transport command arguments. + */ + acp_args?: Array | null; + /** + * Override ACP transport command binary. + */ + acp_command?: string; + /** + * Override command arguments. + */ + args?: Array | null; + /** + * Override command binary. + */ + command?: string; + /** + * Override environment variables. + */ + env?: { + [key: string]: string; + }; + /** + * Provider name. + */ + name?: string; + /** + * Override prompt flag. + */ + prompt_flag?: string; + /** + * Override prompt delivery mode. + */ + prompt_mode?: string; + /** + * Override ready delay in milliseconds. + */ + ready_delay_ms?: number; +}; + +export type ProviderPublicListBody = { + /** + * The list of browser-safe provider summaries. + */ + items: Array | null; + /** + * Cursor for the next page of results. + */ + next_cursor?: string; + /** + * Total number of providers in the list. + */ + total: number; +}; + +export type ProviderPublicResponse = { + builtin: boolean; + city_level: boolean; + display_name?: string; + effective_defaults?: { + [key: string]: string; + }; + name: string; + options_schema?: Array | null; +}; + +export type ProviderReadiness = { + detail?: string; + display_name: string; + status: string; +}; + +export type ProviderReadinessResponse = { + providers: { + [key: string]: ProviderReadiness; + }; +}; + +export type ProviderResponse = { + acp_args?: Array; + acp_command?: string; + args?: Array | null; + builtin: boolean; + city_level: boolean; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + name: string; + prompt_flag?: string; + prompt_mode?: string; + ready_delay_ms?: number; +}; + +export type ProviderSpecJson = { + acp_args?: Array; + acp_command?: string; + args?: Array | null; + command?: string; + display_name?: string; + env?: { + [key: string]: string; + }; + prompt_flag?: string; + prompt_mode?: string; + ready_delay_ms?: number; +}; + +export type ProviderUpdateInputBody = { + /** + * ACP transport command arguments override. + */ + acp_args?: Array | null; + /** + * ACP transport command binary override. + */ + acp_command?: string; + /** + * Command arguments. + */ + args?: Array | null; + /** + * Arguments appended after inherited/base args. + */ + args_append?: Array | null; + /** + * Provider base for inheritance. + */ + base?: string; + /** + * Provider command binary. + */ + command?: string; + /** + * Human-readable display name. + */ + display_name?: string; + /** + * Environment variables. + */ + env?: { + [key: string]: string; + }; + /** + * Options schema merge mode across inheritance chain. + */ + options_schema_merge?: string; + /** + * Flag for prompt delivery. + */ + prompt_flag?: string; + /** + * Prompt delivery mode. + */ + prompt_mode?: string; + /** + * Milliseconds to wait before probing readiness. + */ + ready_delay_ms?: number; +}; + +export type PublishReceipt = { + Conversation: ConversationRef; + Delivered: boolean; + FailureKind: string; + MessageID: string; + Metadata: { + [key: string]: string; + }; + RetryAfter: number; +}; + +export type ReadinessItem = { + detail?: string; + display_name: string; + kind: string; + name: string; + status: string; +}; + +export type ReadinessResponse = { + items: { + [key: string]: ReadinessItem; + }; +}; + +export type RigActionBody = { + /** + * Action that was performed. + */ + action: string; + /** + * Agents that failed to stop (restart only). + */ + failed?: Array | null; + /** + * Agents that were killed (restart only). + */ + killed?: Array | null; + /** + * Rig name. + */ + rig: string; + /** + * Operation result (ok, partial, failed). + */ + status: string; +}; + +export type RigCreateInputBody = { + /** + * Rig name. + */ + name: string; + /** + * Filesystem path. + */ + path: string; + /** + * Session name prefix. + */ + prefix?: string; +}; + +export type RigCreatedOutputBody = { + /** + * Created rig name. + */ + rig: string; + /** + * Operation result. + */ + status: string; +}; + +export type RigPatch = { + Name: string; + Path: string | null; + Prefix: string | null; + Suspended: boolean | null; +}; + +export type RigPatchSetInputBody = { + /** + * Rig name. + */ + name?: string; + /** + * Override filesystem path. + */ + path?: string; + /** + * Override bead ID prefix. + */ + prefix?: string; + /** + * Override suspended state. + */ + suspended?: boolean; +}; + +export type RigResponse = { + agent_count: number; + git?: GitStatus; + last_activity?: string; + name: string; + path: string; + prefix?: string; + running_count: number; + suspended: boolean; +}; + +export type RigUpdateInputBody = { + /** + * Filesystem path. + */ + path?: string; + /** + * Session name prefix. + */ + prefix?: string; + /** + * Whether rig is suspended. + */ + suspended?: boolean; +}; + +export type ScopeGroup = { + [key: string]: never; +}; + +export type ServiceRestartOutputBody = { + /** + * Action performed. + */ + action: string; + /** + * Service name. + */ + service: string; + /** + * Operation result. + */ + status: string; +}; + +export type SessionActivityEvent = { + /** + * Session activity state: 'idle' or 'in-turn'. + */ + activity: string; +}; + +export type SessionAgentGetResponse = { + messages: Array | null; + status?: string; +}; + +export type SessionAgentListResponse = { + agents: Array | null; +}; + +export type SessionBindingRecord = { + BindingGeneration: number; + BoundAt: string; + Conversation: ConversationRef; + ExpiresAt: string | null; + ID: string; + Metadata: { + [key: string]: string; + }; + SchemaVersion: number; + SessionID: string; + Status: BindingStatus; +}; + +export type SessionCreateBody = { + /** + * Optional session alias. + */ + alias?: string; + /** + * Create session asynchronously (agent only). + */ + async?: boolean; + /** + * Session target kind: agent or provider. + */ + kind?: string; + /** + * Initial message to send to the session. + */ + message?: string; + /** + * Agent or provider name. + */ + name?: string; + /** + * Provider/agent option overrides. + */ + options?: { + [key: string]: string; + }; + /** + * Opaque project context identifier. + */ + project_id?: string; + /** + * Deprecated: use alias. + */ + session_name?: string; + /** + * Session title. + */ + title?: string; +}; + +export type SessionInfo = { + attached: boolean; + last_activity?: string; + name: string; +}; + +export type SessionMessageInputBody = { + /** + * Message text to send. + */ + message: string; +}; + +export type SessionMessageOutputBody = { + /** + * Session ID. + */ + id: string; + /** + * Operation result. + */ + status: string; +}; + +export type SessionPatchBody = { + /** + * Session alias. Empty string clears the alias. + */ + alias?: string; + /** + * Session title. If provided, must be non-empty. + */ + title?: string; +}; + +export type SessionPendingResponse = { + pending?: PendingInteraction; + supported: boolean; +}; + +/** + * Session raw transcript frame + * + * Provider-native transcript frame. Gas City forwards the exact JSON the provider wrote to its session log, so the shape is provider-specific and can be any JSON value. The producing provider is identified by the Provider field on the enclosing envelope; consumers dispatch per-provider frame parsing keyed by that identifier. + */ +export type SessionRawMessageFrame = unknown; + +export type SessionRenameInputBody = { + /** + * New session title. + */ + title: string; +}; + +export type SessionRespondInputBody = { + /** + * Response action (e.g. allow, deny). + */ + action: string; + /** + * Optional response metadata. + */ + metadata?: { + [key: string]: string; + }; + /** + * Pending interaction request ID (optional). + */ + request_id?: string; + /** + * Optional response text. + */ + text?: string; +}; + +export type SessionRespondOutputBody = { + /** + * Session ID. + */ + id: string; + /** + * Operation result. + */ + status: string; +}; + +export type SessionResponse = { + active_bead?: string; + activity?: string; + alias?: string; + attached: boolean; + configured_named_session?: boolean; + context_pct?: number; + context_window?: number; + created_at: string; + display_name?: string; + id: string; + kind?: string; + last_active?: string; + last_output?: string; + metadata?: { + [key: string]: string; + }; + model?: string; + options?: { + [key: string]: string; + }; + pool?: string; + provider: string; + reason?: string; + rig?: string; + running: boolean; + session_name: string; + state: string; + submission_capabilities?: SubmissionCapabilities; + template: string; + title: string; +}; + +/** + * Session stream lifecycle event + * + * Non-message events emitted on the session SSE stream: activity transitions, pending interactions, and keepalive heartbeats. The concrete variant is identified by the SSE event name. + */ +export type SessionStreamCommonEvent = SessionActivityEvent | PendingInteraction | HeartbeatEvent; + +export type SessionStreamMessageEvent = { + format: string; + id: string; + pagination?: PaginationInfo; + /** + * Producing provider identifier (claude, codex, gemini, open-code, etc.). + */ + provider: string; + template: string; + turns: Array | null; +}; + +export type SessionStreamRawMessageEvent = { + format: string; + id: string; + /** + * Provider-native transcript frames, emitted verbatim as the provider wrote them. + */ + messages: Array | null; + pagination?: PaginationInfo; + /** + * Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing. + */ + provider: string; + template: string; +}; + +export type SessionSubmitInputBody = { + /** + * Submit intent; empty defaults to "default". + */ + intent?: SubmitIntent; + /** + * Message text to submit. + */ + message: string; +}; + +export type SessionSubmitOutputBody = { + /** + * Session ID. + */ + id: string; + /** + * Resolved submit intent. + */ + intent: string; + /** + * Whether the message was queued. + */ + queued: boolean; + /** + * Operation result. + */ + status: string; +}; + +export type SessionTranscriptGetResponse = { + /** + * conversation, text, or raw. + */ + format: string; + id: string; + /** + * Populated for raw format; provider-native frames emitted verbatim as the provider wrote them. + */ + messages?: Array | null; + pagination?: PaginationInfo; + /** + * Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing. + */ + provider: string; + template: string; + /** + * Populated for conversation/text formats. + */ + turns?: Array | null; +}; + +export type SlingInputBody = { + /** + * Bead ID to attach a formula to. + */ + attached_bead_id?: string; + /** + * Bead ID to sling. + */ + bead?: string; + /** + * Bypass cross-rig guards; for direct bead routes, also bypass missing-bead validation. Formula-backed graph routes may replace existing live workflow roots but still require the source bead to exist. + */ + force?: boolean; + /** + * Formula name for workflow launch. + */ + formula?: string; + /** + * Rig name. + */ + rig?: string; + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Target agent or pool. + */ + target: string; + /** + * Workflow title. + */ + title?: string; + /** + * Formula variables. + */ + vars?: { + [key: string]: string; + }; +}; + +export type SlingResponse = { + attached_bead_id?: string; + bead?: string; + formula?: string; + mode?: string; + root_bead_id?: string; + status: string; + target: string; + warnings?: Array | null; + workflow_id?: string; +}; + +export type Status = { + allow_websockets?: boolean; + hostname?: string; + kind?: string; + local_state: string; + mount_path: string; + publication_state: string; + publish_mode: string; + reason?: string; + service_name: string; + state?: string; + state_root: string; + updated_at: string; + url?: string; + visibility?: string; + workflow_contract?: string; +}; + +export type StatusAgentCounts = { + /** + * Number of quarantined agents. + */ + quarantined: number; + /** + * Number of running agents. + */ + running: number; + /** + * Number of suspended agents. + */ + suspended: number; + /** + * Total number of agents. + */ + total: number; +}; + +export type StatusBody = { + /** + * Total agent count (deprecated, use agents.total). + */ + agent_count: number; + /** + * Agent state counts. + */ + agents: StatusAgentCounts; + /** + * Mail counts. + */ + mail: StatusMailCounts; + /** + * City name. + */ + name: string; + /** + * City directory path. + */ + path: string; + /** + * Total rig count (deprecated, use rigs.total). + */ + rig_count: number; + /** + * Rig state counts. + */ + rigs: StatusRigCounts; + /** + * Number of running agent processes. + */ + running: number; + /** + * Whether the city is suspended. + */ + suspended: boolean; + /** + * Server uptime in seconds. + */ + uptime_sec: number; + /** + * Server version. + */ + version?: string; + /** + * Work item counts. + */ + work: StatusWorkCounts; +}; + +export type StatusMailCounts = { + /** + * Total number of messages. + */ + total: number; + /** + * Number of unread messages. + */ + unread: number; +}; + +export type StatusRigCounts = { + /** + * Number of suspended rigs. + */ + suspended: number; + /** + * Total number of rigs. + */ + total: number; +}; + +export type StatusWorkCounts = { + /** + * Number of in-progress work items. + */ + in_progress: number; + /** + * Number of open work items. + */ + open: number; + /** + * Number of ready work items. + */ + ready: number; +}; + +export type SubmissionCapabilities = { + supports_follow_up: boolean; + supports_interrupt_now: boolean; +}; + +/** + * Semantic delivery choice for a user message on a session submit request. + */ +export type SubmitIntent = 'default' | 'follow_up' | 'interrupt_now'; + +export type SupervisorCitiesOutputBody = { + /** + * Managed cities with status info. + */ + items: Array | null; + /** + * Total count. + */ + total: number; +}; + +export type SupervisorEventListOutputBody = { + items: Array | null; + total: number; +}; + +export type SupervisorHealthOutputBody = { + /** + * Cities currently running. + */ + cities_running: number; + /** + * Total managed cities. + */ + cities_total: number; + /** + * First-city startup info for single-city deployments. + */ + startup?: SupervisorStartup; + /** + * Health status ("ok"). + */ + status: string; + /** + * Supervisor uptime in seconds. + */ + uptime_sec: number; + /** + * Supervisor version. + */ + version: string; +}; + +export type SupervisorStartup = { + /** + * Current phase (when not ready). + */ + phase?: string; + /** + * Phases completed so far. + */ + phases_completed?: Array | null; + /** + * True when the city is running. + */ + ready: boolean; +}; + +export type TaggedEventStreamEnvelope = { + actor: string; + city: string; + message?: string; + payload?: EventPayload; + seq: number; + subject?: string; + ts: string; + type: string; + workflow?: WorkflowEventProjection; +}; + +/** + * Direction of a transcript entry. + */ +export type TranscriptMessageKind = 'inbound' | 'outbound'; + +/** + * Provenance of a transcript entry (freshly observed vs. replayed from persisted history). + */ +export type TranscriptProvenance = 'live' | 'hydrated'; + +/** + * Typed city event stream envelope + * + * Discriminated union of city event stream envelopes. Each variant constrains the envelope type and payload schema together. + */ +export type TypedEventStreamEnvelope = ({ + type: 'bead.closed'; +} & TypedEventStreamEnvelopeBeadClosed) | ({ + type: 'bead.created'; +} & TypedEventStreamEnvelopeBeadCreated) | ({ + type: 'bead.updated'; +} & TypedEventStreamEnvelopeBeadUpdated) | ({ + type: 'city.created'; +} & TypedEventStreamEnvelopeCityCreated) | ({ + type: 'city.init_failed'; +} & TypedEventStreamEnvelopeCityInitFailed) | ({ + type: 'city.ready'; +} & TypedEventStreamEnvelopeCityReady) | ({ + type: 'city.resumed'; +} & TypedEventStreamEnvelopeCityResumed) | ({ + type: 'city.suspended'; +} & TypedEventStreamEnvelopeCitySuspended) | ({ + type: 'city.unregister_failed'; +} & TypedEventStreamEnvelopeCityUnregisterFailed) | ({ + type: 'city.unregister_requested'; +} & TypedEventStreamEnvelopeCityUnregisterRequested) | ({ + type: 'city.unregistered'; +} & TypedEventStreamEnvelopeCityUnregistered) | ({ + type: 'controller.started'; +} & TypedEventStreamEnvelopeControllerStarted) | ({ + type: 'controller.stopped'; +} & TypedEventStreamEnvelopeControllerStopped) | ({ + type: 'convoy.closed'; +} & TypedEventStreamEnvelopeConvoyClosed) | ({ + type: 'convoy.created'; +} & TypedEventStreamEnvelopeConvoyCreated) | ({ + type: 'extmsg.adapter_added'; +} & TypedEventStreamEnvelopeExtmsgAdapterAdded) | ({ + type: 'extmsg.adapter_removed'; +} & TypedEventStreamEnvelopeExtmsgAdapterRemoved) | ({ + type: 'extmsg.bound'; +} & TypedEventStreamEnvelopeExtmsgBound) | ({ + type: 'extmsg.group_created'; +} & TypedEventStreamEnvelopeExtmsgGroupCreated) | ({ + type: 'extmsg.inbound'; +} & TypedEventStreamEnvelopeExtmsgInbound) | ({ + type: 'extmsg.outbound'; +} & TypedEventStreamEnvelopeExtmsgOutbound) | ({ + type: 'extmsg.unbound'; +} & TypedEventStreamEnvelopeExtmsgUnbound) | ({ + type: 'mail.archived'; +} & TypedEventStreamEnvelopeMailArchived) | ({ + type: 'mail.deleted'; +} & TypedEventStreamEnvelopeMailDeleted) | ({ + type: 'mail.marked_read'; +} & TypedEventStreamEnvelopeMailMarkedRead) | ({ + type: 'mail.marked_unread'; +} & TypedEventStreamEnvelopeMailMarkedUnread) | ({ + type: 'mail.read'; +} & TypedEventStreamEnvelopeMailRead) | ({ + type: 'mail.replied'; +} & TypedEventStreamEnvelopeMailReplied) | ({ + type: 'mail.sent'; +} & TypedEventStreamEnvelopeMailSent) | ({ + type: 'order.completed'; +} & TypedEventStreamEnvelopeOrderCompleted) | ({ + type: 'order.failed'; +} & TypedEventStreamEnvelopeOrderFailed) | ({ + type: 'order.fired'; +} & TypedEventStreamEnvelopeOrderFired) | ({ + type: 'provider.swapped'; +} & TypedEventStreamEnvelopeProviderSwapped) | ({ + type: 'session.crashed'; +} & TypedEventStreamEnvelopeSessionCrashed) | ({ + type: 'session.draining'; +} & TypedEventStreamEnvelopeSessionDraining) | ({ + type: 'session.idle_killed'; +} & TypedEventStreamEnvelopeSessionIdleKilled) | ({ + type: 'session.quarantined'; +} & TypedEventStreamEnvelopeSessionQuarantined) | ({ + type: 'session.stopped'; +} & TypedEventStreamEnvelopeSessionStopped) | ({ + type: 'session.suspended'; +} & TypedEventStreamEnvelopeSessionSuspended) | ({ + type: 'session.undrained'; +} & TypedEventStreamEnvelopeSessionUndrained) | ({ + type: 'session.updated'; +} & TypedEventStreamEnvelopeSessionUpdated) | ({ + type: 'session.woke'; +} & TypedEventStreamEnvelopeSessionWoke) | ({ + type: 'worker.operation'; +} & TypedEventStreamEnvelopeWorkerOperation); + +/** + * TypedEventStreamEnvelope bead.closed + */ +export type TypedEventStreamEnvelopeBeadClosed = { + actor: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.closed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope bead.created + */ +export type TypedEventStreamEnvelopeBeadCreated = { + actor: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope bead.updated + */ +export type TypedEventStreamEnvelopeBeadUpdated = { + actor: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.updated'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.created + */ +export type TypedEventStreamEnvelopeCityCreated = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.init_failed + */ +export type TypedEventStreamEnvelopeCityInitFailed = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.init_failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.ready + */ +export type TypedEventStreamEnvelopeCityReady = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.ready'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.resumed + */ +export type TypedEventStreamEnvelopeCityResumed = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'city.resumed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.suspended + */ +export type TypedEventStreamEnvelopeCitySuspended = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'city.suspended'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.unregister_failed + */ +export type TypedEventStreamEnvelopeCityUnregisterFailed = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregister_failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.unregister_requested + */ +export type TypedEventStreamEnvelopeCityUnregisterRequested = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregister_requested'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope city.unregistered + */ +export type TypedEventStreamEnvelopeCityUnregistered = { + actor: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregistered'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope controller.started + */ +export type TypedEventStreamEnvelopeControllerStarted = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'controller.started'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope controller.stopped + */ +export type TypedEventStreamEnvelopeControllerStopped = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'controller.stopped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope convoy.closed + */ +export type TypedEventStreamEnvelopeConvoyClosed = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'convoy.closed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope convoy.created + */ +export type TypedEventStreamEnvelopeConvoyCreated = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'convoy.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.adapter_added + */ +export type TypedEventStreamEnvelopeExtmsgAdapterAdded = { + actor: string; + message?: string; + payload: AdapterEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.adapter_added'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.adapter_removed + */ +export type TypedEventStreamEnvelopeExtmsgAdapterRemoved = { + actor: string; + message?: string; + payload: AdapterEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.adapter_removed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.bound + */ +export type TypedEventStreamEnvelopeExtmsgBound = { + actor: string; + message?: string; + payload: BoundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.bound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.group_created + */ +export type TypedEventStreamEnvelopeExtmsgGroupCreated = { + actor: string; + message?: string; + payload: GroupCreatedEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.group_created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.inbound + */ +export type TypedEventStreamEnvelopeExtmsgInbound = { + actor: string; + message?: string; + payload: InboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.inbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.outbound + */ +export type TypedEventStreamEnvelopeExtmsgOutbound = { + actor: string; + message?: string; + payload: OutboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.outbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope extmsg.unbound + */ +export type TypedEventStreamEnvelopeExtmsgUnbound = { + actor: string; + message?: string; + payload: UnboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.unbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.archived + */ +export type TypedEventStreamEnvelopeMailArchived = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.archived'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.deleted + */ +export type TypedEventStreamEnvelopeMailDeleted = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.deleted'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.marked_read + */ +export type TypedEventStreamEnvelopeMailMarkedRead = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.marked_read'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.marked_unread + */ +export type TypedEventStreamEnvelopeMailMarkedUnread = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.marked_unread'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.read + */ +export type TypedEventStreamEnvelopeMailRead = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.read'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.replied + */ +export type TypedEventStreamEnvelopeMailReplied = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.replied'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope mail.sent + */ +export type TypedEventStreamEnvelopeMailSent = { + actor: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.sent'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope order.completed + */ +export type TypedEventStreamEnvelopeOrderCompleted = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.completed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope order.failed + */ +export type TypedEventStreamEnvelopeOrderFailed = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope order.fired + */ +export type TypedEventStreamEnvelopeOrderFired = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.fired'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope provider.swapped + */ +export type TypedEventStreamEnvelopeProviderSwapped = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'provider.swapped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.crashed + */ +export type TypedEventStreamEnvelopeSessionCrashed = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.crashed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.draining + */ +export type TypedEventStreamEnvelopeSessionDraining = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.draining'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.idle_killed + */ +export type TypedEventStreamEnvelopeSessionIdleKilled = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.idle_killed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.quarantined + */ +export type TypedEventStreamEnvelopeSessionQuarantined = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.quarantined'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.stopped + */ +export type TypedEventStreamEnvelopeSessionStopped = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.stopped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.suspended + */ +export type TypedEventStreamEnvelopeSessionSuspended = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.suspended'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.undrained + */ +export type TypedEventStreamEnvelopeSessionUndrained = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.undrained'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.updated + */ +export type TypedEventStreamEnvelopeSessionUpdated = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.updated'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope session.woke + */ +export type TypedEventStreamEnvelopeSessionWoke = { + actor: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.woke'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedEventStreamEnvelope worker.operation + */ +export type TypedEventStreamEnvelopeWorkerOperation = { + actor: string; + message?: string; + payload: WorkerOperationEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'worker.operation'; + workflow?: WorkflowEventProjection; +}; + +/** + * Typed supervisor event stream envelope + * + * Discriminated union of supervisor event stream envelopes. Each variant constrains the envelope type and payload schema together and includes the source city. + */ +export type TypedTaggedEventStreamEnvelope = ({ + type: 'bead.closed'; +} & TypedTaggedEventStreamEnvelopeBeadClosed) | ({ + type: 'bead.created'; +} & TypedTaggedEventStreamEnvelopeBeadCreated) | ({ + type: 'bead.updated'; +} & TypedTaggedEventStreamEnvelopeBeadUpdated) | ({ + type: 'city.created'; +} & TypedTaggedEventStreamEnvelopeCityCreated) | ({ + type: 'city.init_failed'; +} & TypedTaggedEventStreamEnvelopeCityInitFailed) | ({ + type: 'city.ready'; +} & TypedTaggedEventStreamEnvelopeCityReady) | ({ + type: 'city.resumed'; +} & TypedTaggedEventStreamEnvelopeCityResumed) | ({ + type: 'city.suspended'; +} & TypedTaggedEventStreamEnvelopeCitySuspended) | ({ + type: 'city.unregister_failed'; +} & TypedTaggedEventStreamEnvelopeCityUnregisterFailed) | ({ + type: 'city.unregister_requested'; +} & TypedTaggedEventStreamEnvelopeCityUnregisterRequested) | ({ + type: 'city.unregistered'; +} & TypedTaggedEventStreamEnvelopeCityUnregistered) | ({ + type: 'controller.started'; +} & TypedTaggedEventStreamEnvelopeControllerStarted) | ({ + type: 'controller.stopped'; +} & TypedTaggedEventStreamEnvelopeControllerStopped) | ({ + type: 'convoy.closed'; +} & TypedTaggedEventStreamEnvelopeConvoyClosed) | ({ + type: 'convoy.created'; +} & TypedTaggedEventStreamEnvelopeConvoyCreated) | ({ + type: 'extmsg.adapter_added'; +} & TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded) | ({ + type: 'extmsg.adapter_removed'; +} & TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved) | ({ + type: 'extmsg.bound'; +} & TypedTaggedEventStreamEnvelopeExtmsgBound) | ({ + type: 'extmsg.group_created'; +} & TypedTaggedEventStreamEnvelopeExtmsgGroupCreated) | ({ + type: 'extmsg.inbound'; +} & TypedTaggedEventStreamEnvelopeExtmsgInbound) | ({ + type: 'extmsg.outbound'; +} & TypedTaggedEventStreamEnvelopeExtmsgOutbound) | ({ + type: 'extmsg.unbound'; +} & TypedTaggedEventStreamEnvelopeExtmsgUnbound) | ({ + type: 'mail.archived'; +} & TypedTaggedEventStreamEnvelopeMailArchived) | ({ + type: 'mail.deleted'; +} & TypedTaggedEventStreamEnvelopeMailDeleted) | ({ + type: 'mail.marked_read'; +} & TypedTaggedEventStreamEnvelopeMailMarkedRead) | ({ + type: 'mail.marked_unread'; +} & TypedTaggedEventStreamEnvelopeMailMarkedUnread) | ({ + type: 'mail.read'; +} & TypedTaggedEventStreamEnvelopeMailRead) | ({ + type: 'mail.replied'; +} & TypedTaggedEventStreamEnvelopeMailReplied) | ({ + type: 'mail.sent'; +} & TypedTaggedEventStreamEnvelopeMailSent) | ({ + type: 'order.completed'; +} & TypedTaggedEventStreamEnvelopeOrderCompleted) | ({ + type: 'order.failed'; +} & TypedTaggedEventStreamEnvelopeOrderFailed) | ({ + type: 'order.fired'; +} & TypedTaggedEventStreamEnvelopeOrderFired) | ({ + type: 'provider.swapped'; +} & TypedTaggedEventStreamEnvelopeProviderSwapped) | ({ + type: 'session.crashed'; +} & TypedTaggedEventStreamEnvelopeSessionCrashed) | ({ + type: 'session.draining'; +} & TypedTaggedEventStreamEnvelopeSessionDraining) | ({ + type: 'session.idle_killed'; +} & TypedTaggedEventStreamEnvelopeSessionIdleKilled) | ({ + type: 'session.quarantined'; +} & TypedTaggedEventStreamEnvelopeSessionQuarantined) | ({ + type: 'session.stopped'; +} & TypedTaggedEventStreamEnvelopeSessionStopped) | ({ + type: 'session.suspended'; +} & TypedTaggedEventStreamEnvelopeSessionSuspended) | ({ + type: 'session.undrained'; +} & TypedTaggedEventStreamEnvelopeSessionUndrained) | ({ + type: 'session.updated'; +} & TypedTaggedEventStreamEnvelopeSessionUpdated) | ({ + type: 'session.woke'; +} & TypedTaggedEventStreamEnvelopeSessionWoke) | ({ + type: 'worker.operation'; +} & TypedTaggedEventStreamEnvelopeWorkerOperation); + +/** + * TypedTaggedEventStreamEnvelope bead.closed + */ +export type TypedTaggedEventStreamEnvelopeBeadClosed = { + actor: string; + city: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.closed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope bead.created + */ +export type TypedTaggedEventStreamEnvelopeBeadCreated = { + actor: string; + city: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope bead.updated + */ +export type TypedTaggedEventStreamEnvelopeBeadUpdated = { + actor: string; + city: string; + message?: string; + payload: BeadEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'bead.updated'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.created + */ +export type TypedTaggedEventStreamEnvelopeCityCreated = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.init_failed + */ +export type TypedTaggedEventStreamEnvelopeCityInitFailed = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.init_failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.ready + */ +export type TypedTaggedEventStreamEnvelopeCityReady = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.ready'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.resumed + */ +export type TypedTaggedEventStreamEnvelopeCityResumed = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'city.resumed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.suspended + */ +export type TypedTaggedEventStreamEnvelopeCitySuspended = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'city.suspended'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.unregister_failed + */ +export type TypedTaggedEventStreamEnvelopeCityUnregisterFailed = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregister_failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.unregister_requested + */ +export type TypedTaggedEventStreamEnvelopeCityUnregisterRequested = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregister_requested'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope city.unregistered + */ +export type TypedTaggedEventStreamEnvelopeCityUnregistered = { + actor: string; + city: string; + message?: string; + payload: CityLifecyclePayload; + seq: number; + subject?: string; + ts: string; + type: 'city.unregistered'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope controller.started + */ +export type TypedTaggedEventStreamEnvelopeControllerStarted = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'controller.started'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope controller.stopped + */ +export type TypedTaggedEventStreamEnvelopeControllerStopped = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'controller.stopped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope convoy.closed + */ +export type TypedTaggedEventStreamEnvelopeConvoyClosed = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'convoy.closed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope convoy.created + */ +export type TypedTaggedEventStreamEnvelopeConvoyCreated = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'convoy.created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.adapter_added + */ +export type TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded = { + actor: string; + city: string; + message?: string; + payload: AdapterEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.adapter_added'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.adapter_removed + */ +export type TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved = { + actor: string; + city: string; + message?: string; + payload: AdapterEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.adapter_removed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.bound + */ +export type TypedTaggedEventStreamEnvelopeExtmsgBound = { + actor: string; + city: string; + message?: string; + payload: BoundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.bound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.group_created + */ +export type TypedTaggedEventStreamEnvelopeExtmsgGroupCreated = { + actor: string; + city: string; + message?: string; + payload: GroupCreatedEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.group_created'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.inbound + */ +export type TypedTaggedEventStreamEnvelopeExtmsgInbound = { + actor: string; + city: string; + message?: string; + payload: InboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.inbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.outbound + */ +export type TypedTaggedEventStreamEnvelopeExtmsgOutbound = { + actor: string; + city: string; + message?: string; + payload: OutboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.outbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope extmsg.unbound + */ +export type TypedTaggedEventStreamEnvelopeExtmsgUnbound = { + actor: string; + city: string; + message?: string; + payload: UnboundEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'extmsg.unbound'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.archived + */ +export type TypedTaggedEventStreamEnvelopeMailArchived = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.archived'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.deleted + */ +export type TypedTaggedEventStreamEnvelopeMailDeleted = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.deleted'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.marked_read + */ +export type TypedTaggedEventStreamEnvelopeMailMarkedRead = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.marked_read'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.marked_unread + */ +export type TypedTaggedEventStreamEnvelopeMailMarkedUnread = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.marked_unread'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.read + */ +export type TypedTaggedEventStreamEnvelopeMailRead = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.read'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.replied + */ +export type TypedTaggedEventStreamEnvelopeMailReplied = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.replied'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope mail.sent + */ +export type TypedTaggedEventStreamEnvelopeMailSent = { + actor: string; + city: string; + message?: string; + payload: MailEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'mail.sent'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope order.completed + */ +export type TypedTaggedEventStreamEnvelopeOrderCompleted = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.completed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope order.failed + */ +export type TypedTaggedEventStreamEnvelopeOrderFailed = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.failed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope order.fired + */ +export type TypedTaggedEventStreamEnvelopeOrderFired = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'order.fired'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope provider.swapped + */ +export type TypedTaggedEventStreamEnvelopeProviderSwapped = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'provider.swapped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.crashed + */ +export type TypedTaggedEventStreamEnvelopeSessionCrashed = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.crashed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.draining + */ +export type TypedTaggedEventStreamEnvelopeSessionDraining = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.draining'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.idle_killed + */ +export type TypedTaggedEventStreamEnvelopeSessionIdleKilled = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.idle_killed'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.quarantined + */ +export type TypedTaggedEventStreamEnvelopeSessionQuarantined = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.quarantined'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.stopped + */ +export type TypedTaggedEventStreamEnvelopeSessionStopped = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.stopped'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.suspended + */ +export type TypedTaggedEventStreamEnvelopeSessionSuspended = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.suspended'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.undrained + */ +export type TypedTaggedEventStreamEnvelopeSessionUndrained = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.undrained'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.updated + */ +export type TypedTaggedEventStreamEnvelopeSessionUpdated = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.updated'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope session.woke + */ +export type TypedTaggedEventStreamEnvelopeSessionWoke = { + actor: string; + city: string; + message?: string; + payload: NoPayload; + seq: number; + subject?: string; + ts: string; + type: 'session.woke'; + workflow?: WorkflowEventProjection; +}; + +/** + * TypedTaggedEventStreamEnvelope worker.operation + */ +export type TypedTaggedEventStreamEnvelopeWorkerOperation = { + actor: string; + city: string; + message?: string; + payload: WorkerOperationEventPayload; + seq: number; + subject?: string; + ts: string; + type: 'worker.operation'; + workflow?: WorkflowEventProjection; +}; + +export type UnboundEventPayload = { + count: number; + session_id: string; +}; + +export type WireEvent = { + actor: string; + message?: string; + payload?: EventPayload; + seq: number; + subject?: string; + ts: string; + type: string; +}; + +export type WireTaggedEvent = { + actor: string; + city: string; + message?: string; + payload?: EventPayload; + seq: number; + subject?: string; + ts: string; + type: string; +}; + +export type WorkerOperationEventPayload = { + delivered?: boolean; + duration_ms: number; + error?: string; + finished_at: string; + op_id: string; + operation: string; + provider?: string; + queued?: boolean; + result: string; + session_id?: string; + session_name?: string; + started_at: string; + template?: string; + transport?: string; +}; + +export type WorkflowAttemptSummary = { + active_attempt: number; + attempt_count: number; + max_attempts?: number; +}; + +export type WorkflowBeadResponse = { + assignee?: string; + attempt?: number; + id: string; + kind: string; + logical_bead_id?: string; + metadata: { + [key: string]: string; + }; + scope_ref?: string; + status: string; + step_ref?: string; + title: string; +}; + +export type WorkflowDeleteResponse = { + /** + * Number of beads closed. + */ + closed: number; + /** + * Number of beads deleted. + */ + deleted: number; + /** + * True when one or more teardown steps failed; Closed/Deleted still reflect what succeeded. + */ + partial?: boolean; + /** + * Human-readable errors from failed teardown steps. + */ + partial_errors?: Array | null; + /** + * Workflow ID. + */ + workflow_id: string; +}; + +export type WorkflowDepResponse = { + from: string; + kind?: string; + to: string; +}; + +export type WorkflowEventProjection = { + attempt_summary?: WorkflowAttemptSummary; + bead: WorkflowBeadResponse; + changed_fields: Array | null; + event_seq: number; + event_ts: string; + event_type: string; + logical_node_id: string; + requires_resync?: boolean; + root_bead_id: string; + root_store_ref: string; + scope_kind: string; + scope_ref: string; + type: string; + watch_generation: string; + workflow_id: string; + workflow_seq: number; +}; + +export type WorkflowSnapshotResponse = { + beads: Array | null; + deps: Array | null; + logical_edges: Array | null; + logical_nodes: Array | null; + partial: boolean; + resolved_root_store: string; + root_bead_id: string; + root_store_ref: string; + scope_groups: Array | null; + scope_kind: string; + scope_ref: string; + snapshot_event_seq?: number; + snapshot_version: number; + stores_scanned: Array | null; + workflow_id: string; +}; + +export type WorkspaceResponse = { + declared_name?: string; + declared_prefix?: string; + name: string; + prefix?: string; + provider?: string; + session_template?: string; + suspended: boolean; +}; + +export type GetHealthData = { + body?: never; + path?: never; + query?: never; + url: '/health'; +}; + +export type GetHealthErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetHealthError = GetHealthErrors[keyof GetHealthErrors]; + +export type GetHealthResponses = { + /** + * OK + */ + 200: SupervisorHealthOutputBody; +}; + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; + +export type GetV0CitiesData = { + body?: never; + path?: never; + query?: never; + url: '/v0/cities'; +}; + +export type GetV0CitiesErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CitiesError = GetV0CitiesErrors[keyof GetV0CitiesErrors]; + +export type GetV0CitiesResponses = { + /** + * OK + */ + 200: SupervisorCitiesOutputBody; +}; + +export type GetV0CitiesResponse = GetV0CitiesResponses[keyof GetV0CitiesResponses]; + +export type PostV0CityData = { + body: CityCreateRequest; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path?: never; + query?: never; + url: '/v0/city'; +}; + +export type PostV0CityErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityError = PostV0CityErrors[keyof PostV0CityErrors]; + +export type PostV0CityResponses = { + /** + * Accepted + */ + 202: CityCreateResponse; +}; + +export type PostV0CityResponse = PostV0CityResponses[keyof PostV0CityResponses]; + +export type GetV0CityByCityNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}'; +}; + +export type GetV0CityByCityNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameError = GetV0CityByCityNameErrors[keyof GetV0CityByCityNameErrors]; + +export type GetV0CityByCityNameResponses = { + /** + * OK + */ + 200: CityGetResponse; +}; + +export type GetV0CityByCityNameResponse = GetV0CityByCityNameResponses[keyof GetV0CityByCityNameResponses]; + +export type PatchV0CityByCityNameData = { + body: CityPatchInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}'; +}; + +export type PatchV0CityByCityNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameError = PatchV0CityByCityNameErrors[keyof PatchV0CityByCityNameErrors]; + +export type PatchV0CityByCityNameResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameResponse = PatchV0CityByCityNameResponses[keyof PatchV0CityByCityNameResponses]; + +export type DeleteV0CityByCityNameAgentByBaseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent name (unqualified). + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{base}'; +}; + +export type DeleteV0CityByCityNameAgentByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameAgentByBaseError = DeleteV0CityByCityNameAgentByBaseErrors[keyof DeleteV0CityByCityNameAgentByBaseErrors]; + +export type DeleteV0CityByCityNameAgentByBaseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameAgentByBaseResponse = DeleteV0CityByCityNameAgentByBaseResponses[keyof DeleteV0CityByCityNameAgentByBaseResponses]; + +export type GetV0CityByCityNameAgentByBaseData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent name (unqualified, no rig). + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{base}'; +}; + +export type GetV0CityByCityNameAgentByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameAgentByBaseError = GetV0CityByCityNameAgentByBaseErrors[keyof GetV0CityByCityNameAgentByBaseErrors]; + +export type GetV0CityByCityNameAgentByBaseResponses = { + /** + * OK + */ + 200: AgentResponse; +}; + +export type GetV0CityByCityNameAgentByBaseResponse = GetV0CityByCityNameAgentByBaseResponses[keyof GetV0CityByCityNameAgentByBaseResponses]; + +export type PatchV0CityByCityNameAgentByBaseData = { + body: AgentUpdateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent name (unqualified). + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{base}'; +}; + +export type PatchV0CityByCityNameAgentByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameAgentByBaseError = PatchV0CityByCityNameAgentByBaseErrors[keyof PatchV0CityByCityNameAgentByBaseErrors]; + +export type PatchV0CityByCityNameAgentByBaseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameAgentByBaseResponse = PatchV0CityByCityNameAgentByBaseResponses[keyof PatchV0CityByCityNameAgentByBaseResponses]; + +export type GetV0CityByCityNameAgentByBaseOutputData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent base name. + */ + base: string; + }; + query?: { + /** + * Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. + */ + tail?: string; + /** + * Message UUID cursor for loading older messages. + */ + before?: string; + }; + url: '/v0/city/{cityName}/agent/{base}/output'; +}; + +export type GetV0CityByCityNameAgentByBaseOutputErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameAgentByBaseOutputError = GetV0CityByCityNameAgentByBaseOutputErrors[keyof GetV0CityByCityNameAgentByBaseOutputErrors]; + +export type GetV0CityByCityNameAgentByBaseOutputResponses = { + /** + * OK + */ + 200: AgentOutputResponse; +}; + +export type GetV0CityByCityNameAgentByBaseOutputResponse = GetV0CityByCityNameAgentByBaseOutputResponses[keyof GetV0CityByCityNameAgentByBaseOutputResponses]; + +export type StreamAgentOutputData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{base}/output/stream'; +}; + +export type StreamAgentOutputErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type StreamAgentOutputError = StreamAgentOutputErrors[keyof StreamAgentOutputErrors]; + +export type StreamAgentOutputResponses = { + /** + * Server Sent Events + * + * Each oneOf object represents one possible SSE message. + */ + 200: Array<{ + data: HeartbeatEvent; + /** + * The event name. + */ + event: 'heartbeat'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: AgentOutputResponse; + /** + * The event name. + */ + event: 'turn'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + }>; +}; + +export type StreamAgentOutputResponse = StreamAgentOutputResponses[keyof StreamAgentOutputResponses]; + +export type PostV0CityByCityNameAgentByBaseByActionData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent name (unqualified). + */ + base: string; + /** + * Action to perform. + */ + action: 'suspend' | 'resume'; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{base}/{action}'; +}; + +export type PostV0CityByCityNameAgentByBaseByActionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameAgentByBaseByActionError = PostV0CityByCityNameAgentByBaseByActionErrors[keyof PostV0CityByCityNameAgentByBaseByActionErrors]; + +export type PostV0CityByCityNameAgentByBaseByActionResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameAgentByBaseByActionResponse = PostV0CityByCityNameAgentByBaseByActionResponses[keyof PostV0CityByCityNameAgentByBaseByActionResponses]; + +export type DeleteV0CityByCityNameAgentByDirByBaseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{dir}/{base}'; +}; + +export type DeleteV0CityByCityNameAgentByDirByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameAgentByDirByBaseError = DeleteV0CityByCityNameAgentByDirByBaseErrors[keyof DeleteV0CityByCityNameAgentByDirByBaseErrors]; + +export type DeleteV0CityByCityNameAgentByDirByBaseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameAgentByDirByBaseResponse = DeleteV0CityByCityNameAgentByDirByBaseResponses[keyof DeleteV0CityByCityNameAgentByDirByBaseResponses]; + +export type GetV0CityByCityNameAgentByDirByBaseData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{dir}/{base}'; +}; + +export type GetV0CityByCityNameAgentByDirByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameAgentByDirByBaseError = GetV0CityByCityNameAgentByDirByBaseErrors[keyof GetV0CityByCityNameAgentByDirByBaseErrors]; + +export type GetV0CityByCityNameAgentByDirByBaseResponses = { + /** + * OK + */ + 200: AgentResponse; +}; + +export type GetV0CityByCityNameAgentByDirByBaseResponse = GetV0CityByCityNameAgentByDirByBaseResponses[keyof GetV0CityByCityNameAgentByDirByBaseResponses]; + +export type PatchV0CityByCityNameAgentByDirByBaseData = { + body: AgentUpdateQualifiedInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{dir}/{base}'; +}; + +export type PatchV0CityByCityNameAgentByDirByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameAgentByDirByBaseError = PatchV0CityByCityNameAgentByDirByBaseErrors[keyof PatchV0CityByCityNameAgentByDirByBaseErrors]; + +export type PatchV0CityByCityNameAgentByDirByBaseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameAgentByDirByBaseResponse = PatchV0CityByCityNameAgentByDirByBaseResponses[keyof PatchV0CityByCityNameAgentByDirByBaseResponses]; + +export type GetV0CityByCityNameAgentByDirByBaseOutputData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: { + /** + * Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. + */ + tail?: string; + /** + * Message UUID cursor for loading older messages. + */ + before?: string; + }; + url: '/v0/city/{cityName}/agent/{dir}/{base}/output'; +}; + +export type GetV0CityByCityNameAgentByDirByBaseOutputErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameAgentByDirByBaseOutputError = GetV0CityByCityNameAgentByDirByBaseOutputErrors[keyof GetV0CityByCityNameAgentByDirByBaseOutputErrors]; + +export type GetV0CityByCityNameAgentByDirByBaseOutputResponses = { + /** + * OK + */ + 200: AgentOutputResponse; +}; + +export type GetV0CityByCityNameAgentByDirByBaseOutputResponse = GetV0CityByCityNameAgentByDirByBaseOutputResponses[keyof GetV0CityByCityNameAgentByDirByBaseOutputResponses]; + +export type StreamAgentOutputQualifiedData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{dir}/{base}/output/stream'; +}; + +export type StreamAgentOutputQualifiedErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type StreamAgentOutputQualifiedError = StreamAgentOutputQualifiedErrors[keyof StreamAgentOutputQualifiedErrors]; + +export type StreamAgentOutputQualifiedResponses = { + /** + * Server Sent Events + * + * Each oneOf object represents one possible SSE message. + */ + 200: Array<{ + data: HeartbeatEvent; + /** + * The event name. + */ + event: 'heartbeat'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: AgentOutputResponse; + /** + * The event name. + */ + event: 'turn'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + }>; +}; + +export type StreamAgentOutputQualifiedResponse = StreamAgentOutputQualifiedResponses[keyof StreamAgentOutputQualifiedResponses]; + +export type PostV0CityByCityNameAgentByDirByBaseByActionData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + /** + * Action to perform. + */ + action: 'suspend' | 'resume'; + }; + query?: never; + url: '/v0/city/{cityName}/agent/{dir}/{base}/{action}'; +}; + +export type PostV0CityByCityNameAgentByDirByBaseByActionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameAgentByDirByBaseByActionError = PostV0CityByCityNameAgentByDirByBaseByActionErrors[keyof PostV0CityByCityNameAgentByDirByBaseByActionErrors]; + +export type PostV0CityByCityNameAgentByDirByBaseByActionResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameAgentByDirByBaseByActionResponse = PostV0CityByCityNameAgentByDirByBaseByActionResponses[keyof PostV0CityByCityNameAgentByDirByBaseByActionResponses]; + +export type GetV0CityByCityNameAgentsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Filter by pool name. + */ + pool?: string; + /** + * Filter by rig name. + */ + rig?: string; + /** + * Filter by running state. Omit to return all agents. + */ + running?: 'true' | 'false'; + /** + * Include last output preview. + */ + peek?: boolean; + }; + url: '/v0/city/{cityName}/agents'; +}; + +export type GetV0CityByCityNameAgentsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameAgentsError = GetV0CityByCityNameAgentsErrors[keyof GetV0CityByCityNameAgentsErrors]; + +export type GetV0CityByCityNameAgentsResponses = { + /** + * OK + */ + 200: ListBodyAgentResponse; +}; + +export type GetV0CityByCityNameAgentsResponse = GetV0CityByCityNameAgentsResponses[keyof GetV0CityByCityNameAgentsResponses]; + +export type CreateAgentData = { + body: AgentCreateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/agents'; +}; + +export type CreateAgentErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateAgentError = CreateAgentErrors[keyof CreateAgentErrors]; + +export type CreateAgentResponses = { + /** + * Created + */ + 201: AgentCreatedOutputBody; +}; + +export type CreateAgentResponse = CreateAgentResponses[keyof CreateAgentResponses]; + +export type DeleteV0CityByCityNameBeadByIdData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}'; +}; + +export type DeleteV0CityByCityNameBeadByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameBeadByIdError = DeleteV0CityByCityNameBeadByIdErrors[keyof DeleteV0CityByCityNameBeadByIdErrors]; + +export type DeleteV0CityByCityNameBeadByIdResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameBeadByIdResponse = DeleteV0CityByCityNameBeadByIdResponses[keyof DeleteV0CityByCityNameBeadByIdResponses]; + +export type GetV0CityByCityNameBeadByIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}'; +}; + +export type GetV0CityByCityNameBeadByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameBeadByIdError = GetV0CityByCityNameBeadByIdErrors[keyof GetV0CityByCityNameBeadByIdErrors]; + +export type GetV0CityByCityNameBeadByIdResponses = { + /** + * OK + */ + 200: Bead; +}; + +export type GetV0CityByCityNameBeadByIdResponse = GetV0CityByCityNameBeadByIdResponses[keyof GetV0CityByCityNameBeadByIdResponses]; + +export type PatchV0CityByCityNameBeadByIdData = { + body: BeadUpdateBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}'; +}; + +export type PatchV0CityByCityNameBeadByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameBeadByIdError = PatchV0CityByCityNameBeadByIdErrors[keyof PatchV0CityByCityNameBeadByIdErrors]; + +export type PatchV0CityByCityNameBeadByIdResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameBeadByIdResponse = PatchV0CityByCityNameBeadByIdResponses[keyof PatchV0CityByCityNameBeadByIdResponses]; + +export type PostV0CityByCityNameBeadByIdAssignData = { + body: BeadAssignInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}/assign'; +}; + +export type PostV0CityByCityNameBeadByIdAssignErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameBeadByIdAssignError = PostV0CityByCityNameBeadByIdAssignErrors[keyof PostV0CityByCityNameBeadByIdAssignErrors]; + +export type PostV0CityByCityNameBeadByIdAssignResponses = { + /** + * OK + */ + 200: { + [key: string]: string; + }; +}; + +export type PostV0CityByCityNameBeadByIdAssignResponse = PostV0CityByCityNameBeadByIdAssignResponses[keyof PostV0CityByCityNameBeadByIdAssignResponses]; + +export type PostV0CityByCityNameBeadByIdCloseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}/close'; +}; + +export type PostV0CityByCityNameBeadByIdCloseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameBeadByIdCloseError = PostV0CityByCityNameBeadByIdCloseErrors[keyof PostV0CityByCityNameBeadByIdCloseErrors]; + +export type PostV0CityByCityNameBeadByIdCloseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameBeadByIdCloseResponse = PostV0CityByCityNameBeadByIdCloseResponses[keyof PostV0CityByCityNameBeadByIdCloseResponses]; + +export type GetV0CityByCityNameBeadByIdDepsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}/deps'; +}; + +export type GetV0CityByCityNameBeadByIdDepsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameBeadByIdDepsError = GetV0CityByCityNameBeadByIdDepsErrors[keyof GetV0CityByCityNameBeadByIdDepsErrors]; + +export type GetV0CityByCityNameBeadByIdDepsResponses = { + /** + * OK + */ + 200: BeadDepsResponse; +}; + +export type GetV0CityByCityNameBeadByIdDepsResponse = GetV0CityByCityNameBeadByIdDepsResponses[keyof GetV0CityByCityNameBeadByIdDepsResponses]; + +export type PostV0CityByCityNameBeadByIdReopenData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}/reopen'; +}; + +export type PostV0CityByCityNameBeadByIdReopenErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameBeadByIdReopenError = PostV0CityByCityNameBeadByIdReopenErrors[keyof PostV0CityByCityNameBeadByIdReopenErrors]; + +export type PostV0CityByCityNameBeadByIdReopenResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameBeadByIdReopenResponse = PostV0CityByCityNameBeadByIdReopenResponses[keyof PostV0CityByCityNameBeadByIdReopenResponses]; + +export type PostV0CityByCityNameBeadByIdUpdateData = { + body: BeadUpdateBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/bead/{id}/update'; +}; + +export type PostV0CityByCityNameBeadByIdUpdateErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameBeadByIdUpdateError = PostV0CityByCityNameBeadByIdUpdateErrors[keyof PostV0CityByCityNameBeadByIdUpdateErrors]; + +export type PostV0CityByCityNameBeadByIdUpdateResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameBeadByIdUpdateResponse = PostV0CityByCityNameBeadByIdUpdateResponses[keyof PostV0CityByCityNameBeadByIdUpdateResponses]; + +export type GetV0CityByCityNameBeadsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Pagination cursor from a previous response's next_cursor field. + */ + cursor?: string; + /** + * Maximum number of results to return. 0 = server default. + */ + limit?: number; + /** + * Filter by bead status. + */ + status?: string; + /** + * Filter by bead type. + */ + type?: string; + /** + * Filter by label. + */ + label?: string; + /** + * Filter by assignee. + */ + assignee?: string; + /** + * Filter by rig. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/beads'; +}; + +export type GetV0CityByCityNameBeadsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameBeadsError = GetV0CityByCityNameBeadsErrors[keyof GetV0CityByCityNameBeadsErrors]; + +export type GetV0CityByCityNameBeadsResponses = { + /** + * OK + */ + 200: ListBodyBead; +}; + +export type GetV0CityByCityNameBeadsResponse = GetV0CityByCityNameBeadsResponses[keyof GetV0CityByCityNameBeadsResponses]; + +export type CreateBeadData = { + body: BeadCreateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + /** + * Idempotency key for safe retries. + */ + 'Idempotency-Key'?: string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/beads'; +}; + +export type CreateBeadErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateBeadError = CreateBeadErrors[keyof CreateBeadErrors]; + +export type CreateBeadResponses = { + /** + * Created + */ + 201: Bead; +}; + +export type CreateBeadResponse = CreateBeadResponses[keyof CreateBeadResponses]; + +export type GetV0CityByCityNameBeadsGraphByRootIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Root bead ID for the graph. + */ + rootID: string; + }; + query?: never; + url: '/v0/city/{cityName}/beads/graph/{rootID}'; +}; + +export type GetV0CityByCityNameBeadsGraphByRootIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameBeadsGraphByRootIdError = GetV0CityByCityNameBeadsGraphByRootIdErrors[keyof GetV0CityByCityNameBeadsGraphByRootIdErrors]; + +export type GetV0CityByCityNameBeadsGraphByRootIdResponses = { + /** + * OK + */ + 200: BeadGraphResponse; +}; + +export type GetV0CityByCityNameBeadsGraphByRootIdResponse = GetV0CityByCityNameBeadsGraphByRootIdResponses[keyof GetV0CityByCityNameBeadsGraphByRootIdResponses]; + +export type GetV0CityByCityNameBeadsReadyData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + }; + url: '/v0/city/{cityName}/beads/ready'; +}; + +export type GetV0CityByCityNameBeadsReadyErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameBeadsReadyError = GetV0CityByCityNameBeadsReadyErrors[keyof GetV0CityByCityNameBeadsReadyErrors]; + +export type GetV0CityByCityNameBeadsReadyResponses = { + /** + * OK + */ + 200: ListBodyBead; +}; + +export type GetV0CityByCityNameBeadsReadyResponse = GetV0CityByCityNameBeadsReadyResponses[keyof GetV0CityByCityNameBeadsReadyResponses]; + +export type GetV0CityByCityNameConfigData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/config'; +}; + +export type GetV0CityByCityNameConfigErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConfigError = GetV0CityByCityNameConfigErrors[keyof GetV0CityByCityNameConfigErrors]; + +export type GetV0CityByCityNameConfigResponses = { + /** + * OK + */ + 200: ConfigResponse; +}; + +export type GetV0CityByCityNameConfigResponse = GetV0CityByCityNameConfigResponses[keyof GetV0CityByCityNameConfigResponses]; + +export type GetV0CityByCityNameConfigExplainData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/config/explain'; +}; + +export type GetV0CityByCityNameConfigExplainErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConfigExplainError = GetV0CityByCityNameConfigExplainErrors[keyof GetV0CityByCityNameConfigExplainErrors]; + +export type GetV0CityByCityNameConfigExplainResponses = { + /** + * OK + */ + 200: ConfigExplainResponse; +}; + +export type GetV0CityByCityNameConfigExplainResponse = GetV0CityByCityNameConfigExplainResponses[keyof GetV0CityByCityNameConfigExplainResponses]; + +export type GetV0CityByCityNameConfigValidateData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/config/validate'; +}; + +export type GetV0CityByCityNameConfigValidateErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConfigValidateError = GetV0CityByCityNameConfigValidateErrors[keyof GetV0CityByCityNameConfigValidateErrors]; + +export type GetV0CityByCityNameConfigValidateResponses = { + /** + * OK + */ + 200: ConfigValidateOutputBody; +}; + +export type GetV0CityByCityNameConfigValidateResponse = GetV0CityByCityNameConfigValidateResponses[keyof GetV0CityByCityNameConfigValidateResponses]; + +export type DeleteV0CityByCityNameConvoyByIdData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}'; +}; + +export type DeleteV0CityByCityNameConvoyByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameConvoyByIdError = DeleteV0CityByCityNameConvoyByIdErrors[keyof DeleteV0CityByCityNameConvoyByIdErrors]; + +export type DeleteV0CityByCityNameConvoyByIdResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameConvoyByIdResponse = DeleteV0CityByCityNameConvoyByIdResponses[keyof DeleteV0CityByCityNameConvoyByIdResponses]; + +export type GetV0CityByCityNameConvoyByIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}'; +}; + +export type GetV0CityByCityNameConvoyByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConvoyByIdError = GetV0CityByCityNameConvoyByIdErrors[keyof GetV0CityByCityNameConvoyByIdErrors]; + +export type GetV0CityByCityNameConvoyByIdResponses = { + /** + * OK + */ + 200: ConvoyGetResponse; +}; + +export type GetV0CityByCityNameConvoyByIdResponse = GetV0CityByCityNameConvoyByIdResponses[keyof GetV0CityByCityNameConvoyByIdResponses]; + +export type PostV0CityByCityNameConvoyByIdAddData = { + body: ConvoyAddInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}/add'; +}; + +export type PostV0CityByCityNameConvoyByIdAddErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameConvoyByIdAddError = PostV0CityByCityNameConvoyByIdAddErrors[keyof PostV0CityByCityNameConvoyByIdAddErrors]; + +export type PostV0CityByCityNameConvoyByIdAddResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameConvoyByIdAddResponse = PostV0CityByCityNameConvoyByIdAddResponses[keyof PostV0CityByCityNameConvoyByIdAddResponses]; + +export type GetV0CityByCityNameConvoyByIdCheckData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}/check'; +}; + +export type GetV0CityByCityNameConvoyByIdCheckErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConvoyByIdCheckError = GetV0CityByCityNameConvoyByIdCheckErrors[keyof GetV0CityByCityNameConvoyByIdCheckErrors]; + +export type GetV0CityByCityNameConvoyByIdCheckResponses = { + /** + * OK + */ + 200: ConvoyCheckResponse; +}; + +export type GetV0CityByCityNameConvoyByIdCheckResponse = GetV0CityByCityNameConvoyByIdCheckResponses[keyof GetV0CityByCityNameConvoyByIdCheckResponses]; + +export type PostV0CityByCityNameConvoyByIdCloseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}/close'; +}; + +export type PostV0CityByCityNameConvoyByIdCloseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameConvoyByIdCloseError = PostV0CityByCityNameConvoyByIdCloseErrors[keyof PostV0CityByCityNameConvoyByIdCloseErrors]; + +export type PostV0CityByCityNameConvoyByIdCloseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameConvoyByIdCloseResponse = PostV0CityByCityNameConvoyByIdCloseResponses[keyof PostV0CityByCityNameConvoyByIdCloseResponses]; + +export type PostV0CityByCityNameConvoyByIdRemoveData = { + body: ConvoyRemoveInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Convoy ID. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoy/{id}/remove'; +}; + +export type PostV0CityByCityNameConvoyByIdRemoveErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameConvoyByIdRemoveError = PostV0CityByCityNameConvoyByIdRemoveErrors[keyof PostV0CityByCityNameConvoyByIdRemoveErrors]; + +export type PostV0CityByCityNameConvoyByIdRemoveResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameConvoyByIdRemoveResponse = PostV0CityByCityNameConvoyByIdRemoveResponses[keyof PostV0CityByCityNameConvoyByIdRemoveResponses]; + +export type GetV0CityByCityNameConvoysData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Pagination cursor from a previous response's next_cursor field. + */ + cursor?: string; + /** + * Maximum number of results to return. 0 = server default. + */ + limit?: number; + }; + url: '/v0/city/{cityName}/convoys'; +}; + +export type GetV0CityByCityNameConvoysErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameConvoysError = GetV0CityByCityNameConvoysErrors[keyof GetV0CityByCityNameConvoysErrors]; + +export type GetV0CityByCityNameConvoysResponses = { + /** + * OK + */ + 200: ListBodyBead; +}; + +export type GetV0CityByCityNameConvoysResponse = GetV0CityByCityNameConvoysResponses[keyof GetV0CityByCityNameConvoysResponses]; + +export type CreateConvoyData = { + body: ConvoyCreateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/convoys'; +}; + +export type CreateConvoyErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateConvoyError = CreateConvoyErrors[keyof CreateConvoyErrors]; + +export type CreateConvoyResponses = { + /** + * Created + */ + 201: Bead; +}; + +export type CreateConvoyResponse = CreateConvoyResponses[keyof CreateConvoyResponses]; + +export type GetV0CityByCityNameEventsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Pagination cursor from a previous response's next_cursor field. + */ + cursor?: string; + /** + * Maximum number of results to return. 0 = server default. + */ + limit?: number; + /** + * Filter by event type. + */ + type?: string; + /** + * Filter by actor. + */ + actor?: string; + /** + * Filter events since duration ago (Go duration string, e.g. 5m). + */ + since?: string; + }; + url: '/v0/city/{cityName}/events'; +}; + +export type GetV0CityByCityNameEventsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameEventsError = GetV0CityByCityNameEventsErrors[keyof GetV0CityByCityNameEventsErrors]; + +export type GetV0CityByCityNameEventsResponses = { + /** + * OK + */ + 200: ListBodyWireEvent; +}; + +export type GetV0CityByCityNameEventsResponse = GetV0CityByCityNameEventsResponses[keyof GetV0CityByCityNameEventsResponses]; + +export type EmitEventData = { + body: EventEmitRequest; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/events'; +}; + +export type EmitEventErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type EmitEventError = EmitEventErrors[keyof EmitEventErrors]; + +export type EmitEventResponses = { + /** + * Created + */ + 201: EventEmitOutputBody; +}; + +export type EmitEventResponse = EmitEventResponses[keyof EmitEventResponses]; + +export type StreamEventsData = { + body?: never; + headers?: { + /** + * SSE reconnect position from the last received event ID. + */ + 'Last-Event-ID'?: string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Reconnect position: only deliver events after this sequence number. + */ + after_seq?: string; + }; + url: '/v0/city/{cityName}/events/stream'; +}; + +export type StreamEventsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type StreamEventsError = StreamEventsErrors[keyof StreamEventsErrors]; + +export type StreamEventsResponses = { + /** + * Server Sent Events + * + * Each oneOf object represents one possible SSE message. + */ + 200: Array<{ + data: TypedEventStreamEnvelope; + /** + * The event name. + */ + event: 'event'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: HeartbeatEvent; + /** + * The event name. + */ + event: 'heartbeat'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + }>; +}; + +export type StreamEventsResponse = StreamEventsResponses[keyof StreamEventsResponses]; + +export type DeleteV0CityByCityNameExtmsgAdaptersData = { + body: ExtMsgAdapterUnregisterInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/adapters'; +}; + +export type DeleteV0CityByCityNameExtmsgAdaptersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameExtmsgAdaptersError = DeleteV0CityByCityNameExtmsgAdaptersErrors[keyof DeleteV0CityByCityNameExtmsgAdaptersErrors]; + +export type DeleteV0CityByCityNameExtmsgAdaptersResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameExtmsgAdaptersResponse = DeleteV0CityByCityNameExtmsgAdaptersResponses[keyof DeleteV0CityByCityNameExtmsgAdaptersResponses]; + +export type GetV0CityByCityNameExtmsgAdaptersData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/adapters'; +}; + +export type GetV0CityByCityNameExtmsgAdaptersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameExtmsgAdaptersError = GetV0CityByCityNameExtmsgAdaptersErrors[keyof GetV0CityByCityNameExtmsgAdaptersErrors]; + +export type GetV0CityByCityNameExtmsgAdaptersResponses = { + /** + * OK + */ + 200: ListBodyExtmsgAdapterInfo; +}; + +export type GetV0CityByCityNameExtmsgAdaptersResponse = GetV0CityByCityNameExtmsgAdaptersResponses[keyof GetV0CityByCityNameExtmsgAdaptersResponses]; + +export type RegisterExtmsgAdapterData = { + body: ExtMsgAdapterRegisterInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/adapters'; +}; + +export type RegisterExtmsgAdapterErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type RegisterExtmsgAdapterError = RegisterExtmsgAdapterErrors[keyof RegisterExtmsgAdapterErrors]; + +export type RegisterExtmsgAdapterResponses = { + /** + * Created + */ + 201: ExtMsgAdapterRegisterOutputBody; +}; + +export type RegisterExtmsgAdapterResponse = RegisterExtmsgAdapterResponses[keyof RegisterExtmsgAdapterResponses]; + +export type PostV0CityByCityNameExtmsgBindData = { + body: ExtMsgBindInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/bind'; +}; + +export type PostV0CityByCityNameExtmsgBindErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgBindError = PostV0CityByCityNameExtmsgBindErrors[keyof PostV0CityByCityNameExtmsgBindErrors]; + +export type PostV0CityByCityNameExtmsgBindResponses = { + /** + * OK + */ + 200: SessionBindingRecord; +}; + +export type PostV0CityByCityNameExtmsgBindResponse = PostV0CityByCityNameExtmsgBindResponses[keyof PostV0CityByCityNameExtmsgBindResponses]; + +export type GetV0CityByCityNameExtmsgBindingsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Session ID to list bindings for. + */ + session_id?: string; + }; + url: '/v0/city/{cityName}/extmsg/bindings'; +}; + +export type GetV0CityByCityNameExtmsgBindingsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameExtmsgBindingsError = GetV0CityByCityNameExtmsgBindingsErrors[keyof GetV0CityByCityNameExtmsgBindingsErrors]; + +export type GetV0CityByCityNameExtmsgBindingsResponses = { + /** + * OK + */ + 200: ListBodySessionBindingRecord; +}; + +export type GetV0CityByCityNameExtmsgBindingsResponse = GetV0CityByCityNameExtmsgBindingsResponses[keyof GetV0CityByCityNameExtmsgBindingsResponses]; + +export type GetV0CityByCityNameExtmsgGroupsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Scope ID. + */ + scope_id?: string; + /** + * Provider name. + */ + provider?: string; + /** + * Account ID. + */ + account_id?: string; + /** + * Conversation ID. + */ + conversation_id?: string; + /** + * Conversation kind. + */ + kind?: string; + }; + url: '/v0/city/{cityName}/extmsg/groups'; +}; + +export type GetV0CityByCityNameExtmsgGroupsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameExtmsgGroupsError = GetV0CityByCityNameExtmsgGroupsErrors[keyof GetV0CityByCityNameExtmsgGroupsErrors]; + +export type GetV0CityByCityNameExtmsgGroupsResponses = { + /** + * OK + */ + 200: ConversationGroupRecord; +}; + +export type GetV0CityByCityNameExtmsgGroupsResponse = GetV0CityByCityNameExtmsgGroupsResponses[keyof GetV0CityByCityNameExtmsgGroupsResponses]; + +export type EnsureExtmsgGroupData = { + body: ExtMsgGroupEnsureInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/groups'; +}; + +export type EnsureExtmsgGroupErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type EnsureExtmsgGroupError = EnsureExtmsgGroupErrors[keyof EnsureExtmsgGroupErrors]; + +export type EnsureExtmsgGroupResponses = { + /** + * Created + */ + 201: ConversationGroupRecord; +}; + +export type EnsureExtmsgGroupResponse = EnsureExtmsgGroupResponses[keyof EnsureExtmsgGroupResponses]; + +export type PostV0CityByCityNameExtmsgInboundData = { + body: ExtMsgInboundInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/inbound'; +}; + +export type PostV0CityByCityNameExtmsgInboundErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgInboundError = PostV0CityByCityNameExtmsgInboundErrors[keyof PostV0CityByCityNameExtmsgInboundErrors]; + +export type PostV0CityByCityNameExtmsgInboundResponses = { + /** + * OK + */ + 200: InboundResult; +}; + +export type PostV0CityByCityNameExtmsgInboundResponse = PostV0CityByCityNameExtmsgInboundResponses[keyof PostV0CityByCityNameExtmsgInboundResponses]; + +export type PostV0CityByCityNameExtmsgOutboundData = { + body: ExtMsgOutboundInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/outbound'; +}; + +export type PostV0CityByCityNameExtmsgOutboundErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgOutboundError = PostV0CityByCityNameExtmsgOutboundErrors[keyof PostV0CityByCityNameExtmsgOutboundErrors]; + +export type PostV0CityByCityNameExtmsgOutboundResponses = { + /** + * OK + */ + 200: OutboundResult; +}; + +export type PostV0CityByCityNameExtmsgOutboundResponse = PostV0CityByCityNameExtmsgOutboundResponses[keyof PostV0CityByCityNameExtmsgOutboundResponses]; + +export type DeleteV0CityByCityNameExtmsgParticipantsData = { + body: ExtMsgParticipantRemoveInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/participants'; +}; + +export type DeleteV0CityByCityNameExtmsgParticipantsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameExtmsgParticipantsError = DeleteV0CityByCityNameExtmsgParticipantsErrors[keyof DeleteV0CityByCityNameExtmsgParticipantsErrors]; + +export type DeleteV0CityByCityNameExtmsgParticipantsResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameExtmsgParticipantsResponse = DeleteV0CityByCityNameExtmsgParticipantsResponses[keyof DeleteV0CityByCityNameExtmsgParticipantsResponses]; + +export type PostV0CityByCityNameExtmsgParticipantsData = { + body: ExtMsgParticipantUpsertInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/participants'; +}; + +export type PostV0CityByCityNameExtmsgParticipantsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgParticipantsError = PostV0CityByCityNameExtmsgParticipantsErrors[keyof PostV0CityByCityNameExtmsgParticipantsErrors]; + +export type PostV0CityByCityNameExtmsgParticipantsResponses = { + /** + * OK + */ + 200: ConversationGroupParticipant; +}; + +export type PostV0CityByCityNameExtmsgParticipantsResponse = PostV0CityByCityNameExtmsgParticipantsResponses[keyof PostV0CityByCityNameExtmsgParticipantsResponses]; + +export type GetV0CityByCityNameExtmsgTranscriptData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Scope ID. + */ + scope_id?: string; + /** + * Provider name. + */ + provider?: string; + /** + * Account ID. + */ + account_id?: string; + /** + * Conversation ID. + */ + conversation_id?: string; + /** + * Parent conversation ID. + */ + parent_conversation_id?: string; + /** + * Conversation kind. + */ + kind?: string; + }; + url: '/v0/city/{cityName}/extmsg/transcript'; +}; + +export type GetV0CityByCityNameExtmsgTranscriptErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameExtmsgTranscriptError = GetV0CityByCityNameExtmsgTranscriptErrors[keyof GetV0CityByCityNameExtmsgTranscriptErrors]; + +export type GetV0CityByCityNameExtmsgTranscriptResponses = { + /** + * OK + */ + 200: ListBodyConversationTranscriptRecord; +}; + +export type GetV0CityByCityNameExtmsgTranscriptResponse = GetV0CityByCityNameExtmsgTranscriptResponses[keyof GetV0CityByCityNameExtmsgTranscriptResponses]; + +export type PostV0CityByCityNameExtmsgTranscriptAckData = { + body: ExtMsgTranscriptAckInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/transcript/ack'; +}; + +export type PostV0CityByCityNameExtmsgTranscriptAckErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgTranscriptAckError = PostV0CityByCityNameExtmsgTranscriptAckErrors[keyof PostV0CityByCityNameExtmsgTranscriptAckErrors]; + +export type PostV0CityByCityNameExtmsgTranscriptAckResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameExtmsgTranscriptAckResponse = PostV0CityByCityNameExtmsgTranscriptAckResponses[keyof PostV0CityByCityNameExtmsgTranscriptAckResponses]; + +export type PostV0CityByCityNameExtmsgUnbindData = { + body: ExtMsgUnbindInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/extmsg/unbind'; +}; + +export type PostV0CityByCityNameExtmsgUnbindErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameExtmsgUnbindError = PostV0CityByCityNameExtmsgUnbindErrors[keyof PostV0CityByCityNameExtmsgUnbindErrors]; + +export type PostV0CityByCityNameExtmsgUnbindResponses = { + /** + * OK + */ + 200: ExtMsgUnbindBody; +}; + +export type PostV0CityByCityNameExtmsgUnbindResponse = PostV0CityByCityNameExtmsgUnbindResponses[keyof PostV0CityByCityNameExtmsgUnbindResponses]; + +export type GetV0CityByCityNameFormulaByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Formula name. + */ + name: string; + }; + query: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Target agent for preview compilation. + */ + target: string; + }; + url: '/v0/city/{cityName}/formula/{name}'; +}; + +export type GetV0CityByCityNameFormulaByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameFormulaByNameError = GetV0CityByCityNameFormulaByNameErrors[keyof GetV0CityByCityNameFormulaByNameErrors]; + +export type GetV0CityByCityNameFormulaByNameResponses = { + /** + * OK + */ + 200: FormulaDetailResponse; +}; + +export type GetV0CityByCityNameFormulaByNameResponse = GetV0CityByCityNameFormulaByNameResponses[keyof GetV0CityByCityNameFormulaByNameResponses]; + +export type GetV0CityByCityNameFormulasData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + }; + url: '/v0/city/{cityName}/formulas'; +}; + +export type GetV0CityByCityNameFormulasErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameFormulasError = GetV0CityByCityNameFormulasErrors[keyof GetV0CityByCityNameFormulasErrors]; + +export type GetV0CityByCityNameFormulasResponses = { + /** + * OK + */ + 200: FormulaListBody; +}; + +export type GetV0CityByCityNameFormulasResponse = GetV0CityByCityNameFormulasResponses[keyof GetV0CityByCityNameFormulasResponses]; + +export type GetV0CityByCityNameFormulasFeedData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Maximum number of feed items to return. 0 = default. + */ + limit?: number; + }; + url: '/v0/city/{cityName}/formulas/feed'; +}; + +export type GetV0CityByCityNameFormulasFeedErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameFormulasFeedError = GetV0CityByCityNameFormulasFeedErrors[keyof GetV0CityByCityNameFormulasFeedErrors]; + +export type GetV0CityByCityNameFormulasFeedResponses = { + /** + * OK + */ + 200: FormulaFeedBody; +}; + +export type GetV0CityByCityNameFormulasFeedResponse = GetV0CityByCityNameFormulasFeedResponses[keyof GetV0CityByCityNameFormulasFeedResponses]; + +export type GetV0CityByCityNameFormulasByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Formula name. + */ + name: string; + }; + query: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Target agent for preview compilation. + */ + target: string; + }; + url: '/v0/city/{cityName}/formulas/{name}'; +}; + +export type GetV0CityByCityNameFormulasByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameFormulasByNameError = GetV0CityByCityNameFormulasByNameErrors[keyof GetV0CityByCityNameFormulasByNameErrors]; + +export type GetV0CityByCityNameFormulasByNameResponses = { + /** + * OK + */ + 200: FormulaDetailResponse; +}; + +export type GetV0CityByCityNameFormulasByNameResponse = GetV0CityByCityNameFormulasByNameResponses[keyof GetV0CityByCityNameFormulasByNameResponses]; + +export type PostV0CityByCityNameFormulasByNamePreviewData = { + body: FormulaPreviewBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Formula name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/formulas/{name}/preview'; +}; + +export type PostV0CityByCityNameFormulasByNamePreviewErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameFormulasByNamePreviewError = PostV0CityByCityNameFormulasByNamePreviewErrors[keyof PostV0CityByCityNameFormulasByNamePreviewErrors]; + +export type PostV0CityByCityNameFormulasByNamePreviewResponses = { + /** + * OK + */ + 200: FormulaDetailResponse; +}; + +export type PostV0CityByCityNameFormulasByNamePreviewResponse = PostV0CityByCityNameFormulasByNamePreviewResponses[keyof PostV0CityByCityNameFormulasByNamePreviewResponses]; + +export type GetV0CityByCityNameFormulasByNameRunsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Formula name. + */ + name: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Maximum number of recent runs to return. 0 = default. + */ + limit?: number; + }; + url: '/v0/city/{cityName}/formulas/{name}/runs'; +}; + +export type GetV0CityByCityNameFormulasByNameRunsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameFormulasByNameRunsError = GetV0CityByCityNameFormulasByNameRunsErrors[keyof GetV0CityByCityNameFormulasByNameRunsErrors]; + +export type GetV0CityByCityNameFormulasByNameRunsResponses = { + /** + * OK + */ + 200: FormulaRunsResponse; +}; + +export type GetV0CityByCityNameFormulasByNameRunsResponse = GetV0CityByCityNameFormulasByNameRunsResponses[keyof GetV0CityByCityNameFormulasByNameRunsResponses]; + +export type GetV0CityByCityNameHealthData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/health'; +}; + +export type GetV0CityByCityNameHealthErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameHealthError = GetV0CityByCityNameHealthErrors[keyof GetV0CityByCityNameHealthErrors]; + +export type GetV0CityByCityNameHealthResponses = { + /** + * OK + */ + 200: HealthOutputBody; +}; + +export type GetV0CityByCityNameHealthResponse = GetV0CityByCityNameHealthResponses[keyof GetV0CityByCityNameHealthResponses]; + +export type GetV0CityByCityNameMailData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Pagination cursor from a previous response's next_cursor field. + */ + cursor?: string; + /** + * Maximum number of results to return. 0 = server default. + */ + limit?: number; + /** + * Filter by agent name. + */ + agent?: string; + /** + * Filter by status (unread, all). + */ + status?: string; + /** + * Filter by rig name. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail'; +}; + +export type GetV0CityByCityNameMailErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameMailError = GetV0CityByCityNameMailErrors[keyof GetV0CityByCityNameMailErrors]; + +export type GetV0CityByCityNameMailResponses = { + /** + * OK + */ + 200: MailListBody; +}; + +export type GetV0CityByCityNameMailResponse = GetV0CityByCityNameMailResponses[keyof GetV0CityByCityNameMailResponses]; + +export type SendMailData = { + body: MailSendInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + /** + * Idempotency key for safe retries. + */ + 'Idempotency-Key'?: string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/mail'; +}; + +export type SendMailErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type SendMailError = SendMailErrors[keyof SendMailErrors]; + +export type SendMailResponses = { + /** + * Created + */ + 201: Message; +}; + +export type SendMailResponse = SendMailResponses[keyof SendMailResponses]; + +export type GetV0CityByCityNameMailCountData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Filter by agent name. + */ + agent?: string; + /** + * Filter by rig name. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/count'; +}; + +export type GetV0CityByCityNameMailCountErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameMailCountError = GetV0CityByCityNameMailCountErrors[keyof GetV0CityByCityNameMailCountErrors]; + +export type GetV0CityByCityNameMailCountResponses = { + /** + * OK + */ + 200: MailCountOutputBody; +}; + +export type GetV0CityByCityNameMailCountResponse = GetV0CityByCityNameMailCountResponses[keyof GetV0CityByCityNameMailCountResponses]; + +export type GetV0CityByCityNameMailThreadByIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Thread ID. + */ + id: string; + }; + query?: { + /** + * Filter by rig. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/thread/{id}'; +}; + +export type GetV0CityByCityNameMailThreadByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameMailThreadByIdError = GetV0CityByCityNameMailThreadByIdErrors[keyof GetV0CityByCityNameMailThreadByIdErrors]; + +export type GetV0CityByCityNameMailThreadByIdResponses = { + /** + * OK + */ + 200: MailListBody; +}; + +export type GetV0CityByCityNameMailThreadByIdResponse = GetV0CityByCityNameMailThreadByIdResponses[keyof GetV0CityByCityNameMailThreadByIdResponses]; + +export type DeleteV0CityByCityNameMailByIdData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}'; +}; + +export type DeleteV0CityByCityNameMailByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameMailByIdError = DeleteV0CityByCityNameMailByIdErrors[keyof DeleteV0CityByCityNameMailByIdErrors]; + +export type DeleteV0CityByCityNameMailByIdResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameMailByIdResponse = DeleteV0CityByCityNameMailByIdResponses[keyof DeleteV0CityByCityNameMailByIdResponses]; + +export type GetV0CityByCityNameMailByIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint for O(1) lookup. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}'; +}; + +export type GetV0CityByCityNameMailByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameMailByIdError = GetV0CityByCityNameMailByIdErrors[keyof GetV0CityByCityNameMailByIdErrors]; + +export type GetV0CityByCityNameMailByIdResponses = { + /** + * OK + */ + 200: Message; +}; + +export type GetV0CityByCityNameMailByIdResponse = GetV0CityByCityNameMailByIdResponses[keyof GetV0CityByCityNameMailByIdResponses]; + +export type PostV0CityByCityNameMailByIdArchiveData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}/archive'; +}; + +export type PostV0CityByCityNameMailByIdArchiveErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameMailByIdArchiveError = PostV0CityByCityNameMailByIdArchiveErrors[keyof PostV0CityByCityNameMailByIdArchiveErrors]; + +export type PostV0CityByCityNameMailByIdArchiveResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameMailByIdArchiveResponse = PostV0CityByCityNameMailByIdArchiveResponses[keyof PostV0CityByCityNameMailByIdArchiveResponses]; + +export type PostV0CityByCityNameMailByIdMarkUnreadData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}/mark-unread'; +}; + +export type PostV0CityByCityNameMailByIdMarkUnreadErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameMailByIdMarkUnreadError = PostV0CityByCityNameMailByIdMarkUnreadErrors[keyof PostV0CityByCityNameMailByIdMarkUnreadErrors]; + +export type PostV0CityByCityNameMailByIdMarkUnreadResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameMailByIdMarkUnreadResponse = PostV0CityByCityNameMailByIdMarkUnreadResponses[keyof PostV0CityByCityNameMailByIdMarkUnreadResponses]; + +export type PostV0CityByCityNameMailByIdReadData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}/read'; +}; + +export type PostV0CityByCityNameMailByIdReadErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameMailByIdReadError = PostV0CityByCityNameMailByIdReadErrors[keyof PostV0CityByCityNameMailByIdReadErrors]; + +export type PostV0CityByCityNameMailByIdReadResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameMailByIdReadResponse = PostV0CityByCityNameMailByIdReadResponses[keyof PostV0CityByCityNameMailByIdReadResponses]; + +export type ReplyMailData = { + body: MailReplyInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Message ID. + */ + id: string; + }; + query?: { + /** + * Rig hint. + */ + rig?: string; + }; + url: '/v0/city/{cityName}/mail/{id}/reply'; +}; + +export type ReplyMailErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type ReplyMailError = ReplyMailErrors[keyof ReplyMailErrors]; + +export type ReplyMailResponses = { + /** + * Created + */ + 201: Message; +}; + +export type ReplyMailResponse = ReplyMailResponses[keyof ReplyMailResponses]; + +export type GetV0CityByCityNameOrderHistoryByBeadIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Bead ID for the order run. + */ + bead_id: string; + }; + query?: { + /** + * Store reference for disambiguating store-local bead IDs. + */ + store_ref?: string; + }; + url: '/v0/city/{cityName}/order/history/{bead_id}'; +}; + +export type GetV0CityByCityNameOrderHistoryByBeadIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrderHistoryByBeadIdError = GetV0CityByCityNameOrderHistoryByBeadIdErrors[keyof GetV0CityByCityNameOrderHistoryByBeadIdErrors]; + +export type GetV0CityByCityNameOrderHistoryByBeadIdResponses = { + /** + * OK + */ + 200: OrderHistoryDetailResponse; +}; + +export type GetV0CityByCityNameOrderHistoryByBeadIdResponse = GetV0CityByCityNameOrderHistoryByBeadIdResponses[keyof GetV0CityByCityNameOrderHistoryByBeadIdResponses]; + +export type GetV0CityByCityNameOrderByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Order name or scoped name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/order/{name}'; +}; + +export type GetV0CityByCityNameOrderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrderByNameError = GetV0CityByCityNameOrderByNameErrors[keyof GetV0CityByCityNameOrderByNameErrors]; + +export type GetV0CityByCityNameOrderByNameResponses = { + /** + * OK + */ + 200: OrderResponse; +}; + +export type GetV0CityByCityNameOrderByNameResponse = GetV0CityByCityNameOrderByNameResponses[keyof GetV0CityByCityNameOrderByNameResponses]; + +export type PostV0CityByCityNameOrderByNameDisableData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Order name or scoped name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/order/{name}/disable'; +}; + +export type PostV0CityByCityNameOrderByNameDisableErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameOrderByNameDisableError = PostV0CityByCityNameOrderByNameDisableErrors[keyof PostV0CityByCityNameOrderByNameDisableErrors]; + +export type PostV0CityByCityNameOrderByNameDisableResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameOrderByNameDisableResponse = PostV0CityByCityNameOrderByNameDisableResponses[keyof PostV0CityByCityNameOrderByNameDisableResponses]; + +export type PostV0CityByCityNameOrderByNameEnableData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Order name or scoped name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/order/{name}/enable'; +}; + +export type PostV0CityByCityNameOrderByNameEnableErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameOrderByNameEnableError = PostV0CityByCityNameOrderByNameEnableErrors[keyof PostV0CityByCityNameOrderByNameEnableErrors]; + +export type PostV0CityByCityNameOrderByNameEnableResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameOrderByNameEnableResponse = PostV0CityByCityNameOrderByNameEnableResponses[keyof PostV0CityByCityNameOrderByNameEnableResponses]; + +export type GetV0CityByCityNameOrdersData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/orders'; +}; + +export type GetV0CityByCityNameOrdersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrdersError = GetV0CityByCityNameOrdersErrors[keyof GetV0CityByCityNameOrdersErrors]; + +export type GetV0CityByCityNameOrdersResponses = { + /** + * OK + */ + 200: OrderListBody; +}; + +export type GetV0CityByCityNameOrdersResponse = GetV0CityByCityNameOrdersResponses[keyof GetV0CityByCityNameOrdersResponses]; + +export type GetV0CityByCityNameOrdersCheckData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Bypass cached order-check responses and cached order history. + */ + fresh?: boolean; + }; + url: '/v0/city/{cityName}/orders/check'; +}; + +export type GetV0CityByCityNameOrdersCheckErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrdersCheckError = GetV0CityByCityNameOrdersCheckErrors[keyof GetV0CityByCityNameOrdersCheckErrors]; + +export type GetV0CityByCityNameOrdersCheckResponses = { + /** + * OK + */ + 200: OrderCheckListBody; +}; + +export type GetV0CityByCityNameOrdersCheckResponse = GetV0CityByCityNameOrdersCheckResponses[keyof GetV0CityByCityNameOrdersCheckResponses]; + +export type GetV0CityByCityNameOrdersFeedData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Maximum number of feed items to return. + */ + limit?: number; + }; + url: '/v0/city/{cityName}/orders/feed'; +}; + +export type GetV0CityByCityNameOrdersFeedErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrdersFeedError = GetV0CityByCityNameOrdersFeedErrors[keyof GetV0CityByCityNameOrdersFeedErrors]; + +export type GetV0CityByCityNameOrdersFeedResponses = { + /** + * OK + */ + 200: OrdersFeedBody; +}; + +export type GetV0CityByCityNameOrdersFeedResponse = GetV0CityByCityNameOrdersFeedResponses[keyof GetV0CityByCityNameOrdersFeedResponses]; + +export type GetV0CityByCityNameOrdersHistoryData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query: { + /** + * Scoped order name. + */ + scoped_name: string; + /** + * Maximum number of history entries. 0 = default. + */ + limit?: number; + /** + * Return entries before this RFC3339 timestamp. + */ + before?: string; + }; + url: '/v0/city/{cityName}/orders/history'; +}; + +export type GetV0CityByCityNameOrdersHistoryErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameOrdersHistoryError = GetV0CityByCityNameOrdersHistoryErrors[keyof GetV0CityByCityNameOrdersHistoryErrors]; + +export type GetV0CityByCityNameOrdersHistoryResponses = { + /** + * OK + */ + 200: OrderHistoryListBody; +}; + +export type GetV0CityByCityNameOrdersHistoryResponse = GetV0CityByCityNameOrdersHistoryResponses[keyof GetV0CityByCityNameOrdersHistoryResponses]; + +export type GetV0CityByCityNamePacksData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/packs'; +}; + +export type GetV0CityByCityNamePacksErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePacksError = GetV0CityByCityNamePacksErrors[keyof GetV0CityByCityNamePacksErrors]; + +export type GetV0CityByCityNamePacksResponses = { + /** + * OK + */ + 200: PackListBody; +}; + +export type GetV0CityByCityNamePacksResponse = GetV0CityByCityNamePacksResponses[keyof GetV0CityByCityNamePacksResponses]; + +export type DeleteV0CityByCityNamePatchesAgentByBaseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent patch name (unqualified). + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agent/{base}'; +}; + +export type DeleteV0CityByCityNamePatchesAgentByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNamePatchesAgentByBaseError = DeleteV0CityByCityNamePatchesAgentByBaseErrors[keyof DeleteV0CityByCityNamePatchesAgentByBaseErrors]; + +export type DeleteV0CityByCityNamePatchesAgentByBaseResponses = { + /** + * OK + */ + 200: PatchDeletedResponseBody; +}; + +export type DeleteV0CityByCityNamePatchesAgentByBaseResponse = DeleteV0CityByCityNamePatchesAgentByBaseResponses[keyof DeleteV0CityByCityNamePatchesAgentByBaseResponses]; + +export type GetV0CityByCityNamePatchesAgentByBaseData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent patch name (unqualified). + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agent/{base}'; +}; + +export type GetV0CityByCityNamePatchesAgentByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesAgentByBaseError = GetV0CityByCityNamePatchesAgentByBaseErrors[keyof GetV0CityByCityNamePatchesAgentByBaseErrors]; + +export type GetV0CityByCityNamePatchesAgentByBaseResponses = { + /** + * OK + */ + 200: AgentPatch; +}; + +export type GetV0CityByCityNamePatchesAgentByBaseResponse = GetV0CityByCityNamePatchesAgentByBaseResponses[keyof GetV0CityByCityNamePatchesAgentByBaseResponses]; + +export type DeleteV0CityByCityNamePatchesAgentByDirByBaseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agent/{dir}/{base}'; +}; + +export type DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNamePatchesAgentByDirByBaseError = DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors[keyof DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors]; + +export type DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses = { + /** + * OK + */ + 200: PatchDeletedResponseBody; +}; + +export type DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse = DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses[keyof DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses]; + +export type GetV0CityByCityNamePatchesAgentByDirByBaseData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Agent directory (rig name). + */ + dir: string; + /** + * Agent base name. + */ + base: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agent/{dir}/{base}'; +}; + +export type GetV0CityByCityNamePatchesAgentByDirByBaseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesAgentByDirByBaseError = GetV0CityByCityNamePatchesAgentByDirByBaseErrors[keyof GetV0CityByCityNamePatchesAgentByDirByBaseErrors]; + +export type GetV0CityByCityNamePatchesAgentByDirByBaseResponses = { + /** + * OK + */ + 200: AgentPatch; +}; + +export type GetV0CityByCityNamePatchesAgentByDirByBaseResponse = GetV0CityByCityNamePatchesAgentByDirByBaseResponses[keyof GetV0CityByCityNamePatchesAgentByDirByBaseResponses]; + +export type GetV0CityByCityNamePatchesAgentsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agents'; +}; + +export type GetV0CityByCityNamePatchesAgentsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesAgentsError = GetV0CityByCityNamePatchesAgentsErrors[keyof GetV0CityByCityNamePatchesAgentsErrors]; + +export type GetV0CityByCityNamePatchesAgentsResponses = { + /** + * OK + */ + 200: ListBodyAgentPatch; +}; + +export type GetV0CityByCityNamePatchesAgentsResponse = GetV0CityByCityNamePatchesAgentsResponses[keyof GetV0CityByCityNamePatchesAgentsResponses]; + +export type PutV0CityByCityNamePatchesAgentsData = { + body: AgentPatchSetInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/agents'; +}; + +export type PutV0CityByCityNamePatchesAgentsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PutV0CityByCityNamePatchesAgentsError = PutV0CityByCityNamePatchesAgentsErrors[keyof PutV0CityByCityNamePatchesAgentsErrors]; + +export type PutV0CityByCityNamePatchesAgentsResponses = { + /** + * OK + */ + 200: PatchOkResponseBody; +}; + +export type PutV0CityByCityNamePatchesAgentsResponse = PutV0CityByCityNamePatchesAgentsResponses[keyof PutV0CityByCityNamePatchesAgentsResponses]; + +export type DeleteV0CityByCityNamePatchesProviderByNameData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Provider patch name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/provider/{name}'; +}; + +export type DeleteV0CityByCityNamePatchesProviderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNamePatchesProviderByNameError = DeleteV0CityByCityNamePatchesProviderByNameErrors[keyof DeleteV0CityByCityNamePatchesProviderByNameErrors]; + +export type DeleteV0CityByCityNamePatchesProviderByNameResponses = { + /** + * OK + */ + 200: PatchDeletedResponseBody; +}; + +export type DeleteV0CityByCityNamePatchesProviderByNameResponse = DeleteV0CityByCityNamePatchesProviderByNameResponses[keyof DeleteV0CityByCityNamePatchesProviderByNameResponses]; + +export type GetV0CityByCityNamePatchesProviderByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Provider patch name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/provider/{name}'; +}; + +export type GetV0CityByCityNamePatchesProviderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesProviderByNameError = GetV0CityByCityNamePatchesProviderByNameErrors[keyof GetV0CityByCityNamePatchesProviderByNameErrors]; + +export type GetV0CityByCityNamePatchesProviderByNameResponses = { + /** + * OK + */ + 200: ProviderPatch; +}; + +export type GetV0CityByCityNamePatchesProviderByNameResponse = GetV0CityByCityNamePatchesProviderByNameResponses[keyof GetV0CityByCityNamePatchesProviderByNameResponses]; + +export type GetV0CityByCityNamePatchesProvidersData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/providers'; +}; + +export type GetV0CityByCityNamePatchesProvidersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesProvidersError = GetV0CityByCityNamePatchesProvidersErrors[keyof GetV0CityByCityNamePatchesProvidersErrors]; + +export type GetV0CityByCityNamePatchesProvidersResponses = { + /** + * OK + */ + 200: ListBodyProviderPatch; +}; + +export type GetV0CityByCityNamePatchesProvidersResponse = GetV0CityByCityNamePatchesProvidersResponses[keyof GetV0CityByCityNamePatchesProvidersResponses]; + +export type PutV0CityByCityNamePatchesProvidersData = { + body: ProviderPatchSetInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/providers'; +}; + +export type PutV0CityByCityNamePatchesProvidersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PutV0CityByCityNamePatchesProvidersError = PutV0CityByCityNamePatchesProvidersErrors[keyof PutV0CityByCityNamePatchesProvidersErrors]; + +export type PutV0CityByCityNamePatchesProvidersResponses = { + /** + * OK + */ + 200: PatchOkResponseBody; +}; + +export type PutV0CityByCityNamePatchesProvidersResponse = PutV0CityByCityNamePatchesProvidersResponses[keyof PutV0CityByCityNamePatchesProvidersResponses]; + +export type DeleteV0CityByCityNamePatchesRigByNameData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig patch name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/rig/{name}'; +}; + +export type DeleteV0CityByCityNamePatchesRigByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNamePatchesRigByNameError = DeleteV0CityByCityNamePatchesRigByNameErrors[keyof DeleteV0CityByCityNamePatchesRigByNameErrors]; + +export type DeleteV0CityByCityNamePatchesRigByNameResponses = { + /** + * OK + */ + 200: PatchDeletedResponseBody; +}; + +export type DeleteV0CityByCityNamePatchesRigByNameResponse = DeleteV0CityByCityNamePatchesRigByNameResponses[keyof DeleteV0CityByCityNamePatchesRigByNameResponses]; + +export type GetV0CityByCityNamePatchesRigByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig patch name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/rig/{name}'; +}; + +export type GetV0CityByCityNamePatchesRigByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesRigByNameError = GetV0CityByCityNamePatchesRigByNameErrors[keyof GetV0CityByCityNamePatchesRigByNameErrors]; + +export type GetV0CityByCityNamePatchesRigByNameResponses = { + /** + * OK + */ + 200: RigPatch; +}; + +export type GetV0CityByCityNamePatchesRigByNameResponse = GetV0CityByCityNamePatchesRigByNameResponses[keyof GetV0CityByCityNamePatchesRigByNameResponses]; + +export type GetV0CityByCityNamePatchesRigsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/rigs'; +}; + +export type GetV0CityByCityNamePatchesRigsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNamePatchesRigsError = GetV0CityByCityNamePatchesRigsErrors[keyof GetV0CityByCityNamePatchesRigsErrors]; + +export type GetV0CityByCityNamePatchesRigsResponses = { + /** + * OK + */ + 200: ListBodyRigPatch; +}; + +export type GetV0CityByCityNamePatchesRigsResponse = GetV0CityByCityNamePatchesRigsResponses[keyof GetV0CityByCityNamePatchesRigsResponses]; + +export type PutV0CityByCityNamePatchesRigsData = { + body: RigPatchSetInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/patches/rigs'; +}; + +export type PutV0CityByCityNamePatchesRigsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PutV0CityByCityNamePatchesRigsError = PutV0CityByCityNamePatchesRigsErrors[keyof PutV0CityByCityNamePatchesRigsErrors]; + +export type PutV0CityByCityNamePatchesRigsResponses = { + /** + * OK + */ + 200: PatchOkResponseBody; +}; + +export type PutV0CityByCityNamePatchesRigsResponse = PutV0CityByCityNamePatchesRigsResponses[keyof PutV0CityByCityNamePatchesRigsResponses]; + +export type GetV0CityByCityNameProviderReadinessData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Comma-separated provider names to check (default: claude,codex,gemini). + */ + providers?: string; + /** + * Force fresh probe, bypassing cache. + */ + fresh?: boolean; + }; + url: '/v0/city/{cityName}/provider-readiness'; +}; + +export type GetV0CityByCityNameProviderReadinessErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameProviderReadinessError = GetV0CityByCityNameProviderReadinessErrors[keyof GetV0CityByCityNameProviderReadinessErrors]; + +export type GetV0CityByCityNameProviderReadinessResponses = { + /** + * OK + */ + 200: ProviderReadinessResponse; +}; + +export type GetV0CityByCityNameProviderReadinessResponse = GetV0CityByCityNameProviderReadinessResponses[keyof GetV0CityByCityNameProviderReadinessResponses]; + +export type DeleteV0CityByCityNameProviderByNameData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Provider name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/provider/{name}'; +}; + +export type DeleteV0CityByCityNameProviderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameProviderByNameError = DeleteV0CityByCityNameProviderByNameErrors[keyof DeleteV0CityByCityNameProviderByNameErrors]; + +export type DeleteV0CityByCityNameProviderByNameResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameProviderByNameResponse = DeleteV0CityByCityNameProviderByNameResponses[keyof DeleteV0CityByCityNameProviderByNameResponses]; + +export type GetV0CityByCityNameProviderByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Provider name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/provider/{name}'; +}; + +export type GetV0CityByCityNameProviderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameProviderByNameError = GetV0CityByCityNameProviderByNameErrors[keyof GetV0CityByCityNameProviderByNameErrors]; + +export type GetV0CityByCityNameProviderByNameResponses = { + /** + * OK + */ + 200: ProviderResponse; +}; + +export type GetV0CityByCityNameProviderByNameResponse = GetV0CityByCityNameProviderByNameResponses[keyof GetV0CityByCityNameProviderByNameResponses]; + +export type PatchV0CityByCityNameProviderByNameData = { + body: ProviderUpdateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Provider name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/provider/{name}'; +}; + +export type PatchV0CityByCityNameProviderByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameProviderByNameError = PatchV0CityByCityNameProviderByNameErrors[keyof PatchV0CityByCityNameProviderByNameErrors]; + +export type PatchV0CityByCityNameProviderByNameResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameProviderByNameResponse = PatchV0CityByCityNameProviderByNameResponses[keyof PatchV0CityByCityNameProviderByNameResponses]; + +export type GetV0CityByCityNameProvidersData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/providers'; +}; + +export type GetV0CityByCityNameProvidersErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameProvidersError = GetV0CityByCityNameProvidersErrors[keyof GetV0CityByCityNameProvidersErrors]; + +export type GetV0CityByCityNameProvidersResponses = { + /** + * OK + */ + 200: ListBodyProviderResponse; +}; + +export type GetV0CityByCityNameProvidersResponse = GetV0CityByCityNameProvidersResponses[keyof GetV0CityByCityNameProvidersResponses]; + +export type CreateProviderData = { + body: ProviderCreateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/providers'; +}; + +export type CreateProviderErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateProviderError = CreateProviderErrors[keyof CreateProviderErrors]; + +export type CreateProviderResponses = { + /** + * Created + */ + 201: ProviderCreatedOutputBody; +}; + +export type CreateProviderResponse = CreateProviderResponses[keyof CreateProviderResponses]; + +export type GetV0CityByCityNameProvidersPublicData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/providers/public'; +}; + +export type GetV0CityByCityNameProvidersPublicErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameProvidersPublicError = GetV0CityByCityNameProvidersPublicErrors[keyof GetV0CityByCityNameProvidersPublicErrors]; + +export type GetV0CityByCityNameProvidersPublicResponses = { + /** + * OK + */ + 200: ProviderPublicListBody; +}; + +export type GetV0CityByCityNameProvidersPublicResponse = GetV0CityByCityNameProvidersPublicResponses[keyof GetV0CityByCityNameProvidersPublicResponses]; + +export type GetV0CityByCityNameReadinessData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Comma-separated readiness items to check (default: claude,codex,gemini,github_cli). + */ + items?: string; + /** + * Force fresh probe, bypassing cache. + */ + fresh?: boolean; + }; + url: '/v0/city/{cityName}/readiness'; +}; + +export type GetV0CityByCityNameReadinessErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameReadinessError = GetV0CityByCityNameReadinessErrors[keyof GetV0CityByCityNameReadinessErrors]; + +export type GetV0CityByCityNameReadinessResponses = { + /** + * OK + */ + 200: ReadinessResponse; +}; + +export type GetV0CityByCityNameReadinessResponse = GetV0CityByCityNameReadinessResponses[keyof GetV0CityByCityNameReadinessResponses]; + +export type DeleteV0CityByCityNameRigByNameData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/rig/{name}'; +}; + +export type DeleteV0CityByCityNameRigByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameRigByNameError = DeleteV0CityByCityNameRigByNameErrors[keyof DeleteV0CityByCityNameRigByNameErrors]; + +export type DeleteV0CityByCityNameRigByNameResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type DeleteV0CityByCityNameRigByNameResponse = DeleteV0CityByCityNameRigByNameResponses[keyof DeleteV0CityByCityNameRigByNameResponses]; + +export type GetV0CityByCityNameRigByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig name. + */ + name: string; + }; + query?: { + /** + * Include git status. + */ + git?: boolean; + }; + url: '/v0/city/{cityName}/rig/{name}'; +}; + +export type GetV0CityByCityNameRigByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameRigByNameError = GetV0CityByCityNameRigByNameErrors[keyof GetV0CityByCityNameRigByNameErrors]; + +export type GetV0CityByCityNameRigByNameResponses = { + /** + * OK + */ + 200: RigResponse; +}; + +export type GetV0CityByCityNameRigByNameResponse = GetV0CityByCityNameRigByNameResponses[keyof GetV0CityByCityNameRigByNameResponses]; + +export type PatchV0CityByCityNameRigByNameData = { + body: RigUpdateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/rig/{name}'; +}; + +export type PatchV0CityByCityNameRigByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameRigByNameError = PatchV0CityByCityNameRigByNameErrors[keyof PatchV0CityByCityNameRigByNameErrors]; + +export type PatchV0CityByCityNameRigByNameResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PatchV0CityByCityNameRigByNameResponse = PatchV0CityByCityNameRigByNameResponses[keyof PatchV0CityByCityNameRigByNameResponses]; + +export type PostV0CityByCityNameRigByNameByActionData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Rig name. + */ + name: string; + /** + * Action to perform (suspend, resume, restart). + */ + action: string; + }; + query?: never; + url: '/v0/city/{cityName}/rig/{name}/{action}'; +}; + +export type PostV0CityByCityNameRigByNameByActionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameRigByNameByActionError = PostV0CityByCityNameRigByNameByActionErrors[keyof PostV0CityByCityNameRigByNameByActionErrors]; + +export type PostV0CityByCityNameRigByNameByActionResponses = { + /** + * OK + */ + 200: RigActionBody; +}; + +export type PostV0CityByCityNameRigByNameByActionResponse = PostV0CityByCityNameRigByNameByActionResponses[keyof PostV0CityByCityNameRigByNameByActionResponses]; + +export type GetV0CityByCityNameRigsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + /** + * Include git status. + */ + git?: boolean; + }; + url: '/v0/city/{cityName}/rigs'; +}; + +export type GetV0CityByCityNameRigsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameRigsError = GetV0CityByCityNameRigsErrors[keyof GetV0CityByCityNameRigsErrors]; + +export type GetV0CityByCityNameRigsResponses = { + /** + * OK + */ + 200: ListBodyRigResponse; +}; + +export type GetV0CityByCityNameRigsResponse = GetV0CityByCityNameRigsResponses[keyof GetV0CityByCityNameRigsResponses]; + +export type CreateRigData = { + body: RigCreateInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/rigs'; +}; + +export type CreateRigErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateRigError = CreateRigErrors[keyof CreateRigErrors]; + +export type CreateRigResponses = { + /** + * Created + */ + 201: RigCreatedOutputBody; +}; + +export type CreateRigResponse = CreateRigResponses[keyof CreateRigResponses]; + +export type GetV0CityByCityNameServiceByNameData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Service name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/service/{name}'; +}; + +export type GetV0CityByCityNameServiceByNameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameServiceByNameError = GetV0CityByCityNameServiceByNameErrors[keyof GetV0CityByCityNameServiceByNameErrors]; + +export type GetV0CityByCityNameServiceByNameResponses = { + /** + * OK + */ + 200: Status; +}; + +export type GetV0CityByCityNameServiceByNameResponse = GetV0CityByCityNameServiceByNameResponses[keyof GetV0CityByCityNameServiceByNameResponses]; + +export type PostV0CityByCityNameServiceByNameRestartData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Service name. + */ + name: string; + }; + query?: never; + url: '/v0/city/{cityName}/service/{name}/restart'; +}; + +export type PostV0CityByCityNameServiceByNameRestartErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameServiceByNameRestartError = PostV0CityByCityNameServiceByNameRestartErrors[keyof PostV0CityByCityNameServiceByNameRestartErrors]; + +export type PostV0CityByCityNameServiceByNameRestartResponses = { + /** + * OK + */ + 200: ServiceRestartOutputBody; +}; + +export type PostV0CityByCityNameServiceByNameRestartResponse = PostV0CityByCityNameServiceByNameRestartResponses[keyof PostV0CityByCityNameServiceByNameRestartResponses]; + +export type GetV0CityByCityNameServicesData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/services'; +}; + +export type GetV0CityByCityNameServicesErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameServicesError = GetV0CityByCityNameServicesErrors[keyof GetV0CityByCityNameServicesErrors]; + +export type GetV0CityByCityNameServicesResponses = { + /** + * OK + */ + 200: ListBodyStatus; +}; + +export type GetV0CityByCityNameServicesResponse = GetV0CityByCityNameServicesResponses[keyof GetV0CityByCityNameServicesResponses]; + +export type GetV0CityByCityNameSessionByIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: { + /** + * Include last output preview. + */ + peek?: boolean; + }; + url: '/v0/city/{cityName}/session/{id}'; +}; + +export type GetV0CityByCityNameSessionByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionByIdError = GetV0CityByCityNameSessionByIdErrors[keyof GetV0CityByCityNameSessionByIdErrors]; + +export type GetV0CityByCityNameSessionByIdResponses = { + /** + * OK + */ + 200: SessionResponse; +}; + +export type GetV0CityByCityNameSessionByIdResponse = GetV0CityByCityNameSessionByIdResponses[keyof GetV0CityByCityNameSessionByIdResponses]; + +export type PatchV0CityByCityNameSessionByIdData = { + body: SessionPatchBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}'; +}; + +export type PatchV0CityByCityNameSessionByIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PatchV0CityByCityNameSessionByIdError = PatchV0CityByCityNameSessionByIdErrors[keyof PatchV0CityByCityNameSessionByIdErrors]; + +export type PatchV0CityByCityNameSessionByIdResponses = { + /** + * OK + */ + 200: SessionResponse; +}; + +export type PatchV0CityByCityNameSessionByIdResponse = PatchV0CityByCityNameSessionByIdResponses[keyof PatchV0CityByCityNameSessionByIdResponses]; + +export type GetV0CityByCityNameSessionByIdAgentsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/agents'; +}; + +export type GetV0CityByCityNameSessionByIdAgentsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionByIdAgentsError = GetV0CityByCityNameSessionByIdAgentsErrors[keyof GetV0CityByCityNameSessionByIdAgentsErrors]; + +export type GetV0CityByCityNameSessionByIdAgentsResponses = { + /** + * OK + */ + 200: SessionAgentListResponse; +}; + +export type GetV0CityByCityNameSessionByIdAgentsResponse = GetV0CityByCityNameSessionByIdAgentsResponses[keyof GetV0CityByCityNameSessionByIdAgentsResponses]; + +export type GetV0CityByCityNameSessionByIdAgentsByAgentIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + /** + * Subagent ID within the session. + */ + agentId: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/agents/{agentId}'; +}; + +export type GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionByIdAgentsByAgentIdError = GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors[keyof GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors]; + +export type GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses = { + /** + * OK + */ + 200: SessionAgentGetResponse; +}; + +export type GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse = GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses[keyof GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses]; + +export type PostV0CityByCityNameSessionByIdCloseData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: { + /** + * Permanently delete bead after closing. + */ + delete?: boolean; + }; + url: '/v0/city/{cityName}/session/{id}/close'; +}; + +export type PostV0CityByCityNameSessionByIdCloseErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdCloseError = PostV0CityByCityNameSessionByIdCloseErrors[keyof PostV0CityByCityNameSessionByIdCloseErrors]; + +export type PostV0CityByCityNameSessionByIdCloseResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameSessionByIdCloseResponse = PostV0CityByCityNameSessionByIdCloseResponses[keyof PostV0CityByCityNameSessionByIdCloseResponses]; + +export type PostV0CityByCityNameSessionByIdKillData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/kill'; +}; + +export type PostV0CityByCityNameSessionByIdKillErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdKillError = PostV0CityByCityNameSessionByIdKillErrors[keyof PostV0CityByCityNameSessionByIdKillErrors]; + +export type PostV0CityByCityNameSessionByIdKillResponses = { + /** + * OK + */ + 200: OkWithIdResponseBody; +}; + +export type PostV0CityByCityNameSessionByIdKillResponse = PostV0CityByCityNameSessionByIdKillResponses[keyof PostV0CityByCityNameSessionByIdKillResponses]; + +export type SendSessionMessageData = { + body: SessionMessageInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/messages'; +}; + +export type SendSessionMessageErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type SendSessionMessageError = SendSessionMessageErrors[keyof SendSessionMessageErrors]; + +export type SendSessionMessageResponses = { + /** + * Accepted + */ + 202: SessionMessageOutputBody; +}; + +export type SendSessionMessageResponse = SendSessionMessageResponses[keyof SendSessionMessageResponses]; + +export type GetV0CityByCityNameSessionByIdPendingData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/pending'; +}; + +export type GetV0CityByCityNameSessionByIdPendingErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionByIdPendingError = GetV0CityByCityNameSessionByIdPendingErrors[keyof GetV0CityByCityNameSessionByIdPendingErrors]; + +export type GetV0CityByCityNameSessionByIdPendingResponses = { + /** + * OK + */ + 200: SessionPendingResponse; +}; + +export type GetV0CityByCityNameSessionByIdPendingResponse = GetV0CityByCityNameSessionByIdPendingResponses[keyof GetV0CityByCityNameSessionByIdPendingResponses]; + +export type PostV0CityByCityNameSessionByIdRenameData = { + body: SessionRenameInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/rename'; +}; + +export type PostV0CityByCityNameSessionByIdRenameErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdRenameError = PostV0CityByCityNameSessionByIdRenameErrors[keyof PostV0CityByCityNameSessionByIdRenameErrors]; + +export type PostV0CityByCityNameSessionByIdRenameResponses = { + /** + * OK + */ + 200: SessionResponse; +}; + +export type PostV0CityByCityNameSessionByIdRenameResponse = PostV0CityByCityNameSessionByIdRenameResponses[keyof PostV0CityByCityNameSessionByIdRenameResponses]; + +export type RespondSessionData = { + body: SessionRespondInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/respond'; +}; + +export type RespondSessionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type RespondSessionError = RespondSessionErrors[keyof RespondSessionErrors]; + +export type RespondSessionResponses = { + /** + * Accepted + */ + 202: SessionRespondOutputBody; +}; + +export type RespondSessionResponse = RespondSessionResponses[keyof RespondSessionResponses]; + +export type PostV0CityByCityNameSessionByIdStopData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/stop'; +}; + +export type PostV0CityByCityNameSessionByIdStopErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdStopError = PostV0CityByCityNameSessionByIdStopErrors[keyof PostV0CityByCityNameSessionByIdStopErrors]; + +export type PostV0CityByCityNameSessionByIdStopResponses = { + /** + * OK + */ + 200: OkWithIdResponseBody; +}; + +export type PostV0CityByCityNameSessionByIdStopResponse = PostV0CityByCityNameSessionByIdStopResponses[keyof PostV0CityByCityNameSessionByIdStopResponses]; + +export type StreamSessionData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: { + /** + * Transcript format: conversation (default) or raw. + */ + format?: string; + }; + url: '/v0/city/{cityName}/session/{id}/stream'; +}; + +export type StreamSessionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type StreamSessionError = StreamSessionErrors[keyof StreamSessionErrors]; + +export type StreamSessionResponses = { + /** + * Server Sent Events + * + * Each oneOf object represents one possible SSE message. + */ + 200: Array<{ + data: SessionActivityEvent; + /** + * The event name. + */ + event: 'activity'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: HeartbeatEvent; + /** + * The event name. + */ + event: 'heartbeat'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: SessionStreamRawMessageEvent; + /** + * The event name. + */ + event?: 'message'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: PendingInteraction; + /** + * The event name. + */ + event: 'pending'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: SessionStreamMessageEvent; + /** + * The event name. + */ + event: 'turn'; + /** + * The event ID. + */ + id?: number; + /** + * The retry time in milliseconds. + */ + retry?: number; + }>; +}; + +export type StreamSessionResponse = StreamSessionResponses[keyof StreamSessionResponses]; + +export type SubmitSessionData = { + body: SessionSubmitInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/submit'; +}; + +export type SubmitSessionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type SubmitSessionError = SubmitSessionErrors[keyof SubmitSessionErrors]; + +export type SubmitSessionResponses = { + /** + * Accepted + */ + 202: SessionSubmitOutputBody; +}; + +export type SubmitSessionResponse = SubmitSessionResponses[keyof SubmitSessionResponses]; + +export type PostV0CityByCityNameSessionByIdSuspendData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/suspend'; +}; + +export type PostV0CityByCityNameSessionByIdSuspendErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdSuspendError = PostV0CityByCityNameSessionByIdSuspendErrors[keyof PostV0CityByCityNameSessionByIdSuspendErrors]; + +export type PostV0CityByCityNameSessionByIdSuspendResponses = { + /** + * OK + */ + 200: OkResponseBody; +}; + +export type PostV0CityByCityNameSessionByIdSuspendResponse = PostV0CityByCityNameSessionByIdSuspendResponses[keyof PostV0CityByCityNameSessionByIdSuspendResponses]; + +export type GetV0CityByCityNameSessionByIdTranscriptData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: { + /** + * Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N. + */ + tail?: string; + /** + * Transcript format: conversation (default) or raw. + */ + format?: string; + /** + * Pagination cursor: return entries before this UUID. + */ + before?: string; + }; + url: '/v0/city/{cityName}/session/{id}/transcript'; +}; + +export type GetV0CityByCityNameSessionByIdTranscriptErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionByIdTranscriptError = GetV0CityByCityNameSessionByIdTranscriptErrors[keyof GetV0CityByCityNameSessionByIdTranscriptErrors]; + +export type GetV0CityByCityNameSessionByIdTranscriptResponses = { + /** + * OK + */ + 200: SessionTranscriptGetResponse; +}; + +export type GetV0CityByCityNameSessionByIdTranscriptResponse = GetV0CityByCityNameSessionByIdTranscriptResponses[keyof GetV0CityByCityNameSessionByIdTranscriptResponses]; + +export type PostV0CityByCityNameSessionByIdWakeData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Session ID, alias, or runtime session_name. + */ + id: string; + }; + query?: never; + url: '/v0/city/{cityName}/session/{id}/wake'; +}; + +export type PostV0CityByCityNameSessionByIdWakeErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSessionByIdWakeError = PostV0CityByCityNameSessionByIdWakeErrors[keyof PostV0CityByCityNameSessionByIdWakeErrors]; + +export type PostV0CityByCityNameSessionByIdWakeResponses = { + /** + * OK + */ + 200: OkWithIdResponseBody; +}; + +export type PostV0CityByCityNameSessionByIdWakeResponse = PostV0CityByCityNameSessionByIdWakeResponses[keyof PostV0CityByCityNameSessionByIdWakeResponses]; + +export type GetV0CityByCityNameSessionsData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Pagination cursor from a previous response's next_cursor field. + */ + cursor?: string; + /** + * Maximum number of results to return. 0 = server default. + */ + limit?: number; + /** + * Filter by session state (e.g. active, closed). + */ + state?: string; + /** + * Filter by session template (agent qualified name). + */ + template?: string; + /** + * Include last output preview. + */ + peek?: boolean; + }; + url: '/v0/city/{cityName}/sessions'; +}; + +export type GetV0CityByCityNameSessionsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameSessionsError = GetV0CityByCityNameSessionsErrors[keyof GetV0CityByCityNameSessionsErrors]; + +export type GetV0CityByCityNameSessionsResponses = { + /** + * OK + */ + 200: ListBodySessionResponse; +}; + +export type GetV0CityByCityNameSessionsResponse = GetV0CityByCityNameSessionsResponses[keyof GetV0CityByCityNameSessionsResponses]; + +export type CreateSessionData = { + body: SessionCreateBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/sessions'; +}; + +export type CreateSessionErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type CreateSessionError = CreateSessionErrors[keyof CreateSessionErrors]; + +export type CreateSessionResponses = { + /** + * Accepted + */ + 202: SessionResponse; +}; + +export type CreateSessionResponse = CreateSessionResponses[keyof CreateSessionResponses]; + +export type PostV0CityByCityNameSlingData = { + body: SlingInputBody; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/sling'; +}; + +export type PostV0CityByCityNameSlingErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameSlingError = PostV0CityByCityNameSlingErrors[keyof PostV0CityByCityNameSlingErrors]; + +export type PostV0CityByCityNameSlingResponses = { + /** + * OK + */ + 200: SlingResponse; +}; + +export type PostV0CityByCityNameSlingResponse = PostV0CityByCityNameSlingResponses[keyof PostV0CityByCityNameSlingResponses]; + +export type GetV0CityByCityNameStatusData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + }; + query?: { + /** + * Event sequence number; when provided, blocks until a newer event arrives. + */ + index?: string; + /** + * How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m. + */ + wait?: string; + }; + url: '/v0/city/{cityName}/status'; +}; + +export type GetV0CityByCityNameStatusErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameStatusError = GetV0CityByCityNameStatusErrors[keyof GetV0CityByCityNameStatusErrors]; + +export type GetV0CityByCityNameStatusResponses = { + /** + * OK + */ + 200: StatusBody; +}; + +export type GetV0CityByCityNameStatusResponse = GetV0CityByCityNameStatusResponses[keyof GetV0CityByCityNameStatusResponses]; + +export type PostV0CityByCityNameUnregisterData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * Supervisor-registered city name. + */ + cityName: string; + }; + query?: never; + url: '/v0/city/{cityName}/unregister'; +}; + +export type PostV0CityByCityNameUnregisterErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type PostV0CityByCityNameUnregisterError = PostV0CityByCityNameUnregisterErrors[keyof PostV0CityByCityNameUnregisterErrors]; + +export type PostV0CityByCityNameUnregisterResponses = { + /** + * Accepted + */ + 202: CityUnregisterResponse; +}; + +export type PostV0CityByCityNameUnregisterResponse = PostV0CityByCityNameUnregisterResponses[keyof PostV0CityByCityNameUnregisterResponses]; + +export type DeleteV0CityByCityNameWorkflowByWorkflowIdData = { + body?: never; + headers: { + /** + * Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks. + */ + 'X-GC-Request': string; + }; + path: { + /** + * City name. + */ + cityName: string; + /** + * Workflow (convoy) ID. + */ + workflow_id: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + /** + * Permanently delete beads from store. + */ + delete?: boolean; + }; + url: '/v0/city/{cityName}/workflow/{workflow_id}'; +}; + +export type DeleteV0CityByCityNameWorkflowByWorkflowIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type DeleteV0CityByCityNameWorkflowByWorkflowIdError = DeleteV0CityByCityNameWorkflowByWorkflowIdErrors[keyof DeleteV0CityByCityNameWorkflowByWorkflowIdErrors]; + +export type DeleteV0CityByCityNameWorkflowByWorkflowIdResponses = { + /** + * OK + */ + 200: WorkflowDeleteResponse; +}; + +export type DeleteV0CityByCityNameWorkflowByWorkflowIdResponse = DeleteV0CityByCityNameWorkflowByWorkflowIdResponses[keyof DeleteV0CityByCityNameWorkflowByWorkflowIdResponses]; + +export type GetV0CityByCityNameWorkflowByWorkflowIdData = { + body?: never; + path: { + /** + * City name. + */ + cityName: string; + /** + * Workflow (convoy) ID. + */ + workflow_id: string; + }; + query?: { + /** + * Scope kind (city or rig). + */ + scope_kind?: string; + /** + * Scope reference. + */ + scope_ref?: string; + }; + url: '/v0/city/{cityName}/workflow/{workflow_id}'; +}; + +export type GetV0CityByCityNameWorkflowByWorkflowIdErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0CityByCityNameWorkflowByWorkflowIdError = GetV0CityByCityNameWorkflowByWorkflowIdErrors[keyof GetV0CityByCityNameWorkflowByWorkflowIdErrors]; + +export type GetV0CityByCityNameWorkflowByWorkflowIdResponses = { + /** + * OK + */ + 200: WorkflowSnapshotResponse; +}; + +export type GetV0CityByCityNameWorkflowByWorkflowIdResponse = GetV0CityByCityNameWorkflowByWorkflowIdResponses[keyof GetV0CityByCityNameWorkflowByWorkflowIdResponses]; + +export type GetV0EventsData = { + body?: never; + path?: never; + query?: { + /** + * Filter by event type. + */ + type?: string; + /** + * Filter by actor. + */ + actor?: string; + /** + * Filter to events within the last Go duration (e.g. "5m"). + */ + since?: string; + /** + * Maximum number of trailing events to return. 0 = no limit. Used by 'gc events --seq' to compute the head cursor cheaply. + */ + limit?: number; + }; + url: '/v0/events'; +}; + +export type GetV0EventsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0EventsError = GetV0EventsErrors[keyof GetV0EventsErrors]; + +export type GetV0EventsResponses = { + /** + * OK + */ + 200: SupervisorEventListOutputBody; +}; + +export type GetV0EventsResponse = GetV0EventsResponses[keyof GetV0EventsResponses]; + +export type StreamSupervisorEventsData = { + body?: never; + headers?: { + /** + * Reconnect cursor (composite per-city cursor). + */ + 'Last-Event-ID'?: string; + }; + path?: never; + query?: { + /** + * Alternative to Last-Event-ID for browsers that can't set custom headers. + */ + after_cursor?: string; + }; + url: '/v0/events/stream'; +}; + +export type StreamSupervisorEventsErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type StreamSupervisorEventsError = StreamSupervisorEventsErrors[keyof StreamSupervisorEventsErrors]; + +export type StreamSupervisorEventsResponses = { + /** + * Server Sent Events + * + * Each oneOf object represents one possible SSE message. + */ + 200: Array<{ + data: HeartbeatEvent; + /** + * The event name. + */ + event: 'heartbeat'; + /** + * The event ID (composite cursor). + */ + id?: string; + /** + * The retry time in milliseconds. + */ + retry?: number; + } | { + data: TypedTaggedEventStreamEnvelope; + /** + * The event name. + */ + event: 'tagged_event'; + /** + * The event ID (composite cursor). + */ + id?: string; + /** + * The retry time in milliseconds. + */ + retry?: number; + }>; +}; + +export type StreamSupervisorEventsResponse = StreamSupervisorEventsResponses[keyof StreamSupervisorEventsResponses]; + +export type GetV0ProviderReadinessData = { + body?: never; + path?: never; + query?: { + /** + * Comma-separated list of providers to probe. + */ + providers?: string; + /** + * Force fresh probe, bypassing cache. + */ + fresh?: boolean; + }; + url: '/v0/provider-readiness'; +}; + +export type GetV0ProviderReadinessErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0ProviderReadinessError = GetV0ProviderReadinessErrors[keyof GetV0ProviderReadinessErrors]; + +export type GetV0ProviderReadinessResponses = { + /** + * OK + */ + 200: ProviderReadinessResponse; +}; + +export type GetV0ProviderReadinessResponse = GetV0ProviderReadinessResponses[keyof GetV0ProviderReadinessResponses]; + +export type GetV0ReadinessData = { + body?: never; + path?: never; + query?: { + /** + * Comma-separated list of readiness items to check. + */ + items?: string; + /** + * Force fresh probe, bypassing cache. + */ + fresh?: boolean; + }; + url: '/v0/readiness'; +}; + +export type GetV0ReadinessErrors = { + /** + * Error + */ + default: ErrorModel; +}; + +export type GetV0ReadinessError = GetV0ReadinessErrors[keyof GetV0ReadinessErrors]; + +export type GetV0ReadinessResponses = { + /** + * OK + */ + 200: ReadinessResponse; +}; + +export type GetV0ReadinessResponse = GetV0ReadinessResponses[keyof GetV0ReadinessResponses]; diff --git a/cmd/gc/dispatch_runtime.go b/cmd/gc/dispatch_runtime.go index 0d32e31af..288d72607 100644 --- a/cmd/gc/dispatch_runtime.go +++ b/cmd/gc/dispatch_runtime.go @@ -15,6 +15,7 @@ import ( "github.com/gastownhall/gascity/internal/dispatch" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/formula" + "github.com/gastownhall/gascity/internal/shellquote" "github.com/gastownhall/gascity/internal/sling" ) @@ -65,7 +66,7 @@ func applyGraphRouting(recipe *formula.Recipe, a *config.Agent, routedTo string, var ( workflowServeList = nextWorkflowServeBeads - controlDispatcherServe = runControlDispatcher + controlDispatcherServe = runControlDispatcherInStore workflowServeOpenEventsProvider = func(stderr io.Writer) (events.Provider, error) { ep, code := openCityEventsProvider(stderr, "gc convoy control --serve") if ep == nil { @@ -194,10 +195,10 @@ func runWorkflowServe(agentName string, follow bool, _ io.Writer, stderr io.Writ workQuery := expandAgentCommandTemplate(cityPath, loadedCityName(cfg, cityPath), &agentCfg, cfg.Rigs, "work_query", agentCfg.EffectiveWorkQuery(), stderr) workflowTracef("serve start agent=%s city=%s dir=%s", agentCfg.QualifiedName(), cityPath, workDir) if !follow { - _, err := drainWorkflowServeWork(agentCfg, workQuery, workDir, workEnv, stderr) + _, err := drainWorkflowServeWork(agentCfg, cityPath, workDir, workQuery, workEnv, stderr) return err } - return runWorkflowServeFollow(agentCfg, workQuery, workDir, workEnv, stderr) + return runWorkflowServeFollow(agentCfg, cityPath, workDir, workQuery, workEnv, stderr) } type workflowServeDrainResult struct { @@ -209,11 +210,11 @@ type workflowServeDrainResult struct { // for a single invocation. Returns whether it advanced a control bead and // whether the queue still contains only pending work so the --follow caller // can distinguish blocked work from genuine idle. -func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir string, workEnv map[string]string, stderr io.Writer) (workflowServeDrainResult, error) { +func drainWorkflowServeWork(agentCfg config.Agent, cityPath, storePath, workQuery string, workEnv map[string]string, stderr io.Writer) (workflowServeDrainResult, error) { result := workflowServeDrainResult{} idlePolls := 0 for { - queue, err := workflowServeList(workflowServeQuery(workQuery), workDir, workEnv) + queue, err := workflowServeList(workflowServeWorkQuery(agentCfg, workQuery), storePath, workEnv) if err != nil { workflowTracef("serve query-error agent=%s err=%v", agentCfg.QualifiedName(), err) return result, fmt.Errorf("querying control work for %s: %w", agentCfg.QualifiedName(), err) @@ -231,6 +232,7 @@ func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir str idlePolls = 0 processedThisCycle := false pendingCount := 0 + legacyOversizedCount := 0 for _, candidate := range queue { beadID := candidate.ID kind := strings.TrimSpace(candidate.Metadata["gc.kind"]) @@ -238,7 +240,7 @@ func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir str workflowTracef("serve unexpected-kind bead=%s kind=%s", beadID, kind) return result, fmt.Errorf("bead %s has unexpected non-control kind %q", beadID, kind) } - workflowTracef("serve process bead=%s kind=%s", beadID, kind) + workflowTracef("serve process bead=%s kind=%s store=%s", beadID, kind, storePath) // controlDispatcherServe currently returns nil both when it // successfully advanced a control bead AND when ProcessControl // chose to no-op (e.g., status != "open"). The caller cannot @@ -248,7 +250,7 @@ func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir str // control ga-fw2fm. The silent no-op now emits a separate // `process-control ... skip reason=bead_not_open` line inside // ProcessControl itself; see runtime.go. - if err := controlDispatcherServe(beadID, io.Discard, stderr); err != nil { + if err := controlDispatcherServe(cityPath, storePath, beadID, io.Discard, stderr); err != nil { if errors.Is(err, dispatch.ErrControlPending) { pendingCount++ result.pendingAny = true @@ -256,6 +258,10 @@ func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir str continue } workflowTracef("serve process-error bead=%s kind=%s err=%v", beadID, kind, err) + if isLegacyOversizedControlEventError(err) { + legacyOversizedCount++ + continue + } return result, fmt.Errorf("processing control bead %s: %w", beadID, err) } workflowTracef("serve processed bead=%s kind=%s", beadID, kind) @@ -270,10 +276,24 @@ func drainWorkflowServeWork(agentCfg config.Agent, workQuery string, workDir str workflowTracef("serve pending-queue agent=%s count=%d", agentCfg.QualifiedName(), pendingCount) return result, nil } + if legacyOversizedCount > 0 { + workflowTracef("serve legacy-oversized-queue agent=%s count=%d", agentCfg.QualifiedName(), legacyOversizedCount) + return result, nil + } + } +} + +func isLegacyOversizedControlEventError(err error) bool { + if err == nil { + return false } + msg := err.Error() + return strings.Contains(msg, "recording attempt log") && + strings.Contains(msg, "old_value") && + strings.Contains(msg, "too large") } -func runWorkflowServeFollow(agentCfg config.Agent, workQuery string, workDir string, workEnv map[string]string, stderr io.Writer) error { +func runWorkflowServeFollow(agentCfg config.Agent, cityPath, storePath, workQuery string, workEnv map[string]string, stderr io.Writer) error { ep, err := workflowServeOpenEventsProvider(stderr) if err != nil { return err @@ -297,7 +317,7 @@ func runWorkflowServeFollow(agentCfg config.Agent, workQuery string, workDir str idleSweeps := 0 for { - drainResult, err := drainWorkflowServeWork(agentCfg, workQuery, workDir, workEnv, stderr) + drainResult, err := drainWorkflowServeWork(agentCfg, cityPath, storePath, workQuery, workEnv, stderr) if err != nil { return err } @@ -401,6 +421,65 @@ func workflowServeQuery(workQuery string) string { return workQuery } +func workflowServeWorkQuery(agentCfg config.Agent, expandedWorkQuery ...string) string { + if agentCfg.WorkQuery == "" && isWorkflowServeControlDispatcherAgent(agentCfg) { + return workflowServeControlReadyQuery(agentCfg) + } + workQuery := agentCfg.EffectiveWorkQuery() + if len(expandedWorkQuery) > 0 { + workQuery = expandedWorkQuery[0] + } + return workflowServeQuery(workQuery) +} + +func isWorkflowServeControlDispatcherAgent(agentCfg config.Agent) bool { + qualified := strings.TrimSpace(agentCfg.QualifiedName()) + return qualified == config.ControlDispatcherAgentName || + strings.HasSuffix(qualified, "/"+config.ControlDispatcherAgentName) +} + +func workflowServeControlReadyQuery(agentCfg config.Agent) string { + target := strings.TrimSpace(agentCfg.QualifiedName()) + if target == "" { + target = config.ControlDispatcherAgentName + } + limit := fmt.Sprintf("%d", workflowServeScanLimit) + queryPrefix := `GC_CONTROL_TARGET=` + shellquote.Quote(target) + if legacy := workflowServeLegacyControlRoute(target); legacy != "" { + queryPrefix += ` GC_CONTROL_LEGACY_TARGET=` + shellquote.Quote(legacy) + } + query := queryPrefix + ` sh -c '` + + `for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS" "$GC_CONTROL_TARGET"; do ` + + `[ -z "$id" ] && continue; ` + + `legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; ` + + `for cand in "$id" "$legacy"; do ` + + `[ -z "$cand" ] && continue; ` + + `r=$(bd ready --assignee="$cand" --json --limit=` + limit + ` 2>/dev/null); ` + + `[ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; ` + + `done; ` + + `done; ` + + `r=$(bd ready --metadata-field "gc.routed_to=$GC_CONTROL_TARGET" --unassigned --json --limit=` + limit + ` 2>/dev/null); ` + + `[ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; ` + if legacy := workflowServeLegacyControlRoute(target); legacy != "" { + query += `bd ready --metadata-field "gc.routed_to=$GC_CONTROL_LEGACY_TARGET" --unassigned --json --limit=` + limit + ` 2>/dev/null'` + } else { + query += `printf "[]"` + `'` + } + return query +} + +func workflowServeLegacyControlRoute(target string) string { + target = strings.TrimSpace(target) + if target == config.ControlDispatcherAgentName { + return "workflow-control" + } + const suffix = "/" + config.ControlDispatcherAgentName + if strings.HasSuffix(target, suffix) { + return strings.TrimSuffix(target, suffix) + "/workflow-control" + } + return "" +} + func nextWorkflowServeBeads(workQuery, dir string, env map[string]string) ([]hookBead, error) { if workQuery == "" { return nil, nil diff --git a/cmd/gc/dolt_gc_nudge_script_test.go b/cmd/gc/dolt_gc_nudge_script_test.go index 43af1bf2a..c4c32bf63 100644 --- a/cmd/gc/dolt_gc_nudge_script_test.go +++ b/cmd/gc/dolt_gc_nudge_script_test.go @@ -286,6 +286,7 @@ func TestDoltGCNudgeDefaultCallTimeoutMatchesOrderBudget(t *testing.T) { } func TestDoltGCNudgeBoundsGCCall(t *testing.T) { + skipSlowCmdGCTest(t, "runs dolt GC nudge shell timeout coverage; run make test-cmd-gc-process for full coverage") if _, err := exec.LookPath("timeout"); err != nil { if _, gtimeoutErr := exec.LookPath("gtimeout"); gtimeoutErr != nil { t.Skip("timeout/gtimeout not available") @@ -344,6 +345,7 @@ func TestDoltGCNudgeFailsClosedWithoutBoundedRunner(t *testing.T) { } func TestDoltGCNudgeFallbackLockHonorsFlockHolder(t *testing.T) { + skipSlowCmdGCTest(t, "runs dolt GC nudge shell lock contention coverage; run make test-cmd-gc-process for full coverage") cityPath := writeDoltGCNudgeCity(t) sleepPath, err := exec.LookPath("sleep") if err != nil { @@ -396,6 +398,7 @@ func TestDoltGCNudgeFallbackLockHonorsFlockHolder(t *testing.T) { } func TestDoltGCNudgeLockNormalizesLocalHostAliases(t *testing.T) { + skipSlowCmdGCTest(t, "runs dolt GC nudge shell lock contention coverage; run make test-cmd-gc-process for full coverage") cityPath := writeDoltGCNudgeCity(t) sleepPath, err := exec.LookPath("sleep") if err != nil { @@ -453,6 +456,7 @@ func TestDoltGCNudgeLockNormalizesLocalHostAliases(t *testing.T) { } func TestDoltGCNudgeLockIgnoresDifferentTmpDirs(t *testing.T) { + skipSlowCmdGCTest(t, "runs dolt GC nudge shell lock contention coverage; run make test-cmd-gc-process for full coverage") cityPath := writeDoltGCNudgeCity(t) sleepPath, err := exec.LookPath("sleep") if err != nil { @@ -593,10 +597,10 @@ func TestDoltGCNudgeSkipsExternalRigDatabaseWithoutLocalData(t *testing.T) { if len(lines) != 1 { t.Fatalf("dolt argv lines = %d, want 1 for local managed db only:\n%s", len(lines), argv) } - if !strings.Contains(lines[0], "--database testdb") { + if !strings.Contains(lines[0], "--use-db testdb") { t.Fatalf("dolt argv = %q, want local managed testdb", lines[0]) } - if strings.Contains(argv, "--database extdb") { + if strings.Contains(argv, "--use-db extdb") { t.Fatalf("dolt argv should not target external rig db:\n%s", argv) } } @@ -630,7 +634,7 @@ func TestDoltGCNudgeDefaultsMissingDatabaseMetadataToBeads(t *testing.T) { } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if !strings.Contains(argv, "--database beads") { + if !strings.Contains(argv, "--use-db beads") { t.Fatalf("dolt argv = %q, want default beads database", argv) } } @@ -669,10 +673,10 @@ func TestDoltGCNudgeSkipsInvalidDatabaseMetadata(t *testing.T) { if len(lines) != 1 { t.Fatalf("dolt argv lines = %d, want 1 valid database:\n%s", len(lines), argv) } - if !strings.Contains(lines[0], "--database testdb") { + if !strings.Contains(lines[0], "--use-db testdb") { t.Fatalf("dolt argv = %q, want local managed testdb", lines[0]) } - if strings.Contains(argv, "--database --help") { + if strings.Contains(argv, "--use-db --help") { t.Fatalf("dolt argv should not target invalid database:\n%s", argv) } } @@ -710,10 +714,10 @@ func TestDoltGCNudgeSkipsSystemDatabaseMetadata(t *testing.T) { } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if strings.Contains(argv, "--database mysql") { + if strings.Contains(argv, "--use-db mysql") { t.Fatalf("dolt argv should not target system database:\n%s", argv) } - if !strings.Contains(argv, "--database testdb") { + if !strings.Contains(argv, "--use-db testdb") { t.Fatalf("dolt argv = %q, want valid testdb", argv) } } @@ -747,7 +751,7 @@ func TestDoltGCNudgeAllowsHyphenatedDatabaseMetadata(t *testing.T) { } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if !strings.Contains(argv, "--database frontend-db") { + if !strings.Contains(argv, "--use-db frontend-db") { t.Fatalf("dolt argv = %q, want hyphenated database", argv) } } @@ -786,7 +790,7 @@ func TestDoltGCNudgeHonorsDataDirOverride(t *testing.T) { } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if !strings.Contains(argv, "--database testdb") { + if !strings.Contains(argv, "--use-db testdb") { t.Fatalf("dolt argv = %q, want override-backed testdb", argv) } } @@ -817,7 +821,7 @@ func TestDoltGCNudgeDiscoversOrphanDatabaseDirs(t *testing.T) { } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if !strings.Contains(argv, "--database orphan-db") { + if !strings.Contains(argv, "--use-db orphan-db") { t.Fatalf("dolt argv = %q, want orphan database", argv) } } @@ -869,7 +873,7 @@ func TestDoltGCNudgeAggregateThresholdTriggersSubthresholdDatabases(t *testing.T } argv := strings.TrimSpace(readFileString(t, argvCapture)) - if !strings.Contains(argv, "--database testdb") || !strings.Contains(argv, "--database rigdb") { + if !strings.Contains(argv, "--use-db testdb") || !strings.Contains(argv, "--use-db rigdb") { t.Fatalf("dolt argv = %q, want both subthreshold databases under aggregate trigger", argv) } } @@ -911,10 +915,10 @@ func TestDoltGCNudgeFallbackFindsLocalRigOutsideRigsDir(t *testing.T) { if len(lines) != 2 { t.Fatalf("dolt argv lines = %d, want 2 databases from fallback scan:\n%s", len(lines), argv) } - if !strings.Contains(argv, "--database testdb") { + if !strings.Contains(argv, "--use-db testdb") { t.Fatalf("dolt argv = %q, want city database", argv) } - if !strings.Contains(argv, "--database frontenddb") { + if !strings.Contains(argv, "--use-db frontenddb") { t.Fatalf("dolt argv = %q, want rig database outside rigs/ dir", argv) } } @@ -940,7 +944,7 @@ func TestDoltGCNudgeWarnsWhenRigListFailsBeforeFallback(t *testing.T) { if !strings.Contains(string(out), "gc rig list failed rc=7") { t.Fatalf("gc-nudge output = %q, want rig-list failure warning", out) } - if !strings.Contains(readFileString(t, argvCapture), "--database testdb") { + if !strings.Contains(readFileString(t, argvCapture), "--use-db testdb") { t.Fatalf("gc-nudge did not fall back to local metadata scan; output:\n%s", out) } } diff --git a/cmd/gc/dolt_port_selection.go b/cmd/gc/dolt_port_selection.go index 8f9686871..4135c35ac 100644 --- a/cmd/gc/dolt_port_selection.go +++ b/cmd/gc/dolt_port_selection.go @@ -45,8 +45,33 @@ func chooseManagedDoltPort(cityPath, stateFile string) (string, error) { } return strconv.Itoa(repaired.Port), nil } + if hint, found, hintErr := readPublishedDoltRuntimeStateHint(cityPath); hintErr == nil && found { + if repaired, ok := repairedManagedDoltRuntimeState(cityPath, layout, hint); ok { + if err := writeDoltRuntimeStateFile(stateFile, repaired); err != nil { + return "", fmt.Errorf("repair provider runtime state from published hint: %w", err) + } + if samePath(stateFile, canonicalStateFile) { + if err := publishManagedDoltRuntimeStateIfOwned(cityPath); err != nil { + return "", fmt.Errorf("publish repaired managed dolt runtime state: %w", err) + } + } + return strconv.Itoa(repaired.Port), nil + } + } } else if !os.IsNotExist(err) { return "", fmt.Errorf("read provider runtime state: %w", err) + } else if hint, found, hintErr := readPublishedDoltRuntimeStateHint(cityPath); hintErr == nil && found { + if repaired, ok := repairedManagedDoltRuntimeState(cityPath, layout, hint); ok { + if err := writeDoltRuntimeStateFile(stateFile, repaired); err != nil { + return "", fmt.Errorf("repair missing provider runtime state: %w", err) + } + if samePath(stateFile, canonicalStateFile) { + if err := publishManagedDoltRuntimeStateIfOwned(cityPath); err != nil { + return "", fmt.Errorf("publish repaired managed dolt runtime state: %w", err) + } + } + return strconv.Itoa(repaired.Port), nil + } } seed := deterministicManagedDoltPortSeed(cityPath) return strconv.Itoa(nextAvailableManagedDoltPort(seed)), nil diff --git a/cmd/gc/dolt_preflight_cleanup_test.go b/cmd/gc/dolt_preflight_cleanup_test.go index 1e3c9a108..0d9f3b0be 100644 --- a/cmd/gc/dolt_preflight_cleanup_test.go +++ b/cmd/gc/dolt_preflight_cleanup_test.go @@ -83,6 +83,7 @@ func TestFileOpenedByAnyProcessBoundsLsof(t *testing.T) { } func TestRemoveStaleManagedDoltLocksWithoutLsofUsesAvailableState(t *testing.T) { + skipSlowCmdGCTest(t, "runs managed-dolt preflight cleanup against filesystem locks; run make test-cmd-gc-process for full coverage") dataDir := t.TempDir() lockFile := filepath.Join(dataDir, "hq", ".dolt", "noms", "LOCK") if err := os.MkdirAll(filepath.Dir(lockFile), 0o755); err != nil { diff --git a/cmd/gc/dolt_project_id_test.go b/cmd/gc/dolt_project_id_test.go index 32d7ba50d..0a389722a 100644 --- a/cmd/gc/dolt_project_id_test.go +++ b/cmd/gc/dolt_project_id_test.go @@ -14,7 +14,7 @@ import ( ) func TestEnsureManagedDoltProjectIDGeneratesLocalIdentityWhenMetadataAndDatabaseMissing(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed dolt server; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed dolt server; run make test-cmd-gc-process for full coverage") doltPath := os.Getenv("GC_DOLT_REAL_BINARY") var err error if doltPath == "" { @@ -258,7 +258,7 @@ func TestManagedDoltWaitReadyWithPasswordUsesDirectQueryProbe(t *testing.T) { } func TestRecoverManagedDoltProcessWithPasswordUsesDirectHelpersAgainstRealServer(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed dolt server; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed dolt server; run make test-cmd-gc-process for full coverage") cityPath := t.TempDir() layout, err := resolveManagedDoltRuntimeLayout(cityPath) if err != nil { @@ -305,7 +305,7 @@ func TestRecoverManagedDoltProcessWithPasswordUsesDirectHelpersAgainstRealServer } func TestEnsureManagedDoltProjectIDGeneratesLocalIdentityWithPasswordedServer(t *testing.T) { - skipSlowCmdGCTest(t, "requires a managed dolt server; run without -short or via integration packages") + skipSlowCmdGCTest(t, "requires a managed dolt server; run make test-cmd-gc-process for full coverage") cityDir := t.TempDir() metadataPath := filepath.Join(cityDir, ".beads", "metadata.json") if err := os.MkdirAll(filepath.Dir(metadataPath), 0o755); err != nil { diff --git a/cmd/gc/dolt_runtime_publication.go b/cmd/gc/dolt_runtime_publication.go index b0ddf13b2..892b5dc73 100644 --- a/cmd/gc/dolt_runtime_publication.go +++ b/cmd/gc/dolt_runtime_publication.go @@ -48,6 +48,17 @@ func removeDoltRuntimeStateFile(path string) error { return nil } +func readPublishedDoltRuntimeStateHint(cityPath string) (doltRuntimeState, bool, error) { + hint, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) + if err == nil { + return hint, true, nil + } + if os.IsNotExist(err) { + return doltRuntimeState{}, false, nil + } + return doltRuntimeState{}, false, fmt.Errorf("read published dolt runtime state hint: %w", err) +} + func managedDoltLifecycleOwned(cityPath string) (bool, error) { if cityUsesBdStoreContract(cityPath) { _, _, ok, invalid := resolveConfiguredCityDoltTarget(cityPath) @@ -101,13 +112,52 @@ func syncManagedDoltPortMirrors(cityPath string) error { } func publishManagedDoltRuntimeState(cityPath string) error { - state, err := readDoltRuntimeStateFile(providerManagedDoltStatePath(cityPath)) - if err != nil { - return fmt.Errorf("read provider dolt runtime state: %w", err) - } - if !validDoltRuntimeState(state, cityPath) { - return fmt.Errorf("invalid managed dolt runtime state") + providerStatePath := providerManagedDoltStatePath(cityPath) + state, readErr := readDoltRuntimeStateFile(providerStatePath) + if readErr != nil && !os.IsNotExist(readErr) { + return fmt.Errorf("read provider dolt runtime state: %w", readErr) + } + publishedHintFound := false + if readErr != nil || !validDoltRuntimeState(state, cityPath) { + // Provider state is missing or stale. Attempt recovery by inspecting + // the actual running dolt process. This handles the case where dolt + // was restarted (new PID) but the provider state file was not yet + // updated, or where a crash left the provider state file absent. + layout, layoutErr := resolveManagedDoltRuntimeLayout(cityPath) + if layoutErr != nil { + return fmt.Errorf("resolve managed dolt runtime layout: %w", layoutErr) + } + repaired, ok := repairedManagedDoltRuntimeState(cityPath, layout, state) + if !ok { + // The repair path needs a port hint. When the provider state is + // missing, or exists but points at a dead/stale port, the published + // runtime state is the only managed-local hint source. + hint, found, hintErr := readPublishedDoltRuntimeStateHint(cityPath) + if hintErr != nil { + return hintErr + } + if found { + state = hint + publishedHintFound = true + repaired, ok = repairedManagedDoltRuntimeState(cityPath, layout, state) + } + } + if !ok { + if readErr != nil { + if !publishedHintFound { + return fmt.Errorf("recover missing provider dolt runtime state: no published dolt runtime state hint") + } + return fmt.Errorf("recover missing provider dolt runtime state: no live managed dolt found for published port hint %d", state.Port) + } + return fmt.Errorf("invalid managed dolt runtime state") + } + // Repair the provider state file so future calls see a consistent view. + if err := writeDoltRuntimeStateFile(providerStatePath, repaired); err != nil { + return fmt.Errorf("repair provider dolt runtime state: %w", err) + } + state = repaired } + if err := writeDoltRuntimeStateFile(managedDoltStatePath(cityPath), state); err != nil { return fmt.Errorf("write published dolt runtime state: %w", err) } diff --git a/cmd/gc/dolt_runtime_publication_test.go b/cmd/gc/dolt_runtime_publication_test.go new file mode 100644 index 000000000..c1182e9a5 --- /dev/null +++ b/cmd/gc/dolt_runtime_publication_test.go @@ -0,0 +1,368 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// TestPublishManagedDoltRuntimeStateRepairsStaleProviderState verifies that +// publishManagedDoltRuntimeState recovers when dolt-provider-state.json has a +// stale PID (e.g. dolt was restarted) but the process is actually running and +// healthy. The repaired state must be written to both dolt-provider-state.json +// and dolt-state.json. +func TestPublishManagedDoltRuntimeStateRepairsStaleProviderState(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() + }() + + // Write provider state with a stale PID — simulates dolt having been + // restarted but provider state not yet refreshed. + if err := writeDoltRuntimeStateFile(layout.StateFile, doltRuntimeState{ + Running: true, + PID: 999999, // stale — no such process + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(provider): %v", err) + } + + if err := publishManagedDoltRuntimeState(cityPath); err != nil { + t.Fatalf("publishManagedDoltRuntimeState: %v", err) + } + + // dolt-state.json must now exist and carry the correct live PID. + published, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(dolt-state.json): %v", err) + } + if !published.Running { + t.Fatal("published.Running = false, want true") + } + if published.Port != port { + t.Fatalf("published.Port = %d, want %d", published.Port, port) + } + if published.PID != listener.Process.Pid { + t.Fatalf("published.PID = %d, want %d (actual listener PID)", published.PID, listener.Process.Pid) + } + + // Provider state must also be repaired. + repaired, err := readDoltRuntimeStateFile(layout.StateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if repaired.PID != listener.Process.Pid { + t.Fatalf("repaired provider PID = %d, want %d", repaired.PID, listener.Process.Pid) + } +} + +// TestPublishManagedDoltRuntimeStateFailsWhenProviderStateMissingWithoutPortHint +// verifies that publishManagedDoltRuntimeState fails clearly when +// dolt-provider-state.json is absent and no persisted port hint exists. +func TestPublishManagedDoltRuntimeStateFailsWhenProviderStateMissingWithoutPortHint(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) + } + // Ensure parent directory for provider state exists (normally written by script). + if err := os.MkdirAll(filepath.Dir(layout.StateFile), 0o755); err != nil { + t.Fatalf("MkdirAll(state dir): %v", err) + } + + // No provider state file — absent entirely. + if _, err := os.Stat(layout.StateFile); err == nil { + if err := os.Remove(layout.StateFile); err != nil { + t.Fatalf("remove provider state: %v", err) + } + } + + err = publishManagedDoltRuntimeState(cityPath) + if err == nil { + t.Fatal("publishManagedDoltRuntimeState() succeeded, want error (no port hint)") + } + if !strings.Contains(err.Error(), "no published dolt runtime state hint") { + t.Fatalf("error missing no-hint context: %v", err) + } + if _, statErr := os.Stat(layout.StateFile); statErr == nil { + t.Fatal("dolt-provider-state.json was created despite missing port hint") + } + if _, statErr := os.Stat(managedDoltStatePath(cityPath)); statErr == nil { + t.Fatal("dolt-state.json was created despite missing port hint") + } +} + +// TestPublishManagedDoltRuntimeStateRecoversMissingProviderStateWithPortHint +// verifies recovery when dolt-provider-state.json is absent but dolt IS running +// AND we have a stale state with the correct port to probe. This simulates the +// scenario where the published dolt-state.json exists with a valid port but the +// provider state was lost (e.g. runtime dir was wiped). +func TestPublishManagedDoltRuntimeStateRecoversMissingProviderStateWithPortHint(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() + }() + + // The provider state file is absent, but the published dolt-state.json + // still carries the correct port. This is the only safe hint source for + // repairing a missing provider state file. + 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(provider stopped): %v", err) + } + + if err := publishManagedDoltRuntimeState(cityPath); err != nil { + t.Fatalf("publishManagedDoltRuntimeState: %v", err) + } + + published, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(dolt-state.json): %v", err) + } + if !published.Running { + t.Fatal("published.Running = false, want true") + } + if published.Port != port { + t.Fatalf("published.Port = %d, want %d", published.Port, port) + } + if published.PID != listener.Process.Pid { + t.Fatalf("published.PID = %d, want %d (listener PID)", published.PID, listener.Process.Pid) + } + + repaired, err := readDoltRuntimeStateFile(layout.StateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if !repaired.Running { + t.Fatal("repaired.Running = false, want true") + } + if repaired.Port != port { + t.Fatalf("repaired.Port = %d, want %d", repaired.Port, port) + } + if repaired.PID != listener.Process.Pid { + t.Fatalf("repaired.PID = %d, want %d (listener PID)", repaired.PID, listener.Process.Pid) + } +} + +func TestPublishManagedDoltRuntimeStateRecoversStaleWrongPortProviderStateWithPublishedHint(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) + } + + stalePort := reserveRandomTCPPort(t) + if err := writeDoltRuntimeStateFile(layout.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) + } + + if err := publishManagedDoltRuntimeState(cityPath); err != nil { + t.Fatalf("publishManagedDoltRuntimeState: %v", err) + } + + published, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(dolt-state.json): %v", err) + } + if published.Port != port { + t.Fatalf("published.Port = %d, want %d", published.Port, port) + } + if published.PID != listener.Process.Pid { + t.Fatalf("published.PID = %d, want %d", published.PID, listener.Process.Pid) + } + + repaired, err := readDoltRuntimeStateFile(layout.StateFile) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(provider): %v", err) + } + if repaired.Port != port { + t.Fatalf("repaired.Port = %d, want %d", repaired.Port, port) + } + if repaired.PID != listener.Process.Pid { + t.Fatalf("repaired.PID = %d, want %d", repaired.PID, listener.Process.Pid) + } +} + +func TestPublishManagedDoltRuntimeStateFailsWhenPublishedHintIsDead(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) + 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) + } + + err = publishManagedDoltRuntimeState(cityPath) + if err == nil { + t.Fatal("publishManagedDoltRuntimeState() succeeded, want error (dead port hint)") + } + want := "no live managed dolt found for published port hint " + strconv.Itoa(port) + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want context %q", err, want) + } + if _, statErr := os.Stat(layout.StateFile); statErr == nil { + t.Fatal("dolt-provider-state.json was created despite dead port hint") + } +} + +// TestPublishManagedDoltRuntimeStateSucceedsWhenAlreadyValid verifies the +// normal (non-recovery) path still works correctly. +func TestPublishManagedDoltRuntimeStateSucceedsWhenAlreadyValid(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() + }() + + // Write a fully valid provider state. + if err := writeDoltRuntimeStateFile(layout.StateFile, doltRuntimeState{ + Running: true, + PID: listener.Process.Pid, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(provider): %v", err) + } + + if err := publishManagedDoltRuntimeState(cityPath); err != nil { + t.Fatalf("publishManagedDoltRuntimeState: %v", err) + } + + published, err := readDoltRuntimeStateFile(managedDoltStatePath(cityPath)) + if err != nil { + t.Fatalf("readDoltRuntimeStateFile(dolt-state.json): %v", err) + } + if !published.Running { + t.Fatal("published.Running = false, want true") + } + if published.Port != port { + t.Fatalf("published.Port = %d, want %d", published.Port, port) + } + if published.PID != listener.Process.Pid { + t.Fatalf("published.PID = %d, want %d", published.PID, listener.Process.Pid) + } +} + +// TestPublishManagedDoltRuntimeStateFailsWhenDoltNotRunning verifies that +// publishManagedDoltRuntimeState returns an error when dolt is not running +// (stale PID, no port holder) and does not create a dolt-state.json. +func TestPublishManagedDoltRuntimeStateFailsWhenDoltNotRunning(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) + } + // Reserve a port and immediately release it so we have a valid port number + // but nothing listening there. + port := reserveRandomTCPPort(t) + + if err := writeDoltRuntimeStateFile(layout.StateFile, doltRuntimeState{ + Running: true, + PID: 999999, + Port: port, + DataDir: layout.DataDir, + StartedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDoltRuntimeStateFile(provider): %v", err) + } + + err = publishManagedDoltRuntimeState(cityPath) + if err == nil { + t.Fatal("publishManagedDoltRuntimeState() succeeded, want error (nothing listening)") + } + if !strings.Contains(err.Error(), "managed dolt runtime state") { + t.Fatalf("error missing context: %v", err) + } + + // dolt-state.json must not have been created. + if _, statErr := os.Stat(managedDoltStatePath(cityPath)); statErr == nil { + t.Fatal("dolt-state.json was created despite dolt not running") + } +} diff --git a/cmd/gc/dolt_start_managed.go b/cmd/gc/dolt_start_managed.go index fc4e581a0..4f7431142 100644 --- a/cmd/gc/dolt_start_managed.go +++ b/cmd/gc/dolt_start_managed.go @@ -73,6 +73,7 @@ func startManagedDoltProcessWithOptions(cityPath, host, port, user, logLevel str cmd.Stderr = logFile cmd.Stdin = nil cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Env = doltServerEnv(os.Environ()) if err := cmd.Start(); err != nil { _ = logFile.Close() return report, fmt.Errorf("start dolt sql-server: %w", err) @@ -208,3 +209,20 @@ func terminateManagedDoltPID(pid int) error { time.Sleep(250 * time.Millisecond) return nil } + +// doltServerEnv augments the parent environment with overrides we need +// applied to every managed dolt sql-server we launch. Currently it +// disables Dolt's load-average auto-GC scheduler, which on multi-core +// hosts (>~16 CPUs) silently prevents auto-GC from ever running. See +// https://github.com/dolthub/dolt/issues/10944. Users who explicitly +// set DOLT_GC_SCHEDULER are respected. +func doltServerEnv(parent []string) []string { + const key = "DOLT_GC_SCHEDULER" + prefix := key + "=" + for _, kv := range parent { + if strings.HasPrefix(kv, prefix) { + return parent + } + } + return append(append([]string(nil), parent...), prefix+"NONE") +} diff --git a/cmd/gc/dolt_start_managed_test.go b/cmd/gc/dolt_start_managed_test.go new file mode 100644 index 000000000..a7058f93a --- /dev/null +++ b/cmd/gc/dolt_start_managed_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDoltServerEnv_AppendsDefaultWhenMissing(t *testing.T) { + parent := []string{"PATH=/usr/bin", "HOME=/home/test"} + out := doltServerEnv(parent) + + want := "DOLT_GC_SCHEDULER=NONE" + found := false + for _, kv := range out { + if kv == want { + found = true + break + } + } + if !found { + t.Fatalf("expected %q in env, got %v", want, out) + } + // Original entries preserved. + for _, kv := range parent { + var hit bool + for _, got := range out { + if got == kv { + hit = true + break + } + } + if !hit { + t.Fatalf("parent entry %q missing from output env %v", kv, out) + } + } +} + +func TestDoltServerEnv_RespectsUserOverride(t *testing.T) { + parent := []string{"PATH=/usr/bin", "DOLT_GC_SCHEDULER=LOADAVG", "HOME=/home/test"} + out := doltServerEnv(parent) + + // User-provided value must be preserved exactly. + count := 0 + for _, kv := range out { + if kv == "DOLT_GC_SCHEDULER=LOADAVG" { + count++ + } + if kv == "DOLT_GC_SCHEDULER=NONE" { + t.Fatalf("user override clobbered by default: %v", out) + } + } + if count != 1 { + t.Fatalf("expected exactly one DOLT_GC_SCHEDULER=LOADAVG entry, got %d in %v", count, out) + } +} + +func TestDoltServerEnv_RespectsEmptyUserValue(t *testing.T) { + // An explicit empty value (DOLT_GC_SCHEDULER=) is still a user + // override and we must not replace it. + parent := []string{"DOLT_GC_SCHEDULER="} + out := doltServerEnv(parent) + for _, kv := range out { + if kv == "DOLT_GC_SCHEDULER=NONE" { + t.Fatalf("explicit empty-value override clobbered: %v", out) + } + } +} + +func TestGCBeadsBDScript_RespectsEmptyUserValue(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + scriptPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "examples", "bd", "assets", "scripts", "gc-beads-bd.sh") + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", scriptPath, err) + } + script := string(data) + + if !strings.Contains(script, `${DOLT_GC_SCHEDULER=NONE}`) { + t.Fatalf("gc-beads-bd.sh must default DOLT_GC_SCHEDULER only when unset") + } + if strings.Contains(script, `${DOLT_GC_SCHEDULER:=NONE}`) { + t.Fatalf("gc-beads-bd.sh must not clobber an explicitly empty DOLT_GC_SCHEDULER") + } +} diff --git a/cmd/gc/embed_builtin_packs.go b/cmd/gc/embed_builtin_packs.go index fc5f22363..d55fbf1c3 100644 --- a/cmd/gc/embed_builtin_packs.go +++ b/cmd/gc/embed_builtin_packs.go @@ -14,6 +14,7 @@ import ( "github.com/gastownhall/gascity/examples/gastown/packs/maintenance" "github.com/gastownhall/gascity/internal/bootstrap/packs/core" "github.com/gastownhall/gascity/internal/citylayout" + "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/orders" ) @@ -38,9 +39,10 @@ var builtinPacks = []builtinPack{ } // MaterializeBuiltinPacks writes all embedded pack files to -// .gc/system/packs/{name}/ in the city directory. Files are always -// overwritten to stay in sync with the gc binary version. Shell scripts -// get 0755; everything else 0644. +// .gc/system/packs/{name}/ in the city directory. Files whose content and mode +// already match are left in place; changed content or mode is repaired with an +// atomic rename so readers never observe a truncated file. Shell scripts get +// 0755; everything else 0644. // Idempotent: safe to call on every gc start and gc init. func MaterializeBuiltinPacks(cityPath string) error { for _, bp := range builtinPacks { @@ -160,7 +162,7 @@ func materializeFS(embedded fs.FS, root, dstDir string) error { if isExecutableScriptFilename(path) { perm = 0o755 } - return os.WriteFile(dst, data, perm) + return fsys.WriteFileIfContentOrModeChangedAtomic(fsys.OSFS{}, dst, data, perm) }) } diff --git a/cmd/gc/embed_builtin_packs_test.go b/cmd/gc/embed_builtin_packs_test.go index 1530459ee..d068d3ffb 100644 --- a/cmd/gc/embed_builtin_packs_test.go +++ b/cmd/gc/embed_builtin_packs_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" @@ -325,6 +326,169 @@ func TestMaterializeBuiltinPacks_Idempotent(t *testing.T) { } } +func TestMaterializeBuiltinPacksPiHookUsesCurrentExtensionAPI(t *testing.T) { + dir := t.TempDir() + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() error: %v", err) + } + + data := readMaterializedPiHook(t, dir) + for _, want := range []string{ + "module.exports = function gascityPiExtension(pi)", + `pi.on("session_start"`, + `pi.on("session_compact"`, + `pi.on("session_shutdown"`, + `pi.on("before_agent_start"`, + } { + if !strings.Contains(data, want) { + t.Errorf("materialized Pi hook missing current extension API marker %q:\n%s", want, data) + } + } + for _, legacy := range []string{ + "module.exports = {", + `"session.created"`, + `"session.compacted"`, + `"session.deleted"`, + `"experimental.chat.system.transform"`, + } { + if strings.Contains(data, legacy) { + t.Errorf("materialized Pi hook still contains legacy API marker %q:\n%s", legacy, data) + } + } +} + +func TestMaterializeBuiltinPacksReplacesStaleMaterializedPiHook(t *testing.T) { + dir := t.TempDir() + hookPath := materializedPiHookPath(dir) + if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(hookPath), err) + } + stale := []byte(`// Gas City hooks for Pi Coding Agent. +module.exports = { + name: "gascity", + events: { "session.created": () => "" }, + hooks: { "experimental.chat.system.transform": (system) => system }, +}; +`) + if err := os.WriteFile(hookPath, stale, 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", hookPath, err) + } + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() error: %v", err) + } + + data := readMaterializedPiHook(t, dir) + if data == string(stale) { + t.Fatal("stale materialized Pi hook was preserved; expected core pack materialization to repair it") + } + if !strings.Contains(data, `pi.on("session_start"`) { + t.Fatalf("repaired materialized Pi hook does not use current extension API:\n%s", data) + } +} + +func materializedPiHookPath(dir string) string { + return filepath.Join(dir, citylayout.SystemPacksRoot, "core", "overlay", "per-provider", "pi", ".pi", "extensions", "gc-hooks.js") +} + +func readMaterializedPiHook(t *testing.T, dir string) string { + t.Helper() + path := materializedPiHookPath(dir) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(data) +} + +func TestMaterializeBuiltinPacks_DoesNotRewriteUnchangedFiles(t *testing.T) { + dir := t.TempDir() + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() error: %v", err) + } + + path := filepath.Join(dir, citylayout.SystemPacksRoot, "core", "skills", "gc-dashboard", "SKILL.md") + past := time.Unix(123456789, 0) + if err := os.Chtimes(path, past, past); err != nil { + t.Fatalf("Chtimes(%s): %v", path, err) + } + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() second call error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%s): %v", path, err) + } + if !info.ModTime().Equal(past) { + t.Fatalf("unchanged file was rewritten: modtime = %s, want %s", info.ModTime(), past) + } +} + +func TestMaterializeBuiltinPacks_RestoresModeWhenContentUnchanged(t *testing.T) { + dir := t.TempDir() + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() error: %v", err) + } + + path := filepath.Join(dir, citylayout.SystemPacksRoot, "bd", "doctor", "check-bd", "run.sh") + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod(%s): %v", path, err) + } + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() second call error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%s): %v", path, err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("script mode was not restored: %v", info.Mode().Perm()) + } +} + +func TestMaterializeBuiltinPacks_ReplacesMatchingSymlink(t *testing.T) { + dir := t.TempDir() + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() error: %v", err) + } + + path := filepath.Join(dir, citylayout.SystemPacksRoot, "core", "skills", "gc-dashboard", "SKILL.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + target := filepath.Join(dir, "outside-skill.md") + if err := os.WriteFile(target, data, 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", target, err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove(%s): %v", path, err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("Symlink: %v", err) + } + + if err := MaterializeBuiltinPacks(dir); err != nil { + t.Fatalf("MaterializeBuiltinPacks() second call error: %v", err) + } + + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("Lstat(%s): %v", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("matching symlink was preserved, want regular file") + } +} + func TestMaterializedBuiltinPackOrdersScanWithoutWarnings(t *testing.T) { dir := t.TempDir() diff --git a/cmd/gc/fast_loop_helpers_test.go b/cmd/gc/fast_loop_helpers_test.go index 9ecd5ba24..0e92f2f36 100644 --- a/cmd/gc/fast_loop_helpers_test.go +++ b/cmd/gc/fast_loop_helpers_test.go @@ -14,7 +14,10 @@ import ( func skipSlowCmdGCTest(t *testing.T, reason string) { t.Helper() - if os.Getenv("GC_FAST_UNIT") == "1" || testing.Short() { + if testing.Short() || strings.TrimSpace(os.Getenv("GC_FAST_UNIT")) != "0" { + if strings.TrimSpace(os.Getenv("GC_FAST_UNIT")) == "" && !strings.Contains(reason, "test-cmd-gc-process") { + reason += "; set GC_FAST_UNIT=0 or run make test-cmd-gc-process for full process coverage" + } t.Skip(reason) } } @@ -80,6 +83,7 @@ func reserveRandomTCPPort(t *testing.T) int { func startTCPListenerProcess(t *testing.T, port int) *exec.Cmd { t.Helper() + skipSlowCmdGCTest(t, "spawns a TCP listener process to emulate managed dolt; run make test-cmd-gc-process for full coverage") cmd := exec.Command("python3", "-c", ` import signal import socket diff --git a/cmd/gc/graph_dispatch_mem_test.go b/cmd/gc/graph_dispatch_mem_test.go index c73d4d444..ee15d6df4 100644 --- a/cmd/gc/graph_dispatch_mem_test.go +++ b/cmd/gc/graph_dispatch_mem_test.go @@ -433,8 +433,8 @@ func TestGraphWorkflowInMemoryRouteUsesControlDispatcherForControlBeads(t *testi if bead.Assignee != config.ControlDispatcherAgentName { t.Fatalf("control bead %s assignee = %q, want %q", bead.ID, bead.Assignee, config.ControlDispatcherAgentName) } - if bead.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("control bead %s gc.routed_to = %q, want %q", bead.ID, bead.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if got := bead.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("control bead %s gc.routed_to = %q, want empty direct dispatcher assignee", bead.ID, got) } } if !foundControl { diff --git a/cmd/gc/hook_output.go b/cmd/gc/hook_output.go index 648041418..c3c31bb6b 100644 --- a/cmd/gc/hook_output.go +++ b/cmd/gc/hook_output.go @@ -6,19 +6,51 @@ import ( "strings" ) -const hookOutputFormatGemini = "gemini" +const ( + hookOutputFormatCodex = "codex" + hookOutputFormatGemini = "gemini" +) func writeProviderHookContext(stdout io.Writer, format, content string) error { + return writeProviderHookContextForEvent(stdout, format, "", content) +} + +func writeProviderHookContextForEvent(stdout io.Writer, format, eventName, content string) error { if content == "" { return nil } - if strings.EqualFold(strings.TrimSpace(format), hookOutputFormatGemini) { + switch strings.ToLower(strings.TrimSpace(format)) { + case hookOutputFormatCodex: + return json.NewEncoder(stdout).Encode(codexHookOutput(eventName, content)) + case hookOutputFormatGemini: return json.NewEncoder(stdout).Encode(geminiHookAdditionalContext(content)) } _, err := io.WriteString(stdout, content) return err } +func codexHookOutput(eventName, content string) map[string]any { + if strings.EqualFold(strings.TrimSpace(eventName), "Stop") { + return map[string]any{ + "decision": "block", + "reason": strings.TrimRight(content, "\n"), + } + } + return codexHookAdditionalContext(eventName, content) +} + +func codexHookAdditionalContext(eventName, content string) map[string]any { + if eventName == "" { + eventName = "SessionStart" + } + return map[string]any{ + "hookSpecificOutput": map[string]any{ + "hookEventName": eventName, + "additionalContext": strings.TrimRight(content, "\n"), + }, + } +} + func geminiHookAdditionalContext(content string) map[string]any { return map[string]any{ "hookSpecificOutput": map[string]any{ diff --git a/cmd/gc/hook_output_test.go b/cmd/gc/hook_output_test.go index 7a8d83c0b..dc532fa09 100644 --- a/cmd/gc/hook_output_test.go +++ b/cmd/gc/hook_output_test.go @@ -26,6 +26,52 @@ func TestWriteProviderHookContextGemini(t *testing.T) { } } +func TestWriteProviderHookContextCodex(t *testing.T) { + var out bytes.Buffer + err := writeProviderHookContextForEvent(&out, "codex", "Stop", "\nhello\n\n") + if err != nil { + t.Fatalf("writeProviderHookContextForEvent: %v", err) + } + + var payload struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal output: %v\n%s", err, out.String()) + } + if got, want := payload.Decision, "block"; got != want { + t.Fatalf("decision = %q, want %q", got, want) + } + if got, want := payload.Reason, "\nhello\n"; got != want { + t.Fatalf("reason = %q, want %q", got, want) + } +} + +func TestWriteProviderHookContextCodexAdditionalContext(t *testing.T) { + var out bytes.Buffer + err := writeProviderHookContextForEvent(&out, "codex", "UserPromptSubmit", "\nhello\n\n") + if err != nil { + t.Fatalf("writeProviderHookContextForEvent: %v", err) + } + + var payload struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal output: %v\n%s", err, out.String()) + } + if got, want := payload.HookSpecificOutput.HookEventName, "UserPromptSubmit"; got != want { + t.Fatalf("hookEventName = %q, want %q", got, want) + } + if got, want := payload.HookSpecificOutput.AdditionalContext, "\nhello\n"; got != want { + t.Fatalf("additionalContext = %q, want %q", got, want) + } +} + func TestWriteProviderHookContextPlain(t *testing.T) { var out bytes.Buffer err := writeProviderHookContext(&out, "", "\nhello\n\n") diff --git a/cmd/gc/hooks.go b/cmd/gc/hooks.go index b2c7b3745..48d076484 100644 --- a/cmd/gc/hooks.go +++ b/cmd/gc/hooks.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/gastownhall/gascity/internal/fsys" ) // beadHooks maps bd hook filenames to the Gas City event types they emit. @@ -53,7 +55,7 @@ title=$(echo "$DATA" | grep -o '"title":"[^"]*"' | head -1 | cut -d'"' -f4) // installBeadHooks writes bd hook scripts into dir/.beads/hooks/ so that // bd mutations (create, close, update) emit events to the Gas City event -// log. Idempotent — overwrites existing hooks. Returns nil on success. +// log. Idempotent — leaves matching hooks in place. Returns nil on success. func installBeadHooks(dir string) error { hooksDir := filepath.Join(dir, ".beads", "hooks") if err := os.MkdirAll(hooksDir, 0o755); err != nil { @@ -66,7 +68,7 @@ func installBeadHooks(dir string) error { if filename == "on_close" { content = closeHookScript() } - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fsys.OSFS{}, path, []byte(content), 0o755); err != nil { return fmt.Errorf("writing hook %s: %w", filename, err) } } diff --git a/cmd/gc/hooks_test.go b/cmd/gc/hooks_test.go index 1c263acff..be3034c53 100644 --- a/cmd/gc/hooks_test.go +++ b/cmd/gc/hooks_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestInstallBeadHooksCreatesScripts(t *testing.T) { @@ -98,6 +99,68 @@ func TestInstallBeadHooksIdempotent(t *testing.T) { } } +func TestInstallBeadHooksDoesNotRewriteUnchangedHooks(t *testing.T) { + dir := t.TempDir() + + if err := installBeadHooks(dir); err != nil { + t.Fatalf("first install: %v", err) + } + + path := filepath.Join(dir, ".beads", "hooks", "on_create") + past := time.Unix(123456789, 0) + if err := os.Chtimes(path, past, past); err != nil { + t.Fatalf("Chtimes: %v", err) + } + + if err := installBeadHooks(dir); err != nil { + t.Fatalf("second install: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(past) { + t.Fatalf("unchanged hook was rewritten: modtime = %s, want %s", info.ModTime(), past) + } +} + +func TestInstallBeadHooksReplacesMatchingSymlink(t *testing.T) { + dir := t.TempDir() + + if err := installBeadHooks(dir); err != nil { + t.Fatalf("first install: %v", err) + } + + path := filepath.Join(dir, ".beads", "hooks", "on_create") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + target := filepath.Join(dir, "outside-hook") + if err := os.WriteFile(target, data, 0o755); err != nil { + t.Fatalf("WriteFile(%s): %v", target, err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove(%s): %v", path, err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("Symlink: %v", err) + } + + if err := installBeadHooks(dir); err != nil { + t.Fatalf("second install: %v", err) + } + + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("Lstat(%s): %v", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("matching symlink was preserved, want regular file") + } +} + func TestInstallBeadHooksCreatesDirectories(t *testing.T) { dir := t.TempDir() // No pre-existing .beads/ directory. diff --git a/cmd/gc/lifecycle_live_query_test.go b/cmd/gc/lifecycle_live_query_test.go index bbcfc1a2f..3f19a1f0e 100644 --- a/cmd/gc/lifecycle_live_query_test.go +++ b/cmd/gc/lifecycle_live_query_test.go @@ -111,7 +111,13 @@ func TestUnclaimWorkAssignedToRetiredSessionBead_UsesLiveOpenOwnership(t *testin t.Fatalf("Update(%s, reassigned): %v", work.ID, err) } - unclaimWorkAssignedToRetiredSessionBead(cache, "retired-session", "worker", io.Discard) + unclaimWorkAssignedToRetiredSessionBead( + cache, + nil, + beads.Bead{ID: "retired-session"}, + "worker", + io.Discard, + ) got, err := backing.Get(work.ID) if err != nil { diff --git a/cmd/gc/live_submit_probe_test.go b/cmd/gc/live_submit_probe_test.go index 1043ab4f7..f44f8c0c4 100644 --- a/cmd/gc/live_submit_probe_test.go +++ b/cmd/gc/live_submit_probe_test.go @@ -21,7 +21,7 @@ import ( func preferRealBDOnPath(t *testing.T) { t.Helper() - skipSlowCmdGCTest(t, "requires a live bd-managed session probe; run without -short") + skipSlowCmdGCTest(t, "requires a live bd-managed session probe; run make test-cmd-gc-process for full coverage") currentPath := os.Getenv("PATH") pathEntries := filepath.SplitList(currentPath) diff --git a/cmd/gc/main.go b/cmd/gc/main.go index a092f19bb..ea2cfb923 100644 --- a/cmd/gc/main.go +++ b/cmd/gc/main.go @@ -275,6 +275,9 @@ var cliStoreCache struct { // agents don't open the store repeatedly. Silently falls back to legacy // naming if the store is unavailable. func cliSessionName(cityPath, cityName, agentName, sessionTemplate string) string { + if strings.TrimSpace(cityPath) == "" { + return sessionName(nil, cityName, agentName, sessionTemplate) + } cliStoreCache.mu.Lock() if cliStoreCache.path != cityPath { cliStoreCache.store, _ = openCityStoreAt(cityPath) @@ -447,11 +450,19 @@ func validateCityPath(p string) (string, error) { } // resolveRigToContext resolves a rig name or path to a full context by scanning -// registered cities and their machine-local .gc/site.toml rig bindings. +// registered cities and their machine-local .gc/site.toml rig bindings. This +// is an explicit rig-resolution path, so stale-sibling warnings are emitted +// to os.Stderr (deduped across the two registry scans below). func resolveRigToContext(nameOrPath string) (resolvedContext, error) { - if matches, err := registeredRigBindingsByName(nameOrPath, true); err != nil { + var allStale []staleRegisteredCity + defer func() { emitStaleRegisteredCityWarnings(os.Stderr, allStale) }() + + matches, stale, err := registeredRigBindingsByName(nameOrPath, true) + allStale = append(allStale, stale...) + if err != nil { return resolvedContext{}, err - } else if len(matches) > 0 { + } + if len(matches) > 0 { return resolveRigBindingMatches(nameOrPath, matches) } @@ -459,17 +470,24 @@ func resolveRigToContext(nameOrPath string) (resolvedContext, error) { if err != nil { return resolvedContext{}, fmt.Errorf("rig %q: %w", nameOrPath, err) } - if matches, err := registeredRigBindingsByPath(abs, true); err != nil { + matches, stale, err = registeredRigBindingsByPath(abs, true) + allStale = append(allStale, stale...) + if err != nil { return resolvedContext{}, err - } else if len(matches) > 0 { + } + if len(matches) > 0 { return resolveRigBindingMatches(abs, matches) } return resolvedContext{}, fmt.Errorf("rig %q is not registered in any city", nameOrPath) } +// resolveRigPathToContext resolves an explicit path argument to a registered +// rig context. Stale-sibling warnings are emitted to os.Stderr because the +// caller is explicitly depending on the registry. func resolveRigPathToContext(dir string) (resolvedContext, bool, error) { - matches, err := registeredRigBindingsByPath(dir, true) + matches, stale, err := registeredRigBindingsByPath(dir, true) + emitStaleRegisteredCityWarnings(os.Stderr, stale) if err != nil { return resolvedContext{}, false, err } @@ -485,8 +503,10 @@ func resolveRigPathToContext(dir string) (resolvedContext, bool, error) { // lookupRigFromCwd checks registered city site bindings for a rig matching cwd. // Ambiguous bindings deliberately fall through to the city walk-up fallback. +// This is an opportunistic probe (failOnLoadError=false): stale-sibling +// warnings are intentionally dropped so unrelated commands stay quiet. func lookupRigFromCwd(cwd string) (resolvedContext, bool) { - matches, err := registeredRigBindingsByPath(cwd, false) + matches, _, err := registeredRigBindingsByPath(cwd, false) if err != nil || len(matches) != 1 { return resolvedContext{}, false } @@ -521,35 +541,70 @@ type registeredRigBinding struct { Path string } -func registeredRigBindingsByName(name string, failOnLoadError bool) ([]registeredRigBinding, error) { +func registeredRigBindingsByName(name string, failOnLoadError bool) (matches []registeredRigBinding, stale []staleRegisteredCity, err error) { return registeredRigBindings(failOnLoadError, func(binding registeredRigBinding) bool { return binding.Rig.Name == name }) } -func registeredRigBindingsByPath(dir string, failOnLoadError bool) ([]registeredRigBinding, error) { +func registeredRigBindingsByPath(dir string, failOnLoadError bool) (matches []registeredRigBinding, stale []staleRegisteredCity, err error) { dir = normalizePathForCompare(dir) - matches, err := registeredRigBindings(failOnLoadError, func(binding registeredRigBinding) bool { + matches, stale, err = registeredRigBindings(failOnLoadError, func(binding registeredRigBinding) bool { rigPath := normalizePathForCompare(binding.Path) return pathWithinScope(dir, rigPath) }) if err != nil { - return nil, err + return nil, stale, err } - return keepDeepestRigBindings(matches), nil + return keepDeepestRigBindings(matches), stale, nil +} + +// staleRegisteredCity identifies a registered city whose city.toml is +// missing on disk. registeredRigBindings returns these as structured data +// instead of emitting to stderr so callers that are explicitly resolving a +// registered rig can warn, while opportunistic probes stay quiet. +type staleRegisteredCity struct { + Label string + Path string } -func registeredRigBindings(failOnLoadError bool, match func(registeredRigBinding) bool) ([]registeredRigBinding, error) { +// emitStaleRegisteredCityWarnings writes one `warning: ...` line per stale +// registry entry. Each Label is emitted at most once even if stale carries +// duplicates (e.g. from callers that invoke registeredRigBindings twice in +// one command). +func emitStaleRegisteredCityWarnings(w io.Writer, stale []staleRegisteredCity) { + if w == nil || len(stale) == 0 { + return + } + seen := make(map[string]struct{}, len(stale)) + for _, s := range stale { + if _, already := seen[s.Label]; already { + continue + } + seen[s.Label] = struct{}{} + fmt.Fprintf(w, "warning: skipping stale registered city %q: city.toml missing at %s\n", //nolint:errcheck // best-effort stderr + s.Label, s.Path) + } +} + +func registeredRigBindings(failOnLoadError bool, match func(registeredRigBinding) bool) (_ []registeredRigBinding, stale []staleRegisteredCity, _ error) { reg := supervisor.NewRegistry(supervisor.RegistryPath()) cities, err := reg.List() if err != nil { - return nil, err + return nil, nil, err } var matched []registeredRigBinding var loadErrors []string for _, c := range cities { cfg, err := loadCityConfigSuppressDeprecatedOrderWarnings(c.Path, io.Discard) if err != nil { + // Tolerate stale registry entries whose city.toml has been + // deleted out from under the registry, but keep missing includes + // or other config dependencies as load errors. + if cityTOML, ok := missingRootCityTOML(err, c.Path); ok { + stale = append(stale, staleRegisteredCity{Label: registeredCityLabel(c), Path: cityTOML}) + continue + } loadErrors = append(loadErrors, fmt.Sprintf("%s: %v", registeredCityLabel(c), err)) continue } @@ -587,9 +642,21 @@ func registeredRigBindings(failOnLoadError bool, match func(registeredRigBinding } } if len(loadErrors) > 0 && (failOnLoadError || len(matched) > 0) { - return nil, fmt.Errorf("loading registered city rig bindings: %s", strings.Join(loadErrors, "; ")) + return nil, stale, fmt.Errorf("loading registered city rig bindings: %s", strings.Join(loadErrors, "; ")) + } + return matched, stale, nil +} + +func missingRootCityTOML(err error, cityPath string) (string, bool) { + if !errors.Is(err, os.ErrNotExist) { + return "", false + } + var pathErr *os.PathError + if !errors.As(err, &pathErr) { + return "", false } - return matched, nil + cityTOML := filepath.Clean(filepath.Join(cityPath, "city.toml")) + return cityTOML, samePath(pathErr.Path, cityTOML) } func keepDeepestRigBindings(matches []registeredRigBinding) []registeredRigBinding { diff --git a/cmd/gc/main_test.go b/cmd/gc/main_test.go index f081ed0d3..fc6a4bf00 100644 --- a/cmd/gc/main_test.go +++ b/cmd/gc/main_test.go @@ -160,6 +160,7 @@ func TestMain(m *testing.M) { } func TestTutorial01(t *testing.T) { + skipSlowCmdGCTest(t, "runs tutorial testscript scenarios; run make test-cmd-gc-process for full coverage") testscript.Run(t, newTestscriptParams(t)) } @@ -4767,6 +4768,59 @@ max = -1 } } +func TestDoPrimeFormulaV2GraphWorkerPromptClaimsRoutedWork(t *testing.T) { + dir := t.TempDir() + if err := materializeBuiltinPrompts(dir); err != nil { + t.Fatalf("materializeBuiltinPrompts: %v", err) + } + t.Setenv("GC_CITY", "") + t.Setenv("GC_CITY_PATH", "") + t.Setenv("GC_CITY_ROOT", "") + t.Setenv("GC_DIR", "") + t.Setenv("GC_RIG", "") + t.Setenv("GC_RIG_ROOT", "") + tomlContent := `[workspace] +name = "test-city" + +[daemon] +formula_v2 = true + +[[agent]] +name = "worker" +dir = "myrig" +start_command = "echo" + +[agent.pool] +min = 0 +max = -1 +` + if err := os.WriteFile(filepath.Join(dir, "city.toml"), []byte(tomlContent), 0o644); err != nil { + t.Fatal(err) + } + + orig, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := doPrime([]string{"worker"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("doPrime = %d, want 0; stderr: %s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "gc hook") { + t.Fatalf("graph-worker prompt missing gc hook routed-queue lookup:\n%s", out) + } + if !strings.Contains(out, "bd update --claim") { + t.Fatalf("graph-worker prompt missing atomic claim instruction:\n%s", out) + } + if !strings.Contains(out, "Do not start work with `bd update --status in_progress`") { + t.Fatalf("graph-worker prompt missing guard against unassigned in_progress work:\n%s", out) + } +} + func materializeBuiltinPrompts(cityPath string) error { return MaterializeBuiltinPacks(cityPath) } diff --git a/cmd/gc/mcp_integration.go b/cmd/gc/mcp_integration.go index 03b7ed518..906964f2b 100644 --- a/cmd/gc/mcp_integration.go +++ b/cmd/gc/mcp_integration.go @@ -38,24 +38,6 @@ type resolvedMCPProjection struct { Projection materialize.MCPProjection } -func buildMCPTemplateData(cityPath, qualifiedName, workDir string, agent *config.Agent, rigs []config.Rig) map[string]string { - rigName := configuredRigName(cityPath, agent, rigs) - rigRoot := rigRootForName(rigName, rigs) - return buildTemplateData(PromptContext{ - CityRoot: cityPath, - AgentName: qualifiedName, - TemplateName: templateNameFor(agent, qualifiedName), - RigName: rigName, - RigRoot: rigRoot, - WorkDir: workDir, - IssuePrefix: findRigPrefix(rigName, rigs), - DefaultBranch: defaultBranchFor(workDir), - WorkQuery: agent.EffectiveWorkQuery(), - SlingQuery: agent.EffectiveSlingQuery(), - Env: agent.Env, - }) -} - func supportsMCPProviderKind(kind string) bool { switch strings.TrimSpace(kind) { case materialize.MCPProviderClaude, materialize.MCPProviderCodex, materialize.MCPProviderGemini: @@ -71,8 +53,7 @@ func loadEffectiveMCPForAgent( agent *config.Agent, qualifiedName, workDir string, ) (materialize.MCPCatalog, error) { - templateData := buildMCPTemplateData(cityPath, qualifiedName, workDir, agent, cfg.Rigs) - catalog, err := materialize.EffectiveMCPForAgent(cfg, agent, templateData) + catalog, err := materialize.EffectiveMCPForSession(cfg, cityPath, agent, qualifiedName, workDir) if err != nil { return materialize.MCPCatalog{}, fmt.Errorf("loading effective MCP: %w", err) } diff --git a/cmd/gc/order_dispatch.go b/cmd/gc/order_dispatch.go index 913038070..f312ac21c 100644 --- a/cmd/gc/order_dispatch.go +++ b/cmd/gc/order_dispatch.go @@ -5,13 +5,17 @@ import ( "fmt" "io" "log" + "os" "os/exec" + "path/filepath" "strings" + "sync" "time" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" + "github.com/gastownhall/gascity/internal/execenv" "github.com/gastownhall/gascity/internal/formula" "github.com/gastownhall/gascity/internal/molecule" "github.com/gastownhall/gascity/internal/orders" @@ -54,7 +58,7 @@ func mergeOrderExecEnv(environ, env []string) []string { } func logDispatchError(stderr io.Writer, format string, args ...any) { - msg := fmt.Sprintf(format, args...) + msg := execenv.RedactText(fmt.Sprintf(format, args...), os.Environ()) log.Print(msg) if stderr != nil { fmt.Fprintln(stderr, msg) //nolint:errcheck // best-effort stderr @@ -74,6 +78,9 @@ type memoryOrderDispatcher struct { maxTimeout time.Duration cfg *config.City cityName string + + cacheMu sync.Mutex + lastRunCache map[string]time.Time } // buildOrderDispatcher scans formula layers for orders and returns a @@ -164,13 +171,21 @@ func (m *memoryOrderDispatcher) dispatch(ctx context.Context, cityPath string, n if legacyStore != nil { storesForGate = append(storesForGate, legacyStore) } + storeKeysForGate := []string{storeKey} + if legacyStore != nil { + storeKeysForGate = append(storeKeysForGate, orderStoreTargetKey(legacyOrderCityTarget(cityPath, m.cfg))) + } baseLastRunFn := orders.LastRunAcrossStores(storesForGate...) var lastRunErr error + var lastRunFromCache bool lastRunFn := func(orderName string) (time.Time, error) { - last, err := baseLastRunFn(orderName) + last, fromCache, err := m.cachedLastRun(orderName, storeKeysForGate, baseLastRunFn) if err != nil { lastRunErr = err } + if fromCache { + lastRunFromCache = true + } return last, err } cursorFn := orders.CursorAcrossStores(storesForGate...) @@ -184,7 +199,8 @@ func (m *memoryOrderDispatcher) dispatch(ctx context.Context, cityPath string, n return cursor } } - result := orders.CheckTrigger(a, now, lastRunFn, m.ep, cursorFn) + triggerOpts := orderTriggerOptionsForTarget(cityPath, m.cfg, target, a) + result := orders.CheckTriggerWithOptions(a, now, lastRunFn, m.ep, cursorFn, triggerOpts) if lastRunErr != nil { logDispatchError(m.stderr, "gc: order dispatch: reading last run for %s: %v", a.ScopedName(), lastRunErr) continue @@ -192,6 +208,23 @@ func (m *memoryOrderDispatcher) dispatch(ctx context.Context, cityPath string, n if !result.Due { continue } + if lastRunFromCache && orderTriggerUsesLastRun(a) { + refreshedLastRun, err := baseLastRunFn(a.ScopedName()) + if err != nil { + logDispatchError(m.stderr, "gc: order dispatch: refreshing last run for %s: %v", a.ScopedName(), err) + continue + } + if refreshedLastRun.After(result.LastRun) { + m.rememberLastRun(a.ScopedName(), storeKeysForGate, refreshedLastRun) + refreshedLastRunFn := func(string) (time.Time, error) { + return refreshedLastRun, nil + } + result = orders.CheckTriggerWithOptions(a, now, refreshedLastRunFn, m.ep, cursorFn, triggerOpts) + if !result.Due { + continue + } + } + } // Skip dispatch if previous work hasn't been processed yet. scoped := a.ScopedName() @@ -214,6 +247,7 @@ func (m *memoryOrderDispatcher) dispatch(ctx context.Context, cityPath string, n logDispatchError(m.stderr, "gc: order dispatch: creating tracking bead for %s: %v", scoped, err) continue } + m.rememberLastRun(scoped, storeKeysForGate, trackingBead.CreatedAt) // Fire and forget with timeout. a := a // capture loop variable @@ -239,6 +273,45 @@ func (m *memoryOrderDispatcher) legacyCityStoreForTarget(cityPath string, target return store, true } +func (m *memoryOrderDispatcher) cachedLastRun(orderName string, storeKeys []string, read orders.LastRunFunc) (time.Time, bool, error) { + key := orderHistoryCacheKey(orderName, storeKeys) + m.cacheMu.Lock() + if m.lastRunCache != nil { + if last, ok := m.lastRunCache[key]; ok { + m.cacheMu.Unlock() + return last, true, nil + } + } + m.cacheMu.Unlock() + + last, err := read(orderName) + if err != nil { + return time.Time{}, false, err + } + m.rememberLastRun(orderName, storeKeys, last) + return last, false, nil +} + +func (m *memoryOrderDispatcher) rememberLastRun(orderName string, storeKeys []string, last time.Time) { + key := orderHistoryCacheKey(orderName, storeKeys) + m.cacheMu.Lock() + defer m.cacheMu.Unlock() + if m.lastRunCache == nil { + m.lastRunCache = make(map[string]time.Time) + } + if existing, ok := m.lastRunCache[key]; !ok || existing.IsZero() || last.After(existing) { + m.lastRunCache[key] = last + } +} + +func orderHistoryCacheKey(orderName string, storeKeys []string) string { + return orderName + "\x00" + strings.Join(storeKeys, "\x00") +} + +func orderTriggerUsesLastRun(a orders.Order) bool { + return a.Trigger == "cooldown" || a.Trigger == "cron" +} + // dispatchOne runs a single order dispatch in its own goroutine. // For exec orders, runs the script directly. For formula orders, // instantiates a wisp. Emits events and updates the tracking bead. @@ -271,16 +344,18 @@ func (m *memoryOrderDispatcher) dispatchExec(ctx context.Context, store beads.St env := orderExecEnv(cityPath, m.cfg, target, a) output, err := m.execRun(ctx, a.Exec, target.ScopeRoot, env) if err != nil { + redactionEnv := append(os.Environ(), env...) + errMsg := execenv.RedactText(err.Error(), redactionEnv) labels = append(labels, "exec-failed") - logDispatchError(m.stderr, "gc: order exec %s failed: %v", scoped, err) + logDispatchError(m.stderr, "gc: order exec %s failed: %s", scoped, errMsg) if len(output) > 0 { - logDispatchError(m.stderr, "gc: order exec %s output: %s", scoped, output) + logDispatchError(m.stderr, "gc: order exec %s output: %s", scoped, execenv.RedactText(string(output), redactionEnv)) } m.rec.Record(events.Event{ Type: events.OrderFailed, Actor: "controller", Subject: scoped, - Message: err.Error(), + Message: errMsg, }) } else { m.rec.Record(events.Event{ @@ -327,7 +402,7 @@ func (m *memoryOrderDispatcher) dispatchWisp(ctx context.Context, store beads.St Subject: scoped, Message: err.Error(), }) - store.Update(trackingID, beads.UpdateOpts{Labels: []string{"wisp", "wisp-failed"}}) //nolint:errcheck // best-effort + m.markTrackingFailure(store, trackingID, scoped, a, headSeq) return } if err := molecule.ValidateRecipeRuntimeVars(recipe, molecule.Options{}); err != nil { @@ -337,14 +412,29 @@ func (m *memoryOrderDispatcher) dispatchWisp(ctx context.Context, store beads.St Subject: scoped, Message: err.Error(), }) - store.Update(trackingID, beads.UpdateOpts{Labels: []string{"wisp", "wisp-failed"}}) //nolint:errcheck // best-effort + m.markTrackingFailure(store, trackingID, scoped, a, headSeq) return } + var pool string + if a.Pool != "" { + pool, err = qualifyOrderPool(a, m.cfg) + if err != nil { + logDispatchError(m.stderr, "gc: order %s: %v", scoped, err) + m.rec.Record(events.Event{ + Type: events.OrderFailed, + Actor: "controller", + Subject: scoped, + Message: err.Error(), + }) + m.markTrackingFailure(store, trackingID, scoped, a, headSeq) + return + } + } + // Decorate graph workflow recipes with routing metadata so child step // beads get gc.routed_to set before instantiation. if a.Pool != "" { - pool := qualifyPool(a.Pool, a.Rig) if err := applyGraphRouting(recipe, nil, pool, nil, "", "", "", "", store, m.cityName, cityPath, m.cfg); err != nil { logDispatchError(m.stderr, "gc: order %s: routing decoration failed: %v", scoped, err) // Non-fatal — molecule still works, just without step-level routing. @@ -359,7 +449,7 @@ func (m *memoryOrderDispatcher) dispatchWisp(ctx context.Context, store beads.St Subject: scoped, Message: err.Error(), }) - store.Update(trackingID, beads.UpdateOpts{Labels: []string{"wisp", "wisp-failed"}}) //nolint:errcheck // best-effort + m.markTrackingFailure(store, trackingID, scoped, a, headSeq) return } rootID := cookResult.RootID @@ -374,7 +464,6 @@ func (m *memoryOrderDispatcher) dispatchWisp(ctx context.Context, store beads.St ) } if a.Pool != "" { - pool := qualifyPool(a.Pool, a.Rig) update.Metadata = map[string]string{"gc.routed_to": pool} } if err := store.Update(rootID, update); err != nil { @@ -387,7 +476,7 @@ func (m *memoryOrderDispatcher) dispatchWisp(ctx context.Context, store beads.St Subject: scoped, Message: fmt.Sprintf("wisp %s created but label failed: %v", rootID, err), }) - store.Update(trackingID, beads.UpdateOpts{Labels: []string{"wisp", "wisp-failed"}}) //nolint:errcheck // best-effort + m.markTrackingFailure(store, trackingID, scoped, a, headSeq) return } @@ -409,11 +498,31 @@ func (m *memoryOrderDispatcher) orderRigSuspended(a orders.Order) bool { if m.cfg == nil { return false } - qualified := qualifyPool(a.Pool, a.Rig) + qualified, err := qualifyOrderPool(a, m.cfg) + if err != nil { + return m.rigSuspendedByName(a.Rig) + } rigName, _ := config.ParseQualifiedName(qualified) if rigName == "" { rigName = a.Rig } + return m.rigSuspendedByName(rigName) +} + +func (m *memoryOrderDispatcher) markTrackingFailure(store beads.Store, trackingID, scoped string, a orders.Order, headSeq uint64) { + labels := []string{"wisp", "wisp-failed"} + if a.Trigger == "event" && headSeq > 0 { + labels = append(labels, + fmt.Sprintf("order:%s", scoped), + fmt.Sprintf("seq:%d", headSeq), + ) + } + if err := store.Update(trackingID, beads.UpdateOpts{Labels: labels}); err != nil { + logDispatchError(m.stderr, "gc: order %s: failed to mark tracking bead %s as failed: %v", scoped, trackingID, err) + } +} + +func (m *memoryOrderDispatcher) rigSuspendedByName(rigName string) bool { if rigName == "" { return false } @@ -425,9 +534,9 @@ func (m *memoryOrderDispatcher) orderRigSuspended(a orders.Order) bool { return false } -// hasOpenWorkStrict reports whether any non-closed work bead exists for this -// order. Tracking beads (title "order:") are excluded, so only actual -// work (wisps, exec results) counts. +// hasOpenWorkStrict reports whether any non-closed work or tracking bead +// exists for this order. Open tracking beads represent in-flight dispatch and +// must block condition/event orders that do not consult LastRun. func (m *memoryOrderDispatcher) hasOpenWorkStrict(store beads.Store, scopedName string) (bool, error) { results, err := store.List(beads.ListQuery{ Label: "order-run:" + scopedName, @@ -436,9 +545,8 @@ func (m *memoryOrderDispatcher) hasOpenWorkStrict(store beads.Store, scopedName if err != nil { return false, fmt.Errorf("listing order work beads: %w", err) } - trackingTitle := "order:" + scopedName for _, b := range results { - if b.Status != "closed" && b.Title != trackingTitle { + if b.Status != "closed" { return true, nil } } @@ -533,14 +641,116 @@ func rigExclusiveLayers(rigLayers, cityLayers []string) []string { return rigLayers[len(cityLayers):] } -// qualifyPool prefixes an unqualified pool name with the rig name for -// rig-scoped orders. Already-qualified names (containing "/") are -// returned as-is. City orders (empty rig) are unchanged. -func qualifyPool(pool, rig string) string { - if rig == "" || strings.Contains(pool, "/") { - return pool +// qualifyPool resolves a raw pool name from an order TOML to the qualified +// form used by Agent.QualifiedName() — the same string the scaler queries +// via gc.routed_to. Three layers of qualification stack: +// +// 1. If pool already contains "/" it is rig-qualified — pass through. +// 2. If pool exactly matches a configured binding-qualified target +// ("binding.name"), preserve that target and still stack the rig prefix +// when present. +// 3. If the order came from an imported pack, prefer same-source agents when +// resolving a bare pool name so pack-local orders stay pack-local even if +// other scopes also export the same bare agent name. +// 4. Otherwise look up agents in cfg.Agents whose Dir matches rig +// (city orders use rig=="") and Name matches pool. If exactly one target +// resolves, swap pool for the binding-qualified form ("binding.name") +// before any rig prefixing. This handles V2 pack imports where the +// dispatched wisp must carry "binding.name" so the agent's default +// scale_check matches its own qualified name. +// +// Ambiguity is a hard failure: silently stamping the bare pool string would +// recreate the exact route/scaler mismatch this helper exists to prevent. +// nil cfg preserves the rig-only behavior so call sites without a loaded +// city remain stable. Dotted values that do not match a configured bound +// target are preserved for backward compatibility. +func qualifyOrderPool(a orders.Order, cfg *config.City) (string, error) { + return qualifyPool(a.Pool, a.Rig, cfg, orderPoolSourceDirHint(a)) +} + +func orderPoolSourceDirHint(a orders.Order) string { + if a.FormulaLayer == "" { + return "" + } + return filepath.Clean(filepath.Dir(a.FormulaLayer)) +} + +func qualifyPool(pool, rig string, cfg *config.City, sourceDirHint string) (string, error) { + if strings.Contains(pool, "/") { + return pool, nil + } + if cfg == nil { + if rig == "" { + return pool, nil + } + return rig + "/" + pool, nil + } + + qualified := pool + scope := "city order" + if rig != "" { + scope = fmt.Sprintf("rig %q", rig) + } + + var exactQualified []string + var sourceScopedMatches []string + var localBareMatches []string + var bareMatches []string + cleanHint := "" + if sourceDirHint != "" { + cleanHint = filepath.Clean(sourceDirHint) + } + for i := range cfg.Agents { + a := &cfg.Agents[i] + if a.Dir != rig { + continue + } + switch { + case strings.Contains(pool, ".") && a.BindingQualifiedName() == pool: + exactQualified = appendUniquePoolTarget(exactQualified, a.BindingQualifiedName()) + case a.Name == pool: + bareMatches = appendUniquePoolTarget(bareMatches, a.BindingQualifiedName()) + if a.BindingName == "" { + localBareMatches = appendUniquePoolTarget(localBareMatches, a.BindingQualifiedName()) + } + if cleanHint != "" && filepath.Clean(a.SourceDir) == cleanHint { + sourceScopedMatches = appendUniquePoolTarget(sourceScopedMatches, a.BindingQualifiedName()) + } + } + } + + switch { + case len(exactQualified) == 1: + qualified = exactQualified[0] + case len(exactQualified) > 1: + return "", fmt.Errorf("ambiguous pool %q for %s: matches %s", pool, scope, strings.Join(exactQualified, ", ")) + case len(sourceScopedMatches) == 1: + qualified = sourceScopedMatches[0] + case len(sourceScopedMatches) > 1: + return "", fmt.Errorf("ambiguous pool %q for %s: matches %s", pool, scope, strings.Join(sourceScopedMatches, ", ")) + case len(localBareMatches) == 1: + qualified = localBareMatches[0] + case len(localBareMatches) > 1: + return "", fmt.Errorf("ambiguous pool %q for %s: matches %s", pool, scope, strings.Join(localBareMatches, ", ")) + case len(bareMatches) == 1: + qualified = bareMatches[0] + case len(bareMatches) > 1: + return "", fmt.Errorf("ambiguous pool %q for %s: matches %s", pool, scope, strings.Join(bareMatches, ", ")) + } + + if rig == "" { + return qualified, nil + } + return rig + "/" + qualified, nil +} + +func appendUniquePoolTarget(values []string, want string) []string { + for _, value := range values { + if value == want { + return values + } } - return rig + "/" + pool + return append(values, want) } // convertOverrides converts config.OrderOverride to orders.Override. diff --git a/cmd/gc/order_dispatch_test.go b/cmd/gc/order_dispatch_test.go index bd8cd8336..e00d2dc7f 100644 --- a/cmd/gc/order_dispatch_test.go +++ b/cmd/gc/order_dispatch_test.go @@ -42,6 +42,18 @@ type selectiveUpdateFailStore struct { beads.Store } +type countingListStore struct { + beads.Store + + includeClosedLists int +} + +type createdAtOverrideStore struct { + beads.Store + + createdAt map[string]time.Time +} + func (s selectiveUpdateFailStore) Update(id string, opts beads.UpdateOpts) error { for _, label := range opts.Labels { if strings.HasPrefix(label, "order-run:") { @@ -51,6 +63,45 @@ func (s selectiveUpdateFailStore) Update(id string, opts beads.UpdateOpts) error return s.Store.Update(id, opts) } +func (s *countingListStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.IncludeClosed || query.Status == "closed" { + s.includeClosedLists++ + } + return s.Store.List(query) +} + +func (s *countingListStore) reset() { + s.includeClosedLists = 0 +} + +func (s *createdAtOverrideStore) Create(b beads.Bead) (beads.Bead, error) { + created, err := s.Store.Create(b) + if err != nil { + return beads.Bead{}, err + } + if !b.CreatedAt.IsZero() { + if s.createdAt == nil { + s.createdAt = make(map[string]time.Time) + } + s.createdAt[created.ID] = b.CreatedAt + created.CreatedAt = b.CreatedAt + } + return created, nil +} + +func (s *createdAtOverrideStore) List(query beads.ListQuery) ([]beads.Bead, error) { + results, err := s.Store.List(query) + if err != nil { + return nil, err + } + for i := range results { + if created, ok := s.createdAt[results[i].ID]; ok { + results[i].CreatedAt = created + } + } + return results, nil +} + func TestOrderDispatcherNil(t *testing.T) { ad := buildOrderDispatcher(t.TempDir(), &config.City{}, events.Discard, &bytes.Buffer{}) if ad != nil { @@ -125,6 +176,308 @@ func TestOrderDispatchCooldownDue(t *testing.T) { } } +// TestOrderDispatchResolvesPackBindingForPool reproduces issue #1268: a +// pack-imported agent has BindingName set, so its qualified name is +// "binding.name". A city-level order with pool="" must resolve to the +// binding-qualified value at dispatch so the wisp's gc.routed_to matches what +// the scaler queries via Agent.QualifiedName(). +func TestOrderDispatchResolvesPackBindingForPool(t *testing.T) { + store := beads.NewMemStore() + + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "dog", BindingName: "maintenance"}, + }, + } + + aa := []orders.Order{{ + Name: "mol-dog-doctor", + Trigger: "cooldown", + Interval: "5m", + Formula: "test-formula", + Pool: "dog", + FormulaLayer: sharedTestFormulaDir, + }} + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + execRun: shellExecRunner, + rec: events.Discard, + stderr: &bytes.Buffer{}, + cfg: cfg, + } + + m.dispatch(context.Background(), t.TempDir(), time.Now()) + time.Sleep(50 * time.Millisecond) + + work := workBeadByOrderLabel(t, store, "order-run:mol-dog-doctor") + if got := work.Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Errorf("gc.routed_to = %q, want %q (pack binding must qualify pool target)", got, "maintenance.dog") + } +} + +func TestOrderDispatchPrefersCityShadowForPool(t *testing.T) { + store := beads.NewMemStore() + + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "dog"}, + {Name: "dog", BindingName: "maintenance", SourceDir: "/city/packs/maintenance"}, + }, + } + + aa := []orders.Order{{ + Name: "mol-dog-doctor", + Trigger: "cooldown", + Interval: "5m", + Formula: "test-formula", + Pool: "dog", + FormulaLayer: sharedTestFormulaDir, + }} + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + execRun: shellExecRunner, + rec: events.Discard, + stderr: &bytes.Buffer{}, + cfg: cfg, + } + + m.dispatch(context.Background(), t.TempDir(), time.Now()) + time.Sleep(50 * time.Millisecond) + + work := workBeadByOrderLabel(t, store, "order-run:mol-dog-doctor") + if got := work.Metadata["gc.routed_to"]; got != "dog" { + t.Errorf("gc.routed_to = %q, want %q (city-local shadow should stay local)", got, "dog") + } +} + +func TestOrderDispatchRejectsAmbiguousPackPool(t *testing.T) { + store := beads.NewMemStore() + var rec memRecorder + var stderr bytes.Buffer + + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "dog", BindingName: "gastown"}, + {Name: "dog", BindingName: "maintenance"}, + }, + } + + aa := []orders.Order{{ + Name: "mol-dog-doctor", + Trigger: "cooldown", + Interval: "5m", + Formula: "test-formula", + Pool: "dog", + FormulaLayer: sharedTestFormulaDir, + }} + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + execRun: shellExecRunner, + rec: &rec, + stderr: &stderr, + cfg: cfg, + } + + m.dispatch(context.Background(), t.TempDir(), time.Now()) + time.Sleep(50 * time.Millisecond) + + if !rec.hasType(events.OrderFailed) { + t.Fatal("missing order.failed event for ambiguous pool") + } + if !strings.Contains(stderr.String(), `ambiguous pool "dog"`) { + t.Fatalf("stderr = %q, want ambiguity error", stderr.String()) + } + all := trackingBeads(t, store, "order-run:mol-dog-doctor") + var workCount int + for _, bead := range all { + if !strings.HasPrefix(bead.Title, "order:") { + workCount++ + } + } + if len(all) != 1 { + t.Fatalf("tracking beads with order-run label = %d, want 1", len(all)) + } + if workCount != 0 { + t.Fatalf("work bead count = %d, want 0", workCount) + } + + // An ambiguous failure should still count as the authoritative last run, + // so the next patrol tick within the cooldown interval must not create a + // second tracking bead or emit another order.failed event. + failedEvents := 0 + for _, event := range rec.events { + if event.Type == events.OrderFailed && event.Subject == "mol-dog-doctor" { + failedEvents++ + } + } + if failedEvents != 1 { + t.Fatalf("order.failed count after first dispatch = %d, want 1", failedEvents) + } + + m.dispatch(context.Background(), t.TempDir(), time.Now().Add(10*time.Second)) + time.Sleep(50 * time.Millisecond) + + all = trackingBeads(t, store, "order-run:mol-dog-doctor") + if len(all) != 1 { + t.Fatalf("tracking beads with order-run label after second dispatch = %d, want 1", len(all)) + } + failedEvents = 0 + for _, event := range rec.events { + if event.Type == events.OrderFailed && event.Subject == "mol-dog-doctor" { + failedEvents++ + } + } + if failedEvents != 1 { + t.Fatalf("order.failed count after second dispatch = %d, want 1", failedEvents) + } +} + +func TestOrderDispatchRejectsAmbiguousEventPoolOncePerEvent(t *testing.T) { + store := beads.NewMemStore() + var rec memRecorder + var stderr bytes.Buffer + + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "dog", BindingName: "gastown"}, + {Name: "dog", BindingName: "maintenance"}, + }, + } + + 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-watch", + Trigger: "event", + On: events.BeadClosed, + Formula: "test-formula", + Pool: "dog", + FormulaLayer: sharedTestFormulaDir, + }} + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + ep: eventLog, + execRun: shellExecRunner, + rec: &rec, + stderr: &stderr, + cfg: cfg, + } + + m.dispatch(context.Background(), t.TempDir(), time.Now()) + time.Sleep(50 * time.Millisecond) + + all := trackingBeads(t, store, "order-run:release-watch") + if len(all) != 1 { + t.Fatalf("tracking beads with order-run label after first dispatch = %d, want 1", len(all)) + } + if !slicesContain(all[0].Labels, "order:release-watch") { + t.Fatalf("tracking bead labels = %v, want order cursor label", all[0].Labels) + } + if !slicesContain(all[0].Labels, fmt.Sprintf("seq:%d", headSeq)) { + t.Fatalf("tracking bead labels = %v, want seq:%d", all[0].Labels, headSeq) + } + + failedEvents := 0 + for _, event := range rec.events { + if event.Type == events.OrderFailed && event.Subject == "release-watch" { + failedEvents++ + } + } + if failedEvents != 1 { + t.Fatalf("order.failed count after first dispatch = %d, want 1", failedEvents) + } + + m.dispatch(context.Background(), t.TempDir(), time.Now().Add(10*time.Second)) + time.Sleep(50 * time.Millisecond) + + all = trackingBeads(t, store, "order-run:release-watch") + if len(all) != 1 { + t.Fatalf("tracking beads with order-run label after second dispatch = %d, want 1", len(all)) + } + failedEvents = 0 + for _, event := range rec.events { + if event.Type == events.OrderFailed && event.Subject == "release-watch" { + failedEvents++ + } + } + if failedEvents != 1 { + t.Fatalf("order.failed count after second dispatch = %d, want 1", failedEvents) + } +} + +func TestOrderDispatchResolvesImportedPackPoolAgainstCityShadow(t *testing.T) { + cityDir := t.TempDir() + writeImportedDogOrderFixture(t, cityDir, true) + cfg, aa := loadImportedDogOrders(t, cityDir) + store := beads.NewMemStore() + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + execRun: shellExecRunner, + rec: events.Discard, + stderr: &bytes.Buffer{}, + cfg: cfg, + } + + m.dispatch(context.Background(), cityDir, time.Now()) + time.Sleep(50 * time.Millisecond) + + work := workBeadByOrderLabel(t, store, "order-run:digest") + if got := work.Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Fatalf("gc.routed_to = %q, want maintenance.dog", got) + } +} + +func TestOrderDispatchResolvesImportedPackPoolAgainstSiblingImportCollision(t *testing.T) { + cityDir := t.TempDir() + writeImportedDogOrderFixture(t, cityDir, false, "gastown") + cfg, aa := loadImportedDogOrders(t, cityDir) + store := beads.NewMemStore() + + m := &memoryOrderDispatcher{ + aa: aa, + storeFn: func(_ execStoreTarget) (beads.Store, error) { + return store, nil + }, + execRun: shellExecRunner, + rec: events.Discard, + stderr: &bytes.Buffer{}, + cfg: cfg, + } + + m.dispatch(context.Background(), cityDir, time.Now()) + time.Sleep(50 * time.Millisecond) + + work := workBeadByOrderLabel(t, store, "order-run:digest") + if got := work.Metadata["gc.routed_to"]; got != "maintenance.dog" { + t.Fatalf("gc.routed_to = %q, want maintenance.dog", got) + } +} + func TestOrderDispatchCooldownNotDue(t *testing.T) { store := beads.NewMemStore() @@ -201,6 +554,129 @@ func TestOrderDispatchMultiple(t *testing.T) { } } +func TestOrderDispatchCachesLastRunBetweenDispatches(t *testing.T) { + store := &countingListStore{Store: beads.NewMemStore()} + + if _, err := store.Create(beads.Bead{ + Title: "recent run", + Labels: []string{"order-run:test-order"}, + }); err != nil { + t.Fatal(err) + } + + aa := []orders.Order{{ + Name: "test-order", + Trigger: "cooldown", + Interval: "1h", + Formula: "test-formula", + }} + ad := buildOrderDispatcherFromList(aa, store, nil) + if ad == nil { + t.Fatal("expected non-nil dispatcher") + } + + cityPath := t.TempDir() + now := time.Now() + ad.dispatch(context.Background(), cityPath, now) + if store.includeClosedLists == 0 { + t.Fatal("first dispatch did not read persisted order history") + } + + store.reset() + ad.dispatch(context.Background(), cityPath, now.Add(time.Second)) + if store.includeClosedLists != 0 { + t.Fatalf("second dispatch performed %d closed-history reads, want cached last-run result", store.includeClosedLists) + } + + all, _ := store.ListOpen() + if len(all) != 1 { + t.Errorf("expected only seed bead, got %d", len(all)) + } +} + +func TestOrderDispatchRefreshesCachedLastRunBeforeDueDispatch(t *testing.T) { + baseStore := &createdAtOverrideStore{Store: beads.NewMemStore()} + store := &countingListStore{Store: baseStore} + now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) + + if _, err := store.Create(beads.Bead{ + Title: "recent run", + Labels: []string{"order-run:test-order"}, + CreatedAt: now.Add(-30 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + aa := []orders.Order{{ + Name: "test-order", + Trigger: "cooldown", + Interval: "1h", + Exec: "true", + }} + ad := buildOrderDispatcherFromListExec(aa, store, nil, successfulExec, nil) + if ad == nil { + t.Fatal("expected non-nil dispatcher") + } + + cityPath := t.TempDir() + ad.dispatch(context.Background(), cityPath, now) + if store.includeClosedLists == 0 { + t.Fatal("first dispatch did not read persisted order history") + } + + store.reset() + if _, err := store.Create(beads.Bead{ + Title: "manual run", + Labels: []string{"order-run:test-order"}, + CreatedAt: now.Add(20 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + ad.dispatch(context.Background(), cityPath, now.Add(31*time.Minute)) + if store.includeClosedLists == 0 { + t.Fatal("due cached dispatch did not refresh persisted order history") + } + + all := trackingBeads(t, store, "order-run:test-order") + if len(all) != 2 { + t.Fatalf("order-run beads = %d, want only seed plus manual run", len(all)) + } +} + +func TestOrderDispatchCachesAutoTrackingBeadCreatedAt(t *testing.T) { + store := &countingListStore{Store: beads.NewMemStore()} + now := time.Now() + + aa := []orders.Order{{ + Name: "test-order", + Trigger: "cooldown", + Interval: "1h", + Exec: "true", + }} + ad := buildOrderDispatcherFromListExec(aa, store, nil, successfulExec, nil) + if ad == nil { + t.Fatal("expected non-nil dispatcher") + } + + cityPath := t.TempDir() + ad.dispatch(context.Background(), cityPath, now) + all := trackingBeads(t, store, "order-run:test-order") + if len(all) != 1 { + t.Fatalf("order-run beads after first dispatch = %d, want 1", len(all)) + } + + store.reset() + ad.dispatch(context.Background(), cityPath, now.Add(time.Second)) + if store.includeClosedLists != 0 { + t.Fatalf("second dispatch performed %d closed-history reads, want cached tracking bead timestamp", store.includeClosedLists) + } + all = trackingBeads(t, store, "order-run:test-order") + if len(all) != 1 { + t.Fatalf("order-run beads after second dispatch = %d, want cached cooldown suppression", len(all)) + } +} + // --- exec order dispatch tests --- func TestOrderDispatchExecDue(t *testing.T) { @@ -315,6 +791,53 @@ func TestOrderDispatchExecFailure(t *testing.T) { } } +func TestOrderDispatchExecFailureRedactsSecrets(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghs_order_secret") + store := beads.NewMemStore() + var rec memRecorder + var stderr bytes.Buffer + tracking, err := store.Create(beads.Bead{ + Title: "order:leaky-exec", + Labels: []string{"order-run:leaky-exec", labelOrderTracking}, + }) + if err != nil { + t.Fatal(err) + } + + fakeExec := func(_ context.Context, _, _ string, _ []string) ([]byte, error) { + return []byte("GITHUB_TOKEN=ghs_order_secret\n--password hunter2\n"), fmt.Errorf("token=ghs_order_secret password=hunter2") + } + + aa := []orders.Order{{ + Name: "leaky-exec", + Trigger: "cooldown", + Interval: "2m", + Exec: "scripts/fail.sh", + }} + ad := buildOrderDispatcherFromListExec(aa, store, nil, fakeExec, &rec) + mad := ad.(*memoryOrderDispatcher) + mad.stderr = &stderr + + logs := captureCmdOrderLogs(t, func() { + mad.dispatchExec(context.Background(), store, execStoreTarget{ScopeRoot: t.TempDir()}, aa[0], t.TempDir(), tracking.ID) + }) + + combined := logs + "\n" + stderr.String() + for _, secret := range []string{"ghs_order_secret", "hunter2"} { + if strings.Contains(combined, secret) { + t.Fatalf("order exec logs leaked %q:\n%s", secret, combined) + } + } + if !strings.Contains(combined, "[redacted]") { + t.Fatalf("order exec logs = %q, want redaction marker", combined) + } + for _, event := range rec.events { + if strings.Contains(event.Message, "ghs_order_secret") || strings.Contains(event.Message, "hunter2") { + t.Fatalf("order failed event leaked secret: %#v", event) + } + } +} + func TestOrderDispatchFormulaCookFailureLabelsTrackingBead(t *testing.T) { store := beads.NewMemStore() var rec memRecorder @@ -1368,6 +1891,23 @@ func TestOrderRigSuspended(t *testing.T) { } } +func TestOrderRigSuspendedFallsBackToOrderRigOnPoolResolutionError(t *testing.T) { + cfg := &config.City{ + Rigs: []config.Rig{ + {Name: "frozen", Path: "/tmp/frozen", Suspended: true}, + }, + Agents: []config.Agent{ + {Name: "dog", Dir: "frozen", BindingName: "alpha"}, + {Name: "dog", Dir: "frozen", BindingName: "beta"}, + }, + } + m := &memoryOrderDispatcher{cfg: cfg} + + if got := m.orderRigSuspended(orders.Order{Rig: "frozen", Pool: "dog"}); !got { + t.Fatal("orderRigSuspended() = false, want true for suspended rig when pool resolution fails") + } +} + // --- orphaned tracking bead sweep tests (#520) --- func TestSweepOrphanedOrderTracking_ClosesOpenTrackingBeads(t *testing.T) { @@ -1860,18 +2400,94 @@ func TestRigExclusiveLayersNoCityPrefix(t *testing.T) { } func TestQualifyPool(t *testing.T) { + cityBindingCfg := &config.City{Agents: []config.Agent{ + {Name: "dog", BindingName: "maintenance"}, + }} + cityNoBindingCfg := &config.City{Agents: []config.Agent{ + {Name: "dog"}, + }} + rigBindingCfg := &config.City{Agents: []config.Agent{ + {Name: "dog", BindingName: "foo", Dir: "api"}, + }} + ambiguousCfg := &config.City{Agents: []config.Agent{ + {Name: "dog", BindingName: "gastown"}, + {Name: "dog", BindingName: "maintenance"}, + }} + importedOnlyCollisionCfg := &config.City{Agents: []config.Agent{ + {Name: "dog", BindingName: "maintenance", SourceDir: "/city/packs/maintenance"}, + {Name: "dog", BindingName: "gastown", SourceDir: "/city/packs/gastown"}, + }} + importedShadowCfg := &config.City{Agents: []config.Agent{ + {Name: "dog"}, + {Name: "dog", BindingName: "maintenance", SourceDir: "/city/packs/maintenance"}, + {Name: "dog", BindingName: "gastown", SourceDir: "/city/packs/gastown"}, + }} + dirIsolatedCfg := &config.City{Agents: []config.Agent{ + // City-level binding agent should NOT match a rig-scoped order. + {Name: "dog", BindingName: "maintenance"}, + }} + tests := []struct { - pool, rig, want string + name string + cfg *config.City + pool, rig string + sourceDirHint string + want string + wantErr string }{ - {"polecat", "demo-repo", "demo-repo/polecat"}, - {"demo-repo/polecat", "demo-repo", "demo-repo/polecat"}, // already qualified - {"dog", "", "dog"}, // city order + // Existing behavior preserved when cfg is nil (call sites that + // don't have a loaded city, e.g. TestOrderRun fixtures). + {"nil cfg city order", nil, "dog", "", "", "dog", ""}, + {"nil cfg rig order", nil, "polecat", "demo-repo", "", "demo-repo/polecat", ""}, + {"nil cfg pre-rig-qualified", nil, "demo-repo/polecat", "demo-repo", "", "demo-repo/polecat", ""}, + + // Already-qualified passthroughs. + {"already rig-qualified passthrough", cityBindingCfg, "demo-repo/dog", "", "", "demo-repo/dog", ""}, + {"already binding-qualified passthrough", cityBindingCfg, "maintenance.dog", "", "", "maintenance.dog", ""}, + {"binding-qualified gets rig prefix", rigBindingCfg, "foo.dog", "api", "", "api/foo.dog", ""}, + + // City-order binding lookup (the bug fix). + {"city order resolves binding", cityBindingCfg, "dog", "", "", "maintenance.dog", ""}, + {"city order no binding agent", cityNoBindingCfg, "dog", "", "", "dog", ""}, + {"city order miss falls through", cityBindingCfg, "wolf", "", "", "wolf", ""}, + {"city local shadow wins without hint", importedShadowCfg, "dog", "", "", "dog", ""}, + {"no hint stays ambiguous", importedOnlyCollisionCfg, "dog", "", "", "", `ambiguous pool "dog" for city order: matches maintenance.dog, gastown.dog`}, + {"source hint beats city shadow", importedShadowCfg, "dog", "", "/city/packs/maintenance", "maintenance.dog", ""}, + {"source hint beats sibling import collision", importedShadowCfg, "dog", "", "/city/packs/gastown", "gastown.dog", ""}, + + // Rig-order binding lookup. + {"rig order resolves binding", rigBindingCfg, "dog", "api", "", "api/foo.dog", ""}, + {"rig order isolated from city agent", dirIsolatedCfg, "dog", "api", "", "api/dog", ""}, + + // Ambiguity is a hard failure — dispatch must not recreate the + // original bare-name route/scaler mismatch. + {"ambiguous bindings fail", ambiguousCfg, "dog", "", "", "", `ambiguous pool "dog" for city order: matches gastown.dog, maintenance.dog`}, + + // Unresolved dotted pools preserve the legacy pass-through behavior. + {"unresolved dotted pool passes through", cityBindingCfg, "team.alpha", "", "", "team.alpha", ""}, + + // Empty/edge cases. + {"empty cfg agents", &config.City{}, "dog", "", "", "dog", ""}, } for _, tt := range tests { - got := qualifyPool(tt.pool, tt.rig) - if got != tt.want { - t.Errorf("qualifyPool(%q, %q) = %q, want %q", tt.pool, tt.rig, got, tt.want) - } + t.Run(tt.name, func(t *testing.T) { + got, err := qualifyPool(tt.pool, tt.rig, tt.cfg, tt.sourceDirHint) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("qualifyPool(%q, %q, cfg) error = nil, want %q", tt.pool, tt.rig, tt.wantErr) + } + if err.Error() != tt.wantErr { + t.Fatalf("qualifyPool(%q, %q, cfg) error = %q, want %q", tt.pool, tt.rig, err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("qualifyPool(%q, %q, cfg) error = %v", tt.pool, tt.rig, err) + } + if got != tt.want { + t.Errorf("qualifyPool(%q, %q, cfg) = %q, want %q", tt.pool, tt.rig, got, tt.want) + } + }) } } @@ -2173,6 +2789,11 @@ func TestOrderDispatchSkipsRigEventWhenLegacyCursorReadFails(t *testing.T) { } func TestOrderDispatchSkipsRigConditionWhenLegacyOpenWorkReadFails(t *testing.T) { + cityDir := t.TempDir() + rigDir := filepath.Join(cityDir, "frontend") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } rigStore := beads.NewMemStore() legacyStore := labelFailListStore{ Store: beads.NewMemStore(), @@ -2202,12 +2823,12 @@ func TestOrderDispatchSkipsRigConditionWhenLegacyOpenWorkReadFails(t *testing.T) cfg: &config.City{ Rigs: []config.Rig{{ Name: "frontend", - Path: "frontend", + Path: rigDir, }}, }, } - m.dispatch(context.Background(), t.TempDir(), time.Now()) + m.dispatch(context.Background(), cityDir, time.Now()) time.Sleep(50 * time.Millisecond) rigRuns := trackingBeads(t, rigStore, "order-run:rig-digest:rig:frontend") @@ -2219,6 +2840,37 @@ func TestOrderDispatchSkipsRigConditionWhenLegacyOpenWorkReadFails(t *testing.T) } } +func TestOrderDispatchConditionUsesScopedEnv(t *testing.T) { + cityDir := t.TempDir() + store := beads.NewMemStore() + check := fmt.Sprintf( + `test "$GC_CITY_PATH" = '%s' && test "$GC_STORE_ROOT" = '%s' && test "$GC_STORE_SCOPE" = city && test "$(pwd)" = '%s'`, + cityDir, + cityDir, + cityDir, + ) + ran := make(chan struct{}, 1) + fakeExec := func(_ context.Context, _, _ string, _ []string) ([]byte, error) { + ran <- struct{}{} + return nil, nil + } + aa := []orders.Order{{ + Name: "scoped-check", + Trigger: "condition", + Check: check, + Exec: "true", + }} + ad := buildOrderDispatcherFromListExec(aa, store, nil, fakeExec, nil) + + ad.dispatch(context.Background(), cityDir, time.Now()) + + select { + case <-ran: + case <-time.After(2 * time.Second): + t.Fatal("condition order did not dispatch with scoped cwd/env") + } +} + func TestOrderDispatchSkipsRigCooldownWhenLegacyLastRunReadFails(t *testing.T) { rigStore := beads.NewMemStore() legacyStore := labelFailListStore{ @@ -2502,6 +3154,97 @@ func writeFile(t *testing.T, path, content string) { } } +func writeImportedDogOrderFixture(t *testing.T, cityDir string, includeCityDog bool, extraBindings ...string) { + t.Helper() + + const orderBinding = "maintenance" + packRoot := filepath.Join(cityDir, "packs") + if err := os.MkdirAll(packRoot, 0o755); err != nil { + t.Fatal(err) + } + + cityToml := ` +[workspace] +name = "test-city" +` + if includeCityDog { + cityToml += ` + +[[agent]] +name = "dog" +scope = "city" +` + } + writeFile(t, filepath.Join(cityDir, "city.toml"), cityToml) + + formulaText, err := os.ReadFile(filepath.Join(sharedTestFormulaDir, "test-formula.formula.toml")) + if err != nil { + t.Fatalf("ReadFile(test-formula): %v", err) + } + + allBindings := append([]string{orderBinding}, extraBindings...) + var packToml strings.Builder + packToml.WriteString(` +[pack] +name = "test-city" +schema = 1 +`) + + for _, binding := range allBindings { + packDir := filepath.Join(packRoot, binding) + if err := os.MkdirAll(filepath.Join(packDir, "orders"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(packDir, "formulas"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(packDir, "pack.toml"), ` +[pack] +name = "`+binding+`" +schema = 1 + +[[agent]] +name = "dog" +scope = "city" +`) + if binding == orderBinding { + writeFile(t, filepath.Join(packDir, "orders", "digest.toml"), ` +[order] +formula = "test-formula" +trigger = "cooldown" +interval = "24h" +pool = "dog" +`) + writeFile(t, filepath.Join(packDir, "formulas", "test-formula.formula.toml"), string(formulaText)) + } + packToml.WriteString(` +[imports.` + binding + `] +source = "./packs/` + binding + `" +`) + } + + writeFile(t, filepath.Join(cityDir, "pack.toml"), packToml.String()) +} + +func loadImportedDogOrders(t *testing.T, cityDir string) (*config.City, []orders.Order) { + t.Helper() + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + var stderr bytes.Buffer + aa, err := scanAllOrders(cityDir, cfg, &stderr, "gc order list") + if err != nil { + t.Fatalf("scanAllOrders: %v; stderr: %s", err, stderr.String()) + } + if len(aa) != 1 { + t.Fatalf("scanAllOrders() len = %d, want 1 (%#v)", len(aa), aa) + } + return cfg, aa +} + // memRecorder records events in memory for test assertions. type memRecorder struct { events []events.Event @@ -2605,6 +3348,39 @@ func TestOrderDispatchSkipsOpenWork(t *testing.T) { } } +func TestOrderDispatchSkipsOpenTrackingBeadForConditionOrder(t *testing.T) { + store := beads.NewMemStore() + + _, err := store.Create(beads.Bead{ + Title: "order:my-auto", + Labels: []string{"order-run:my-auto", labelOrderTracking}, + }) + if err != nil { + t.Fatal(err) + } + + ran := false + fakeExec := func(_ context.Context, _, _ string, _ []string) ([]byte, error) { + ran = true + return nil, nil + } + + aa := []orders.Order{{ + Name: "my-auto", + Trigger: "condition", + Check: "true", + Exec: "scripts/run.sh", + }} + ad := buildOrderDispatcherFromListExec(aa, store, nil, fakeExec, nil) + + ad.dispatch(context.Background(), t.TempDir(), time.Now()) + time.Sleep(50 * time.Millisecond) + + if ran { + t.Error("exec should not have run while an order-tracking bead is open") + } +} + func TestOrderDispatchFiresAfterWorkClosed(t *testing.T) { store := beads.NewMemStore() diff --git a/cmd/gc/order_store.go b/cmd/gc/order_store.go index 7afa8f658..d149d1633 100644 --- a/cmd/gc/order_store.go +++ b/cmd/gc/order_store.go @@ -128,6 +128,27 @@ func orderExecEnv(cityPath string, cfg *config.City, target execStoreTarget, a o return mergeRuntimeEnv(nil, env) } +func orderTriggerOptions(cityPath string, cfg *config.City, a orders.Order) (orders.TriggerOptions, error) { + if a.Trigger != "condition" || strings.TrimSpace(cityPath) == "" { + return orders.TriggerOptions{}, nil + } + target, err := resolveOrderExecTarget(cityPath, cfg, a) + if err != nil { + return orders.TriggerOptions{}, err + } + return orderTriggerOptionsForTarget(cityPath, cfg, target, a), nil +} + +func orderTriggerOptionsForTarget(cityPath string, cfg *config.City, target execStoreTarget, a orders.Order) orders.TriggerOptions { + if a.Trigger != "condition" || strings.TrimSpace(cityPath) == "" { + return orders.TriggerOptions{} + } + return orders.TriggerOptions{ + ConditionDir: target.ScopeRoot, + ConditionEnv: orderExecEnv(cityPath, cfg, target, a), + } +} + func applyOrderExecCanonicalDoltEnv(cityPath, scopeRoot string, env map[string]string) { if env == nil { return diff --git a/cmd/gc/pool.go b/cmd/gc/pool.go index 7172b3df2..09105637f 100644 --- a/cmd/gc/pool.go +++ b/cmd/gc/pool.go @@ -72,9 +72,7 @@ func shellCommand(command, dir string, timeout time.Duration, env map[string]str if dir != "" { cmd.Dir = dir } - if env != nil { - cmd.Env = mergeRuntimeEnv(os.Environ(), env) - } + cmd.Env = mergeRuntimeEnv(os.Environ(), env) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("running command %q: %w", command, err) @@ -127,17 +125,10 @@ func evaluatePool(agentName string, sp scaleParams, dir string, env map[string]s telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, sp.Min, err) return sp.Min, fmt.Errorf("agent %q: %w", agentName, err) } - trimmed := strings.TrimSpace(out) - if trimmed == "" { - checkErr := fmt.Errorf("agent %q: check %q produced empty output", agentName, sp.Check) - telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, sp.Min, checkErr) - return sp.Min, checkErr - } - n, err := strconv.Atoi(trimmed) + n, err := parseScaleCheckCount(agentName, sp.Check, out) if err != nil { - parseErr := fmt.Errorf("agent %q: check output %q is not an integer", agentName, trimmed) - telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, sp.Min, parseErr) - return sp.Min, parseErr + telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, sp.Min, err) + return sp.Min, err } desired := n if desired < sp.Min { @@ -150,6 +141,38 @@ func evaluatePool(agentName string, sp scaleParams, dir string, env map[string]s return desired, nil } +func evaluatePoolNewDemand(agentName string, sp scaleParams, dir string, env map[string]string, runner ScaleCheckRunner) (int, error) { + start := time.Now() + out, err := runner(sp.Check, dir, env) + durationMs := float64(time.Since(start).Milliseconds()) + if err != nil { + telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, 0, err) + return 0, fmt.Errorf("agent %q: %w", agentName, err) + } + n, err := parseScaleCheckCount(agentName, sp.Check, out) + if err != nil { + telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, 0, err) + return 0, err + } + telemetry.RecordPoolCheck(context.Background(), agentName, durationMs, n, nil) + return n, nil +} + +func parseScaleCheckCount(agentName, check, out string) (int, error) { + trimmed := strings.TrimSpace(out) + if trimmed == "" { + return 0, fmt.Errorf("agent %q: check %q produced empty output", agentName, check) + } + n, err := strconv.Atoi(trimmed) + if err != nil { + return 0, fmt.Errorf("agent %q: check output %q is not an integer", agentName, trimmed) + } + if n < 0 { + return 0, fmt.Errorf("agent %q: check output %q is negative", agentName, trimmed) + } + return n, nil +} + // SessionSetupContext holds template variables for session_setup command expansion. type SessionSetupContext struct { Session string // tmux session name diff --git a/cmd/gc/pool_desired_state.go b/cmd/gc/pool_desired_state.go index db5cf5ef9..53d4f5211 100644 --- a/cmd/gc/pool_desired_state.go +++ b/cmd/gc/pool_desired_state.go @@ -54,7 +54,7 @@ func PoolDesiredCounts(states []PoolDesiredState) map[string]int { // from scale_check, while this function only preserves sessions that already // own actionable work. // Each bead's gc.routed_to determines which agent template it belongs to. -// scaleCheckCounts maps agent template → desired count from scale_check. +// scaleCheckCounts maps agent template → new session demand from scale_check. // Pass nil for either when unavailable. func ComputePoolDesiredStates( cfg *config.City, @@ -103,8 +103,7 @@ func computePoolDesiredStates( } } - // Collect uncapped requests per agent template. - var allRequests []SessionRequest + var resumeRequests []SessionRequest for i := range cfg.Agents { agent := &cfg.Agents[i] @@ -138,7 +137,7 @@ func computePoolDesiredStates( continue } if sessionBeadID != "" { - allRequests = append(allRequests, SessionRequest{ + resumeRequests = append(resumeRequests, SessionRequest{ Template: template, BeadPriority: beadPriority(wb), Tier: "resume", @@ -151,17 +150,17 @@ func computePoolDesiredStates( } } - // Merge scale_check demand: for each agent, if scale_check wants more - // sessions than bead-driven requests already cover, add the difference - // as "new" tier requests. This ensures the scale_check command (which - // runs in the correct rig directory) is always the authoritative demand - // signal, while bead-driven resume requests preserve running sessions. + limits := newNestedCapLimits(cfg) + usage := acceptedNestedCapUsage(limits, resumeRequests) + allRequests := append([]SessionRequest(nil), resumeRequests...) + + // Merge scale_check demand. In bead-backed reconciliation, scale_check is + // the authoritative signal for new unassigned demand only; resume requests + // are calculated independently from assigned work and must not be deducted + // from that count. if len(scaleCheckCounts) > 0 { - beadDriven := make(map[string]int, len(allRequests)) - for _, r := range allRequests { - beadDriven[r.Template]++ - } - for _, agent := range cfg.Agents { + for i := range cfg.Agents { + agent := &cfg.Agents[i] if agent.Suspended { continue } @@ -170,12 +169,14 @@ func computePoolDesiredStates( if !ok { continue } - deficit := scaleCount - beadDriven[template] - for j := 0; j < deficit; j++ { - allRequests = append(allRequests, SessionRequest{ + newCount := capNewDemandCount(limits, usage, agent, scaleCount) + for j := 0; j < newCount; j++ { + req := SessionRequest{ Template: template, Tier: "new", - }) + } + allRequests = append(allRequests, req) + usage.accept(req, limits) } } } @@ -198,92 +199,20 @@ func applyNestedCaps(cfg *config.City, requests []SessionRequest, trace *session return false }) - // Counters for nested caps. - agentCount := make(map[string]int) // template → count - rigCount := make(map[string]int) // rig name → count - workspaceCount := 0 - - // Resolve caps. - workspaceMax := -1 // -1 = unlimited - if cfg.Workspace.MaxActiveSessions != nil { - workspaceMax = *cfg.Workspace.MaxActiveSessions - } - rigMaxMap := make(map[string]int) // rig name → max (-1 = unlimited) - for _, rig := range cfg.Rigs { - if rig.MaxActiveSessions != nil { - rigMaxMap[rig.Name] = *rig.MaxActiveSessions - } else { - rigMaxMap[rig.Name] = -1 - } - } - agentMaxMap := make(map[string]int) // template → max (-1 = unlimited) - agentRigMap := make(map[string]string) // template → rig name - for i := range cfg.Agents { - agent := &cfg.Agents[i] - template := agent.QualifiedName() - agentRigMap[template] = agent.Dir - resolved := agent.ResolvedMaxActiveSessions(cfg) - if resolved != nil { - agentMaxMap[template] = *resolved - } else { - agentMaxMap[template] = -1 - } - } + limits := newNestedCapLimits(cfg) + usage := newNestedCapUsage() // Walk sorted requests, accepting each if all caps have room. accepted := make(map[string][]SessionRequest) // template → accepted requests - // Dedup: don't accept multiple requests for the same session bead. - seenSessionBeads := make(map[string]bool) for _, req := range requests { - // Dedup resume requests for the same session bead. - if req.Tier == "resume" && req.SessionBeadID != "" { - if seenSessionBeads[req.SessionBeadID] { - continue - } - } - template := req.Template - rig := agentRigMap[template] - - // Check agent cap. - agentMax := agentMaxMap[template] - if agentMax >= 0 && agentCount[template] >= agentMax { - if trace != nil { - trace.recordDecision("reconciler.pool.agent_cap", template, "", "agent_cap", "rejected", traceRecordPayload{ - "agent_max": agentMax, - "current": agentCount[template], - "tier": req.Tier, - }, nil, "") - } + if usage.isDuplicateResume(req) { continue } - // Check rig cap. - if rig != "" { - rigMax, ok := rigMaxMap[rig] - if !ok { - rigMax = -1 - } - if rigMax >= 0 && rigCount[rig] >= rigMax { - if trace != nil { - trace.recordDecision("reconciler.pool.rig_cap", template, "", "rig_cap", "rejected", traceRecordPayload{ - "rig": rig, - "rig_max": rigMax, - "current": rigCount[rig], - "tier": req.Tier, - }, nil, "") - } - continue - } - } - // Check workspace cap. - if workspaceMax >= 0 && workspaceCount >= workspaceMax { + if site, reason, payload, rejected := usage.rejection(req, limits); rejected { if trace != nil { - trace.recordDecision("reconciler.pool.workspace_cap", template, "", "workspace_cap", "rejected", traceRecordPayload{ - "workspace_max": workspaceMax, - "current": workspaceCount, - "tier": req.Tier, - }, nil, "") + trace.recordDecision(site, template, "", reason, "rejected", payload, nil, "") } continue } @@ -295,14 +224,7 @@ func applyNestedCaps(cfg *config.City, requests []SessionRequest, trace *session "tier": req.Tier, }, nil, "") } - agentCount[template]++ - if rig != "" { - rigCount[rig]++ - } - workspaceCount++ - if req.Tier == "resume" && req.SessionBeadID != "" { - seenSessionBeads[req.SessionBeadID] = true - } + usage.accept(req, limits) } // Fill agent mins (if caps allow). @@ -313,41 +235,23 @@ func applyNestedCaps(cfg *config.City, requests []SessionRequest, trace *session } template := agent.QualifiedName() minSess := agent.EffectiveMinActiveSessions() - for agentCount[template] < minSess { - rig := agentRigMap[template] - // Check caps before adding idle session. - agentMax := agentMaxMap[template] - if agentMax >= 0 && agentCount[template] >= agentMax { - break - } - if rig != "" { - rigMax, ok := rigMaxMap[rig] - if !ok { - rigMax = -1 - } - if rigMax >= 0 && rigCount[rig] >= rigMax { - break - } + for usage.agentCount[template] < minSess { + req := SessionRequest{ + Template: template, + Tier: "new", } - if workspaceMax >= 0 && workspaceCount >= workspaceMax { + if _, _, _, rejected := usage.rejection(req, limits); rejected { break } - accepted[template] = append(accepted[template], SessionRequest{ - Template: template, - Tier: "new", - }) + accepted[template] = append(accepted[template], req) if trace != nil { trace.recordDecision("reconciler.pool.min_fill", template, "", "min_fill", "accepted", traceRecordPayload{ "min": minSess, - "current": agentCount[template], + "current": usage.agentCount[template], "tier": "new", }, nil, "") } - agentCount[template]++ - if rig != "" { - rigCount[rig]++ - } - workspaceCount++ + usage.accept(req, limits) } } @@ -365,3 +269,167 @@ func applyNestedCaps(cfg *config.City, requests []SessionRequest, trace *session }) return result } + +type nestedCapLimits struct { + workspaceMax int + rigMax map[string]int + agentMax map[string]int + agentRig map[string]string +} + +type nestedCapUsage struct { + agentCount map[string]int + rigCount map[string]int + workspaceCount int + seenSessionBead map[string]bool +} + +func newNestedCapLimits(cfg *config.City) nestedCapLimits { + limits := nestedCapLimits{ + workspaceMax: -1, + rigMax: make(map[string]int), + agentMax: make(map[string]int), + agentRig: make(map[string]string), + } + if cfg.Workspace.MaxActiveSessions != nil { + limits.workspaceMax = *cfg.Workspace.MaxActiveSessions + } + for _, rig := range cfg.Rigs { + if rig.MaxActiveSessions != nil { + limits.rigMax[rig.Name] = *rig.MaxActiveSessions + } else { + limits.rigMax[rig.Name] = -1 + } + } + for i := range cfg.Agents { + agent := &cfg.Agents[i] + template := agent.QualifiedName() + limits.agentRig[template] = agent.Dir + resolved := agent.ResolvedMaxActiveSessions(cfg) + if resolved != nil { + limits.agentMax[template] = *resolved + } else { + limits.agentMax[template] = -1 + } + } + return limits +} + +func newNestedCapUsage() nestedCapUsage { + return nestedCapUsage{ + agentCount: make(map[string]int), + rigCount: make(map[string]int), + seenSessionBead: make(map[string]bool), + } +} + +func acceptedNestedCapUsage(limits nestedCapLimits, requests []SessionRequest) nestedCapUsage { + usage := newNestedCapUsage() + sorted := append([]SessionRequest(nil), requests...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].BeadPriority != sorted[j].BeadPriority { + return sorted[i].BeadPriority > sorted[j].BeadPriority + } + if sorted[i].Tier != sorted[j].Tier { + return sorted[i].Tier == "resume" + } + return false + }) + for _, req := range sorted { + if usage.canAccept(req, limits) { + usage.accept(req, limits) + } + } + return usage +} + +func capNewDemandCount(limits nestedCapLimits, usage nestedCapUsage, agent *config.Agent, demand int) int { + if demand <= 0 { + return 0 + } + template := agent.QualifiedName() + remaining := demand + if agentMax := limits.agentMax[template]; agentMax >= 0 { + remaining = minInt(remaining, agentMax-usage.agentCount[template]) + } + if rig := limits.agentRig[template]; rig != "" { + rigMax, ok := limits.rigMax[rig] + if !ok { + rigMax = -1 + } + if rigMax >= 0 { + remaining = minInt(remaining, rigMax-usage.rigCount[rig]) + } + } + if limits.workspaceMax >= 0 { + remaining = minInt(remaining, limits.workspaceMax-usage.workspaceCount) + } + if remaining < 0 { + return 0 + } + return remaining +} + +func (u nestedCapUsage) canAccept(req SessionRequest, limits nestedCapLimits) bool { + if u.isDuplicateResume(req) { + return false + } + _, _, _, rejected := u.rejection(req, limits) + return !rejected +} + +func (u nestedCapUsage) isDuplicateResume(req SessionRequest) bool { + return req.Tier == "resume" && req.SessionBeadID != "" && u.seenSessionBead[req.SessionBeadID] +} + +func (u nestedCapUsage) rejection(req SessionRequest, limits nestedCapLimits) (string, string, traceRecordPayload, bool) { + template := req.Template + if agentMax := limits.agentMax[template]; agentMax >= 0 && u.agentCount[template] >= agentMax { + return "reconciler.pool.agent_cap", "agent_cap", traceRecordPayload{ + "agent_max": agentMax, + "current": u.agentCount[template], + "tier": req.Tier, + }, true + } + rig := limits.agentRig[template] + if rig != "" { + rigMax, ok := limits.rigMax[rig] + if !ok { + rigMax = -1 + } + if rigMax >= 0 && u.rigCount[rig] >= rigMax { + return "reconciler.pool.rig_cap", "rig_cap", traceRecordPayload{ + "rig": rig, + "rig_max": rigMax, + "current": u.rigCount[rig], + "tier": req.Tier, + }, true + } + } + if limits.workspaceMax >= 0 && u.workspaceCount >= limits.workspaceMax { + return "reconciler.pool.workspace_cap", "workspace_cap", traceRecordPayload{ + "workspace_max": limits.workspaceMax, + "current": u.workspaceCount, + "tier": req.Tier, + }, true + } + return "", "", nil, false +} + +func (u *nestedCapUsage) accept(req SessionRequest, limits nestedCapLimits) { + u.agentCount[req.Template]++ + if rig := limits.agentRig[req.Template]; rig != "" { + u.rigCount[rig]++ + } + u.workspaceCount++ + if req.Tier == "resume" && req.SessionBeadID != "" { + u.seenSessionBead[req.SessionBeadID] = true + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/gc/pool_desired_state_test.go b/cmd/gc/pool_desired_state_test.go index e211c6dae..fb732374b 100644 --- a/cmd/gc/pool_desired_state_test.go +++ b/cmd/gc/pool_desired_state_test.go @@ -24,6 +24,26 @@ func sessionBead(id, status string) beads.Bead { return beads.Bead{ID: id, Status: status, Type: "session"} } +func newPoolDesiredStateTestTrace(templates ...string) *sessionReconcilerTraceCycle { + detail := make(map[string]TraceSource, len(templates)) + for _, template := range templates { + detail[normalizedTraceTemplate(template)] = TraceSourceManual + } + return &sessionReconcilerTraceCycle{ + tracer: &SessionReconcilerTracer{detail: detail}, + dropReasons: make(map[string]int), + pendingDetail: make(map[string][]SessionReconcilerTraceRecord), + pendingDropped: make(map[string]int), + templatesTouched: make(map[string]struct{}), + detailedTemplates: make(map[string]struct{}), + decisionCounts: make(map[string]int), + operationCounts: make(map[string]int), + mutationCounts: make(map[string]int), + reasonCounts: make(map[string]int), + outcomeCounts: make(map[string]int), + } +} + func poolAgent(name, dir string, maxSess *int, minSess int) config.Agent { var minPtr *int if minSess > 0 { @@ -41,12 +61,13 @@ func TestComputePoolDesiredStates_ResumeBeatsNew(t *testing.T) { cfg := &config.City{ Agents: []config.Agent{poolAgent("claude", "rig", intPtr(2), 0)}, } - // 1 assigned (resume) + 2 unassigned. scale_check reports 3 total demand. + // 1 assigned (resume) + 2 new demand. scale_check reports only the new + // demand, and the max cap admits one of those two new requests. work := []beads.Bead{ workBead("w1", "rig/claude", "sess-1", "in_progress", 5), } sessions := []beads.Bead{sessionBead("sess-1", "open")} - scaleCheck := map[string]int{"rig/claude": 3} + scaleCheck := map[string]int{"rig/claude": 2} result := ComputePoolDesiredStates(cfg, work, sessions, scaleCheck) @@ -54,7 +75,7 @@ func TestComputePoolDesiredStates_ResumeBeatsNew(t *testing.T) { t.Fatalf("len(result) = %d, want 1", len(result)) } reqs := result[0].Requests - // Max=2: resume (w1) + 1 new from scale_check deficit (3-1=2, capped at max=2). + // Max=2: resume (w1) + 1 new from scale_check, capped at max=2. if len(reqs) != 2 { t.Fatalf("len(requests) = %d, want 2 (max=2)", len(reqs)) } @@ -423,6 +444,43 @@ func TestComputePoolDesiredStates_ScaleCheckRespectsCaps(t *testing.T) { } } +func TestComputePoolDesiredStates_CapsNewDemandBeforeMaterializingRequests(t *testing.T) { + workspaceMax := 2 + cfg := &config.City{ + Workspace: config.Workspace{MaxActiveSessions: &workspaceMax}, + Agents: []config.Agent{poolAgent("claude", "", nil, 0)}, + } + work := []beads.Bead{ + workBead("w1", "claude", "sess-1", "in_progress", 5), + } + sessions := []beads.Bead{sessionBead("sess-1", "open")} + trace := newPoolDesiredStateTestTrace("claude") + + result := computePoolDesiredStates(cfg, work, sessions, map[string]int{"claude": 10}, trace) + + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + if len(result[0].Requests) != 2 { + t.Fatalf("len(requests) = %d, want 2 (one resume plus one new demand within workspace cap)", len(result[0].Requests)) + } + newCount := 0 + for _, req := range result[0].Requests { + if req.Tier == "new" { + newCount++ + } + } + if newCount != 1 { + t.Fatalf("new requests = %d, want 1", newCount) + } + capRejections := trace.decisionCounts[string(TraceSitePoolAgentCap)] + + trace.decisionCounts[string(TraceSitePoolRigCap)] + + trace.decisionCounts[string(TraceSitePoolWorkspaceCap)] + if capRejections != 0 { + t.Fatalf("cap rejections = %d, want 0; new demand should be capped before request materialization", capRejections) + } +} + func TestComputePoolDesiredStates_OpenAssignedWorkResumes(t *testing.T) { cfg := &config.City{ Agents: []config.Agent{poolAgent("claude", "", intPtr(5), 0)}, @@ -487,7 +545,7 @@ func TestComputePoolDesiredStates_NoDemandNoAssignment(t *testing.T) { } } -// Regression: scale_check=3 with 1 assigned → poolDesired=3 (1 resume + 2 new). +// Regression: scale_check reports new demand, not total desired sessions. func TestComputePoolDesiredStates_ScaleCheckAndResumeAddUp(t *testing.T) { cfg := &config.City{ Agents: []config.Agent{poolAgent("claude", "", intPtr(5), 0)}, @@ -496,7 +554,7 @@ func TestComputePoolDesiredStates_ScaleCheckAndResumeAddUp(t *testing.T) { workBead("w1", "claude", "sess-1", "in_progress", 5), } sessions := []beads.Bead{sessionBead("sess-1", "open")} - scaleCheck := map[string]int{"claude": 3} + scaleCheck := map[string]int{"claude": 2} result := ComputePoolDesiredStates(cfg, work, sessions, scaleCheck) @@ -504,7 +562,7 @@ func TestComputePoolDesiredStates_ScaleCheckAndResumeAddUp(t *testing.T) { t.Fatalf("len(result) = %d, want 1", len(result)) } if len(result[0].Requests) != 3 { - t.Fatalf("len(requests) = %d, want 3 (1 resume + 2 new from scale_check deficit)", len(result[0].Requests)) + t.Fatalf("len(requests) = %d, want 3 (1 resume + 2 new from scale_check)", len(result[0].Requests)) } resumeCount := 0 newCount := 0 diff --git a/cmd/gc/pool_session_name.go b/cmd/gc/pool_session_name.go index 07754b560..5215ccc6b 100644 --- a/cmd/gc/pool_session_name.go +++ b/cmd/gc/pool_session_name.go @@ -46,14 +46,16 @@ func GCSweepSessionBeads(store beads.Store, rigStores map[string]beads.Store, se } // releaseOrphanedPoolAssignments reopens active pool-routed work whose -// assignee no longer maps to any open session bead. This recovers attempts -// that were left in_progress after a pooled worker exited or was swept. +// assignee no longer maps to any open session bead. This also recovers +// pool-routed work left in_progress with no assignee, which cannot be claimed +// again until it is moved back to open. func releaseOrphanedPoolAssignments( store beads.Store, cfg *config.City, openSessionBeads []beads.Bead, assignedWorkBeads []beads.Bead, assignedWorkStores []beads.Store, + rigStores map[string]beads.Store, ) []releasedPoolAssignment { if store == nil || cfg == nil || len(assignedWorkBeads) == 0 { return nil @@ -85,12 +87,6 @@ func releaseOrphanedPoolAssignments( continue } assignee := strings.TrimSpace(wb.Assignee) - if assignee == "" { - continue - } - if _, ok := openIdentifiers[assignee]; ok { - continue - } template := strings.TrimSpace(wb.Metadata["gc.routed_to"]) if template == "" { continue @@ -99,17 +95,31 @@ func releaseOrphanedPoolAssignments( if agentCfg == nil || !agentCfg.SupportsGenericEphemeralSessions() { continue } - if assigneePreservesNamedSessionRoute(cfg, template, assignee) { - continue + if assignee == "" { + if wb.Status != "in_progress" { + continue + } + } else { + if _, ok := openIdentifiers[assignee]; ok { + continue + } + if assigneePreservesNamedSessionRoute(cfg, template, assignee) { + continue + } } - ownerStore := store + var ownerStore beads.Store if storeAware { if i >= len(assignedWorkStores) || assignedWorkStores[i] == nil { log.Printf("releaseOrphanedPoolAssignments: missing owner store for assigned work %q at index %d", wb.ID, i) continue } ownerStore = assignedWorkStores[i] + } else { + ownerStore = storeForPoolAssignment(cfg, store, rigStores, wb) + if ownerStore == nil { + continue + } } if !releaseOrphanedPoolAssignment(ownerStore, wb.ID) { continue @@ -119,6 +129,48 @@ func releaseOrphanedPoolAssignments( return released } +func storeForPoolAssignment(cfg *config.City, cityStore beads.Store, rigStores map[string]beads.Store, wb beads.Bead) beads.Store { + if cfg == nil || len(rigStores) == 0 { + return cityStore + } + if routed := strings.TrimSpace(wb.Metadata["gc.routed_to"]); routed != "" { + if slash := strings.IndexByte(routed, '/'); slash > 0 { + if store := rigStores[routed[:slash]]; store != nil { + return store + } + } + } + idPrefix := beadIDPrefix(wb.ID) + for _, rig := range cfg.Rigs { + if idPrefix == rig.EffectivePrefix() { + if store := rigStores[rig.Name]; store != nil { + return store + } + } + } + return cityStore +} + +func isRecoverableUnassignedInProgressPoolWork(cfg *config.City, wb beads.Bead) bool { + if wb.Status != "in_progress" || strings.TrimSpace(wb.Assignee) != "" { + return false + } + template := strings.TrimSpace(wb.Metadata["gc.routed_to"]) + if template == "" { + return false + } + agentCfg := findAgentByTemplate(cfg, template) + return agentCfg != nil && agentCfg.SupportsGenericEphemeralSessions() +} + +func beadIDPrefix(id string) string { + trimmed := strings.TrimSpace(id) + if dash := strings.IndexByte(trimmed, '-'); dash > 0 { + return trimmed[:dash] + } + return "" +} + func releaseOrphanedPoolAssignment(store beads.Store, id string) bool { if store == nil || id == "" { return false diff --git a/cmd/gc/pool_session_name_test.go b/cmd/gc/pool_session_name_test.go index f73bd610b..14bbfc722 100644 --- a/cmd/gc/pool_session_name_test.go +++ b/cmd/gc/pool_session_name_test.go @@ -165,6 +165,7 @@ func TestReleaseOrphanedPoolAssignments_ReopensMissingPoolAssignee(t *testing.T) nil, []beads.Bead{work}, nil, + nil, ) if len(released) != 1 || released[0].ID != work.ID { t.Fatalf("released = %v, want [%s]", released, work.ID) @@ -182,6 +183,129 @@ func TestReleaseOrphanedPoolAssignments_ReopensMissingPoolAssignee(t *testing.T) } } +func TestReleaseOrphanedPoolAssignments_ReopensUnassignedInProgressPoolWork(t *testing.T) { + store := beads.NewMemStore() + work, err := store.Create(beads.Bead{ + Title: "stranded pool work", + Metadata: map[string]string{"gc.routed_to": "worker"}, + }) + 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 status: %v", err) + } + work, err = store.Get(work.ID) + if err != nil { + t.Fatalf("Reload work bead: %v", err) + } + if work.Assignee != "" { + t.Fatalf("test setup assignee = %q, want empty", work.Assignee) + } + + released := releaseOrphanedPoolAssignments( + store, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + nil, + []beads.Bead{work}, + nil, + nil, + ) + if len(released) != 1 || released[0].ID != work.ID { + t.Fatalf("released = %v, want [%s]", released, work.ID) + } + + got, err := store.Get(work.ID) + if err != nil { + t.Fatalf("Get work bead: %v", err) + } + if got.Status != "open" { + t.Fatalf("status = %q, want open", got.Status) + } + if got.Assignee != "" { + t.Fatalf("assignee = %q, want empty", got.Assignee) + } +} + +func TestCollectAssignedWorkBeadsIncludesUnassignedInProgressPoolWorkForRecovery(t *testing.T) { + store := beads.NewMemStore() + work, err := store.Create(beads.Bead{ + Title: "stranded pool work", + Metadata: map[string]string{"gc.routed_to": "worker"}, + }) + 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 status: %v", err) + } + + found, stores, partial := collectAssignedWorkBeadsWithStores( + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + store, + nil, + nil, + ) + if partial { + t.Fatal("collectAssignedWorkBeadsWithStores reported partial results") + } + if len(found) != 1 || found[0].ID != work.ID { + t.Fatalf("found = %#v, want stranded work %s", found, work.ID) + } + if len(stores) != 1 || stores[0] != store { + t.Fatalf("stores = %#v, want owner store", stores) + } +} + +func TestReleaseOrphanedPoolAssignments_UpdatesRigStoreFallback(t *testing.T) { + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + work, err := rigStore.Create(beads.Bead{ + Title: "orphaned rig pool work", + Assignee: "worker-dead", + Metadata: map[string]string{"gc.routed_to": "rig/worker"}, + }) + 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("Set work status: %v", err) + } + work, err = rigStore.Get(work.ID) + if err != nil { + t.Fatalf("Reload work bead: %v", err) + } + + released := releaseOrphanedPoolAssignments( + cityStore, + &config.City{ + Rigs: []config.Rig{{Name: "rig", Prefix: "ga"}}, + Agents: []config.Agent{{Name: "worker", Dir: "rig", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}, + }, + nil, + []beads.Bead{work}, + nil, + map[string]beads.Store{"rig": rigStore}, + ) + if len(released) != 1 || released[0].ID != work.ID { + t.Fatalf("released = %v, want [%s]", released, work.ID) + } + + got, err := rigStore.Get(work.ID) + if err != nil { + t.Fatalf("Get rig work bead: %v", err) + } + if got.Status != "open" { + t.Fatalf("rig status = %q, want open", got.Status) + } + if got.Assignee != "" { + t.Fatalf("rig assignee = %q, want empty", got.Assignee) + } + if _, err := cityStore.Get(work.ID); err == nil { + t.Fatalf("city store unexpectedly contains rig work bead %s", work.ID) + } +} + func TestReleaseOrphanedPoolAssignments_ReopensRigStoreMissingPoolAssignee(t *testing.T) { cityStore := beads.NewMemStore() rigStore := beads.NewMemStore() @@ -227,6 +351,7 @@ func TestReleaseOrphanedPoolAssignments_ReopensRigStoreMissingPoolAssignee(t *te nil, []beads.Bead{work}, []beads.Store{rigStore}, + nil, ) if len(released) != 1 || released[0].ID != work.ID { t.Fatalf("released = %v, want [%s]", released, work.ID) @@ -305,6 +430,7 @@ func TestReleaseOrphanedPoolAssignments_ReopensCrossStoreIDCollisions(t *testing nil, []beads.Bead{cityWork, rigWork}, []beads.Store{cityStore, rigStore}, + nil, ) if len(released) != 2 || released[0].ID != cityWork.ID || released[1].ID != rigWork.ID { t.Fatalf("released = %v, want [%s %s]", released, cityWork.ID, rigWork.ID) @@ -350,6 +476,7 @@ func TestReleaseOrphanedPoolAssignments_SkipsStoreAwareEntryWithoutOwnerStore(t nil, []beads.Bead{work}, []beads.Store{nil}, + nil, ) if len(released) != 0 { t.Fatalf("released = %v, want none without owner store", released) @@ -401,6 +528,7 @@ func TestReleaseOrphanedPoolAssignments_KeepsOpenSessionOwnership(t *testing.T) []beads.Bead{session}, []beads.Bead{work}, nil, + nil, ) if len(released) != 0 { t.Fatalf("released = %v, want none", released) @@ -446,7 +574,7 @@ func TestReleaseOrphanedPoolAssignments_ReopensStaleDirectAssigneeForNamedBacked ResolvedWorkspaceName: "test-city", } - released := releaseOrphanedPoolAssignments(store, cfg, nil, []beads.Bead{work}, nil) + released := releaseOrphanedPoolAssignments(store, cfg, nil, []beads.Bead{work}, nil, nil) if len(released) != 1 || released[0].ID != work.ID { t.Fatalf("released = %v, want [%s]", released, work.ID) } @@ -491,7 +619,7 @@ func TestReleaseOrphanedPoolAssignments_PreservesCanonicalNamedIdentity(t *testi ResolvedWorkspaceName: "test-city", } - released := releaseOrphanedPoolAssignments(store, cfg, nil, []beads.Bead{work}, nil) + released := releaseOrphanedPoolAssignments(store, cfg, nil, []beads.Bead{work}, nil, nil) if len(released) != 0 { t.Fatalf("released = %v, want none", released) } diff --git a/cmd/gc/pool_test.go b/cmd/gc/pool_test.go index ff4bff01c..9e0239d80 100644 --- a/cmd/gc/pool_test.go +++ b/cmd/gc/pool_test.go @@ -101,6 +101,7 @@ func TestEvaluatePoolNonInteger(t *testing.T) { } func TestEvaluatePoolDefaultScaleCheckCountsRoutedReadyWork(t *testing.T) { + skipSlowCmdGCTest(t, "uses real bd and jq for default scale_check coverage; run make test-cmd-gc-process for full coverage") bdPath, err := findPreferredBinary("bd", "/home/ubuntu/.local/bin/bd") if err != nil { t.Skip("bd not installed") @@ -143,7 +144,8 @@ func TestEvaluatePoolDefaultScaleCheckCountsRoutedReadyWork(t *testing.T) { } } -func TestEvaluatePoolDefaultScaleCheckCountsRoutedActiveUnassignedWork(t *testing.T) { +func TestEvaluatePoolDefaultScaleCheckIgnoresRoutedActiveUnassignedWork(t *testing.T) { + skipSlowCmdGCTest(t, "uses real bd and jq for default scale_check coverage; run make test-cmd-gc-process for full coverage") bdPath, err := findPreferredBinary("bd", "/home/ubuntu/.local/bin/bd") if err != nil { t.Skip("bd not installed") @@ -185,8 +187,34 @@ func TestEvaluatePoolDefaultScaleCheckCountsRoutedActiveUnassignedWork(t *testin if err != nil { t.Fatalf("evaluatePool with routed in-progress work: %v", err) } - if got != 1 { - t.Fatalf("evaluatePool with routed in-progress work = %d, want 1", got) + if got != 0 { + t.Fatalf("evaluatePool with routed in-progress work = %d, want 0", got) + } +} + +func TestEvaluatePoolNewDemandDoesNotApplyMinOrMax(t *testing.T) { + sp := scaleParams{Min: 2, Max: 3, Check: "ignored"} + runner := func(_, _ string, _ map[string]string) (string, error) { return "5\n", nil } + + got, err := evaluatePoolNewDemand("worker", sp, "", nil, runner) + if err != nil { + t.Fatalf("evaluatePoolNewDemand: %v", err) + } + if got != 5 { + t.Fatalf("evaluatePoolNewDemand = %d, want raw new demand 5", got) + } +} + +func TestEvaluatePoolNewDemandErrorFallsBackToZero(t *testing.T) { + sp := scaleParams{Min: 2, Max: 3, Check: "ignored"} + runner := func(_, _ string, _ map[string]string) (string, error) { return "not-a-number\n", nil } + + got, err := evaluatePoolNewDemand("worker", sp, "", nil, runner) + if err == nil { + t.Fatal("expected parse error") + } + if got != 0 { + t.Fatalf("evaluatePoolNewDemand error fallback = %d, want 0", got) } } diff --git a/cmd/gc/providers.go b/cmd/gc/providers.go index 5e9f77b9b..91320a27c 100644 --- a/cmd/gc/providers.go +++ b/cmd/gc/providers.go @@ -26,6 +26,7 @@ import ( sessionk8s "github.com/gastownhall/gascity/internal/runtime/k8s" sessionsubprocess "github.com/gastownhall/gascity/internal/runtime/subprocess" sessiontmux "github.com/gastownhall/gascity/internal/runtime/tmux" + "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/supervisor" ) @@ -70,7 +71,10 @@ func sessionProviderContextForCity(cfg *config.City, cityPath, providerOverride return ctx } -var openSessionProviderStore = openCityStoreAt +var ( + openSessionProviderStore = openCityStoreAt + buildSessionProviderByName = newSessionProviderByName +) // tmuxConfigFromSession converts a config.SessionConfig into a // sessiontmux.Config with resolved durations and defaults. If the @@ -161,7 +165,7 @@ func newSessionProviderForCity(cfg *config.City, cityPath string) runtime.Provid } func loadProviderSessionSnapshot(ctx sessionProviderContext) *sessionBeadSnapshot { - if ctx.cityPath == "" || ctx.providerName == "acp" || !hasACPAgents(ctx.agents) { + if ctx.cityPath == "" || ctx.providerName == "acp" { return nil } store, err := openSessionProviderStore(ctx.cityPath) @@ -176,47 +180,64 @@ func loadProviderSessionSnapshot(ctx sessionProviderContext) *sessionBeadSnapsho } func newSessionProviderFromContext(ctx sessionProviderContext, sessionBeads *sessionBeadSnapshot) runtime.Provider { - sp, err := newSessionProviderByName(ctx.providerName, ctx.sc, ctx.cityName, ctx.cityPath) + sp, err := newSessionProviderFromContextWithError(ctx, sessionBeads) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) //nolint:errcheck // best-effort stderr os.Exit(1) } + return sp +} + +func newSessionProviderFromContextWithError(ctx sessionProviderContext, sessionBeads *sessionBeadSnapshot) (runtime.Provider, error) { + sp, err := newSessionProviderByName(ctx.providerName, ctx.sc, ctx.cityName, ctx.cityPath) + if err != nil { + return nil, err + } // If the city-level provider is not ACP but some agents need ACP, // wrap in an auto provider that routes per-session. // NOTE: agents comes from loadCityConfig which applies pack overrides, // so the Session field from overrides is already resolved here. - if ctx.providerName != "acp" && hasACPAgents(ctx.agents) { - acpSP, acpErr := newSessionProviderByName("acp", ctx.sc, ctx.cityName, ctx.cityPath) + requireACPWrapper := requiresACPProviderWrapper(sessionBeads, ctx.cityName, ctx.cfg) + if ctx.providerName != "acp" && needsACPProviderWrapper(sessionBeads, ctx.cityName, ctx.cfg) { + acpSP, acpErr := buildSessionProviderByName("acp", ctx.sc, ctx.cityName, ctx.cityPath) if acpErr != nil { - fmt.Fprintf(os.Stderr, "acp provider: %v\n", acpErr) //nolint:errcheck // best-effort stderr - os.Exit(1) + if requireACPWrapper { + return nil, fmt.Errorf("acp provider: %w", acpErr) + } + return sp, nil } autoSP := sessionauto.New(sp, acpSP) - for _, sessName := range configuredACPSessionNames(sessionBeads, ctx.cityName, ctx.sessionTemplate, ctx.agents) { + for _, sessName := range configuredACPRouteNames(sessionBeads, ctx.cityName, ctx.cfg) { autoSP.RouteACP(sessName) } - return autoSP + return autoSP, nil } - return sp + return sp, nil } -// hasACPAgents reports whether any agent in the config uses session = "acp". -func hasACPAgents(agents []config.Agent) bool { - for _, a := range agents { - if a.Session == "acp" { - return true - } +func agentSessionCreateTransport(cfg *config.City, agentCfg config.Agent) string { + if cfg == nil { + return strings.TrimSpace(agentCfg.Session) + } + resolved, err := config.ResolveProvider( + &agentCfg, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return strings.TrimSpace(agentCfg.Session) } - return false + return config.ResolveSessionCreateTransport(agentCfg.Session, resolved) } // configuredACPSessionNames resolves the runtime session names for ACP-backed // agents using a single session-bead snapshot. When the snapshot is unavailable // or bead lookup fails, it falls back to the legacy deterministic name. -func configuredACPSessionNames(snapshot *sessionBeadSnapshot, cityName, sessionTemplate string, agents []config.Agent) []string { +func configuredACPSessionNames(snapshot *sessionBeadSnapshot, cityName, sessionTemplate string, cfg *config.City, agents []config.Agent) []string { names := make([]string, 0, len(agents)) for _, a := range agents { - if a.Session != "acp" { + if agentSessionCreateTransport(cfg, a) != "acp" { continue } sessName := agent.SessionNameFor(cityName, a.QualifiedName(), sessionTemplate) @@ -230,6 +251,171 @@ func configuredACPSessionNames(snapshot *sessionBeadSnapshot, cityName, sessionT return names } +func needsACPProviderWrapper(snapshot *sessionBeadSnapshot, cityName string, cfg *config.City) bool { + return requiresACPProviderWrapper(snapshot, cityName, cfg) || (cfg != nil && hasACPProviderTargets(cfg)) +} + +func requiresACPProviderWrapper(snapshot *sessionBeadSnapshot, cityName string, cfg *config.City) bool { + return len(configuredACPRouteNames(snapshot, cityName, cfg)) > 0 +} + +func hasACPProviderTargets(cfg *config.City) bool { + if cfg == nil { + return false + } + candidates := map[string]bool{} + add := func(name string) { + name = strings.TrimSpace(name) + if name != "" { + candidates[name] = true + } + } + add(cfg.Workspace.Provider) + for name := range cfg.Providers { + add(name) + } + for _, agentCfg := range cfg.Agents { + add(agentCfg.Provider) + } + for name := range candidates { + if providerSessionCreateUsesACP(cfg, name) { + return true + } + } + return false +} + +func resolveProviderForACPTransport(cfg *config.City, providerName string) *config.ResolvedProvider { + if cfg == nil || strings.TrimSpace(providerName) == "" { + return nil + } + resolved, err := config.ResolveProvider( + &config.Agent{Provider: providerName}, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return nil + } + return resolved +} + +func providerSessionCreateUsesACP(cfg *config.City, providerName string) bool { + resolved := resolveProviderForACPTransport(cfg, providerName) + return resolved != nil && resolved.ProviderSessionCreateTransport() == "acp" +} + +func providerLegacyDefaultsToACP(cfg *config.City, providerName string) bool { + resolved := resolveProviderForACPTransport(cfg, providerName) + return resolved != nil && resolved.ProviderSessionCreateTransport() == "acp" +} + +func observedACPSessionNames(snapshot *sessionBeadSnapshot, cfg *config.City) []string { + if snapshot == nil { + return nil + } + names := make([]string, 0, len(snapshot.open)) + seen := make(map[string]bool, len(snapshot.open)) + for _, bead := range snapshot.Open() { + if !beadUsesACPTransport(bead, cfg) { + continue + } + sessionName := strings.TrimSpace(bead.Metadata["session_name"]) + if sessionName == "" || seen[sessionName] { + continue + } + seen[sessionName] = true + names = append(names, sessionName) + } + return names +} + +func beadUsesACPTransport(bead beads.Bead, cfg *config.City) bool { + transport := strings.TrimSpace(bead.Metadata["transport"]) + if transport != "" { + return transport == "acp" + } + providerName := strings.TrimSpace(bead.Metadata["provider"]) + if providerName == "acp" { + return true + } + if strings.TrimSpace(bead.Metadata[session.MCPIdentityMetadataKey]) != "" || + strings.TrimSpace(bead.Metadata[session.MCPServersSnapshotMetadataKey]) != "" { + return true + } + templateName := strings.TrimSpace(bead.Metadata["template"]) + if cfg != nil { + if agentCfg, ok := resolveAgentIdentity(cfg, templateName, currentRigContext(cfg)); ok { + if strings.TrimSpace(agentCfg.Session) != "" && agentSessionCreateTransport(cfg, agentCfg) == "acp" { + return true + } + if strings.TrimSpace(bead.Metadata["command"]) == "" && + strings.TrimSpace(bead.Metadata["pending_create_claim"]) == "true" && + agentSessionCreateTransport(cfg, agentCfg) == "acp" { + return true + } + if providerName == "" { + providerName = strings.TrimSpace(agentCfg.Provider) + } + } + if providerName == "" { + providerName = templateName + } + resolved := resolveProviderForACPTransport(cfg, providerName) + if resolved != nil { + acpCommand := strings.TrimSpace(resolved.ACPCommandString()) + defaultCommand := strings.TrimSpace(resolved.CommandString()) + storedCommand := strings.TrimSpace(bead.Metadata["command"]) + if acpCommand != "" && acpCommand != defaultCommand && + (storedCommand == acpCommand || strings.HasPrefix(storedCommand, acpCommand+" ")) { + return true + } + } + if strings.TrimSpace(bead.Metadata["command"]) == "" && + strings.TrimSpace(bead.Metadata["pending_create_claim"]) == "true" { + return providerLegacyDefaultsToACP(cfg, providerName) + } + } + return false +} + +func configuredACPRouteNames(snapshot *sessionBeadSnapshot, cityName string, cfg *config.City) []string { + names := observedACPSessionNames(snapshot, cfg) + seen := make(map[string]bool, len(names)) + for _, name := range names { + seen[name] = true + } + if cfg == nil { + return names + } + for _, name := range configuredACPSessionNames(snapshot, cityName, cfg.Workspace.SessionTemplate, cfg, cfg.Agents) { + if name == "" || seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + for _, named := range cfg.NamedSessions { + agentCfg := config.FindAgent(cfg, named.TemplateQualifiedName()) + if agentCfg == nil || agentSessionCreateTransport(cfg, *agentCfg) != "acp" { + continue + } + sessionName := config.NamedSessionRuntimeName(cityName, cfg.Workspace, named.QualifiedName()) + if snapshot != nil { + if snapName := snapshot.FindSessionNameByNamedIdentity(named.QualifiedName()); snapName != "" { + sessionName = snapName + } + } + if sessionName == "" || seen[sessionName] { + continue + } + seen[sessionName] = true + names = append(names, sessionName) + } + return names +} + // displayProviderName returns a human-readable provider name for logging. func displayProviderName(name string) string { if name == "" { diff --git a/cmd/gc/providers_test.go b/cmd/gc/providers_test.go index 6dcf126d4..e4dd0d17f 100644 --- a/cmd/gc/providers_test.go +++ b/cmd/gc/providers_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "strings" @@ -9,6 +10,7 @@ import ( "github.com/gastownhall/gascity/internal/agent" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" ) func TestTmuxConfigFromSessionDefaultsSocketToCityName(t *testing.T) { @@ -225,7 +227,7 @@ func TestConfiguredACPSessionNames_UsesProvidedSnapshot(t *testing.T) { {Name: "mayor"}, } - got := configuredACPSessionNames(snapshot, "city", "", agents) + got := configuredACPSessionNames(snapshot, "city", "", nil, agents) want := []string{ "custom-reviewer", agent.SessionNameFor("city", "witness", ""), @@ -240,6 +242,154 @@ func TestConfiguredACPSessionNames_UsesProvidedSnapshot(t *testing.T) { } } +func TestSessionBeadSnapshotFindSessionNameByNamedIdentity(t *testing.T) { + snapshot := newSessionBeadSnapshot([]beads.Bead{{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "reviewer-template", + "configured_named_identity": "reviewer", + "session_name": "custom-reviewer", + }, + }}) + + if got := snapshot.FindSessionNameByNamedIdentity("reviewer"); got != "custom-reviewer" { + t.Fatalf("FindSessionNameByNamedIdentity(reviewer) = %q, want %q", got, "custom-reviewer") + } +} + +func TestConfiguredACPRouteNames_IncludeNamedSessionRuntimeNames(t *testing.T) { + cfg := &config.City{ + Workspace: config.Workspace{ + Name: "test-city", + }, + Agents: []config.Agent{ + {Name: "reviewer-template", Session: "acp"}, + {Name: "mayor"}, + }, + NamedSessions: []config.NamedSession{ + {Name: "reviewer", Template: "reviewer-template"}, + }, + } + + t.Run("deterministic fallback", func(t *testing.T) { + got := configuredACPRouteNames(nil, "test-city", cfg) + want := []string{ + agent.SessionNameFor("test-city", "reviewer-template", ""), + config.NamedSessionRuntimeName("test-city", cfg.Workspace, "reviewer"), + } + if len(got) != len(want) { + t.Fatalf("configuredACPRouteNames len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("configuredACPRouteNames[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("snapshot override", func(t *testing.T) { + snapshot := newSessionBeadSnapshot([]beads.Bead{{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "reviewer-template", + "configured_named_identity": "reviewer", + "session_name": "custom-reviewer", + }, + }}) + + got := configuredACPRouteNames(snapshot, "test-city", cfg) + want := []string{"custom-reviewer"} + if len(got) != len(want) { + t.Fatalf("configuredACPRouteNames len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("configuredACPRouteNames[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) +} + +func TestConfiguredACPRouteNames_IncludeObservedACPProviderSessions(t *testing.T) { + snapshot := newSessionBeadSnapshot([]beads.Bead{{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "opencode", + "provider": "opencode", + "transport": "acp", + "session_name": "provider-session", + }, + }}) + + got := configuredACPRouteNames(snapshot, "test-city", nil) + if len(got) != 1 || got[0] != "provider-session" { + t.Fatalf("configuredACPRouteNames() = %v, want [provider-session]", got) + } +} + +func TestConfiguredACPRouteNames_IncludeLegacyObservedACPProviderSessionsWithoutTransportMetadata(t *testing.T) { + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + snapshot := newSessionBeadSnapshot([]beads.Bead{{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "opencode", + "provider": "opencode", + "command": "/bin/echo acp", + "session_name": "provider-session", + }, + }}) + + got := configuredACPRouteNames(snapshot, "test-city", cfg) + if len(got) != 1 || got[0] != "provider-session" { + t.Fatalf("configuredACPRouteNames() = %v, want [provider-session]", got) + } +} + +func TestConfiguredACPRouteNames_IncludeLegacyObservedCustomACPProviderSessionsWithoutTransportMetadata(t *testing.T) { + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + snapshot := newSessionBeadSnapshot([]beads.Bead{{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "custom-acp", + "provider": "custom-acp", + "command": "/bin/echo acp", + "session_name": "provider-session", + }, + }}) + + got := configuredACPRouteNames(snapshot, "test-city", cfg) + if len(got) != 1 || got[0] != "provider-session" { + t.Fatalf("configuredACPRouteNames() = %v, want [provider-session]", got) + } +} + func TestNewSessionProvider_PreregistersACPBeadAndLegacyNames(t *testing.T) { t.Setenv("GC_BEADS", "file") t.Setenv("GC_SESSION", "fake") @@ -281,7 +431,246 @@ func TestNewSessionProvider_PreregistersACPBeadAndLegacyNames(t *testing.T) { } } -func TestLoadProviderSessionSnapshotSkipsStoreWithoutACPAgents(t *testing.T) { +func TestNewSessionProvider_PreregistersACPNamedSessionRuntimeName(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writeACPNamedSessionRouteCityTOML(t, cityDir, "test-city") + + sp := newSessionProvider() + namedRuntime := config.NamedSessionRuntimeName("test-city", config.Workspace{}, "reviewer") + if err := sp.Attach(namedRuntime); err == nil || !strings.Contains(err.Error(), "ACP transport") { + t.Fatalf("Attach(%q) error = %v, want ACP transport error", namedRuntime, err) + } +} + +func TestNewSessionProvider_PreregistersProviderDefaultACPNamedSessionRuntimeName(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writeProviderDefaultACPNamedSessionRouteCityTOML(t, cityDir, "test-city") + + sp := newSessionProvider() + namedRuntime := config.NamedSessionRuntimeName("test-city", config.Workspace{}, "reviewer") + if err := sp.Attach(namedRuntime); err == nil || !strings.Contains(err.Error(), "ACP transport") { + t.Fatalf("Attach(%q) error = %v, want ACP transport error", namedRuntime, err) + } +} + +func TestNewSessionProviderWrapsACPProvidersWithoutACPAgents(t *testing.T) { + ctx := sessionProviderContextForCity(&config.City{ + Workspace: config.Workspace{ + Name: "test-city", + Provider: "opencode", + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + }, t.TempDir(), "fake") + + sp := newSessionProviderFromContext(ctx, nil) + if _, ok := sp.(interface{ RouteACP(string) }); !ok { + t.Fatalf("provider = %T, want ACP-routing wrapper", sp) + } +} + +func TestNewSessionProviderWrapsCustomACPProvidersWithExplicitACPConfig(t *testing.T) { + ctx := sessionProviderContextForCity(&config.City{ + Workspace: config.Workspace{ + Name: "test-city", + Provider: "custom-acp", + }, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + }, t.TempDir(), "fake") + + sp := newSessionProviderFromContext(ctx, nil) + if _, ok := sp.(interface{ RouteACP(string) }); !ok { + t.Fatalf("provider = %T, want ACP-routing wrapper", sp) + } +} + +func TestNewSessionProviderIgnoresACPInitFailureForUnusedACPProviders(t *testing.T) { + 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("acp unavailable") + } + return oldBuild(name, sc, cityName, cityPath) + } + + ctx := sessionProviderContextForCity(&config.City{ + Workspace: config.Workspace{ + Name: "test-city", + Provider: "opencode", + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + }, t.TempDir(), "fake") + + sp, err := newSessionProviderFromContextWithError(ctx, nil) + if err != nil { + t.Fatalf("newSessionProviderFromContextWithError: %v", err) + } + if _, ok := sp.(interface{ RouteACP(string) }); ok { + t.Fatalf("provider = %T, want plain provider fallback when ACP is unavailable", sp) + } +} + +func TestNewSessionProviderRequiresACPInitForACPAgents(t *testing.T) { + 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("acp unavailable") + } + return oldBuild(name, sc, cityName, cityPath) + } + + ctx := sessionProviderContextForCity(&config.City{ + Workspace: config.Workspace{ + Name: "test-city", + }, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode", Session: "acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + }, t.TempDir(), "fake") + + if _, err := newSessionProviderFromContextWithError(ctx, nil); err == nil { + t.Fatal("newSessionProviderFromContextWithError() error = nil, want ACP init failure") + } +} + +func TestNewSessionProviderRequiresACPInitForImplicitACPTemplates(t *testing.T) { + 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("acp unavailable") + } + return oldBuild(name, sc, cityName, cityPath) + } + + ctx := sessionProviderContextForCity(&config.City{ + Workspace: config.Workspace{ + Name: "test-city", + }, + Agents: []config.Agent{ + {Name: "worker", Provider: "custom-acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: boolPtr(true), + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + }, t.TempDir(), "fake") + + if _, err := newSessionProviderFromContextWithError(ctx, nil); err == nil { + t.Fatal("newSessionProviderFromContextWithError() error = nil, want ACP init failure") + } +} + +func TestNewSessionProviderRoutesObservedACPProviderSessionsWithoutACPAgents(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writeACPProviderRouteCityTOML(t, cityDir, "test-city") + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "opencode", + "provider": "opencode", + "transport": "acp", + "session_name": "provider-session", + }, + }); err != nil { + t.Fatalf("Create(provider session bead): %v", err) + } + + sp := newSessionProvider() + if err := sp.Attach("provider-session"); err == nil || !strings.Contains(err.Error(), "ACP transport") { + t.Fatalf("Attach(provider-session) error = %v, want ACP transport error", err) + } +} + +func TestNewSessionProviderRoutesLegacyObservedACPProviderSessionsWithoutTransportMetadata(t *testing.T) { + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + writeACPProviderRouteCityTOML(t, cityDir, "test-city") + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "opencode", + "provider": "opencode", + "command": "/bin/echo acp", + "session_name": "provider-session", + }, + }); err != nil { + t.Fatalf("Create(provider session bead): %v", err) + } + + sp := newSessionProvider() + if err := sp.Attach("provider-session"); err == nil || !strings.Contains(err.Error(), "ACP transport") { + t.Fatalf("Attach(provider-session) error = %v, want ACP transport error", err) + } +} + +func TestLoadProviderSessionSnapshotLoadsStoreWithoutACPAgents(t *testing.T) { oldOpen := openSessionProviderStore t.Cleanup(func() { openSessionProviderStore = oldOpen }) @@ -298,11 +687,11 @@ func TestLoadProviderSessionSnapshotSkipsStoreWithoutACPAgents(t *testing.T) { {Name: "mayor"}, }, }) - if snapshot != nil { - t.Fatalf("loadProviderSessionSnapshot() = %#v, want nil", snapshot) + if snapshot == nil { + t.Fatal("loadProviderSessionSnapshot() = nil, want empty snapshot") } - if calls != 0 { - t.Fatalf("openSessionProviderStore called %d times, want 0", calls) + if calls != 1 { + t.Fatalf("openSessionProviderStore called %d times, want 1", calls) } } @@ -379,3 +768,92 @@ start_command = "echo" t.Fatalf("WriteFile(city.toml): %v", err) } } + +func writeACPNamedSessionRouteCityTOML(t *testing.T, dir, cityName string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + data := []byte(`[workspace] +name = "` + cityName + `" + +[beads] +provider = "file" + +[[agent]] +name = "reviewer-template" +provider = "claude" +start_command = "echo" +session = "acp" + +[[named_session]] +name = "reviewer" +template = "reviewer-template" + +[[agent]] +name = "mayor" +provider = "claude" +start_command = "echo" +`) + if err := os.WriteFile(filepath.Join(dir, "city.toml"), data, 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } +} + +func writeProviderDefaultACPNamedSessionRouteCityTOML(t *testing.T, dir, cityName string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + data := []byte(`[workspace] +name = "` + cityName + `" + +[beads] +provider = "file" + +[[agent]] +name = "reviewer" +provider = "custom-acp" + +[[named_session]] +template = "reviewer" + +[providers.custom-acp] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + if err := os.WriteFile(filepath.Join(dir, "city.toml"), data, 0o644); err != nil { + t.Fatalf("WriteFile(city.toml): %v", err) + } +} + +func writeACPProviderRouteCityTOML(t *testing.T, dir, cityName string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".gc"), 0o755); err != nil { + t.Fatalf("MkdirAll(.gc): %v", err) + } + data := []byte(`[workspace] +name = "` + cityName + `" + +[beads] +provider = "file" + +[[agent]] +name = "mayor" +provider = "codex" +start_command = "echo" + +[providers.opencode] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + 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/rig_anywhere_test.go b/cmd/gc/rig_anywhere_test.go index f74eef136..b10cc149a 100644 --- a/cmd/gc/rig_anywhere_test.go +++ b/cmd/gc/rig_anywhere_test.go @@ -1309,6 +1309,237 @@ func TestRigAnywhere_ResolveRigToContext(t *testing.T) { } }) + // Regression: gc stop (and other commands that scan registered rig + // bindings) must not abort when a sibling city's directory has been + // deleted out from under the registry. Resolution still succeeds on + // the healthy target and registeredRigBindingsByPath reports the + // stale entry as structured data so only explicit-rig-resolution + // callers (not opportunistic probes) need to warn about it. + t.Run("stale_sibling_directory_is_skipped_with_warning", func(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + goodCity := setupCity(t, "stale-sibling-good") + rigDir := filepath.Join(t.TempDir(), "stale-sibling-rig") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + registerRigBindingForResolution(t, gcHome, goodCity, "stale-sibling-good", "stale-sibling-rig", rigDir) + + // Register a second city, then delete its directory to simulate + // "gc stop ~/my-city" after the sibling city was rm -rf'd. + staleDir := filepath.Join(t.TempDir(), "vanished-city") + if err := os.MkdirAll(filepath.Join(staleDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staleDir, "city.toml"), + []byte("[workspace]\nname = \"stale-sibling-bad\"\n\n[[agent]]\nname = \"mayor\"\n"), 0o644); err != nil { + t.Fatal(err) + } + registerCityForRigResolution(t, gcHome, staleDir, "stale-sibling-bad") + if err := os.RemoveAll(staleDir); err != nil { + t.Fatal(err) + } + + ctx, err := resolveContextFromPath(rigDir) + if err != nil { + t.Fatalf("resolveContextFromPath error: %v (want success with stale sibling skipped)", err) + } + assertSameTestPath(t, ctx.CityPath, goodCity) + if ctx.RigName != "stale-sibling-rig" { + t.Errorf("RigName = %q, want %q", ctx.RigName, "stale-sibling-rig") + } + + // registeredRigBindingsByPath returns stale entries as structured + // data; callers decide whether to emit a user-facing warning. This + // asserts the diagnostic is available without coupling the test to + // a particular stderr routing scheme. + _, stale, err := registeredRigBindingsByPath(rigDir, true) + if err != nil { + t.Fatalf("registeredRigBindingsByPath error: %v", err) + } + if len(stale) == 0 { + t.Fatal("expected a stale-registered-city entry, got none") + } + var found bool + for _, s := range stale { + if strings.Contains(s.Label, "stale-sibling-bad") { + found = true + break + } + } + if !found { + t.Errorf("stale = %+v, want an entry mentioning stale-sibling-bad", stale) + } + + // The helper renders the structured list to a command's stderr. + var warnings bytes.Buffer + emitStaleRegisteredCityWarnings(&warnings, stale) + warn := warnings.String() + if !strings.Contains(warn, "stale-sibling-bad") { + t.Errorf("warning = %q, want it to mention the stale city name", warn) + } + if !strings.Contains(warn, "city.toml missing") { + t.Errorf("warning = %q, want it to explain city.toml is missing", warn) + } + if !strings.Contains(warn, filepath.Join(staleDir, "city.toml")) { + t.Errorf("warning = %q, want it to mention the missing city.toml path", warn) + } + }) + + // Regression: the stale-entry check handles ENOENT from the config-load + // path itself. A registered city whose directory exists but whose city.toml + // is missing must still be skipped rather than abort the resolver. + t.Run("stale_sibling_city_toml_missing_hits_load_path", func(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + goodCity := setupCity(t, "load-path-good") + rigDir := filepath.Join(t.TempDir(), "load-path-rig") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + registerRigBindingForResolution(t, gcHome, goodCity, "load-path-good", "load-path-rig", rigDir) + + // Register a second city whose directory exists but whose + // city.toml was never created. The load path (not a Stat + // pre-check) has to handle ENOENT here. + emptyDir := filepath.Join(t.TempDir(), "empty-city") + if err := os.MkdirAll(filepath.Join(emptyDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + registerCityForRigResolution(t, gcHome, emptyDir, "empty-city") + + ctx, err := resolveContextFromPath(rigDir) + if err != nil { + t.Fatalf("resolveContextFromPath error: %v (want success with ENOENT on load path)", err) + } + assertSameTestPath(t, ctx.CityPath, goodCity) + + _, stale, err := registeredRigBindingsByPath(rigDir, true) + if err != nil { + t.Fatalf("registeredRigBindingsByPath error: %v", err) + } + var found bool + for _, s := range stale { + if strings.Contains(s.Label, "empty-city") { + found = true + break + } + } + if !found { + t.Errorf("stale = %+v, want an entry mentioning empty-city", stale) + } + }) + + t.Run("registered_city_with_missing_include_fails_closed_not_stale", func(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + goodCity := setupCity(t, "missing-include-good") + rigDir := filepath.Join(t.TempDir(), "missing-include-rig") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + registerRigBindingForResolution(t, gcHome, goodCity, "missing-include-good", "missing-include-rig", rigDir) + + brokenCity := setupCity(t, "missing-include-broken") + if err := os.WriteFile(filepath.Join(brokenCity, "city.toml"), []byte(` +include = ["missing.toml"] + +[workspace] +name = "missing-include-broken" + +[[agent]] +name = "missing-include-agent" +`), 0o644); err != nil { + t.Fatal(err) + } + registerCityForRigResolution(t, gcHome, brokenCity, "missing-include-broken") + + _, stale, err := registeredRigBindingsByPath(rigDir, true) + if err == nil { + t.Fatal("registeredRigBindingsByPath should fail closed on missing include") + } + if !strings.Contains(err.Error(), "loading registered city rig bindings") { + t.Fatalf("error = %q, want registered binding load error", err) + } + if !strings.Contains(err.Error(), "missing.toml") { + t.Fatalf("error = %q, want missing include path", err) + } + for _, s := range stale { + if strings.Contains(s.Label, "missing-include-broken") { + t.Fatalf("stale = %+v, missing include must not be reported as stale", stale) + } + } + }) + + t.Run("path_lookup_error_preserves_stale_entries", func(t *testing.T) { + gcHome := t.TempDir() + t.Setenv("GC_HOME", gcHome) + + goodCity := setupCity(t, "path-stale-error-good") + rigDir := filepath.Join(t.TempDir(), "path-stale-error-rig") + if err := os.MkdirAll(rigDir, 0o755); err != nil { + t.Fatal(err) + } + registerRigBindingForResolution(t, gcHome, goodCity, "path-stale-error-good", "path-stale-error-rig", rigDir) + + staleDir := filepath.Join(t.TempDir(), "path-stale-error-vanished") + if err := os.MkdirAll(filepath.Join(staleDir, ".gc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staleDir, "city.toml"), []byte("[workspace]\nname = \"path-stale-error-vanished\"\n"), 0o644); err != nil { + t.Fatal(err) + } + registerCityForRigResolution(t, gcHome, staleDir, "path-stale-error-vanished") + if err := os.RemoveAll(staleDir); err != nil { + t.Fatal(err) + } + + badCity := setupCity(t, "path-stale-error-bad") + if err := os.WriteFile(config.SiteBindingPath(badCity), []byte("[[rig]\nname = \"broken\"\n"), 0o644); err != nil { + t.Fatal(err) + } + registerCityForRigResolution(t, gcHome, badCity, "path-stale-error-bad") + + _, stale, err := registeredRigBindingsByPath(rigDir, true) + if err == nil { + t.Fatal("registeredRigBindingsByPath should fail closed on the malformed site binding") + } + var found bool + for _, s := range stale { + if strings.Contains(s.Label, "path-stale-error-vanished") { + found = true + break + } + } + if !found { + t.Fatalf("stale = %+v, want vanished city preserved on error", stale) + } + }) + + // Regression: emitStaleRegisteredCityWarnings dedupes by Label so a + // command that invokes registeredRigBindings twice (e.g. + // resolveRigToContext tries both name and path lookups) emits each + // stale entry at most once. + t.Run("emit_stale_warnings_deduplicates_by_label", func(t *testing.T) { + stale := []staleRegisteredCity{ + {Label: "city-a", Path: "/tmp/a"}, + {Label: "city-b", Path: "/tmp/b"}, + {Label: "city-a", Path: "/tmp/a"}, // duplicate from a second scan + } + var out bytes.Buffer + emitStaleRegisteredCityWarnings(&out, stale) + got := out.String() + if strings.Count(got, "city-a") != 1 { + t.Errorf("city-a should appear once, got %d in %q", strings.Count(got, "city-a"), got) + } + if strings.Count(got, "city-b") != 1 { + t.Errorf("city-b should appear once, got %d in %q", strings.Count(got, "city-b"), got) + } + }) + t.Run("rig_ambiguous_no_default_helpful_error", func(t *testing.T) { gcHome := t.TempDir() t.Setenv("GC_HOME", gcHome) diff --git a/cmd/gc/session_bead_snapshot.go b/cmd/gc/session_bead_snapshot.go index 1e787572d..5f0746120 100644 --- a/cmd/gc/session_bead_snapshot.go +++ b/cmd/gc/session_bead_snapshot.go @@ -1,33 +1,53 @@ package main import ( + "fmt" "strings" "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" ) -// sessionBeadSnapshot caches open session-bead state for a single reconcile -// cycle so build/sync/reconcile can reuse one store scan. +// sessionBeadSnapshot caches session-bead state for a single reconcile cycle. +// Open-session lookups stay open-only; closed records are retained by ID for +// lifecycle guards such as stale wait epoch cancellation. type sessionBeadSnapshot struct { open []beads.Bead + recordByID map[string]beads.Bead sessionNameByAgentName map[string]string sessionNameByTemplateHint map[string]string } func loadSessionBeadSnapshot(store beads.Store) (*sessionBeadSnapshot, error) { - open, err := loadSessionBeads(store) + if store == nil { + return newSessionBeadSnapshot(nil), nil + } + all, err := store.List(beads.ListQuery{ + Label: sessionBeadLabel, + IncludeClosed: true, + }) if err != nil { - return nil, err + return nil, fmt.Errorf("listing session beads: %w", err) + } + sessions := make([]beads.Bead, 0, len(all)) + for _, bead := range all { + if sessionpkg.IsSessionBeadOrRepairable(bead) { + sessions = append(sessions, bead) + } } - return newSessionBeadSnapshot(open), nil + return newSessionBeadSnapshot(sessions), nil } -func newSessionBeadSnapshot(open []beads.Bead) *sessionBeadSnapshot { - filtered := make([]beads.Bead, 0, len(open)) +func newSessionBeadSnapshot(beadsIn []beads.Bead) *sessionBeadSnapshot { + filtered := make([]beads.Bead, 0, len(beadsIn)) + byID := make(map[string]beads.Bead) sessionNameByAgentName := make(map[string]string) sessionNameByTemplateHint := make(map[string]string) - for _, b := range open { + for _, b := range beadsIn { + if b.ID != "" { + byID[b.ID] = b + } if b.Status == "closed" { continue } @@ -69,6 +89,7 @@ func newSessionBeadSnapshot(open []beads.Bead) *sessionBeadSnapshot { return &sessionBeadSnapshot{ open: filtered, + recordByID: byID, sessionNameByAgentName: sessionNameByAgentName, sessionNameByTemplateHint: sessionNameByTemplateHint, } @@ -81,6 +102,7 @@ func (s *sessionBeadSnapshot) replaceOpen(open []beads.Bead) { rebuilt := newSessionBeadSnapshot(open) if rebuilt == nil { s.open = nil + s.recordByID = nil s.sessionNameByAgentName = nil s.sessionNameByTemplateHint = nil return @@ -115,3 +137,41 @@ func (s *sessionBeadSnapshot) FindSessionNameByTemplate(template string) string } return s.sessionNameByTemplateHint[template] } + +func (s *sessionBeadSnapshot) FindByID(id string) (beads.Bead, bool) { + if s == nil || strings.TrimSpace(id) == "" { + return beads.Bead{}, false + } + for _, bead := range s.open { + if bead.ID == id { + return bead, true + } + } + return beads.Bead{}, false +} + +func (s *sessionBeadSnapshot) findByIDIncludingClosed(id string) (beads.Bead, bool) { + if s == nil || strings.TrimSpace(id) == "" { + return beads.Bead{}, false + } + bead, ok := s.recordByID[id] + if !ok { + return beads.Bead{}, false + } + return bead, true +} + +func (s *sessionBeadSnapshot) FindSessionNameByNamedIdentity(identity string) string { + if s == nil || strings.TrimSpace(identity) == "" { + return "" + } + for _, bead := range s.open { + if strings.TrimSpace(bead.Metadata["configured_named_identity"]) != identity { + continue + } + if sessionName := strings.TrimSpace(bead.Metadata["session_name"]); sessionName != "" { + return sessionName + } + } + return "" +} diff --git a/cmd/gc/session_beads.go b/cmd/gc/session_beads.go index fc7380610..ad909e5f3 100644 --- a/cmd/gc/session_beads.go +++ b/cmd/gc/session_beads.go @@ -55,6 +55,28 @@ func snapshotOrLoadSessionBeads(store beads.Store, sessionBeads *sessionBeadSnap return loadSessionBeads(store) } +func syncSessionCachedState(sessionName string, existing beads.Bead, exists bool, sp runtime.Provider) string { + if exists { + switch session.State(strings.TrimSpace(existing.Metadata["state"])) { + case "", session.StateActive, session.StateAwake: + return string(session.StateActive) + case session.StateCreating: + return string(session.StateCreating) + case session.StateAsleep, session.StateSuspended, session.StateDraining, session.StateArchived, session.StateQuarantined: + return strings.TrimSpace(existing.Metadata["state"]) + default: + if state := strings.TrimSpace(existing.Metadata["state"]); state != "" { + return state + } + return string(session.StateActive) + } + } + if sp != nil && strings.TrimSpace(sessionName) != "" && sp.IsRunning(sessionName) { + return string(session.StateActive) + } + return "stopped" +} + func stampResolvedProviderSessionMetadata(meta map[string]string, resolved *config.ResolvedProvider) { if meta == nil || resolved == nil { return @@ -216,6 +238,7 @@ func reopenClosedConfiguredNamedSessionBead( func retireDuplicateConfiguredNamedSessionBeads( store beads.Store, + rigStores map[string]beads.Store, sp runtime.Provider, cfg *config.City, cityName string, @@ -278,7 +301,7 @@ func retireDuplicateConfiguredNamedSessionBeads( fmt.Fprintf(stderr, "session beads: archiving duplicate named session %s: %v\n", b.ID, err) //nolint:errcheck continue } - reassignWorkAssignedToRetiredSessionBead(store, b.ID, openBeads[winner].ID, stderr) + reassignWorkAssignedToRetiredSessionBead(store, rigStores, b, openBeads[winner].ID, stderr) reassignStateAssignedToRetiredSessionBead(store, b.ID, openBeads[winner].ID, now, stderr) if b.Metadata == nil { b.Metadata = make(map[string]string, len(batch)) @@ -327,6 +350,7 @@ func namedSessionBeadWinsCanonicalRepair(candidate, incumbent beads.Bead, canoni func retireRemovedConfiguredNamedSessionBead( store beads.Store, + rigStores map[string]beads.Store, sp runtime.Provider, b beads.Bead, now time.Time, @@ -354,7 +378,7 @@ func retireRemovedConfiguredNamedSessionBead( fmt.Fprintf(stderr, "session beads: archiving removed named session %s: %v\n", b.ID, err) //nolint:errcheck return false } - unclaimWorkAssignedToRetiredSessionBead(store, b.ID, retiredSessionFallbackRoute(b), stderr) + unclaimWorkAssignedToRetiredSessionBead(store, rigStores, b, retiredSessionFallbackRoute(b), stderr) cancelStateAssignedToRetiredSessionBead(store, b.ID, now, stderr) return true } @@ -366,54 +390,139 @@ func retiredSessionFallbackRoute(b beads.Bead) string { return strings.TrimSpace(b.Metadata["agent_name"]) } -func unclaimWorkAssignedToRetiredSessionBead(store beads.Store, sessionID, fallbackRoute string, stderr io.Writer) { - if store == nil || strings.TrimSpace(sessionID) == "" { +func sessionAssignmentIdentifiers(sessionBead beads.Bead) []string { + raw := []string{ + strings.TrimSpace(sessionBead.ID), + strings.TrimSpace(sessionBead.Metadata["session_name"]), + strings.TrimSpace(sessionBead.Metadata[namedSessionIdentityMetadata]), + } + seen := make(map[string]struct{}, len(raw)) + identifiers := make([]string, 0, len(raw)) + for _, id := range raw { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + identifiers = append(identifiers, id) + } + return identifiers +} + +func workAssignmentStores(store beads.Store, rigStores map[string]beads.Store) []beads.Store { + if store == nil { + return nil + } + stores := []beads.Store{store} + if len(rigStores) == 0 { + return stores + } + names := make([]string, 0, len(rigStores)) + for name, rs := range rigStores { + if rs == nil { + continue + } + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + stores = append(stores, rigStores[name]) + } + return stores +} + +func unclaimWorkAssignedToRetiredSessionBead( + store beads.Store, + rigStores map[string]beads.Store, + sessionBead beads.Bead, + fallbackRoute string, + stderr io.Writer, +) { + if store == nil || strings.TrimSpace(sessionBead.ID) == "" { return } if stderr == nil { stderr = io.Discard } empty := "" - for _, status := range []string{"open", "in_progress"} { - work, err := store.List(beads.ListQuery{Assignee: sessionID, Status: status, Live: true}) - if err != nil { - fmt.Fprintf(stderr, "session beads: listing work assigned to retired session %s: %v\n", sessionID, err) //nolint:errcheck - continue - } - for _, item := range work { - if session.IsSessionBeadOrRepairable(item) { - continue - } - update := beads.UpdateOpts{Assignee: &empty} - if fallbackRoute != "" && strings.TrimSpace(item.Metadata["gc.routed_to"]) == "" { - update.Metadata = map[string]string{"gc.routed_to": fallbackRoute} - } - if err := store.Update(item.ID, update); err != nil { - fmt.Fprintf(stderr, "session beads: unclaiming work %s assigned to retired session %s: %v\n", item.ID, sessionID, err) //nolint:errcheck + open := "open" + identifiers := sessionAssignmentIdentifiers(sessionBead) + seen := make(map[string]struct{}) + for storeIndex, ownerStore := range workAssignmentStores(store, rigStores) { + for _, status := range []string{"open", "in_progress"} { + for _, assignee := range identifiers { + work, err := ownerStore.List(beads.ListQuery{Assignee: assignee, Status: status, Live: true}) + if err != nil { + fmt.Fprintf(stderr, "session beads: listing work assigned to retired session %s via %q: %v\n", sessionBead.ID, assignee, err) //nolint:errcheck + continue + } + for _, item := range work { + if session.IsSessionBeadOrRepairable(item) { + continue + } + key := strconv.Itoa(storeIndex) + "\x00" + item.ID + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + update := beads.UpdateOpts{Assignee: &empty} + // Clearing assignee on an in_progress bead leaves it invisible to + // the work_query: Tier 1 needs an assignee match, Tiers 2/3 only + // match "ready" status. Reset to "open" so a fresh worker can + // re-claim via the routed queue (gc.routed_to + --unassigned). + if item.Status == "in_progress" { + update.Status = &open + } + if fallbackRoute != "" && strings.TrimSpace(item.Metadata["gc.routed_to"]) == "" { + update.Metadata = map[string]string{"gc.routed_to": fallbackRoute} + } + if err := ownerStore.Update(item.ID, update); err != nil { + fmt.Fprintf(stderr, "session beads: unclaiming work %s assigned to retired session %s: %v\n", item.ID, sessionBead.ID, err) //nolint:errcheck + } + } } } } } -func reassignWorkAssignedToRetiredSessionBead(store beads.Store, oldSessionID, newSessionID string, stderr io.Writer) { - if store == nil || strings.TrimSpace(oldSessionID) == "" || strings.TrimSpace(newSessionID) == "" { +func reassignWorkAssignedToRetiredSessionBead( + store beads.Store, + rigStores map[string]beads.Store, + retiredSession beads.Bead, + newSessionID string, + stderr io.Writer, +) { + if store == nil || strings.TrimSpace(retiredSession.ID) == "" || strings.TrimSpace(newSessionID) == "" { return } if stderr == nil { stderr = io.Discard } - for _, status := range []string{"open", "in_progress"} { - work, err := store.List(beads.ListQuery{Assignee: oldSessionID, Status: status, Live: true}) - if err != nil { - fmt.Fprintf(stderr, "session beads: listing work assigned to retired session %s: %v\n", oldSessionID, err) //nolint:errcheck - continue - } - for _, item := range work { - if session.IsSessionBeadOrRepairable(item) { - continue - } - if err := store.Update(item.ID, beads.UpdateOpts{Assignee: &newSessionID}); err != nil { - fmt.Fprintf(stderr, "session beads: reassigning work %s from retired session %s to %s: %v\n", item.ID, oldSessionID, newSessionID, err) //nolint:errcheck + identifiers := sessionAssignmentIdentifiers(retiredSession) + seen := make(map[string]struct{}) + for storeIndex, ownerStore := range workAssignmentStores(store, rigStores) { + for _, status := range []string{"open", "in_progress"} { + for _, assignee := range identifiers { + work, err := ownerStore.List(beads.ListQuery{Assignee: assignee, Status: status, Live: true}) + if err != nil { + fmt.Fprintf(stderr, "session beads: listing work assigned to retired session %s via %q: %v\n", retiredSession.ID, assignee, err) //nolint:errcheck + continue + } + for _, item := range work { + if session.IsSessionBeadOrRepairable(item) { + continue + } + key := strconv.Itoa(storeIndex) + "\x00" + item.ID + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if err := ownerStore.Update(item.ID, beads.UpdateOpts{Assignee: &newSessionID}); err != nil { + fmt.Fprintf(stderr, "session beads: reassigning work %s from retired session %s to %s: %v\n", item.ID, retiredSession.ID, newSessionID, err) //nolint:errcheck + } + } } } } @@ -477,8 +586,8 @@ func syncSessionBeads( stderr io.Writer, skipClose bool, ) map[string]string { - openIndex, _ := syncSessionBeadsWithSnapshot( - cityPath, store, desiredState, sp, configuredNames, cfg, clk, stderr, skipClose, nil, + openIndex, _ := syncSessionBeadsWithSnapshotAndRigStores( + cityPath, store, nil, desiredState, sp, configuredNames, cfg, clk, stderr, skipClose, nil, ) return openIndex } @@ -494,6 +603,24 @@ func syncSessionBeadsWithSnapshot( stderr io.Writer, skipClose bool, sessionBeads *sessionBeadSnapshot, +) (map[string]string, *sessionBeadSnapshot) { + return syncSessionBeadsWithSnapshotAndRigStores( + cityPath, store, nil, desiredState, sp, configuredNames, cfg, clk, stderr, skipClose, sessionBeads, + ) +} + +func syncSessionBeadsWithSnapshotAndRigStores( + cityPath string, + store beads.Store, + rigStores map[string]beads.Store, + desiredState map[string]TemplateParams, + sp runtime.Provider, + configuredNames map[string]bool, + cfg *config.City, + clk clock.Clock, + stderr io.Writer, + skipClose bool, + sessionBeads *sessionBeadSnapshot, ) (map[string]string, *sessionBeadSnapshot) { if store == nil { return nil, nil @@ -562,7 +689,7 @@ func syncSessionBeadsWithSnapshot( } canonical, ok := bySessionName[sn] if ok && canonical.ID != b.ID { - if closeBead(store, b.ID, "duplicate", clk.Now().UTC(), stderr) { + if closeSessionBeadIfUnassigned(store, rigStores, b, "duplicate", clk.Now().UTC(), stderr) { openBeads[i].Status = "closed" } } @@ -587,7 +714,7 @@ func syncSessionBeadsWithSnapshot( if strings.TrimSpace(b.Metadata["session_name"]) == spec.SessionName { continue } - if closeBead(store, b.ID, "reconfigured", now, stderr) { + if closeSessionBeadIfUnassigned(store, rigStores, b, "reconfigured", now, stderr) { if sn := strings.TrimSpace(b.Metadata["session_name"]); sn != "" { running, _ := workerSessionTargetRunningWithConfig("", store, sp, cfg, sn) if running { @@ -601,7 +728,7 @@ func syncSessionBeadsWithSnapshot( } } openBeads = retireDuplicateConfiguredNamedSessionBeads( - store, sp, cfg, cityName, openBeads, bySessionName, indexBySessionName, now, stderr, + store, rigStores, sp, cfg, cityName, openBeads, bySessionName, indexBySessionName, now, stderr, ) } @@ -612,13 +739,6 @@ func syncSessionBeadsWithSnapshot( isConfiguredNamed := strings.TrimSpace(tp.ConfiguredNamedIdentity) != "" origin := templateParamsSessionOrigin(tp) - // Use provider for liveness check (includes zombie detection). - state := "stopped" - alive, _ := workerSessionTargetAliveWithConfig(store, sp, cfg, sn, tp.Hints.ProcessNames) - if alive { - state = "active" - } - agentName := tp.TemplateName // For pool instances, use the qualified instance name as the agent_name. if slot := resolvePoolSlot(tp.InstanceName, tp.TemplateName); slot > 0 { @@ -629,10 +749,12 @@ func syncSessionBeadsWithSnapshot( isManagedPool := origin == "ephemeral" b, exists := bySessionName[sn] + state := syncSessionCachedState(sn, b, exists, sp) if !exists && isConfiguredNamed { if reopened, ok := reopenClosedConfiguredNamedSessionBead(cityPath, store, cfg, cityName, tp.ConfiguredNamedIdentity, sn, state, now, nil, stderr); ok { b = reopened exists = true + state = syncSessionCachedState(sn, b, exists, sp) bySessionName[sn] = reopened openBeads = append(openBeads, reopened) indexBySessionName[sn] = len(openBeads) - 1 @@ -998,7 +1120,7 @@ func syncSessionBeadsWithSnapshot( if isNamedSessionBead(b) { identity := namedSessionIdentity(b) if identity != "" && (cfg == nil || config.FindNamedSession(cfg, identity) == nil) { - if retireRemovedConfiguredNamedSessionBead(store, sp, b, now, stderr) { + if retireRemovedConfiguredNamedSessionBead(store, rigStores, sp, b, now, stderr) { if idx, ok := indexBySessionName[sn]; ok { openBeads[idx].Status = "open" if openBeads[idx].Metadata == nil { @@ -1022,7 +1144,7 @@ func syncSessionBeadsWithSnapshot( continue } if configuredNames[sn] { - if closeBead(store, b.ID, "suspended", now, stderr) { + if closeSessionBeadIfUnassigned(store, rigStores, b, "suspended", now, stderr) { if idx, ok := indexBySessionName[sn]; ok { openBeads[idx].Status = "closed" } @@ -1036,7 +1158,7 @@ func syncSessionBeadsWithSnapshot( } } } - if closeBead(store, b.ID, "orphaned", now, stderr) { + if closeSessionBeadIfUnassigned(store, rigStores, b, "orphaned", now, stderr) { if idx, ok := indexBySessionName[sn]; ok { openBeads[idx].Status = "closed" } @@ -1205,13 +1327,20 @@ func setMetaBatch(store beads.Store, id string, batch map[string]string, stderr return nil } -// reapStaleSessionBeads cross-references open session beads against live -// tmux sessions. If a bead claims a session_name but no matching tmux -// session exists, and the bead has been in that state past the startup -// grace period, the bead is closed. +// reapStaleSessionBeads closes session beads that are stuck in the creating +// state past the startup grace period — sessions whose tmux process never +// completed startup, so they are guaranteed not to hold work claims (claim +// is the first thing a worker does after startup). // -// This prevents infinite retry loops where a dead tmux session's bead -// blocks name availability for new sessions (see #742). +// Sessions that completed startup (state=active, awake, etc.) are NEVER reaped +// here even if their tmux session has died: they may hold in_progress claims, +// and reaping would orphan that work without a way for the reconciler to +// recover via the assignee-keyed wake path. The session lifecycle reconciler +// is responsible for restarting completed-but-dead session beads so the +// original assignee resumes its work. +// +// This prevents infinite retry loops for stuck-creating sessions while +// preserving claim continuity across tmux death+restart for active ones. // // Returns the number of beads reaped. func reapStaleSessionBeads( @@ -1236,8 +1365,14 @@ func reapStaleSessionBeads( if sn == "" { continue } - // Don't reap beads whose tmux session hasn't been started yet. - if b.Metadata["state"] == "creating" || strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true" { + // Only reap beads stuck in the creating state after their one-shot + // pending_create_claim has already been cleared. The pending create + // claim is authoritative across the lifecycle model: it keeps an + // in-flight or partially-healed start eligible for retry even when + // the bead's cached state has already moved past creating. + state := strings.TrimSpace(b.Metadata["state"]) + pendingCreate := strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true" + if state != "creating" || pendingCreate { continue } // Don't reap beads with an active drain — the drainTracker is @@ -1246,6 +1381,13 @@ func reapStaleSessionBeads( if dt != nil && dt.get(b.ID) != nil { continue } + // Configured named-session beads are controller-owned identities. + // They may legitimately be stopped between supervisor restarts; the + // named-session reconciler is responsible for preserving, waking, or + // retiring them after desired state is rebuilt from config. + if isNamedSessionBead(b) { + continue + } // Session is alive — nothing to reap. if sp.IsRunning(sn) { continue @@ -1256,7 +1398,7 @@ func reapStaleSessionBeads( continue } if closeBead(store, b.ID, "stale-session", now.UTC(), stderr) { - fmt.Fprintf(stderr, "WARN: reconciler: reaped stale session bead %s — tmux session %q not found\n", b.ID, sn) //nolint:errcheck + fmt.Fprintf(stderr, "WARN: reconciler: reaped stuck-creating session bead %s — tmux session %q not found\n", b.ID, sn) //nolint:errcheck reaped++ } } @@ -1270,6 +1412,12 @@ func reapStaleSessionBeads( // Follows the commit-signal pattern: metadata is written first, and Close // is only called if all writes succeed. If any write fails, the bead stays // open so the next tick retries the entire sequence. +// +// Ownership checks live in closeSessionBeadIfUnassigned, which can see the +// full cross-store, multi-identifier assignment picture. closeBead remains +// the low-level metadata+close helper used once a caller has already decided +// the bead is safe to retire (or the close reason is unrelated to work +// ownership, such as failed-create cleanup). func closeBead(store beads.Store, id, reason string, now time.Time, stderr io.Writer) bool { if setMetaBatch(store, id, session.ClosePatch(now, reason), stderr) != nil { return false diff --git a/cmd/gc/session_beads_test.go b/cmd/gc/session_beads_test.go index fc4a849b6..4097e77f7 100644 --- a/cmd/gc/session_beads_test.go +++ b/cmd/gc/session_beads_test.go @@ -27,6 +27,11 @@ type countingMetadataStore struct { batchCalls int } +type sessionGetSpyStore struct { + beads.Store + getIDs []string +} + type failingCloseStore struct { *beads.MemStore } @@ -49,6 +54,11 @@ func (s *countingMetadataStore) SetMetadataBatch(id string, kvs map[string]strin return s.MemStore.SetMetadataBatch(id, kvs) } +func (s *sessionGetSpyStore) Get(id string) (beads.Bead, error) { + s.getIDs = append(s.getIDs, id) + return s.Store.Get(id) +} + // allConfiguredDS builds configuredNames from a desiredState map. func allConfiguredDS(ds map[string]TemplateParams) map[string]bool { m := make(map[string]bool, len(ds)) @@ -110,6 +120,52 @@ func TestSyncSessionBeads_CreatesNewBeads(t *testing.T) { } } +func TestSyncSessionBeads_ExistingDesiredUsesSnapshotStateWithoutWorkerLookup(t *testing.T) { + base := beads.NewMemStore() + store := &sessionGetSpyStore{Store: base} + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 22, 0, 0, 0, time.UTC)} + sessionBead, err := store.Create(beads.Bead{ + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "control-dispatcher", + "agent_name": "control-dispatcher", + "template": "control-dispatcher", + "command": "claude", + "state": string(session.StateActive), + "generation": "1", + "continuation_epoch": "1", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + ds := map[string]TemplateParams{ + "control-dispatcher": {TemplateName: "control-dispatcher", Command: "claude"}, + } + sp := runtime.NewFake() + + var stderr bytes.Buffer + syncSessionBeadsWithSnapshot( + "", store, ds, sp, allConfiguredDS(ds), nil, clk, &stderr, false, + newSessionBeadSnapshot([]beads.Bead{sessionBead}), + ) + if stderr.Len() > 0 { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } + for _, id := range store.getIDs { + if id == "control-dispatcher" { + t.Fatalf("sync looked up configured session name as bead id; getIDs=%v", store.getIDs) + } + } + for _, call := range sp.Calls { + switch call.Method { + case "IsRunning", "ProcessAlive", "IsAttached", "GetLastActivity", "GetMeta": + t.Fatalf("sync should trust the session snapshot for existing desired sessions, saw provider call %#v", call) + } + } +} + func TestSyncSessionBeads_CreatesImportedConfiguredNamedSessionBeads(t *testing.T) { cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "repo") @@ -1170,7 +1226,7 @@ func TestRetireDuplicateConfiguredNamedSessionBeads_DoesNotStopWinnerSharingSess indexBySessionName := map[string]int{sessionName: 1} retired := retireDuplicateConfiguredNamedSessionBeads( - store, sp, cfg, "test-city", openBeads, bySessionName, indexBySessionName, time.Now().UTC(), io.Discard, + store, nil, sp, cfg, "test-city", openBeads, bySessionName, indexBySessionName, time.Now().UTC(), io.Discard, ) if !sp.IsRunning(sessionName) { @@ -2852,14 +2908,14 @@ func TestReapStaleSessionBeads(t *testing.T) { wantOpen int // expected number of open beads after reap }{ { - name: "dead_session_reaped", + name: "stuck_creating_reaped", beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "worker-1", - "state": "active", + "state": "creating", }, }}, running: nil, @@ -2868,7 +2924,45 @@ func TestReapStaleSessionBeads(t *testing.T) { wantOpen: 0, }, { - name: "live_session_kept", + name: "pending_create_creating_kept", + beads: []beads.Bead{{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "creating", + "pending_create_claim": "true", + }, + }}, + running: nil, + clock: clockPastGrace, + wantReaped: 0, + wantOpen: 1, + }, + { + name: "pending_create_active_kept", + beads: []beads.Bead{{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "active", + "pending_create_claim": "true", + }, + }}, + running: nil, + clock: clockPastGrace, + wantReaped: 0, + wantOpen: 1, + }, + { + name: "active_session_dead_tmux_kept", + // Bug 1 fix: a session past creating must NEVER be reaped here, + // even when its tmux is dead. It may hold in_progress claims; the + // session lifecycle reconciler is responsible for restarting the + // same bead so the original assignee resumes the work. beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, @@ -2878,20 +2972,20 @@ func TestReapStaleSessionBeads(t *testing.T) { "state": "active", }, }}, - running: []string{"worker-1"}, + running: nil, clock: clockPastGrace, wantReaped: 0, wantOpen: 1, }, { - name: "creating_state_skipped", + name: "awake_session_dead_tmux_kept", beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "worker-1", - "state": "creating", + "state": "awake", }, }}, running: nil, @@ -2900,31 +2994,30 @@ func TestReapStaleSessionBeads(t *testing.T) { wantOpen: 1, }, { - name: "pending_create_skipped", + name: "live_session_kept", beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ - "session_name": "worker-1", - "state": "stopped", - "pending_create_claim": "true", + "session_name": "worker-1", + "state": "creating", }, }}, - running: nil, + running: []string{"worker-1"}, clock: clockPastGrace, wantReaped: 0, wantOpen: 1, }, { - name: "grace_period_honored", + name: "creating_within_grace_kept", beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "worker-1", - "state": "active", + "state": "creating", }, }}, running: nil, @@ -2939,7 +3032,7 @@ func TestReapStaleSessionBeads(t *testing.T) { Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ - "state": "active", + "state": "creating", }, }}, running: nil, @@ -2948,14 +3041,14 @@ func TestReapStaleSessionBeads(t *testing.T) { wantOpen: 1, }, { - name: "draining_session_skipped", + name: "draining_creating_session_skipped", beads: []beads.Bead{{ Title: "worker", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "worker-1", - "state": "active", + "state": "creating", }, }}, running: nil, @@ -2965,7 +3058,49 @@ func TestReapStaleSessionBeads(t *testing.T) { wantOpen: 1, }, { - name: "multiple_stale_reaped", + name: "configured_named_session_skipped", + beads: []beads.Bead{{ + Title: "gascity/control-dispatcher", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "gascity--control-dispatcher", + "template": "gascity/control-dispatcher", + "state": "active", + "configured_named_session": "true", + "configured_named_identity": "gascity/control-dispatcher", + "configured_named_mode": "always", + }, + }}, + running: nil, + clock: clockPastGrace, + wantReaped: 0, + wantOpen: 1, + }, + { + name: "configured_named_creating_session_skipped", + beads: []beads.Bead{{ + Title: "gascity/control-dispatcher", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "gascity--control-dispatcher", + "template": "gascity/control-dispatcher", + "state": "creating", + "configured_named_session": "true", + "configured_named_identity": "gascity/control-dispatcher", + "configured_named_mode": "always", + }, + }}, + running: nil, + clock: clockPastGrace, + wantReaped: 0, + wantOpen: 1, + }, + { + name: "only_creating_among_dead_reaped", + // Mixed pool: alpha is stuck creating, beta is past creating + // (active) with dead tmux, gamma is alive. Only alpha is reaped. beads: []beads.Bead{ { Title: "session alpha", @@ -2973,7 +3108,7 @@ func TestReapStaleSessionBeads(t *testing.T) { Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "session-alpha", - "state": "active", + "state": "creating", }, }, { @@ -2982,7 +3117,7 @@ func TestReapStaleSessionBeads(t *testing.T) { Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "session-beta", - "state": "awake", + "state": "active", }, }, { @@ -2991,14 +3126,14 @@ func TestReapStaleSessionBeads(t *testing.T) { Labels: []string{sessionBeadLabel}, Metadata: map[string]string{ "session_name": "session-gamma", - "state": "active", + "state": "creating", }, }, }, - running: []string{"session-gamma"}, // only gamma is alive + running: []string{"session-gamma"}, // gamma's tmux is alive clock: clockPastGrace, - wantReaped: 2, - wantOpen: 1, + wantReaped: 1, // only alpha (creating + dead tmux) is reaped + wantOpen: 2, // beta (active dead tmux), gamma (creating live tmux) }, } @@ -3078,8 +3213,8 @@ func TestReapStaleSessionBeads(t *testing.T) { b.ID, b.Metadata["close_reason"], "stale-session") } } - if !strings.Contains(stderr.String(), "WARN: reconciler: reaped stale session bead") { - t.Error("expected WARN log line for reaped bead") + if !strings.Contains(stderr.String(), "WARN: reconciler: reaped stuck-creating session bead") { + t.Errorf("expected WARN log line for reaped bead; stderr=%q", stderr.String()) } } }) @@ -3100,3 +3235,357 @@ func TestReapStaleSessionBeads_NilStoreAndProvider(t *testing.T) { t.Errorf("nil store: got %d, want 0", got) } } + +// TestUnclaimResetsInProgressStatus verifies the Bug 2 fix: unclaiming a +// retired session's in_progress work must reset status to "open" so a fresh +// worker can re-claim via the routed queue (Tier 3: gc.routed_to + +// --unassigned). Leaving status=in_progress with no assignee makes the bead +// invisible to every work_query tier. +func TestUnclaimResetsInProgressStatus(t *testing.T) { + store := beads.NewMemStore() + + // Session bead the work was assigned to (mimics a retired worker). + sessionBead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + // In-progress work assigned to that session, with gc.routed_to set so + // Tier 3 of the work_query can re-route it after unclaim. + work, err := store.Create(beads.Bead{ + Title: "finalize", + Status: "in_progress", + Assignee: sessionBead.ID, + Metadata: map[string]string{"gc.routed_to": "myrig/codex-max"}, + }) + 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) + } + + // Open work also assigned: should also be cleared but stays "open". + openWork, err := store.Create(beads.Bead{ + Title: "queued", + Status: "open", + Assignee: sessionBead.ID, + Metadata: map[string]string{"gc.routed_to": "myrig/codex-max"}, + }) + if err != nil { + t.Fatalf("create open work: %v", err) + } + + var stderr bytes.Buffer + unclaimWorkAssignedToRetiredSessionBead(store, nil, sessionBead, "myrig/codex-max", &stderr) + + gotInProgress, err := store.Get(work.ID) + if err != nil { + t.Fatalf("get in_progress work: %v", err) + } + if gotInProgress.Assignee != "" { + t.Errorf("in_progress assignee = %q, want empty", gotInProgress.Assignee) + } + if gotInProgress.Status != "open" { + t.Errorf("in_progress status = %q, want %q (status must reset so the bead is visible to the work_query)", gotInProgress.Status, "open") + } + + gotOpen, err := store.Get(openWork.ID) + if err != nil { + t.Fatalf("get open work: %v", err) + } + if gotOpen.Assignee != "" { + t.Errorf("open assignee = %q, want empty", gotOpen.Assignee) + } + if gotOpen.Status != "open" { + t.Errorf("open status = %q, want %q (already open, must stay open)", gotOpen.Status, "open") + } +} + +// closeBead is the low-level metadata+close helper. Ownership checks live in +// closeSessionBeadIfUnassigned, which has the full multi-store, multi-identifier +// view of assigned work. closeBead itself must stay dumb so it doesn't +// introduce a narrower contract than the live-query helper. +func TestCloseBeadDoesNotDuplicateOwnershipGuard(t *testing.T) { + store := beads.NewMemStore() + + sessionBead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + if _, err := store.Create(beads.Bead{ + Title: "finalize", + Status: "in_progress", + Assignee: sessionBead.ID, + }); err != nil { + t.Fatalf("create assigned work: %v", err) + } + + var stderr bytes.Buffer + now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + if !closeBead(store, sessionBead.ID, "stale-session", now, &stderr) { + t.Fatalf("closeBead returned false; want true because ownership gating belongs to closeSessionBeadIfUnassigned: stderr=%s", stderr.String()) + } + got, err := store.Get(sessionBead.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got.Status != "closed" { + t.Fatalf("session bead status = %q, want closed", got.Status) + } +} + +func TestCloseSessionBeadIfUnassignedRefusesWhenRigStoreWorkAssignedBySessionName(t *testing.T) { + store := beads.NewMemStore() + rigStore := beads.NewMemStore() + + sessionBead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + if _, err := rigStore.Create(beads.Bead{ + Title: "rig work", + Status: "open", + Assignee: "worker-1", + }); err != nil { + t.Fatalf("create rig work: %v", err) + } + + var stderr bytes.Buffer + now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + if closeSessionBeadIfUnassigned(store, map[string]beads.Store{"demo": rigStore}, sessionBead, "stale-session", now, &stderr) { + t.Fatal("closeSessionBeadIfUnassigned returned true; want false because rig-store work is still assigned by session_name") + } + got, err := store.Get(sessionBead.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got.Status == "closed" { + t.Fatalf("session bead status = closed; want still open after helper refused close") + } +} + +func TestUnclaimWorkAssignedToRetiredSessionBeadClearsRigStoreSessionIdentifiers(t *testing.T) { + store := beads.NewMemStore() + rigStore := beads.NewMemStore() + + sessionBead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "retired", + namedSessionIdentityMetadata: "frontend/worker", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + + bySessionName, err := rigStore.Create(beads.Bead{ + Title: "session-name work", + Status: "open", + Assignee: "worker-1", + }) + if err != nil { + t.Fatalf("create session-name work: %v", err) + } + + byIdentity, err := rigStore.Create(beads.Bead{ + Title: "named-identity work", + Status: "open", + Assignee: "frontend/worker", + }) + if err != nil { + t.Fatalf("create named-identity work: %v", err) + } + inProgress := "in_progress" + if err := rigStore.Update(byIdentity.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("mark named-identity work in_progress: %v", err) + } + + var stderr bytes.Buffer + unclaimWorkAssignedToRetiredSessionBead( + store, + map[string]beads.Store{"frontend": rigStore}, + sessionBead, + "frontend/codex-max", + &stderr, + ) + + gotBySessionName, err := rigStore.Get(bySessionName.ID) + if err != nil { + t.Fatalf("get session-name work: %v", err) + } + if gotBySessionName.Assignee != "" { + t.Fatalf("session-name assignee = %q, want empty", gotBySessionName.Assignee) + } + if gotBySessionName.Status != "open" { + t.Fatalf("session-name status = %q, want open", gotBySessionName.Status) + } + + gotByIdentity, err := rigStore.Get(byIdentity.ID) + if err != nil { + t.Fatalf("get named-identity work: %v", err) + } + if gotByIdentity.Assignee != "" { + t.Fatalf("named-identity assignee = %q, want empty", gotByIdentity.Assignee) + } + if gotByIdentity.Status != "open" { + t.Fatalf("named-identity status = %q, want open after unclaim", gotByIdentity.Status) + } + if gotByIdentity.Metadata["gc.routed_to"] != "frontend/codex-max" { + t.Fatalf("named-identity gc.routed_to = %q, want frontend/codex-max", gotByIdentity.Metadata["gc.routed_to"]) + } +} + +func TestReassignWorkAssignedToRetiredSessionBeadReassignsRigStoreSessionIdentifiers(t *testing.T) { + store := beads.NewMemStore() + rigStore := beads.NewMemStore() + + retired, err := store.Create(beads.Bead{ + Title: "old worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "retired", + namedSessionIdentityMetadata: "frontend/worker", + }, + }) + if err != nil { + t.Fatalf("create retired session bead: %v", err) + } + successor, err := store.Create(beads.Bead{ + Title: "new worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-2", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create successor session bead: %v", err) + } + + bySessionName, err := rigStore.Create(beads.Bead{ + Title: "session-name work", + Status: "open", + Assignee: "worker-1", + }) + if err != nil { + t.Fatalf("create session-name work: %v", err) + } + byIdentity, err := rigStore.Create(beads.Bead{ + Title: "named-identity work", + Status: "open", + Assignee: "frontend/worker", + }) + if err != nil { + t.Fatalf("create named-identity work: %v", err) + } + + var stderr bytes.Buffer + reassignWorkAssignedToRetiredSessionBead( + store, + map[string]beads.Store{"frontend": rigStore}, + retired, + successor.ID, + &stderr, + ) + + gotBySessionName, err := rigStore.Get(bySessionName.ID) + if err != nil { + t.Fatalf("get session-name work: %v", err) + } + if gotBySessionName.Assignee != successor.ID { + t.Fatalf("session-name assignee = %q, want %q", gotBySessionName.Assignee, successor.ID) + } + + gotByIdentity, err := rigStore.Get(byIdentity.ID) + if err != nil { + t.Fatalf("get named-identity work: %v", err) + } + if gotByIdentity.Assignee != successor.ID { + t.Fatalf("named-identity assignee = %q, want %q", gotByIdentity.Assignee, successor.ID) + } +} + +func TestSyncSessionBeadsWithSnapshotAndRigStoresLeavesOrphanedSessionBeadOpenWhenRigStoreWorkAssigned(t *testing.T) { + store := beads.NewMemStore() + rigStore := beads.NewMemStore() + sp := runtime.NewFake() + clk := &clock.Fake{} + + sessionBead, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "worker-1", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + if _, err := rigStore.Create(beads.Bead{ + Title: "rig work", + Status: "open", + Assignee: "worker-1", + }); err != nil { + t.Fatalf("create rig work: %v", err) + } + + var stderr bytes.Buffer + syncSessionBeadsWithSnapshotAndRigStores( + "", + store, + map[string]beads.Store{"frontend": rigStore}, + nil, + sp, + map[string]bool{}, + nil, + clk, + &stderr, + false, + nil, + ) + + got, err := store.Get(sessionBead.ID) + if err != nil { + t.Fatalf("get session bead: %v", err) + } + if got.Status != "open" { + t.Fatalf("session bead status = %q, want open because rig-store work still owns it", got.Status) + } +} diff --git a/cmd/gc/session_lifecycle_parallel.go b/cmd/gc/session_lifecycle_parallel.go index 923d999d2..9f20bf817 100644 --- a/cmd/gc/session_lifecycle_parallel.go +++ b/cmd/gc/session_lifecycle_parallel.go @@ -10,6 +10,7 @@ import ( "runtime/debug" "strconv" "strings" + "sync" "time" "github.com/gastownhall/gascity/internal/beads" @@ -19,6 +20,7 @@ import ( "github.com/gastownhall/gascity/internal/runtime" sessionpkg "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/shellquote" + "github.com/gastownhall/gascity/internal/worker" ) const ( @@ -66,6 +68,96 @@ type startResult struct { rollbackPending bool } +type startExecutionOptions struct { + async bool + asyncFollowUp func() + asyncLimiter chan struct{} + asyncTracker *asyncStartTracker +} + +type startExecutionOption func(*startExecutionOptions) + +func withAsyncStartExecution() startExecutionOption { + return func(opts *startExecutionOptions) { + opts.async = true + } +} + +func withAsyncStartFollowUp(fn func()) startExecutionOption { + return func(opts *startExecutionOptions) { + opts.asyncFollowUp = fn + } +} + +func withAsyncStartLimiter(limiter chan struct{}) startExecutionOption { + return func(opts *startExecutionOptions) { + opts.asyncLimiter = limiter + } +} + +func withAsyncStartTracker(tracker *asyncStartTracker) startExecutionOption { + return func(opts *startExecutionOptions) { + opts.asyncTracker = tracker + } +} + +type asyncStartTracker struct { + mu sync.Mutex + wg sync.WaitGroup + stopping bool +} + +func (t *asyncStartTracker) start() (func(), bool) { + if t == nil { + return func() {}, true + } + t.mu.Lock() + defer t.mu.Unlock() + if t.stopping { + return nil, false + } + t.wg.Add(1) + return t.wg.Done, true +} + +func (t *asyncStartTracker) wait(timeout time.Duration) bool { + if t == nil { + return true + } + t.mu.Lock() + t.stopping = true + t.mu.Unlock() + if timeout < 0 { + t.wg.Wait() + return true + } + done := make(chan struct{}) + go func() { + t.wg.Wait() + close(done) + }() + if timeout == 0 { + select { + case <-done: + return true + default: + return false + } + } + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +type asyncPreparedStart struct { + item preparedStart + release func() + done func() +} + type stopTarget struct { sessionID string name string @@ -185,6 +277,7 @@ func dependencyTemplateAlive( sp runtime.Provider, cityName string, store beads.Store, + clk clock.Clock, ) bool { if cfg == nil || template == "" { return false @@ -198,17 +291,62 @@ func dependencyTemplateAlive( if tp.TemplateName != template { continue } + if dependencySessionStartInFlight(store, name, cfg, clk) { + continue + } if alive, err := workerSessionTargetAliveWithConfig(store, sp, cfg, name, tp.Hints.ProcessNames); err == nil && alive { return true } } } sessionName := lookupSessionNameOrLegacy(store, cityName, template, cfg.Workspace.SessionTemplate) + if dependencySessionStartInFlight(store, sessionName, cfg, clk) { + return false + } depTP := desiredState[sessionName] alive, err := workerSessionTargetAliveWithConfig(store, sp, cfg, sessionName, depTP.Hints.ProcessNames) return err == nil && alive } +func dependencySessionStartInFlight(store beads.Store, sessionName string, cfg *config.City, clk clock.Clock) bool { + sessionName = strings.TrimSpace(sessionName) + if store == nil || sessionName == "" { + return false + } + matches, err := store.ListByMetadata(map[string]string{"session_name": sessionName}, 0) + if err != nil { + return true + } + for _, session := range matches { + if session.Status == "closed" { + continue + } + if !isSessionBead(session) { + continue + } + var startupTimeout time.Duration + if cfg != nil { + startupTimeout = cfg.Session.StartupTimeoutDuration() + } + if pendingCreateStartInFlight(session, clk, startupTimeout) { + return true + } + } + return false +} + +func isSessionBead(session beads.Bead) bool { + if session.Type == sessionBeadType { + return true + } + for _, label := range session.Labels { + if label == sessionBeadLabel { + return true + } + } + return false +} + func candidateWaveOrder( candidates []startCandidate, cfg *config.City, @@ -216,6 +354,7 @@ func candidateWaveOrder( sp runtime.Provider, cityName string, store beads.Store, + clk clock.Clock, ) (map[int]int, bool) { if len(candidates) == 0 { return map[int]int{}, true @@ -240,7 +379,7 @@ func candidateWaveOrder( continue } for _, dep := range cfgAgent.DependsOn { - if dependencyTemplateAlive(dep, cfg, desiredState, sp, cityName, store) { + if dependencyTemplateAlive(dep, cfg, desiredState, sp, cityName, store, clk) { continue } if candidateTemplates[dep] { @@ -268,14 +407,62 @@ func prepareStartCandidate( cfg *config.City, store beads.Store, clk clock.Clock, +) (*preparedStart, error) { + return prepareStartCandidateForCity(candidate, "", "", cfg, nil, store, clk, io.Discard) +} + +func prepareStartCandidateForCity( + candidate startCandidate, + cityPath string, + cityName string, + cfg *config.City, + sp runtime.Provider, + store beads.Store, + clk clock.Clock, + stderr io.Writer, ) (*preparedStart, error) { session := candidate.session if _, _, err := preWakeCommit(session, store, clk); err != nil { return nil, err } + candidate = refreshConfiguredNamedStartCandidate(candidate, cityPath, cityName, cfg, sp, store, clk, stderr) return buildPreparedStart(candidate, cfg, store) } +func refreshConfiguredNamedStartCandidate( + candidate startCandidate, + cityPath string, + cityName string, + cfg *config.City, + sp runtime.Provider, + store beads.Store, + clk clock.Clock, + stderr io.Writer, +) startCandidate { + if candidate.session == nil || cfg == nil || store == nil || !isNamedSessionBead(*candidate.session) { + return candidate + } + if cityName == "" { + cityName = config.EffectiveCityName(cfg, "") + } + snapshot, err := loadSessionBeadSnapshot(store) + if err != nil { + if stderr != nil { + fmt.Fprintf(stderr, "session reconciler: refreshing named session start %s: listing sessions: %v\n", candidate.name(), err) //nolint:errcheck + } + return candidate + } + refreshed, err := resolvePreservedConfiguredNamedSessionTemplate(cityPath, cityName, cfg, sp, store, snapshot.Open(), *candidate.session, clk, stderr) + if err != nil { + if stderr != nil { + fmt.Fprintf(stderr, "session reconciler: refreshing named session start %s: %v\n", candidate.name(), err) //nolint:errcheck + } + return candidate + } + candidate.tp = refreshed + return candidate +} + func buildPreparedStart( candidate startCandidate, cfg *config.City, @@ -444,6 +631,17 @@ func executePreparedStartWave( prepared []preparedStart, sp runtime.Provider, store beads.Store, + startupTimeout time.Duration, +) []startResult { + return executePreparedStartWaveForCity(ctx, prepared, "", sp, store, nil, startupTimeout, 1) +} + +func executePreparedStartWaveForCity( + ctx context.Context, + prepared []preparedStart, + cityPath string, + sp runtime.Provider, + store beads.Store, cfg *config.City, startupTimeout time.Duration, maxParallel int, @@ -451,7 +649,6 @@ func executePreparedStartWave( if len(prepared) == 0 { return nil } - cityPath := "" if maxParallel <= 0 { maxParallel = 1 } @@ -462,98 +659,11 @@ func executePreparedStartWave( i, item := i, item sem <- struct{}{} go func() { - started := time.Now() defer func() { - if recovered := recover(); recovered != nil { - stack := debug.Stack() - results[i] = startResult{ - prepared: item, - err: fmt.Errorf("panic during start: %v\n%s", recovered, stack), - outcome: "panic_recovered", - started: started, - finished: time.Now(), - } - } <-sem done <- i }() - startCtx := ctx - cancel := func() {} - if startupTimeout > 0 { - startCtx, cancel = context.WithTimeout(ctx, startupTimeout) - } - defer cancel() - _, err := startPreparedStartCandidate(startCtx, item, cityPath, store, sp, cfg) - if err != nil && errors.Is(err, sessionpkg.ErrStateSync) { - running, runningErr := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, item.candidate.name()) - if runningErr == nil && running { - err = nil - } - } - // Stale session key detection: if the session was started - // with a resume flag but dies immediately, the session key - // likely references a conversation that no longer exists - // (e.g., "No conversation found"). Report as a failure so - // recordWakeFailure clears the key for the next attempt. - if err == nil && item.candidate.session != nil && item.candidate.session.Metadata["session_key"] != "" { - time.Sleep(staleKeyDetectDelay) - running := false - if store == nil || strings.TrimSpace(item.candidate.session.ID) == "" { - running = sp != nil && sp.IsRunning(item.candidate.name()) - } else { - running, err = workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, item.candidate.name()) - } - if err != nil || !running { - err = fmt.Errorf("session %q died during startup", item.candidate.name()) - } - } - finished := time.Now() - rollbackPending := err != nil && shouldRollbackPendingCreate(item.candidate.session) - if err != nil && rollbackPending && runningSessionMatchesPendingCreate(item.candidate.session, item.candidate.name(), sp) { - results[i] = startResult{ - prepared: item, - err: nil, - outcome: "start_error_converged", - started: started, - finished: finished, - rollbackPending: false, - } - return - } - var outcome string - switch { - case err == nil: - outcome = "success" - case startCtx.Err() == context.DeadlineExceeded: - outcome = "deadline_exceeded" - case startCtx.Err() == context.Canceled: - outcome = "canceled" - case errors.Is(err, runtime.ErrSessionInitializing): - outcome = "session_initializing" - err = nil - case errors.Is(err, runtime.ErrSessionExists): - running, runningErr := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, item.candidate.name()) - switch { - case runningErr != nil || !running: - outcome = "provider_error" - case rollbackPending && runningSessionMatchesPendingCreate(item.candidate.session, item.candidate.name(), sp): - outcome = "session_exists_converged" - err = nil - rollbackPending = false - default: - outcome = "session_exists" - } - default: - outcome = "provider_error" - } - results[i] = startResult{ - prepared: item, - err: err, - outcome: outcome, - started: started, - finished: finished, - rollbackPending: rollbackPending, - } + results[i] = runPreparedStartCandidate(ctx, item, cityPath, sp, store, cfg, startupTimeout) }() } for range prepared { @@ -562,6 +672,359 @@ func executePreparedStartWave( return results } +func runPreparedStartCandidate( + ctx context.Context, + item preparedStart, + cityPath string, + sp runtime.Provider, + store beads.Store, + cfg *config.City, + startupTimeout time.Duration, +) (result startResult) { + started := time.Now() + result = startResult{ + prepared: item, + started: started, + finished: started, + } + defer func() { + if recovered := recover(); recovered != nil { + stack := debug.Stack() + result = startResult{ + prepared: item, + err: fmt.Errorf("panic during start: %v\n%s", recovered, stack), + outcome: "panic_recovered", + started: started, + finished: time.Now(), + } + } + }() + + startCtx := ctx + cancel := func() {} + if startupTimeout > 0 { + startCtx, cancel = context.WithTimeout(ctx, startupTimeout) + } + defer cancel() + _, err := startPreparedStartCandidate(startCtx, item, cityPath, store, sp, cfg) + if err != nil && errors.Is(err, sessionpkg.ErrStateSync) { + running, runningErr := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, item.candidate.name()) + if runningErr == nil && running { + err = nil + } + } + // Stale session key detection: if the session was started + // with a resume flag but dies immediately, the session key + // likely references a conversation that no longer exists + // (e.g., "No conversation found"). Report as a failure so + // recordWakeFailure clears the key for the next attempt. + if err == nil && item.candidate.session != nil && item.candidate.session.Metadata["session_key"] != "" { + time.Sleep(staleKeyDetectDelay) + running := false + alive := false + if store == nil || strings.TrimSpace(item.candidate.session.ID) == "" { + running = sp != nil && sp.IsRunning(item.candidate.name()) + alive = running && (sp == nil || sp.ProcessAlive(item.candidate.name(), item.cfg.ProcessNames)) + } else { + var obs worker.LiveObservation + obs, err = workerObserveSessionTargetWithRuntimeHintsWithConfig(cityPath, store, sp, cfg, item.candidate.name(), item.cfg.ProcessNames) + running = obs.Running + alive = obs.Alive + } + if err != nil || !running || !alive { + err = fmt.Errorf("session %q died during startup", item.candidate.name()) + } + } + finished := time.Now() + rollbackPending := err != nil && shouldRollbackPendingCreate(item.candidate.session) + if err != nil && rollbackPending && runningSessionMatchesPendingCreate(item.candidate.session, item.candidate.name(), sp) { + return startResult{ + prepared: item, + err: nil, + outcome: "start_error_converged", + started: started, + finished: finished, + rollbackPending: false, + } + } + var outcome string + switch { + case err == nil: + outcome = "success" + case startCtx.Err() == context.DeadlineExceeded: + outcome = "deadline_exceeded" + case startCtx.Err() == context.Canceled: + outcome = "canceled" + case errors.Is(err, runtime.ErrSessionInitializing): + outcome = "session_initializing" + err = nil + case errors.Is(err, runtime.ErrSessionExists): + running, runningErr := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, item.candidate.name()) + switch { + case runningErr != nil || !running: + outcome = "provider_error" + case rollbackPending && runningSessionMatchesPendingCreate(item.candidate.session, item.candidate.name(), sp): + outcome = "session_exists_converged" + err = nil + rollbackPending = false + default: + outcome = "session_exists" + } + default: + outcome = "provider_error" + } + return startResult{ + prepared: item, + err: err, + outcome: outcome, + started: started, + finished: finished, + rollbackPending: rollbackPending, + } +} + +func enqueuePreparedStartWaveForCity( + ctx context.Context, + prepared []asyncPreparedStart, + cityPath string, + sp runtime.Provider, + store beads.Store, + cfg *config.City, + clk clock.Clock, + rec events.Recorder, + startupTimeout time.Duration, + wave int, + stdout, stderr io.Writer, + trace *sessionReconcilerTraceCycle, + asyncFollowUp func(), +) []startResult { + if len(prepared) == 0 { + return nil + } + results := make([]startResult, len(prepared)) + for i, reserved := range prepared { + item := clonePreparedStartForAsync(reserved.item) + release := reserved.release + now := time.Now() + results[i] = startResult{ + prepared: item, + outcome: "start_enqueued", + started: now, + finished: now, + } + done := reserved.done + go func(item preparedStart, release func(), done func()) { + if done != nil { + defer done() + } + if release != nil { + defer release() + } + result := runPreparedStartCandidate(ctx, item, cityPath, sp, store, cfg, startupTimeout) + commitAsyncStartResultWithContext(ctx, result, sp, store, clk, rec, wave, stdout, stderr, trace) + if asyncFollowUp != nil { + asyncFollowUp() + } + }(item, release, done) + } + return results +} + +func reserveAsyncStartSlot(ctx context.Context, limiter chan struct{}) (func(), bool, string) { + if limiter == nil { + return func() {}, true, "" + } + if ctx != nil { + select { + case <-ctx.Done(): + return nil, false, "context_canceled" + default: + } + } + select { + case limiter <- struct{}{}: + return func() { <-limiter }, true, "" + default: + return nil, false, "deferred_by_async_start_limit" + } +} + +func commitAsyncStartResultWithContext( + ctx context.Context, + result startResult, + sp runtime.Provider, + store beads.Store, + clk clock.Clock, + rec events.Recorder, + wave int, + stdout, stderr io.Writer, + trace *sessionReconcilerTraceCycle, +) (committed bool) { + name := result.prepared.candidate.name() + template := result.prepared.candidate.tp.TemplateName + defer func() { + if trace != nil { + _ = trace.flushCurrentBatch(TraceDurabilityDurable) + } + }() + defer func() { + if recovered := recover(); recovered != nil { + err := fmt.Errorf("panic during async start commit: %v\n%s", recovered, debug.Stack()) + clearPendingStartInFlightLease(result.prepared.candidate.session, store, stderr) + fmt.Fprintf(stderr, "session reconciler: committing async start %s: %s\n", name, formatLifecycleError(err)) //nolint:errcheck + logLifecycleOutcome(stderr, "start", wave, name, template, "panic_recovered", result.started, time.Now(), err) + committed = false + } + }() + + refreshed, ok, cleanupRuntime, releaseInFlight := refreshAsyncStartResult(result, store, stderr) + if !ok { + if cleanupRuntime { + stopStaleAsyncStartRuntime(result, sp, stderr) + } + outcome := "stale_async_start" + if releaseInFlight { + clearPendingStartInFlightLease(result.prepared.candidate.session, store, stderr) + outcome = "async_start_refresh_failed" + } + logLifecycleOutcome(stderr, "start", wave, name, template, outcome, result.started, time.Now(), nil) + return false + } + if refreshed.err != nil && refreshed.rollbackPending && runningSessionMatchesPendingCreate(refreshed.prepared.candidate.session, refreshed.prepared.candidate.name(), sp) { + refreshed.err = nil + refreshed.outcome = "session_exists_converged" + refreshed.rollbackPending = false + } + if ctx != nil && ctx.Err() != nil { + if refreshed.err != nil && refreshed.rollbackPending { + return commitStartResultTraced(refreshed, store, clk, rec, wave, stdout, stderr, trace) + } + if refreshed.err == nil && shouldRollbackPendingCreate(refreshed.prepared.candidate.session) { + stopStaleAsyncStartRuntime(refreshed, sp, stderr) + clearPendingStartInFlightLease(refreshed.prepared.candidate.session, store, stderr) + } + logLifecycleOutcome(stderr, "start", wave, name, template, "context_canceled", refreshed.started, time.Now(), ctx.Err()) + return false + } + if sp != nil && refreshed.err == nil && refreshed.outcome != "session_initializing" { + clearReconcilerDrainAckMetadata(sp, refreshed.prepared.candidate.name()) + } + return commitStartResultTraced(refreshed, store, clk, rec, wave, stdout, stderr, trace) +} + +func refreshAsyncStartResult(result startResult, store beads.Store, stderr io.Writer) (startResult, bool, bool, bool) { + session := result.prepared.candidate.session + if store == nil || session == nil || strings.TrimSpace(session.ID) == "" { + return result, true, false, false + } + current, err := store.Get(session.ID) + if err != nil { + fmt.Fprintf(stderr, "session reconciler: refreshing async start %s: %v\n", result.prepared.candidate.name(), err) //nolint:errcheck + return result, false, false, true + } + if asyncStartPreparedCommandStale(result.prepared, current) { + fmt.Fprintf(stderr, "session reconciler: ignoring stale async start result for %s: desired command changed during startup\n", result.prepared.candidate.name()) //nolint:errcheck + return result, false, true, true + } + if !asyncStartSessionStillCurrent(*session, current) { + fmt.Fprintf(stderr, "session reconciler: ignoring stale async start result for %s\n", result.prepared.candidate.name()) //nolint:errcheck + return result, false, asyncStartStaleRuntimeCleanupAllowed(*session, current), false + } + result.prepared.candidate.session = ¤t + return result, true, false, false +} + +func asyncStartPreparedCommandStale(prepared preparedStart, current beads.Bead) bool { + preparedCommand := strings.TrimSpace(prepared.candidate.tp.Command) + currentCommand := strings.TrimSpace(current.Metadata["command"]) + return preparedCommand != "" && currentCommand != "" && preparedCommand != currentCommand +} + +func clearPendingStartInFlightLease(session *beads.Bead, store beads.Store, stderr io.Writer) { + if session == nil || store == nil { + return + } + if setMeta(store, session.ID, "last_woke_at", "", stderr) == nil { + if session.Metadata == nil { + session.Metadata = make(map[string]string) + } + session.Metadata["last_woke_at"] = "" + } +} + +func stopStaleAsyncStartRuntime(result startResult, sp runtime.Provider, stderr io.Writer) { + if sp == nil || result.prepared.candidate.session == nil { + return + } + name := result.prepared.candidate.name() + if !runningSessionMatchesPendingCreate(result.prepared.candidate.session, name, sp) { + return + } + if err := sp.Stop(name); err != nil && !runtime.IsSessionGone(err) { + fmt.Fprintf(stderr, "session reconciler: stopping stale async start runtime %s: %v\n", name, err) //nolint:errcheck + } +} + +func asyncStartSessionStillCurrent(prepared, current beads.Bead) bool { + if strings.TrimSpace(current.Status) == "closed" { + return false + } + preparedGeneration := strings.TrimSpace(prepared.Metadata["generation"]) + if preparedGeneration != "" && strings.TrimSpace(current.Metadata["generation"]) != preparedGeneration { + return false + } + preparedToken := strings.TrimSpace(prepared.Metadata["instance_token"]) + if preparedToken != "" && strings.TrimSpace(current.Metadata["instance_token"]) != preparedToken { + return false + } + if shouldRollbackPendingCreate(&prepared) && !shouldRollbackPendingCreate(¤t) { + return false + } + currentState := strings.TrimSpace(current.Metadata["state"]) + return confirmPendingStart(currentState) || + sessionpkg.State(currentState) == sessionpkg.StateAwake || + sessionpkg.State(currentState) == sessionpkg.StateActive +} + +func asyncStartStaleRuntimeCleanupAllowed(prepared, current beads.Bead) bool { + if strings.TrimSpace(current.Status) == "closed" { + return true + } + preparedGeneration := strings.TrimSpace(prepared.Metadata["generation"]) + if preparedGeneration != "" && strings.TrimSpace(current.Metadata["generation"]) != preparedGeneration { + return true + } + preparedToken := strings.TrimSpace(prepared.Metadata["instance_token"]) + if preparedToken != "" && strings.TrimSpace(current.Metadata["instance_token"]) != preparedToken { + return true + } + currentState := sessionpkg.State(strings.TrimSpace(current.Metadata["state"])) + if shouldRollbackPendingCreate(&prepared) && !shouldRollbackPendingCreate(¤t) { + return currentState != sessionpkg.StateAwake && currentState != sessionpkg.StateActive + } + return !confirmPendingStart(string(currentState)) && + currentState != sessionpkg.StateAwake && + currentState != sessionpkg.StateActive +} + +func clonePreparedStartForAsync(item preparedStart) preparedStart { + if item.candidate.session == nil { + return item + } + sessionCopy := *item.candidate.session + if item.candidate.session.Labels != nil { + sessionCopy.Labels = append([]string(nil), item.candidate.session.Labels...) + } + if item.candidate.session.Metadata != nil { + sessionCopy.Metadata = make(map[string]string, len(item.candidate.session.Metadata)) + for key, value := range item.candidate.session.Metadata { + sessionCopy.Metadata[key] = value + } + } + item.candidate.session = &sessionCopy + return item +} + func startPreparedStartCandidate( ctx context.Context, item preparedStart, @@ -634,6 +1097,7 @@ func commitStartResultTraced( // Session still starting up — back off silently without recording failure. // The reconciler will retry on the next patrol tick. if result.outcome == "session_initializing" { + clearPendingStartInFlightLease(session, store, stderr) logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, nil) return false } @@ -690,7 +1154,36 @@ func commitStartResultTraced( ClearPendingCreateClaim: shouldRollbackPendingCreate(session), Now: clk.Now(), }) + storedMCPSnapshot, err := sessionpkg.EncodeMCPServersSnapshot(result.prepared.cfg.MCPServers) + if err != nil { + clearPendingStartInFlightLease(session, store, stderr) + fmt.Fprintf(stderr, "session reconciler: encoding MCP snapshot for %s: %v\n", name, err) //nolint:errcheck + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, "metadata_encode_failed", result.started, result.finished, err) + return false + } + if storedMCPSnapshot != "" || session.Metadata[sessionpkg.MCPServersSnapshotMetadataKey] != "" { + metadata[sessionpkg.MCPServersSnapshotMetadataKey] = storedMCPSnapshot + } + if err := sessionpkg.PersistRuntimeMCPServersSnapshot(result.prepared.cfg.Env["GC_CITY_PATH"], session.ID, result.prepared.cfg.MCPServers); err != nil { + clearPendingStartInFlightLease(session, store, stderr) + fmt.Fprintf(stderr, "session reconciler: storing runtime MCP snapshot for %s: %v\n", name, err) //nolint:errcheck + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, "runtime_mcp_snapshot_failed", result.started, result.finished, err) + return false + } + if result.prepared.candidate.tp.IsACP || + session.Metadata[sessionpkg.MCPIdentityMetadataKey] != "" || + session.Metadata[sessionpkg.MCPServersSnapshotMetadataKey] != "" { + storedMCPIdentity := firstNonEmptyGCString( + session.Metadata[sessionpkg.MCPIdentityMetadataKey], + session.Metadata[sessionpkg.NamedSessionIdentityMetadata], + session.Metadata["agent_name"], + ) + if storedMCPIdentity != "" || session.Metadata[sessionpkg.MCPIdentityMetadataKey] != "" { + metadata[sessionpkg.MCPIdentityMetadataKey] = storedMCPIdentity + } + } if err := store.SetMetadataBatch(session.ID, metadata); err != nil { + clearPendingStartInFlightLease(session, store, stderr) fmt.Fprintf(stderr, "session reconciler: storing hashes for %s: %v\n", name, err) //nolint:errcheck if trace != nil { trace.recordMutation("bead_metadata", tp.TemplateName, name, "metadata_batch", session.ID, "started_config_hash", "", result.prepared.coreHash, "failed", traceRecordPayload{ @@ -798,27 +1291,43 @@ func runningSessionMatchesPendingCreate(session *beads.Bead, sessionName string, if session == nil || sp == nil { return false } - if liveID, err := sp.GetMeta(sessionName, "GC_SESSION_ID"); err == nil { - liveID = strings.TrimSpace(liveID) - if liveID != "" { - return liveID == session.ID + liveID := "" + if value, err := sp.GetMeta(sessionName, "GC_SESSION_ID"); err == nil { + liveID = strings.TrimSpace(value) + if liveID != "" && liveID != session.ID { + return false } } expectedToken := strings.TrimSpace(session.Metadata["instance_token"]) - if expectedToken == "" { - return false + liveToken := "" + if value, err := sp.GetMeta(sessionName, "GC_INSTANCE_TOKEN"); err == nil { + liveToken = value + liveToken = strings.TrimSpace(liveToken) + if liveToken != "" && liveToken != expectedToken { + liveGeneration, _ := sp.GetMeta(sessionName, "GC_RUNTIME_EPOCH") + expectedGeneration := strings.TrimSpace(session.Metadata["generation"]) + if strings.TrimSpace(liveGeneration) != "" && expectedGeneration != "" && strings.TrimSpace(liveGeneration) != expectedGeneration { + return false + } + if liveID == "" { + return false + } + } } - liveToken, err := sp.GetMeta(sessionName, "GC_INSTANCE_TOKEN") - if err != nil { + if liveID != "" { + return liveID == session.ID + } + if expectedToken == "" { return false } - return strings.TrimSpace(liveToken) == expectedToken + return expectedToken != "" && liveToken == expectedToken } func rollbackPendingCreate(session *beads.Bead, store beads.Store, now time.Time, stderr io.Writer) { if session == nil || store == nil { return } + clearPendingStartInFlightLease(session, store, stderr) if strings.TrimSpace(session.Metadata["session_name_explicit"]) == "true" { if setMeta(store, session.ID, "session_name", "", stderr) == nil { if session.Metadata == nil { @@ -843,7 +1352,7 @@ func executePlannedStarts( startupTimeout time.Duration, stdout, stderr io.Writer, ) int { - return executePlannedStartsTraced(ctx, candidates, cfg, desiredState, sp, store, cityName, clk, rec, startupTimeout, stdout, stderr, nil) + return executePlannedStartsTraced(ctx, candidates, cfg, desiredState, sp, store, cityName, "", clk, rec, startupTimeout, stdout, stderr, nil) } func executePlannedStartsTraced( @@ -854,17 +1363,29 @@ func executePlannedStartsTraced( sp runtime.Provider, store beads.Store, cityName string, + cityPath string, clk clock.Clock, rec events.Recorder, startupTimeout time.Duration, stdout, stderr io.Writer, trace *sessionReconcilerTraceCycle, + options ...startExecutionOption, ) int { if len(candidates) == 0 { return 0 } + startOpts := startExecutionOptions{} + for _, apply := range options { + if apply != nil { + apply(&startOpts) + } + } + asyncLimiter := startOpts.asyncLimiter + if startOpts.async && asyncLimiter == nil { + asyncLimiter = make(chan struct{}, defaultMaxParallelStartsPerWave) + } maxWakes := cfg.Daemon.MaxWakesPerTickOrDefault() - waveByCandidate, ok := candidateWaveOrder(candidates, cfg, desiredState, sp, cityName, store) + waveByCandidate, ok := candidateWaveOrder(candidates, cfg, desiredState, sp, cityName, store, clk) if !ok { fmt.Fprintln(stderr, "session reconciler: dependency graph fallback to serial start order") //nolint:errcheck } @@ -877,6 +1398,7 @@ func executePlannedStartsTraced( wakeCount := 0 for wave := 0; wave <= maxWave; wave++ { waveStarted := time.Now() + asyncBatchEnqueued := false var waveCandidates []startCandidate for idx, candidate := range candidates { if waveByCandidate[idx] == wave { @@ -894,7 +1416,7 @@ func executePlannedStartsTraced( } var ready []startCandidate for _, candidate := range waveCandidates { - if !allDependenciesAliveForTemplate(candidate.logicalTemplate(cfg), cfg, desiredState, sp, cityName, store) { + if !allDependenciesAliveForTemplateWithClock(candidate.logicalTemplate(cfg), cfg, desiredState, sp, cityName, store, clk) { logLifecycleOutcome(stderr, "start", wave, candidate.name(), candidate.logicalTemplate(cfg), "blocked_on_dependencies", time.Time{}, time.Time{}, nil) continue } @@ -910,21 +1432,56 @@ func executePlannedStartsTraced( batchSize := min(defaultMaxParallelStartsPerWave, maxWakes-wakeCount) end := min(offset+batchSize, len(ready)) var prepared []preparedStart + var asyncPrepared []asyncPreparedStart for _, candidate := range ready[offset:end] { - if !allDependenciesAliveForTemplate(candidate.logicalTemplate(cfg), cfg, desiredState, sp, cityName, store) { + if !allDependenciesAliveForTemplateWithClock(candidate.logicalTemplate(cfg), cfg, desiredState, sp, cityName, store, clk) { logLifecycleOutcome(stderr, "start", wave, candidate.name(), candidate.logicalTemplate(cfg), "blocked_on_dependencies", time.Time{}, time.Time{}, nil) continue } - item, err := prepareStartCandidate(candidate, cfg, store, clk) + var release func() + var done func() + if startOpts.async { + var tracking bool + done, tracking = startOpts.asyncTracker.start() + if !tracking { + logLifecycleOutcome(stderr, "start", wave, candidate.name(), candidate.logicalTemplate(cfg), "context_canceled", time.Time{}, time.Time{}, nil) + continue + } + var reserved bool + var outcome string + release, reserved, outcome = reserveAsyncStartSlot(ctx, asyncLimiter) + if !reserved { + done() + logLifecycleOutcome(stderr, "start", wave, candidate.name(), candidate.logicalTemplate(cfg), outcome, time.Time{}, time.Time{}, nil) + continue + } + } + item, err := prepareStartCandidateForCity(candidate, cityPath, cityName, cfg, sp, store, clk, stderr) if err != nil { + clearPendingStartInFlightLease(candidate.session, store, stderr) + if release != nil { + release() + } + if done != nil { + done() + } fmt.Fprintf(stderr, "session reconciler: pre-wake %s: %s\n", candidate.name(), formatLifecycleError(err)) //nolint:errcheck logLifecycleOutcome(stderr, "start", wave, candidate.name(), candidate.logicalTemplate(cfg), "failed", time.Time{}, time.Time{}, err) continue } - prepared = append(prepared, *item) + if startOpts.async { + asyncPrepared = append(asyncPrepared, asyncPreparedStart{item: *item, release: release, done: done}) + } else { + prepared = append(prepared, *item) + } } offset = end - results := executePreparedStartWave(ctx, prepared, sp, store, cfg, startupTimeout, defaultMaxParallelStartsPerWave) + var results []startResult + if startOpts.async { + results = enqueuePreparedStartWaveForCity(ctx, asyncPrepared, cityPath, sp, store, cfg, clk, rec, startupTimeout, wave, stdout, stderr, trace, startOpts.asyncFollowUp) + } else { + results = executePreparedStartWaveForCity(ctx, prepared, cityPath, sp, store, cfg, startupTimeout, defaultMaxParallelStartsPerWave) + } for _, result := range results { if trace != nil { trace.recordOperation("reconciler.start.execute", result.prepared.candidate.tp.TemplateName, result.prepared.candidate.name(), "", "start", result.outcome, traceRecordPayload{ @@ -932,6 +1489,12 @@ func executePlannedStartsTraced( "duration_ms": result.finished.Sub(result.started).Milliseconds(), }, "") } + if result.outcome == "start_enqueued" { + logLifecycleOutcome(stderr, "start", wave, result.prepared.candidate.name(), result.prepared.candidate.logicalTemplate(cfg), result.outcome, result.started, result.finished, nil) + wakeCount++ + asyncBatchEnqueued = true + continue + } if result.err == nil && result.outcome != "session_initializing" { clearReconcilerDrainAckMetadata(sp, result.prepared.candidate.name()) } @@ -939,8 +1502,17 @@ func executePlannedStartsTraced( wakeCount++ } } + if startOpts.async && asyncBatchEnqueued { + break + } } logLifecycleWave(stderr, "start", wave, waveStarted, len(waveCandidates)) + if startOpts.async && asyncBatchEnqueued { + // Async starts intentionally enqueue one bounded batch per tick. + // Completion pokes the controller so the next batch observes + // committed dependency and pending-create state first. + return wakeCount + } } return wakeCount } @@ -1205,9 +1777,37 @@ func stopTargetThroughWorkerBoundary(target stopTarget, store beads.Store, sp ru if targetID == "" { targetID = strings.TrimSpace(target.name) } + if cityStopSessionMarked(store, target.sessionID) { + if err := workerKillSessionTargetWithConfig("", store, sp, cfg, targetID); err != nil { + return err + } + markCityStopSessionAsAsleep(store, target.sessionID, nil) + return nil + } return workerStopSessionTargetWithConfig("", store, sp, cfg, targetID) } +func cityStopSessionMarked(store beads.Store, sessionID string) bool { + if store == nil || strings.TrimSpace(sessionID) == "" { + return false + } + b, err := store.Get(sessionID) + if err != nil { + return false + } + return strings.TrimSpace(b.Metadata["sleep_reason"]) == sleepReasonCityStop +} + +func markCityStopSessionAsAsleep(store beads.Store, sessionID string, stderr io.Writer) { + if store == nil || strings.TrimSpace(sessionID) == "" { + return + } + batch := sessionpkg.SleepPatch(time.Now().UTC(), sleepReasonCityStop) + if err := store.SetMetadataBatch(sessionID, batch); err != nil && stderr != nil { + fmt.Fprintf(stderr, "gc stop: marking session %s asleep: %v\n", sessionID, err) //nolint:errcheck + } +} + func interruptTargetsBounded(targets []stopTarget, cfg *config.City, store beads.Store, sp runtime.Provider, stderr io.Writer) int { targets = hydrateStopTargets(targets, cfg, store, stderr) // Pool-managed sessions have no human user, so Claude Code's diff --git a/cmd/gc/session_lifecycle_parallel_test.go b/cmd/gc/session_lifecycle_parallel_test.go index 73b4cfef4..b7f3d8c5c 100644 --- a/cmd/gc/session_lifecycle_parallel_test.go +++ b/cmd/gc/session_lifecycle_parallel_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "reflect" "strconv" "strings" @@ -50,6 +51,64 @@ func (s *failNthMetadataBatchStore) SetMetadataBatch(id string, kvs map[string]s return s.MemStore.SetMetadataBatch(id, kvs) } +type failSetMetadataStore struct { + *beads.MemStore + failKey string +} + +func (s *failSetMetadataStore) SetMetadata(id, key, value string) error { + if key == s.failKey { + return fmt.Errorf("set metadata %s failed", key) + } + return s.MemStore.SetMetadata(id, key, value) +} + +type panicMetadataBatchStore struct { + *beads.MemStore +} + +func (s *panicMetadataBatchStore) SetMetadataBatch(string, map[string]string) error { + panic("metadata batch panic") +} + +type getErrorStore struct { + *beads.MemStore +} + +func (s *getErrorStore) Get(string) (beads.Bead, error) { + return beads.Bead{}, fmt.Errorf("get failed") +} + +type closedMetadataMatchStore struct { + *beads.MemStore + matches []beads.Bead +} + +func (s *closedMetadataMatchStore) ListByMetadata(filters map[string]string, _ int, _ ...beads.QueryOpt) ([]beads.Bead, error) { + var out []beads.Bead + for _, match := range s.matches { + ok := true + for key, value := range filters { + if match.Metadata[key] != value { + ok = false + break + } + } + if ok { + out = append(out, match) + } + } + return out, nil +} + +type listMetadataErrorStore struct { + *beads.MemStore +} + +func (s *listMetadataErrorStore) ListByMetadata(map[string]string, int, ...beads.QueryOpt) ([]beads.Bead, error) { + return nil, errors.New("list failed") +} + type gatedStartProvider struct { *runtime.Fake mu sync.Mutex @@ -137,6 +196,24 @@ func (p *gatedStartProvider) ensureNoFurtherStart(t *testing.T, wait time.Durati } } +type shutdownWaitProvider struct { + *gatedStartProvider + listCalled chan struct{} + listOnce sync.Once +} + +func newShutdownWaitProvider() *shutdownWaitProvider { + return &shutdownWaitProvider{ + gatedStartProvider: newGatedStartProvider(), + listCalled: make(chan struct{}), + } +} + +func (p *shutdownWaitProvider) ListRunning(prefix string) ([]string, error) { + p.listOnce.Do(func() { close(p.listCalled) }) + return p.Fake.ListRunning(prefix) +} + func creatingMeta(meta map[string]string) map[string]string { cp := make(map[string]string, len(meta)+1) for key, value := range meta { @@ -541,6 +618,7 @@ func TestPrepareStartCandidate_UsesSessionIDForTaskWorkDir(t *testing.T) { } func TestExecutePlannedStarts_FreshWakeAfterDrainRetainsStartupContext(t *testing.T) { + skipSlowCmdGCTest(t, "waits through stale session-key detection; run make test-cmd-gc-process for full coverage") sp := runtime.NewFake() store := beads.NewMemStore() clk := &clock.Fake{Time: time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC)} @@ -819,116 +897,1624 @@ func TestReconcileSessionBeads_DaemonMaxWakesPerTickOverride(t *testing.T) { } } -func TestPrepareStartCandidate_NoneModeInitialMessageStaysInNudge(t *testing.T) { - store := beads.NewMemStore() - bead, err := store.Create(beads.Bead{ - ID: "gc-1", - Title: "mayor", +func TestPrepareStartCandidate_NoneModeInitialMessageStaysInNudge(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + ID: "gc-1", + Title: "mayor", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "mayor", + "template": "mayor", + "generation": "1", + "instance_token": "tok-mayor", + "state": "creating", + "template_overrides": `{"initial_message":"hello from the user"}`, + }, + }) + if err != nil { + t.Fatal(err) + } + + prepared, err := prepareStartCandidate(startCandidate{ + session: &bead, + tp: TemplateParams{ + TemplateName: "mayor", + SessionName: "mayor", + Prompt: "startup prompt", + ResolvedProvider: &config.ResolvedProvider{ + Name: "gemini", + PromptMode: "none", + }, + }, + order: 0, + }, &config.City{ + Agents: []config.Agent{ + {Name: "mayor"}, + }, + }, store, &clock.Fake{Time: time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatalf("prepareStartCandidate: %v", err) + } + + if prepared.cfg.PromptSuffix != "" { + t.Fatalf("prepared.cfg.PromptSuffix = %q, want empty for prompt_mode none", prepared.cfg.PromptSuffix) + } + wantNudge := "startup prompt\n\n---\n\nUser message:\nhello from the user" + if prepared.cfg.Nudge != wantNudge { + t.Fatalf("prepared.cfg.Nudge = %q, want %q", prepared.cfg.Nudge, wantNudge) + } +} + +func TestExecutePlannedStarts_RevalidatesDependenciesBetweenWaveBatches(t *testing.T) { + sp := &dropDependencyAfterNStartsProvider{ + Fake: runtime.NewFake(), + dropAfter: defaultMaxParallelStartsPerWave, + depName: "db", + } + if err := sp.Start(context.Background(), "db", runtime.Config{}); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "app-1", DependsOn: []string{"db"}}, + {Name: "app-2", DependsOn: []string{"db"}}, + {Name: "app-3", DependsOn: []string{"db"}}, + {Name: "app-4", DependsOn: []string{"db"}}, + {Name: "db", MaxActiveSessions: intPtr(1)}, + }, + } + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)} + desired := map[string]TemplateParams{} + var sessions []beads.Bead + for _, name := range []string{"app-1", "app-2", "app-3", "app-4"} { + desired[name] = TemplateParams{Command: name, SessionName: name, TemplateName: name} + bead := makeBead(name+"-id", creatingMeta(map[string]string{ + "session_name": name, + "template": name, + "generation": "1", + "instance_token": "tok-" + name, + })) + created, err := store.Create(beads.Bead{ + ID: bead.ID, + Title: name, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: bead.Metadata, + }) + if err != nil { + t.Fatal(err) + } + sessions = append(sessions, created) + } + + poolDesired := map[string]int{"app-1": 1, "app-2": 1, "app-3": 1, "app-4": 1} + woken := reconcileSessionBeads( + context.Background(), sessions, desired, configuredSessionNames(cfg, "", store), + cfg, sp, store, nil, nil, nil, newDrainTracker(), poolDesired, false, nil, "", + nil, clk, events.Discard, 5*time.Second, 0, ioDiscard{}, ioDiscard{}, + ) + + if woken != defaultMaxParallelStartsPerWave { + t.Fatalf("woken = %d, want %d", woken, defaultMaxParallelStartsPerWave) + } + for _, name := range []string{"app-1", "app-2", "app-3"} { + if !sp.IsRunning(name) { + t.Fatalf("%s should have started before dependency loss", name) + } + } + if sp.IsRunning("app-4") { + t.Fatal("app-4 should be blocked after db dies between wave batches") + } +} + +func TestExecutePlannedStartsTraced_AsyncReturnsBeforeProviderStartCompletes(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newGatedStartProvider() + t.Cleanup(func() { sp.release("worker") }) + cfg := &config.City{ + Agents: []config.Agent{{Name: "worker"}}, + } + tp := TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + } + desired := map[string]TemplateParams{"worker": tp} + + done := make(chan int, 1) + go func() { + done <- executePlannedStartsTraced( + context.Background(), + []startCandidate{{session: &session, tp: tp}}, + cfg, + desired, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + ) + }() + + select { + case woken := <-done: + if woken != 1 { + t.Fatalf("woken = %d, want 1", woken) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("async planned start blocked waiting for provider Start to finish") + } + sp.waitForStarts(t, 1) + + inFlight, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if inFlight.Metadata["pending_create_claim"] != "true" { + t.Fatalf("pending_create_claim = %q, want true until async start commits", inFlight.Metadata["pending_create_claim"]) + } + if inFlight.Metadata["last_woke_at"] == "" { + t.Fatal("last_woke_at was not stamped before async start") + } + + sp.release("worker") + deadline := time.After(2 * time.Second) + for { + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Metadata["state"] == "active" && updated.Metadata["pending_create_claim"] == "" { + break + } + select { + case <-deadline: + t.Fatalf("async start did not commit active state; metadata=%v", updated.Metadata) + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestExecutePlannedStartsTraced_AsyncLimitsEnqueuedStartsPerTick(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 0, 0, time.UTC)} + sp := newGatedStartProvider() + cfg := &config.City{} + desired := map[string]TemplateParams{} + var candidates []startCandidate + for _, name := range []string{"worker-1", "worker-2", "worker-3", "worker-4"} { + session, err := store.Create(beads.Bead{ + ID: "gc-" + name, + Title: name, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": name, + "template": name, + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-" + name, + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sp.release(name) }) + cfg.Agents = append(cfg.Agents, config.Agent{Name: name}) + tp := TemplateParams{Command: name, SessionName: name, TemplateName: name} + desired[name] = tp + candidates = append(candidates, startCandidate{session: &session, tp: tp}) + } + + woken := executePlannedStartsTraced( + context.Background(), + candidates, + cfg, + desired, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + ) + if woken != defaultMaxParallelStartsPerWave { + t.Fatalf("woken = %d, want one bounded async batch of %d", woken, defaultMaxParallelStartsPerWave) + } + sp.waitForStarts(t, defaultMaxParallelStartsPerWave) + sp.ensureNoFurtherStart(t, 100*time.Millisecond) + if sp.maxInFlight > defaultMaxParallelStartsPerWave { + t.Fatalf("max in-flight starts = %d, want <= %d", sp.maxInFlight, defaultMaxParallelStartsPerWave) + } +} + +func TestExecutePlannedStartsTraced_AsyncLimiterSharedAcrossTicks(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 15, 0, time.UTC)} + sp := newGatedStartProvider() + cfg := &config.City{ + Agents: []config.Agent{{Name: "worker-1"}, {Name: "worker-2"}}, + } + desired := map[string]TemplateParams{} + makeCandidate := func(name string) startCandidate { + session, err := store.Create(beads.Bead{ + ID: "gc-" + name, + Title: name, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": name, + "template": name, + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-" + name, + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sp.release(name) }) + tp := TemplateParams{Command: name, SessionName: name, TemplateName: name} + desired[name] = tp + return startCandidate{session: &session, tp: tp} + } + limiter := make(chan struct{}, 1) + first := makeCandidate("worker-1") + second := makeCandidate("worker-2") + + if got := executePlannedStartsTraced( + context.Background(), + []startCandidate{first}, + cfg, + desired, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartLimiter(limiter), + ); got != 1 { + t.Fatalf("first woken = %d, want 1", got) + } + sp.waitForStarts(t, 1) + if got := executePlannedStartsTraced( + context.Background(), + []startCandidate{second}, + cfg, + desired, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartLimiter(limiter), + ); got != 0 { + t.Fatalf("second woken = %d, want 0 while shared limiter is full", got) + } + sp.ensureNoFurtherStart(t, 100*time.Millisecond) + deferred, err := store.Get(second.session.ID) + if err != nil { + t.Fatal(err) + } + if got := deferred.Metadata["last_woke_at"]; got != "" { + t.Fatalf("deferred last_woke_at = %q, want empty until limiter slot is reserved", got) + } + sp.release("worker-1") + deadline := time.After(2 * time.Second) + for { + updated, err := store.Get(first.session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Metadata["state"] == "active" { + break + } + select { + case <-deadline: + t.Fatalf("first async start did not commit active state; metadata=%v", updated.Metadata) + case <-time.After(10 * time.Millisecond): + } + } + if got := executePlannedStartsTraced( + context.Background(), + []startCandidate{second}, + cfg, + desired, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartLimiter(limiter), + ); got != 1 { + t.Fatalf("second woken after release = %d, want 1", got) + } + started := sp.waitForStarts(t, 1) + if len(started) != 1 || started[0] != "worker-2" { + t.Fatalf("second start = %v, want [worker-2]", started) + } +} + +func TestExecutePlannedStartsTraced_AsyncLimiterDeferredStartDoesNotRunAfterCancel(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 20, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newGatedStartProvider() + t.Cleanup(func() { sp.release("worker") }) + cfg := &config.City{Agents: []config.Agent{{Name: "worker"}}} + tp := TemplateParams{Command: "worker", SessionName: "worker", TemplateName: "worker"} + limiter := make(chan struct{}, 1) + limiter <- struct{}{} + ctx, cancel := context.WithCancel(context.Background()) + + if got := executePlannedStartsTraced( + ctx, + []startCandidate{{session: &session, tp: tp}}, + cfg, + map[string]TemplateParams{"worker": tp}, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartLimiter(limiter), + ); got != 0 { + t.Fatalf("woken = %d, want 0 while async limiter is full", got) + } + cancel() + <-limiter + sp.ensureNoFurtherStart(t, 100*time.Millisecond) + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want empty because no async start was queued", got) + } +} + +func TestCityRuntimeShutdownWaitsForTrackedAsyncStartsBeforeStopSnapshot(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 25, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newShutdownWaitProvider() + t.Cleanup(func() { sp.release("worker") }) + cfg := &config.City{ + Daemon: config.DaemonConfig{ShutdownTimeout: "500ms"}, + Agents: []config.Agent{{Name: "worker"}}, + } + cr := &CityRuntime{ + cfg: cfg, + sp: sp, + rec: events.Discard, + standaloneCityStore: store, + asyncStartLimiter: make(chan struct{}, defaultMaxParallelStartsPerWave), + logPrefix: "gc test", + stdout: ioDiscard{}, + stderr: ioDiscard{}, + } + tp := TemplateParams{Command: "worker", SessionName: "worker", TemplateName: "worker"} + if got := executePlannedStartsTraced( + context.Background(), + []startCandidate{{session: &session, tp: tp}}, + cfg, + map[string]TemplateParams{"worker": tp}, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartLimiter(cr.ensureAsyncStartLimiter()), + withAsyncStartTracker(&cr.asyncStarts), + ); got != 1 { + t.Fatalf("woken = %d, want 1", got) + } + sp.waitForStarts(t, 1) + + shutdownDone := make(chan struct{}) + go func() { + cr.shutdown() + close(shutdownDone) + }() + select { + case <-sp.listCalled: + t.Fatal("shutdown listed running sessions before the async start completed") + case <-shutdownDone: + t.Fatal("shutdown returned before the async start completed") + case <-time.After(100 * time.Millisecond): + } + + sp.release("worker") + select { + case <-shutdownDone: + case <-time.After(2 * time.Second): + t.Fatal("shutdown did not finish after the async start completed") + } + select { + case <-sp.listCalled: + default: + t.Fatal("shutdown did not list running sessions after waiting for async starts") + } + if sp.IsRunning("worker") { + t.Fatal("shutdown should stop the runtime that the async start created") + } +} + +func TestExecutePlannedStartsTraced_AsyncPrepareFailureClearsPreWakeLease(t *testing.T) { + store := &failSetMetadataStore{MemStore: beads.NewMemStore(), failKey: "session_key"} + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 27, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newGatedStartProvider() + t.Cleanup(func() { sp.release("worker") }) + cfg := &config.City{Agents: []config.Agent{{Name: "worker"}}} + tp := TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + ResolvedProvider: &config.ResolvedProvider{SessionIDFlag: "--session-id"}, + } + if got := executePlannedStartsTraced( + context.Background(), + []startCandidate{{session: &session, tp: tp}}, + cfg, + map[string]TemplateParams{"worker": tp}, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + ); got != 0 { + t.Fatalf("woken = %d, want 0 when async preparation fails after preWake", got) + } + sp.ensureNoFurtherStart(t, 100*time.Millisecond) + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared after async preparation failure", got) + } +} + +func TestExecutePlannedStartsTraced_AsyncRequestsFollowUpAfterCommit(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 30, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newGatedStartProvider() + t.Cleanup(func() { sp.release("worker") }) + cfg := &config.City{Agents: []config.Agent{{Name: "worker"}}} + tp := TemplateParams{Command: "worker", SessionName: "worker", TemplateName: "worker"} + followUp := make(chan struct{}, 1) + + woken := executePlannedStartsTraced( + context.Background(), + []startCandidate{{session: &session, tp: tp}}, + cfg, + map[string]TemplateParams{"worker": tp}, + sp, + store, + "test-city", + "", + clk, + events.Discard, + time.Minute, + ioDiscard{}, + ioDiscard{}, + nil, + withAsyncStartExecution(), + withAsyncStartFollowUp(func() { + select { + case followUp <- struct{}{}: + default: + } + }), + ) + if woken != 1 { + t.Fatalf("woken = %d, want 1", woken) + } + sp.waitForStarts(t, 1) + select { + case <-followUp: + t.Fatal("follow-up requested before async provider start finished") + case <-time.After(100 * time.Millisecond): + } + + sp.release("worker") + select { + case <-followUp: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for async completion follow-up") + } +} + +func TestAllDependenciesAliveForTemplate_TreatsPendingCreateDependencyAsNotAlive(t *testing.T) { + store := beads.NewMemStore() + now := time.Now().UTC() + dep, err := store.Create(beads.Bead{ + ID: "gc-db", + Title: "db", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "db", + "template": "db", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "tok-db", + "pending_create_claim": "true", + "last_woke_at": now.Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "db", runtime.Config{}); err != nil { + t.Fatal(err) + } + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", DependsOn: []string{"db"}}, + {Name: "db"}, + }, + } + desired := map[string]TemplateParams{ + "worker": {Command: "worker", SessionName: "worker", TemplateName: "worker"}, + "db": {Command: "db", SessionName: "db", TemplateName: "db"}, + } + + if allDependenciesAliveForTemplate("worker", cfg, desired, sp, "test-city", store) { + t.Fatal("worker dependency should stay blocked while db start is still in flight") + } + if err := store.SetMetadataBatch(dep.ID, map[string]string{ + "state": string(sessionpkg.StateActive), + "pending_create_claim": "", + "creation_complete_at": now.Add(time.Second).Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + if !allDependenciesAliveForTemplate("worker", cfg, desired, sp, "test-city", store) { + t.Fatal("worker dependency should be alive after db start is committed") + } +} + +func TestDependencySessionStartInFlightIgnoresClosedMetadataMatches(t *testing.T) { + now := time.Now().UTC() + store := &closedMetadataMatchStore{ + MemStore: beads.NewMemStore(), + matches: []beads.Bead{{ + ID: "gc-db-old", + Title: "db", + Status: "closed", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "db", + "template": "db", + "pending_create_claim": "true", + "last_woke_at": now.Format(time.RFC3339), + }), + }}, + } + + if dependencySessionStartInFlight(store, "db", &config.City{}, clock.Real{}) { + t.Fatal("closed failed-create bead should not count as an in-flight dependency start") + } +} + +func TestDependencySessionStartInFlightFailsClosedOnMetadataListError(t *testing.T) { + store := &listMetadataErrorStore{MemStore: beads.NewMemStore()} + if !dependencySessionStartInFlight(store, "db", &config.City{}, clock.Real{}) { + t.Fatal("metadata query errors should block dependent starts until the store recovers") + } +} + +func TestPendingCreateStartInFlight_ZeroStartupTimeoutUsesRecoveryLease(t *testing.T) { + now := time.Date(2026, 4, 26, 12, 1, 40, 0, time.UTC) + recent := beads.Bead{ + Metadata: map[string]string{ + "pending_create_claim": "true", + "last_woke_at": now.Add(-10 * time.Second).Format(time.RFC3339), + }, + } + if !pendingCreateStartInFlight(recent, &clock.Fake{Time: now}, 0) { + t.Fatal("explicit zero startup timeout should still use a finite recovery lease while recent") + } + stale := beads.Bead{ + Metadata: map[string]string{ + "pending_create_claim": "true", + "last_woke_at": now.Add(-24 * time.Hour).Format(time.RFC3339), + }, + } + if pendingCreateStartInFlight(stale, &clock.Fake{Time: now}, 0) { + t.Fatal("explicit zero startup timeout should not suppress recovery forever") + } +} + +func TestAsyncStartTrackerWaitZeroDoesNotBlock(t *testing.T) { + var tracker asyncStartTracker + done, ok := tracker.start() + if !ok { + t.Fatal("tracker should accept work before shutdown") + } + if tracker.wait(0) { + t.Fatal("zero-timeout wait should not report completion while async work is still running") + } + done() + if !tracker.wait(time.Second) { + t.Fatal("tracker should report completion after async work finishes") + } +} + +func TestReconcileSessionBeads_RollsBackPendingCreateWhenRuntimeTokenMismatches(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 1, 45, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-new", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "tok-old"); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_RUNTIME_EPOCH", "1"); err != nil { + t.Fatal(err) + } + cfg := &config.City{Agents: []config.Agent{{Name: "worker"}}} + tp := TemplateParams{Command: "worker", SessionName: "worker", TemplateName: "worker"} + + woken := reconcileSessionBeads( + context.Background(), + []beads.Bead{session}, + map[string]TemplateParams{"worker": tp}, + configuredSessionNames(cfg, "test-city", store), + cfg, + sp, + store, + nil, + nil, + nil, + newDrainTracker(), + map[string]int{"worker": 1}, + false, + map[string]bool{"worker": true}, + "test-city", + nil, + clk, + events.Discard, + time.Minute, + 0, + ioDiscard{}, + ioDiscard{}, + ) + if woken != 0 { + t.Fatalf("woken = %d, want 0", woken) + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Status != "closed" { + t.Fatalf("status = %q, want closed so stale runtime is not recovered", updated.Status) + } +} + +func TestRunningSessionMatchesPendingCreateAcceptsTokenOnlyRuntime(t *testing.T) { + session := &beads.Bead{ + ID: "gc-worker", + Metadata: map[string]string{ + "session_name": "worker", + "generation": "2", + "instance_token": "tok-worker", + }, + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "tok-worker"); err != nil { + t.Fatal(err) + } + + if !runningSessionMatchesPendingCreate(session, "worker", sp) { + t.Fatal("runtime with matching token and no session id should match pending create") + } +} + +func TestRunningSessionMatchesPendingCreateAcceptsIDOnlyRuntime(t *testing.T) { + session := &beads.Bead{ + ID: "gc-worker", + Metadata: map[string]string{ + "session_name": "worker", + "generation": "2", + }, + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + + if !runningSessionMatchesPendingCreate(session, "worker", sp) { + t.Fatal("runtime with matching session id and no token should match pending create") + } +} + +func TestReconcileSessionBeads_SkipsPendingCreateStartAlreadyInFlight(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 0, 30, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Add(-10 * time.Second).UTC().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := newGatedStartProvider() + cfg := &config.City{ + Agents: []config.Agent{{Name: "worker"}}, + } + tp := TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + } + woken := reconcileSessionBeads( + context.Background(), + []beads.Bead{session}, + map[string]TemplateParams{"worker": tp}, + configuredSessionNames(cfg, "", store), + cfg, + sp, + store, + nil, + nil, + nil, + newDrainTracker(), + map[string]int{"worker": 1}, + false, + map[string]bool{"worker": true}, + "test-city", + nil, + clk, + events.Discard, + time.Minute, + 0, + ioDiscard{}, + ioDiscard{}, + ) + if woken != 0 { + t.Fatalf("woken = %d, want 0 while start is already in flight", woken) + } + sp.ensureNoFurtherStart(t, 100*time.Millisecond) +} + +func TestCommitAsyncStartResult_IgnoresStaleSessionSnapshot(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 2, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-old", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + if err := store.SetMetadataBatch(session.ID, map[string]string{ + "generation": "3", + "instance_token": "tok-new", + }); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, nil, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("stale async start result should not commit") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + 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["instance_token"]; got != "tok-new" { + t.Fatalf("instance_token = %q, want tok-new", got) + } +} + +func TestCommitAsyncStartResult_IgnoresClosedSessionSnapshot(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 2, 30, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + if err := store.Close(session.ID); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, nil, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("closed async start result should not commit") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Status != "closed" { + t.Fatalf("status = %q, want closed", updated.Status) + } + 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) + } +} + +func TestCommitAsyncStartResult_StopsMatchingRuntimeForStaleSnapshot(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 2, 45, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-old", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + if err := store.SetMetadataBatch(session.ID, map[string]string{ + "generation": "3", + "instance_token": "tok-new", + }); err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "tok-old"); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_RUNTIME_EPOCH", "2"); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, sp, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("stale async start result should not commit") + } + if sp.IsRunning("worker") { + t.Fatal("stale runtime with matching old session metadata should be stopped") + } +} + +func TestCommitAsyncStartResult_IgnoresCommandChangedDuringStartup(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 28, 13, 6, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-drifter", + Title: "drifter", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "drifter", + "template": "drifter", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-drifter", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + "command": "CUSTOM_VERSION=v1 report", + }), + }) + if err != nil { + t.Fatal(err) + } + if err := store.SetMetadata(session.ID, "command", "CUSTOM_VERSION=v2 report"); err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "drifter", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("drifter", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("drifter", "GC_INSTANCE_TOKEN", "tok-drifter"); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("drifter", "GC_RUNTIME_EPOCH", "2"); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "CUSTOM_VERSION=v1 report", + SessionName: "drifter", + TemplateName: "drifter", + }, + }, + coreHash: "core-v1", + liveHash: "live-v1", + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, sp, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("async start with stale command should not commit") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if sp.IsRunning("drifter") { + t.Fatal("stale runtime with old command should be stopped") + } + if got := updated.Metadata["started_config_hash"]; got != "" { + t.Fatalf("started_config_hash = %q, want empty until fresh command starts", got) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared so the new command can retry next tick", got) + } + if got := updated.Metadata["pending_create_claim"]; got != "true" { + t.Fatalf("pending_create_claim = %q, want true for pending-create retry", got) + } + if got := updated.Metadata["command"]; got != "CUSTOM_VERSION=v2 report" { + t.Fatalf("command = %q, want current config preserved", got) + } +} + +func TestCommitAsyncStartResult_PreservesRuntimeWhenRefreshFails(t *testing.T) { + store := &getErrorStore{MemStore: beads.NewMemStore()} + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 2, 50, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "tok-worker"); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, sp, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("async result should not commit when refresh fails") + } + if !sp.IsRunning("worker") { + t.Fatal("refresh failure should not stop a runtime without proving staleness") + } + updated, err := store.MemStore.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared so the next tick can recover or retry", got) + } +} + +func TestCommitAsyncStartResult_RecoversCommitPanic(t *testing.T) { + store := &panicMetadataBatchStore{MemStore: beads.NewMemStore()} + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 3, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + + if commitAsyncStartResultWithContext(context.Background(), result, nil, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("async commit with panic should report not committed") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared after async commit panic", got) + } +} + +func TestCommitAsyncStartResultWithContext_SkipsCanceledCommit(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 4, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if commitAsyncStartResultWithContext(ctx, result, nil, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("canceled async commit should report not committed") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + 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) + } +} + +func TestCommitAsyncStartResultWithContext_StopsCanceledSuccessfulPendingCreateRuntime(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 4, 15, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{}); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "tok-worker"); err != nil { + t.Fatal(err) + } + if err := sp.SetMeta("worker", "GC_RUNTIME_EPOCH", "2"); err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "success", + started: clk.Now(), + finished: clk.Now(), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if commitAsyncStartResultWithContext(ctx, result, sp, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("canceled async success should report not committed") + } + if sp.IsRunning("worker") { + t.Fatal("canceled async success should stop the runtime it started") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared so the next controller can retry", got) + } + if got := updated.Metadata["pending_create_claim"]; got != "true" { + t.Fatalf("pending_create_claim = %q, want true for next-tick retry", got) + } +} + +func TestCommitAsyncStartResultWithContext_RollsBackCanceledPendingCreateError(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 4, 30, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + }), + }) + if err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + err: context.Canceled, + outcome: "canceled", + started: clk.Now(), + finished: clk.Now(), + rollbackPending: true, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if commitAsyncStartResultWithContext(ctx, result, nil, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}, nil) { + t.Fatal("canceled async error commit should report not committed") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Status != "closed" { + t.Fatalf("status = %q, want closed so pending-create can be retried by replacement bead", updated.Status) + } +} + +func TestCommitStartResult_SessionInitializingClearsInFlightLease(t *testing.T) { + store := beads.NewMemStore() + clk := &clock.Fake{Time: time.Date(2026, 4, 26, 12, 5, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-worker", + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: creatingMeta(map[string]string{ + "session_name": "worker", + "template": "worker", + "generation": "2", + "continuation_epoch": "1", + "instance_token": "tok-worker", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), + }) + if err != nil { + t.Fatal(err) + } + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "worker", + SessionName: "worker", + TemplateName: "worker", + }, + }, + }, + outcome: "session_initializing", + started: clk.Now(), + finished: clk.Now(), + rollbackPending: true, + } + + if commitStartResult(result, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}) { + t.Fatal("session_initializing result should not count as committed") + } + updated, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if updated.Status != "open" { + t.Fatalf("status = %q, want open", updated.Status) + } + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared for next-tick retry", got) + } + if got := updated.Metadata["pending_create_claim"]; got != "true" { + t.Fatalf("pending_create_claim = %q, want true", got) + } +} + +func TestCommitStartResult_RollbackPendingErrorClearsInFlightLeaseWhenCloseFails(t *testing.T) { + store := &failingCloseStore{MemStore: beads.NewMemStore()} + clk := &clock.Fake{Time: time.Date(2026, 4, 28, 13, 0, 0, 0, time.UTC)} + session, err := store.Create(beads.Bead{ + ID: "gc-shortlived", + Title: "shortlived", Type: sessionBeadType, Labels: []string{sessionBeadLabel}, - Metadata: map[string]string{ - "session_name": "mayor", - "template": "mayor", - "generation": "1", - "instance_token": "tok-mayor", - "state": "creating", - "template_overrides": `{"initial_message":"hello from the user"}`, - }, + Metadata: creatingMeta(map[string]string{ + "session_name": "shortlived", + "template": "shortlived", + "generation": "2", + "instance_token": "tok-shortlived", + "pending_create_claim": "true", + "last_woke_at": clk.Now().Format(time.RFC3339), + }), }) if err != nil { t.Fatal(err) } - - prepared, err := prepareStartCandidate(startCandidate{ - session: &bead, - tp: TemplateParams{ - TemplateName: "mayor", - SessionName: "mayor", - Prompt: "startup prompt", - ResolvedProvider: &config.ResolvedProvider{ - Name: "gemini", - PromptMode: "none", + result := startResult{ + prepared: preparedStart{ + candidate: startCandidate{ + session: &session, + tp: TemplateParams{ + Command: "exit 0", + SessionName: "shortlived", + TemplateName: "shortlived", + }, }, }, - order: 0, - }, &config.City{ - Agents: []config.Agent{ - {Name: "mayor"}, - }, - }, store, &clock.Fake{Time: time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)}) - if err != nil { - t.Fatalf("prepareStartCandidate: %v", err) - } - - if prepared.cfg.PromptSuffix != "" { - t.Fatalf("prepared.cfg.PromptSuffix = %q, want empty for prompt_mode none", prepared.cfg.PromptSuffix) - } - wantNudge := "startup prompt\n\n---\n\nUser message:\nhello from the user" - if prepared.cfg.Nudge != wantNudge { - t.Fatalf("prepared.cfg.Nudge = %q, want %q", prepared.cfg.Nudge, wantNudge) + err: errors.New("session died during startup"), + outcome: "provider_error", + started: clk.Now(), + finished: clk.Now(), + rollbackPending: true, } -} -func TestExecutePlannedStarts_RevalidatesDependenciesBetweenWaveBatches(t *testing.T) { - sp := &dropDependencyAfterNStartsProvider{ - Fake: runtime.NewFake(), - dropAfter: defaultMaxParallelStartsPerWave, - depName: "db", + if commitStartResult(result, store, clk, events.Discard, 0, ioDiscard{}, ioDiscard{}) { + t.Fatal("rollback-pending error should not count as committed") } - if err := sp.Start(context.Background(), "db", runtime.Config{}); err != nil { + updated, err := store.Get(session.ID) + if err != nil { t.Fatal(err) } - cfg := &config.City{ - Agents: []config.Agent{ - {Name: "app-1", DependsOn: []string{"db"}}, - {Name: "app-2", DependsOn: []string{"db"}}, - {Name: "app-3", DependsOn: []string{"db"}}, - {Name: "app-4", DependsOn: []string{"db"}}, - {Name: "db", MaxActiveSessions: intPtr(1)}, - }, - } - store := beads.NewMemStore() - clk := &clock.Fake{Time: time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)} - desired := map[string]TemplateParams{} - var sessions []beads.Bead - for _, name := range []string{"app-1", "app-2", "app-3", "app-4"} { - desired[name] = TemplateParams{Command: name, SessionName: name, TemplateName: name} - bead := makeBead(name+"-id", creatingMeta(map[string]string{ - "session_name": name, - "template": name, - "generation": "1", - "instance_token": "tok-" + name, - })) - created, err := store.Create(beads.Bead{ - ID: bead.ID, - Title: name, - Type: sessionBeadType, - Labels: []string{sessionBeadLabel}, - Metadata: bead.Metadata, - }) - if err != nil { - t.Fatal(err) - } - sessions = append(sessions, created) + if updated.Status != "open" { + t.Fatalf("status = %q, want open after injected close failure", updated.Status) } - - poolDesired := map[string]int{"app-1": 1, "app-2": 1, "app-3": 1, "app-4": 1} - woken := reconcileSessionBeads( - context.Background(), sessions, desired, configuredSessionNames(cfg, "", store), - cfg, sp, store, nil, nil, nil, newDrainTracker(), poolDesired, false, nil, "", - nil, clk, events.Discard, 5*time.Second, 0, ioDiscard{}, ioDiscard{}, - ) - - if woken != defaultMaxParallelStartsPerWave { - t.Fatalf("woken = %d, want %d", woken, defaultMaxParallelStartsPerWave) + if got := updated.Metadata["last_woke_at"]; got != "" { + t.Fatalf("last_woke_at = %q, want cleared so the next reconciler tick can retry", got) } - for _, name := range []string{"app-1", "app-2", "app-3"} { - if !sp.IsRunning(name) { - t.Fatalf("%s should have started before dependency loss", name) - } + if got := updated.Metadata["pending_create_claim"]; got != "true" { + t.Fatalf("pending_create_claim = %q, want true for pending-create retry", got) } - if sp.IsRunning("app-4") { - t.Fatal("app-4 should be blocked after db dies between wave batches") + if pendingCreateStartInFlight(updated, clk, 0) { + t.Fatal("rollback-pending error left the pending-create bead leased") } } @@ -950,6 +2536,7 @@ func TestCommitStartResult_AtomicBatchFailureLeavesClaimIntact(t *testing.T) { "session_name_explicit": "true", "pending_create_claim": "true", "state": "creating", + "last_woke_at": "2026-03-18T12:00:00Z", }, }) if err != nil { @@ -990,6 +2577,100 @@ func TestCommitStartResult_AtomicBatchFailureLeavesClaimIntact(t *testing.T) { if got.Metadata["creation_complete_at"] != "" { t.Fatalf("creation_complete_at = %q, want empty (atomic batch failed)", got.Metadata["creation_complete_at"]) } + if got.Metadata["last_woke_at"] != "" { + t.Fatalf("last_woke_at = %q, want cleared so a failed metadata commit can retry", got.Metadata["last_woke_at"]) + } +} + +func TestRefreshConfiguredNamedStartCandidateAddsCurrentSkillFingerprint(t *testing.T) { + resetSkillCatalogCache() + cityPath := t.TempDir() + writeTemplateResolveCityConfig(t, cityPath, "file") + if err := os.WriteFile(filepath.Join(cityPath, "pack.toml"), + []byte("[pack]\nname = \"named-refresh-test\"\nversion = \"0.1.0\"\nschema = 2\n"), 0o644); err != nil { + t.Fatal(err) + } + skillDir := filepath.Join(cityPath, "skills", "plan") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: plan\ndescription: test skill\n---\nbody\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city", Provider: "claude"}, + Session: config.SessionConfig{Provider: "tmux"}, + PackSkillsDir: filepath.Join(cityPath, "skills"), + Providers: map[string]config.ProviderSpec{ + "claude": {Command: "true", PromptMode: "none", SupportsACP: boolPtr(true)}, + }, + Agents: []config.Agent{{ + Name: "mayor", + Scope: "city", + Provider: "claude", + }}, + NamedSessions: []config.NamedSession{{ + Template: "mayor", + Mode: "always", + }}, + } + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Title: "mayor", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "mayor", + "session_name_explicit": boolMetadata(true), + "template": "mayor", + "agent_name": "mayor", + "state": string(sessionpkg.StateCreating), + "pending_create_claim": "true", + namedSessionMetadataKey: boolMetadata(true), + namedSessionIdentityMetadata: "mayor", + namedSessionModeMetadata: "always", + "continuation_epoch": "1", + "generation": "1", + "instance_token": sessionpkg.NewInstanceToken(), + }, + }) + if err != nil { + t.Fatal(err) + } + + stale := TemplateParams{ + TemplateName: "mayor", + SessionName: "mayor", + InstanceName: "mayor", + Command: "true", + WorkDir: cityPath, + } + candidate := startCandidate{session: &bead, tp: stale} + refreshed := refreshConfiguredNamedStartCandidate( + candidate, + cityPath, + cfg.Workspace.Name, + cfg, + runtime.NewFake(), + store, + &clock.Fake{Time: time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC)}, + ioDiscard{}, + ) + + if _, ok := stale.FPExtra["skills:plan"]; ok { + t.Fatal("test setup invalid: stale candidate already had skills fingerprint") + } + if got := refreshed.tp.FPExtra["skills:plan"]; got == "" { + t.Fatalf("refreshed FPExtra missing skills:plan: %#v", refreshed.tp.FPExtra) + } + if refreshed.tp.ConfiguredNamedIdentity != "mayor" { + t.Fatalf("ConfiguredNamedIdentity = %q, want mayor", refreshed.tp.ConfiguredNamedIdentity) + } + if runtime.CoreFingerprint(templateParamsToConfig(refreshed.tp)) == runtime.CoreFingerprint(templateParamsToConfig(stale)) { + t.Fatal("refreshed candidate core fingerprint did not change after skill FPExtra refresh") + } } func TestExecutePlannedStartsClearsLegacyDrainAckAfterProviderStartBeforeMetadataRetry(t *testing.T) { @@ -1910,9 +3591,7 @@ func TestExecutePreparedStartWave_PanicIncludesStackTrace(t *testing.T) { }}, &panicStartProvider{Fake: runtime.NewFake()}, nil, - nil, time.Second, - 1, ) if len(results) != 1 { t.Fatalf("len(results) = %d, want 1", len(results)) @@ -2038,7 +3717,7 @@ func TestCandidateWaveOrder_FallsBackToSerialOnCycle(t *testing.T) { }, } - waves, ok := candidateWaveOrder(candidates, cfg, map[string]TemplateParams{}, runtime.NewFake(), "city", nil) + waves, ok := candidateWaveOrder(candidates, cfg, map[string]TemplateParams{}, runtime.NewFake(), "city", nil, clock.Real{}) if ok { t.Fatal("expected serial fallback for cycle") } @@ -2104,7 +3783,7 @@ func TestCandidateWaveOrder_UsesLegacyAgentLabelTemplate(t *testing.T) { }, } - waves, ok := candidateWaveOrder(candidates, cfg, map[string]TemplateParams{}, runtime.NewFake(), "city", store) + waves, ok := candidateWaveOrder(candidates, cfg, map[string]TemplateParams{}, runtime.NewFake(), "city", store, clock.Real{}) if !ok { t.Fatal("unexpected serial fallback") } @@ -2133,7 +3812,23 @@ func (p *dieAfterStartProvider) IsRunning(name string) bool { return p.Fake.IsRunning(name) } +// zombieAfterStartProvider leaves the runtime container/pane present but marks +// the actual agent process dead. This matches wrappers that keep tmux alive +// after the CLI exits with a stale resume-session error. +type zombieAfterStartProvider struct { + *runtime.Fake +} + +func (p *zombieAfterStartProvider) Start(ctx context.Context, name string, cfg runtime.Config) error { + if err := p.Fake.Start(ctx, name, cfg); err != nil { + return err + } + p.Zombies[name] = true + return nil +} + func TestExecutePreparedStartWave_StaleSessionKeyDetected(t *testing.T) { + skipSlowCmdGCTest(t, "waits through stale session-key detection; run make test-cmd-gc-process for full coverage") sp := &dieAfterStartProvider{Fake: runtime.NewFake()} item := preparedStart{ candidate: startCandidate{ @@ -2159,9 +3854,7 @@ func TestExecutePreparedStartWave_StaleSessionKeyDetected(t *testing.T) { []preparedStart{item}, sp, nil, - nil, 10*time.Second, - 1, ) if len(results) != 1 { @@ -2176,6 +3869,50 @@ func TestExecutePreparedStartWave_StaleSessionKeyDetected(t *testing.T) { } } +func TestExecutePreparedStartWave_StaleSessionKeyDetectedWhenPaneSurvives(t *testing.T) { + sp := &zombieAfterStartProvider{Fake: runtime.NewFake()} + item := preparedStart{ + candidate: startCandidate{ + session: &beads.Bead{ + ID: "gc-99", + Metadata: map[string]string{ + "session_name": "test-agent", + "session_key": "stale-key-abc", + "template": "worker", + }, + }, + tp: TemplateParams{ + Command: "claude --resume stale-key-abc", + SessionName: "test-agent", + TemplateName: "worker", + }, + }, + cfg: runtime.Config{ + Command: "claude --resume stale-key-abc", + ProcessNames: []string{"claude"}, + }, + } + + results := executePreparedStartWave( + context.Background(), + []preparedStart{item}, + sp, + nil, + 10*time.Second, + ) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + if r.err == nil { + t.Fatal("expected error for dead agent process left behind in a live pane") + } + if !strings.Contains(r.err.Error(), "died during startup") { + t.Fatalf("unexpected error: %v", r.err) + } +} + func TestExecutePreparedStartWave_NoStaleCheckWithoutSessionKey(t *testing.T) { // Session without a session_key should not trigger stale detection, // even if the session dies after start. @@ -2203,9 +3940,7 @@ func TestExecutePreparedStartWave_NoStaleCheckWithoutSessionKey(t *testing.T) { []preparedStart{item}, sp, nil, - nil, 10*time.Second, - 1, ) if len(results) != 1 { @@ -2590,3 +4325,106 @@ func TestCommitStartResult_TransitionsCreatingToActive(t *testing.T) { t.Errorf("started_config_hash = %q, want %q", got.Metadata["started_config_hash"], "core-abc") } } + +func TestCommitStartResult_PersistsMCPIdentityForACPStart(t *testing.T) { + store := beads.NewMemStore() + session, err := store.Create(beads.Bead{ + Title: "worker-session", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "template": "worker", + "agent_name": "myrig/worker-adhoc-123", + "session_name": "worker-1", + "state": "creating", + }, + }) + if err != nil { + t.Fatal(err) + } + candidate := startCandidate{ + session: &session, + tp: TemplateParams{ + TemplateName: "worker", + InstanceName: "worker-1", + IsACP: true, + }, + } + result := startResult{ + prepared: preparedStart{ + candidate: candidate, + cfg: runtime.Config{ + MCPServers: []runtime.MCPServerConfig{{ + Name: "filesystem", + Transport: runtime.MCPTransportStdio, + Command: "/bin/mcp", + }}, + }, + coreHash: "core-abc", + liveHash: "live-xyz", + }, + outcome: "success", + started: time.Unix(100, 0), + finished: time.Unix(101, 0), + } + rec := events.NewFake() + ok := commitStartResult(result, store, &clock.Fake{Time: time.Unix(102, 0)}, rec, 0, ioDiscard{}, ioDiscard{}) + if !ok { + t.Fatal("commitStartResult returned false for successful start") + } + got, err := store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got.Metadata[sessionpkg.MCPIdentityMetadataKey] != "myrig/worker-adhoc-123" { + t.Fatalf("mcp_identity = %q, want %q", got.Metadata[sessionpkg.MCPIdentityMetadataKey], "myrig/worker-adhoc-123") + } + if got.Metadata[sessionpkg.MCPServersSnapshotMetadataKey] == "" { + t.Fatal("mcp_servers_snapshot = empty, want persisted snapshot") + } +} + +func TestStopTargetThroughWorkerBoundary_CityStopLeavesSessionAsleep(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + 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", + "sleep_reason": sleepReasonCityStop, + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := sp.Start(context.Background(), "control-dispatcher", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + + err = stopTargetThroughWorkerBoundary(stopTarget{ + sessionID: session.ID, + name: "control-dispatcher", + resolved: true, + }, store, sp, &config.City{}) + if err != nil { + t.Fatalf("stopTargetThroughWorkerBoundary: %v", err) + } + + got, err := store.Get(session.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Metadata["state"] != string(sessionpkg.StateAsleep) { + t.Fatalf("state = %q, want %q", got.Metadata["state"], sessionpkg.StateAsleep) + } + if got.Metadata["sleep_reason"] != sleepReasonCityStop { + t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], sleepReasonCityStop) + } + if got.Metadata["suspended_at"] != "" { + t.Fatalf("suspended_at = %q, want empty", got.Metadata["suspended_at"]) + } +} diff --git a/cmd/gc/session_lifecycle_start_boundary_test.go b/cmd/gc/session_lifecycle_start_boundary_test.go index fb7031e6d..5ea5dc3c4 100644 --- a/cmd/gc/session_lifecycle_start_boundary_test.go +++ b/cmd/gc/session_lifecycle_start_boundary_test.go @@ -37,9 +37,7 @@ func TestExecutePreparedStartWaveUsesWorkerBoundaryForKnownSession(t *testing.T) }}, sp, store, - nil, 10*time.Second, - 1, ) if len(results) != 1 { t.Fatalf("len(results) = %d, want 1", len(results)) diff --git a/cmd/gc/session_manager_test.go b/cmd/gc/session_manager_test.go index 5624d9b72..d4f86a281 100644 --- a/cmd/gc/session_manager_test.go +++ b/cmd/gc/session_manager_test.go @@ -1,6 +1,8 @@ package main import ( + "strings" + "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/runtime" @@ -12,11 +14,36 @@ func newSessionManagerWithConfig(cityPath string, store beads.Store, sp runtime. return session.NewManagerWithCityPath(store, sp, cityPath) } rigContext := currentRigContext(cfg) - return session.NewManagerWithTransportResolverAndCityPath(store, sp, cityPath, func(template string) string { + return session.NewManagerWithTransportPolicyResolverAndCityPath(store, sp, cityPath, func(template, provider string) (string, bool) { agentCfg, ok := resolveAgentIdentity(cfg, template, rigContext) - if !ok { - return "" + if ok { + resolved, err := config.ResolveProvider( + &agentCfg, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return agentCfg.Session, strings.TrimSpace(agentCfg.Session) != "" + } + return config.ResolveSessionCreateTransport(agentCfg.Session, resolved), strings.TrimSpace(agentCfg.Session) != "" + } + provider = strings.TrimSpace(provider) + if provider == "" { + provider = strings.TrimSpace(template) + } + if provider == "" { + return "", false + } + resolved, err := config.ResolveProvider( + &config.Agent{Provider: provider}, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return "", false } - return agentCfg.Session + return strings.TrimSpace(resolved.ProviderSessionCreateTransport()), false }) } diff --git a/cmd/gc/session_model_phase0_hook_spec_test.go b/cmd/gc/session_model_phase0_hook_spec_test.go index 49c8b5c5e..2c77398ed 100644 --- a/cmd/gc/session_model_phase0_hook_spec_test.go +++ b/cmd/gc/session_model_phase0_hook_spec_test.go @@ -49,7 +49,7 @@ work_query = "printf 'pwd=%s|agent=%s|template=%s|session=%s|origin=%s' \"$PWD\" } 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()) } @@ -107,7 +107,7 @@ work_query = "printf 'agent=%s|template=%s|session=%s|origin=%s' \"$GC_AGENT\" \ } 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()) } @@ -169,7 +169,7 @@ start_command = "true" } 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()) } diff --git a/cmd/gc/session_model_phase0_rare_state_spec_test.go b/cmd/gc/session_model_phase0_rare_state_spec_test.go index 7630b8eb0..efd73c5b7 100644 --- a/cmd/gc/session_model_phase0_rare_state_spec_test.go +++ b/cmd/gc/session_model_phase0_rare_state_spec_test.go @@ -156,14 +156,14 @@ func TestPhase0ConfigDrift_IdleNamedSessionRestartsInPlaceWithoutCapVacancy(t *t if all[0].Status != "open" { t.Fatalf("status = %q, want open while live restart is in progress", all[0].Status) } - if got := all[0].Metadata["state"]; got != "creating" { - t.Fatalf("state = %q, want creating for idle config-drift restart without cap vacancy", got) + if got := all[0].Metadata["state"]; got != "active" { + t.Fatalf("state = %q, want active after same-tick config-drift restart", got) } - if got := all[0].Metadata["started_config_hash"]; got != "" { - t.Fatalf("started_config_hash = %q, want cleared so next start uses fresh config", got) + if got := all[0].Metadata["started_config_hash"]; got == "" || got == runtime.CoreFingerprint(oldRuntime) { + t.Fatalf("started_config_hash = %q, want non-empty fresh config hash", got) } - if got := all[0].Metadata["continuation_reset_pending"]; got != "true" { - t.Fatalf("continuation_reset_pending = %q, want true for unified restart path", got) + if got := all[0].Metadata["continuation_reset_pending"]; got != "" { + t.Fatalf("continuation_reset_pending = %q, want cleared after same-tick wake", got) } } @@ -235,8 +235,8 @@ func TestPhase0ConfigDrift_NamedSessionBoundsRecentActivityDeferral(t *testing.T if err != nil { t.Fatalf("Get(%s) after deferral limit: %v", session.ID, err) } - if got.Metadata["state"] != "creating" { - t.Fatalf("state = %q, want creating after bounded recent-activity deferral", got.Metadata["state"]) + if got.Metadata["state"] != "active" { + t.Fatalf("state = %q, want active after bounded recent-activity restart", got.Metadata["state"]) } if got.Metadata[namedSessionConfigDriftDeferredAtMetadata] != "" { t.Fatalf("deferred timestamp = %q, want cleared after restart", got.Metadata[namedSessionConfigDriftDeferredAtMetadata]) @@ -293,8 +293,8 @@ func TestPhase0ConfigDrift_NamedSessionDrainsWhenStaleActivity(t *testing.T) { if err != nil { t.Fatalf("Get(%s): %v", session.ID, err) } - if got.Metadata["state"] != "creating" { - t.Fatalf("state = %q, want creating for stale-activity config-drift restart", got.Metadata["state"]) + if got.Metadata["state"] != "active" { + t.Fatalf("state = %q, want active after stale-activity config-drift restart", got.Metadata["state"]) } } diff --git a/cmd/gc/session_model_phase0_workflow_spec_test.go b/cmd/gc/session_model_phase0_workflow_spec_test.go index cb6ebef1d..87fd78e71 100644 --- a/cmd/gc/session_model_phase0_workflow_spec_test.go +++ b/cmd/gc/session_model_phase0_workflow_spec_test.go @@ -231,8 +231,11 @@ func TestPhase0WorkflowRouting_ControlStepPreservesExecutionConfigLane(t *testin if check == nil { t.Fatal("scope-check step missing after decorate") } - if got := check.Metadata["gc.routed_to"]; got != "frontend/control-dispatcher" { - t.Fatalf("scope-check gc.routed_to = %q, want frontend/control-dispatcher", got) + if got := check.Assignee; got != "frontend--control-dispatcher" { + t.Fatalf("scope-check assignee = %q, want frontend--control-dispatcher", got) + } + if got := check.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("scope-check gc.routed_to = %q, want empty direct dispatcher assignee", got) } if got := check.Metadata[graphExecutionRouteMetaKey]; got != "frontend/codex" { t.Fatalf("scope-check execution route = %q, want frontend/codex", got) diff --git a/cmd/gc/session_reconcile.go b/cmd/gc/session_reconcile.go index 53c84a8e5..2e4582e80 100644 --- a/cmd/gc/session_reconcile.go +++ b/cmd/gc/session_reconcile.go @@ -506,6 +506,13 @@ func checkStability(session *beads.Bead, cfg *config.City, alive bool, dt *drain if lastWoke == "" { return false } + var startupTimeout time.Duration + if cfg != nil { + startupTimeout = cfg.Session.StartupTimeoutDuration() + } + if pendingCreateStartInFlight(*session, clk, startupTimeout) { + return false + } t, err := time.Parse(time.RFC3339, lastWoke) if err != nil { return false @@ -569,6 +576,10 @@ func recordWakeFailure(session *beads.Bead, store beads.Store, clk clock.Clock) // clearWakeFailures resets crash counter and quarantine for a stable session. func clearWakeFailures(session *beads.Bead, store beads.Store) { + attempts := session.Metadata["wake_attempts"] + if (attempts == "" || attempts == "0") && session.Metadata["quarantined_until"] == "" { + return + } batch := map[string]string{ "wake_attempts": "0", "quarantined_until": "", diff --git a/cmd/gc/session_reconcile_test.go b/cmd/gc/session_reconcile_test.go index 49f10ec2b..d4dbda11f 100644 --- a/cmd/gc/session_reconcile_test.go +++ b/cmd/gc/session_reconcile_test.go @@ -917,6 +917,28 @@ func TestCheckStability_RapidExit(t *testing.T) { } } +func TestCheckStability_PendingCreateInFlightNotCounted(t *testing.T) { + now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) + clk := &clock.Fake{Time: now} + store := newTestStore() + dt := newDrainTracker() + session := makeBead("b1", map[string]string{ + "last_woke_at": now.Add(-10 * time.Second).Format(time.RFC3339), + "pending_create_claim": "true", + "wake_attempts": "0", + }) + + if checkStability(&session, nil, false, dt, store, clk) { + t.Fatal("in-flight pending create should not be counted as a rapid exit") + } + if got := session.Metadata["wake_attempts"]; got != "0" { + t.Fatalf("wake_attempts = %q, want 0", got) + } + if got := session.Metadata["last_woke_at"]; got == "" { + t.Fatal("last_woke_at should remain while pending create is still in flight") + } +} + func TestCheckStability_DrainingNotCounted(t *testing.T) { now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) clk := &clock.Fake{Time: now} @@ -1072,6 +1094,49 @@ func TestClearWakeFailures(t *testing.T) { } } +func TestClearWakeFailures_SkipsWriteWhenAlreadyClear(t *testing.T) { + tests := []struct { + name string + meta map[string]string + wantNil bool + }{ + { + name: "zero attempts and empty quarantine", + meta: map[string]string{"wake_attempts": "0", "quarantined_until": ""}, + wantNil: true, + }, + { + name: "missing attempts and empty quarantine", + meta: map[string]string{}, + wantNil: true, + }, + { + name: "nonzero attempts triggers write", + meta: map[string]string{"wake_attempts": "3", "quarantined_until": ""}, + wantNil: false, + }, + { + name: "quarantine set triggers write", + meta: map[string]string{"wake_attempts": "0", "quarantined_until": "2026-03-08T12:00:00Z"}, + wantNil: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newTestStore() + session := makeBead("b1", tt.meta) + clearWakeFailures(&session, store) + wrote := len(store.metadata["b1"]) > 0 + if tt.wantNil && wrote { + t.Errorf("expected no store write, but got %v", store.metadata["b1"]) + } + if !tt.wantNil && !wrote { + t.Error("expected a store write, but none occurred") + } + }) + } +} + func TestStableLongEnough(t *testing.T) { now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) clk := &clock.Fake{Time: now} diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 6bfa81e7c..b61dadfc3 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -94,6 +94,18 @@ func allDependenciesAliveForTemplate( sp runtime.Provider, cityName string, store beads.Store, +) bool { + return allDependenciesAliveForTemplateWithClock(template, cfg, desiredState, sp, cityName, store, clock.Real{}) +} + +func allDependenciesAliveForTemplateWithClock( + template string, + cfg *config.City, + desiredState map[string]TemplateParams, + sp runtime.Provider, + cityName string, + store beads.Store, + clk clock.Clock, ) bool { cfgAgent := findAgentByTemplate(cfg, template) if cfgAgent == nil || len(cfgAgent.DependsOn) == 0 { @@ -104,7 +116,7 @@ func allDependenciesAliveForTemplate( if depCfg == nil { continue // dependency not in config — skip } - if !dependencyTemplateAlive(dep, cfg, desiredState, sp, cityName, store) { + if !dependencyTemplateAlive(dep, cfg, desiredState, sp, cityName, store, clk) { return false } } @@ -122,7 +134,7 @@ func allDependenciesAlive( cityName string, store beads.Store, ) bool { - return allDependenciesAliveForTemplate(normalizedSessionTemplate(session, cfg), cfg, desiredState, sp, cityName, store) + return allDependenciesAliveForTemplateWithClock(normalizedSessionTemplate(session, cfg), cfg, desiredState, sp, cityName, store, clock.Real{}) } func pendingCreateSessionStillLeased(session beads.Bead, cfg *config.City, clk clock.Clock) bool { @@ -137,6 +149,31 @@ func pendingCreateSessionStillLeased(session beads.Bead, cfg *config.City, clk c return agent != nil && !agent.Suspended } +func pendingCreateStartInFlight(session beads.Bead, clk clock.Clock, startupTimeout time.Duration) bool { + if strings.TrimSpace(session.Metadata["pending_create_claim"]) != "true" { + return false + } + lastWoke := strings.TrimSpace(session.Metadata["last_woke_at"]) + if lastWoke == "" { + return false + } + started, err := time.Parse(time.RFC3339, lastWoke) + if err != nil { + return false + } + if startupTimeout <= 0 { + // Disabling the provider Start() deadline must not disable stuck-bead + // recovery forever. Use the default lease window for in-flight detection + // while leaving the actual Start() context unwrapped. + startupTimeout = time.Minute + } + now := time.Now() + if clk != nil { + now = clk.Now() + } + return now.Before(started.Add(startupTimeout + staleKeyDetectDelay + 5*time.Second)) +} + // reconcileSessionBeads performs bead-driven reconciliation using wake/sleep // semantics. For each session bead, it determines if the session should be // awake (has a matching entry in the desired state) and manages lifecycle @@ -155,7 +192,7 @@ func pendingCreateSessionStillLeased(session beads.Bead, cfg *config.City, clk c // suspended agents). Used to distinguish "orphaned" (removed from config) // from "suspended" (still in config, not runnable) when closing beads. // -// Returns the number of sessions woken this tick. +// Returns the number of start attempts issued or enqueued this tick. // //nolint:unparam // compatibility wrapper retains the full production signature. func reconcileSessionBeads( @@ -249,6 +286,7 @@ func reconcileSessionBeadsTraced( driftDrainTimeout time.Duration, stdout, stderr io.Writer, trace *sessionReconcilerTraceCycle, + startOptions ...startExecutionOption, ) int { deps := buildDepsMap(cfg) if cityName == "" { @@ -272,7 +310,7 @@ func reconcileSessionBeadsTraced( } } sessions = retireDuplicateConfiguredNamedSessionBeads( - store, sp, cfg, cityName, sessions, bySessionName, indexBySessionName, clk.Now().UTC(), stderr, + store, rigStores, sp, cfg, cityName, sessions, bySessionName, indexBySessionName, clk.Now().UTC(), stderr, ) } @@ -358,6 +396,59 @@ func reconcileSessionBeadsTraced( } continue default: + if dops != nil { + if acked, _ := dops.isDrainAcked(name); acked { + stopped := !providerAlive + if providerAlive { + if err := workerKillSessionTargetWithConfig("", store, sp, cfg, name); err != nil { + fmt.Fprintf(stderr, "session reconciler: stopping drain-acked %s: %v\n", name, err) //nolint:errcheck + } else { + stopped = true + fmt.Fprintf(stdout, "Stopped drain-acked session '%s'\n", name) //nolint:errcheck + } + } + if stopped { + template := normalizedSessionTemplate(*session, cfg) + if template == "" { + template = session.Metadata["template"] + } + rec.Record(events.Event{ + Type: events.SessionStopped, + Actor: "gc", + Subject: template, + Message: "drain acknowledged by agent", + }) + hasAssignedWork, assignedErr := sessionHasOpenAssignedWork(store, rigStores, *session) + if assignedErr != nil { + fmt.Fprintf(stderr, "session reconciler: checking assigned work for drain-acked %s: %v\n", name, assignedErr) //nolint:errcheck + hasAssignedWork = true + } + if hasAssignedWork { + batch := sessionpkg.CompleteDrainPatch(clk.Now().UTC(), "idle", session.Metadata["wake_mode"] == "fresh") + _ = store.SetMetadataBatch(session.ID, batch) + if session.Metadata == nil { + session.Metadata = make(map[string]string, len(batch)) + } + for key, value := range batch { + session.Metadata[key] = value + } + _ = dops.clearDrain(name) + if dt != nil { + dt.clearIdleProbe(session.ID) + dt.remove(session.ID) + } + continue + } + _ = dops.clearDrain(name) + if dt != nil { + dt.clearIdleProbe(session.ID) + dt.remove(session.ID) + } + closeSessionBeadIfUnassigned(store, rigStores, *session, "drained", clk.Now().UTC(), stderr) + } + continue + } + } if providerAlive { // When a store query failed (partial results), // skip drain — the session may have work that we @@ -523,6 +614,7 @@ func reconcileSessionBeadsTraced( policy := resolveSessionSleepPolicy(*session, cfg, sp) // Heal advisory state metadata. + stateBeforeHeal := sessionpkg.State(strings.TrimSpace(session.Metadata["state"])) healState(session, alive, store, clk) if recoverPendingIdleSleep(session, store, running, clk) { alive = false @@ -551,6 +643,12 @@ func reconcileSessionBeadsTraced( clearChurn(session, store) } if alive && shouldRollbackPendingCreate(session) { + if stateBeforeHeal == sessionpkg.StateCreating && pendingCreateStartInFlight(*session, clk, startupTimeout) { + if trace != nil { + trace.recordDecision("reconciler.session.pending_create", tp.TemplateName, name, "pending_create_recovery_in_flight", "deferred", nil, nil, "") + } + continue + } if !recoverRunningPendingCreate(session, tp, cfg, store, clk, trace) { fmt.Fprintf(stderr, "session reconciler: recovering pending create %s: metadata repair incomplete\n", name) //nolint:errcheck } @@ -656,6 +754,7 @@ func reconcileSessionBeadsTraced( _ = json.Unmarshal([]byte(raw), &storedBreakdown) } runtime.LogCoreFingerprintDrift(stderr, name, storedBreakdown, agentCfg) + restartedInPlace := false if isNamedSessionBead(*session) { // Defer config-drift restart for named sessions // that are actively in use (pending interaction, @@ -689,83 +788,70 @@ func reconcileSessionBeadsTraced( Subject: tp.DisplayName(), Message: "config drift detected", }) - continue + alive = false + restartedInPlace = true } - // Defer ordinary-session config-drift drain while a - // user is attached. Named-session config drift is - // deferred when actively in use (see above). - if pendingInteractionKeepsAwake(*session, sp, name, clk) { - drainCancelled := false - if dt != nil { - drainCancelled = cancelSessionDrainForPending(*session, sp, dt) + if !restartedInPlace { + // Defer ordinary-session config-drift drain while a + // user is attached. Named-session config drift is + // deferred when actively in use (see above). + if pendingInteractionKeepsAwake(*session, sp, name, clk) { + drainCancelled := false + if dt != nil { + drainCancelled = cancelSessionDrainForPending(*session, sp, dt) + } + if trace != nil { + trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "pending", "deferred_pending", traceRecordPayload{ + "stored_hash": storedHash, + "current_hash": currentHash, + "drain_canceled": drainCancelled, + }, nil, "") + } + continue } - if trace != nil { - trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "pending", "deferred_pending", traceRecordPayload{ - "stored_hash": storedHash, - "current_hash": currentHash, - "drain_canceled": drainCancelled, - }, nil, "") + attached, err := workerSessionTargetAttachedWithConfig(cityPath, store, sp, cfg, session.ID) + if err == nil && attached { + if trace != nil { + trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "deferred_attached", traceRecordPayload{ + "stored_hash": storedHash, + "current_hash": currentHash, + }, nil, "") + } + continue } - continue - } - attached, err := workerSessionTargetAttachedWithConfig(cityPath, store, sp, cfg, session.ID) - if err == nil && attached { - if trace != nil { - trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "deferred_attached", traceRecordPayload{ - "stored_hash": storedHash, - "current_hash": currentHash, - }, nil, "") + // Defer ordinary-session config-drift drain while a + // user is attached. Named-session config drift is + // non-deferrable and is handled above. + if sp.IsAttached(name) { + if trace != nil { + trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "deferred_attached", traceRecordPayload{ + "stored_hash": storedHash, + "current_hash": currentHash, + }, nil, "") + } + continue } - continue - } - if isNamedSessionBead(*session) { - resetConfiguredNamedSessionForConfigDrift(session, store, sp, name, alive, "creating", stderr) - if trace != nil { - trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "restart_in_place", traceRecordPayload{ - "stored_hash": storedHash, - "current_hash": currentHash, - }, nil, "") + ddt := driftDrainTimeout + if ddt <= 0 { + ddt = defaultDrainTimeout } - rec.Record(events.Event{ - Type: events.SessionDraining, - Actor: "gc", - Subject: tp.DisplayName(), - Message: "config drift detected", - }) - continue - } - // Defer ordinary-session config-drift drain while a - // user is attached. Named-session config drift is - // non-deferrable and is handled above. - if sp.IsAttached(name) { - if trace != nil { - trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "deferred_attached", traceRecordPayload{ - "stored_hash": storedHash, - "current_hash": currentHash, - }, nil, "") + if beginSessionDrain(*session, sp, dt, "config-drift", clk, ddt) { + fmt.Fprintf(stdout, "Draining session '%s': config-drift\n", name) //nolint:errcheck + if trace != nil { + trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "drain", traceRecordPayload{ + "stored_hash": storedHash, + "current_hash": currentHash, + }, nil, "") + } + rec.Record(events.Event{ + Type: events.SessionDraining, + Actor: "gc", + Subject: tp.DisplayName(), + Message: "config drift detected", + }) } continue } - ddt := driftDrainTimeout - if ddt <= 0 { - ddt = defaultDrainTimeout - } - if beginSessionDrain(*session, sp, dt, "config-drift", clk, ddt) { - fmt.Fprintf(stdout, "Draining session '%s': config-drift\n", name) //nolint:errcheck - if trace != nil { - trace.recordDecision("reconciler.session.config_drift", tp.TemplateName, name, "config_drift", "drain", traceRecordPayload{ - "stored_hash": storedHash, - "current_hash": currentHash, - }, nil, "") - } - rec.Record(events.Event{ - Type: events.SessionDraining, - Actor: "gc", - Subject: tp.DisplayName(), - Message: "config drift detected", - }) - } - continue } if isNamedSessionBead(*session) { @@ -938,6 +1024,15 @@ func reconcileSessionBeadsTraced( if sessionIsQuarantined(*target.session, clk) { continue // crash-loop protection } + if pendingCreateStartInFlight(*target.session, clk, startupTimeout) { + if trace != nil { + trace.recordDecision("reconciler.session.wake", target.tp.TemplateName, name, "wake", "start_in_flight", traceRecordPayload{ + "pending_create_claim": strings.TrimSpace(target.session.Metadata["pending_create_claim"]), + "last_woke_at": target.session.Metadata["last_woke_at"], + }, nil, "") + } + continue + } if trace != nil { trace.recordDecision("reconciler.session.wake", target.tp.TemplateName, name, "wake", "start_candidate", traceRecordPayload{ "should_wake": shouldWake, @@ -1043,7 +1138,9 @@ func reconcileSessionBeadsTraced( plannedWakes := executePlannedStartsTraced( ctx, startCandidates, cfg, desiredState, sp, store, cityName, + cityPath, clk, rec, startupTimeout, stdout, stderr, trace, + startOptions..., ) // Phase 2: Advance all in-flight drains. @@ -1127,11 +1224,7 @@ func sessionHasOpenAssignedWorkInStore(store beads.Store, session beads.Bead) (b if store == nil { return false, nil } - identifiers := []string{ - strings.TrimSpace(session.ID), - strings.TrimSpace(session.Metadata["session_name"]), - strings.TrimSpace(session.Metadata[namedSessionIdentityMetadata]), - } + identifiers := sessionAssignmentIdentifiers(session) seen := make(map[string]struct{}, len(identifiers)) for _, status := range []string{"open", "in_progress"} { for _, assignee := range identifiers { diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index e964eb579..85e51b272 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -365,6 +365,132 @@ func TestReconcileSessionBeads_DrainAckWithAssignedOpenWorkSleepsInsteadOfDraini } } +func TestReconcileSessionBeads_UndesiredDrainAckStopsAndCloses(t *testing.T) { + env := newReconcilerTestEnv() + session := env.createSessionBead("worker", "worker") + env.markSessionActive(&session) + if err := env.sp.Start(context.Background(), "worker", runtime.Config{Command: "test-cmd"}); err != nil { + t.Fatalf("Start(worker): %v", err) + } + + dops := newFakeDrainOps() + if err := dops.setDrainAck("worker"); err != nil { + t.Fatalf("setDrainAck: %v", err) + } + + woken := reconcileSessionBeads( + context.Background(), + []beads.Bead{session}, + env.desiredState, + nil, + env.cfg, + env.sp, + env.store, + dops, + nil, + nil, + env.dt, + nil, + false, + nil, + "", + nil, + env.clk, + env.rec, + 0, + 0, + &env.stdout, + &env.stderr, + ) + if woken != 0 { + t.Fatalf("woken = %d, want 0", woken) + } + if env.sp.IsRunning("worker") { + t.Fatal("worker should be stopped after drain-ack even after leaving desired state") + } + + got, err := env.store.Get(session.ID) + if err != nil { + t.Fatalf("Get(%s): %v", session.ID, err) + } + if got.Status != "closed" { + t.Fatalf("status = %q, want closed; metadata=%v", got.Status, got.Metadata) + } + if got.Metadata["close_reason"] != "drained" { + t.Fatalf("close_reason = %q, want drained", got.Metadata["close_reason"]) + } +} + +func TestReconcileSessionBeads_UndesiredDrainAckWithAssignedOpenWorkSleepsInsteadOfClosing(t *testing.T) { + env := newReconcilerTestEnv() + session := env.createSessionBead("worker", "worker") + env.markSessionActive(&session) + if err := env.sp.Start(context.Background(), "worker", runtime.Config{Command: "test-cmd"}); err != nil { + t.Fatalf("Start(worker): %v", err) + } + if _, err := env.store.Create(beads.Bead{ + Title: "future work", + Type: "task", + Status: "open", + Assignee: session.ID, + }); err != nil { + t.Fatalf("Create(future work): %v", err) + } + + dops := newFakeDrainOps() + if err := dops.setDrainAck("worker"); err != nil { + t.Fatalf("setDrainAck: %v", err) + } + + woken := reconcileSessionBeads( + context.Background(), + []beads.Bead{session}, + env.desiredState, + nil, + env.cfg, + env.sp, + env.store, + dops, + nil, + nil, + env.dt, + nil, + false, + nil, + "", + nil, + env.clk, + env.rec, + 0, + 0, + &env.stdout, + &env.stderr, + ) + if woken != 0 { + t.Fatalf("woken = %d, want 0", woken) + } + if env.sp.IsRunning("worker") { + t.Fatal("worker should be stopped after drain-ack even after leaving desired state") + } + + got, err := env.store.Get(session.ID) + if err != nil { + t.Fatalf("Get(%s): %v", session.ID, err) + } + if got.Status == "closed" { + t.Fatalf("session bead closed unexpectedly: metadata=%v", got.Metadata) + } + if got.Metadata["state"] != "asleep" { + t.Fatalf("state = %q, want asleep", got.Metadata["state"]) + } + if got.Metadata["sleep_reason"] != "idle" { + t.Fatalf("sleep_reason = %q, want idle", got.Metadata["sleep_reason"]) + } + if got.Metadata["pending_create_claim"] != "" { + t.Fatalf("pending_create_claim = %q, want cleared after drain-ack", got.Metadata["pending_create_claim"]) + } +} + // TestReconcileSessionBeads_DrainAckUsesLiveStoreQuery is the regression // guard for the stuck-pool-worker bug on ga-ttn5z. Pool workers close // their own work bead with `bd close` BEFORE calling `gc runtime @@ -1600,6 +1726,52 @@ func TestReconcileSessionBeads_NoDriftBeforeStartedHashWritten(t *testing.T) { } } +func TestReconcileSessionBeads_DefersPendingCreateRecoveryWhileStartInFlight(t *testing.T) { + env := newReconcilerTestEnv() + env.cfg = &config.City{Agents: []config.Agent{{Name: "worker"}}} + env.desiredState["worker"] = TemplateParams{ + Command: "new-cmd", + SessionName: "worker", + TemplateName: "worker", + } + session := env.createSessionBead("worker", "worker") + env.setSessionMetadata(&session, map[string]string{ + "command": "old-cmd", + "state": "creating", + "pending_create_claim": "true", + "last_woke_at": env.clk.Now().UTC().Format(time.RFC3339), + }) + if err := env.sp.Start(context.Background(), "worker", runtime.Config{Command: "old-cmd"}); err != nil { + t.Fatal(err) + } + if err := env.sp.SetMeta("worker", "GC_SESSION_ID", session.ID); err != nil { + t.Fatal(err) + } + if err := env.sp.SetMeta("worker", "GC_INSTANCE_TOKEN", session.Metadata["instance_token"]); err != nil { + t.Fatal(err) + } + + woken := env.reconcile([]beads.Bead{session}) + if woken != 0 { + t.Fatalf("woken = %d, want 0 while pending create start is still in flight", woken) + } + got, err := env.store.Get(session.ID) + if err != nil { + t.Fatal(err) + } + if got.Metadata["started_config_hash"] != "" { + t.Fatalf("started_config_hash = %q, want empty until async start commits", got.Metadata["started_config_hash"]) + } + if got.Metadata["pending_create_claim"] != "true" { + t.Fatalf("pending_create_claim = %q, want preserved while async start is in flight", got.Metadata["pending_create_claim"]) + } + switch got.Metadata["state"] { + case "creating", "awake": + default: + t.Fatalf("state = %q, want creating or awake while async start is in flight", got.Metadata["state"]) + } +} + func TestReconcileSessionBeads_PendingCreateLeasePreventsOrphanClose(t *testing.T) { env := newReconcilerTestEnv() env.cfg = &config.City{Agents: []config.Agent{{Name: "worker"}}} @@ -2175,6 +2347,44 @@ func TestReconcileSessionBeads_StableClearsFailures(t *testing.T) { } } +func TestReconcileSessionBeads_StableAlreadyClearDoesNotWriteMetadata(t *testing.T) { + env := newReconcilerTestEnv() + countingStore := newCountingMetadataStore() + env.store = countingStore + env.cfg = &config.City{Agents: []config.Agent{{Name: "worker"}}} + env.addDesired("worker", "worker", true) + session := env.createSessionBead("worker", "worker") + stableWake := env.clk.Now().Add(-2 * time.Minute).UTC().Format(time.RFC3339) + env.setSessionMetadata(&session, map[string]string{ + "state": "active", + "wake_attempts": "3", + "last_woke_at": stableWake, + "quarantined_until": "", + }) + + countingStore.singleCalls = 0 + countingStore.batchCalls = 0 + env.reconcile([]beads.Bead{session}) + if countingStore.batchCalls == 0 { + t.Fatal("first stable tick should write metadata to clear wake failures") + } + + cleared, err := env.store.Get(session.ID) + if err != nil { + t.Fatalf("getting session bead: %v", err) + } + if cleared.Metadata["wake_attempts"] != "0" { + t.Fatalf("wake_attempts after first tick = %q, want 0", cleared.Metadata["wake_attempts"]) + } + + countingStore.singleCalls = 0 + countingStore.batchCalls = 0 + env.reconcile([]beads.Bead{cleared}) + if got := countingStore.singleCalls + countingStore.batchCalls; got != 0 { + t.Fatalf("second stable tick performed %d metadata write(s), want 0", got) + } +} + func TestReconcileSessionBeads_NoAgentNotWoken(t *testing.T) { env := newReconcilerTestEnv() env.cfg = &config.City{} diff --git a/cmd/gc/session_reconciler_trace_collector.go b/cmd/gc/session_reconciler_trace_collector.go index 4f5346b3c..00870e64c 100644 --- a/cmd/gc/session_reconciler_trace_collector.go +++ b/cmd/gc/session_reconciler_trace_collector.go @@ -73,6 +73,7 @@ type SessionReconcilerTraceCycle struct { recordCount int droppedRecords int droppedBatches int + ended bool dropReasons map[string]int completionStatus TraceCompletionStatus traceMode TraceMode @@ -273,6 +274,13 @@ func (c *SessionReconcilerTraceCycle) addRecord(rec SessionReconcilerTraceRecord c.dropReasons["record_budget_exceeded"]++ return } + if c.ended { + rec.ensureFields() + rec.Fields["post_cycle_result"] = true + rec.Fields["rollup_excluded"] = true + c.records = append(c.records, rec) + return + } c.accumulateRecordLocked(rec) c.records = append(c.records, rec) c.recordCount++ @@ -874,6 +882,8 @@ func (c *SessionReconcilerTraceCycle) End(completion TraceCompletionStatus, fiel dur := now.Sub(c.start) c.mu.Lock() batch := append([]SessionReconcilerTraceRecord(nil), c.records...) + c.records = nil + c.ended = true droppedRecords := c.droppedRecords droppedBatches := c.droppedBatches dropReasons := make(map[string]int, len(c.dropReasons)) diff --git a/cmd/gc/session_reconciler_trace_integration_test.go b/cmd/gc/session_reconciler_trace_integration_test.go index 98fd0b0b8..b0c055551 100644 --- a/cmd/gc/session_reconciler_trace_integration_test.go +++ b/cmd/gc/session_reconciler_trace_integration_test.go @@ -345,6 +345,7 @@ func TestSessionReconcilerTraceStartAndDrainSubOps(t *testing.T) { sp, store, "trace-town", + "", clock.Real{}, events.NewFake(), 5*time.Second, diff --git a/cmd/gc/session_reconciler_trace_test.go b/cmd/gc/session_reconciler_trace_test.go index 53f1ca18b..a1dda921c 100644 --- a/cmd/gc/session_reconciler_trace_test.go +++ b/cmd/gc/session_reconciler_trace_test.go @@ -458,6 +458,98 @@ func TestTraceCycleResultRollupIncludesFlushedRecords(t *testing.T) { } } +func TestTraceFlushAfterEndOnlyPersistsPostEndRecords(t *testing.T) { + cityDir := t.TempDir() + tracer := newSessionReconcilerTracer(cityDir, "trace-town", io.Discard) + if !tracer.Enabled() { + t.Fatal("tracer should be enabled") + } + now := time.Now().UTC() + if _, err := tracer.armStore.upsertArm(TraceArm{ + ScopeType: TraceArmScopeTemplate, + ScopeValue: "worker", + Source: TraceArmSourceManual, + Level: TraceModeDetail, + ArmedAt: now, + ExpiresAt: now.Add(15 * time.Minute), + LastExtendedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsertArm: %v", err) + } + cycle := tracer.BeginCycle(TraceTickTriggerPatrol, "", time.Now().UTC(), &config.City{}) + if cycle == nil { + t.Fatal("BeginCycle returned nil") + } + cycle.RecordOperation( + TraceSiteLifecycleStartExecute, + TraceReasonWake, + TraceOutcomeApplied, + "provider_start", + "worker", + "worker", + 10*time.Millisecond, + map[string]any{"step": "before-end"}, + ) + if err := cycle.End(TraceCompletionCompleted, map[string]any{}); err != nil { + t.Fatalf("End: %v", err) + } + cycle.RecordOperation( + TraceSiteLifecycleStartExecute, + TraceReasonWake, + TraceOutcomeApplied, + "provider_start", + "worker", + "worker", + 20*time.Millisecond, + map[string]any{"step": "after-end"}, + ) + if err := cycle.flushCurrentBatch(TraceDurabilityDurable); err != nil { + t.Fatalf("flushCurrentBatch: %v", err) + } + if err := tracer.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + records, err := ReadTraceRecords(traceCityRuntimeDir(cityDir), TraceFilter{}) + if err != nil { + t.Fatalf("ReadTraceRecords: %v", err) + } + var beforeEnd, afterEnd int + var cycleResult *SessionReconcilerTraceRecord + for _, rec := range records { + if rec.RecordType == TraceRecordCycleResult { + recCopy := rec + cycleResult = &recCopy + continue + } + if rec.RecordType != TraceRecordOperation { + continue + } + switch rec.Fields["step"] { + case "before-end": + beforeEnd++ + case "after-end": + if got := rec.Fields["post_cycle_result"]; got != true { + t.Fatalf("post_cycle_result = %#v, want true", got) + } + if got := rec.Fields["rollup_excluded"]; got != true { + t.Fatalf("rollup_excluded = %#v, want true", got) + } + afterEnd++ + } + } + if cycleResult == nil { + t.Fatal("cycle_result missing") + } + if cycleResult.RecordCount >= len(records) { + t.Fatalf("cycle_result record_count = %d, want less than persisted records %d because post-End records are rollup-excluded", cycleResult.RecordCount, len(records)) + } + if beforeEnd != 1 || afterEnd != 1 { + t.Fatalf("operation counts before-end=%d after-end=%d, want 1 each", beforeEnd, afterEnd) + } +} + func TestTraceFlushCurrentBatchQueueFullDegrades(t *testing.T) { cityDir := t.TempDir() store, err := newSessionReconcilerTraceStore(cityDir, io.Discard) diff --git a/cmd/gc/session_template_start.go b/cmd/gc/session_template_start.go index 44f5c3631..98276d30c 100644 --- a/cmd/gc/session_template_start.go +++ b/cmd/gc/session_template_start.go @@ -119,7 +119,12 @@ func materializeSessionForTemplateWithOptions( if err != nil { return "", err } - sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil) + sessionTransport := config.ResolveSessionCreateTransport(spec.Agent.Session, resolved) + sp := newSessionProvider() + if err := validateResolvedSessionTransport(resolved, sessionTransport, sp); err != nil { + return "", err + } + sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil, sessionTransport) if err != nil { return "", err } @@ -129,7 +134,6 @@ func materializeSessionForTemplateWithOptions( return "", err } - sp := newSessionProvider() title := spec.Identity templateIdentity := namedSessionBackingTemplate(spec) extraMeta := map[string]string{ @@ -169,7 +173,7 @@ func materializeSessionForTemplateWithOptions( sessionCommand, providerName, workDir, - spec.Agent.Session, + sessionTransport, resolved, extraMeta, ) @@ -272,7 +276,12 @@ func materializeSessionForAgentConfig(cityPath string, cfg *config.City, store b if err != nil { return "", err } - sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil) + sessionTransport := config.ResolveSessionCreateTransport(agentCfg.Session, resolved) + sp := newSessionProvider() + if err := validateResolvedSessionTransport(resolved, sessionTransport, sp); err != nil { + return "", err + } + sessionCommand, err := resolvedSessionCommand(cityPath, resolved, nil, sessionTransport) if err != nil { return "", err } @@ -291,7 +300,6 @@ func materializeSessionForAgentConfig(cityPath string, cfg *config.City, store b return "", err } - sp := newSessionProvider() title := agentCfg.QualifiedName() extraMeta := map[string]string{ "agent_name": sessionQualifiedName, @@ -315,7 +323,7 @@ func materializeSessionForAgentConfig(cityPath string, cfg *config.City, store b sessionCommand, agentCfg.Provider, workDir, - agentCfg.Session, + sessionTransport, resolved, extraMeta, ) diff --git a/cmd/gc/store_target_exec_test.go b/cmd/gc/store_target_exec_test.go index b01f1445f..83cd65577 100644 --- a/cmd/gc/store_target_exec_test.go +++ b/cmd/gc/store_target_exec_test.go @@ -143,6 +143,40 @@ func TestGcExecLifecycleInitProcessEnvDoesNotProjectCanonicalFilesOwnedFlagForGc } } +func TestGcExecLifecycleInitProcessEnvDoesNotLeakAmbientBEADS_DIRForGcBeadsK8s(t *testing.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", + }}) + + t.Setenv("BEADS_DIR", "/tmp/ambient-beads") + target := execStoreTarget{ + ScopeRoot: rigDir, + ScopeKind: "rig", + Prefix: "fe", + RigName: "frontend", + } + env, err := gcExecLifecycleInitProcessEnv(cityDir, target, "exec:/tmp/gc-beads-k8s") + if err != nil { + t.Fatalf("gcExecLifecycleInitProcessEnv(gc-beads-k8s): %v", err) + } + if got := envSliceValue(env, "BEADS_DIR"); got != "" { + t.Fatalf("BEADS_DIR leaked as %q", got) + } + if got := envSliceValue(env, "GC_STORE_ROOT"); got != rigDir { + t.Fatalf("GC_STORE_ROOT = %q, want %q", got, rigDir) + } + if got := envSliceValue(env, "GC_RIG"); got != "frontend" { + t.Fatalf("GC_RIG = %q, want frontend", got) + } +} + func TestGcExecStoreEnvProjectsGCBinForGcBeadsBd(t *testing.T) { cityDir := t.TempDir() oldResolve := resolveProviderLifecycleGCBinary @@ -469,7 +503,7 @@ func TestControllerStateOpenRigStoreExecProjectsRigTarget(t *testing.T) { t.Setenv("GC_DOLT_HOST", "ambient-dolt") cs := &controllerState{cityPath: cityDir} - store := cs.openRigStore(provider, "frontend", rigDir, "fe") + store := cs.openRigStore(provider, "frontend", rigDir, "fe", nil) if _, err := store.Create(beads.Bead{Title: "rig"}); err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/template_resolve.go b/cmd/gc/template_resolve.go index b2eee2131..836d7e0f4 100644 --- a/cmd/gc/template_resolve.go +++ b/cmd/gc/template_resolve.go @@ -103,6 +103,9 @@ type TemplateParams struct { // identity-stamped templates (pool workers, dependency floors) from the // resolver's default stamping on ordinary sessions. EnvIdentityStamped bool + // MCPServers is the effective ACP session/new MCP server set for this + // concrete session context. + MCPServers []runtime.MCPServerConfig } // DisplayName returns the name to use for log messages and event subjects. @@ -127,8 +130,9 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName if err != nil { return TemplateParams{}, fmt.Errorf("agent %q: %w", qualifiedName, err) } + sessionTransport := config.ResolveSessionCreateTransport(cfgAgent.Session, resolved) // Step 2: Validate session vs provider compatibility. - if cfgAgent.Session == "acp" && !resolved.SupportsACP { + if sessionTransport == "acp" && !resolved.SupportsACP { return TemplateParams{}, fmt.Errorf("agent %q: session = \"acp\" but provider %q does not support ACP (set supports_acp = true on the provider)", qualifiedName, resolved.Name) } @@ -147,7 +151,12 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName // Step 5: Build copy_files and command with settings args + schema defaults. var copyFiles []runtime.CopyEntry - command := resolved.CommandString() + var command string + if sessionTransport == "acp" { + command = resolved.ACPCommandString() + } else { + command = resolved.CommandString() + } // Append schema-derived default args (e.g., --dangerously-skip-permissions // from EffectiveDefaults["permission_mode"] = "unrestricted"). if defaultArgs := resolved.ResolveDefaultArgs(); len(defaultArgs) > 0 { @@ -468,6 +477,10 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName ) } } + var mcpServers []runtime.MCPServerConfig + if sessionTransport == "acp" { + mcpServers = materialize.RuntimeMCPServers(mcpCatalog.Servers) + } // Step 12: Build startup hints. hints := agent.StartupHints{ @@ -502,8 +515,9 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName RigName: rigName, RigRoot: rigRoot, WakeMode: cfgAgent.WakeMode, - IsACP: cfgAgent.Session == "acp", + IsACP: sessionTransport == "acp", HookEnabled: hasHooks, + MCPServers: mcpServers, }, nil } @@ -574,10 +588,16 @@ func templateParamsToConfig(tp TemplateParams) runtime.Config { env[startupPromptDeliveredEnv] = "1" } return runtime.Config{ - Command: tp.Command, - PromptSuffix: promptSuffix, - PromptFlag: promptFlag, - Env: env, + Command: tp.Command, + PromptSuffix: promptSuffix, + PromptFlag: promptFlag, + Env: env, + MCPServers: func() []runtime.MCPServerConfig { + if tp.IsACP { + return tp.MCPServers + } + return nil + }(), WorkDir: tp.WorkDir, ReadyPromptPrefix: tp.Hints.ReadyPromptPrefix, ReadyDelayMs: tp.Hints.ReadyDelayMs, diff --git a/cmd/gc/template_resolve_mcp_test.go b/cmd/gc/template_resolve_mcp_test.go index 6f15417a6..1d3c41ad7 100644 --- a/cmd/gc/template_resolve_mcp_test.go +++ b/cmd/gc/template_resolve_mcp_test.go @@ -90,6 +90,21 @@ args = ["notes-mcp"] } }) + t.Run("non acp runtime excludes mcp servers", func(t *testing.T) { + agent := &config.Agent{Name: "mayor", Scope: "city", Provider: "gemini"} + tp, err := resolveTemplate(buildParams("tmux"), agent, agent.QualifiedName(), nil) + if err != nil { + t.Fatalf("resolveTemplate: %v", err) + } + if len(tp.MCPServers) != 0 { + t.Fatalf("TemplateParams.MCPServers len = %d, want 0", len(tp.MCPServers)) + } + cfg := templateParamsToConfig(tp) + if len(cfg.MCPServers) != 0 { + t.Fatalf("runtime.Config.MCPServers len = %d, want 0", len(cfg.MCPServers)) + } + }) + t.Run("undeliverable runtime hard errors", func(t *testing.T) { agent := &config.Agent{ Name: "worker", diff --git a/cmd/gc/test_gc_binary_test.go b/cmd/gc/test_gc_binary_test.go index b9fea9965..c3cac08e2 100644 --- a/cmd/gc/test_gc_binary_test.go +++ b/cmd/gc/test_gc_binary_test.go @@ -24,9 +24,6 @@ func currentGCBinaryForTests(t *testing.T) string { return } binPath := filepath.Join(buildDir, "gc") - goModCache := filepath.Join(buildDir, "gomodcache") - goCache := filepath.Join(buildDir, "gocache") - goPath := filepath.Join(buildDir, "gopath") wd, err := os.Getwd() if err != nil { testGCBinaryErr = fmt.Errorf("getwd: %w", err) @@ -34,11 +31,6 @@ func currentGCBinaryForTests(t *testing.T) string { } cmd := exec.Command("go", "build", "-o", binPath, ".") cmd.Dir = wd - cmd.Env = append(os.Environ(), - "GOMODCACHE="+goModCache, - "GOCACHE="+goCache, - "GOPATH="+goPath, - ) out, err := cmd.CombinedOutput() if err != nil { testGCBinaryErr = fmt.Errorf("go build -o %s .: %w\n%s", binPath, err, string(out)) diff --git a/cmd/gc/worker_handle.go b/cmd/gc/worker_handle.go index 33072d9c2..400062926 100644 --- a/cmd/gc/worker_handle.go +++ b/cmd/gc/worker_handle.go @@ -9,6 +9,7 @@ import ( "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/materialize" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/worker" @@ -24,17 +25,42 @@ func workerSessionCatalogWithConfig(cityPath string, store beads.Store, sp runti func workerFactoryWithConfig(cityPath string, store beads.Store, sp runtime.Provider, cfg *config.City) (*worker.Factory, error) { var ( - resolveTransport func(template string) string + resolveTransport func(template, provider string) string searchPaths []string ) if cfg != nil { rigContext := currentRigContext(cfg) - resolveTransport = func(template string) string { + resolveTransport = func(template, provider string) string { agentCfg, ok := resolveAgentIdentity(cfg, template, rigContext) - if !ok { + if ok { + resolved, err := config.ResolveProvider( + &agentCfg, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return agentCfg.Session + } + return config.ResolveSessionCreateTransport(agentCfg.Session, resolved) + } + provider = strings.TrimSpace(provider) + if provider == "" { + provider = strings.TrimSpace(template) + } + if provider == "" { return "" } - return agentCfg.Session + resolved, err := config.ResolveProvider( + &config.Agent{Provider: provider}, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return "" + } + return strings.TrimSpace(resolved.ProviderSessionCreateTransport()) } searchPaths = worker.MergeSearchPaths(cfg.Daemon.ObservePaths) } @@ -52,8 +78,11 @@ func workerSessionRuntimeResolverWithConfig(cityPath string, cfg *config.City) w if cfg == nil { return nil } - return func(info session.Info, sessionKind string) (*worker.ResolvedRuntime, error) { - runtimeCfg := resolvedWorkerRuntimeWithConfig(cityPath, cfg, info, sessionKind) + return func(info session.Info, sessionKind string, metadata map[string]string) (*worker.ResolvedRuntime, error) { + runtimeCfg, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityPath, cfg, info, sessionKind, metadata) + if err != nil { + return nil, err + } if runtimeCfg == nil { return nil, nil } @@ -77,6 +106,93 @@ func workerSessionCreateHints(resolved *config.ResolvedProvider) runtime.Config } } +func resolvedRuntimeMCPServersWithConfig( + cityPath string, + cfg *config.City, + alias, template, provider, workDir string, + transport string, + metadata map[string]string, +) ([]runtime.MCPServerConfig, error) { + if cfg == nil || strings.TrimSpace(workDir) == "" || strings.TrimSpace(transport) != "acp" { + return nil, nil + } + identity := strings.TrimSpace(metadata[session.MCPIdentityMetadataKey]) + if identity == "" { + identity = strings.TrimSpace(metadata["agent_name"]) + } + if identity == "" { + identity = strings.TrimSpace(alias) + } + if identity == "" { + identity = strings.TrimSpace(template) + } + if identity == "" { + identity = strings.TrimSpace(provider) + } + if agentCfg := findAgentByTemplate(cfg, template); agentCfg != nil { + catalog, err := materialize.EffectiveMCPForSession(cfg, cityPath, agentCfg, identity, workDir) + if err != nil { + return nil, fmt.Errorf("loading effective MCP: %w", err) + } + return materialize.RuntimeMCPServers(catalog.Servers), nil + } + synthetic := &config.Agent{Provider: provider} + catalog, err := materialize.EffectiveMCPForSession(cfg, cityPath, synthetic, identity, workDir) + if err != nil { + return nil, fmt.Errorf("loading effective MCP: %w", err) + } + return materialize.RuntimeMCPServers(catalog.Servers), nil +} + +func resumeRuntimeMCPServersWithConfig( + cityPath string, + cfg *config.City, + info session.Info, + resolved *config.ResolvedProvider, + transport string, + metadata map[string]string, +) ([]runtime.MCPServerConfig, error) { + if cfg == nil || resolved == nil { + return nil, nil + } + workDir := strings.TrimSpace(info.WorkDir) + if workDir == "" { + workDir = cityPath + } + resumeMeta := make(map[string]string) + for key, value := range metadata { + resumeMeta[key] = value + } + if agentName := strings.TrimSpace(info.AgentName); agentName != "" { + resumeMeta["agent_name"] = agentName + } + mcpServers, err := resolvedRuntimeMCPServersWithConfig( + cityPath, + cfg, + info.Alias, + info.Template, + firstNonEmptyGCString(info.Provider, resolved.Name, info.Template), + workDir, + transport, + resumeMeta, + ) + if err == nil { + return mcpServers, nil + } + runtimeSnapshot, loadErr := session.LoadRuntimeMCPServersSnapshot(cityPath, info.ID) + if loadErr != nil { + return nil, loadErr + } + if len(runtimeSnapshot) > 0 { + return runtimeSnapshot, nil + } + stored, decodeErr := session.DecodeMCPServersSnapshot(resumeMeta[session.MCPServersSnapshotMetadataKey]) + if decodeErr != nil { + return nil, fmt.Errorf("decoding stored MCP snapshot: %w", decodeErr) + } + return session.SanitizeStoredMCPSnapshotForResume(stored), nil +} + func newWorkerSessionHandleForResolvedRuntimeWithConfig( cityPath string, store beads.Store, @@ -90,6 +206,10 @@ func newWorkerSessionHandleForResolvedRuntimeWithConfig( if err != nil { return nil, err } + mcpServers, err := resolvedRuntimeMCPServersWithConfig(cityPath, cfg, alias, template, provider, workDir, transport, metadata) + if err != nil { + return nil, err + } sessionCfg, err := resolvedWorkerSessionConfigWithConfig( command, provider, @@ -101,6 +221,7 @@ func newWorkerSessionHandleForResolvedRuntimeWithConfig( transport, resolved, metadata, + mcpServers, ) if err != nil { return nil, err @@ -119,13 +240,29 @@ func resolvedWorkerSessionConfigWithConfig( transport string, resolved *config.ResolvedProvider, metadata map[string]string, + mcpServers []runtime.MCPServerConfig, ) (worker.ResolvedSessionConfig, error) { if resolved == nil { return worker.ResolvedSessionConfig{}, fmt.Errorf("resolved provider is required") } + if transport == "acp" { + var err error + metadata, err = session.WithStoredMCPMetadata( + metadata, + firstNonEmptyGCString(metadata[session.MCPIdentityMetadataKey], metadata["agent_name"]), + mcpServers, + ) + if err != nil { + return worker.ResolvedSessionConfig{}, err + } + } command = strings.TrimSpace(command) if command == "" { - command = strings.TrimSpace(resolved.CommandString()) + if transport == "acp" { + command = strings.TrimSpace(resolved.ACPCommandString()) + } else { + command = strings.TrimSpace(resolved.CommandString()) + } } providerName := strings.TrimSpace(resolved.Name) if providerName == "" { @@ -152,7 +289,11 @@ func resolvedWorkerSessionConfigWithConfig( ResumeCommand: resolved.ResumeCommand, SessionIDFlag: resolved.SessionIDFlag, }, - Hints: workerSessionCreateHints(resolved), + Hints: func() runtime.Config { + hints := workerSessionCreateHints(resolved) + hints.MCPServers = mcpServers + return hints + }(), }, }) } @@ -313,29 +454,36 @@ func workerRespondSessionTargetWithConfig(cityPath string, store beads.Store, sp return handle.Respond(context.Background(), response) } -func resolvedWorkerRuntimeWithConfig(cityPath string, cfg *config.City, info session.Info, sessionKind string) *worker.ResolvedRuntime { +func resolvedWorkerRuntimeWithConfig(cityPath string, cfg *config.City, info session.Info, sessionKind string) (*worker.ResolvedRuntime, error) { + return resolvedWorkerRuntimeWithConfigAndMetadata(cityPath, cfg, info, sessionKind, nil) +} + +func resolvedWorkerRuntimeWithConfigAndMetadata(cityPath string, cfg *config.City, info session.Info, sessionKind string, metadata map[string]string) (*worker.ResolvedRuntime, error) { if cfg == nil { - return nil + return nil, nil } - resolved := resolveWorkerRuntimeWithConfig(cfg, info, sessionKind) + resolved, configuredTransport := resolveWorkerRuntimeProviderWithConfig(cfg, info, sessionKind) if resolved == nil { - return nil + return nil, nil } - - command := strings.TrimSpace(info.Command) - if !shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) { - launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, nil) - command = resolved.CommandString() - if err == nil { - command = launchCommand.Command - } + transport := resolvedWorkerRuntimeTransport(info, resolved, configuredTransport, metadata) + if transport == "" && startedConfigHashProvesWorkerACPTransport(cityPath, cfg, info, sessionKind, resolved, metadata, configuredTransport) { + transport = "acp" + } + if transport == "" && legacyWorkerACPTransportAmbiguous(resolved, configuredTransport, info.Command, metadata) { + return nil, fmt.Errorf("legacy session transport is ambiguous: recreate the stopped session or resume it while ACP metadata can still be persisted") } - command = firstNonEmptyGCString(command, info.Provider, resolved.Name) + + command := resolvedWorkerRuntimeCommandForTransport(cityPath, resolved, transport, info.Command, info.Provider, metadata) workDir := strings.TrimSpace(info.WorkDir) if workDir == "" { workDir = cityPath } + mcpServers, err := resumeRuntimeMCPServersWithConfig(cityPath, cfg, info, resolved, transport, metadata) + if err != nil { + return nil, err + } return &worker.ResolvedRuntime{ Command: command, WorkDir: workDir, @@ -347,6 +495,7 @@ func resolvedWorkerRuntimeWithConfig(cityPath string, cfg *config.City, info ses ReadyDelayMs: resolved.ReadyDelayMs, ProcessNames: resolved.ProcessNames, EmitsPermissionWarning: resolved.EmitsPermissionWarning, + MCPServers: mcpServers, }, Resume: session.ProviderResume{ ResumeFlag: firstNonEmptyGCString(resolved.ResumeFlag, info.ResumeFlag), @@ -354,7 +503,41 @@ func resolvedWorkerRuntimeWithConfig(cityPath string, cfg *config.City, info ses ResumeCommand: firstNonEmptyGCString(resolved.ResumeCommand, info.ResumeCommand), SessionIDFlag: resolved.SessionIDFlag, }, + }, nil +} + +func resolvedWorkerRuntimeCommandForTransport(cityPath string, resolved *config.ResolvedProvider, transport, storedCommand, fallbackProvider string, metadata map[string]string) string { + command := strings.TrimSpace(storedCommand) + configuredCommand := configuredWorkerRuntimeCommand(resolved, transport) + if configuredCommand == "" { + return firstNonEmptyGCString(command, fallbackProvider, resolved.Name) + } + desiredCommand := configuredCommand + if optionOverrides, err := session.ParseTemplateOverrides(metadata); err == nil { + if launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, optionOverrides, transport); err == nil { + desiredCommand = firstNonEmptyGCString(launchCommand.Command, configuredCommand, resolved.Name) + if shouldPreserveStoredRuntimeCommandForTransport(command, desiredCommand, transport, optionOverrides) { + desiredCommand = command + } + } } + if !shouldPreserveStoredRuntimeCommand(command, desiredCommand) { + command = desiredCommand + } + return firstNonEmptyGCString(command, fallbackProvider, resolved.Name) +} + +func configuredWorkerRuntimeCommand(resolved *config.ResolvedProvider, transport string) string { + if resolved == nil { + return "" + } + if transport == "acp" && (strings.TrimSpace(resolved.ACPCommand) != "" || resolved.ACPArgs != nil) { + return strings.TrimSpace(resolved.ACPCommandString()) + } + if strings.TrimSpace(resolved.Command) != "" { + return strings.TrimSpace(resolved.CommandString()) + } + return "" } func shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand string) bool { @@ -366,25 +549,165 @@ func shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand string) b if resolvedCommand == "" { return true } - return storedCommand == resolvedCommand || strings.HasPrefix(storedCommand, resolvedCommand+" ") + // A bare stored command (just the provider binary) lacks schema + // defaults like --dangerously-skip-permissions and the --settings + // path. Rebuild from the current config instead of preserving it. + // See #799: pool-agent sessions resumed through the control- + // dispatcher path wedged on interactive permission prompts because + // the bare stored command was preserved without re-injecting flags. + if storedCommand == resolvedCommand { + return false + } + return strings.HasPrefix(storedCommand, resolvedCommand+" ") +} + +func shouldPreserveStoredRuntimeCommandForTransport(storedCommand, resolvedCommand, _ string, optionOverrides map[string]string) bool { + if shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand) { + return true + } + if len(optionOverrides) == 0 && storedCommandHasSettingsArg(storedCommand) && sameRuntimeCommandExecutable(storedCommand, resolvedCommand) { + return true + } + return false +} + +func sameRuntimeCommandExecutable(storedCommand, resolvedCommand string) bool { + storedFields := strings.Fields(strings.TrimSpace(storedCommand)) + resolvedFields := strings.Fields(strings.TrimSpace(resolvedCommand)) + if len(storedFields) == 0 || len(resolvedFields) == 0 { + return false + } + return storedFields[0] == resolvedFields[0] +} + +func storedCommandHasSettingsArg(command string) bool { + return strings.Contains(" "+strings.TrimSpace(command)+" ", " --settings ") } -func resolveWorkerRuntimeWithConfig(cfg *config.City, info session.Info, sessionKind string) *config.ResolvedProvider { +func storedWorkerSessionProvesACPTransport(resolved *config.ResolvedProvider, configuredTransport, storedCommand string, metadata map[string]string) bool { + if metadata != nil { + if strings.TrimSpace(metadata[session.MCPIdentityMetadataKey]) != "" || + strings.TrimSpace(metadata[session.MCPServersSnapshotMetadataKey]) != "" { + return true + } + if strings.TrimSpace(configuredTransport) == "acp" && legacyWorkerResumeMetadataProvesACPTransport(metadata) { + return true + } + } + if resolved == nil { + return false + } + acpCommand := strings.TrimSpace(resolved.ACPCommandString()) + defaultCommand := strings.TrimSpace(resolved.CommandString()) + if acpCommand == "" || acpCommand == defaultCommand { + return false + } + return shouldPreserveStoredRuntimeCommand(storedCommand, acpCommand) +} + +func legacyWorkerResumeMetadataProvesACPTransport(metadata map[string]string) bool { + if metadata == nil { + return false + } + return strings.TrimSpace(metadata["resume_command"]) != "" || + strings.TrimSpace(metadata["resume_flag"]) != "" || + strings.TrimSpace(metadata["session_key"]) != "" +} + +func legacyWorkerACPTransportAmbiguous(resolved *config.ResolvedProvider, configuredTransport, storedCommand string, metadata map[string]string) bool { + if strings.TrimSpace(configuredTransport) != "acp" || resolved == nil { + return false + } + if storedWorkerSessionProvesACPTransport(resolved, configuredTransport, storedCommand, metadata) { + return false + } + acpCommand := strings.TrimSpace(resolved.ACPCommandString()) + defaultCommand := strings.TrimSpace(resolved.CommandString()) + if acpCommand == "" || acpCommand != defaultCommand { + return false + } + storedCommand = strings.TrimSpace(storedCommand) + return storedCommand == "" || sameRuntimeCommandExecutable(storedCommand, defaultCommand) +} + +func startedConfigHashProvesWorkerACPTransport( + cityPath string, + cfg *config.City, + info session.Info, + _ string, + resolved *config.ResolvedProvider, + metadata map[string]string, + configuredTransport string, +) bool { + if cfg == nil || resolved == nil || metadata == nil || strings.TrimSpace(configuredTransport) != "acp" { + return false + } + startedHash := strings.TrimSpace(metadata["started_config_hash"]) + if startedHash == "" { + return false + } + acpCommand := resolvedWorkerRuntimeCommandForTransport(cityPath, resolved, "acp", info.Command, info.Provider, metadata) + defaultCommand := resolvedWorkerRuntimeCommandForTransport(cityPath, resolved, "", info.Command, info.Provider, metadata) + mcpServers, err := resolvedRuntimeMCPServersWithConfig( + cityPath, + cfg, + info.Alias, + info.Template, + firstNonEmptyGCString(info.Provider, resolved.Name, info.Template), + firstNonEmptyGCString(info.WorkDir, cityPath), + "acp", + metadata, + ) + if err != nil { + return false + } + acpHash := runtime.CoreFingerprint(runtime.Config{ + Command: acpCommand, + Env: resolved.Env, + MCPServers: mcpServers, + }) + defaultHash := runtime.CoreFingerprint(runtime.Config{ + Command: defaultCommand, + Env: resolved.Env, + }) + if acpHash == defaultHash { + return false + } + return startedHash == acpHash +} + +func resolvedWorkerRuntimeTransport(info session.Info, resolved *config.ResolvedProvider, configuredTransport string, metadata map[string]string) string { + if transport := strings.TrimSpace(info.Transport); transport != "" { + return transport + } + if strings.TrimSpace(info.Provider) == "acp" { + return "acp" + } + if storedWorkerSessionProvesACPTransport(resolved, configuredTransport, info.Command, metadata) { + return "acp" + } + if strings.TrimSpace(info.Command) == "" { + return strings.TrimSpace(configuredTransport) + } + return "" +} + +func resolveWorkerRuntimeProviderWithConfig(cfg *config.City, info session.Info, sessionKind string) (*config.ResolvedProvider, string) { if cfg == nil { - return nil + return nil, "" } if sessionKind != "provider" { if found, ok := resolveAgentIdentity(cfg, info.Template, ""); ok { if resolved, err := config.ResolveProvider(&found, &cfg.Workspace, cfg.Providers, exec.LookPath); err == nil { - return resolved + return resolved, config.ResolveSessionCreateTransport(found.Session, resolved) } } } resolved, err := config.ResolveProvider(&config.Agent{Provider: info.Template}, &cfg.Workspace, cfg.Providers, exec.LookPath) if err != nil { - return nil + return nil, "" } - return resolved + return resolved, strings.TrimSpace(resolved.ProviderSessionCreateTransport()) } func workerDeliveryIntentForSubmitIntent(intent session.SubmitIntent) worker.DeliveryIntent { diff --git a/cmd/gc/worker_handle_test.go b/cmd/gc/worker_handle_test.go index d821c06a5..79b916c5e 100644 --- a/cmd/gc/worker_handle_test.go +++ b/cmd/gc/worker_handle_test.go @@ -29,6 +29,7 @@ func (s *failingSessionLookupStore) List(beads.ListQuery) ([]beads.Bead, error) } func TestWorkerHandleForSessionWithConfigUsesResolvedProviderOnFirstStart(t *testing.T) { + skipSlowCmdGCTest(t, "waits through stale session-key detection; run make test-cmd-gc-process for full coverage") cityDir := t.TempDir() writePhase0InterfaceCity(t, cityDir, `[workspace] name = "test-city" @@ -124,25 +125,588 @@ func TestResolvedWorkerRuntimeWithConfigUsesProviderLaunchCommand(t *testing.T) }, } - resolved := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ Template: "worker", WorkDir: cityDir, }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } if resolved == nil { t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") } if !strings.Contains(resolved.Command, "--dangerously-skip-permissions") { t.Fatalf("Command = %q, want unrestricted default", resolved.Command) } - if !strings.Contains(resolved.Command, "--effort max") { - t.Fatalf("Command = %q, want effort max default", resolved.Command) + if !strings.Contains(resolved.Command, "--effort max") { + t.Fatalf("Command = %q, want effort max default", resolved.Command) + } + if !strings.Contains(resolved.Command, "--settings") { + t.Fatalf("Command = %q, want settings arg", resolved.Command) + } +} + +// TestResolvedWorkerRuntimeResumesPoolSessionPreservesLaunchFlags is a +// regression test for gastownhall/gascity#799: a pool-agent session +// resumed through the control-dispatcher path must reconstruct the full +// launch command (--dangerously-skip-permissions, --settings, schema +// defaults) even when the persisted session command is the bare +// provider name. The pre-fix path dropped those flags and caused pool +// workers resumed via `claude --resume ` to wedge on interactive +// permission prompts. +func TestResolvedWorkerRuntimeResumesPoolSessionPreservesLaunchFlags(t *testing.T) { + cityDir := t.TempDir() + gcDir := filepath.Join(cityDir, ".gc") + if err := os.MkdirAll(gcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcDir, "settings.json"), []byte(`{"hooks":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + claude := config.BuiltinProviders()["claude"] + maxActive := 3 + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "perspective_planner", + Provider: "claude", + MaxActiveSessions: &maxActive, + }}, + Providers: map[string]config.ProviderSpec{ + "claude": claude, + }, + } + + // Simulate a pool-instance session bead whose persisted command is + // the bare provider name — the shape produced before the April 2026 + // worker-boundary refactor when the API created the bead with + // sessionCreateAgentCommand(resolved) before the reconciler synced + // the full tp.Command. + runtimeCfg, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "perspective_planner", + Command: "claude", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if !strings.Contains(runtimeCfg.Command, "--dangerously-skip-permissions") { + t.Fatalf("resumed pool Command = %q, want --dangerously-skip-permissions", runtimeCfg.Command) + } + if !strings.Contains(runtimeCfg.Command, "--effort max") { + t.Fatalf("resumed pool Command = %q, want --effort max default", runtimeCfg.Command) + } + if !strings.Contains(runtimeCfg.Command, "--settings") { + t.Fatalf("resumed pool Command = %q, want --settings arg", runtimeCfg.Command) + } +} + +func TestShouldPreserveStoredRuntimeCommandForTransportRejectsExecutableOnlyMatch(t *testing.T) { + if shouldPreserveStoredRuntimeCommandForTransport( + "claude", + "claude --settings /tmp/settings.json", + "", + nil, + ) { + t.Fatal("shouldPreserveStoredRuntimeCommandForTransport() = true, want false") + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStoredTemplateACPTransport(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" +session = "acp" + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/filesystem.toml", ` +name = "filesystem" +command = "/bin/mcp" +args = ["--stdio"] + +[env] +TOKEN = "abc" +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + Command: "/bin/echo", + Transport: "acp", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } + if len(resolved.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(resolved.Hints.MCPServers)) + } + if got, want := resolved.Hints.MCPServers[0].Name, "filesystem"; got != want { + t.Fatalf("Hints.MCPServers[0].Name = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigDoesNotInferConfiguredTransportWithoutStoredTemplateACPMetadata(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" +session = "acp" + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + Command: "/bin/echo", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeTransportUsesResumeMetadataForLegacyACPWithSameCommand(t *testing.T) { + resolved := &config.ResolvedProvider{ + Command: "/bin/echo", + ACPCommand: "/bin/echo", + } + + got := resolvedWorkerRuntimeTransport(session.Info{ + Command: "/bin/echo", + }, resolved, "acp", map[string]string{ + "resume_flag": "--resume", + }) + if got != "acp" { + t.Fatalf("resolvedWorkerRuntimeTransport() = %q, want acp", got) + } +} + +func TestResolvedWorkerRuntimeWithConfigErrorsForAmbiguousLegacyACPTransportWithSameCommand(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" +session = "acp" + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + _, err = resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + Command: "/bin/echo", + WorkDir: cityDir, + }, "") + if err == nil || !strings.Contains(err.Error(), "legacy session transport is ambiguous") { + t.Fatalf("resolvedWorkerRuntimeWithConfig() error = %v, want ambiguous legacy ACP transport", err) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStartedConfigHashForLegacyProviderACPWithSameCommand(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[providers.custom-acp] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + cfg.PackMCPDir = filepath.Join(cityDir, "mcp") + if err := os.MkdirAll(cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + info := session.Info{ + Template: "custom-acp", + Command: "/bin/echo", + Provider: "custom-acp", + WorkDir: cityDir, + } + resolved, _ := resolveWorkerRuntimeProviderWithConfig(cfg, info, "provider") + mcpServers, err := resolvedRuntimeMCPServersWithConfig( + cityDir, + cfg, + info.Alias, + info.Template, + info.Provider, + info.WorkDir, + "acp", + nil, + ) + if err != nil { + t.Fatalf("resolvedRuntimeMCPServersWithConfig: %v", err) + } + startedHash := runtime.CoreFingerprint(runtime.Config{ + Command: resolved.ACPCommandString(), + Env: resolved.Env, + MCPServers: mcpServers, + }) + + runtimeCfg, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, info, "provider", map[string]string{ + "started_config_hash": startedHash, + }) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("len(runtimeCfg.Hints.MCPServers) = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } +} + +func TestResolvedWorkerRuntimeWithConfigKeepsDefaultTransportWithoutExplicitACPTemplate(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + Command: "/bin/echo", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStoredACPTransportForProviderSession(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[providers.opencode] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "opencode", + Command: "/bin/echo", + Transport: "acp", + WorkDir: cityDir, + }, "provider") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStoredACPTransportForLegacyProviderSessionWithoutMetadata(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[providers.opencode] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "opencode", + Command: "/bin/echo acp", + WorkDir: cityDir, + }, "provider") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStoredACPTransportForLegacyProviderSessionOnACPEnabledCustomProvider(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[providers.custom-acp] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "custom-acp", + Command: "/bin/echo acp", + WorkDir: cityDir, + }, "provider") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesProviderACPDefaultForAgentTemplateWithoutSessionOverride(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +dir = "myrig" +provider = "custom-acp" + +[providers.custom-acp] +command = "/bin/echo" +path_check = "true" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "myrig/worker", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigReplaysTemplateOverridesOnResume(t *testing.T) { + cityDir := t.TempDir() + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom", + }}, + Providers: map[string]config.ProviderSpec{ + "custom": { + Command: "/bin/echo", + PathCheck: "true", + OptionsSchema: []config.ProviderOption{{ + Key: "effort", + Type: "select", + Choices: []config.OptionChoice{{ + Value: "high", + FlagArgs: []string{"--effort", "high"}, + }}, + }}, + }, + }, + } + + resolved, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, session.Info{ + Template: "myrig/worker", + Command: "/bin/echo", + WorkDir: cityDir, + }, "", map[string]string{ + "template_overrides": `{"effort":"high","initial_message":"hello"}`, + }) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") + } + if got, want := resolved.Command, "/bin/echo --effort high"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigFallsBackToStoredCommandWhenTemplateOverridesInvalid(t *testing.T) { + cityDir := t.TempDir() + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom", + }}, + Providers: map[string]config.ProviderSpec{ + "custom": { + Command: "/bin/echo", + PathCheck: "true", + }, + }, + } + + resolved, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, session.Info{ + Template: "myrig/worker", + Command: "/bin/echo --stored", + WorkDir: cityDir, + }, "", map[string]string{ + "template_overrides": `{`, + }) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") } - if !strings.Contains(resolved.Command, "--settings") { - t.Fatalf("Command = %q, want settings arg", resolved.Command) + if got, want := resolved.Command, "/bin/echo --stored"; got != want { + t.Fatalf("Command = %q, want %q", got, want) } } func TestWorkerHandleForSessionWithConfigUsesResolvedProviderOnResume(t *testing.T) { + skipSlowCmdGCTest(t, "waits through stale session-key detection; run make test-cmd-gc-process for full coverage") cityDir := t.TempDir() writePhase0InterfaceCity(t, cityDir, `[workspace] name = "test-city" @@ -421,6 +985,7 @@ func TestResolvedWorkerSessionConfigWithConfigFallsBackToResolvedProviderNameFor Name: "custom-provider", }, map[string]string{"session_origin": "test"}, + nil, ) if err != nil { t.Fatalf("resolvedWorkerSessionConfigWithConfig: %v", err) @@ -445,6 +1010,7 @@ func TestResolvedWorkerSessionConfigWithConfigFallsBackToProviderArgForCommand(t "", &config.ResolvedProvider{}, nil, + nil, ) if err != nil { t.Fatalf("resolvedWorkerSessionConfigWithConfig: %v", err) @@ -457,6 +1023,71 @@ func TestResolvedWorkerSessionConfigWithConfigFallsBackToProviderArgForCommand(t } } +func TestResolvedWorkerSessionConfigWithConfigPersistsStoredMCPMetadata(t *testing.T) { + cfg, err := resolvedWorkerSessionConfigWithConfig( + "", + "legacy-provider", + "/tmp/work", + "worker", + "", + "worker", + "Worker", + "acp", + &config.ResolvedProvider{ + Name: "custom-provider", + }, + map[string]string{ + "session_origin": "test", + "agent_name": "myrig/worker-adhoc-123", + }, + []runtime.MCPServerConfig{{ + Name: "filesystem", + Transport: runtime.MCPTransportStdio, + Command: "/bin/mcp", + Args: []string{"--stdio"}, + }}, + ) + if err != nil { + t.Fatalf("resolvedWorkerSessionConfigWithConfig: %v", err) + } + if got, want := cfg.Metadata[session.MCPIdentityMetadataKey], "myrig/worker-adhoc-123"; got != want { + t.Fatalf("Metadata[mcp_identity] = %q, want %q", got, want) + } + if got := cfg.Metadata[session.MCPServersSnapshotMetadataKey]; got == "" { + t.Fatal("Metadata[mcp_servers_snapshot] = empty, want persisted snapshot") + } +} + +func TestResolvedWorkerSessionConfigWithConfigSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) { + cfg, err := resolvedWorkerSessionConfigWithConfig( + "", + "legacy-provider", + "/tmp/work", + "worker", + "", + "worker", + "Worker", + "", + &config.ResolvedProvider{ + Name: "custom-provider", + }, + map[string]string{ + "session_origin": "test", + "agent_name": "myrig/worker-adhoc-123", + }, + nil, + ) + if err != nil { + t.Fatalf("resolvedWorkerSessionConfigWithConfig: %v", err) + } + if got := cfg.Metadata[session.MCPIdentityMetadataKey]; got != "" { + t.Fatalf("Metadata[mcp_identity] = %q, want empty for tmux transport", got) + } + if got := cfg.Metadata[session.MCPServersSnapshotMetadataKey]; got != "" { + t.Fatalf("Metadata[mcp_servers_snapshot] = %q, want empty for tmux transport", got) + } +} + func TestResolvedWorkerRuntimeWithConfigFallsBackToCityPathAndSyncsHintsWorkDir(t *testing.T) { cityDir := t.TempDir() writePhase0InterfaceCity(t, cityDir, `[workspace] @@ -480,9 +1111,12 @@ ready_delay_ms = 250 t.Fatalf("loadCityConfig: %v", err) } - runtimeCfg := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + runtimeCfg, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ Template: "worker", }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } if runtimeCfg == nil { t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") } @@ -500,6 +1134,395 @@ ready_delay_ms = 250 } } +func TestResolvedWorkerRuntimeWithConfigIgnoresMCPResolutionErrorForACPResume(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" +session = "acp" + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/filesystem.toml", ` +name = "filesystem" +command = [broken +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + Transport: "acp", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } + if len(resolved.Hints.MCPServers) != 0 { + t.Fatalf("Hints.MCPServers len = %d, want 0", len(resolved.Hints.MCPServers)) + } +} + +func TestResolvedWorkerRuntimeWithConfigIgnoresMCPResolutionErrorWithoutACPTransport(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "worker" +provider = "stub" + +[providers.stub] +command = "/bin/echo" +`) + writeCatalogFile(t, cityDir, "mcp/filesystem.toml", ` +name = "filesystem" +command = [broken +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "worker", + WorkDir: cityDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if got, want := resolved.Command, "/bin/echo"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } + if len(resolved.Hints.MCPServers) != 0 { + t.Fatalf("Hints.MCPServers len = %d, want 0", len(resolved.Hints.MCPServers)) + } +} + +func TestResolvedWorkerRuntimeWithConfigUsesStoredAgentNameForResumeMCPMaterialization(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "ant" +dir = "myrig" +provider = "stub" +session = "acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + workDir := filepath.Join(cityDir, ".gc", "worktrees", "myrig", "ants", "ant") + resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{ + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + }, "") + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfig() = nil") + } + if len(resolved.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(resolved.Hints.MCPServers)) + } + if got, want := resolved.Hints.MCPServers[0].Args[0], "myrig/ant-adhoc-123"; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Args[1], workDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigFallsBackToStoredMCPServersWhenCatalogBreaks(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "ant" +dir = "myrig" +provider = "stub" +session = "acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = [broken +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + workDir := filepath.Join(cityDir, ".gc", "worktrees", "myrig", "ants", "ant") + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportStdio, + Command: "/bin/mcp", + Args: []string{"myrig/ant-adhoc-123", workDir, "myrig/ant"}, + }}) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, session.Info{ + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + }, "", metadata) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") + } + if len(resolved.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(resolved.Hints.MCPServers)) + } + if got, want := resolved.Hints.MCPServers[0].Args[0], "myrig/ant-adhoc-123"; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Args[1], workDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigFallsBackToRuntimeMCPServersSnapshotWhenCatalogBreaks(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "ant" +dir = "myrig" +provider = "stub" +session = "acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = [broken +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + workDir := filepath.Join(cityDir, ".gc", "worktrees", "myrig", "ants", "ant") + servers := []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{"--api-key", "super-secret"}, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }} + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", servers) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + if err := session.PersistRuntimeMCPServersSnapshot(cityDir, "sess-1", servers); err != nil { + t.Fatalf("PersistRuntimeMCPServersSnapshot: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + }, "", metadata) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") + } + if len(resolved.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(resolved.Hints.MCPServers)) + } + if got, want := resolved.Hints.MCPServers[0].Args[1], "super-secret"; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Env["API_TOKEN"], "super-secret"; got != want { + t.Fatalf("Env[API_TOKEN] = %q, want %q", got, want) + } + if got, want := resolved.Hints.MCPServers[0].Headers["Authorization"], "Bearer secret"; got != want { + t.Fatalf("Headers[Authorization] = %q, want %q", got, want) + } +} + +func TestResolvedWorkerRuntimeWithConfigFallsBackToSanitizedStoredMCPServersWhenRuntimeSnapshotMissing(t *testing.T) { + cityDir := t.TempDir() + writePhase0InterfaceCity(t, cityDir, `[workspace] +name = "test-city" + +[beads] +provider = "file" + +[[agent]] +name = "ant" +dir = "myrig" +provider = "stub" +session = "acp" +work_dir = ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}" +min_active_sessions = 0 +max_active_sessions = 4 + +[providers.stub] +command = "/bin/echo" +supports_acp = true +acp_command = "/bin/echo" +acp_args = ["acp"] +`) + writeCatalogFile(t, cityDir, "mcp/identity.template.toml", ` +name = "identity" +command = [broken +`) + + cfg, err := loadCityConfig(cityDir) + if err != nil { + t.Fatalf("loadCityConfig: %v", err) + } + + workDir := filepath.Join(cityDir, ".gc", "worktrees", "myrig", "ants", "ant") + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{"--serve", "--api-key", "super-secret"}, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }}) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + + resolved, err := resolvedWorkerRuntimeWithConfigAndMetadata(cityDir, cfg, session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + }, "", metadata) + if err != nil { + t.Fatalf("resolvedWorkerRuntimeWithConfigAndMetadata: %v", err) + } + if resolved == nil { + t.Fatal("resolvedWorkerRuntimeWithConfigAndMetadata() = nil") + } + if len(resolved.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(resolved.Hints.MCPServers)) + } + if got, want := resolved.Hints.MCPServers[0].Args, []string{"--serve"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("Args = %#v, want %#v", got, want) + } + if len(resolved.Hints.MCPServers[0].Env) != 0 { + t.Fatalf("Env = %#v, want empty", resolved.Hints.MCPServers[0].Env) + } + if len(resolved.Hints.MCPServers[0].Headers) != 0 { + t.Fatalf("Headers = %#v, want empty", resolved.Hints.MCPServers[0].Headers) + } + if got, want := resolved.Hints.MCPServers[0].URL, "https://example.invalid/mcp"; got != want { + t.Fatalf("URL = %q, want %q", got, want) + } +} + func TestWorkerSessionRuntimeResolverWithConfigFallsBackToProviderNameWhenResolvedCommandMissing(t *testing.T) { cfg := &config.City{ Workspace: config.Workspace{Name: "test-city"}, @@ -517,7 +1540,7 @@ func TestWorkerSessionRuntimeResolverWithConfigFallsBackToProviderNameWhenResolv t.Fatal("workerSessionRuntimeResolverWithConfig() = nil") } - runtimeCfg, err := resolver(session.Info{Template: "worker"}, "") + runtimeCfg, err := resolver(session.Info{Template: "worker"}, "", nil) if err != nil { t.Fatalf("resolver: %v", err) } @@ -562,7 +1585,7 @@ func TestWorkerSessionRuntimeResolverWithConfigFallsBackToPersistedRuntimeOnInco ResumeCommand: "persisted resume {{.SessionKey}}", } - runtimeCfg, err := resolver(info, "") + runtimeCfg, err := resolver(info, "", nil) if err != nil { t.Fatalf("resolver: %v", err) } @@ -622,7 +1645,7 @@ func TestWorkerSessionRuntimeResolverWithConfigFallsBackToPersistedProviderWhenC Provider: "persisted-provider", } - runtimeCfg, err := resolver(info, "") + runtimeCfg, err := resolver(info, "", nil) if err != nil { t.Fatalf("resolver: %v", err) } diff --git a/contrib/beads-scripts/gc-beads-k8s b/contrib/beads-scripts/gc-beads-k8s index 58f28aabe..6c3b981ca 100755 --- a/contrib/beads-scripts/gc-beads-k8s +++ b/contrib/beads-scripts/gc-beads-k8s @@ -104,32 +104,33 @@ runner_workdir_for_scope() { # run_bd executes bd inside the beads runner pod for the projected store root. # When GC_BEADS_PREFIX is set, the prefix switch and bd command run in a # single kubectl exec to avoid interleave from concurrent invocations. +# +# BEADS_DIR is exported for every in-pod bd invocation so the runner always +# targets the scope-local .beads store, including the post-init config-set +# follow-ups in the init flow. The init branch itself must not run +# `bd config set issue_prefix` before `bd init`, because a fresh scope has no +# database for config writes yet. run_bd() { local scope_root workdir want scope_root=$(scope_root_arg_or_env "") workdir=$(runner_workdir_for_scope "$scope_root") || return 1 want="${GC_BEADS_PREFIX:-}" if [ -n "$want" ]; then - "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ - 'workdir="$1"; prefix="$2"; shift 2; mkdir -p "$workdir" && cd "$workdir" && bd config set issue_prefix "$prefix" >/dev/null 2>&1 && bd "$@"' -- "$workdir" "$want" "$@" - else - "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ - 'workdir="$1"; shift; mkdir -p "$workdir" && cd "$workdir" && bd "$@"' -- "$workdir" "$@" - fi -} - -# run_bd_stdin executes bd inside the beads runner pod with stdin piped through. -run_bd_stdin() { - local scope_root workdir want - scope_root=$(scope_root_arg_or_env "") - workdir=$(runner_workdir_for_scope "$scope_root") || return 1 - want="${GC_BEADS_PREFIX:-}" - if [ -n "$want" ]; then - "${KUBECTL[@]}" exec -i "$POD_NAME" -- sh -c \ - 'workdir="$1"; prefix="$2"; shift 2; mkdir -p "$workdir" && cd "$workdir" && bd config set issue_prefix "$prefix" >/dev/null 2>&1 && bd "$@"' -- "$workdir" "$want" "$@" + if [ "${1:-}" = "init" ]; then + "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ + 'workdir="$1"; shift; mkdir -p "$workdir" && cd "$workdir" && export BEADS_DIR="$workdir/.beads" && bd "$@"' -- "$workdir" "$@" + else + "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ + 'workdir="$1"; prefix="$2"; shift 2; mkdir -p "$workdir" && cd "$workdir" && export BEADS_DIR="$workdir/.beads" && bd config set issue_prefix "$prefix" >/dev/null 2>&1 && bd "$@"' -- "$workdir" "$want" "$@" + fi else - "${KUBECTL[@]}" exec -i "$POD_NAME" -- sh -c \ - 'workdir="$1"; shift; mkdir -p "$workdir" && cd "$workdir" && bd "$@"' -- "$workdir" "$@" + if [ "${1:-}" = "init" ]; then + "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ + 'workdir="$1"; shift; mkdir -p "$workdir" && cd "$workdir" && export BEADS_DIR="$workdir/.beads" && bd "$@"' -- "$workdir" "$@" + else + "${KUBECTL[@]}" exec "$POD_NAME" -- sh -c \ + 'workdir="$1"; shift; mkdir -p "$workdir" && cd "$workdir" && export BEADS_DIR="$workdir/.beads" && bd "$@"' -- "$workdir" "$@" + fi fi } diff --git a/contrib/k8s/Dockerfile.agent b/contrib/k8s/Dockerfile.agent index 01f1a9a60..5b7381481 100644 --- a/contrib/k8s/Dockerfile.agent +++ b/contrib/k8s/Dockerfile.agent @@ -14,6 +14,7 @@ # The gc binary should be built first and placed in the build context root: # go build -o gc ./cmd/gc +# Local build-layer image produced by Dockerfile.base, not a registry pull. ARG BASE_IMAGE=gc-agent-base:latest FROM ${BASE_IMAGE} diff --git a/contrib/k8s/Dockerfile.base b/contrib/k8s/Dockerfile.base index 01533f6f2..ebb7adeff 100644 --- a/contrib/k8s/Dockerfile.base +++ b/contrib/k8s/Dockerfile.base @@ -1,16 +1,18 @@ # Gas City agent base image — system dependencies. # # Contains everything an agent needs EXCEPT gc/bd/br binaries: OS packages, -# Node.js, Claude Code CLI, Dolt. Rebuild only when system dependencies -# change (~2.5 min). Agent image rebuilds on top take ~5s. +# Claude Code CLI, Dolt. Rebuild only when system dependencies change +# (~2.5 min). Agent image rebuilds on top take ~5s. # # Build: # make docker-base # # or: docker build -f contrib/k8s/Dockerfile.base -t gc-agent-base:latest . -FROM ubuntu:24.04 +FROM ubuntu:24.04@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b ENV DEBIAN_FRONTEND=noninteractive +ARG CLAUDE_CODE_VERSION=2.1.123 +ARG DOLT_VERSION=1.85.0 # System packages. RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -25,13 +27,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tmux \ && rm -rf /var/lib/apt/lists/* -# Node.js (for Claude Code CLI). -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && rm -rf /var/lib/apt/lists/* - -# Claude Code CLI. -RUN npm install -g @anthropic-ai/claude-code +COPY .github/scripts/install-claude-native.sh /tmp/install-claude-native.sh +RUN /tmp/install-claude-native.sh "${CLAUDE_CODE_VERSION}" \ + && rm -f /tmp/install-claude-native.sh # GitHub CLI (for git credential helper in containers). RUN mkdir -p -m 755 /etc/apt/keyrings \ @@ -44,8 +42,9 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \ && rm -rf /var/lib/apt/lists/* # Dolt CLI — pinned version (keep in sync with deps.env). -ARG DOLT_VERSION=1.85.0 -RUN curl -fsSL https://github.com/dolthub/dolt/releases/download/v${DOLT_VERSION}/install.sh | bash +COPY .github/scripts/install-dolt-archive.sh /tmp/install-dolt-archive.sh +RUN /tmp/install-dolt-archive.sh "${DOLT_VERSION}" \ + && rm -f /tmp/install-dolt-archive.sh # Default non-root user for Claude Code (--dangerously-skip-permissions rejects root). # When LINUX_USERNAME is set at runtime, the pod entrypoint creates a dynamic diff --git a/contrib/k8s/Dockerfile.controller b/contrib/k8s/Dockerfile.controller index 3c23182d0..7e64508fe 100644 --- a/contrib/k8s/Dockerfile.controller +++ b/contrib/k8s/Dockerfile.controller @@ -10,6 +10,7 @@ # The gc-agent image must be built first: # docker build -f contrib/k8s/Dockerfile.agent -t gc-agent:latest . +# Local build-layer image produced by Dockerfile.agent, not a registry pull. ARG BASE=gc-agent:latest FROM ${BASE} @@ -17,9 +18,16 @@ FROM ${BASE} USER root # kubectl for agent attach and beads/events exec providers. -RUN curl -fsSL "https://dl.k8s.io/release/$(curl -Ls \ - https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ - -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl +ARG KUBECTL_VERSION=v1.36.0 +RUN curl -fsSL \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \ + -o /tmp/kubectl \ + && curl -fsSL \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \ + -o /tmp/kubectl.sha256 \ + && echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum -c - \ + && install -m 0755 /tmp/kubectl /usr/local/bin/kubectl \ + && rm -f /tmp/kubectl /tmp/kubectl.sha256 # K8s provider scripts (beads, events). Session provider is now native # (compiled into gc binary as GC_SESSION=k8s). diff --git a/contrib/k8s/Dockerfile.mail b/contrib/k8s/Dockerfile.mail index ecc21a9f6..5c46e27d9 100644 --- a/contrib/k8s/Dockerfile.mail +++ b/contrib/k8s/Dockerfile.mail @@ -8,10 +8,12 @@ # # The server exposes JSON-RPC on port 8765 and stores messages in SQLite. -FROM python:3.12-slim +FROM python:3.12-slim@sha256:46cb7cc2877e60fbd5e21a9ae6115c30ace7a077b9f8772da879e4590c18c2e3 -RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir "mcp_agent_mail @ git+https://github.com/Dicklesworthstone/mcp_agent_mail.git" +COPY .github/requirements/mcp-agent-mail.txt /tmp/requirements-mcp-agent-mail.txt +RUN python -m pip install --no-cache-dir --require-hashes \ + -r /tmp/requirements-mcp-agent-mail.txt \ + && rm -f /tmp/requirements-mcp-agent-mail.txt EXPOSE 8765 diff --git a/docs/docs.json b/docs/docs.json index b2fbb6d5b..d197370bd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -111,6 +111,7 @@ "reference/cli", "reference/config", "reference/formula", + "reference/trust-boundaries", "reference/api", "reference/events", "schema/index", diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 9736df2ab..a656d8c40 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -40,8 +40,13 @@ The exact versions CI pins are in [`deps.env`](https://github.com/gastownhall/ga brew install gastownhall/gascity/gascity ``` -This taps the `gastownhall/gascity` formula, builds or fetches the `gc` binary, -and installs all six runtime dependencies (tmux, jq, git, dolt, flock, beads). +This taps the `gastownhall/gascity` formula, downloads the matching `gc` +release asset, and installs all six runtime dependencies (tmux, jq, git, dolt, +flock, beads). + +Once Gas City is accepted into homebrew-core, the normal install path will be +`brew install gascity`; the `gastownhall/gascity` tap remains available for +emergency updates. Verify the installation: @@ -98,7 +103,7 @@ Release tarballs are published for every tagged version. Supported platforms: ```bash # Set the version you want (check https://github.com/gastownhall/gascity/releases) -VERSION=0.13.3 +VERSION=1.0.0 # Detect platform OS=$(uname -s | tr '[:upper:]' '[:lower:]') @@ -119,6 +124,39 @@ sudo install -m 755 gc /usr/local/bin/gc gc version ``` +### Verify release artifacts + +Homebrew verifies release checksums from the formula automatically. For direct +downloads, verify the archive before installing it: + +```bash +ARCHIVE="gascity_${VERSION}_${OS}_${ARCH}.tar.gz" +CHECKSUMS="gascity_${VERSION}_checksums.txt" + +curl -fsSLO "https://github.com/gastownhall/gascity/releases/download/v${VERSION}/${CHECKSUMS}" +grep " ${ARCHIVE}$" "${CHECKSUMS}" > "${ARCHIVE}.sha256" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${ARCHIVE}.sha256" +else + shasum -a 256 -c "${ARCHIVE}.sha256" +fi +``` + +Release archives are also published with GitHub artifact attestations. If you +have the GitHub CLI installed, verify the downloaded archive against the +`gastownhall/gascity` repository: + +```bash +gh attestation verify "${ARCHIVE}" --repo gastownhall/gascity +``` + +Each release also includes an SPDX SBOM asset: + +```bash +curl -fsSLO "https://github.com/gastownhall/gascity/releases/download/v${VERSION}/gascity-v${VERSION}.spdx.json" +``` + ### Upgrading a direct-download install Repeat the download steps above with the new version number. The `gc` binary is diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 32f37161f..7f99687ed 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -643,7 +643,7 @@ gc convoy | [gc convoy close](#gc-convoy-close) | Close a convoy | | [gc convoy control](#gc-convoy-control) | Execute control beads or run the control-dispatcher loop | | [gc convoy create](#gc-convoy-create) | Create a convoy and optionally track issues | -| [gc convoy delete](#gc-convoy-delete) | Close and optionally delete a convoy and all its beads | +| [gc convoy delete](#gc-convoy-delete) | Close or delete a convoy and all its beads | | [gc convoy delete-source](#gc-convoy-delete-source) | Close workflows sourced from a bead | | [gc convoy land](#gc-convoy-land) | Land an owned convoy (terminate + cleanup) | | [gc convoy list](#gc-convoy-list) | List open convoys with progress | @@ -730,13 +730,13 @@ gc convoy create sprint-42 ## gc convoy delete -Close all open beads in a convoy, then optionally delete them. +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. ``` gc convoy delete [flags] @@ -744,7 +744,7 @@ gc convoy delete [flags] | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--delete` | bool | | Also delete beads from the store after closing | +| `--delete` | bool | | Delete beads from the store instead of closing | | `-f`, `--force` | bool | | Actually close/delete (without this, shows preview) | ## gc convoy delete-source @@ -1410,6 +1410,7 @@ gc mail read 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. ``` diff --git a/docs/reference/config.md b/docs/reference/config.md index 4181018c3..1c8529a13 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -83,7 +83,7 @@ Agent defines a configured agent in the city. | `option_defaults` | map[string]string | | | OptionDefaults overrides the provider's effective schema defaults for this agent. Keys are option keys, values are choice values. Applied on top of the provider's OptionDefaults (agent keys win). Example: option_defaults = { permission_mode = "plan", model = "sonnet" } | | `max_active_sessions` | integer | | | MaxActiveSessions is the agent-level cap on concurrent sessions. Nil means inherit from rig, then workspace, then unlimited. Replaces pool.max. | | `min_active_sessions` | integer | | | MinActiveSessions is the minimum number of sessions to keep alive. Agent-level only. Counts against rig/workspace caps. Replaces pool.min. | -| `scale_check` | string | | | ScaleCheck is a shell command template whose output determines desired session count. Optional override — when set, its output is the desired count (still clamped by all cap levels). If it contains Go template placeholders, gc expands them using the same PathContext fields as work_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName) before running the command. | +| `scale_check` | string | | | ScaleCheck is a shell command template whose output reports new unassigned session demand. In bead-backed reconciliation this is additive: assigned work is resumed separately, and ScaleCheck reports only how many new generic sessions to start, still bounded by all cap levels. Legacy no-store evaluation continues to treat the output as the desired session count. If it contains Go template placeholders, gc expands them using the same PathContext fields as work_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName) before running the command. | | `drain_timeout` | string | | `5m` | DrainTimeout is the maximum time to wait for a session to finish its current work before force-killing it during scale-down. Duration string (e.g., "5m", "30m", "1h"). Defaults to "5m". | | `on_boot` | string | | | OnBoot is a shell command template run once at controller startup for this agent. If it contains Go template placeholders, gc expands them using the same PathContext fields as work_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName) before running the command. | | `on_death` | string | | | OnDeath is a shell command template run when a session dies unexpectedly. If it contains Go template placeholders, gc expands them using the same PathContext fields as work_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName) before running the command. | @@ -172,7 +172,7 @@ AgentOverride modifies a pack-stamped agent for a specific rig. | `inject_fragments_append` | []string | | | InjectFragmentsAppend appends to the agent's inject_fragments list. | | `max_active_sessions` | integer | | | MaxActiveSessions overrides the agent-level cap on concurrent sessions. | | `min_active_sessions` | integer | | | MinActiveSessions overrides the minimum number of sessions to keep alive. | -| `scale_check` | string | | | ScaleCheck overrides the shell command whose output determines desired session count. | +| `scale_check` | string | | | ScaleCheck overrides the shell command whose output reports new unassigned session demand for bead-backed reconciliation. | | `option_defaults` | map[string]string | | | OptionDefaults adds or overrides provider option defaults for this agent. Keys are option keys, values are choice values. Merges additively (override keys win over existing agent keys). Example: option_defaults = { model = "sonnet" } | ## AgentPatch @@ -222,7 +222,7 @@ AgentPatch modifies an existing agent identified by (Dir, Name). | `inject_fragments_append` | []string | | | InjectFragmentsAppend appends to the agent's inject_fragments list. | | `max_active_sessions` | integer | | | MaxActiveSessions overrides the agent-level cap on concurrent sessions. | | `min_active_sessions` | integer | | | MinActiveSessions overrides the minimum number of sessions to keep alive. | -| `scale_check` | string | | | ScaleCheck overrides the command template whose output determines desired session count. Supports the same Go template placeholders as Agent.scale_check. | +| `scale_check` | string | | | ScaleCheck overrides the command template whose output reports new unassigned session demand for bead-backed reconciliation. Supports the same Go template placeholders as Agent.scale_check. | | `option_defaults` | map[string]string | | | OptionDefaults adds or overrides provider option defaults for this agent. Keys are option keys, values are choice values. Merges additively (patch keys win over existing agent keys). Example: option_defaults = { model = "sonnet" } | ## BeadsConfig @@ -350,6 +350,7 @@ OptionChoice is one allowed value for a "select" option. | `value` | string | **yes** | | | | `label` | string | **yes** | | | | `flag_args` | []string | **yes** | | FlagArgs are the CLI arguments injected when this choice is selected. json:"-" is intentional: FlagArgs must never appear in the public API DTO (security boundary — prevents clients from seeing internal CLI flags). | +| `flag_aliases` | []array | | | FlagAliases are equivalent CLI argument sequences stripped from legacy provider args. Like FlagArgs, they stay server-side only. | ## OrderOverride @@ -434,7 +435,9 @@ ProviderPatch modifies an existing provider identified by Name. | `name` | string | **yes** | | Name is the targeting key (required). Must match an existing provider's name. | | `base` | string | | | Base overrides the provider's inheritance parent (presence-aware). Pointer to a pointer so the patch can distinguish "no change" (double-nil) from "clear to inherit default" (single-nil value in outer pointer) from "set to explicit empty opt-out" (value "" in inner pointer) from "set to <name>". Callers use: nil = patch does not touch Base &(*string)(nil) = patch clears Base to absent &(&"") = patch sets Base = "" (explicit opt-out) &(&"builtin:codex") = patch sets Base to that value | | `command` | string | | | Command overrides the provider command. | +| `acp_command` | string | | | ACPCommand overrides the provider command for ACP transport sessions. | | `args` | []string | | | Args overrides the provider args. | +| `acp_args` | []string | | | ACPArgs overrides the provider args for ACP transport sessions. | | `args_append` | []string | | | ArgsAppend overrides the provider args_append list. | | `options_schema_merge` | string | | | OptionsSchemaMerge overrides the options_schema merge mode. | | `prompt_mode` | string | | | PromptMode overrides prompt delivery mode. Enum: `arg`, `flag`, `none` | @@ -476,6 +479,8 @@ ProviderSpec defines a named provider's startup parameters. | `options_schema` | []ProviderOption | | | OptionsSchema declares the configurable options this provider supports. Each option maps to CLI args via its Choices[].FlagArgs field. Serialized via a dedicated DTO (not directly to JSON) so FlagArgs stays server-side. | | `print_args` | []string | | | PrintArgs are CLI arguments that enable one-shot non-interactive mode. The provider prints its response to stdout and exits. When empty, the provider does not support one-shot invocation. Examples: ["-p"] (claude, gemini), ["exec"] (codex) | | `title_model` | string | | | TitleModel is the OptionsSchema model key used for title generation. Resolved via the "model" option in OptionsSchema to get FlagArgs. Defaults to the cheapest/fastest model for each provider. Examples: "haiku" (claude), "o4-mini" (codex), "gemini-2.5-flash" (gemini) | +| `acp_command` | string | | | ACPCommand overrides Command when the session transport is ACP. When empty, Command is used for both tmux and ACP transports. | +| `acp_args` | []string | | | ACPArgs overrides Args when the session transport is ACP. When nil, Args is used for both tmux and ACP transports. | ## Rig diff --git a/docs/reference/trust-boundaries.md b/docs/reference/trust-boundaries.md new file mode 100644 index 000000000..e9da0e678 --- /dev/null +++ b/docs/reference/trust-boundaries.md @@ -0,0 +1,62 @@ +--- +title: "Command Execution Trust Boundaries" +--- + +Gas City intentionally runs operator-configured commands. Those commands are a +feature, not a sandbox. Treat city config, imported packs, exec provider +scripts, and agent startup commands as trusted code with the same review +expectations as shell scripts committed to the repository. + +## Trust Model + +| Input | Trust level | Rule | +|-------|-------------|------| +| Maintainer-authored city config and local site config | Trusted operator code | May define shell commands and explicit env. Review before use. | +| Imported packs and rig configs | Trusted dependency code | Pin/review packs before importing into a privileged city. | +| Bead titles, descriptions, mail, formula vars, PR text, and API request fields | Untrusted data | Do not concatenate into shell commands. Pass as env, JSON, stdin, or argv. | +| GitHub Actions `pull_request_target` payloads | Untrusted data in a privileged workflow | Do not checkout or execute contributor code. Use metadata-only operations. | +| Ambient process environment | Untrusted for secret propagation | Controller-side shell helpers strip inherited secret-looking env keys by default. | + +## Execution Surfaces + +| Surface | Command source | Actor | Working directory | Env behavior | Log behavior | +|---------|----------------|-------|-------------------|--------------|--------------| +| `work_query` via `gc hook` and controller probes | Agent config | Trusted operator or pack | Agent's canonical city or rig repo | Inherited secrets are stripped; Gas City projects explicit store/session env. | Errors are diagnostic only. Avoid placing secrets in command literals. | +| `scale_check` | Agent config | Trusted operator or pack | Agent's canonical city or rig repo | Inherited secrets are stripped; Gas City projects explicit store env. | Parse failures include command context; command literals must not contain secrets. | +| `on_boot` and `on_death` | Agent pool config | Trusted operator or pack | City or rig repo | Inherited secrets are stripped; explicit store env may be provided when needed. | Hook failures are logged; output should not include secrets. | +| Order `check` triggers | Order config | Trusted operator or pack | Order target scope | Inherited secrets are stripped; explicit condition env may be provided. | Failure reason records exit status, not command output. | +| Order `exec` | Order config | Trusted operator or pack | Order target scope | Inherited secrets are stripped; explicit order env may be provided. | Failure errors and output are redacted before logs/events. | +| `gc sling` and `/sling` command runner | Sling target config | Trusted operator or pack | City or rig repo | Inherited secrets are stripped; explicit routing/store env may be provided. | Returned command output is caller-visible. Do not route untrusted text into shell. | +| Agent `command` | Agent config | Trusted operator or pack | Session work directory | Session env is explicit runtime env plus configured env. Secrets may be passed only by intentional config. | Agent stdout/stderr is session output and may be visible to operators. | +| `pre_start` | Agent config | Trusted operator or pack | Session work directory | Provider-specific runtime env; intended for setup before session start. | Provider warnings should avoid secrets. | +| `session_setup`, `session_setup_script`, `session_live` | Agent config | Trusted operator or pack | Running session environment | Provider-specific runtime env; remote providers run inside the target container or pod. | Provider warnings should avoid secrets. | +| `exec:` session provider | User-supplied provider script | Trusted operator code | Provider-defined | Direct exec, not `sh -c`; start config is JSON on stdin. | Provider stderr may be surfaced in errors. Do not print secrets. | +| `exec:` beads, mail, and events providers | User-supplied provider script | Trusted operator code | Provider-defined | Direct exec, not `sh -c`; request data is stdin/argv. | Provider stderr may be surfaced in errors. Do not print secrets. | +| Pack fetch/include, Git probes, Docker, Dolt, tmux, kubectl, `bd` helpers | Gas City code plus configured paths/URLs | Maintainer-reviewed code paths | Command-specific | Direct exec with argv except provider setup scripts where documented. | Errors are surfaced for diagnosis; avoid embedding credentials in URLs. | + +## Secret Propagation + +Controller-side shell helpers remove inherited environment variables whose keys +look secret-bearing, including names containing `TOKEN`, `PASSWORD`, `SECRET`, +`PRIVATE_KEY`, `API_KEY`, `ACCESS_KEY`, `CREDENTIAL`, `OAUTH`, or `AUTH_JSON`. +This prevents ambient CI or maintainer shell secrets from reaching `work_query`, +`scale_check`, hooks, order checks, order exec commands, and sling helpers by +accident. + +If a command truly needs a secret, pass it explicitly through the relevant city, +rig, provider, or workflow configuration. Explicit values are preserved because +they represent an operator decision, and failure logs redact known secret values +before writing order exec errors or events. + +## Rules For Authors + +- Do not put secrets directly in command strings. Use env variables or provider + credential files. +- Do not interpolate bead content, PR text, mail, formula vars, branch names, or + other user-controlled values into `sh -c` commands. +- When showing a command for a human to copy, build it from argv and quote each + argument with Gas City's shell quoting helper. +- Keep `pull_request_target` workflows metadata-only. They may label or comment + but must not checkout or run contributor code with privileged tokens. +- Prefer direct `exec.Command(..., args...)` style boundaries for new provider + contracts. Use `sh -c` only for explicitly operator-authored shell snippets. diff --git a/docs/schema/city-schema.json b/docs/schema/city-schema.json index e6aaa40de..e1b521634 100644 --- a/docs/schema/city-schema.json +++ b/docs/schema/city-schema.json @@ -169,7 +169,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck is a shell command template whose output determines desired\nsession count. Optional override — when set, its output is the desired\ncount (still clamped by all cap levels). If it contains Go template\nplaceholders, gc expands them using the same PathContext fields as\nwork_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot,\nCityName) before running the command." + "description": "ScaleCheck is a shell command template whose output reports new\nunassigned session demand. In bead-backed reconciliation this is\nadditive: assigned work is resumed separately, and ScaleCheck reports\nonly how many new generic sessions to start, still bounded by all cap\nlevels. Legacy no-store evaluation continues to treat the output as\nthe desired session count. If it contains Go template placeholders, gc\nexpands them using the same PathContext fields as work_dir and\nsession_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName)\nbefore running the command." }, "drain_timeout": { "type": "string", @@ -592,7 +592,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck overrides the shell command whose output determines desired session count." + "description": "ScaleCheck overrides the shell command whose output reports new\nunassigned session demand for bead-backed reconciliation." }, "option_defaults": { "additionalProperties": { @@ -835,7 +835,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck overrides the command template whose output determines desired\nsession count. Supports the same Go template placeholders as\nAgent.scale_check." + "description": "ScaleCheck overrides the command template whose output reports new\nunassigned session demand for bead-backed reconciliation. Supports the\nsame Go template placeholders as Agent.scale_check." }, "option_defaults": { "additionalProperties": { @@ -1266,6 +1266,16 @@ }, "type": "array", "description": "FlagArgs are the CLI arguments injected when this choice is selected.\njson:\"-\" is intentional: FlagArgs must never appear in the public API DTO\n(security boundary — prevents clients from seeing internal CLI flags)." + }, + "flag_aliases": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array", + "description": "FlagAliases are equivalent CLI argument sequences stripped from legacy\nprovider args. Like FlagArgs, they stay server-side only." } }, "additionalProperties": false, @@ -1491,6 +1501,10 @@ "type": "string", "description": "Command overrides the provider command." }, + "acp_command": { + "type": "string", + "description": "ACPCommand overrides the provider command for ACP transport sessions." + }, "args": { "items": { "type": "string" @@ -1498,6 +1512,13 @@ "type": "array", "description": "Args overrides the provider args." }, + "acp_args": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ACPArgs overrides the provider args for ACP transport sessions." + }, "args_append": { "items": { "type": "string" @@ -1693,6 +1714,17 @@ "title_model": { "type": "string", "description": "TitleModel is the OptionsSchema model key used for title generation.\nResolved via the \"model\" option in OptionsSchema to get FlagArgs.\nDefaults to the cheapest/fastest model for each provider.\nExamples: \"haiku\" (claude), \"o4-mini\" (codex), \"gemini-2.5-flash\" (gemini)" + }, + "acp_command": { + "type": "string", + "description": "ACPCommand overrides Command when the session transport is ACP.\nWhen empty, Command is used for both tmux and ACP transports." + }, + "acp_args": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ACPArgs overrides Args when the session transport is ACP.\nWhen nil, Args is used for both tmux and ACP transports." } }, "additionalProperties": false, diff --git a/docs/schema/city-schema.txt b/docs/schema/city-schema.txt index e6aaa40de..e1b521634 100644 --- a/docs/schema/city-schema.txt +++ b/docs/schema/city-schema.txt @@ -169,7 +169,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck is a shell command template whose output determines desired\nsession count. Optional override — when set, its output is the desired\ncount (still clamped by all cap levels). If it contains Go template\nplaceholders, gc expands them using the same PathContext fields as\nwork_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot,\nCityName) before running the command." + "description": "ScaleCheck is a shell command template whose output reports new\nunassigned session demand. In bead-backed reconciliation this is\nadditive: assigned work is resumed separately, and ScaleCheck reports\nonly how many new generic sessions to start, still bounded by all cap\nlevels. Legacy no-store evaluation continues to treat the output as\nthe desired session count. If it contains Go template placeholders, gc\nexpands them using the same PathContext fields as work_dir and\nsession_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName)\nbefore running the command." }, "drain_timeout": { "type": "string", @@ -592,7 +592,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck overrides the shell command whose output determines desired session count." + "description": "ScaleCheck overrides the shell command whose output reports new\nunassigned session demand for bead-backed reconciliation." }, "option_defaults": { "additionalProperties": { @@ -835,7 +835,7 @@ }, "scale_check": { "type": "string", - "description": "ScaleCheck overrides the command template whose output determines desired\nsession count. Supports the same Go template placeholders as\nAgent.scale_check." + "description": "ScaleCheck overrides the command template whose output reports new\nunassigned session demand for bead-backed reconciliation. Supports the\nsame Go template placeholders as Agent.scale_check." }, "option_defaults": { "additionalProperties": { @@ -1266,6 +1266,16 @@ }, "type": "array", "description": "FlagArgs are the CLI arguments injected when this choice is selected.\njson:\"-\" is intentional: FlagArgs must never appear in the public API DTO\n(security boundary — prevents clients from seeing internal CLI flags)." + }, + "flag_aliases": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array", + "description": "FlagAliases are equivalent CLI argument sequences stripped from legacy\nprovider args. Like FlagArgs, they stay server-side only." } }, "additionalProperties": false, @@ -1491,6 +1501,10 @@ "type": "string", "description": "Command overrides the provider command." }, + "acp_command": { + "type": "string", + "description": "ACPCommand overrides the provider command for ACP transport sessions." + }, "args": { "items": { "type": "string" @@ -1498,6 +1512,13 @@ "type": "array", "description": "Args overrides the provider args." }, + "acp_args": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ACPArgs overrides the provider args for ACP transport sessions." + }, "args_append": { "items": { "type": "string" @@ -1693,6 +1714,17 @@ "title_model": { "type": "string", "description": "TitleModel is the OptionsSchema model key used for title generation.\nResolved via the \"model\" option in OptionsSchema to get FlagArgs.\nDefaults to the cheapest/fastest model for each provider.\nExamples: \"haiku\" (claude), \"o4-mini\" (codex), \"gemini-2.5-flash\" (gemini)" + }, + "acp_command": { + "type": "string", + "description": "ACPCommand overrides Command when the session transport is ACP.\nWhen empty, Command is used for both tmux and ACP transports." + }, + "acp_args": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ACPArgs overrides Args when the session transport is ACP.\nWhen nil, Args is used for both tmux and ACP transports." } }, "additionalProperties": false, diff --git a/docs/schema/openapi.json b/docs/schema/openapi.json index 377d0abcf..45a78ea76 100644 --- a/docs/schema/openapi.json +++ b/docs/schema/openapi.json @@ -674,6 +674,15 @@ "AnnotatedProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4473,6 +4482,20 @@ "ProviderCreateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -4598,6 +4621,21 @@ "ProviderPatch": { "additionalProperties": false, "properties": { + "ACPArgs": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "ACPCommand": { + "type": [ + "string", + "null" + ] + }, "Args": { "items": { "type": "string" @@ -4679,7 +4717,9 @@ "Name", "Base", "Command", + "ACPCommand", "Args", + "ACPArgs", "ArgsAppend", "OptionsSchemaMerge", "PromptMode", @@ -4694,6 +4734,20 @@ "ProviderPatchSetInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "Override ACP transport command arguments.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "Override ACP transport command binary.", + "type": "string" + }, "args": { "description": "Override command arguments.", "items": { @@ -4839,6 +4893,15 @@ "ProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4890,6 +4953,15 @@ "ProviderSpecJSON": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4927,6 +4999,20 @@ "ProviderUpdateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -17657,6 +17743,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } } ], "responses": { diff --git a/docs/schema/openapi.txt b/docs/schema/openapi.txt index 377d0abcf..45a78ea76 100644 --- a/docs/schema/openapi.txt +++ b/docs/schema/openapi.txt @@ -674,6 +674,15 @@ "AnnotatedProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4473,6 +4482,20 @@ "ProviderCreateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -4598,6 +4621,21 @@ "ProviderPatch": { "additionalProperties": false, "properties": { + "ACPArgs": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "ACPCommand": { + "type": [ + "string", + "null" + ] + }, "Args": { "items": { "type": "string" @@ -4679,7 +4717,9 @@ "Name", "Base", "Command", + "ACPCommand", "Args", + "ACPArgs", "ArgsAppend", "OptionsSchemaMerge", "PromptMode", @@ -4694,6 +4734,20 @@ "ProviderPatchSetInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "Override ACP transport command arguments.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "Override ACP transport command binary.", + "type": "string" + }, "args": { "description": "Override command arguments.", "items": { @@ -4839,6 +4893,15 @@ "ProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4890,6 +4953,15 @@ "ProviderSpecJSON": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4927,6 +4999,20 @@ "ProviderUpdateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -17657,6 +17743,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } } ], "responses": { diff --git a/docs/troubleshooting/dolt-bloat-recovery.md b/docs/troubleshooting/dolt-bloat-recovery.md index 12eda71b9..758990221 100644 --- a/docs/troubleshooting/dolt-bloat-recovery.md +++ b/docs/troubleshooting/dolt-bloat-recovery.md @@ -85,13 +85,20 @@ If GC finishes but the size barely moves, the chunks are nearly all live floor; newer releases ship improved auto-GC heuristics and default archive compression. - **Let the dolt pack's `dolt-gc-nudge` order run continuously.** It - ships embedded in the dolt pack and fires every 6h by default. The - nudge catches the "bloated-but-stable" corner case where no single - write burst crosses Dolt's 125 MB auto-GC threshold. To opt out on a - given city, add `dolt-gc-nudge` to the city's `[orders] skip = [...]` - list (or to a rig-level `[[order.override]]`). Tune the trigger size - via the `GC_DOLT_GC_THRESHOLD_BYTES` environment variable - (default: 2 GiB) in the city's environment. + ships embedded in the dolt pack and fires `CALL DOLT_GC()` every 1h + by default, unconditionally. Gas City's managed-Dolt launch path now + forces `DOLT_GC_SCHEDULER=NONE`, which restores Dolt's configured + auto-GC behavior on multi-core hosts affected by + [dolthub/dolt#10944](https://github.com/dolthub/dolt/issues/10944). + The hourly nudge remains valuable as a belt-and-suspenders backstop + for the bd workload and as an unconditional recovery path if the + threshold-triggered auto-GC has nothing to do for a while. GC is + idempotent and near-free when there's nothing to reclaim, so running + it every hour is cheap. To opt out on a given city, add + `dolt-gc-nudge` to the city's `[orders] skip = [...]` list (or to a + rig-level `[[order.override]]`). To skip GC on small databases, set + `GC_DOLT_GC_THRESHOLD_BYTES` to a positive byte count in the city's + environment (default: 0 — run unconditionally). - **Mind `orders.max_timeout` if you set one.** The nudge order asks for a 24-hour timeout to accommodate serialized `CALL DOLT_GC()` runs on large stores. A city-level `orders.max_timeout` below 24h will cap the diff --git a/examples/bd/assets/scripts/gc-beads-bd.sh b/examples/bd/assets/scripts/gc-beads-bd.sh index 69d3639b1..56cb7b79d 100755 --- a/examples/bd/assets/scripts/gc-beads-bd.sh +++ b/examples/bd/assets/scripts/gc-beads-bd.sh @@ -1511,6 +1511,17 @@ op_start() { log_offset=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0) fi + # Disable Dolt's load-average auto-GC scheduler. Dolt 1.86.0+ + # ships a loadAvgGCScheduler whose threshold formula scales + # inversely with CPU count (10/CPUs), so on multi-core hosts the + # gate is essentially always tripped and CALL DOLT_GC() is + # queued but never executed; auto_gc_behavior.enable: true in + # config.yaml has no effect. See + # https://github.com/dolthub/dolt/issues/10944. Users who + # explicitly set DOLT_GC_SCHEDULER are respected. + : "${DOLT_GC_SCHEDULER=NONE}" + export DOLT_GC_SCHEDULER + # Start dolt sql-server with config file. Close the startup lock fd in # the child so the flock is released when this starter exits. nohup sh -c 'exec 9>&-; exec dolt sql-server --config "$1"' sh "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 & diff --git a/examples/dolt/commands/gc-nudge/run.sh b/examples/dolt/commands/gc-nudge/run.sh index 37af4e0d8..302c988d3 100755 --- a/examples/dolt/commands/gc-nudge/run.sh +++ b/examples/dolt/commands/gc-nudge/run.sh @@ -1,16 +1,20 @@ #!/bin/sh -# gc dolt gc-nudge — Size-triggered CALL DOLT_GC() to compact a bloated -# Dolt database. +# gc dolt gc-nudge — periodic CALL DOLT_GC() to bound the Dolt commit graph. # -# Why this exists: Dolt's auto-GC (default-on in 1.75+) fires on *growth* -# — 125 MB delta since last GC. A database that bloated once and then -# stabilized never auto-GCs on its own. This command closes that corner: -# it checks disk size on each registered rig's Dolt database, and if any -# are above the configured threshold, issues CALL DOLT_GC() against the -# managed sql-server. +# Why this exists: Gas City's managed-Dolt launch path now forces +# `DOLT_GC_SCHEDULER=NONE` to work around +# https://github.com/dolthub/dolt/issues/10944, so threshold-triggered +# auto-GC can fire again on multi-core hosts. We still keep an hourly +# nudge because the bd workload can accumulate history quickly, and an +# unconditional `CALL DOLT_GC()` remains a cheap belt-and-suspenders +# backstop for reclaiming orphan chunks before they turn into disk bloat +# and tail-latency spikes. # -# Runs from the dolt pack's dolt-gc-nudge order on a slow cooldown (6h by -# default). Intended to be idempotent and cheap when nothing needs GC. +# Policy: fire CALL DOLT_GC() unconditionally on every cooldown tick +# (default 1h). The GC is idempotent and near-free when there's nothing +# to reclaim. A threshold knob remains as an optional escape hatch. +# +# Runs from the dolt pack's dolt-gc-nudge order. # # Environment: # GC_CITY_PATH (required) — city root @@ -19,8 +23,9 @@ # GC_DOLT_USER (default: root) # GC_DOLT_PASSWORD (optional) # GC_DOLT_GC_THRESHOLD_BYTES -# (default: 2147483648 = 2 GiB) — minimum .dolt/ size that triggers GC. -# Set to 0 to force GC on every tick (useful for tests). +# (default: 0 — run unconditionally). Set a positive byte count to +# skip GC on databases below that size; useful for test suites that +# don't want GC noise on tiny fixtures. # GC_DOLT_GC_CALL_TIMEOUT_SECS # (default: 1800) — wall-clock bound for one `CALL DOLT_GC()` invocation. # GC_DOLT_GC_DRY_RUN (optional) — when set, prints what would happen @@ -90,7 +95,7 @@ fi : "${GC_DOLT_USER:=root}" host="${GC_DOLT_HOST:-127.0.0.1}" -threshold="${GC_DOLT_GC_THRESHOLD_BYTES:-2147483648}" +threshold="${GC_DOLT_GC_THRESHOLD_BYTES:-0}" gc_call_timeout="${GC_DOLT_GC_CALL_TIMEOUT_SECS:-1800}" dry_run="${GC_DOLT_GC_DRY_RUN:-}" @@ -291,7 +296,8 @@ run_dolt_gc_for_db() { run_bounded "$gc_call_timeout" \ dolt --host "$host" --port "$GC_DOLT_PORT" \ --user "$GC_DOLT_USER" --no-tls \ - sql --database "$db" -q "CALL DOLT_GC()" || cmd_rc=$? + --use-db "$db" \ + sql -q "CALL DOLT_GC()" || cmd_rc=$? elapsed=$(( $(date +%s) - start )) after=$(dir_bytes "$db_dir") diff --git a/examples/dolt/commands/health/run.sh b/examples/dolt/commands/health/run.sh index 3e3385b67..4490dffb4 100755 --- a/examples/dolt/commands/health/run.sh +++ b/examples/dolt/commands/health/run.sh @@ -235,6 +235,9 @@ fi # positives from processes that merely mention "dolt" in their args # (e.g., Claude sessions whose prompt text contains "dolt sql-server"). # +# Rig-local Dolt servers (configured via dolt.port in config.yaml) +# are legitimate — exclude any PID listening on a known rig port. +# # GC_HEALTH_SKIP_ZOMBIE_SCAN is a test-only escape hatch. Zombie # enumeration spawns one `ps` per matching process, which on shared # dev machines with many accumulated dolt processes dominates the @@ -244,8 +247,22 @@ fi zombie_count=0 zombie_pids="" if [ "${GC_HEALTH_SKIP_ZOMBIE_SCAN:-0}" != "1" ]; then + # Collect PIDs of legitimate rig-local Dolt servers. + rig_dolt_pids="" + while IFS= read -r meta; do + [ -f "$meta" ] || continue + config_file="$(dirname "$meta")/config.yaml" + [ -f "$config_file" ] || continue + rig_port=$(grep '^dolt\.port:' "$config_file" 2>/dev/null | sed "s/^dolt\\.port:[[:space:]]*//; s/[[:space:]]*#.*$//; s/['\\\"]//g; s/[[:space:]]*$//" | head -1) + case "$rig_port" in ''|*[!0-9]*) continue ;; esac + [ "$rig_port" = "$GC_DOLT_PORT" ] && continue + rig_pid=$(managed_runtime_listener_pid "$rig_port" || true) + [ -n "$rig_pid" ] && rig_dolt_pids="$rig_dolt_pids $rig_pid " + done < "$_meta_cache" + for p in $(pgrep -x dolt 2>/dev/null || true); do [ "$p" = "$server_pid" ] && continue + case "$rig_dolt_pids" in *" $p "*) continue ;; esac cmd=$(ps -p "$p" -o args= 2>/dev/null || true) case "$cmd" in *sql-server*) ;; diff --git a/examples/dolt/health_test.go b/examples/dolt/health_test.go index f72e3a0a5..5c7addf3f 100644 --- a/examples/dolt/health_test.go +++ b/examples/dolt/health_test.go @@ -688,6 +688,141 @@ func writeExecutable(t *testing.T, path, contents string) { } } +// TestHealthScriptZombieScanExcludesRigLocalServers verifies that +// Dolt processes on rig-configured ports are not flagged as zombies. +// Regression guard for the bug where deacon patrol killed rig-local +// Dolt servers because the zombie scan treated every non-city-server +// dolt sql-server PID as a zombie. +func runHealthScriptZombieScanExcludesRigLocalServers(t *testing.T, rigConfig string) { + cityPath := t.TempDir() + fakeBin := t.TempDir() + + mainPort := "19901" + rigPort := "19902" + + mainPID := "424201" + rigPID := "424202" + zombiePID := "424203" + + // City .beads directory with metadata. + if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cityPath, ".beads", "metadata.json"), + []byte(`{"dolt_database":"city"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Rig directory with config.yaml containing dolt.port. + rigBeads := filepath.Join(cityPath, "rigs", "enterprise", ".beads") + if err := os.MkdirAll(rigBeads, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigBeads, "metadata.json"), + []byte(`{"dolt_database":"enterprise"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigBeads, "config.yaml"), + []byte(rigConfig), 0o644); err != nil { + t.Fatal(err) + } + + // Fake gc: fail so metadata_files() falls back to find. + writeExecutable(t, filepath.Join(fakeBin, "gc"), "#!/bin/sh\nexit 1\n") + + // Fake pgrep: returns rig PID and zombie PID (main PID excluded + // by server_pid check, not by pgrep filtering). + writeExecutable(t, filepath.Join(fakeBin, "pgrep"), + fmt.Sprintf("#!/bin/sh\necho %s\necho %s\necho %s\n", mainPID, rigPID, zombiePID)) + + // Fake lsof: maps ports to PIDs. + writeExecutable(t, filepath.Join(fakeBin, "lsof"), + fmt.Sprintf(`#!/bin/sh +for arg in "$@"; do + case "$arg" in + -iTCP:%s) echo %s; exit 0 ;; + -iTCP:%s) echo %s; exit 0 ;; + esac +done +exit 1 +`, mainPort, mainPID, rigPort, rigPID)) + + // Fake ps: handles pid_is_running (-o pid=) and zombie scan (-o args=). + writeExecutable(t, filepath.Join(fakeBin, "ps"), `#!/bin/sh +if [ "$1" = "-p" ] && [ "$3" = "-o" ]; then + case "$4" in + pid=) printf ' %s\n' "$2"; exit 0 ;; + args=) echo "dolt sql-server"; exit 0 ;; + esac +fi +exit 1 +`) + + // Fake nc: unreachable (no real server). + writeExecutable(t, filepath.Join(fakeBin, "nc"), "#!/bin/sh\nexit 1\n") + + // Fake dolt: SELECT 1 fails (no real server). + writeExecutable(t, filepath.Join(fakeBin, "dolt"), "#!/bin/sh\nexit 1\n") + + root := repoRoot(t) + cmd := exec.Command("sh", filepath.Join(root, healthScript), "--json") + cmd.Env = append( + filteredEnv("GC_CITY_PATH", "GC_PACK_DIR", "GC_DOLT_HOST", "GC_DOLT_PORT", + "GC_DOLT_USER", "GC_DOLT_PASSWORD", "GC_HEALTH_SKIP_ZOMBIE_SCAN", "PATH"), + "GC_CITY_PATH="+cityPath, + "GC_PACK_DIR="+root, + "GC_DOLT_HOST=127.0.0.1", + "GC_DOLT_PORT="+mainPort, + "GC_DOLT_USER=root", + "GC_DOLT_PASSWORD=", + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("health.sh failed: %v\n%s", err, out) + } + + output := string(out) + + // The true zombie (424203) should be counted. + if !strings.Contains(output, `"zombie_count": 1`) { + t.Errorf("expected zombie_count 1; got:\n%s", output) + } + + // The rig PID (424202) must NOT appear in zombie_pids. + if strings.Contains(output, rigPID) { + t.Errorf("rig-local Dolt PID %s should not be in zombie_pids; got:\n%s", rigPID, output) + } + + // The true zombie PID (424203) must appear in zombie_pids. + if !strings.Contains(output, zombiePID) { + t.Errorf("true zombie PID %s should be in zombie_pids; got:\n%s", zombiePID, output) + } +} + +func TestHealthScriptZombieScanExcludesRigLocalServers(t *testing.T) { + tests := []struct { + name string + rigConfig string + }{ + { + name: "bare port", + rigConfig: "dolt.port: 19902\n", + }, + { + name: "quoted port", + rigConfig: "dolt.port: \"19902\"\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runHealthScriptZombieScanExcludesRigLocalServers(t, tc.rigConfig) + }) + } +} + // TestHealthScriptJSONAlwaysExitsZero guards the JSON-mode exit // contract. Automation consumers (notably the deacon patrol formula) // parse the JSON payload and key health decisions off `server.reachable`. diff --git a/examples/dolt/orders/dolt-gc-nudge.toml b/examples/dolt/orders/dolt-gc-nudge.toml index 297f938dd..a44b78c63 100644 --- a/examples/dolt/orders/dolt-gc-nudge.toml +++ b/examples/dolt/orders/dolt-gc-nudge.toml @@ -1,6 +1,6 @@ [order] -description = "Size-triggered CALL DOLT_GC() when a Dolt database grows past threshold" +description = "Periodic CALL DOLT_GC() to bound commit-graph size (Dolt's auto-GC doesn't fire on bd workloads)" trigger = "cooldown" -interval = "6h" +interval = "1h" exec = "gc dolt gc-nudge" timeout = "24h" diff --git a/examples/gastown/packs/gastown/formulas/mol-polecat-work.toml b/examples/gastown/packs/gastown/formulas/mol-polecat-work.toml index afcbc722a..961733231 100644 --- a/examples/gastown/packs/gastown/formulas/mol-polecat-work.toml +++ b/examples/gastown/packs/gastown/formulas/mol-polecat-work.toml @@ -33,7 +33,7 @@ refinery. Resume the existing branch — don't redo all the work. | Unsure what to do | Mail Witness, don't guess |""" formula = "mol-polecat-work" extends = ["mol-polecat-base"] -version = 8 +version = 9 [[steps]] id = "workspace-setup" @@ -93,6 +93,60 @@ Check if `metadata.branch` already records a branch: BRANCH=$(gc bd show {{issue}} --json | jq -r '.[0].metadata.branch // empty') ``` +**Pre-spawn duplicate-branch check.** If `metadata.branch` is empty, scan +origin for any polecat branch already carrying this bead ID before +creating a fresh one. Re-spawning a polecat for a bead that another +polecat alias is already working leads to duplicate PRs against the same +issue. The check turns that re-spawn into a hand-off (same alias) or a +soft refusal (different alias). + +```bash +if [ -z "$BRANCH" ]; then + ALIAS=$(basename "$GC_DIR") + EXISTING=$(git ls-remote origin "refs/heads/polecat/*" 2>/dev/null \ + | awk '{print $2}' | sed 's,refs/heads/,,' \ + | grep -E "/{{issue}}([@-]|\\$)" || true) + if [ -n "$EXISTING" ]; then + SAME_ALIAS="" + OTHER_ALIAS="" + for B in $EXISTING; do + case "$B" in + polecat/$ALIAS/*|polecat/$ALIAS-*) SAME_ALIAS="$B" ;; + *) OTHER_ALIAS="$OTHER_ALIAS $B" ;; + esac + done + if [ -n "$SAME_ALIAS" ]; then + BRANCH="$SAME_ALIAS" + gc bd update {{issue}} --set-metadata branch="$BRANCH" + if [ -n "$OTHER_ALIAS" ]; then + gc nudge "$GC_RIG/witness" \ + "Resumed $BRANCH for {{issue}} but other polecats also have branches:$OTHER_ALIAS" + fi + elif [ -n "$OTHER_ALIAS" ]; then + echo "DUPLICATE: existing polecat branches for {{issue}} from another alias:" + echo "$OTHER_ALIAS" + gc mail send "$GC_RIG/witness" \ + -s "DUPLICATE BRANCH: {{issue}} has branches from multiple polecats" \ + -m "Existing polecat branches for {{issue}} from another alias detected. +My alias: $ALIAS +Other branches:$OTHER_ALIAS +Released claim back to pool." + gc bd update {{issue}} --status=open --assignee="" \ + --set-metadata duplicate_detected="$OTHER_ALIAS" + gc runtime drain-ack + exit 0 + fi + fi +fi +``` + +Same-alias match → `BRANCH` (and `metadata.branch`) point at our prior +branch; the **If branch exists in metadata** path below checks it out +(and rebases if `rejection_reason` is set). Different-alias match → +claim is released, witness is notified, and this polecat exits without +opening a duplicate PR. No matches → `BRANCH` is still empty and the +**If no branch** path below creates a fresh one. + **If branch exists in metadata** — treat it as authoritative. This metadata may come from rejection recovery or from a caller that wants work applied to an existing branch. Fetch the named remote branch first. diff --git a/internal/api/cache_read_model.go b/internal/api/cache_read_model.go new file mode 100644 index 000000000..cdd09b13b --- /dev/null +++ b/internal/api/cache_read_model.go @@ -0,0 +1,23 @@ +package api + +import ( + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/session" +) + +type cachedListStore interface { + CachedList(beads.ListQuery) ([]beads.Bead, bool) +} + +func listSessionBeadsForReadModel(store beads.Store) ([]beads.Bead, error) { + query := beads.ListQuery{ + Label: session.LabelSession, + Sort: beads.SortCreatedDesc, + } + if cached, ok := store.(cachedListStore); ok { + if rows, cacheOK := cached.CachedList(query); cacheOK { + return rows, nil + } + } + return store.List(query) +} diff --git a/internal/api/fake_state_test.go b/internal/api/fake_state_test.go index 431d817f5..47d01c2bc 100644 --- a/internal/api/fake_state_test.go +++ b/internal/api/fake_state_test.go @@ -302,10 +302,17 @@ func (f *fakeMutatorState) UpdateProvider(name string, patch ProviderUpdate) err if patch.Command != nil { spec.Command = *patch.Command } + if patch.ACPCommand != nil { + spec.ACPCommand = *patch.ACPCommand + } if patch.Args != nil { spec.Args = make([]string, len(patch.Args)) copy(spec.Args, patch.Args) } + if patch.ACPArgs != nil { + spec.ACPArgs = make([]string, len(patch.ACPArgs)) + copy(spec.ACPArgs, patch.ACPArgs) + } if patch.ArgsAppend != nil { spec.ArgsAppend = make([]string, len(patch.ArgsAppend)) copy(spec.ArgsAppend, patch.ArgsAppend) diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go index 2f98ea293..4bb1179a8 100644 --- a/internal/api/genclient/client_gen.go +++ b/internal/api/genclient/client_gen.go @@ -372,6 +372,8 @@ type AnnotatedAgentResponse struct { // AnnotatedProviderResponse defines model for AnnotatedProviderResponse. type AnnotatedProviderResponse struct { + AcpArgs *[]string `json:"acp_args,omitempty"` + AcpCommand *string `json:"acp_command,omitempty"` Args *[]string `json:"args,omitempty"` Command *string `json:"command,omitempty"` DisplayName *string `json:"display_name,omitempty"` @@ -1759,6 +1761,12 @@ type PoolOverride struct { // ProviderCreateInputBody defines model for ProviderCreateInputBody. type ProviderCreateInputBody struct { + // AcpArgs ACP transport command arguments override. + AcpArgs *[]string `json:"acp_args,omitempty"` + + // AcpCommand ACP transport command binary override. + AcpCommand *string `json:"acp_command,omitempty"` + // Args Command arguments. Args *[]string `json:"args,omitempty"` @@ -1813,6 +1821,8 @@ type ProviderOptionDTO struct { // ProviderPatch defines model for ProviderPatch. type ProviderPatch struct { + ACPArgs *[]string `json:"ACPArgs"` + ACPCommand *string `json:"ACPCommand"` Args *[]string `json:"Args"` ArgsAppend *[]string `json:"ArgsAppend"` Base *string `json:"Base"` @@ -1829,6 +1839,12 @@ type ProviderPatch struct { // ProviderPatchSetInputBody defines model for ProviderPatchSetInputBody. type ProviderPatchSetInputBody struct { + // AcpArgs Override ACP transport command arguments. + AcpArgs *[]string `json:"acp_args,omitempty"` + + // AcpCommand Override ACP transport command binary. + AcpCommand *string `json:"acp_command,omitempty"` + // Args Override command arguments. Args *[]string `json:"args,omitempty"` @@ -1887,6 +1903,8 @@ type ProviderReadinessResponse struct { // ProviderResponse defines model for ProviderResponse. type ProviderResponse struct { + AcpArgs *[]string `json:"acp_args,omitempty"` + AcpCommand *string `json:"acp_command,omitempty"` Args *[]string `json:"args,omitempty"` Builtin bool `json:"builtin"` CityLevel bool `json:"city_level"` @@ -1901,6 +1919,8 @@ type ProviderResponse struct { // ProviderSpecJSON defines model for ProviderSpecJSON. type ProviderSpecJSON struct { + AcpArgs *[]string `json:"acp_args,omitempty"` + AcpCommand *string `json:"acp_command,omitempty"` Args *[]string `json:"args,omitempty"` Command *string `json:"command,omitempty"` DisplayName *string `json:"display_name,omitempty"` @@ -1912,6 +1932,12 @@ type ProviderSpecJSON struct { // ProviderUpdateInputBody defines model for ProviderUpdateInputBody. type ProviderUpdateInputBody struct { + // AcpArgs ACP transport command arguments override. + AcpArgs *[]string `json:"acp_args,omitempty"` + + // AcpCommand ACP transport command binary override. + AcpCommand *string `json:"acp_command,omitempty"` + // Args Command arguments. Args *[]string `json:"args,omitempty"` @@ -4301,6 +4327,12 @@ type PostV0CityByCityNameOrderByNameEnableParams struct { XGCRequest string `json:"X-GC-Request"` } +// GetV0CityByCityNameOrdersCheckParams defines parameters for GetV0CityByCityNameOrdersCheck. +type GetV0CityByCityNameOrdersCheckParams struct { + // Fresh Bypass cached order-check responses and cached order history. + Fresh *bool `form:"fresh,omitempty" json:"fresh,omitempty"` +} + // GetV0CityByCityNameOrdersFeedParams defines parameters for GetV0CityByCityNameOrdersFeed. type GetV0CityByCityNameOrdersFeedParams struct { // ScopeKind Scope kind (city or rig). @@ -8160,7 +8192,7 @@ type ClientInterface interface { GetV0CityByCityNameOrders(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*http.Response, error) // GetV0CityByCityNameOrdersCheck request - GetV0CityByCityNameOrdersCheck(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*http.Response, error) + GetV0CityByCityNameOrdersCheck(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersCheckParams, reqEditors ...RequestEditorFn) (*http.Response, error) // GetV0CityByCityNameOrdersFeed request GetV0CityByCityNameOrdersFeed(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersFeedParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -9658,8 +9690,8 @@ func (c *Client) GetV0CityByCityNameOrders(ctx context.Context, cityName string, return c.Client.Do(req) } -func (c *Client) GetV0CityByCityNameOrdersCheck(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetV0CityByCityNameOrdersCheckRequest(c.Server, cityName) +func (c *Client) GetV0CityByCityNameOrdersCheck(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersCheckParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetV0CityByCityNameOrdersCheckRequest(c.Server, cityName, params) if err != nil { return nil, err } @@ -15926,7 +15958,7 @@ func NewGetV0CityByCityNameOrdersRequest(server string, cityName string) (*http. } // NewGetV0CityByCityNameOrdersCheckRequest generates requests for GetV0CityByCityNameOrdersCheck -func NewGetV0CityByCityNameOrdersCheckRequest(server string, cityName string) (*http.Request, error) { +func NewGetV0CityByCityNameOrdersCheckRequest(server string, cityName string, params *GetV0CityByCityNameOrdersCheckParams) (*http.Request, error) { var err error var pathParam0 string @@ -15951,6 +15983,28 @@ func NewGetV0CityByCityNameOrdersCheckRequest(server string, cityName string) (* return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Fresh != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fresh", *params.Fresh, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -19951,7 +20005,7 @@ type ClientWithResponsesInterface interface { GetV0CityByCityNameOrdersWithResponse(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersResponse, error) // GetV0CityByCityNameOrdersCheckWithResponse request - GetV0CityByCityNameOrdersCheckWithResponse(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersCheckResponse, error) + GetV0CityByCityNameOrdersCheckWithResponse(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersCheckParams, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersCheckResponse, error) // GetV0CityByCityNameOrdersFeedWithResponse request GetV0CityByCityNameOrdersFeedWithResponse(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersFeedParams, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersFeedResponse, error) @@ -24387,8 +24441,8 @@ func (c *ClientWithResponses) GetV0CityByCityNameOrdersWithResponse(ctx context. } // GetV0CityByCityNameOrdersCheckWithResponse request returning *GetV0CityByCityNameOrdersCheckResponse -func (c *ClientWithResponses) GetV0CityByCityNameOrdersCheckWithResponse(ctx context.Context, cityName string, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersCheckResponse, error) { - rsp, err := c.GetV0CityByCityNameOrdersCheck(ctx, cityName, reqEditors...) +func (c *ClientWithResponses) GetV0CityByCityNameOrdersCheckWithResponse(ctx context.Context, cityName string, params *GetV0CityByCityNameOrdersCheckParams, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameOrdersCheckResponse, error) { + rsp, err := c.GetV0CityByCityNameOrdersCheck(ctx, cityName, params, reqEditors...) if err != nil { return nil, err } diff --git a/internal/api/handler_agents.go b/internal/api/handler_agents.go index a7a8be4e2..1ed7900b7 100644 --- a/internal/api/handler_agents.go +++ b/internal/api/handler_agents.go @@ -243,13 +243,25 @@ func (s *Server) findActiveBeadForAssigneesWithFreshness(rig string, live bool, } for _, assignee := range unique { for _, rn := range rigNames { - matches, err := stores[rn].List(beads.ListQuery{ + query := beads.ListQuery{ Assignee: assignee, Status: "in_progress", Live: live, Limit: 1, Sort: beads.SortCreatedDesc, - }) + } + if !live { + if cached, ok := stores[rn].(cachedListStore); ok { + matches, cacheOK := cached.CachedList(query) + if cacheOK { + if len(matches) > 0 { + return matches[0].ID + } + continue + } + } + } + matches, err := stores[rn].List(query) if err != nil { continue } diff --git a/internal/api/handler_beads.go b/internal/api/handler_beads.go index e4141241d..a0d5844e9 100644 --- a/internal/api/handler_beads.go +++ b/internal/api/handler_beads.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/session" ) @@ -109,7 +110,11 @@ func (s *Server) findStore(rig string) beads.Store { // prefix/routes mapping when possible. If there is no routed match, it falls // back to the legacy store scan order. func (s *Server) beadStoresForID(id string) []beads.Store { - if prefix := beadPrefix(strings.TrimSpace(id)); prefix != "" { + id = strings.TrimSpace(id) + if store := s.resolveStoreByConfiguredIDPrefix(id); store != nil { + return []beads.Store{store} + } + if prefix := beadPrefix(id); prefix != "" { if store := s.resolveStoreByPrefix(prefix); store != nil { return []beads.Store{store} } @@ -127,6 +132,45 @@ func (s *Server) beadStoresForID(id string) []beads.Store { return candidates } +func (s *Server) resolveStoreByConfiguredIDPrefix(id string) beads.Store { + if id == "" { + return nil + } + cfg := s.state.Config() + if cfg == nil { + return nil + } + + var bestStore beads.Store + bestLen := -1 + if prefix := strings.TrimSpace(config.EffectiveHQPrefix(cfg)); beadIDHasConfiguredPrefix(id, prefix) { + if cityStore := s.state.CityBeadStore(); cityStore != nil { + bestStore = cityStore + bestLen = len(prefix) + } + } + for _, rig := range cfg.Rigs { + prefix := strings.TrimSpace(rig.EffectivePrefix()) + if !beadIDHasConfiguredPrefix(id, prefix) || len(prefix) <= bestLen { + continue + } + store := s.state.BeadStore(rig.Name) + if store == nil { + continue + } + bestStore = store + bestLen = len(prefix) + } + return bestStore +} + +func beadIDHasConfiguredPrefix(id, prefix string) bool { + if prefix == "" { + return false + } + return id == prefix || strings.HasPrefix(id, prefix+"-") +} + // resolveStoreByPrefix finds the store that owns a bead prefix by checking // routes.jsonl files in the city and each rig's .beads/ directory, then // mapping the resolved store path back to the correct store. diff --git a/internal/api/handler_beads_test.go b/internal/api/handler_beads_test.go index d90542d1a..64c95cd4d 100644 --- a/internal/api/handler_beads_test.go +++ b/internal/api/handler_beads_test.go @@ -727,6 +727,26 @@ func TestBeadUpdateUsesRoutePrefixStore(t *testing.T) { } } +func TestBeadStoresForIDUsesLongestConfiguredHyphenatedPrefix(t *testing.T) { + state := newFakeState(t) + cityStore := beads.NewMemStore() + rigStore := beads.NewMemStore() + state.cityBeadStore = cityStore + state.cfg.Workspace.Prefix = "mc" + state.cfg.Rigs = []config.Rig{{ + Name: "alpha", + Path: "/tmp/alpha", + Prefix: "mc-alpha", + }} + state.stores = map[string]beads.Store{"alpha": rigStore} + + server := &Server{state: state} + stores := server.beadStoresForID("mc-alpha-123") + if len(stores) != 1 || stores[0] != rigStore { + t.Fatalf("beadStoresForID returned %#v, want only authoritative rig store", stores) + } +} + func TestBeadUpdateSetsAndClearsParent(t *testing.T) { state := newFakeState(t) store := state.stores["myrig"] diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index eab75db54..21d6fb478 100644 --- a/internal/api/handler_config.go +++ b/internal/api/handler_config.go @@ -45,7 +45,9 @@ type configRigResponse struct { type providerSpecJSON struct { DisplayName string `json:"display_name,omitempty"` Command string `json:"command,omitempty"` + ACPCommand string `json:"acp_command,omitempty"` Args []string `json:"args,omitempty"` + ACPArgs *[]string `json:"acp_args,omitempty"` PromptMode string `json:"prompt_mode,omitempty"` PromptFlag string `json:"prompt_flag,omitempty"` ReadyDelayMs int `json:"ready_delay_ms,omitempty"` diff --git a/internal/api/handler_config_test.go b/internal/api/handler_config_test.go index 1b6e8f66e..060d9a7d7 100644 --- a/internal/api/handler_config_test.go +++ b/internal/api/handler_config_test.go @@ -16,7 +16,12 @@ func TestHandleConfigGet(t *testing.T) { fs.cfg.Agents[0].MinActiveSessions = intPtr(0) fs.cfg.Agents[0].MaxActiveSessions = intPtr(3) fs.cfg.Providers = map[string]config.ProviderSpec{ - "custom": {DisplayName: "Custom", Command: "custom-cli"}, + "custom": { + DisplayName: "Custom", + Command: "custom-cli", + ACPCommand: "custom-cli-acp", + ACPArgs: []string{"rpc", "--stdio"}, + }, } h := newTestCityHandler(t, fs) @@ -52,6 +57,12 @@ func TestHandleConfigGet(t *testing.T) { if _, ok := resp.Providers["custom"]; !ok { t.Error("expected 'custom' in providers") } + if resp.Providers["custom"].ACPCommand != "custom-cli-acp" { + t.Errorf("providers.custom.acp_command = %q, want %q", resp.Providers["custom"].ACPCommand, "custom-cli-acp") + } + if resp.Providers["custom"].ACPArgs == nil || len(*resp.Providers["custom"].ACPArgs) != 2 || (*resp.Providers["custom"].ACPArgs)[0] != "rpc" || (*resp.Providers["custom"].ACPArgs)[1] != "--stdio" { + t.Errorf("providers.custom.acp_args = %#v, want [rpc --stdio]", resp.Providers["custom"].ACPArgs) + } } func TestHandleConfigGet_UsesEffectiveWorkspaceIdentity(t *testing.T) { @@ -86,6 +97,46 @@ func TestHandleConfigGet_UsesEffectiveWorkspaceIdentity(t *testing.T) { } } +func TestHandleConfigGetPreservesExplicitEmptyACPArgs(t *testing.T) { + fs := newFakeState(t) + fs.cfg.Providers = map[string]config.ProviderSpec{ + "custom": { + Command: "custom-cli", + ACPCommand: "custom-cli-acp", + ACPArgs: []string{}, + }, + } + h := newTestCityHandler(t, fs) + + req := httptest.NewRequest("GET", cityURL(fs, "/config"), nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + providers, ok := resp["providers"].(map[string]any) + if !ok { + t.Fatal("expected providers map") + } + custom, ok := providers["custom"].(map[string]any) + if !ok { + t.Fatal("expected custom provider") + } + acpArgs, ok := custom["acp_args"].([]any) + if !ok { + t.Fatalf("acp_args = %#v, want empty array field", custom["acp_args"]) + } + if len(acpArgs) != 0 { + t.Fatalf("acp_args len = %d, want 0", len(acpArgs)) + } +} + func TestHandleConfigGet_DerivesPrefixFromRuntimeAliasWhenNoExplicitPrefix(t *testing.T) { fs := newFakeState(t) fs.cityName = "machine-alias" @@ -166,7 +217,12 @@ func TestHandleConfigExplain(t *testing.T) { fs.cfg.Agents[0].MinActiveSessions = intPtr(0) fs.cfg.Agents[0].MaxActiveSessions = intPtr(3) fs.cfg.Providers = map[string]config.ProviderSpec{ - "claude": {DisplayName: "My Claude", Command: "my-claude"}, + "claude": { + DisplayName: "My Claude", + Command: "my-claude", + ACPCommand: "my-claude-acp", + ACPArgs: []string{"rpc"}, + }, } h := newTestCityHandler(t, fs) @@ -206,6 +262,13 @@ func TestHandleConfigExplain(t *testing.T) { if claude["origin"] != "builtin+city" { t.Errorf("claude origin = %q, want %q", claude["origin"], "builtin+city") } + if claude["acp_command"] != "my-claude-acp" { + t.Errorf("claude acp_command = %q, want %q", claude["acp_command"], "my-claude-acp") + } + acpArgs, ok := claude["acp_args"].([]any) + if !ok || len(acpArgs) != 1 || acpArgs[0] != "rpc" { + t.Errorf("claude acp_args = %#v, want [rpc]", claude["acp_args"]) + } // A builtin-only provider should have origin "builtin". codex := providers["codex"].(map[string]any) if codex["origin"] != "builtin" { diff --git a/internal/api/handler_mail.go b/internal/api/handler_mail.go index 3ed001ae9..eeb988b58 100644 --- a/internal/api/handler_mail.go +++ b/internal/api/handler_mail.go @@ -299,6 +299,11 @@ func mailMessagesForRecipients(fetch func(string) ([]mail.Message, error), recip func mailCountForRecipients(mp mail.Provider, recipients []string) (int, int, error) { recipients = uniqueMailRecipients(recipients) + if counter, ok := mp.(interface { + CountRecipients([]string) (int, int, error) + }); ok { + return counter.CountRecipients(recipients) + } var totalAll, unreadAll int for _, recipient := range recipients { total, unread, err := mp.Count(recipient) diff --git a/internal/api/handler_orders_test.go b/internal/api/handler_orders_test.go index 36f66b9e7..82fd00146 100644 --- a/internal/api/handler_orders_test.go +++ b/internal/api/handler_orders_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "strconv" "testing" "time" @@ -405,6 +407,33 @@ func TestHandleOrderCheckTreatsWispFailedAsFailed(t *testing.T) { } } +func TestHandleOrderCheckRunsConditionByDefault(t *testing.T) { + fs := newFakeState(t) + marker := t.TempDir() + "/condition-ran" + fs.autos = []orders.Order{ + {Name: "router", Formula: "review-pr", Trigger: "condition", Check: "printf x >> " + strconv.Quote(marker)}, + } + + h := newTestCityHandler(t, fs) + for _, path := range []string{"/orders/check", "/orders/check", "/orders/check?fresh=true"} { + req := httptest.NewRequest(http.MethodGet, cityURL(fs, path), nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status for %s = %d, want 200; body = %s", path, w.Code, w.Body.String()) + } + } + + got, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("read condition marker: %v", err) + } + if string(got) != "xxx" { + t.Fatalf("condition marker = %q, want one execution per request", got) + } +} + func TestLastRunOutcomeFromLabelsPrioritizesTerminalLabels(t *testing.T) { tests := []struct { name string @@ -731,6 +760,126 @@ func TestHandleOrderCheckUsesRigStoreLastRunState(t *testing.T) { } } +type cachedOnlyOrderHistoryStore struct { + beads.Store + cached []beads.Bead + cacheOK bool + includeClosedListCalls int +} + +func (s *cachedOnlyOrderHistoryStore) CachedList(query beads.ListQuery) ([]beads.Bead, bool) { + return beads.ApplyListQuery(s.cached, query), s.cacheOK +} + +func (s *cachedOnlyOrderHistoryStore) List(query beads.ListQuery) ([]beads.Bead, error) { + if query.IncludeClosed { + s.includeClosedListCalls++ + } + return s.Store.List(query) +} + +func TestHandleOrderCheckUsesCachedHistoryWhenAvailable(t *testing.T) { + fs := newFakeState(t) + run := beads.Bead{ + ID: "run-1", + Title: "nightly-review wisp", + Status: "closed", + CreatedAt: time.Now().UTC(), + Labels: []string{"order-run:nightly-review", "wisp"}, + } + cachedStore := &cachedOnlyOrderHistoryStore{ + Store: beads.NewMemStore(), + cached: []beads.Bead{run}, + cacheOK: true, + } + fs.cityBeadStore = cachedStore + fs.autos = []orders.Order{ + {Name: "nightly-review", Formula: "mol-adopt-pr-v2", Trigger: "cooldown", Interval: "24h"}, + } + + h := newTestCityHandler(t, fs) + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/orders/check"), nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp struct { + Checks []struct { + Due bool `json:"due"` + LastRunOutcome *string `json:"last_run_outcome"` + } `json:"checks"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Checks) != 1 { + t.Fatalf("len(checks) = %d, want 1", len(resp.Checks)) + } + if resp.Checks[0].Due { + t.Fatal("due = true, want false from cached recent run") + } + if resp.Checks[0].LastRunOutcome == nil || *resp.Checks[0].LastRunOutcome != "success" { + t.Fatalf("last_run_outcome = %v, want success", resp.Checks[0].LastRunOutcome) + } + if cachedStore.includeClosedListCalls != 0 { + t.Fatalf("IncludeClosed List calls = %d, want 0 when cached history is available", cachedStore.includeClosedListCalls) + } +} + +func TestHandleOrderCheckFallsBackToLiveHistoryWhenCacheUnavailable(t *testing.T) { + fs := newFakeState(t) + cachedStore := &cachedOnlyOrderHistoryStore{ + Store: beads.NewMemStore(), + } + _, err := cachedStore.Create(beads.Bead{ + Title: "nightly-review wisp", + Status: "closed", + CreatedAt: time.Now().UTC(), + Labels: []string{"order-run:nightly-review", "wisp"}, + }) + if err != nil { + t.Fatalf("create live history bead: %v", err) + } + fs.cityBeadStore = cachedStore + fs.autos = []orders.Order{ + {Name: "nightly-review", Formula: "mol-adopt-pr-v2", Trigger: "cooldown", Interval: "24h"}, + } + + h := newTestCityHandler(t, fs) + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/orders/check"), nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp struct { + Checks []struct { + Due bool `json:"due"` + LastRunOutcome *string `json:"last_run_outcome"` + } `json:"checks"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Checks) != 1 { + t.Fatalf("len(checks) = %d, want 1", len(resp.Checks)) + } + if resp.Checks[0].Due { + t.Fatal("due = true, want false from live recent run") + } + if resp.Checks[0].LastRunOutcome == nil || *resp.Checks[0].LastRunOutcome != "success" { + t.Fatalf("last_run_outcome = %v, want success", resp.Checks[0].LastRunOutcome) + } + if cachedStore.includeClosedListCalls == 0 { + t.Fatal("IncludeClosed List calls = 0, want live fallback when cache is unavailable") + } +} + func TestHandleOrderCheckSkipsUnavailableRigStore(t *testing.T) { fs := newFakeState(t) fs.cityBeadStore = beads.NewMemStore() diff --git a/internal/api/handler_patches_test.go b/internal/api/handler_patches_test.go index 57cd216a1..fc91d9145 100644 --- a/internal/api/handler_patches_test.go +++ b/internal/api/handler_patches_test.go @@ -252,7 +252,7 @@ func TestHandleProviderPatchSet(t *testing.T) { fs := newFakeMutatorState(t) h := newTestCityHandler(t, fs) - body := `{"name":"claude","command":"my-claude"}` + body := `{"name":"claude","command":"my-claude","acp_command":"my-claude-acp","acp_args":["serve","--stdio"]}` req := httptest.NewRequest("PUT", cityURL(fs, "/patches/providers"), strings.NewReader(body)) req.Header.Set("X-GC-Request", "true") w := httptest.NewRecorder() @@ -265,6 +265,12 @@ func TestHandleProviderPatchSet(t *testing.T) { if len(fs.cfg.Patches.Providers) != 1 { t.Fatalf("patches.providers count = %d, want 1", len(fs.cfg.Patches.Providers)) } + if got := fs.cfg.Patches.Providers[0].ACPCommand; got == nil || *got != "my-claude-acp" { + t.Fatalf("ACPCommand = %v, want %q", got, "my-claude-acp") + } + if got := fs.cfg.Patches.Providers[0].ACPArgs; len(got) != 2 || got[0] != "serve" || got[1] != "--stdio" { + t.Fatalf("ACPArgs = %#v, want [\"serve\" \"--stdio\"]", got) + } } func TestHandleProviderPatchDelete(t *testing.T) { diff --git a/internal/api/handler_provider_crud_test.go b/internal/api/handler_provider_crud_test.go index 04ce338d0..ace9d75ae 100644 --- a/internal/api/handler_provider_crud_test.go +++ b/internal/api/handler_provider_crud_test.go @@ -1,10 +1,13 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/gastownhall/gascity/internal/config" ) func TestHandleProviderCreate_AllowsBaseOnlyDescendant(t *testing.T) { @@ -32,6 +35,32 @@ func TestHandleProviderCreate_AllowsBaseOnlyDescendant(t *testing.T) { } } +func TestHandleProviderCreate_PersistsACPTransportOverrides(t *testing.T) { + fs := newFakeMutatorState(t) + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + req := newPostRequest(cityURL(fs, "/providers"), strings.NewReader( + `{"name":"custom-acp","command":"custom","acp_command":"custom-acp","acp_args":["rpc","--stdio"]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + spec, ok := fs.cfg.Providers["custom-acp"] + if !ok { + t.Fatal("provider custom-acp not created") + } + if spec.ACPCommand != "custom-acp" { + t.Fatalf("ACPCommand = %q, want %q", spec.ACPCommand, "custom-acp") + } + if len(spec.ACPArgs) != 2 || spec.ACPArgs[0] != "rpc" || spec.ACPArgs[1] != "--stdio" { + t.Fatalf("ACPArgs = %#v, want [rpc --stdio]", spec.ACPArgs) + } +} + func TestHandleProviderUpdate_UpdatesInheritanceFields(t *testing.T) { fs := newFakeMutatorState(t) fs.cfg.Providers["custom"] = fs.cfg.Providers["test-agent"] @@ -58,3 +87,87 @@ func TestHandleProviderUpdate_UpdatesInheritanceFields(t *testing.T) { t.Fatalf("OptionsSchemaMerge = %q, want by_key", spec.OptionsSchemaMerge) } } + +func TestHandleProviderUpdate_UpdatesACPTransportOverrides(t *testing.T) { + fs := newFakeMutatorState(t) + fs.cfg.Providers["custom"] = fs.cfg.Providers["test-agent"] + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + req := httptest.NewRequest(http.MethodPatch, cityURL(fs, "/provider/custom"), strings.NewReader( + `{"acp_command":"custom-acp","acp_args":["rpc","--stdio"]}`)) + req.Header.Set("X-GC-Request", "true") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + spec := fs.cfg.Providers["custom"] + if spec.ACPCommand != "custom-acp" { + t.Fatalf("ACPCommand = %q, want %q", spec.ACPCommand, "custom-acp") + } + if len(spec.ACPArgs) != 2 || spec.ACPArgs[0] != "rpc" || spec.ACPArgs[1] != "--stdio" { + t.Fatalf("ACPArgs = %#v, want [rpc --stdio]", spec.ACPArgs) + } +} + +func TestHandleProviderGet_IncludesACPTransportOverrides(t *testing.T) { + fs := newFakeState(t) + fs.cfg.Providers["custom"] = config.ProviderSpec{ + Command: "custom", + ACPCommand: "custom-acp", + ACPArgs: []string{"rpc", "--stdio"}, + } + h := newTestCityHandler(t, fs) + + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/provider/custom"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp providerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ACPCommand != "custom-acp" { + t.Fatalf("ACPCommand = %q, want %q", resp.ACPCommand, "custom-acp") + } + if resp.ACPArgs == nil || len(*resp.ACPArgs) != 2 || (*resp.ACPArgs)[0] != "rpc" || (*resp.ACPArgs)[1] != "--stdio" { + t.Fatalf("ACPArgs = %#v, want [rpc --stdio]", resp.ACPArgs) + } +} + +func TestHandleProviderGetPreservesExplicitEmptyACPArgs(t *testing.T) { + fs := newFakeState(t) + fs.cfg.Providers["custom"] = config.ProviderSpec{ + Command: "custom", + ACPCommand: "custom-acp", + ACPArgs: []string{}, + } + h := newTestCityHandler(t, fs) + + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/provider/custom"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + acpArgs, ok := resp["acp_args"].([]any) + if !ok { + t.Fatalf("acp_args = %#v, want empty array field", resp["acp_args"]) + } + if len(acpArgs) != 0 { + t.Fatalf("acp_args len = %d, want 0", len(acpArgs)) + } +} diff --git a/internal/api/handler_provider_readiness_test.go b/internal/api/handler_provider_readiness_test.go index 3d9019db9..b32ba79a3 100644 --- a/internal/api/handler_provider_readiness_test.go +++ b/internal/api/handler_provider_readiness_test.go @@ -148,8 +148,8 @@ func TestFindProbeBinaryUsesNVMInstallDir(t *testing.T) { originalPathEnv := providerProbePathEnv originalGOOS := providerProbeGOOS - providerProbePathEnv = "/usr/local/bin:/usr/bin:/bin" - providerProbeGOOS = "darwin" + providerProbePathEnv = filepath.Join(homeDir, "empty-path") + providerProbeGOOS = "test" defer func() { providerProbePathEnv = originalPathEnv providerProbeGOOS = originalGOOS diff --git a/internal/api/handler_providers.go b/internal/api/handler_providers.go index 6802256b5..1891ada8f 100644 --- a/internal/api/handler_providers.go +++ b/internal/api/handler_providers.go @@ -13,7 +13,9 @@ type providerResponse struct { Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` Command string `json:"command,omitempty"` + ACPCommand string `json:"acp_command,omitempty"` Args []string `json:"args,omitempty"` + ACPArgs *[]string `json:"acp_args,omitempty"` PromptMode string `json:"prompt_mode,omitempty"` PromptFlag string `json:"prompt_flag,omitempty"` ReadyDelayMs int `json:"ready_delay_ms,omitempty"` @@ -40,7 +42,9 @@ func providerFromSpec(name string, spec config.ProviderSpec, builtin, cityLevel Name: name, DisplayName: spec.DisplayName, Command: spec.Command, + ACPCommand: spec.ACPCommand, Args: spec.Args, + ACPArgs: optionalStringSlice(spec.ACPArgs), PromptMode: spec.PromptMode, PromptFlag: spec.PromptFlag, ReadyDelayMs: spec.ReadyDelayMs, @@ -50,6 +54,15 @@ func providerFromSpec(name string, spec config.ProviderSpec, builtin, cityLevel } } +func optionalStringSlice(values []string) *[]string { + if values == nil { + return nil + } + cloned := make([]string, len(values)) + copy(cloned, values) + return &cloned +} + // toProviderPublicResponse builds the browser-safe DTO from a MERGED // provider spec. The spec must already be the result of // MergeProviderOverBuiltin so it carries the correct OptionsSchema and diff --git a/internal/api/handler_rigs.go b/internal/api/handler_rigs.go index 2795a9fdf..749ec7964 100644 --- a/internal/api/handler_rigs.go +++ b/internal/api/handler_rigs.go @@ -6,11 +6,10 @@ import ( "time" "github.com/gastownhall/gascity/internal/agent" - "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" gitpkg "github.com/gastownhall/gascity/internal/git" + "github.com/gastownhall/gascity/internal/runtime" workdirutil "github.com/gastownhall/gascity/internal/workdir" - "github.com/gastownhall/gascity/internal/worker" ) type rigResponse struct { @@ -33,7 +32,7 @@ type gitStatus struct { } // buildRigResponse creates a rigResponse with agent counts and last activity. -func (s *Server) buildRigResponse(cfg *config.City, rig config.Rig, store beads.Store, sp sessionLister, cityName, cityPath string) rigResponse { +func (s *Server) buildRigResponse(cfg *config.City, rig config.Rig, sp runtime.Provider, cityName, cityPath string) rigResponse { tmpl := cfg.Workspace.SessionTemplate var agentCount, runningCount int var maxActivity time.Time @@ -46,8 +45,7 @@ func (s *Server) buildRigResponse(cfg *config.City, rig config.Rig, store beads. for _, ea := range expanded { agentCount++ sessionName := agent.SessionNameFor(cityName, ea.qualifiedName, tmpl) - handle, _ := s.workerHandleForSessionTarget(store, sessionName) - obs, _ := worker.ObserveHandle(context.Background(), handle) + obs := observeProviderSession(sp, sessionName, nil) if obs.Running { runningCount++ } @@ -60,7 +58,7 @@ func (s *Server) buildRigResponse(cfg *config.City, rig config.Rig, store beads. resp := rigResponse{ Name: rig.Name, Path: rig.Path, - Suspended: s.rigSuspended(cfg, rig, store, sp, cityName, cityPath), + Suspended: s.rigSuspended(cfg, rig, sp, cityName, cityPath), Prefix: rig.Prefix, AgentCount: agentCount, RunningCount: runningCount, @@ -74,7 +72,7 @@ func (s *Server) buildRigResponse(cfg *config.City, rig config.Rig, store beads. // rigSuspended computes effective suspended state for a rig by merging config // and runtime session metadata. A rig is suspended if the config says so, or // if all its agents are runtime-suspended via session metadata. -func (s *Server) rigSuspended(cfg *config.City, rig config.Rig, store beads.Store, sp sessionLister, cityName, cityPath string) bool { +func (s *Server) rigSuspended(cfg *config.City, rig config.Rig, sp runtime.Provider, cityName, cityPath string) bool { if rig.Suspended { return true } @@ -88,8 +86,7 @@ func (s *Server) rigSuspended(cfg *config.City, rig config.Rig, store beads.Stor for _, ea := range expanded { agentCount++ sessionName := agent.SessionNameFor(cityName, ea.qualifiedName, tmpl) - handle, _ := s.workerHandleForSessionTarget(store, sessionName) - obs, _ := worker.ObserveHandle(context.Background(), handle) + obs := observeProviderSession(sp, sessionName, nil) if obs.Suspended { suspendedCount++ } diff --git a/internal/api/handler_session_chat_test.go b/internal/api/handler_session_chat_test.go index bf1f1973a..88e7298d2 100644 --- a/internal/api/handler_session_chat_test.go +++ b/internal/api/handler_session_chat_test.go @@ -1,9 +1,15 @@ package api import ( + "os" + "path/filepath" + "strings" "testing" + "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" + sessionauto "github.com/gastownhall/gascity/internal/runtime/auto" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/shellquote" ) @@ -66,7 +72,10 @@ func TestBuildSessionResumeUsesResolvedProviderCommand(t *testing.T) { WorkDir: "/tmp/workdir", } - cmd, hints := srv.buildSessionResume(info) + cmd, hints, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } if got, want := cmd, "aimux run gemini -- --approval-mode yolo"; got != want { t.Fatalf("resume command = %q, want %q", got, want) } @@ -106,8 +115,654 @@ func TestBuildSessionResumePreservesStoredResolvedCommand(t *testing.T) { WorkDir: "/tmp/workdir", } - cmd, _ := srv.buildSessionResume(info) + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } if got, want := cmd, "claude --dangerously-skip-permissions --settings /tmp/settings.json"; got != want { t.Fatalf("resume command = %q, want %q", got, want) } } + +// TestBuildSessionResumeRebuildsBareStoredCommandForPoolClaudeAgent is a +// regression test for gastownhall/gascity#799: when a pool-agent session +// resumed through the control-dispatcher path has only the bare +// provider binary ("claude") as its stored command, the API must +// re-inject schema defaults (--dangerously-skip-permissions) and the +// provider-owned --settings path from the current resolved config. +// Before the fix, the bare stored command was preserved as-is and pool +// workers wedged on interactive permission prompts on resume. +func TestBuildSessionResumeRebuildsBareStoredCommandForPoolClaudeAgent(t *testing.T) { + fs := newSessionFakeState(t) + claude := config.BuiltinProviders()["claude"] + claude.PathCheck = "true" // use /usr/bin/true so LookPath succeeds in CI + maxActive := 3 + gcDir := filepath.Join(fs.cityPath, ".gc") + if err := os.MkdirAll(gcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcDir, "settings.json"), []byte(`{"hooks":{}}`), 0o644); err != nil { + t.Fatal(err) + } + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + { + Name: "perspective_planner", + Provider: "claude", + MaxActiveSessions: &maxActive, + }, + }, + Providers: map[string]config.ProviderSpec{ + "claude": claude, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "perspective_planner", + Command: "claude", + Provider: "claude", + WorkDir: fs.cityPath, + SessionKey: "abc-123", + ResumeFlag: "--resume", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if !strings.Contains(cmd, "--dangerously-skip-permissions") { + t.Fatalf("resume command missing default args:\n got: %s", cmd) + } + if !strings.Contains(cmd, "--resume abc-123") { + t.Fatalf("resume command missing resume flag:\n got: %s", cmd) + } + if !strings.Contains(cmd, "--settings") { + t.Fatalf("resume command missing settings arg:\n got: %s", cmd) + } +} + +func TestBuildSessionResumeUsesStoredACPCommandForProviderSession(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + state := &stateWithSessionProvider{ + fakeState: fs, + provider: sessionauto.New(runtime.NewFake(), runtime.NewFake()), + } + srv := New(state) + info := session.Info{ + ID: "gc-1", + Template: "opencode", + Command: "/bin/echo", + Provider: "opencode", + Transport: "acp", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeFallsBackToStoredCommandWhenTemplateOverridesInvalid(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Providers["test-agent"] = config.ProviderSpec{ + Command: "/bin/echo", + PathCheck: "true", + } + + info := createTestSession(t, fs.cityBeadStore, fs.sp, "Chat") + info.Template = "myrig/worker" + info.Command = "/bin/echo --stored" + if err := fs.cityBeadStore.SetMetadata(info.ID, "template_overrides", "{"); err != nil { + t.Fatalf("SetMetadata(template_overrides): %v", err) + } + + srv := New(fs) + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo --stored"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeUsesStoredACPCommandForLegacyProviderSessionWithoutTransportMetadataWithoutSessionAutoProvider(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "opencode", + Command: "/bin/echo acp", + Provider: "opencode", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeUsesStoredACPCommandForLegacyProviderSessionWithoutTransportMetadata(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + state := &stateWithSessionProvider{ + fakeState: fs, + provider: sessionauto.New(runtime.NewFake(), runtime.NewFake()), + } + srv := New(state) + info := session.Info{ + ID: "gc-1", + Template: "opencode", + Command: "/bin/echo acp", + Provider: "opencode", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeUsesStoredACPCommandForLegacyProviderSessionOnACPEnabledCustomProvider(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + DisplayName: "Custom ACP", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "custom-acp", + Command: "/bin/echo acp", + Provider: "custom-acp", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeUsesStoredACPTransportForTemplateSession(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode", Session: "acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Command: "/bin/echo", + Provider: "opencode", + Transport: "acp", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeDoesNotInferConfiguredACPTransportForTemplateSessionWithoutStoredMetadata(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode", Session: "acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Command: "/bin/echo", + Provider: "opencode", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestResolvedSessionTransportUsesResumeMetadataForLegacyACPWithSameCommand(t *testing.T) { + resolved := &config.ResolvedProvider{ + Command: "/bin/echo", + ACPCommand: "/bin/echo", + } + + got := resolvedSessionTransport(session.Info{ + Command: "/bin/echo", + }, resolved, "acp", map[string]string{ + "resume_flag": "--resume", + }, false) + if got != "acp" { + t.Fatalf("resolvedSessionTransport() = %q, want acp", got) + } +} + +func TestLegacyACPTransportAmbiguousWithSameCommand(t *testing.T) { + resolved := &config.ResolvedProvider{ + Command: "/bin/echo", + ACPCommand: "/bin/echo", + } + + if !legacyACPTransportAmbiguous(resolved, "acp", "/bin/echo", nil) { + t.Fatal("legacyACPTransportAmbiguous() = false, want true") + } +} + +func TestBuildSessionResumeUsesStartedConfigHashForLegacyProviderACPWithSameCommand(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + DisplayName: "Custom ACP", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + }, + }, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + resolved, err := srv.resolveBareProvider("custom-acp") + if err != nil { + t.Fatalf("resolveBareProvider: %v", err) + } + mcpServers, err := srv.sessionMCPServers("custom-acp", "custom-acp", "custom-acp", fs.cityPath, "acp", "provider") + if err != nil { + t.Fatalf("sessionMCPServers: %v", err) + } + startedHash := runtime.CoreFingerprint(runtime.Config{ + Command: resolved.ACPCommandString(), + Env: resolved.Env, + MCPServers: mcpServers, + }) + bead, err := fs.cityBeadStore.Create(beads.Bead{ + Type: "session", + Metadata: map[string]string{ + "mc_session_kind": "provider", + "started_config_hash": startedHash, + }, + }) + if err != nil { + t.Fatalf("Create(session bead): %v", err) + } + + _, hints, err := srv.buildSessionResume(session.Info{ + ID: bead.ID, + Template: "custom-acp", + Command: "/bin/echo", + Provider: "custom-acp", + WorkDir: fs.cityPath, + }) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if len(hints.MCPServers) != 1 { + t.Fatalf("len(hints.MCPServers) = %d, want 1", len(hints.MCPServers)) + } +} + +func TestBuildSessionResumeUsesStoredACPCommandForLegacyTemplateSessionWithoutTransportMetadata(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode", Session: "acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Command: "/bin/echo acp", + Provider: "opencode", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeKeepsDefaultCommandForLegacyTemplateWithoutExplicitTransport(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Command: "/bin/echo", + Provider: "opencode", + WorkDir: "/tmp/workdir", + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeIgnoresMCPResolutionErrorForACPResume(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "opencode", Session: "acp"}, + }, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "filesystem.toml"), []byte(` +name = "filesystem" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Provider: "opencode", + Transport: "acp", + WorkDir: fs.cityPath, + } + + cmd, hints, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } + if len(hints.MCPServers) != 0 { + t.Fatalf("Hints.MCPServers len = %d, want 0", len(hints.MCPServers)) + } +} + +func TestBuildSessionResumeIgnoresMCPResolutionErrorWithoutACPTransport(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "stub"}, + }, + Providers: map[string]config.ProviderSpec{ + "stub": { + DisplayName: "Stub", + Command: "/bin/echo", + }, + }, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "filesystem.toml"), []byte(` +name = "filesystem" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "worker", + Provider: "stub", + WorkDir: fs.cityPath, + } + + cmd, _, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } +} + +func TestBuildSessionResumeUsesStoredAgentNameForResumeMCPMaterialization(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "opencode", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }}, + Providers: map[string]config.ProviderSpec{ + "opencode": { + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + workDir := filepath.Join(fs.cityPath, ".gc", "worktrees", "myrig", "ants", "ant") + srv := New(fs) + info := session.Info{ + ID: "gc-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Provider: "opencode", + Transport: "acp", + WorkDir: workDir, + } + + cmd, hints, err := srv.buildSessionResume(info) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if got, want := cmd, "/bin/echo acp"; got != want { + t.Fatalf("resume command = %q, want %q", got, want) + } + if len(hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(hints.MCPServers)) + } + if got, want := hints.MCPServers[0].Args[0], info.AgentName; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := hints.MCPServers[0].Args[1], workDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := hints.MCPServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} diff --git a/internal/api/handler_session_create.go b/internal/api/handler_session_create.go index 62826eefa..d23f917de 100644 --- a/internal/api/handler_session_create.go +++ b/internal/api/handler_session_create.go @@ -77,7 +77,7 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { switch kind { case "agent": var err error - resolved, workDir, transport, template, err = s.resolveSessionTemplate(name) + resolved, _, transport, template, err = s.resolveSessionTemplateForCreate(name) if err != nil { if errors.Is(err, errSessionTemplateNotFound) { s.idem.unreserve(idemKey) @@ -88,8 +88,14 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } - // Agent track: command comes from the agent config as-is. - // Do NOT inject OptionsSchema defaults — agents encode their own CLI flags. + transport, err = validateSessionTransport(resolved, transport, s.state.SessionProvider()) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusServiceUnavailable, "provider_unavailable", err.Error()) + return + } + // Agent track stores a transport-aligned base command only. + // Do NOT inject OptionsSchema defaults or explicit overrides here. // Options are stored as template_overrides and applied at start time // by the session lifecycle via ResolveExplicitOptions. if len(body.Options) > 0 { @@ -126,8 +132,29 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { writeSessionManagerError(w, err) return } + createCtx, err := s.resolveAgentCreateContext(template, alias) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + alias = createCtx.Alias + workDir = createCtx.WorkDir + + mcpServers, err := s.sessionMCPServers(template, resolved.Name, createCtx.Identity, workDir, transport, kind) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } - command := sessionCreateAgentCommand(resolved) + launchCommand, err := config.BuildProviderLaunchCommandWithoutOptions(s.state.CityPath(), resolved, transport) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + command := launchCommand.Command // Build template_overrides metadata. Includes schema overrides AND // the initial message (as "initial_message" key). The reconciler @@ -137,13 +164,22 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { if extraMeta == nil { extraMeta = make(map[string]string) } + extraMeta["agent_name"] = createCtx.Identity extraMeta["session_origin"] = "ephemeral" + if transport == "acp" { + extraMeta, err = session.WithStoredMCPMetadata(extraMeta, createCtx.Identity, mcpServers) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + } // Agent sessions always use async (bead-only) creation. The reconciler // starts the agent process on the next tick. This avoids blocking the // HTTP response for 10-30s while the agent boots in tmux, and lets MC // show the session in the sidebar immediately via optimistic UI. - resolvedCfg, err := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir) + resolvedCfg, err := resolvedSessionConfigForProvider(alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers) if err != nil { s.idem.unreserve(idemKey) writeSessionManagerError(w, err) @@ -156,10 +192,23 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { return } var info session.Info - err = session.WithCitySessionAliasLock(s.state.CityPath(), alias, func() error { + reservationIDs := []string{alias, createCtx.ExplicitName} + reserveConcreteIdentity := createCtx.Agent.SupportsMultipleSessions() && strings.TrimSpace(createCtx.Identity) != "" + if reserveConcreteIdentity { + reservationIDs = append(reservationIDs, createCtx.Identity) + } + err = session.WithCitySessionIdentifierLocks(s.state.CityPath(), reservationIDs, func() error { if err := session.EnsureAliasAvailableWithConfig(store, s.state.Config(), alias, ""); err != nil { return err } + if reserveConcreteIdentity && createCtx.Identity != alias { + if err := session.EnsureAliasAvailableWithConfig(store, s.state.Config(), createCtx.Identity, ""); err != nil { + return err + } + } + if err := session.EnsureSessionNameAvailableWithConfig(store, s.state.Config(), createCtx.ExplicitName, ""); err != nil { + return err + } var createErr error info, createErr = handle.Create(r.Context(), worker.CreateModeDeferred) return createErr @@ -192,7 +241,7 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { } } if handle, handleErr := s.workerHandleForSession(store, info.ID); handleErr == nil { - s.enrichSessionResponse(&resp, info, s.state.Config(), handle, false, true) + s.enrichSessionResponse(&resp, info, s.state.Config(), handle, false, true, true) } statusCode := http.StatusAccepted // always async for agent sessions s.idem.storeResponse(idemKey, bodyHash, statusCode, resp) @@ -273,8 +322,20 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s writeSessionManagerError(w, err) return } + mcpIdentity, err := providerSessionMCPIdentity(providerName, alias) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } - launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, body.Options) + transport, err := providerSessionTransport(resolved, s.state.SessionProvider()) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusServiceUnavailable, "provider_unavailable", err.Error()) + return + } + launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, body.Options, transport) if err != nil { s.idem.unreserve(idemKey) if errors.Is(err, config.ErrUnknownOption) { @@ -285,10 +346,25 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s return } command := launchCommand.Command - - resolvedCfg, err := resolvedSessionConfigForProvider(alias, "", template, title, "", map[string]string{ + mcpServers, err := s.providerSessionMCPServers(providerName, mcpIdentity, workDir, transport) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + extraMeta := map[string]string{ "session_origin": "manual", - }, resolved, command, workDir) + } + if transport == "acp" { + extraMeta, err = session.WithStoredMCPMetadata(extraMeta, mcpIdentity, mcpServers) + if err != nil { + s.idem.unreserve(idemKey) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + } + + resolvedCfg, err := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers) if err != nil { s.idem.unreserve(idemKey) writeSessionManagerError(w, err) @@ -352,17 +428,13 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s } } if handle, handleErr := s.workerHandleForSession(store, info.ID); handleErr == nil { - s.enrichSessionResponse(&resp, info, s.state.Config(), handle, false, true) + s.enrichSessionResponse(&resp, info, s.state.Config(), handle, false, true, true) } statusCode := http.StatusCreated s.idem.storeResponse(idemKey, bodyHash, statusCode, resp) writeJSON(w, statusCode, resp) } -func sessionCreateAgentCommand(resolved *config.ResolvedProvider) string { - return firstNonEmptyString(resolved.CommandString(), resolved.Name) -} - func sessionTemplateOverridesMetadata(options map[string]string, message string) map[string]string { allOverrides := make(map[string]string, len(options)+1) for k, v := range options { diff --git a/internal/api/handler_sessions.go b/internal/api/handler_sessions.go index 75871a489..96b7c94ef 100644 --- a/internal/api/handler_sessions.go +++ b/internal/api/handler_sessions.go @@ -71,6 +71,13 @@ type sessionResponseHandle interface { worker.PeekHandle } +func (s *Server) runtimeSessionResponseHandle(info session.Info) sessionResponseHandle { + if info.State != session.StateActive { + return nil + } + return newProviderSessionResponseHandle(s.state.SessionProvider(), info.SessionName, info.Provider) +} + func sessionToResponse(info session.Info, cfg *config.City) sessionResponse { provider, displayName := info.Provider, "" if cfg != nil { @@ -201,28 +208,25 @@ func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) { templateFilter := q.Get("template") wantPeek := q.Get("peek") == "true" - sessions, err := catalog.List(stateFilter, templateFilter) + all, err := listSessionBeadsForReadModel(store) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } + listResult := catalog.ListFullFromBeads(all, stateFilter, templateFilter) + sessions := listResult.Sessions // Build bead index for reason enrichment. beadIndex := make(map[string]*beads.Bead) - if all, err := store.List(beads.ListQuery{Label: session.LabelSession}); err == nil { - for i := range all { - beadIndex[all[i].ID] = &all[i] - } + for i := range listResult.Beads { + beadIndex[listResult.Beads[i].ID] = &listResult.Beads[i] } items := make([]sessionResponse, len(sessions)) hasDeferredQueue := strings.TrimSpace(s.state.CityPath()) != "" for i, sess := range sessions { items[i] = sessionResponseWithReason(sess, beadIndex[sess.ID], cfg, hasDeferredQueue) - handle, err := s.workerHandleForSession(store, sess.ID) - if err == nil { - s.enrichSessionResponse(&items[i], sess, cfg, handle, wantPeek, false) - } + s.enrichSessionResponse(&items[i], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false) } pp := parsePagination(r, maxPaginationLimit) @@ -268,7 +272,7 @@ func (s *Server) handleSessionGet(w http.ResponseWriter, r *http.Request) { resp := sessionResponseWithReason(info, &b, cfg, strings.TrimSpace(s.state.CityPath()) != "") handle, err := s.workerHandleForSession(store, id) if err == nil { - s.enrichSessionResponse(&resp, info, cfg, handle, wantPeek, true) + s.enrichSessionResponse(&resp, info, cfg, handle, wantPeek, true, true) } writeJSON(w, http.StatusOK, resp) } @@ -449,7 +453,7 @@ func (s *Server) handleSessionRename(w http.ResponseWriter, r *http.Request) { // enrichSessionResponse populates runtime fields on a session response: // running state, active bead, peek output, and model/context metadata. -func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info, _ *config.City, runtimeHandle any, wantPeek, liveActiveBead bool) { +func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info, cfg *config.City, runtimeHandle any, wantPeek, liveActiveBead, allowWorkdirTranscriptDiscovery bool) { if info.State != session.StateActive { return } @@ -523,7 +527,14 @@ func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info, } // Prefer session-key lookup to avoid cross-reading another session's transcript. // Cache the resolved file path — session files don't move once created. - sessionFile := factory.DiscoverTranscript(info.Provider, workDir, info.SessionKey) + provider := info.Provider + if strings.TrimSpace(provider) == "" && cfg != nil { + provider, _ = resolveProviderInfo(provider, cfg) + } + if !allowWorkdirTranscriptDiscovery && !canUseCheapTranscriptLookup(provider, info.SessionKey) { + return + } + sessionFile := factory.DiscoverTranscript(provider, workDir, info.SessionKey) if sessionFile != "" { if meta, err := factory.TailMeta(sessionFile); err == nil && meta != nil { resp.Model = meta.Model @@ -537,6 +548,17 @@ func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info, } } +func canUseCheapTranscriptLookup(provider, sessionKey string) bool { + if strings.TrimSpace(sessionKey) == "" { + return false + } + p := strings.ToLower(strings.TrimSpace(provider)) + if strings.Contains(p, "codex") || strings.Contains(p, "gemini") { + return false + } + return true +} + // handleSessionPatch handles PATCH /v0/session/{id}. Title and alias are mutable. func (s *Server) handleSessionPatch(w http.ResponseWriter, r *http.Request) { store := s.state.CityBeadStore() diff --git a/internal/api/handler_sessions_test.go b/internal/api/handler_sessions_test.go index 83cb25056..d05df7ffb 100644 --- a/internal/api/handler_sessions_test.go +++ b/internal/api/handler_sessions_test.go @@ -19,6 +19,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" + sessionauto "github.com/gastownhall/gascity/internal/runtime/auto" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/sessionlog" "github.com/gastownhall/gascity/internal/worker" @@ -41,6 +42,28 @@ func createTestSession(t *testing.T, store beads.Store, sp *runtime.Fake, title return info } +type cachedOnlyListStoreForSessionTest struct { + *beads.MemStore + blockList bool + listCalls int +} + +func (s *cachedOnlyListStoreForSessionTest) List(query beads.ListQuery) ([]beads.Bead, error) { + if s.blockList { + s.listCalls++ + return nil, errors.New("backing List should not be used") + } + return s.MemStore.List(query) +} + +func (s *cachedOnlyListStoreForSessionTest) CachedList(query beads.ListQuery) ([]beads.Bead, bool) { + rows, err := s.MemStore.List(query) + if err != nil { + return nil, false + } + return rows, true +} + func writeGeminiHistoryFixtureForAPI(t *testing.T, path, sessionID string, messages ...string) { t.Helper() @@ -76,6 +99,14 @@ func (p *failNudgeProvider) Nudge(name string, content []runtime.ContentBlock) e return nil } +type transportCapableProvider struct { + *runtime.Fake +} + +func (p *transportCapableProvider) SupportsTransport(transport string) bool { + return transport == "acp" +} + type stateWithSessionProvider struct { *fakeState provider runtime.Provider @@ -395,7 +426,7 @@ func TestHandleSessionListActiveBeadUsesCachedLookup(t *testing.T) { resp := sessionResponse{} srv.enrichSessionResponse(&resp, info, fs.Config(), sessionResponseCapabilityHandle{ state: worker.State{Phase: worker.PhaseReady}, - }, false, false) + }, false, false, false) if !resp.Running { t.Fatal("Running = false, want true") @@ -405,6 +436,173 @@ func TestHandleSessionListActiveBeadUsesCachedLookup(t *testing.T) { } } +func TestHandleSessionListUsesCachedSessionBeadsWhenAvailable(t *testing.T) { + fs := newSessionFakeState(t) + store := &cachedOnlyListStoreForSessionTest{MemStore: beads.NewMemStore()} + fs.cityBeadStore = store + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + info := createTestSession(t, fs.cityBeadStore, fs.sp, "My Session") + store.blockList = true + + req := httptest.NewRequest("GET", cityURL(fs, "/sessions"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Items []sessionResponse `json:"items"` + Total int `json:"total"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != info.ID { + t.Fatalf("response = %#v, want one session %s", resp, info.ID) + } + if store.listCalls != 0 { + t.Fatalf("backing List calls = %d, want 0", store.listCalls) + } +} + +func TestHandleSessionListSkipsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T) { + fs := newSessionFakeState(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".codex", "sessions"), 0o755); err != nil { + t.Fatalf("MkdirAll default codex sessions: %v", err) + } + searchBase := t.TempDir() + srv := New(fs) + srv.sessionLogSearchPaths = []string{searchBase} + h := newTestCityHandlerWith(t, fs, srv) + + workDir := t.TempDir() + mgr := session.NewManager(fs.cityBeadStore, fs.sp) + info, err := mgr.Create(context.Background(), "myrig/worker", "Codex Chat", "codex", workDir, "codex-max", nil, session.ProviderResume{}, runtime.Config{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if info.SessionKey != "" { + t.Fatalf("SessionKey = %q, want empty for codex provider without SessionIDFlag", info.SessionKey) + } + + codexDir := filepath.Join(searchBase, "2026", "04", "18") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + codexPayload := strings.Join([]string{ + fmt.Sprintf(`{"type":"session_meta","payload":{"cwd":%q}}`, workDir), + `{"type":"assistant","message":{"model":"gpt-5.5","usage":{"input_tokens":1000}}}`, + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(codexDir, "session.jsonl"), []byte(codexPayload), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + req := httptest.NewRequest("GET", cityURL(fs, "/sessions?template=myrig%2Fworker"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Items []sessionResponse `json:"items"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Items) != 1 || resp.Items[0].ID != info.ID { + t.Fatalf("items = %#v, want session %s", resp.Items, info.ID) + } + if resp.Items[0].Model != "" || resp.Items[0].ContextPct != nil { + t.Fatalf("session list used workdir-only Codex transcript discovery: model=%q context=%v", resp.Items[0].Model, resp.Items[0].ContextPct) + } +} + +func TestHandleSessionGetAllowsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T) { + fs := newSessionFakeState(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".codex", "sessions"), 0o755); err != nil { + t.Fatalf("MkdirAll default codex sessions: %v", err) + } + searchBase := t.TempDir() + srv := New(fs) + srv.sessionLogSearchPaths = []string{searchBase} + h := newTestCityHandlerWith(t, fs, srv) + + workDir := t.TempDir() + mgr := session.NewManager(fs.cityBeadStore, fs.sp) + info, err := mgr.Create(context.Background(), "myrig/worker", "Codex Chat", "codex", workDir, "codex-max", nil, session.ProviderResume{}, runtime.Config{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + codexDir := filepath.Join(searchBase, "2026", "04", "18") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + codexPayload := strings.Join([]string{ + fmt.Sprintf(`{"type":"session_meta","payload":{"cwd":%q}}`, workDir), + `{"type":"assistant","message":{"model":"gpt-5.5","usage":{"input_tokens":1000}}}`, + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(codexDir, "session.jsonl"), []byte(codexPayload), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + req := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp sessionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ID != info.ID { + t.Fatalf("ID = %q, want %q", resp.ID, info.ID) + } + if resp.Model != "gpt-5.5" { + t.Fatalf("model = %q, want gpt-5.5", resp.Model) + } +} + +func TestHandleSessionListActiveBeadUsesCachedListWhenAvailable(t *testing.T) { + fs := newSessionFakeState(t) + store := &cachedOnlyListStoreForSessionTest{MemStore: beads.NewMemStore(), blockList: true} + fs.stores["myrig"] = store + srv := New(fs) + + info := createTestSession(t, fs.cityBeadStore, fs.sp, "My Session") + work, err := store.Create(beads.Bead{Title: "active work"}) + if err != nil { + t.Fatalf("Create(work): %v", err) + } + status := "in_progress" + assignee := info.ID + if err := store.Update(work.ID, beads.UpdateOpts{Status: &status, Assignee: &assignee}); err != nil { + t.Fatalf("Update(work): %v", err) + } + + resp := sessionResponse{} + srv.enrichSessionResponse(&resp, info, fs.Config(), sessionResponseCapabilityHandle{ + state: worker.State{Phase: worker.PhaseReady}, + }, false, false, false) + + if got := resp.ActiveBead; got != work.ID { + t.Fatalf("active_bead = %q, want cached %q", got, work.ID) + } + if store.listCalls != 0 { + t.Fatalf("backing List calls = %d, want 0", store.listCalls) + } +} + func TestHandleSessionGetActiveBeadUsesLiveLookup(t *testing.T) { fs := newSessionFakeState(t) backing := beads.NewMemStore() @@ -433,7 +631,7 @@ func TestHandleSessionGetActiveBeadUsesLiveLookup(t *testing.T) { resp := sessionResponse{} srv.enrichSessionResponse(&resp, info, fs.Config(), sessionResponseCapabilityHandle{ state: worker.State{Phase: worker.PhaseReady}, - }, false, true) + }, false, true, true) if !resp.Running { t.Fatal("Running = false, want true") @@ -1125,6 +1323,16 @@ func TestHandleSessionCreate(t *testing.T) { if resp.Title != "myrig/worker" { t.Errorf("Title = %q, want default %q", resp.Title, "myrig/worker") } + bead, err := fs.cityBeadStore.Get(resp.ID) + if err != nil { + t.Fatalf("Get(%s): %v", resp.ID, err) + } + if got := bead.Metadata[session.MCPIdentityMetadataKey]; got != "" { + t.Fatalf("mcp_identity = %q, want empty for non-ACP agent session", got) + } + if got := bead.Metadata[session.MCPServersSnapshotMetadataKey]; got != "" { + t.Fatalf("mcp_servers_snapshot = %q, want empty for non-ACP agent session", got) + } // Agent sessions are always created async — not running until the // reconciler starts the process. if resp.Running { @@ -1135,6 +1343,242 @@ func TestHandleSessionCreate(t *testing.T) { } } +func TestHandleSessionCreateUsesACPTransportCommandForAgentTemplate(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + state := &stateWithSessionProvider{ + fakeState: fs, + provider: &transportCapableProvider{Fake: runtime.NewFake()}, + } + srv := New(state) + h := newTestCityHandlerWith(t, state, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"agent","name":"myrig/worker"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + + var resp sessionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + bead, err := state.cityBeadStore.Get(resp.ID) + if err != nil { + t.Fatalf("Get(%s): %v", resp.ID, err) + } + if got, want := bead.Metadata["command"], "/bin/echo acp"; got != want { + t.Fatalf("command metadata = %q, want %q", got, want) + } + if got, want := bead.Metadata["transport"], "acp"; got != want { + t.Fatalf("transport metadata = %q, want %q", got, want) + } +} + +func TestHumaHandleSessionCreateUsesACPTransportCommandForAgentTemplate(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + state := &stateWithSessionProvider{ + fakeState: fs, + provider: &transportCapableProvider{Fake: runtime.NewFake()}, + } + srv := New(state) + + out, err := srv.humaHandleSessionCreate(context.Background(), &SessionCreateInput{ + Body: sessionCreateBody{ + Kind: "agent", + Name: "myrig/worker", + }, + }) + if err != nil { + t.Fatalf("humaHandleSessionCreate: %v", err) + } + if got, want := out.Status, http.StatusAccepted; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + bead, err := state.cityBeadStore.Get(out.Body.ID) + if err != nil { + t.Fatalf("Get(%s): %v", out.Body.ID, err) + } + if got, want := bead.Metadata["command"], "/bin/echo acp"; got != want { + t.Fatalf("command metadata = %q, want %q", got, want) + } + if got, want := bead.Metadata["transport"], "acp"; got != want { + t.Fatalf("transport metadata = %q, want %q", got, want) + } +} + +func TestHandleSessionCreateRejectsACPAgentWithoutACPRouting(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"agent","name":"myrig/worker"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "requires ACP transport") { + t.Fatalf("body = %q, want ACP transport error", rec.Body.String()) + } +} + +func TestHumaHandleSessionCreateRejectsACPAgentWithoutACPRouting(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + srv := New(fs) + + if _, err := srv.humaHandleSessionCreate(context.Background(), &SessionCreateInput{ + Body: sessionCreateBody{ + Kind: "agent", + Name: "myrig/worker", + }, + }); err == nil { + t.Fatal("humaHandleSessionCreate() error = nil, want ACP routing error") + } else if !strings.Contains(err.Error(), "requires ACP transport") { + t.Fatalf("humaHandleSessionCreate() error = %v, want ACP transport error", err) + } +} + +func TestHandleSessionCreateRejectsACPAgentWhenProviderLacksACP(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "custom" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["custom"] = config.ProviderSpec{ + DisplayName: "Custom", + Command: "/bin/echo", + PathCheck: "true", + } + state := &stateWithSessionProvider{ + fakeState: fs, + provider: &transportCapableProvider{Fake: runtime.NewFake()}, + } + srv := New(state) + h := newTestCityHandlerWith(t, state, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"agent","name":"myrig/worker"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "does not support ACP transport") { + t.Fatalf("body = %q, want provider ACP support error", rec.Body.String()) + } +} + +func TestHumaHandleSessionCreatePropagatesMCPResolutionErrorForACPAgent(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "filesystem.toml"), []byte(` +name = "filesystem" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + state := &stateWithSessionProvider{ + fakeState: fs, + provider: &transportCapableProvider{Fake: runtime.NewFake()}, + } + srv := New(state) + + if _, err := srv.humaHandleSessionCreate(context.Background(), &SessionCreateInput{ + Body: sessionCreateBody{ + Kind: "agent", + Name: "myrig/worker", + }, + }); err == nil { + t.Fatal("humaHandleSessionCreate() error = nil, want MCP resolution error") + } else if !strings.Contains(err.Error(), "loading effective MCP") { + t.Fatalf("humaHandleSessionCreate() error = %v, want MCP resolution error", err) + } +} + +func TestHandleSessionCreateIgnoresBrokenMCPWithoutACPTransport(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "filesystem.toml"), []byte(` +name = "filesystem" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"agent","name":"myrig/worker"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusAccepted, rec.Body.String()) + } +} + func TestHandleSessionCreateAsync(t *testing.T) { fs := newSessionFakeState(t) srv := New(fs) @@ -1247,6 +1691,62 @@ func TestHandleSessionCreateAsync_PoolTemplateWithoutAliasUsesGeneratedWorkDirId } } +func TestResolveAgentCreateContextUsesConcreteIdentityForMCPMaterialization(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents = []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "opencode", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }} + fs.cfg.NamedSessions = nil + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + createCtx, err := srv.resolveAgentCreateContext("myrig/ant", "") + if err != nil { + t.Fatalf("resolveAgentCreateContext: %v", err) + } + mcpServers, err := srv.sessionMCPServers("myrig/ant", "opencode", createCtx.Identity, createCtx.WorkDir, "acp", "agent") + if err != nil { + t.Fatalf("sessionMCPServers: %v", err) + } + if len(mcpServers) != 1 { + t.Fatalf("len(mcpServers) = %d, want 1", len(mcpServers)) + } + if got, want := mcpServers[0].Args[0], createCtx.Identity; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := mcpServers[0].Args[1], createCtx.WorkDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := mcpServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} + func TestHandleSessionCreateAsync_PoolTemplateCanonicalizesAliasCollisions(t *testing.T) { fs := newSessionFakeState(t) fs.cfg.Agents = []config.Agent{{ @@ -1391,6 +1891,95 @@ func TestMaterializeNamedSessionStampsProviderFamilyMetadata(t *testing.T) { } } +func TestMaterializeNamedSessionRejectsACPTemplateWithoutACPRouting(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + srv := New(fs) + + spec, ok, err := srv.findNamedSessionSpecForTarget(fs.cityBeadStore, "worker") + if err != nil { + t.Fatalf("findNamedSessionSpecForTarget: %v", err) + } + if !ok { + t.Fatal("expected named session spec") + } + if _, err := srv.materializeNamedSession(fs.cityBeadStore, spec); err == nil { + t.Fatal("materializeNamedSession() error = nil, want ACP routing error") + } else if !strings.Contains(err.Error(), "requires ACP transport") { + t.Fatalf("materializeNamedSession() error = %v, want ACP transport error", err) + } + items, err := fs.cityBeadStore.ListByLabel(session.LabelSession, 0) + if err != nil { + t.Fatalf("ListByLabel: %v", err) + } + if len(items) != 0 { + t.Fatalf("session bead count = %d, want 0", len(items)) + } +} + +func TestMaterializeNamedSessionPersistsStoredMCPMetadata(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "opencode" + fs.cfg.Agents[0].Session = "acp" + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + state := &stateWithSessionProvider{ + fakeState: fs, + provider: &transportCapableProvider{Fake: runtime.NewFake()}, + } + srv := New(state) + + spec, ok, err := srv.findNamedSessionSpecForTarget(fs.cityBeadStore, "worker") + if err != nil { + t.Fatalf("findNamedSessionSpecForTarget: %v", err) + } + if !ok { + t.Fatal("expected named session spec") + } + id, err := srv.materializeNamedSession(fs.cityBeadStore, spec) + if err != nil { + t.Fatalf("materializeNamedSession: %v", err) + } + bead, err := fs.cityBeadStore.Get(id) + if err != nil { + t.Fatalf("Get(%s): %v", id, err) + } + if got, want := bead.Metadata[session.MCPIdentityMetadataKey], spec.Identity; got != want { + t.Fatalf("mcp_identity = %q, want %q", got, want) + } + if got := bead.Metadata[session.MCPServersSnapshotMetadataKey]; got == "" { + t.Fatal("mcp_servers_snapshot = empty, want persisted snapshot") + } +} + func TestHandleProviderSessionCreateWithMessageUsesProviderDefaultNudge(t *testing.T) { fs := newSessionFakeState(t) srv := New(fs) @@ -1431,6 +2020,269 @@ func TestHandleProviderSessionCreateWithMessageUsesProviderDefaultNudge(t *testi } } +func TestHandleProviderSessionCreateUsesACPTransportCommand(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + defaultSP := runtime.NewFake() + acpSP := &transportCapableProvider{Fake: runtime.NewFake()} + state := &stateWithSessionProvider{ + fakeState: fs, + provider: sessionauto.New(defaultSP, acpSP), + } + srv := New(state) + h := newTestCityHandlerWith(t, state, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"provider","name":"opencode"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp sessionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + start := acpSP.LastStartConfig(resp.SessionName) + if start == nil { + t.Fatalf("LastStartConfig(%q) = nil", resp.SessionName) + } + if got, want := start.Command, "/bin/echo acp"; got != want { + t.Fatalf("start command = %q, want %q", got, want) + } + bead, err := fs.cityBeadStore.Get(resp.ID) + if err != nil { + t.Fatalf("Get(%s): %v", resp.ID, err) + } + if got, want := bead.Metadata["transport"], "acp"; got != want { + t.Fatalf("transport metadata = %q, want %q", got, want) + } + if defaultSP.IsRunning(resp.SessionName) { + t.Fatalf("default backend should not own ACP session %q", resp.SessionName) + } +} + +func TestHumaCreateProviderSessionUsesACPTransportCommand(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + defaultSP := runtime.NewFake() + acpSP := &transportCapableProvider{Fake: runtime.NewFake()} + state := &stateWithSessionProvider{ + fakeState: fs, + provider: sessionauto.New(defaultSP, acpSP), + } + srv := New(state) + + out, err := srv.humaCreateProviderSession(context.Background(), fs.cityBeadStore, sessionCreateBody{ + Kind: "provider", + Name: "opencode", + }, "opencode") + if err != nil { + t.Fatalf("humaCreateProviderSession: %v", err) + } + if got, want := out.Status, http.StatusCreated; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + start := acpSP.LastStartConfig(out.Body.SessionName) + if start == nil { + t.Fatalf("LastStartConfig(%q) = nil", out.Body.SessionName) + } + if got, want := start.Command, "/bin/echo acp"; got != want { + t.Fatalf("start command = %q, want %q", got, want) + } + bead, err := fs.cityBeadStore.Get(out.Body.ID) + if err != nil { + t.Fatalf("Get(%s): %v", out.Body.ID, err) + } + if got, want := bead.Metadata["transport"], "acp"; got != want { + t.Fatalf("transport metadata = %q, want %q", got, want) + } + if defaultSP.IsRunning(out.Body.SessionName) { + t.Fatalf("default backend should not own ACP session %q", out.Body.SessionName) + } +} + +func TestHandleProviderSessionCreateUsesACPTransportCapabilityProvider(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + provider := &transportCapableProvider{Fake: runtime.NewFake()} + state := &stateWithSessionProvider{ + fakeState: fs, + provider: provider, + } + srv := New(state) + h := newTestCityHandlerWith(t, state, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"provider","name":"opencode"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp sessionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + start := provider.LastStartConfig(resp.SessionName) + if start == nil { + t.Fatalf("LastStartConfig(%q) = nil", resp.SessionName) + } + if got, want := start.Command, "/bin/echo acp"; got != want { + t.Fatalf("start command = %q, want %q", got, want) + } + bead, err := fs.cityBeadStore.Get(resp.ID) + if err != nil { + t.Fatalf("Get(%s): %v", resp.ID, err) + } + if got, want := bead.Metadata["transport"], "acp"; got != want { + t.Fatalf("transport metadata = %q, want %q", got, want) + } +} + +func TestHandleProviderSessionCreateUsesPerSessionMCPIdentity(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + provider := &transportCapableProvider{Fake: runtime.NewFake()} + state := &stateWithSessionProvider{ + fakeState: fs, + provider: provider, + } + srv := New(state) + h := newTestCityHandlerWith(t, state, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"provider","name":"opencode"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp sessionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + start := provider.LastStartConfig(resp.SessionName) + if start == nil { + t.Fatalf("LastStartConfig(%q) = nil", resp.SessionName) + } + if len(start.MCPServers) != 1 { + t.Fatalf("Start MCPServers len = %d, want 1", len(start.MCPServers)) + } + bead, err := fs.cityBeadStore.Get(resp.ID) + if err != nil { + t.Fatalf("Get(%s): %v", resp.ID, err) + } + if got := bead.Metadata[session.MCPIdentityMetadataKey]; got == "" { + t.Fatal("mcp_identity metadata = empty, want per-session identity") + } + if got, want := start.MCPServers[0].Args[0], bead.Metadata[session.MCPIdentityMetadataKey]; got != want { + t.Fatalf("Start MCP identity = %q, want %q", got, want) + } + if got := bead.Metadata[session.MCPIdentityMetadataKey]; got == "opencode" { + t.Fatalf("mcp_identity metadata = %q, want unique per-session identity", got) + } + if got, want := start.MCPServers[0].Args[1], fs.cityPath; got != want { + t.Fatalf("Start workdir arg = %q, want %q", got, want) + } + if got, want := start.MCPServers[0].Args[2], bead.Metadata[session.MCPIdentityMetadataKey]; got != want { + t.Fatalf("Start template arg = %q, want %q", got, want) + } +} + +func TestHandleProviderSessionCreateRejectsACPProviderWithoutACPRouting(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + srv := New(fs) + h := newTestCityHandlerWith(t, fs, srv) + + req := newPostRequest(cityURL(fs, "/sessions"), strings.NewReader(`{"kind":"provider","name":"opencode"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "requires ACP transport") { + t.Fatalf("body = %q, want ACP transport error", rec.Body.String()) + } +} + +func TestHumaCreateProviderSessionRejectsACPProviderWithoutACPRouting(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["opencode"] = config.ProviderSpec{ + DisplayName: "OpenCode", + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + srv := New(fs) + + if _, err := srv.humaCreateProviderSession(context.Background(), fs.cityBeadStore, sessionCreateBody{ + Kind: "provider", + Name: "opencode", + }, "opencode"); err == nil { + t.Fatal("humaCreateProviderSession() error = nil, want ACP routing error") + } +} + func TestHandleProviderSessionCreateWithMessageRollsBackOnDeliveryFailure(t *testing.T) { fs := newSessionFakeState(t) provider := &failNudgeProvider{Fake: runtime.NewFake(), err: errors.New("nudge failed")} diff --git a/internal/api/handler_sling.go b/internal/api/handler_sling.go index 5c1e3acc3..68cdb9a9e 100644 --- a/internal/api/handler_sling.go +++ b/internal/api/handler_sling.go @@ -14,6 +14,7 @@ import ( "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/execenv" "github.com/gastownhall/gascity/internal/sling" "github.com/gastownhall/gascity/internal/sourceworkflow" ) @@ -313,9 +314,7 @@ func (s *Server) slingRunner() sling.SlingRunner { if dir != "" { cmd.Dir = dir } - if len(env) > 0 { - cmd.Env = mergeEnvForSling(env) - } + cmd.Env = mergeEnvForSling(env) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("running %q: %w", command, err) @@ -326,13 +325,7 @@ func (s *Server) slingRunner() sling.SlingRunner { // mergeEnvForSling merges extra env vars into the current process env. func mergeEnvForSling(extra map[string]string) []string { - base := os.Environ() - merged := make([]string, 0, len(base)+len(extra)) - merged = append(merged, base...) - for k, v := range extra { - merged = append(merged, k+"="+v) - } - return merged + return execenv.MergeMap(os.Environ(), extra) } // apiAgentResolver implements sling.AgentResolver for the API context. diff --git a/internal/api/handler_status.go b/internal/api/handler_status.go index a44f754d8..d2b9d5259 100644 --- a/internal/api/handler_status.go +++ b/internal/api/handler_status.go @@ -3,10 +3,13 @@ package api import ( "context" "fmt" + "strings" "time" "github.com/gastownhall/gascity/internal/beads" - "github.com/gastownhall/gascity/internal/worker" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/session" + workdirutil "github.com/gastownhall/gascity/internal/workdir" ) // statusResponse is the JSON body for GET /v0/status. @@ -50,28 +53,34 @@ func (s *Server) humaHandleStatus(ctx context.Context, input *StatusInput) (*Ind func (s *Server) buildStatusBody() StatusBody { cfg := s.state.Config() sp := s.state.SessionProvider() - store := s.state.CityBeadStore() cityName := s.state.CityName() sessTmpl := cfg.Workspace.SessionTemplate + sessionSnapshot := s.statusSessionSnapshot() // Count agents by state. var ac agentCounts var rawRunning int + rigAgentCounts := make(map[string]int) + rigSuspendedCounts := make(map[string]int) for _, a := range cfg.Agents { - for _, ea := range expandAgent(a, cityName, sessTmpl, sp) { + rigName := workdirutil.ConfiguredRigName(s.state.CityPath(), a, cfg.Rigs) + for _, slot := range statusAgentSlots(a, cityName, sessTmpl, sessionSnapshot) { ac.Total++ - sessName := agentSessionName(cityName, ea.qualifiedName, sessTmpl) - handle, _ := s.workerHandleForSessionTarget(store, sessName) - obs, _ := worker.ObserveHandle(context.Background(), handle) - running := obs.Running + if rigName != "" { + rigAgentCounts[rigName]++ + } + running := statusProviderRunning(sp, slot.sessionName) if running { rawRunning++ } - suspended := ea.suspended || obs.Suspended + suspended := a.Suspended || slot.suspended + if suspended && rigName != "" { + rigSuspendedCounts[rigName]++ + } switch { case suspended: ac.Suspended++ - case s.state.IsQuarantined(sessName): + case s.state.IsQuarantined(slot.sessionName): ac.Quarantined++ case running: ac.Running++ @@ -82,7 +91,11 @@ func (s *Server) buildStatusBody() StatusBody { // Count rigs by state. rc := rigCounts{Total: len(cfg.Rigs)} for _, rig := range cfg.Rigs { - if s.rigSuspended(cfg, rig, store, sp, cityName, s.state.CityPath()) { + if rig.Suspended { + rc.Suspended++ + continue + } + if total := rigAgentCounts[rig.Name]; total > 0 && total == rigSuspendedCounts[rig.Name] { rc.Suspended++ } } @@ -151,6 +164,126 @@ func (s *Server) buildStatusBody() StatusBody { } } +type statusSessionSnapshot struct { + bySessionName map[string]statusSessionInfo + byTemplate map[string][]statusSessionInfo +} + +type statusSessionInfo struct { + sessionName string + template string + state session.State +} + +type statusAgentSlot struct { + sessionName string + suspended bool +} + +func (s *Server) statusSessionSnapshot() statusSessionSnapshot { + snapshot := statusSessionSnapshot{ + bySessionName: make(map[string]statusSessionInfo), + byTemplate: make(map[string][]statusSessionInfo), + } + store := s.state.CityBeadStore() + if store == nil { + return snapshot + } + + rows, err := listSessionBeadsForReadModel(store) + if err != nil { + return snapshot + } + + seenSessionName := make(map[string]bool, len(rows)) + for _, b := range rows { + if b.Status == "closed" { + continue + } + info := statusSessionInfo{ + sessionName: strings.TrimSpace(b.Metadata["session_name"]), + template: strings.TrimSpace(b.Metadata["template"]), + state: statusSessionState(b), + } + if info.sessionName == "" { + continue + } + if info.state == session.StateArchived { + continue + } + if seenSessionName[info.sessionName] { + continue + } + seenSessionName[info.sessionName] = true + snapshot.bySessionName[info.sessionName] = info + if info.template != "" { + snapshot.byTemplate[info.template] = append(snapshot.byTemplate[info.template], info) + } + } + return snapshot +} + +func statusSessionState(b beads.Bead) session.State { + state := session.State(strings.TrimSpace(b.Metadata["state"])) + switch state { + case "awake": + return session.StateActive + case "drained": + return session.StateAsleep + default: + return state + } +} + +func statusAgentSlots(a config.Agent, cityName, sessTmpl string, snapshot statusSessionSnapshot) []statusAgentSlot { + maxSess := a.EffectiveMaxActiveSessions() + isMultiSession := maxSess == nil || *maxSess != 1 + if isMultiSession && (maxSess == nil || *maxSess < 0) { + sessions := snapshot.byTemplate[a.QualifiedName()] + slots := make([]statusAgentSlot, 0, len(sessions)) + for _, info := range sessions { + slots = append(slots, statusAgentSlot{ + sessionName: info.sessionName, + suspended: info.state == session.StateSuspended, + }) + } + return slots + } + + if !isMultiSession { + sessionName := agentSessionName(cityName, a.QualifiedName(), sessTmpl) + info, ok := snapshot.bySessionName[sessionName] + return []statusAgentSlot{{ + sessionName: sessionName, + suspended: ok && info.state == session.StateSuspended, + }} + } + + poolMax := 1 + if maxSess != nil && *maxSess > 1 { + poolMax = *maxSess + } + slots := make([]statusAgentSlot, 0, poolMax) + for i := 1; i <= poolMax; i++ { + memberName := poolInstanceNameForAPI(a.Name, i, a) + sessionName := agentSessionName(cityName, a.QualifiedInstanceName(memberName), sessTmpl) + info, ok := snapshot.bySessionName[sessionName] + slots = append(slots, statusAgentSlot{ + sessionName: sessionName, + suspended: ok && info.state == session.StateSuspended, + }) + } + return slots +} + +func statusProviderRunning(sp interface{ IsRunning(string) bool }, sessionName string) bool { + sessionName = strings.TrimSpace(sessionName) + if sp == nil || sessionName == "" { + return false + } + return sp.IsRunning(sessionName) +} + // HealthInput is the Huma input for GET /v0/city/{cityName}/health. type HealthInput struct { CityScope diff --git a/internal/api/handler_status_test.go b/internal/api/handler_status_test.go index 7e8921279..1ded7defb 100644 --- a/internal/api/handler_status_test.go +++ b/internal/api/handler_status_test.go @@ -7,7 +7,9 @@ import ( "net/http/httptest" "testing" + "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" ) func TestHandleStatus(t *testing.T) { @@ -133,3 +135,249 @@ func TestHandleStatus_Suspended(t *testing.T) { t.Error("expected suspended=true in status response") } } + +func TestHandleStatusUsesCachedSessionStateForSuspendedAgents(t *testing.T) { + state := newFakeState(t) + store := beads.NewMemStore() + state.cityBeadStore = store + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateSuspended), + "template": "myrig/worker", + "session_name": "myrig--worker", + }, + }); err != nil { + t.Fatalf("Create session bead: %v", err) + } + if err := state.sp.Start(context.Background(), "myrig--worker", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + state.sp.Calls = nil + h := newTestCityHandler(t, state) + + req := httptest.NewRequest("GET", cityURL(state, "/status"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp statusResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Agents.Suspended != 1 { + t.Fatalf("Agents.Suspended = %d, want 1", resp.Agents.Suspended) + } + if resp.Agents.Running != 0 { + t.Fatalf("Agents.Running = %d, want 0 for suspended session", resp.Agents.Running) + } + if resp.Running != 1 { + t.Fatalf("Running = %d, want raw liveness count 1", resp.Running) + } +} + +func TestHandleStatusUsesNewestSessionBeadForDuplicateSessionName(t *testing.T) { + state := newFakeState(t) + store := beads.NewMemStore() + state.cityBeadStore = store + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateSuspended), + "template": "myrig/worker", + "session_name": "myrig--worker", + }, + }); err != nil { + t.Fatalf("Create old session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateActive), + "template": "myrig/worker", + "session_name": "myrig--worker", + }, + }); err != nil { + t.Fatalf("Create new session bead: %v", err) + } + if err := state.sp.Start(context.Background(), "myrig--worker", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + h := newTestCityHandler(t, state) + + req := httptest.NewRequest("GET", cityURL(state, "/status"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp statusResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Agents.Suspended != 0 { + t.Fatalf("Agents.Suspended = %d, want 0 from newest active bead", resp.Agents.Suspended) + } + if resp.Agents.Running != 1 { + t.Fatalf("Agents.Running = %d, want 1", resp.Agents.Running) + } +} + +func TestHandleStatusUnlimitedPoolUsesOpenNonArchivedSessionBeads(t *testing.T) { + state := newFakeState(t) + state.cfg.Agents[0].MaxActiveSessions = intPtr(-1) + store := beads.NewMemStore() + state.cityBeadStore = store + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateActive), + "template": "myrig/worker", + "session_name": "myrig--worker-1", + }, + }); err != nil { + t.Fatalf("Create active session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateSuspended), + "template": "myrig/worker", + "session_name": "myrig--worker-2", + }, + }); err != nil { + t.Fatalf("Create suspended session bead: %v", err) + } + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateArchived), + "template": "myrig/worker", + "session_name": "myrig--worker-3", + }, + }); err != nil { + t.Fatalf("Create archived session bead: %v", err) + } + if err := state.sp.Start(context.Background(), "myrig--worker-1", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + h := newTestCityHandler(t, state) + + req := httptest.NewRequest("GET", cityURL(state, "/status"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp statusResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Agents.Total != 2 { + t.Fatalf("Agents.Total = %d, want 2 non-archived unlimited-pool slots", resp.Agents.Total) + } + if resp.Agents.Running != 1 { + t.Fatalf("Agents.Running = %d, want 1", resp.Agents.Running) + } + if resp.Agents.Suspended != 1 { + t.Fatalf("Agents.Suspended = %d, want 1", resp.Agents.Suspended) + } +} + +func TestHandleStatusBoundedPoolUsesCachedSessionState(t *testing.T) { + state := newFakeState(t) + state.cfg.Agents[0].MaxActiveSessions = intPtr(2) + store := beads.NewMemStore() + state.cityBeadStore = store + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Status: "open", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "state": string(session.StateSuspended), + "template": "myrig/worker", + "session_name": "myrig--worker-2", + }, + }); err != nil { + t.Fatalf("Create suspended pool session bead: %v", err) + } + if err := state.sp.Start(context.Background(), "myrig--worker-1", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + h := newTestCityHandler(t, state) + + req := httptest.NewRequest("GET", cityURL(state, "/status"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp statusResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Agents.Total != 2 { + t.Fatalf("Agents.Total = %d, want 2 bounded pool slots", resp.Agents.Total) + } + if resp.Agents.Running != 1 { + t.Fatalf("Agents.Running = %d, want 1", resp.Agents.Running) + } + if resp.Agents.Suspended != 1 { + t.Fatalf("Agents.Suspended = %d, want 1", resp.Agents.Suspended) + } +} + +func TestHandleStatusOnlyUsesProviderLiveness(t *testing.T) { + state := newFakeState(t) + if err := state.sp.Start(context.Background(), "myrig--worker", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + if err := state.sp.SetMeta("myrig--worker", "suspended", "true"); err != nil { + t.Fatalf("SetMeta: %v", err) + } + state.sp.SetAttached("myrig--worker", true) + state.sp.SetActivity("myrig--worker", state.startedAt) + state.sp.Calls = nil + h := newTestCityHandler(t, state) + + req := httptest.NewRequest("GET", cityURL(state, "/status"), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + for _, call := range state.sp.Calls { + switch call.Method { + case "ProcessAlive", "IsAttached", "GetLastActivity", "GetMeta", "ListRunning": + t.Fatalf("/status called provider %s for %q; calls=%#v", call.Method, call.Name, state.sp.Calls) + } + } + var resp statusResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Agents.Running != 1 { + t.Fatalf("Agents.Running = %d, want 1", resp.Agents.Running) + } + if resp.Running != 1 { + t.Fatalf("Running = %d, want 1", resp.Running) + } +} diff --git a/internal/api/huma_handlers_config.go b/internal/api/huma_handlers_config.go index dbec8d8ed..19e951a0e 100644 --- a/internal/api/huma_handlers_config.go +++ b/internal/api/huma_handlers_config.go @@ -44,7 +44,9 @@ func (s *Server) humaHandleConfigGet(_ context.Context, _ *ConfigGetInput) (*Ind providers[name] = providerSpecJSON{ DisplayName: spec.DisplayName, Command: spec.Command, + ACPCommand: spec.ACPCommand, Args: spec.Args, + ACPArgs: optionalStringSlice(spec.ACPArgs), PromptMode: spec.PromptMode, PromptFlag: spec.PromptFlag, ReadyDelayMs: spec.ReadyDelayMs, @@ -129,7 +131,9 @@ func (s *Server) humaHandleConfigExplain(_ context.Context, _ *ConfigExplainInpu provMap[name] = annotatedProviderResponse{ DisplayName: spec.DisplayName, Command: spec.Command, + ACPCommand: spec.ACPCommand, Args: spec.Args, + ACPArgs: optionalStringSlice(spec.ACPArgs), PromptMode: spec.PromptMode, PromptFlag: spec.PromptFlag, ReadyDelayMs: spec.ReadyDelayMs, @@ -143,7 +147,9 @@ func (s *Server) humaHandleConfigExplain(_ context.Context, _ *ConfigExplainInpu provMap[name] = annotatedProviderResponse{ DisplayName: spec.DisplayName, Command: spec.Command, + ACPCommand: spec.ACPCommand, Args: spec.Args, + ACPArgs: optionalStringSlice(spec.ACPArgs), PromptMode: spec.PromptMode, PromptFlag: spec.PromptFlag, ReadyDelayMs: spec.ReadyDelayMs, @@ -220,7 +226,9 @@ type annotatedAgentResponse struct { type annotatedProviderResponse struct { DisplayName string `json:"display_name,omitempty"` Command string `json:"command,omitempty"` + ACPCommand string `json:"acp_command,omitempty"` Args []string `json:"args,omitempty"` + ACPArgs *[]string `json:"acp_args,omitempty"` PromptMode string `json:"prompt_mode,omitempty"` PromptFlag string `json:"prompt_flag,omitempty"` ReadyDelayMs int `json:"ready_delay_ms,omitempty"` diff --git a/internal/api/huma_handlers_mail.go b/internal/api/huma_handlers_mail.go index 62cefe358..9a4792afc 100644 --- a/internal/api/huma_handlers_mail.go +++ b/internal/api/huma_handlers_mail.go @@ -263,7 +263,7 @@ func (s *Server) humaHandleMailSend(ctx context.Context, input *MailSendInput) ( } msg.Rig = input.Body.Rig s.idem.storeResponse(idemKey, bodyHash, msg) - s.recordMailEvent(events.MailSent, input.Body.From, msg.ID, input.Body.Rig, &msg) + s.recordMailEvent(events.MailSent, msg.From, msg.ID, input.Body.Rig, &msg) return &IndexOutput[mail.Message]{ Index: s.latestIndex(), @@ -449,7 +449,7 @@ func (s *Server) humaHandleMailReply(_ context.Context, input *MailReplyInput) ( return nil, huma.Error500InternalServerError(err.Error()) } msg.Rig = resolvedRig - s.recordMailEvent(events.MailReplied, input.Body.From, msg.ID, resolvedRig, &msg) + s.recordMailEvent(events.MailReplied, msg.From, msg.ID, resolvedRig, &msg) return &IndexOutput[mail.Message]{ Index: s.latestIndex(), diff --git a/internal/api/huma_handlers_orders.go b/internal/api/huma_handlers_orders.go index 75883376c..1c34a4e6d 100644 --- a/internal/api/huma_handlers_orders.go +++ b/internal/api/huma_handlers_orders.go @@ -11,6 +11,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/orders" ) @@ -64,11 +65,20 @@ type OrderCheckListOutput struct { } // humaHandleOrderCheck is the Huma-typed handler for GET /v0/orders/check. -func (s *Server) humaHandleOrderCheck(_ context.Context, _ *OrderCheckInput) (*OrderCheckListOutput, error) { +func (s *Server) humaHandleOrderCheck(_ context.Context, input *OrderCheckInput) (*OrderCheckListOutput, error) { aa := s.state.Orders() ep := s.state.EventProvider() + index := s.latestIndex() + cacheKey := cacheKeyFor("orders-check", input) + useResponseCache := !input.Fresh && !hasConditionOrder(aa) + if useResponseCache { + if body, ok := cachedResponseAs[OrderCheckListBody](s, cacheKey, index); ok { + return &OrderCheckListOutput{Body: body}, nil + } + } + now := time.Now() checks := make([]orderCheckResponse, 0, len(aa)) for _, a := range aa { @@ -76,8 +86,8 @@ func (s *Server) humaHandleOrderCheck(_ context.Context, _ *OrderCheckInput) (*O if err != nil { storeInfos = nil } - stores := storesFromWorkflowInfos(storeInfos) - result := orders.CheckTrigger(a, now, orders.LastRunAcrossStores(stores...), ep, orders.CursorAcrossStores(stores...)) + history, _ := orderHistoryBeadsAcrossStoreInfosForCheck(storeInfos, a.ScopedName(), 1, time.Time{}, input.Fresh) + result := checkOrderTriggerForAPI(a, now, history, storeInfos, ep, input.Fresh) cr := orderCheckResponse{ Name: a.Name, ScopedName: a.ScopedName(), @@ -89,8 +99,8 @@ func (s *Server) humaHandleOrderCheck(_ context.Context, _ *OrderCheckInput) (*O ts := result.LastRun.Format(time.RFC3339) cr.LastRun = &ts } - if results, err := orderHistoryBeadsAcrossStoreInfos(storeInfos, a.ScopedName(), 1, time.Time{}); err == nil && len(results) > 0 { - outcome := lastRunOutcomeFromLabels(results[0].bead.Labels) + if len(history) > 0 { + outcome := lastRunOutcomeFromLabels(history[0].bead.Labels) if outcome != "" { cr.LastRunOutcome = &outcome } @@ -104,9 +114,44 @@ func (s *Server) humaHandleOrderCheck(_ context.Context, _ *OrderCheckInput) (*O out := &OrderCheckListOutput{} out.Body.Checks = checks + if useResponseCache { + s.storeResponse(cacheKey, index, out.Body) + } return out, nil } +func hasConditionOrder(aa []orders.Order) bool { + for _, a := range aa { + if a.Trigger == "condition" { + return true + } + } + return false +} + +func checkOrderTriggerForAPI(a orders.Order, now time.Time, history []orderHistoryStoreBead, infos []workflowStoreInfo, ep events.Provider, fresh bool) orders.TriggerResult { + lastRunFn := func(string) (time.Time, error) { + if len(history) == 0 { + return time.Time{}, nil + } + return history[0].bead.CreatedAt, nil + } + var cursorFn orders.CursorFunc + if a.Trigger == "event" { + if fresh { + cursorFn = orders.CursorAcrossStores(storesFromWorkflowInfos(infos)...) + } else { + labelSets := make([][]string, 0, len(history)) + for _, row := range history { + labelSets = append(labelSets, row.bead.Labels) + } + cursor := orders.MaxSeqFromLabels(labelSets) + cursorFn = func(string) uint64 { return cursor } + } + } + return orders.CheckTrigger(a, now, lastRunFn, ep, cursorFn) +} + // orderCheckResponse is the response item for GET /v0/orders/check. type orderCheckResponse struct { Name string `json:"name"` @@ -345,6 +390,74 @@ func storesFromWorkflowInfos(infos []workflowStoreInfo) []beads.Store { return stores } +func orderHistoryBeadsAcrossStoreInfosForCheck(infos []workflowStoreInfo, scopedName string, limit int, beforeTime time.Time, fresh bool) ([]orderHistoryStoreBead, error) { + if fresh { + return orderHistoryBeadsAcrossStoreInfos(infos, scopedName, limit, beforeTime) + } + return orderHistoryBeadsAcrossStoreInfosCachedFirst(infos, scopedName, limit, beforeTime) +} + +func orderHistoryBeadsAcrossStoreInfosCachedFirst(infos []workflowStoreInfo, scopedName string, limit int, beforeTime time.Time) ([]orderHistoryStoreBead, error) { + if len(infos) == 0 { + return nil, errNoOrderStores + } + + label := "order-run:" + scopedName + seen := make(map[string]bool) + results := make([]orderHistoryStoreBead, 0) + for i, info := range infos { + if info.store == nil { + continue + } + query := beads.ListQuery{ + Label: label, + CreatedBefore: beforeTime, + Limit: limit, + IncludeClosed: true, + Sort: beads.SortCreatedDesc, + } + var ( + rows []beads.Bead + err error + ) + if cached, ok := info.store.(cachedListStore); ok { + var cacheOK bool + rows, cacheOK = cached.CachedList(query) + if !cacheOK { + rows, err = info.store.List(query) + } + } else { + rows, err = info.store.List(query) + } + if err != nil { + if i == 0 { + return nil, err + } + log.Printf("api: order history list failed for %s: %v", info.ref, err) + continue + } + for _, row := range rows { + if !beforeTime.IsZero() && !row.CreatedAt.Before(beforeTime) { + continue + } + key := info.ref + "\x00" + row.ID + if seen[key] { + continue + } + seen[key] = true + results = append(results, orderHistoryStoreBead{storeRef: info.ref, bead: row}) + } + } + + sort.SliceStable(results, func(i, j int) bool { + return results[i].bead.CreatedAt.After(results[j].bead.CreatedAt) + }) + if limit > 0 && len(results) > limit { + results = results[:limit] + } + return results, nil +} + func orderHistoryBeadsAcrossStoreInfos(infos []workflowStoreInfo, scopedName string, limit int, beforeTime time.Time) ([]orderHistoryStoreBead, error) { if len(infos) == 0 { return nil, errNoOrderStores diff --git a/internal/api/huma_handlers_patches.go b/internal/api/huma_handlers_patches.go index ec215ab8a..8ffb58248 100644 --- a/internal/api/huma_handlers_patches.go +++ b/internal/api/huma_handlers_patches.go @@ -222,7 +222,9 @@ func (s *Server) humaHandleProviderPatchSet(_ context.Context, input *ProviderPa patch := config.ProviderPatch{ Name: input.Body.Name, Command: input.Body.Command, + ACPCommand: input.Body.ACPCommand, Args: input.Body.Args, + ACPArgs: input.Body.ACPArgs, PromptMode: input.Body.PromptMode, PromptFlag: input.Body.PromptFlag, ReadyDelayMs: input.Body.ReadyDelayMs, diff --git a/internal/api/huma_handlers_providers.go b/internal/api/huma_handlers_providers.go index d5a9a82e3..9fd3132b3 100644 --- a/internal/api/huma_handlers_providers.go +++ b/internal/api/huma_handlers_providers.go @@ -140,7 +140,9 @@ func (s *Server) humaHandleProviderCreate(_ context.Context, input *ProviderCrea DisplayName: input.Body.DisplayName, Base: input.Body.Base, Command: input.Body.Command, + ACPCommand: input.Body.ACPCommand, Args: input.Body.Args, + ACPArgs: input.Body.ACPArgs, ArgsAppend: input.Body.ArgsAppend, PromptMode: input.Body.PromptMode, PromptFlag: input.Body.PromptFlag, @@ -172,7 +174,9 @@ func (s *Server) humaHandleProviderUpdate(_ context.Context, input *ProviderUpda patch := ProviderUpdate{ DisplayName: input.Body.DisplayName, Command: input.Body.Command, + ACPCommand: input.Body.ACPCommand, Args: input.Body.Args, + ACPArgs: input.Body.ACPArgs, ArgsAppend: input.Body.ArgsAppend, PromptMode: input.Body.PromptMode, PromptFlag: input.Body.PromptFlag, diff --git a/internal/api/huma_handlers_rigs.go b/internal/api/huma_handlers_rigs.go index fec52db8c..ee8886712 100644 --- a/internal/api/huma_handlers_rigs.go +++ b/internal/api/huma_handlers_rigs.go @@ -19,12 +19,11 @@ func (s *Server) humaHandleRigList(ctx context.Context, input *RigListInput) (*L cfg := s.state.Config() sp := s.state.SessionProvider() cityName := s.state.CityName() - store := s.state.CityBeadStore() wantGit := input.Git rigs := make([]rigResponse, 0, len(cfg.Rigs)) for _, rig := range cfg.Rigs { - resp := s.buildRigResponse(cfg, rig, store, sp, cityName, s.state.CityPath()) + resp := s.buildRigResponse(cfg, rig, sp, cityName, s.state.CityPath()) if wantGit { resp.Git = fetchGitStatus(rig.Path) } @@ -41,12 +40,11 @@ func (s *Server) humaHandleRigGet(_ context.Context, input *RigGetInput) (*Index name := input.Name cfg := s.state.Config() sp := s.state.SessionProvider() - store := s.state.CityBeadStore() wantGit := input.Git for _, rig := range cfg.Rigs { if rig.Name == name { - resp := s.buildRigResponse(cfg, rig, store, sp, s.state.CityName(), s.state.CityPath()) + resp := s.buildRigResponse(cfg, rig, sp, s.state.CityName(), s.state.CityPath()) if wantGit { resp.Git = fetchGitStatus(rig.Path) } diff --git a/internal/api/huma_handlers_sessions_command.go b/internal/api/huma_handlers_sessions_command.go index 6a1b16e05..56ca4a7b2 100644 --- a/internal/api/huma_handlers_sessions_command.go +++ b/internal/api/huma_handlers_sessions_command.go @@ -17,7 +17,7 @@ import ( "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/sessionlog" - workdirutil "github.com/gastownhall/gascity/internal/workdir" + "github.com/gastownhall/gascity/internal/worker" ) // Command-side session handlers (create, patch, submit, message, stop, kill, @@ -56,6 +56,10 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea } return nil, huma.Error500InternalServerError(err.Error()) } + transport, err = validateSessionTransport(resolved, transport, s.state.SessionProvider()) + if err != nil { + return nil, huma.Error503ServiceUnavailable(err.Error()) + } if len(body.Options) > 0 { if len(resolved.OptionsSchema) == 0 { @@ -74,12 +78,6 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea title = template } - resume := session.ProviderResume{ - ResumeFlag: resolved.ResumeFlag, - ResumeStyle: resolved.ResumeStyle, - ResumeCommand: resolved.ResumeCommand, - SessionIDFlag: resolved.SessionIDFlag, - } alias, err := session.ValidateAlias(body.Alias) if err != nil { return nil, humaSessionManagerError(err) @@ -88,27 +86,27 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea if cfg == nil { return nil, huma.Error500InternalServerError("no city config loaded") } - agentCfg, ok := resolveSessionTemplateAgent(cfg, template) - if !ok { - return nil, huma.Error500InternalServerError("resolved agent template disappeared: " + template) - } - if alias != "" && agentCfg.SupportsMultipleSessions() { - alias = workdirutil.SessionQualifiedName(s.state.CityPath(), agentCfg, cfg.Rigs, alias, "") - } - explicitName, err := sessionExplicitNameForCreate(agentCfg, alias) + createCtx, err := s.resolveAgentCreateContext(template, alias) if err != nil { - return nil, humaSessionManagerError(err) + return nil, huma.Error500InternalServerError(err.Error()) } - workDirQualifiedName := workdirutil.SessionQualifiedName(s.state.CityPath(), agentCfg, cfg.Rigs, alias, explicitName) - workDir, err = s.resolveSessionWorkDir(agentCfg, workDirQualifiedName) + agentCfg := createCtx.Agent + alias = createCtx.Alias + explicitName := createCtx.ExplicitName + workDirQualifiedName := createCtx.Identity + workDir = createCtx.WorkDir + + launchCommand, err := config.BuildProviderLaunchCommandWithoutOptions(s.state.CityPath(), resolved, transport) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } - - command := sessionCreateAgentCommand(resolved) + command := launchCommand.Command extraMeta := sessionTemplateOverridesMetadata(body.Options, body.Message) + mcpServers, err := s.sessionMCPServers(template, resolved.Name, workDirQualifiedName, workDir, transport, kind) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } - mgr := s.sessionManager(store) var info session.Info reservationIDs := []string{alias, explicitName} reserveConcreteIdentity := agentCfg.SupportsMultipleSessions() && strings.TrimSpace(workDirQualifiedName) != "" @@ -132,19 +130,34 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea } extraMeta["agent_name"] = workDirQualifiedName extraMeta["session_origin"] = "manual" - var createErr error - info, createErr = mgr.CreateAliasedBeadOnlyNamedWithMetadata( + if transport == "acp" { + var mcpMetaErr error + extraMeta, mcpMetaErr = session.WithStoredMCPMetadata(extraMeta, workDirQualifiedName, mcpServers) + if mcpMetaErr != nil { + return mcpMetaErr + } + } + resolvedCfg, cfgErr := resolvedSessionConfigForProvider( alias, explicitName, template, title, - command, - workDir, - resolved.Name, transport, - resume, extraMeta, + resolved, + command, + workDir, + mcpServers, ) + if cfgErr != nil { + return cfgErr + } + handle, handleErr := s.newResolvedWorkerSessionHandle(store, resolvedCfg) + if handleErr != nil { + return handleErr + } + var createErr error + info, createErr = handle.Create(ctx, worker.CreateModeDeferred) return createErr }) if err != nil { @@ -164,7 +177,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea if caps, capErr := s.sessionManager(store).SubmissionCapabilities(info.ID); capErr == nil { resp.SubmissionCapabilities = caps } - s.enrichSessionResponse(&resp, info, s.state.Config(), s.state.SessionProvider(), false, true) + s.enrichSessionResponse(&resp, info, s.state.Config(), s.state.SessionProvider(), false, true, true) out := &SessionCreateOutput{Status: http.StatusAccepted} out.Body = resp @@ -233,22 +246,43 @@ func (s *Server) humaCreateProviderSession(ctx context.Context, store beads.Stor if err != nil { return nil, humaSessionManagerError(err) } + mcpIdentity, err := providerSessionMCPIdentity(resolved.Name, alias) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } - launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, body.Options) + transport, err := providerSessionTransport(resolved, s.state.SessionProvider()) + if err != nil { + return nil, huma.Error503ServiceUnavailable(err.Error()) + } + launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, body.Options, transport) if err != nil { return nil, huma.Error400BadRequest(err.Error()) } command := launchCommand.Command + mcpServers, err := s.providerSessionMCPServers(resolved.Name, mcpIdentity, workDir, transport) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + extraMeta := map[string]string{ + "session_origin": "manual", + } + if transport == "acp" { + extraMeta, err = session.WithStoredMCPMetadata(extraMeta, mcpIdentity, mcpServers) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + } mgr := s.sessionManager(store) - hints := sessionCreateHints(resolved) + hints := sessionCreateHints(resolved, mcpServers) var info session.Info err = session.WithCitySessionAliasLock(s.state.CityPath(), alias, func() error { if aliasErr := session.EnsureAliasAvailableWithConfig(store, s.state.Config(), alias, ""); aliasErr != nil { return aliasErr } var createErr error - info, createErr = mgr.CreateAliasedNamedWithTransport( + info, createErr = mgr.CreateAliasedNamedWithTransportAndMetadata( ctx, alias, "", @@ -257,10 +291,11 @@ func (s *Server) humaCreateProviderSession(ctx context.Context, store beads.Stor command, workDir, resolved.Name, - "", + transport, resolved.Env, resume, hints, + extraMeta, ) return createErr }) @@ -292,7 +327,7 @@ func (s *Server) humaCreateProviderSession(ctx context.Context, store beads.Stor if caps, capErr := s.sessionManager(store).SubmissionCapabilities(info.ID); capErr == nil { resp.SubmissionCapabilities = caps } - s.enrichSessionResponse(&resp, info, s.state.Config(), s.state.SessionProvider(), false, true) + s.enrichSessionResponse(&resp, info, s.state.Config(), s.state.SessionProvider(), false, true, true) out := &SessionCreateOutput{Status: http.StatusCreated} out.Body = resp diff --git a/internal/api/huma_handlers_sessions_query.go b/internal/api/huma_handlers_sessions_query.go index 6d588046b..b4f6c1ae4 100644 --- a/internal/api/huma_handlers_sessions_query.go +++ b/internal/api/huma_handlers_sessions_query.go @@ -24,19 +24,18 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu } mgr := s.sessionManager(store) cfg := s.state.Config() - sp := s.state.SessionProvider() - sessions, err := mgr.List(input.State, input.Template) + all, err := listSessionBeadsForReadModel(store) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } + listResult := mgr.ListFullFromBeads(all, input.State, input.Template) + sessions := listResult.Sessions // Build bead index for reason enrichment. beadIndex := make(map[string]*beads.Bead) - if all, listErr := store.List(beads.ListQuery{Label: session.LabelSession}); listErr == nil { - for i := range all { - beadIndex[all[i].ID] = &all[i] - } + for i := range listResult.Beads { + beadIndex[listResult.Beads[i].ID] = &listResult.Beads[i] } wantPeek := input.Peek @@ -44,7 +43,7 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu items := make([]sessionResponse, len(sessions)) for i, sess := range sessions { items[i] = sessionResponseWithReason(sess, beadIndex[sess.ID], cfg, hasDeferredQueue) - s.enrichSessionResponse(&items[i], sess, cfg, sp, wantPeek, false) + s.enrichSessionResponse(&items[i], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false) } // Pagination support. @@ -109,7 +108,7 @@ func (s *Server) humaHandleSessionGet(_ context.Context, input *SessionGetInput) b, _ := store.Get(id) wantPeek := input.Peek resp := sessionResponseWithReason(info, &b, cfg, strings.TrimSpace(s.state.CityPath()) != "") - s.enrichSessionResponse(&resp, info, cfg, sp, wantPeek, true) + s.enrichSessionResponse(&resp, info, cfg, sp, wantPeek, true, true) return &IndexOutput[sessionResponse]{ Index: s.latestIndex(), Body: resp, diff --git a/internal/api/huma_types_orders.go b/internal/api/huma_types_orders.go index 85e8429e4..3af423a3e 100644 --- a/internal/api/huma_types_orders.go +++ b/internal/api/huma_types_orders.go @@ -28,6 +28,7 @@ type OrderGetInput struct { // OrderCheckInput is the Huma input for GET /v0/city/{cityName}/orders/check. type OrderCheckInput struct { CityScope + Fresh bool `query:"fresh" required:"false" doc:"Bypass cached order-check responses and cached order history."` } // OrderHistoryInput is the Huma input for GET /v0/city/{cityName}/orders/history. diff --git a/internal/api/huma_types_patches.go b/internal/api/huma_types_patches.go index a5d7bfcbf..d4215cb13 100644 --- a/internal/api/huma_types_patches.go +++ b/internal/api/huma_types_patches.go @@ -109,7 +109,9 @@ type ProviderPatchSetInput struct { Body struct { Name string `json:"name,omitempty" doc:"Provider name."` Command *string `json:"command,omitempty" doc:"Override command binary."` + ACPCommand *string `json:"acp_command,omitempty" doc:"Override ACP transport command binary."` Args []string `json:"args,omitempty" doc:"Override command arguments."` + ACPArgs []string `json:"acp_args,omitempty" doc:"Override ACP transport command arguments."` PromptMode *string `json:"prompt_mode,omitempty" doc:"Override prompt delivery mode."` PromptFlag *string `json:"prompt_flag,omitempty" doc:"Override prompt flag."` ReadyDelayMs *int `json:"ready_delay_ms,omitempty" doc:"Override ready delay in milliseconds."` diff --git a/internal/api/huma_types_providers.go b/internal/api/huma_types_providers.go index 49d8028b2..6b62b235b 100644 --- a/internal/api/huma_types_providers.go +++ b/internal/api/huma_types_providers.go @@ -61,7 +61,9 @@ type ProviderCreateInput struct { DisplayName string `json:"display_name,omitempty" doc:"Human-readable display name."` Base *string `json:"base,omitempty" doc:"Optional provider base for inheritance."` Command string `json:"command,omitempty" doc:"Provider command binary. Omit for base-only descendants."` + ACPCommand string `json:"acp_command,omitempty" doc:"ACP transport command binary override."` Args []string `json:"args,omitempty" doc:"Command arguments."` + ACPArgs []string `json:"acp_args,omitempty" doc:"ACP transport command arguments override."` ArgsAppend []string `json:"args_append,omitempty" doc:"Arguments appended after inherited/base args."` PromptMode string `json:"prompt_mode,omitempty" doc:"Prompt delivery mode."` PromptFlag string `json:"prompt_flag,omitempty" doc:"Flag for prompt delivery."` @@ -79,7 +81,9 @@ type ProviderUpdateInput struct { DisplayName *string `json:"display_name,omitempty" doc:"Human-readable display name."` Base *string `json:"base,omitempty" doc:"Provider base for inheritance."` Command *string `json:"command,omitempty" doc:"Provider command binary."` + ACPCommand *string `json:"acp_command,omitempty" doc:"ACP transport command binary override."` Args []string `json:"args,omitempty" doc:"Command arguments."` + ACPArgs []string `json:"acp_args,omitempty" doc:"ACP transport command arguments override."` ArgsAppend []string `json:"args_append,omitempty" doc:"Arguments appended after inherited/base args."` PromptMode *string `json:"prompt_mode,omitempty" doc:"Prompt delivery mode."` PromptFlag *string `json:"prompt_flag,omitempty" doc:"Flag for prompt delivery."` diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 377d0abcf..45a78ea76 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -674,6 +674,15 @@ "AnnotatedProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4473,6 +4482,20 @@ "ProviderCreateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -4598,6 +4621,21 @@ "ProviderPatch": { "additionalProperties": false, "properties": { + "ACPArgs": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "ACPCommand": { + "type": [ + "string", + "null" + ] + }, "Args": { "items": { "type": "string" @@ -4679,7 +4717,9 @@ "Name", "Base", "Command", + "ACPCommand", "Args", + "ACPArgs", "ArgsAppend", "OptionsSchemaMerge", "PromptMode", @@ -4694,6 +4734,20 @@ "ProviderPatchSetInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "Override ACP transport command arguments.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "Override ACP transport command binary.", + "type": "string" + }, "args": { "description": "Override command arguments.", "items": { @@ -4839,6 +4893,15 @@ "ProviderResponse": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4890,6 +4953,15 @@ "ProviderSpecJSON": { "additionalProperties": false, "properties": { + "acp_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "acp_command": { + "type": "string" + }, "args": { "items": { "type": "string" @@ -4927,6 +4999,20 @@ "ProviderUpdateInputBody": { "additionalProperties": false, "properties": { + "acp_args": { + "description": "ACP transport command arguments override.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "acp_command": { + "description": "ACP transport command binary override.", + "type": "string" + }, "args": { "description": "Command arguments.", "items": { @@ -17657,6 +17743,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } } ], "responses": { diff --git a/internal/api/read_model_no_get_test.go b/internal/api/read_model_no_get_test.go new file mode 100644 index 000000000..c65856789 --- /dev/null +++ b/internal/api/read_model_no_get_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/runtime" +) + +type getCountingStore struct { + beads.Store + gets atomic.Int64 +} + +func (s *getCountingStore) Get(id string) (beads.Bead, error) { + s.gets.Add(1) + return s.Store.Get(id) +} + +func TestSessionListUsesLoadedSessionBeadsWithoutPerSessionGet(t *testing.T) { + fs := newSessionFakeState(t) + createTestSession(t, fs.cityBeadStore, fs.sp, "Session A") + createTestSession(t, fs.cityBeadStore, fs.sp, "Session B") + counting := &getCountingStore{Store: fs.cityBeadStore} + fs.cityBeadStore = counting + + h := newTestCityHandler(t, fs) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/sessions"), nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := counting.gets.Load(); got != 0 { + t.Fatalf("store.Get calls = %d, want 0 for session list read model", got) + } +} + +func TestSessionListDoesNotProbePendingInteractions(t *testing.T) { + fs := newSessionFakeState(t) + createTestSession(t, fs.cityBeadStore, fs.sp, "Session A") + createTestSession(t, fs.cityBeadStore, fs.sp, "Session B") + fs.sp.Calls = nil + + h := newTestCityHandler(t, fs) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/sessions"), nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + for _, call := range fs.sp.Calls { + if call.Method == "Pending" { + t.Fatalf("session list called Pending for %s; calls=%#v", call.Name, fs.sp.Calls) + } + } +} + +func TestRigListUsesProviderStateWithoutSessionStoreGet(t *testing.T) { + state := newFakeState(t) + counting := &getCountingStore{Store: beads.NewMemStore()} + state.cityBeadStore = counting + if err := state.sp.Start(context.Background(), "myrig--worker", runtime.Config{}); err != nil { + t.Fatalf("start provider session: %v", err) + } + + h := newTestCityHandler(t, state) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, cityURL(state, "/rigs"), nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var resp struct { + Items []rigResponse `json:"items"` + Total int `json:"total"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Total != 1 || len(resp.Items) != 1 { + t.Fatalf("rig response total/items = %d/%d, want 1/1", resp.Total, len(resp.Items)) + } + if resp.Items[0].RunningCount != 1 { + t.Fatalf("RunningCount = %d, want 1", resp.Items[0].RunningCount) + } + if got := counting.gets.Load(); got != 0 { + t.Fatalf("store.Get calls = %d, want 0 for rig list read model", got) + } +} diff --git a/internal/api/runtime_observation.go b/internal/api/runtime_observation.go new file mode 100644 index 000000000..e9f2741e9 --- /dev/null +++ b/internal/api/runtime_observation.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + "fmt" + "strings" + + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" + "github.com/gastownhall/gascity/internal/worker" +) + +func observeProviderSession(sp runtime.Provider, sessionName string, processNames []string) worker.LiveObservation { + sessionName = strings.TrimSpace(sessionName) + obs := worker.LiveObservation{SessionName: sessionName} + if sp == nil || sessionName == "" { + return obs + } + obs.Running = sp.IsRunning(sessionName) + if suspended, err := sp.GetMeta(sessionName, "suspended"); err == nil && strings.TrimSpace(suspended) == "true" { + obs.Suspended = true + } + if sessionID, err := sp.GetMeta(sessionName, "GC_SESSION_ID"); err == nil { + obs.RuntimeSessionID = strings.TrimSpace(sessionID) + } + if !obs.Running { + return obs + } + obs.Alive = sp.ProcessAlive(sessionName, processNames) + obs.Attached = sp.IsAttached(sessionName) + if lastActive, err := sp.GetLastActivity(sessionName); err == nil && !lastActive.IsZero() { + last := lastActive + obs.LastActivity = &last + } + return obs +} + +type providerSessionResponseHandle struct { + provider runtime.Provider + sessionName string + providerName string +} + +func newProviderSessionResponseHandle(sp runtime.Provider, sessionName, providerName string) sessionResponseHandle { + sessionName = strings.TrimSpace(sessionName) + if sp == nil || sessionName == "" { + return nil + } + return providerSessionResponseHandle{ + provider: sp, + sessionName: sessionName, + providerName: strings.TrimSpace(providerName), + } +} + +func (h providerSessionResponseHandle) State(context.Context) (worker.State, error) { + state := worker.State{ + SessionName: h.sessionName, + Provider: h.providerName, + } + if h.provider == nil || !h.provider.IsRunning(h.sessionName) { + state.Phase = worker.PhaseStopped + return state, nil + } + state.Phase = worker.PhaseReady + return state, nil +} + +func (h providerSessionResponseHandle) Peek(_ context.Context, lines int) (string, error) { + if h.provider == nil || !h.provider.IsRunning(h.sessionName) { + return "", fmt.Errorf("%w: %s", session.ErrSessionInactive, h.sessionName) + } + return h.provider.Peek(h.sessionName, lines) +} diff --git a/internal/api/session_create_agent.go b/internal/api/session_create_agent.go new file mode 100644 index 000000000..8712afd8b --- /dev/null +++ b/internal/api/session_create_agent.go @@ -0,0 +1,47 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/gastownhall/gascity/internal/config" + workdirutil "github.com/gastownhall/gascity/internal/workdir" +) + +type agentCreateContext struct { + Agent config.Agent + Alias string + ExplicitName string + Identity string + WorkDir string +} + +func (s *Server) resolveAgentCreateContext(template, alias string) (agentCreateContext, error) { + cfg := s.state.Config() + if cfg == nil { + return agentCreateContext{}, fmt.Errorf("no city config loaded") + } + agentCfg, ok := resolveSessionTemplateAgent(cfg, template) + if !ok { + return agentCreateContext{}, fmt.Errorf("resolved agent template disappeared: %s", template) + } + if alias != "" && agentCfg.SupportsMultipleSessions() { + alias = workdirutil.SessionQualifiedName(s.state.CityPath(), agentCfg, cfg.Rigs, alias, "") + } + explicitName, err := sessionExplicitNameForCreate(agentCfg, alias) + if err != nil { + return agentCreateContext{}, err + } + identity := workdirutil.SessionQualifiedName(s.state.CityPath(), agentCfg, cfg.Rigs, alias, explicitName) + workDir, err := s.resolveSessionWorkDir(agentCfg, identity) + if err != nil { + return agentCreateContext{}, err + } + return agentCreateContext{ + Agent: agentCfg, + Alias: strings.TrimSpace(alias), + ExplicitName: explicitName, + Identity: identity, + WorkDir: workDir, + }, nil +} diff --git a/internal/api/session_manager.go b/internal/api/session_manager.go index c7aa80231..afea441a8 100644 --- a/internal/api/session_manager.go +++ b/internal/api/session_manager.go @@ -1,7 +1,10 @@ package api import ( + "strings" + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/session" ) @@ -10,11 +13,52 @@ func (s *Server) sessionManager(store beads.Store) *session.Manager { if cfg == nil { return session.NewManagerWithCityPath(store, s.state.SessionProvider(), s.state.CityPath()) } - return session.NewManagerWithTransportResolverAndCityPath(store, s.state.SessionProvider(), s.state.CityPath(), func(template string) string { - agentCfg, ok := resolveSessionTemplateAgent(cfg, template) - if !ok { - return "" + return session.NewManagerWithTransportPolicyResolverAndCityPath( + store, + s.state.SessionProvider(), + s.state.CityPath(), + func(template, provider string) (string, bool) { + return configuredSessionTransportResolution(cfg, template, provider) + }, + ) +} + +func configuredSessionTransport(cfg *config.City, template, provider string) string { + transport, _ := configuredSessionTransportResolution(cfg, template, provider) + return transport +} + +func configuredSessionTransportResolution(cfg *config.City, template, provider string) (string, bool) { + if cfg == nil { + return "", false + } + if agentCfg, ok := resolveSessionTemplateAgent(cfg, template); ok { + resolved, err := config.ResolveProvider( + &agentCfg, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return strings.TrimSpace(agentCfg.Session), false } - return agentCfg.Session - }) + return config.ResolveSessionCreateTransport(agentCfg.Session, resolved), false + } + provider = strings.TrimSpace(provider) + if provider == "" { + provider = strings.TrimSpace(template) + } + if provider == "" { + return "", false + } + resolved, err := config.ResolveProvider( + &config.Agent{Provider: provider}, + &cfg.Workspace, + cfg.Providers, + func(name string) (string, error) { return name, nil }, + ) + if err != nil { + return "", false + } + return strings.TrimSpace(resolved.ProviderSessionCreateTransport()), false } diff --git a/internal/api/session_resolution.go b/internal/api/session_resolution.go index da195f85b..4540e6f63 100644 --- a/internal/api/session_resolution.go +++ b/internal/api/session_resolution.go @@ -275,7 +275,11 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b return "", err } - resolved, _, transport, qualifiedTemplate, err := s.resolveSessionTemplate(spec.Agent.QualifiedName()) + resolved, _, transport, qualifiedTemplate, err := s.resolveSessionTemplateForCreate(spec.Agent.QualifiedName()) + if err != nil { + return "", err + } + transport, err = validateSessionTransport(resolved, transport, s.state.SessionProvider()) if err != nil { return "", err } @@ -285,7 +289,7 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b if err != nil { return "", err } - launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, nil) + launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, nil, transport) if err != nil { return "", err } @@ -308,7 +312,17 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b if resolved.BuiltinAncestor != "" && resolved.BuiltinAncestor != resolved.Name { extraMeta["builtin_ancestor"] = resolved.BuiltinAncestor } - hints := sessionCreateHints(resolved) + mcpServers, err := s.sessionMCPServers(qualifiedTemplate, resolved.Name, spec.Identity, workDir, transport, "") + if err != nil { + return "", err + } + if transport == "acp" { + extraMeta, err = session.WithStoredMCPMetadata(extraMeta, spec.Identity, mcpServers) + if err != nil { + return "", err + } + } + hints := sessionCreateHints(resolved, mcpServers) var info session.Info err = session.WithCitySessionIdentifierLocks(s.state.CityPath(), []string{spec.Identity, spec.SessionName}, func() error { if err := session.EnsureAliasAvailableWithConfigForOwner(store, s.state.Config(), spec.Identity, "", spec.Identity); err != nil { diff --git a/internal/api/session_resolved_config.go b/internal/api/session_resolved_config.go index bd7621aee..8e746f111 100644 --- a/internal/api/session_resolved_config.go +++ b/internal/api/session_resolved_config.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/worker" ) @@ -13,10 +14,28 @@ func resolvedSessionConfigForProvider( metadata map[string]string, resolved *config.ResolvedProvider, command, workDir string, + mcpServers []runtime.MCPServerConfig, ) (worker.ResolvedSessionConfig, error) { if resolved == nil { return worker.ResolvedSessionConfig{}, fmt.Errorf("%w: resolved provider is required", worker.ErrHandleConfig) } + if transport == "acp" { + var err error + metadata, err = session.WithStoredMCPMetadata( + metadata, + firstNonEmptyString(metadata[session.MCPIdentityMetadataKey], metadata["agent_name"]), + mcpServers, + ) + if err != nil { + return worker.ResolvedSessionConfig{}, err + } + } + // Use the ACP-specific command when the session uses ACP transport, + // falling back to the default command for tmux sessions. + resolvedCommand := resolved.CommandString() + if transport == "acp" { + resolvedCommand = resolved.ACPCommandString() + } return worker.NormalizeResolvedSessionConfig(worker.ResolvedSessionConfig{ Alias: alias, ExplicitName: explicitName, @@ -25,7 +44,7 @@ func resolvedSessionConfigForProvider( Transport: transport, Metadata: metadata, Runtime: worker.ResolvedRuntime{ - Command: firstNonEmptyString(command, resolved.CommandString(), resolved.Name), + Command: firstNonEmptyString(command, resolvedCommand, resolved.Name), WorkDir: workDir, Provider: resolved.Name, SessionEnv: resolved.Env, @@ -35,7 +54,7 @@ func resolvedSessionConfigForProvider( ResumeCommand: resolved.ResumeCommand, SessionIDFlag: resolved.SessionIDFlag, }, - Hints: sessionCreateHints(resolved), + Hints: sessionCreateHints(resolved, mcpServers), }, }) } diff --git a/internal/api/session_resolved_config_test.go b/internal/api/session_resolved_config_test.go index f1a84578d..106e5d24f 100644 --- a/internal/api/session_resolved_config_test.go +++ b/internal/api/session_resolved_config_test.go @@ -4,11 +4,21 @@ import ( "testing" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" ) func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) { - metadata := map[string]string{"session_origin": "named"} + metadata := map[string]string{ + "session_origin": "named", + "agent_name": "myrig/worker-adhoc-123", + } env := map[string]string{"API_TOKEN": "present"} + mcpServers := []runtime.MCPServerConfig{{ + Name: "filesystem", + Command: "/bin/mcp", + Args: []string{"--stdio"}, + }} resolved := &config.ResolvedProvider{ Name: "stub", Command: "/bin/echo", @@ -33,6 +43,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) { resolved, "", "/tmp/workdir", + mcpServers, ) if err != nil { t.Fatalf("resolvedSessionConfigForProvider: %v", err) @@ -53,9 +64,21 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) { if got, want := cfg.Runtime.Hints.ReadyPromptPrefix, "stub-ready>"; got != want { t.Fatalf("Runtime.Hints.ReadyPromptPrefix = %q, want %q", got, want) } + if len(cfg.Runtime.Hints.MCPServers) != 1 { + t.Fatalf("Runtime.Hints.MCPServers len = %d, want 1", len(cfg.Runtime.Hints.MCPServers)) + } + if got, want := cfg.Runtime.Hints.MCPServers[0].Name, "filesystem"; got != want { + t.Fatalf("Runtime.Hints.MCPServers[0].Name = %q, want %q", got, want) + } if got, want := cfg.Runtime.Resume.SessionIDFlag, "--session-id"; got != want { t.Fatalf("Runtime.Resume.SessionIDFlag = %q, want %q", got, want) } + if got, want := cfg.Metadata[session.MCPIdentityMetadataKey], "myrig/worker-adhoc-123"; got != want { + t.Fatalf("Metadata[mcp_identity] = %q, want %q", got, want) + } + if got := cfg.Metadata[session.MCPServersSnapshotMetadataKey]; got == "" { + t.Fatal("Metadata[mcp_servers_snapshot] = empty, want persisted snapshot") + } metadata["session_origin"] = "mutated" env["API_TOKEN"] = "mutated" @@ -78,7 +101,38 @@ func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) { nil, "", "/tmp/workdir", + nil, ); err == nil { t.Fatal("resolvedSessionConfigForProvider() error = nil, want error") } } + +func TestResolvedSessionConfigForProviderSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) { + cfg, err := resolvedSessionConfigForProvider( + "worker", + "", + "myrig/worker", + "Worker", + "", + map[string]string{ + "session_origin": "manual", + "agent_name": "myrig/worker-adhoc-123", + }, + &config.ResolvedProvider{ + Name: "stub", + Command: "/bin/echo", + }, + "", + "/tmp/workdir", + nil, + ) + if err != nil { + t.Fatalf("resolvedSessionConfigForProvider: %v", err) + } + if got := cfg.Metadata[session.MCPIdentityMetadataKey]; got != "" { + t.Fatalf("Metadata[mcp_identity] = %q, want empty for tmux transport", got) + } + if got := cfg.Metadata[session.MCPServersSnapshotMetadataKey]; got != "" { + t.Fatalf("Metadata[mcp_servers_snapshot] = %q, want empty for tmux transport", got) + } +} diff --git a/internal/api/session_runtime.go b/internal/api/session_runtime.go index 3be2da5b9..d13088c19 100644 --- a/internal/api/session_runtime.go +++ b/internal/api/session_runtime.go @@ -7,12 +7,15 @@ import ( "strings" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/materialize" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" workdirutil "github.com/gastownhall/gascity/internal/workdir" "github.com/gastownhall/gascity/internal/worker" ) +var errAmbiguousLegacyACPTransport = errors.New("legacy session transport is ambiguous") + func (s *Server) sessionLogPaths() []string { if s.sessionLogSearchPaths != nil { return s.sessionLogSearchPaths @@ -24,16 +27,17 @@ func (s *Server) sessionLogPaths() []string { return worker.MergeSearchPaths(cfg.Daemon.ObservePaths) } -func sessionCreateHints(resolved *config.ResolvedProvider) runtime.Config { +func sessionCreateHints(resolved *config.ResolvedProvider, mcpServers []runtime.MCPServerConfig) runtime.Config { return runtime.Config{ ReadyPromptPrefix: resolved.ReadyPromptPrefix, ReadyDelayMs: resolved.ReadyDelayMs, ProcessNames: resolved.ProcessNames, EmitsPermissionWarning: resolved.EmitsPermissionWarning, + MCPServers: mcpServers, } } -func sessionResumeHints(resolved *config.ResolvedProvider, workDir string) runtime.Config { +func sessionResumeHints(resolved *config.ResolvedProvider, workDir string, mcpServers []runtime.MCPServerConfig) runtime.Config { return runtime.Config{ WorkDir: workDir, ReadyPromptPrefix: resolved.ReadyPromptPrefix, @@ -41,7 +45,101 @@ func sessionResumeHints(resolved *config.ResolvedProvider, workDir string) runti ProcessNames: resolved.ProcessNames, EmitsPermissionWarning: resolved.EmitsPermissionWarning, Env: resolved.Env, + MCPServers: mcpServers, + } +} + +func resumeSessionIdentity(info session.Info, metadata map[string]string) string { + if metadata != nil { + if identity := strings.TrimSpace(metadata[session.MCPIdentityMetadataKey]); identity != "" { + return identity + } + } + return firstNonEmptyString(info.AgentName, info.Alias, info.Template, info.Provider) +} + +func (s *Server) resumeSessionMCPServers(info session.Info, metadata map[string]string, resolved *config.ResolvedProvider, workDir, transport string) ([]runtime.MCPServerConfig, error) { + if resolved == nil { + return nil, nil } + mcpServers, err := s.sessionMCPServers( + info.Template, + firstNonEmptyString(info.Provider, resolved.Name), + resumeSessionIdentity(info, metadata), + workDir, + transport, + s.sessionKind(info.ID), + ) + if err == nil { + return mcpServers, nil + } + runtimeSnapshot, loadErr := session.LoadRuntimeMCPServersSnapshot(s.state.CityPath(), info.ID) + if loadErr != nil { + return nil, loadErr + } + if len(runtimeSnapshot) > 0 { + return runtimeSnapshot, nil + } + stored, decodeErr := session.DecodeMCPServersSnapshot(metadata[session.MCPServersSnapshotMetadataKey]) + if decodeErr != nil { + return nil, fmt.Errorf("decoding stored MCP snapshot: %w", decodeErr) + } + return session.SanitizeStoredMCPSnapshotForResume(stored), nil +} + +func (s *Server) providerSessionMCPServers(providerName, identity, workDir, transport string) ([]runtime.MCPServerConfig, error) { + cfg := s.state.Config() + if cfg == nil || strings.TrimSpace(workDir) == "" || strings.TrimSpace(transport) != "acp" { + return nil, nil + } + synthetic := &config.Agent{Provider: providerName} + catalog, err := materialize.EffectiveMCPForSession(cfg, s.state.CityPath(), synthetic, firstNonEmptyString(identity, providerName), workDir) + if err != nil { + return nil, fmt.Errorf("loading effective MCP: %w", err) + } + return materialize.RuntimeMCPServers(catalog.Servers), nil +} + +func (s *Server) sessionMCPServers(template, providerName, identity, workDir, transport, sessionKind string) ([]runtime.MCPServerConfig, error) { + cfg := s.state.Config() + if cfg == nil || strings.TrimSpace(workDir) == "" || strings.TrimSpace(transport) != "acp" { + return nil, nil + } + if sessionKind != "provider" { + if agentCfg, ok := resolveSessionTemplateAgent(cfg, template); ok { + catalog, err := materialize.EffectiveMCPForSession( + cfg, + s.state.CityPath(), + &agentCfg, + firstNonEmptyString(identity, template), + workDir, + ) + if err != nil { + return nil, fmt.Errorf("loading effective MCP: %w", err) + } + return materialize.RuntimeMCPServers(catalog.Servers), nil + } + } + return s.providerSessionMCPServers(firstNonEmptyString(providerName, template), identity, workDir, transport) +} + +func (s *Server) sessionMetadata(sessionID string) map[string]string { + store := s.state.CityBeadStore() + if store == nil || strings.TrimSpace(sessionID) == "" { + return nil + } + bead, err := store.Get(sessionID) + if err != nil { + return nil + } + return bead.Metadata +} + +func providerSessionMCPIdentity(providerName, alias string) (string, error) { + if alias = strings.TrimSpace(alias); alias != "" { + return alias, nil + } + return session.GenerateAdhocIdentity(providerName) } func sessionExplicitNameForCreate(agentCfg config.Agent, alias string) (string, error) { @@ -77,7 +175,7 @@ func (s *Server) resolveSessionWorkDir(agentCfg config.Agent, qualifiedName stri // agent name that matches exactly one configured agent. Keeps the // two-phase lookup out of the handler. func (s *Server) resolveSessionTemplateWithBareNameFallback(name string) (*config.ResolvedProvider, string, string, string, error) { - resolved, workDir, transport, template, err := s.resolveSessionTemplate(name) + resolved, workDir, transport, template, err := s.resolveSessionTemplateForCreate(name) if err == nil { return resolved, workDir, transport, template, nil } @@ -88,9 +186,30 @@ func (s *Server) resolveSessionTemplateWithBareNameFallback(name string) (*confi if !ok { return nil, "", "", "", err } - return s.resolveSessionTemplate(agentCfg.QualifiedName()) + return s.resolveSessionTemplateForCreate(agentCfg.QualifiedName()) +} + +func (s *Server) resolveSessionTemplateForCreate(template string) (*config.ResolvedProvider, string, string, string, error) { + cfg := s.state.Config() + if cfg == nil { + return nil, "", "", "", errors.New("no city config loaded") + } + agentCfg, ok := resolveSessionTemplateAgent(cfg, template) + if !ok { + return nil, "", "", "", errSessionTemplateNotFound + } + resolved, err := config.ResolveProvider(&agentCfg, &cfg.Workspace, cfg.Providers, exec.LookPath) + if err != nil { + return nil, "", "", "", err + } + workDir, err := s.resolveSessionWorkDir(agentCfg, agentCfg.QualifiedName()) + if err != nil { + return nil, "", "", "", err + } + return resolved, workDir, config.ResolveSessionCreateTransport(agentCfg.Session, resolved), agentCfg.QualifiedName(), nil } +//nolint:unparam // kept as a focused test helper even though current call sites use one template shape. func (s *Server) resolveSessionTemplate(template string) (*config.ResolvedProvider, string, string, string, error) { cfg := s.state.Config() if cfg == nil { @@ -108,37 +227,76 @@ func (s *Server) resolveSessionTemplate(template string) (*config.ResolvedProvid if err != nil { return nil, "", "", "", err } - return resolved, workDir, agentCfg.Session, agentCfg.QualifiedName(), nil + return resolved, workDir, config.ResolveSessionCreateTransport(agentCfg.Session, resolved), agentCfg.QualifiedName(), nil } -func (s *Server) buildSessionResume(info session.Info) (string, runtime.Config) { +func (s *Server) buildSessionResume(info session.Info) (string, runtime.Config, error) { cmd := session.BuildResumeCommand(info) - resolved, workDir := s.resolveSessionRuntime(info) + metadata := s.sessionMetadata(info.ID) + resolved, workDir, transport, ambiguous := s.resolveSessionRuntimeWithMetadata(info, metadata) if resolved == nil { - return cmd, runtime.Config{WorkDir: info.WorkDir} + return cmd, runtime.Config{WorkDir: info.WorkDir}, nil + } + if ambiguous { + return "", runtime.Config{}, fmt.Errorf("%w: recreate the stopped session or resume it while ACP metadata can still be persisted", errAmbiguousLegacyACPTransport) + } + mcpServers, err := s.resumeSessionMCPServers(info, metadata, resolved, firstNonEmptyString(workDir, info.WorkDir), transport) + if err != nil { + return "", runtime.Config{}, err } resolvedInfo := info - if command, err := s.resolvedSessionRuntimeCommand(resolved, info.Command); err == nil { + if command, err := s.resolvedSessionRuntimeCommand(resolved, transport, info.Command, metadata); err == nil { resolvedInfo.Command = command } else { - resolvedInfo.Command = firstNonEmptyString(info.Command, resolved.CommandString(), resolved.Name) + resolvedInfo.Command = fallbackSessionRuntimeCommand(resolved, transport, info.Command, info.Provider) } resolvedInfo.Provider = resolved.Name + resolvedInfo.Transport = transport resolvedInfo.ResumeFlag = resolved.ResumeFlag resolvedInfo.ResumeStyle = resolved.ResumeStyle resolvedInfo.ResumeCommand = resolved.ResumeCommand - return session.BuildResumeCommand(resolvedInfo), sessionResumeHints(resolved, workDir) + return session.BuildResumeCommand(resolvedInfo), sessionResumeHints(resolved, workDir, mcpServers), nil } -func (s *Server) resolvedSessionRuntimeCommand(resolved *config.ResolvedProvider, storedCommand string) (string, error) { - if command := strings.TrimSpace(storedCommand); shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) { - return command, nil +func (s *Server) resolvedSessionRuntimeCommand(resolved *config.ResolvedProvider, transport, storedCommand string, metadata map[string]string) (string, error) { + configuredCommand := configuredSessionRuntimeCommand(resolved, transport) + if configuredCommand == "" { + if command := strings.TrimSpace(storedCommand); command != "" { + return command, nil + } + return "", fmt.Errorf("resolved provider %q has no launch command", resolved.Name) } - launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, nil) + optionOverrides, err := session.ParseTemplateOverrides(metadata) + if err != nil { + return "", fmt.Errorf("parsing template overrides: %w", err) + } + launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, optionOverrides, transport) if err != nil { return "", fmt.Errorf("building provider launch command: %w", err) } - return firstNonEmptyString(launchCommand.Command, resolved.CommandString(), resolved.Name), nil + desiredCommand := firstNonEmptyString(launchCommand.Command, configuredCommand, resolved.Name) + if command := strings.TrimSpace(storedCommand); shouldPreserveStoredRuntimeCommandForTransport(command, desiredCommand, transport, optionOverrides) { + return command, nil + } + return desiredCommand, nil +} + +func configuredSessionRuntimeCommand(resolved *config.ResolvedProvider, transport string) string { + if resolved == nil { + return "" + } + if transport == "acp" && (strings.TrimSpace(resolved.ACPCommand) != "" || resolved.ACPArgs != nil) { + return strings.TrimSpace(resolved.ACPCommandString()) + } + if strings.TrimSpace(resolved.Command) != "" { + return strings.TrimSpace(resolved.CommandString()) + } + return "" +} + +func fallbackSessionRuntimeCommand(resolved *config.ResolvedProvider, transport, storedCommand, fallbackProvider string) string { + resolvedCommand := configuredSessionRuntimeCommand(resolved, transport) + return firstNonEmptyString(storedCommand, resolvedCommand, fallbackProvider, resolved.Name) } func shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand string) bool { @@ -150,24 +308,70 @@ func shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand string) b if resolvedCommand == "" { return true } - return storedCommand == resolvedCommand || strings.HasPrefix(storedCommand, resolvedCommand+" ") + // A bare stored command (just the provider binary) lacks schema + // defaults like --dangerously-skip-permissions and the --settings + // path. Rebuild from the current config instead of preserving it. + // See #799: pool-agent sessions resumed through the control- + // dispatcher path wedged on interactive permission prompts because + // the bare stored command was preserved without re-injecting flags. + if storedCommand == resolvedCommand { + return false + } + return strings.HasPrefix(storedCommand, resolvedCommand+" ") +} + +func shouldPreserveStoredRuntimeCommandForTransport(storedCommand, resolvedCommand, _ string, optionOverrides map[string]string) bool { + if shouldPreserveStoredRuntimeCommand(storedCommand, resolvedCommand) { + return true + } + if len(optionOverrides) == 0 && storedCommandHasSettingsArg(storedCommand) && sameRuntimeCommandExecutable(storedCommand, resolvedCommand) { + return true + } + return false } -func (s *Server) resolveWorkerSessionRuntime(info session.Info, _ string) (*worker.ResolvedRuntime, error) { - resolved, workDir := s.resolveSessionRuntime(info) +func sameRuntimeCommandExecutable(storedCommand, resolvedCommand string) bool { + storedFields := strings.Fields(strings.TrimSpace(storedCommand)) + resolvedFields := strings.Fields(strings.TrimSpace(resolvedCommand)) + if len(storedFields) == 0 || len(resolvedFields) == 0 { + return false + } + return storedFields[0] == resolvedFields[0] +} + +func storedCommandHasSettingsArg(command string) bool { + return strings.Contains(" "+strings.TrimSpace(command)+" ", " --settings ") +} + +func (s *Server) resolveWorkerSessionRuntime(info session.Info) (*worker.ResolvedRuntime, error) { + return s.resolveWorkerSessionRuntimeWithMetadata(info, "", nil) +} + +func (s *Server) resolveWorkerSessionRuntimeWithMetadata(info session.Info, _ string, metadata map[string]string) (*worker.ResolvedRuntime, error) { + if metadata == nil { + metadata = s.sessionMetadata(info.ID) + } + resolved, workDir, transport, ambiguous := s.resolveSessionRuntimeWithMetadata(info, metadata) if resolved == nil { return nil, nil } - command, err := s.resolvedSessionRuntimeCommand(resolved, info.Command) + if ambiguous { + return nil, fmt.Errorf("%w: recreate the stopped session or resume it while ACP metadata can still be persisted", errAmbiguousLegacyACPTransport) + } + mcpServers, err := s.resumeSessionMCPServers(info, metadata, resolved, firstNonEmptyString(workDir, info.WorkDir), transport) if err != nil { return nil, err } + command, err := s.resolvedSessionRuntimeCommand(resolved, transport, info.Command, metadata) + if err != nil { + command = fallbackSessionRuntimeCommand(resolved, transport, info.Command, info.Provider) + } runtimeCfg, err := worker.NormalizeResolvedRuntime(worker.ResolvedRuntime{ Command: command, WorkDir: firstNonEmptyString(info.WorkDir, workDir), Provider: firstNonEmptyString(info.Provider, resolved.Name), SessionEnv: resolved.Env, - Hints: sessionResumeHints(resolved, firstNonEmptyString(workDir, info.WorkDir)), + Hints: sessionResumeHints(resolved, firstNonEmptyString(workDir, info.WorkDir), mcpServers), Resume: session.ProviderResume{ ResumeFlag: firstNonEmptyString(resolved.ResumeFlag, info.ResumeFlag), ResumeStyle: firstNonEmptyString(resolved.ResumeStyle, info.ResumeStyle), @@ -181,27 +385,161 @@ func (s *Server) resolveWorkerSessionRuntime(info session.Info, _ string) (*work return &runtimeCfg, nil } -func (s *Server) resolveSessionRuntime(info session.Info) (*config.ResolvedProvider, string) { - kind := s.sessionKind(info.ID) - if kind != "provider" { - resolved, workDir, _, _, err := s.resolveSessionTemplate(info.Template) - if err == nil { - if info.WorkDir != "" { - workDir = info.WorkDir - } - return resolved, workDir +func storedSessionProvesACPTransport(resolved *config.ResolvedProvider, configuredTransport, storedCommand string, metadata map[string]string) bool { + if metadata != nil { + if strings.TrimSpace(metadata[session.MCPIdentityMetadataKey]) != "" || + strings.TrimSpace(metadata[session.MCPServersSnapshotMetadataKey]) != "" { + return true + } + if strings.TrimSpace(configuredTransport) == "acp" && legacyResumeMetadataProvesACPTransport(metadata) { + return true } } + if resolved == nil { + return false + } + acpCommand := strings.TrimSpace(resolved.ACPCommandString()) + defaultCommand := strings.TrimSpace(resolved.CommandString()) + if acpCommand == "" || acpCommand == defaultCommand { + return false + } + return shouldPreserveStoredRuntimeCommand(storedCommand, acpCommand) +} + +func legacyResumeMetadataProvesACPTransport(metadata map[string]string) bool { + if metadata == nil { + return false + } + return strings.TrimSpace(metadata["resume_command"]) != "" || + strings.TrimSpace(metadata["resume_flag"]) != "" || + strings.TrimSpace(metadata["session_key"]) != "" +} + +func legacyACPTransportAmbiguous(resolved *config.ResolvedProvider, configuredTransport, storedCommand string, metadata map[string]string) bool { + if strings.TrimSpace(configuredTransport) != "acp" || resolved == nil { + return false + } + if storedSessionProvesACPTransport(resolved, configuredTransport, storedCommand, metadata) { + return false + } + acpCommand := strings.TrimSpace(resolved.ACPCommandString()) + defaultCommand := strings.TrimSpace(resolved.CommandString()) + if acpCommand == "" || acpCommand != defaultCommand { + return false + } + storedCommand = strings.TrimSpace(storedCommand) + return storedCommand == "" || sameRuntimeCommandExecutable(storedCommand, defaultCommand) +} - resolved, err := s.resolveBareProvider(info.Template) +func (s *Server) startedConfigHashProvesACPTransport( + info session.Info, + metadata map[string]string, + resolved *config.ResolvedProvider, + workDir, + configuredTransport, + sessionKind string, +) bool { + if strings.TrimSpace(configuredTransport) != "acp" || resolved == nil || metadata == nil { + return false + } + startedHash := strings.TrimSpace(metadata["started_config_hash"]) + if startedHash == "" { + return false + } + acpCommand, err := s.resolvedSessionRuntimeCommand(resolved, "acp", info.Command, metadata) if err != nil { - return nil, "" + acpCommand = fallbackSessionRuntimeCommand(resolved, "acp", info.Command, info.Provider) } - workDir := info.WorkDir - if workDir == "" { - workDir = s.state.CityPath() + defaultCommand, err := s.resolvedSessionRuntimeCommand(resolved, "", info.Command, metadata) + if err != nil { + defaultCommand = fallbackSessionRuntimeCommand(resolved, "", info.Command, info.Provider) + } + mcpServers, err := s.sessionMCPServers( + info.Template, + firstNonEmptyString(info.Provider, resolved.Name), + resumeSessionIdentity(info, metadata), + firstNonEmptyString(workDir, info.WorkDir), + "acp", + sessionKind, + ) + if err != nil { + return false + } + acpHash := runtime.CoreFingerprint(runtime.Config{ + Command: acpCommand, + Env: resolved.Env, + MCPServers: mcpServers, + }) + defaultHash := runtime.CoreFingerprint(runtime.Config{ + Command: defaultCommand, + Env: resolved.Env, + }) + if acpHash == defaultHash { + return false + } + return startedHash == acpHash +} + +func resolvedSessionTransport(info session.Info, resolved *config.ResolvedProvider, configuredTransport string, metadata map[string]string, allowConfiguredTransportFallback bool) string { + if transport := strings.TrimSpace(info.Transport); transport != "" { + return transport + } + if strings.TrimSpace(info.Provider) == "acp" { + return "acp" + } + if storedSessionProvesACPTransport(resolved, configuredTransport, info.Command, metadata) { + return "acp" + } + if strings.TrimSpace(info.Command) == "" { + return strings.TrimSpace(configuredTransport) + } + if allowConfiguredTransportFallback { + return strings.TrimSpace(configuredTransport) + } + return "" +} + +func (s *Server) resolveSessionRuntimeWithMetadata(info session.Info, metadata map[string]string) (*config.ResolvedProvider, string, string, bool) { + kind := s.sessionKind(info.ID) + cfg := s.state.Config() + var ( + resolved *config.ResolvedProvider + workDir string + configuredTransport string + ) + if kind != "provider" && cfg != nil { + if agentCfg, ok := resolveSessionTemplateAgent(cfg, info.Template); ok { + candidate, err := config.ResolveProvider(&agentCfg, &cfg.Workspace, cfg.Providers, exec.LookPath) + if err == nil { + candidateWorkDir, workDirErr := s.resolveSessionWorkDir(agentCfg, agentCfg.QualifiedName()) + if workDirErr == nil { + resolved = candidate + workDir = candidateWorkDir + if info.WorkDir != "" { + workDir = info.WorkDir + } + configuredTransport = config.ResolveSessionCreateTransport(agentCfg.Session, resolved) + } + } + } + } + if resolved == nil { + candidate, err := s.resolveBareProvider(info.Template) + if err != nil { + return nil, "", "", false + } + resolved = candidate + workDir = info.WorkDir + if workDir == "" { + workDir = s.state.CityPath() + } + configuredTransport = resolved.ProviderSessionCreateTransport() + } + transport := resolvedSessionTransport(info, resolved, configuredTransport, metadata, false) + if transport == "" && s.startedConfigHashProvesACPTransport(info, metadata, resolved, workDir, configuredTransport, kind) { + transport = "acp" } - return resolved, workDir + return resolved, workDir, transport, transport == "" && legacyACPTransportAmbiguous(resolved, configuredTransport, info.Command, metadata) } // sessionKind reads the persisted mc_session_kind from bead metadata. diff --git a/internal/api/session_transport.go b/internal/api/session_transport.go new file mode 100644 index 000000000..301b73e2f --- /dev/null +++ b/internal/api/session_transport.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" +) + +type acpRoutingProvider interface { + RouteACP(name string) +} + +func validateSessionTransport(resolved *config.ResolvedProvider, transport string, sp runtime.Provider) (string, error) { + transport = strings.TrimSpace(transport) + if transport != "acp" { + return transport, nil + } + providerName := "" + if resolved != nil { + providerName = resolved.Name + if !resolved.SupportsACP { + if providerName == "" { + providerName = transport + } + return "", fmt.Errorf("provider %q does not support ACP transport", providerName) + } + } + if transportSupportsACP(sp) { + return transport, nil + } + if providerName == "" { + providerName = transport + } + return "", fmt.Errorf("provider %q requires ACP transport but the session provider cannot route ACP sessions", providerName) +} + +func providerSessionTransport(resolved *config.ResolvedProvider, sp runtime.Provider) (string, error) { + if resolved == nil { + return "", nil + } + return validateSessionTransport(resolved, resolved.ProviderSessionCreateTransport(), sp) +} + +func transportSupportsACP(sp runtime.Provider) bool { + if sp == nil { + return false + } + if provider, ok := sp.(runtime.TransportCapabilityProvider); ok { + return provider.SupportsTransport("acp") + } + if _, ok := sp.(acpRoutingProvider); ok { + return true + } + return false +} diff --git a/internal/api/session_transport_test.go b/internal/api/session_transport_test.go new file mode 100644 index 000000000..0edce90b3 --- /dev/null +++ b/internal/api/session_transport_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/session" +) + +type createTransportCapableProvider struct { + *runtime.Fake +} + +func (p *createTransportCapableProvider) SupportsTransport(transport string) bool { + return transport == "acp" +} + +func TestProviderSessionTransportUsesExplicitACPConfigOnCustomProvider(t *testing.T) { + transport, err := providerSessionTransport(&config.ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + ACPCommand: "/bin/echo", + }, &createTransportCapableProvider{Fake: runtime.NewFake()}) + if err != nil { + t.Fatalf("providerSessionTransport: %v", err) + } + if transport != "acp" { + t.Fatalf("providerSessionTransport() = %q, want %q", transport, "acp") + } +} + +func TestProviderSessionTransportSupportsACPAloneStaysDefault(t *testing.T) { + transport, err := providerSessionTransport(&config.ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + }, &createTransportCapableProvider{Fake: runtime.NewFake()}) + if err != nil { + t.Fatalf("providerSessionTransport: %v", err) + } + if transport != "" { + t.Fatalf("providerSessionTransport() = %q, want empty transport", transport) + } +} + +func TestResolveSessionTemplateForCreateUsesProviderACPDefault(t *testing.T) { + fs := newSessionFakeState(t) + supportsACP := true + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom-acp", + }}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + _, _, transport, _, err := srv.resolveSessionTemplateForCreate("myrig/worker") + if err != nil { + t.Fatalf("resolveSessionTemplateForCreate: %v", err) + } + if transport != "acp" { + t.Fatalf("transport = %q, want %q", transport, "acp") + } +} + +func TestResolveSessionTemplateUsesProviderACPDefaultForLegacyRuntimeTransport(t *testing.T) { + fs := newSessionFakeState(t) + supportsACP := true + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom-acp", + }}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + _, _, transport, _, err := srv.resolveSessionTemplate("myrig/worker") + if err != nil { + t.Fatalf("resolveSessionTemplate: %v", err) + } + if transport != "acp" { + t.Fatalf("transport = %q, want %q", transport, "acp") + } +} + +func TestConfiguredSessionTransportUsesProviderACPDefaultForAgentTemplates(t *testing.T) { + supportsACP := true + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom-acp", + }}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + transport := configuredSessionTransport(cfg, "myrig/worker", "") + if transport != "acp" { + t.Fatalf("configuredSessionTransport() = %q, want %q", transport, "acp") + } +} + +func TestBuildSessionResumeDoesNotInferProviderACPDefaultForStoppedLegacyTemplateSession(t *testing.T) { + fs := newSessionFakeState(t) + supportsACP := true + fs.cfg = &config.City{ + Workspace: config.Workspace{Name: "test-city"}, + Agents: []config.Agent{{ + Name: "worker", + Dir: "myrig", + Provider: "custom-acp", + }}, + Providers: map[string]config.ProviderSpec{ + "custom-acp": { + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + }, + }, + } + + srv := New(fs) + cmd, _, err := srv.buildSessionResume(session.Info{ + ID: "gc-1", + Template: "myrig/worker", + Command: "/bin/echo", + WorkDir: "/tmp/workdir", + }) + if err != nil { + t.Fatalf("buildSessionResume: %v", err) + } + if cmd != "/bin/echo" { + t.Fatalf("resume command = %q, want %q", cmd, "/bin/echo") + } +} + +func TestResolvedSessionRuntimeCommandReplaysTemplateOverrides(t *testing.T) { + fs := newSessionFakeState(t) + srv := New(fs) + resolved := &config.ResolvedProvider{ + Name: "custom", + Command: "/bin/echo", + OptionsSchema: []config.ProviderOption{{ + Key: "effort", + Type: "select", + Choices: []config.OptionChoice{{ + Value: "high", + FlagArgs: []string{"--effort", "high"}, + }}, + }}, + } + + command, err := srv.resolvedSessionRuntimeCommand( + resolved, + "", + "/bin/echo", + map[string]string{"template_overrides": `{"effort":"high","initial_message":"hello"}`}, + ) + if err != nil { + t.Fatalf("resolvedSessionRuntimeCommand: %v", err) + } + if command != "/bin/echo --effort high" { + t.Fatalf("command = %q, want %q", command, "/bin/echo --effort high") + } +} + +func TestShouldPreserveStoredRuntimeCommandForTransportRejectsExecutableOnlyMatch(t *testing.T) { + if shouldPreserveStoredRuntimeCommandForTransport( + "claude", + "claude --settings /tmp/settings.json", + "", + nil, + ) { + t.Fatal("shouldPreserveStoredRuntimeCommandForTransport() = true, want false") + } +} diff --git a/internal/api/state.go b/internal/api/state.go index e3d6c647a..9646ac2ce 100644 --- a/internal/api/state.go +++ b/internal/api/state.go @@ -121,7 +121,9 @@ type ProviderUpdate struct { DisplayName *string Base **string Command *string + ACPCommand *string Args []string // nil = not set, non-nil = replace + ACPArgs []string // nil = not set, non-nil = replace ArgsAppend []string // nil = not set, non-nil = replace PromptMode *string PromptFlag *string diff --git a/internal/api/worker_capability_guardrail_test.go b/internal/api/worker_capability_guardrail_test.go index 16ab93dfc..8f6ba37e2 100644 --- a/internal/api/worker_capability_guardrail_test.go +++ b/internal/api/worker_capability_guardrail_test.go @@ -53,7 +53,7 @@ func TestEnrichSessionResponseAcceptsStateAndPeekCapability(t *testing.T) { }, nil, sessionResponseCapabilityHandle{ state: worker.State{Phase: worker.PhaseReady}, output: "peek output", - }, true, false) + }, true, false, false) if !resp.Running { t.Fatal("Running = false, want true") diff --git a/internal/api/worker_factory.go b/internal/api/worker_factory.go index ab4f15225..04318cd5a 100644 --- a/internal/api/worker_factory.go +++ b/internal/api/worker_factory.go @@ -7,14 +7,10 @@ import ( func (s *Server) workerFactory(store beads.Store) (*worker.Factory, error) { cfg := s.state.Config() - var resolveTransport func(template string) string + var resolveTransport func(template, provider string) string if cfg != nil { - resolveTransport = func(template string) string { - agentCfg, ok := resolveSessionTemplateAgent(cfg, template) - if !ok { - return "" - } - return agentCfg.Session + resolveTransport = func(template, provider string) string { + return configuredSessionTransport(cfg, template, provider) } } return worker.NewFactory(worker.FactoryConfig{ @@ -24,7 +20,7 @@ func (s *Server) workerFactory(store beads.Store) (*worker.Factory, error) { SearchPaths: s.sessionLogPaths(), Recorder: s.state.EventProvider(), ResolveTransport: resolveTransport, - ResolveSessionRuntime: s.resolveWorkerSessionRuntime, + ResolveSessionRuntime: s.resolveWorkerSessionRuntimeWithMetadata, }) } diff --git a/internal/api/worker_factory_test.go b/internal/api/worker_factory_test.go index 12d9d3c9d..b05b3797e 100644 --- a/internal/api/worker_factory_test.go +++ b/internal/api/worker_factory_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "os" + "path/filepath" "testing" "github.com/gastownhall/gascity/internal/config" @@ -37,7 +39,7 @@ func TestResolveWorkerSessionRuntimePreservesStoredResolvedCommandAndBackfillsCu ResumeCommand: "persisted resume {{.SessionKey}}", } - runtimeCfg, err := srv.resolveWorkerSessionRuntime(info, "") + runtimeCfg, err := srv.resolveWorkerSessionRuntime(info) if err != nil { t.Fatalf("resolveWorkerSessionRuntime: %v", err) } @@ -99,7 +101,7 @@ func TestResolveWorkerSessionRuntimeUsesResolvedCommandWhenPersistedCommandIsSta ResumeCommand: "persisted resume {{.SessionKey}}", } - runtimeCfg, err := srv.resolveWorkerSessionRuntime(info, "") + runtimeCfg, err := srv.resolveWorkerSessionRuntime(info) if err != nil { t.Fatalf("resolveWorkerSessionRuntime: %v", err) } @@ -135,6 +137,488 @@ func TestResolveWorkerSessionRuntimeUsesResolvedCommandWhenPersistedCommandIsSta } } +func TestResolveWorkerSessionRuntimeIncludesEffectiveMCPServers(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents[0].Provider = "resolved-worker" + fs.cfg.Agents[0].Session = "acp" + supportsACP := true + fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{ + DisplayName: "Resolved Worker", + Command: "/bin/echo", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "filesystem.toml"), []byte(` +name = "filesystem" +command = "/bin/mcp" +args = ["--stdio"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/worker", + Transport: "acp", + WorkDir: t.TempDir(), + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntime(info) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntime: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntime() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Name, "filesystem"; got != want { + t.Fatalf("Hints.MCPServers[0].Name = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeUsesStoredAgentNameForResumeMCPMaterialization(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents = []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "resolved-worker", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }} + supportsACP := true + fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{ + DisplayName: "Resolved Worker", + Command: "/bin/echo", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = "/bin/mcp" +args = ["{{.AgentName}}", "{{.WorkDir}}", "{{.TemplateName}}"] +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + workDir := filepath.Join(fs.cityPath, ".gc", "worktrees", "myrig", "ants", "ant") + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntime(info) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntime: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntime() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[0], info.AgentName; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[1], workDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToStoredMCPServersWhenCatalogBreaks(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents = []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "resolved-worker", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }} + supportsACP := true + fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{ + DisplayName: "Resolved Worker", + Command: "/bin/echo", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + workDir := filepath.Join(fs.cityPath, ".gc", "worktrees", "myrig", "ants", "ant") + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportStdio, + Command: "/bin/mcp", + Args: []string{"myrig/ant-adhoc-123", workDir, "myrig/ant"}, + }}) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: workDir, + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", metadata) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[0], "myrig/ant-adhoc-123"; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[1], workDir; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[2], "myrig/ant"; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToRuntimeMCPServersSnapshotWhenCatalogBreaks(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents = []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "resolved-worker", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }} + supportsACP := true + fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{ + DisplayName: "Resolved Worker", + Command: "/bin/echo", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + servers := []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{"--api-key", "super-secret"}, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }} + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", servers) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + if err := session.PersistRuntimeMCPServersSnapshot(fs.cityPath, "sess-1", servers); err != nil { + t.Fatalf("PersistRuntimeMCPServersSnapshot: %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: filepath.Join(fs.cityPath, ".gc", "worktrees", "myrig", "ants", "ant"), + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", metadata) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args[1], "super-secret"; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Env["API_TOKEN"], "super-secret"; got != want { + t.Fatalf("Env[API_TOKEN] = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Headers["Authorization"], "Bearer secret"; got != want { + t.Fatalf("Headers[Authorization] = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToSanitizedStoredMCPServersWhenRuntimeSnapshotMissing(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Agents = []config.Agent{{ + Name: "ant", + Dir: "myrig", + Provider: "resolved-worker", + Session: "acp", + WorkDir: ".gc/worktrees/{{.Rig}}/ants/{{.AgentBase}}", + MinActiveSessions: intPtr(0), + MaxActiveSessions: intPtr(4), + }} + supportsACP := true + fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{ + DisplayName: "Resolved Worker", + Command: "/bin/echo", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + fs.cfg.PackMCPDir = filepath.Join(fs.cityPath, "mcp") + if err := os.MkdirAll(fs.cfg.PackMCPDir, 0o755); err != nil { + t.Fatalf("MkdirAll(mcp): %v", err) + } + if err := os.WriteFile(filepath.Join(fs.cfg.PackMCPDir, "identity.template.toml"), []byte(` +name = "identity" +command = [broken +`), 0o644); err != nil { + t.Fatalf("WriteFile(mcp): %v", err) + } + + metadata, err := session.WithStoredMCPMetadata(nil, "myrig/ant-adhoc-123", []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{"--serve", "--api-key", "super-secret"}, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }}) + if err != nil { + t.Fatalf("WithStoredMCPMetadata: %v", err) + } + + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/ant", + Alias: "ant", + AgentName: "myrig/ant-adhoc-123", + Transport: "acp", + WorkDir: filepath.Join(fs.cityPath, ".gc", "worktrees", "myrig", "ants", "ant"), + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", metadata) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if len(runtimeCfg.Hints.MCPServers) != 1 { + t.Fatalf("Hints.MCPServers len = %d, want 1", len(runtimeCfg.Hints.MCPServers)) + } + if got, want := runtimeCfg.Hints.MCPServers[0].Args, []string{"--serve"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("Args = %#v, want %#v", got, want) + } + if len(runtimeCfg.Hints.MCPServers[0].Env) != 0 { + t.Fatalf("Env = %#v, want empty", runtimeCfg.Hints.MCPServers[0].Env) + } + if len(runtimeCfg.Hints.MCPServers[0].Headers) != 0 { + t.Fatalf("Headers = %#v, want empty", runtimeCfg.Hints.MCPServers[0].Headers) + } + if got, want := runtimeCfg.Hints.MCPServers[0].URL, "https://example.invalid/mcp"; got != want { + t.Fatalf("URL = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToStoredCommandWhenTemplateOverridesInvalid(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Providers["test-agent"] = config.ProviderSpec{ + Command: "/bin/echo", + PathCheck: "true", + } + + srv := New(fs) + info := session.Info{ + ID: "sess-1", + Template: "myrig/worker", + Command: "/bin/echo --stored", + WorkDir: t.TempDir(), + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", map[string]string{ + "template_overrides": `{`, + }) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if got, want := runtimeCfg.Command, "/bin/echo --stored"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeUsesProviderACPDefaultWithoutTemplateSessionOverride(t *testing.T) { + supportsACP := true + fs := newSessionFakeState(t) + fs.cfg.Providers["test-agent"] = config.ProviderSpec{ + Command: "/bin/echo", + PathCheck: "true", + SupportsACP: &supportsACP, + ACPCommand: "/bin/echo", + ACPArgs: []string{"acp"}, + } + + srv := New(fs) + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(session.Info{ + Template: "myrig/worker", + WorkDir: t.TempDir(), + }, "", nil) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if got, want := runtimeCfg.Command, "/bin/echo acp"; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToPersistedRuntimeOnIncompleteResolvedConfig(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Providers["test-agent"] = config.ProviderSpec{ + ReadyPromptPrefix: "resolved-ready>", + ReadyDelayMs: 321, + } + + srv := New(fs) + info := session.Info{ + Template: "myrig/worker", + Command: "persisted-worker --dangerously-skip-permissions", + Provider: "persisted-provider", + WorkDir: "/tmp/persisted-workdir", + ResumeFlag: "--resume-persisted", + ResumeStyle: "subcommand", + ResumeCommand: "persisted resume {{.SessionKey}}", + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", nil) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if got, want := runtimeCfg.Command, info.Command; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } + if got, want := runtimeCfg.Provider, info.Provider; got != want { + t.Fatalf("Provider = %q, want %q", got, want) + } + if got, want := runtimeCfg.WorkDir, info.WorkDir; got != want { + t.Fatalf("WorkDir = %q, want %q", got, want) + } + if got, want := runtimeCfg.Resume.ResumeFlag, info.ResumeFlag; got != want { + t.Fatalf("Resume.ResumeFlag = %q, want %q", got, want) + } + if got, want := runtimeCfg.Resume.ResumeStyle, info.ResumeStyle; got != want { + t.Fatalf("Resume.ResumeStyle = %q, want %q", got, want) + } + if got, want := runtimeCfg.Resume.ResumeCommand, info.ResumeCommand; got != want { + t.Fatalf("Resume.ResumeCommand = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.WorkDir, info.WorkDir; got != want { + t.Fatalf("Hints.WorkDir = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.ReadyPromptPrefix, "resolved-ready>"; got != want { + t.Fatalf("Hints.ReadyPromptPrefix = %q, want %q", got, want) + } + if got, want := runtimeCfg.Hints.ReadyDelayMs, 321; got != want { + t.Fatalf("Hints.ReadyDelayMs = %d, want %d", got, want) + } +} + +func TestResolveWorkerSessionRuntimeFallsBackToPersistedProviderWhenCommandMissing(t *testing.T) { + fs := newSessionFakeState(t) + fs.cfg.Providers["test-agent"] = config.ProviderSpec{ + ReadyPromptPrefix: "resolved-ready>", + } + + srv := New(fs) + info := session.Info{ + Template: "myrig/worker", + Provider: "persisted-provider", + } + + runtimeCfg, err := srv.resolveWorkerSessionRuntimeWithMetadata(info, "", nil) + if err != nil { + t.Fatalf("resolveWorkerSessionRuntimeWithMetadata: %v", err) + } + if runtimeCfg == nil { + t.Fatal("resolveWorkerSessionRuntimeWithMetadata() = nil") + } + if got, want := runtimeCfg.Command, info.Provider; got != want { + t.Fatalf("Command = %q, want %q", got, want) + } + if got, want := runtimeCfg.Provider, info.Provider; got != want { + t.Fatalf("Provider = %q, want %q", got, want) + } +} + func TestWorkerFactorySessionByIDUsesResolvedTemplateRuntime(t *testing.T) { fs := newSessionFakeState(t) fs.cfg.Agents[0].Provider = "resolved-worker" diff --git a/internal/beads/caching_store_events.go b/internal/beads/caching_store_events.go index 3bdb3c50d..8ef6a0d4d 100644 --- a/internal/beads/caching_store_events.go +++ b/internal/beads/caching_store_events.go @@ -2,6 +2,7 @@ package beads import ( "encoding/json" + "errors" "fmt" "maps" "slices" @@ -17,17 +18,37 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { return } - b, err := decodeCacheEvent(payload) + patch, fields, err := decodeCacheEvent(payload) if err != nil { c.recordProblem(fmt.Sprintf("apply %s event", eventType), err) return } + c.mu.RLock() + if c.state != cacheLive { + c.mu.RUnlock() + return + } + _, cached := c.beads[patch.ID] + c.mu.RUnlock() + + b := patch + if !cached { + if fresh, err := c.backing.Get(patch.ID); err == nil { + b = fresh + } else if !errors.Is(err, ErrNotFound) { + c.recordProblem(fmt.Sprintf("refresh %s event", eventType), err) + } + } + c.mu.Lock() defer c.mu.Unlock() if c.state != cacheLive { return } + if current, ok := c.beads[patch.ID]; ok { + b = mergeCacheEventPatch(current, patch, fields) + } mutated := false switch eventType { @@ -37,9 +58,9 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { c.beads[b.ID] = cloneBead(b) delete(c.dirty, b.ID) delete(c.deletedSeq, b.ID) - c.updateStatsLocked() - mutated = true } + c.updateStatsLocked() + mutated = true case "bead.updated": c.noteMutationLocked(b.ID) c.beads[b.ID] = cloneBead(b) @@ -80,27 +101,83 @@ func (c *CachingStore) ApplyDepEvent(beadID string, deps []Dep) { c.updateStatsLocked() } -func decodeCacheEvent(payload json.RawMessage) (Bead, error) { +func mergeCacheEventPatch(base, patch Bead, fields map[string]json.RawMessage) Bead { + merged := cloneBead(base) + if hasCacheEventField(fields, "title") { + merged.Title = patch.Title + } + if hasCacheEventField(fields, "status") { + merged.Status = patch.Status + } + if hasCacheEventField(fields, "issue_type") || hasCacheEventField(fields, "type") { + merged.Type = patch.Type + } + if hasCacheEventField(fields, "priority") { + merged.Priority = cloneIntPtr(patch.Priority) + } + if hasCacheEventField(fields, "created_at") { + merged.CreatedAt = patch.CreatedAt + } + if hasCacheEventField(fields, "assignee") { + merged.Assignee = patch.Assignee + } + if hasCacheEventField(fields, "from") { + merged.From = patch.From + } + if hasCacheEventField(fields, "parent") { + merged.ParentID = patch.ParentID + } + if hasCacheEventField(fields, "ref") { + merged.Ref = patch.Ref + } + if hasCacheEventField(fields, "needs") { + merged.Needs = slices.Clone(patch.Needs) + } + if hasCacheEventField(fields, "description") { + merged.Description = patch.Description + } + if hasCacheEventField(fields, "labels") { + merged.Labels = slices.Clone(patch.Labels) + } + if hasCacheEventField(fields, "metadata") { + merged.Metadata = maps.Clone(patch.Metadata) + } + if hasCacheEventField(fields, "dependencies") { + merged.Dependencies = slices.Clone(patch.Dependencies) + } + return merged +} + +func hasCacheEventField(fields map[string]json.RawMessage, name string) bool { + _, ok := fields[name] + return ok +} + +func decodeCacheEvent(payload json.RawMessage) (Bead, map[string]json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return Bead{}, nil, err + } var wire struct { Bead Metadata StringMap `json:"metadata,omitempty"` TypeCompat string `json:"type,omitempty"` } if err := json.Unmarshal(payload, &wire); err != nil { - return Bead{}, err + return Bead{}, nil, err } b := wire.Bead if wire.Metadata != nil { b.Metadata = map[string]string(wire.Metadata) } if b.ID == "" { - return Bead{}, fmt.Errorf("missing bead id") + return Bead{}, nil, fmt.Errorf("missing bead id") } // bd hook payloads use "issue_type" while exec-style payloads may use "type". if b.Type == "" && wire.TypeCompat != "" { b.Type = wire.TypeCompat } - return b, nil + return b, fields, nil } func (c *CachingStore) notifyChange(eventType string, b Bead) { diff --git a/internal/beads/caching_store_internal_test.go b/internal/beads/caching_store_internal_test.go index 30b47bc77..904d5f9bd 100644 --- a/internal/beads/caching_store_internal_test.go +++ b/internal/beads/caching_store_internal_test.go @@ -598,6 +598,37 @@ func TestCachingStoreCloseAllMarksRefreshFailuresDirty(t *testing.T) { } } +func TestCachingStoreCachedListReturnsSnapshotWithDirtyEntries(t *testing.T) { + t.Parallel() + + backing := &refreshFailingStore{Store: NewMemStore()} + bead, err := backing.Create(Bead{Title: "active work"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + title := "updated while refresh fails" + backing.failNextGet = true + if err := cache.Update(bead.ID, UpdateOpts{Title: &title}); err != nil { + t.Fatalf("Update: %v", err) + } + + rows, ok := cache.CachedList(ListQuery{Status: "open"}) + if !ok { + t.Fatal("CachedList returned ok=false for dirty cache, want snapshot") + } + if len(rows) != 1 || rows[0].ID != bead.ID { + t.Fatalf("CachedList = %#v, want dirty snapshot row %s", rows, bead.ID) + } + if rows[0].Title == title { + t.Fatalf("CachedList returned refreshed title %q; test setup did not create a dirty stale snapshot", rows[0].Title) + } +} + type refreshFailingStore struct { Store failNextGet bool @@ -702,3 +733,50 @@ func (s *closeAllRefreshFailingStore) List(query ListQuery) ([]Bead, error) { s.listCalls++ return s.Store.List(query) } + +// Reconciliation must not re-emit bead.closed for a cache entry whose status +// is already "closed". When ApplyEvent ingests an external bead.closed event +// (from the bus), it stores the closed bead in c.beads. List({AllowScan:true}) +// filters out closed beads, so the next reconcile sees the entry as missing +// from the fresh DB read and would re-emit a duplicate close notification. +// Routed back through the event bus, that notification re-applies into every +// caching store and reconciles into another spurious close — the storm. +func TestCachingStoreRunReconciliationDoesNotEmitBeadClosedForAlreadyClosedCacheEntry(t *testing.T) { + t.Parallel() + + backing := NewMemStore() + bead, err := backing.Create(Bead{Title: "task"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + var events []string + cache := NewCachingStoreForTest(backing, func(eventType, beadID string, _ json.RawMessage) { + events = append(events, eventType+":"+beadID) + }) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + // External writer closes the bead in the backing store, then the close + // event is delivered through the bus and applied to this cache. + if err := backing.Close(bead.ID); err != nil { + t.Fatalf("backing Close: %v", err) + } + closed := bead + closed.Status = "closed" + payload, err := json.Marshal(closed) + if err != nil { + t.Fatalf("marshal: %v", err) + } + cache.ApplyEvent("bead.closed", payload) + events = nil // ignore notifications from prime/apply; only assert on reconcile output + + cache.runReconciliation() + + for _, e := range events { + if e == "bead.closed:"+bead.ID { + t.Fatalf("reconciler emitted duplicate bead.closed for an already-closed cache entry; events=%v", events) + } + } +} diff --git a/internal/beads/caching_store_reads.go b/internal/beads/caching_store_reads.go index 3413e3e98..494f9b1aa 100644 --- a/internal/beads/caching_store_reads.go +++ b/internal/beads/caching_store_reads.go @@ -83,6 +83,31 @@ func (c *CachingStore) List(query ListQuery) ([]Bead, error) { return c.backing.List(query) } +// CachedList returns query results from the in-memory cache only. The boolean +// reports whether the cache was initialized enough to answer without touching +// the backing store. Dirty entries are returned from the last observed +// snapshot; callers must treat this as a read model that may lag writes or +// reconciliation by one tick. +func (c *CachingStore) CachedList(query ListQuery) ([]Bead, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.state != cacheLive && c.state != cachePartial { + return nil, false + } + cached := make([]Bead, 0, len(c.beads)) + for _, b := range c.beads { + if !query.Matches(b) { + continue + } + cached = append(cached, cloneBead(b)) + } + sortBeadsForQuery(cached, query.Sort) + if query.Limit > 0 && len(cached) > query.Limit { + cached = cached[:query.Limit] + } + return cached, true +} + func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, items []Bead) []Bead { refreshedParents := make(map[string]Bead) removedParents := make(map[string]struct{}) @@ -250,7 +275,6 @@ func (c *CachingStore) Ready() ([]Bead, error) { statusByID := make(map[string]string, len(c.beads)) depsByID := make(map[string][]Dep, len(c.deps)) openBeads := make([]Bead, 0, len(c.beads)) - missingDepIDs := make(map[string]struct{}) for _, b := range c.beads { statusByID[b.ID] = b.Status if b.Status == "open" && !IsReadyExcludedType(b.Type) { @@ -260,30 +284,9 @@ func (c *CachingStore) Ready() ([]Bead, error) { for _, b := range openBeads { deps := cloneDeps(c.deps[b.ID]) depsByID[b.ID] = deps - for _, dep := range deps { - switch dep.Type { - case "blocks", "waits-for", "conditional-blocks": - default: - continue - } - if _, ok := statusByID[dep.DependsOnID]; !ok { - missingDepIDs[dep.DependsOnID] = struct{}{} - } - } } c.mu.RUnlock() - for depID := range missingDepIDs { - dep, err := c.backing.Get(depID) - if err != nil { - if errors.Is(err, ErrNotFound) { - continue - } - return nil, err - } - statusByID[depID] = dep.Status - } - var result []Bead for _, b := range openBeads { blocked := false @@ -293,7 +296,7 @@ func (c *CachingStore) Ready() ([]Bead, error) { default: continue } - if statusByID[dep.DependsOnID] != "closed" { + if status, ok := statusByID[dep.DependsOnID]; ok && status != "closed" { blocked = true break } diff --git a/internal/beads/caching_store_reconcile.go b/internal/beads/caching_store_reconcile.go index 227187a44..e04be13e4 100644 --- a/internal/beads/caching_store_reconcile.go +++ b/internal/beads/caching_store_reconcile.go @@ -139,12 +139,14 @@ func (c *CachingStore) runReconciliation() { continue } removes++ - closed := cloneBead(old) - closed.Status = "closed" - notifications = append(notifications, cacheNotification{ - eventType: "bead.closed", - bead: closed, - }) + if old.Status != "closed" { + closed := cloneBead(old) + closed.Status = "closed" + notifications = append(notifications, cacheNotification{ + eventType: "bead.closed", + bead: closed, + }) + } delete(c.beads, id) delete(c.deps, id) delete(c.dirty, id) @@ -207,6 +209,9 @@ func (c *CachingStore) runReconciliation() { for id, old := range c.beads { if _, exists := freshByID[id]; !exists { removes++ + if old.Status == "closed" { + continue + } closed := cloneBead(old) closed.Status = "closed" notifications = append(notifications, cacheNotification{ diff --git a/internal/beads/caching_store_reconcile_internal_test.go b/internal/beads/caching_store_reconcile_internal_test.go index eb39a91fb..7c9b80823 100644 --- a/internal/beads/caching_store_reconcile_internal_test.go +++ b/internal/beads/caching_store_reconcile_internal_test.go @@ -3,6 +3,7 @@ package beads import ( "context" "encoding/json" + "strings" "sync" "testing" ) @@ -132,6 +133,123 @@ func TestCachingStoreReconciliationPreservesConcurrentEvent(t *testing.T) { } } +func TestCachingStoreReconciliationSkipsReemitForAlreadyClosedBead(t *testing.T) { + mem := NewMemStore() + bead, err := mem.Create(Bead{Title: "to be closed"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + var events []string + cs := NewCachingStoreForTest(mem, func(eventType, beadID string, _ json.RawMessage) { + events = append(events, eventType+":"+beadID) + }) + if err := cs.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + if err := cs.Close(bead.ID); err != nil { + t.Fatalf("Close: %v", err) + } + wantClose := "bead.closed:" + bead.ID + closeSeen := false + for _, e := range events { + if e == wantClose { + closeSeen = true + break + } + } + if !closeSeen { + t.Fatalf("events after Close = %v, want to include %q", events, wantClose) + } + events = nil + + cs.runReconciliation() + + for _, e := range events { + if strings.HasPrefix(e, "bead.closed:") { + t.Fatalf("reconciliation re-emitted close event: %v", events) + } + } + + cs.mu.RLock() + _, stillCached := cs.beads[bead.ID] + cs.mu.RUnlock() + if stillCached { + t.Fatalf("closed bead %s should be evicted from cache after reconcile", bead.ID) + } +} + +func TestCachingStoreReconciliationSkipsReemitForAlreadyClosedBeadWithConcurrentMutation(t *testing.T) { + mem := NewMemStore() + closedBead, err := mem.Create(Bead{Title: "closed before reconcile"}) + if err != nil { + t.Fatalf("Create(closed): %v", err) + } + other, err := mem.Create(Bead{Title: "concurrent target"}) + if err != nil { + t.Fatalf("Create(other): %v", err) + } + + backing := &reconcileRaceStore{ + Store: mem, + started: make(chan struct{}), + release: make(chan struct{}), + stale: []Bead{other}, + } + + var events []string + var eventsMu sync.Mutex + cs := NewCachingStoreForTest(backing, func(eventType, beadID string, _ json.RawMessage) { + eventsMu.Lock() + defer eventsMu.Unlock() + events = append(events, eventType+":"+beadID) + }) + if err := cs.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + if err := cs.Close(closedBead.ID); err != nil { + t.Fatalf("Close: %v", err) + } + eventsMu.Lock() + events = nil + eventsMu.Unlock() + + backing.mu.Lock() + backing.block = true + backing.mu.Unlock() + + done := make(chan struct{}) + go func() { + cs.runReconciliation() + close(done) + }() + + <-backing.started + title := "after concurrent update" + if err := cs.Update(other.ID, UpdateOpts{Title: &title}); err != nil { + t.Fatalf("Update(other): %v", err) + } + close(backing.release) + <-done + + eventsMu.Lock() + defer eventsMu.Unlock() + for _, e := range events { + if strings.HasPrefix(e, "bead.closed:") { + t.Fatalf("reconciliation re-emitted close event in race path: %v", events) + } + } + + cs.mu.RLock() + _, stillCached := cs.beads[closedBead.ID] + cs.mu.RUnlock() + if stillCached { + t.Fatalf("closed bead %s should be evicted from cache after reconcile", closedBead.ID) + } +} + func TestCachingStoreReconciliationMergesFreshDataWithConcurrentMutation(t *testing.T) { mem := NewMemStore() mutated, err := mem.Create(Bead{Title: "before mutate"}) diff --git a/internal/beads/caching_store_test.go b/internal/beads/caching_store_test.go index 675227e77..60c6351fc 100644 --- a/internal/beads/caching_store_test.go +++ b/internal/beads/caching_store_test.go @@ -681,7 +681,32 @@ func TestCachingStoreGetFallsBackForClosedBeadsAfterPrime(t *testing.T) { } } -func TestCachingStoreReadyFallsBackForClosedBlockingDepsAfterPrime(t *testing.T) { +type countingGetStore struct { + beads.Store + mu sync.Mutex + gets int +} + +func (s *countingGetStore) Get(id string) (beads.Bead, error) { + s.mu.Lock() + s.gets++ + s.mu.Unlock() + return s.Store.Get(id) +} + +func (s *countingGetStore) resetGets() { + s.mu.Lock() + s.gets = 0 + s.mu.Unlock() +} + +func (s *countingGetStore) getCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.gets +} + +func TestCachingStoreReadyTreatsMissingDepTargetAsClosedWithoutBackingGet(t *testing.T) { t.Parallel() mem := beads.NewMemStore() blocker, err := mem.Create(beads.Bead{Title: "Closed blocker"}) @@ -699,10 +724,12 @@ func TestCachingStoreReadyFallsBackForClosedBlockingDepsAfterPrime(t *testing.T) t.Fatalf("DepAdd: %v", err) } - cs := beads.NewCachingStoreForTest(mem, nil) + backing := &countingGetStore{Store: mem} + cs := beads.NewCachingStoreForTest(backing, nil) if err := cs.Prime(context.Background()); err != nil { t.Fatalf("Prime: %v", err) } + backing.resetGets() got, err := cs.Ready() if err != nil { @@ -711,6 +738,9 @@ func TestCachingStoreReadyFallsBackForClosedBlockingDepsAfterPrime(t *testing.T) if len(got) != 1 || got[0].ID != ready.ID { t.Fatalf("Ready() = %v, want only %s", got, ready.ID) } + if gets := backing.getCount(); gets != 0 { + t.Fatalf("Ready() performed %d backing Get calls, want 0", gets) + } } func TestCachingStoreListPartialAllowScanReturnsCompleteActiveSnapshot(t *testing.T) { @@ -902,7 +932,14 @@ func TestCachingStoreApplyEvent(t *testing.T) { } // Apply an update event. - updated := beads.Bead{ID: b1.ID, Title: "Modified by agent", Status: "open", Metadata: map[string]string{"gc.step_ref": "mol.review"}} + updatedTitle := "Modified by agent" + if err := mem.Update(b1.ID, beads.UpdateOpts{ + Title: &updatedTitle, + Metadata: map[string]string{"gc.step_ref": "mol.review"}, + }); err != nil { + t.Fatalf("Update backing: %v", err) + } + updated := beads.Bead{ID: b1.ID, Title: updatedTitle, Status: "open", Metadata: map[string]string{"gc.step_ref": "mol.review"}} payload, _ = json.Marshal(updated) cs.ApplyEvent("bead.updated", payload) @@ -915,9 +952,20 @@ func TestCachingStoreApplyEvent(t *testing.T) { } // Apply a close event with the full closed bead payload. + closedTitle := "Closed by agent" + if err := mem.Update(b1.ID, beads.UpdateOpts{ + Title: &closedTitle, + Labels: []string{"done"}, + Metadata: map[string]string{"gc.outcome": "pass"}, + }); err != nil { + t.Fatalf("Update backing before close: %v", err) + } + if err := mem.Close(b1.ID); err != nil { + t.Fatalf("Close backing: %v", err) + } closed := beads.Bead{ ID: b1.ID, - Title: "Closed by agent", + Title: closedTitle, Status: "closed", Labels: []string{"done"}, Metadata: map[string]string{"gc.outcome": "pass"}, @@ -943,21 +991,88 @@ func TestCachingStoreApplyEvent(t *testing.T) { } } -func TestCachingStoreApplyEventCoercesNonStringMetadata(t *testing.T) { +func TestCachingStoreApplyEventRefreshesPartialHookPayload(t *testing.T) { t.Parallel() mem := beads.NewMemStore() - b1, err := mem.Create(beads.Bead{Title: "Existing"}) + parent, err := mem.Create(beads.Bead{Title: "parent"}) if err != nil { - t.Fatalf("Create: %v", err) + t.Fatalf("Create parent: %v", err) + } + child, err := mem.Create(beads.Bead{ + Title: "child", + ParentID: parent.ID, + Labels: []string{"mc-live-contract"}, + }) + if err != nil { + t.Fatalf("Create child: %v", err) } + backing := &eventGetFailStore{Store: mem} + cs := beads.NewCachingStoreForTest(backing, nil) + if err := cs.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + backing.failGet = true + + updatedTitle := "child updated externally" + if err := mem.Update(child.ID, beads.UpdateOpts{Title: &updatedTitle}); err != nil { + t.Fatalf("Update backing: %v", err) + } + payload, err := json.Marshal(map[string]any{ + "id": child.ID, + "title": updatedTitle, + "status": "open", + "issue_type": "task", + "owner": "agent@example.com", + "updated_at": "2026-04-25T04:45:55Z", + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + cs.ApplyEvent("bead.updated", payload) + if stats := cs.Stats(); stats.ProblemCount != 0 { + t.Fatalf("ProblemCount = %d, want 0 (last problem: %s)", stats.ProblemCount, stats.LastProblem) + } + + labeled, err := cs.List(beads.ListQuery{Label: "mc-live-contract"}) + if err != nil { + t.Fatalf("List(label): %v", err) + } + if len(labeled) != 1 || labeled[0].ID != child.ID { + t.Fatalf("labeled = %#v, want child %s", labeled, child.ID) + } + if labeled[0].ParentID != parent.ID { + t.Fatalf("ParentID = %q, want %q", labeled[0].ParentID, parent.ID) + } + if labeled[0].Title != updatedTitle { + t.Fatalf("Title = %q, want %q", labeled[0].Title, updatedTitle) + } +} + +type eventGetFailStore struct { + beads.Store + failGet bool +} + +func (s *eventGetFailStore) Get(id string) (beads.Bead, error) { + if s.failGet { + return beads.Bead{}, errors.New("unexpected event backing get") + } + return s.Store.Get(id) +} + +func TestCachingStoreApplyEventCoercesNonStringMetadata(t *testing.T) { + t.Parallel() + mem := beads.NewMemStore() + cs := beads.NewCachingStoreForTest(mem, nil) if err := cs.Prime(context.Background()); err != nil { t.Fatalf("Prime: %v", err) } payload, err := json.Marshal(map[string]any{ - "id": b1.ID, + "id": "ext-1", "title": "mayor", "status": "open", "issue_type": "session", @@ -979,7 +1094,7 @@ func TestCachingStoreApplyEventCoercesNonStringMetadata(t *testing.T) { t.Fatalf("ProblemCount = %d, want 0 (last problem: %s)", stats.ProblemCount, stats.LastProblem) } - got := requireCachedBead(t, cs, b1.ID, false) + got := requireCachedBead(t, cs, "ext-1", false) if got.Type != "session" { t.Fatalf("Type = %q, want session", got.Type) } diff --git a/internal/bootstrap/packs/core/assets/prompts/graph-worker.md b/internal/bootstrap/packs/core/assets/prompts/graph-worker.md index 65407ef8e..a38e2952a 100644 --- a/internal/bootstrap/packs/core/assets/prompts/graph-worker.md +++ b/internal/bootstrap/packs/core/assets/prompts/graph-worker.md @@ -22,6 +22,9 @@ bd ready --assignee="$GC_SESSION_NAME" --json --limit=1 # Step 3: If still nothing, check the routed queue (multi-session configs only) gc hook + +# Step 4: If gc hook returned an unassigned routed bead, claim it atomically +bd update --claim ``` If you have no work after all three checks, run: @@ -33,14 +36,17 @@ gc runtime drain-ack ## How To Work 1. Find your assigned bead (see Startup above). -2. Read it with `bd show `. -3. **Claim continuation group** (see below). -4. Execute exactly that bead's description. -5. On success, close it: +2. If the bead came from `gc hook`, claim it with `bd update --claim` + before doing any work. Do not start work with `bd update --status in_progress`; + only `--claim` sets both assignee and in-progress state atomically. +3. Read it with `bd show `. +4. **Claim continuation group** (see below). +5. Execute exactly that bead's description. +6. On success, close it: ```bash bd update --set-metadata gc.outcome=pass --status closed ``` -6. On transient failure, mark it transient and close it: +7. On transient failure, mark it transient and close it: ```bash bd update \ --set-metadata gc.outcome=fail \ @@ -48,7 +54,7 @@ gc runtime drain-ack --set-metadata gc.failure_reason= \ --status closed ``` -7. On unrecoverable failure, mark it hard-failed and close it: +8. On unrecoverable failure, mark it hard-failed and close it: ```bash bd update \ --set-metadata gc.outcome=fail \ @@ -56,11 +62,11 @@ gc runtime drain-ack --set-metadata gc.failure_reason= \ --status closed ``` -8. After closing, check for more assigned work: +9. After closing, check for more assigned work: ```bash bd ready --assignee="$GC_SESSION_NAME" --json --limit=1 ``` -9. If more work exists, go to step 2. If not, poll briefly (see below). +10. If more work exists, go to step 2. If not, poll briefly (see below). ## Continuation Group — Session Affinity diff --git a/internal/bootstrap/packs/core/overlay/per-provider/codex/.codex/hooks.json b/internal/bootstrap/packs/core/overlay/per-provider/codex/.codex/hooks.json index bce9dc4b6..fe38792f0 100644 --- a/internal/bootstrap/packs/core/overlay/per-provider/codex/.codex/hooks.json +++ b/internal/bootstrap/packs/core/overlay/per-provider/codex/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc prime --hook" + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc prime --hook --hook-format codex" } ] } @@ -17,11 +17,11 @@ "hooks": [ { "type": "command", - "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc nudge drain --inject" + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc nudge drain --inject --hook-format codex" }, { "type": "command", - "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc mail check --inject" + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc mail check --inject --hook-format codex" } ] } @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc hook --inject" + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc hook --inject --hook-format codex" } ] } diff --git a/internal/bootstrap/packs/core/overlay/per-provider/pi/.pi/extensions/gc-hooks.js b/internal/bootstrap/packs/core/overlay/per-provider/pi/.pi/extensions/gc-hooks.js index cfda92196..506826d0e 100644 --- a/internal/bootstrap/packs/core/overlay/per-provider/pi/.pi/extensions/gc-hooks.js +++ b/internal/bootstrap/packs/core/overlay/per-provider/pi/.pi/extensions/gc-hooks.js @@ -1,20 +1,24 @@ // Gas City hooks for Pi Coding Agent. // Installed by gc into {workDir}/.pi/extensions/gc-hooks.js // +// Pi 0.70+ extension API uses a factory function and pi.on(...) +// subscriptions. Keep this file as .js for existing Gas City provider args +// and auto-discovery paths. +// // Events: -// session.created → gc prime (load context) -// session.compacted → gc prime (reload after compaction) -// session.deleted → gc hook --inject (pick up work on exit) -// chat.system.transform → gc nudge drain --inject + gc mail check --inject +// session_start → gc prime --hook (load context side effects) +// session_compact → gc prime --hook (reload after compaction) +// session_shutdown → gc hook --inject on process quit +// before_agent_start → gc nudge drain --inject + gc mail check --inject -const { execSync } = require("child_process"); +const { execFileSync } = require("node:child_process"); -const PATH_PREFIX = - `${process.env.HOME}/go/bin:${process.env.HOME}/.local/bin:`; +const PATH_PREFIX = `${process.env.HOME}/go/bin:${process.env.HOME}/.local/bin:`; -function run(cmd) { +function run(args, cwd) { try { - return execSync(cmd, { + return execFileSync("gc", args, { + cwd: cwd || process.cwd(), encoding: "utf-8", timeout: 30000, env: { ...process.env, PATH: PATH_PREFIX + (process.env.PATH || "") }, @@ -24,24 +28,35 @@ function run(cmd) { } } -module.exports = { - name: "gascity", - - events: { - "session.created": () => run("gc prime --hook"), - "session.compacted": () => run("gc prime --hook"), - "session.deleted": () => run("gc hook --inject"), - }, - - hooks: { - "experimental.chat.system.transform": (system) => { - const nudges = run("gc nudge drain --inject"); - const mail = run("gc mail check --inject"); - const extras = [nudges, mail].filter(Boolean); - if (extras.length > 0) { - return system + "\n\n" + extras.join("\n\n"); - } - return system; - }, - }, +function appendSystemPrompt(systemPrompt, additions) { + const extras = additions.filter(Boolean); + if (extras.length === 0) { + return systemPrompt; + } + return [systemPrompt, ...extras].filter(Boolean).join("\n\n"); +} + +module.exports = function gascityPiExtension(pi) { + pi.on("session_start", (_event, ctx) => { + run(["prime", "--hook"], ctx.cwd); + }); + + pi.on("session_compact", (_event, ctx) => { + run(["prime", "--hook"], ctx.cwd); + }); + + pi.on("session_shutdown", (event, ctx) => { + if (event.reason === "quit") { + run(["hook", "--inject"], ctx.cwd); + } + }); + + pi.on("before_agent_start", (event, ctx) => { + const nudges = run(["nudge", "drain", "--inject"], ctx.cwd); + const mail = run(["mail", "check", "--inject"], ctx.cwd); + const systemPrompt = appendSystemPrompt(event.systemPrompt, [nudges, mail]); + if (systemPrompt !== event.systemPrompt) { + return { systemPrompt }; + } + }); }; diff --git a/internal/config/chain.go b/internal/config/chain.go index a7a74238f..69985d519 100644 --- a/internal/config/chain.go +++ b/internal/config/chain.go @@ -385,6 +385,8 @@ func recordScalarProvenance(spec ProviderSpec, layer string, into map[string]str set("resume_style", spec.ResumeStyle) set("resume_command", spec.ResumeCommand) set("session_id_flag", spec.SessionIDFlag) + set("acp_command", spec.ACPCommand) + setSlice("acp_args", spec.ACPArgs) set("title_model", spec.TitleModel) set("options_schema_merge", spec.OptionsSchemaMerge) setSlice("print_args", spec.PrintArgs) diff --git a/internal/config/config.go b/internal/config/config.go index 7fe378b47..076b62cb8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -547,7 +547,8 @@ type AgentOverride struct { MaxActiveSessions *int `toml:"max_active_sessions,omitempty"` // MinActiveSessions overrides the minimum number of sessions to keep alive. MinActiveSessions *int `toml:"min_active_sessions,omitempty"` - // ScaleCheck overrides the shell command whose output determines desired session count. + // ScaleCheck overrides the shell command whose output reports new + // unassigned session demand for bead-backed reconciliation. ScaleCheck *string `toml:"scale_check,omitempty"` // OptionDefaults adds or overrides provider option defaults for this agent. // Keys are option keys, values are choice values. Merges additively @@ -1539,12 +1540,15 @@ type Agent struct { // MinActiveSessions is the minimum number of sessions to keep alive. // Agent-level only. Counts against rig/workspace caps. Replaces pool.min. MinActiveSessions *int `toml:"min_active_sessions,omitempty"` - // ScaleCheck is a shell command template whose output determines desired - // session count. Optional override — when set, its output is the desired - // count (still clamped by all cap levels). If it contains Go template - // placeholders, gc expands them using the same PathContext fields as - // work_dir and session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, - // CityName) before running the command. + // ScaleCheck is a shell command template whose output reports new + // unassigned session demand. In bead-backed reconciliation this is + // additive: assigned work is resumed separately, and ScaleCheck reports + // only how many new generic sessions to start, still bounded by all cap + // levels. Legacy no-store evaluation continues to treat the output as + // the desired session count. If it contains Go template placeholders, gc + // expands them using the same PathContext fields as work_dir and + // session_setup (Agent, AgentBase, Rig, RigRoot, CityRoot, CityName) + // before running the command. ScaleCheck string `toml:"scale_check,omitempty"` // DrainTimeout is the maximum time to wait for a session to finish its // current work before force-killing it during scale-down. Duration string @@ -1909,10 +1913,10 @@ func (a *Agent) DrainTimeoutDuration() time.Duration { // EffectiveScaleCheck returns the scale check command for this agent. // If ScaleCheck is set, returns it. Otherwise returns a default that -// counts actionable work routed to this agent's template, including +// counts new unassigned work routed to this agent's template, including // standalone formula-dispatched molecule beads (which bd ready excludes). -// Attached formulas contribute demand through the routed source bead in the -// ready/in_progress tiers instead of through the molecule count. +// Assigned in-progress work is resumed from session beads, so it must not +// create additional generic pool demand here. func (a *Agent) EffectiveScaleCheck() string { if a.ScaleCheck != "" { return a.ScaleCheck @@ -1920,11 +1924,9 @@ func (a *Agent) EffectiveScaleCheck() string { template := a.QualifiedName() return `ready=$(bd ready --metadata-field gc.routed_to=` + template + ` --unassigned --json 2>/dev/null | jq 'length' 2>/dev/null); ` + - `active=$(bd list --metadata-field gc.routed_to=` + template + - ` --status=in_progress --no-assignee --json 2>/dev/null | jq 'length' 2>/dev/null); ` + `molecules=$(bd list --metadata-field gc.routed_to=` + template + ` --status=open --type=molecule --no-assignee --json 2>/dev/null | jq 'length' 2>/dev/null); ` + - `echo "$(( ${ready:-0} + ${active:-0} + ${molecules:-0} ))" || echo 0` + `echo "$(( ${ready:-0} + ${molecules:-0} ))" || echo 0` } // EffectiveMaxActiveSessions returns the agent's max active sessions. @@ -2022,10 +2024,26 @@ func (a *Agent) EffectiveOnDeath() string { if a.OnDeath != "" { return a.OnDeath } + route := a.QualifiedName() + if a.PoolName != "" { + route = a.PoolName + } + // Reset both assignee and status: clearing assignee alone leaves the bead + // invisible to every work_query tier (Tier 1 needs assignee match, Tiers + // 2/3 only match "ready" status). The next worker re-claims via Tier 3 + // (gc.routed_to + --unassigned). If routed metadata is missing entirely, + // backfill the fallback route so reopened direct-assigned work does not + // stay invisible. return `bd list --assignee=` + a.QualifiedName() + ` --status=in_progress --json 2>/dev/null | ` + - `jq -r '.[].id' 2>/dev/null | ` + - `xargs -rI{} bd update {} --assignee "" 2>/dev/null` + `jq -r '.[] | [.id, (.metadata["gc.routed_to"] // "")] | @tsv' 2>/dev/null | ` + + `while IFS="$(printf '\t')" read -r id current_route; do ` + + `[ -z "$id" ] && continue; ` + + `if [ -n "$current_route" ]; then ` + + `bd update "$id" --assignee "" --status open 2>/dev/null; ` + + `else bd update "$id" --assignee "" --status open --set-metadata gc.routed_to=` + route + ` 2>/dev/null; ` + + `fi; ` + + `done` } // EffectiveOnBoot returns the on_boot command for this agent. @@ -2040,9 +2058,9 @@ func (a *Agent) EffectiveOnBoot() string { template = a.PoolName } return `bd list --metadata-field gc.routed_to=` + template + - ` --status=in_progress --json 2>/dev/null | ` + + ` --status=in_progress --no-assignee --json 2>/dev/null | ` + `jq -r '.[].id' 2>/dev/null | ` + - `xargs -rI{} bd update {} --assignee "" 2>/dev/null` + `xargs -rI{} bd update {} --status open 2>/dev/null` } // InjectImplicitAgents adds on-demand agents for each configured provider at diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b1e840f00..d1e54affe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1489,12 +1489,12 @@ func TestDefaultPoolCheckUsesBdReady(t *testing.T) { if !strings.Contains(check, "bd ready") { t.Errorf("EffectiveScaleCheck() = %q, want bd ready for blocker-aware counting", check) } - if !strings.Contains(check, "--status=in_progress") { - t.Errorf("EffectiveScaleCheck() = %q, want --status=in_progress for active work", check) - } if !strings.Contains(check, "--type=molecule") { t.Errorf("EffectiveScaleCheck() = %q, want --type=molecule for formula-dispatched work", check) } + if strings.Contains(check, "--status=in_progress") || strings.Contains(check, "${active:-0}") { + t.Errorf("EffectiveScaleCheck() = %q, should not count in-progress work as new demand", check) + } } func TestValidateAgentsCustomQueries(t *testing.T) { @@ -1587,15 +1587,12 @@ func TestEffectiveScaleCheckDefaults(t *testing.T) { MinActiveSessions: ptrInt(0), MaxActiveSessions: ptrInt(1), } check := a.EffectiveScaleCheck() - // Default check uses bd ready (blocker-aware) + in_progress count + molecule count via gc.routed_to. + // Default check uses bd ready (blocker-aware) + molecule count via gc.routed_to. if !strings.Contains(check, "gc.routed_to=refinery") { t.Errorf("EffectiveScaleCheck = %q, want gc.routed_to=refinery", check) } - if !strings.Contains(check, "--status=in_progress") { - t.Errorf("EffectiveScaleCheck = %q, want --status=in_progress for active work", check) - } if !strings.Contains(check, "--no-assignee") { - t.Errorf("EffectiveScaleCheck = %q, want --no-assignee for active unassigned work", check) + t.Errorf("EffectiveScaleCheck = %q, want --no-assignee for new unassigned demand", check) } if !strings.Contains(check, "--type=molecule") { t.Errorf("EffectiveScaleCheck = %q, want --type=molecule for formula-dispatched work", check) @@ -1603,6 +1600,9 @@ func TestEffectiveScaleCheckDefaults(t *testing.T) { if !strings.Contains(check, "${molecules:-0}") { t.Errorf("EffectiveScaleCheck = %q, want ${molecules:-0} in arithmetic sum", check) } + if strings.Contains(check, "--status=in_progress") || strings.Contains(check, "${active:-0}") { + t.Errorf("EffectiveScaleCheck = %q, should not count in-progress work as new demand", check) + } } func TestEffectiveScaleCheckDefaultsQualified(t *testing.T) { @@ -1616,15 +1616,15 @@ func TestEffectiveScaleCheckDefaultsQualified(t *testing.T) { if !strings.Contains(check, "gc.routed_to=myproject/polecat") { t.Errorf("EffectiveScaleCheck = %q, want gc.routed_to=myproject/polecat", check) } - if !strings.Contains(check, "--status=in_progress") { - t.Errorf("EffectiveScaleCheck = %q, want --status=in_progress for active work", check) - } if !strings.Contains(check, "--no-assignee") { - t.Errorf("EffectiveScaleCheck = %q, want --no-assignee for active unassigned work", check) + t.Errorf("EffectiveScaleCheck = %q, want --no-assignee for new unassigned demand", check) } if !strings.Contains(check, "--type=molecule") { t.Errorf("EffectiveScaleCheck = %q, want --type=molecule for formula-dispatched work", check) } + if strings.Contains(check, "--status=in_progress") || strings.Contains(check, "${active:-0}") { + t.Errorf("EffectiveScaleCheck = %q, should not count in-progress work as new demand", check) + } } func TestEffectiveScaleCheckMoleculeQuery(t *testing.T) { @@ -1637,24 +1637,21 @@ func TestEffectiveScaleCheckMoleculeQuery(t *testing.T) { } check := a.EffectiveScaleCheck() - // Must contain three separate queries summed together. + // Must contain blocker-aware ready demand and standalone molecule demand. if !strings.Contains(check, "bd ready") { t.Errorf("missing bd ready query for blocker-aware task counting") } - if !strings.Contains(check, "--status=in_progress") { - t.Errorf("missing in_progress query for active work") - } if !strings.Contains(check, "--status=open --type=molecule") { t.Errorf("missing molecule query for formula-dispatched work (GH #505)") } + if strings.Contains(check, "--status=in_progress") || strings.Contains(check, "${active:-0}") { + t.Errorf("EffectiveScaleCheck = %q, should not count in-progress work as new demand", check) + } - // All three variables must appear in the arithmetic sum. + // Both variables must appear in the arithmetic sum. if !strings.Contains(check, "${ready:-0}") { t.Errorf("missing ${ready:-0} in arithmetic sum") } - if !strings.Contains(check, "${active:-0}") { - t.Errorf("missing ${active:-0} in arithmetic sum") - } if !strings.Contains(check, "${molecules:-0}") { t.Errorf("missing ${molecules:-0} in arithmetic sum") } @@ -3193,6 +3190,34 @@ func runEffectiveWorkQuery(t *testing.T, a Agent, env map[string]string, bdScrip return string(out) } +func runLifecycleHookCommand(t *testing.T, command 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) + } + logPath := filepath.Join(tmp, "bd.log") + + cmd := exec.Command("sh", "-c", command) + cmd.Env = []string{ + "PATH=" + tmp + ":" + os.Getenv("PATH"), + "BD_LOG=" + logPath, + } + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run lifecycle hook: %v\n%s", err, out) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read hook log: %v", err) + } + return string(data) +} + // TestEffectiveMethodsAgentRouting verifies that all agents use // gc.routed_to= metadata routing. func TestEffectiveMethodsAgentRouting(t *testing.T) { @@ -3556,7 +3581,7 @@ func TestEffectiveOnDeathDefault(t *testing.T) { MinActiveSessions: ptrInt(0), MaxActiveSessions: ptrInt(5), } got := a.EffectiveOnDeath() - for _, want := range []string{"bd list --assignee=myrig/dog", "--status=in_progress", "--assignee \"\""} { + for _, want := range []string{"bd list --assignee=myrig/dog", "--status=in_progress", `--assignee "" --status open`, "--set-metadata gc.routed_to=myrig/dog"} { if !strings.Contains(got, want) { t.Errorf("EffectiveOnDeath() = %q, want %q", got, want) } @@ -3577,13 +3602,73 @@ func TestEffectiveOnDeathCustom(t *testing.T) { func TestEffectiveOnDeathFixedAgent(t *testing.T) { a := Agent{Name: "mayor"} got := a.EffectiveOnDeath() - for _, want := range []string{"bd list --assignee=mayor", "--status=in_progress", "--assignee \"\""} { + for _, want := range []string{"bd list --assignee=mayor", "--status=in_progress", `--assignee "" --status open`, "--set-metadata gc.routed_to=mayor"} { if !strings.Contains(got, want) { t.Errorf("EffectiveOnDeath() = %q, want %q", got, want) } } } +func TestEffectiveOnDeathBackfillsMissingRouteOnReopen(t *testing.T) { + a := Agent{ + Name: "dog-1", + Dir: "hello-world", + MinActiveSessions: ptrInt(0), MaxActiveSessions: ptrInt(5), + PoolName: "hello-world/dog", + } + + log := runLifecycleHookCommand(t, a.EffectiveOnDeath(), nil, `#!/bin/sh +set -eu +case "$1" in + list) + printf '[{"id":"ga-missing","metadata":{}}]' + ;; + update) + printf '%s\n' "$*" >> "$BD_LOG" + ;; + *) + exit 1 + ;; +esac +`) + if !strings.Contains(log, "--status open") { + t.Fatalf("hook log = %q, want reopened status", log) + } + if !strings.Contains(log, "--set-metadata gc.routed_to=hello-world/dog") { + t.Fatalf("hook log = %q, want fallback route for ownerless reopened work", log) + } +} + +func TestEffectiveOnDeathPreservesExistingRouteOnReopen(t *testing.T) { + a := Agent{ + Name: "dog-1", + Dir: "hello-world", + MinActiveSessions: ptrInt(0), MaxActiveSessions: ptrInt(5), + PoolName: "hello-world/dog", + } + + log := runLifecycleHookCommand(t, a.EffectiveOnDeath(), nil, `#!/bin/sh +set -eu +case "$1" in + list) + printf '[{"id":"ga-routed","metadata":{"gc.routed_to":"already/routed"}}]' + ;; + update) + printf '%s\n' "$*" >> "$BD_LOG" + ;; + *) + exit 1 + ;; +esac +`) + if !strings.Contains(log, "--status open") { + t.Fatalf("hook log = %q, want reopened status", log) + } + if strings.Contains(log, "--set-metadata") { + t.Fatalf("hook log = %q, want existing route preserved without overwrite", log) + } +} + func TestEffectiveOnBootDefault(t *testing.T) { a := Agent{ Name: "dog", @@ -3591,11 +3676,14 @@ func TestEffectiveOnBootDefault(t *testing.T) { MinActiveSessions: ptrInt(0), MaxActiveSessions: ptrInt(5), } got := a.EffectiveOnBoot() - for _, want := range []string{"bd list --metadata-field gc.routed_to=myrig/dog", "--status=in_progress", "--assignee \"\""} { + for _, want := range []string{"bd list --metadata-field gc.routed_to=myrig/dog", "--status=in_progress", "--no-assignee", "--status open"} { if !strings.Contains(got, want) { t.Errorf("EffectiveOnBoot() = %q, want %q", got, want) } } + if strings.Contains(got, `--assignee ""`) { + t.Errorf("EffectiveOnBoot() = %q, want to target only ownerless work instead of bulk-unassigning routed work", got) + } } func TestEffectiveOnBootDefaultPoolName(t *testing.T) { @@ -3607,11 +3695,14 @@ func TestEffectiveOnBootDefaultPoolName(t *testing.T) { PoolName: "myrig/dog", } got := a.EffectiveOnBoot() - for _, want := range []string{"bd list --metadata-field gc.routed_to=myrig/dog", "--status=in_progress", "--assignee \"\""} { + for _, want := range []string{"bd list --metadata-field gc.routed_to=myrig/dog", "--status=in_progress", "--no-assignee", "--status open"} { if !strings.Contains(got, want) { t.Errorf("EffectiveOnBoot() = %q, want %q", got, want) } } + if strings.Contains(got, `--assignee ""`) { + t.Errorf("EffectiveOnBoot() = %q, want to target only ownerless work instead of bulk-unassigning routed work", got) + } } func TestEffectiveOnBootCustom(t *testing.T) { @@ -3628,11 +3719,14 @@ func TestEffectiveOnBootCustom(t *testing.T) { func TestEffectiveOnBootNonPool(t *testing.T) { a := Agent{Name: "mayor"} got := a.EffectiveOnBoot() - for _, want := range []string{"bd list --metadata-field gc.routed_to=mayor", "--status=in_progress", "--assignee \"\""} { + for _, want := range []string{"bd list --metadata-field gc.routed_to=mayor", "--status=in_progress", "--no-assignee", "--status open"} { if !strings.Contains(got, want) { t.Errorf("EffectiveOnBoot() = %q, want %q", got, want) } } + if strings.Contains(got, `--assignee ""`) { + t.Errorf("EffectiveOnBoot() = %q, want to target only ownerless work instead of bulk-unassigning routed work", got) + } } func TestValidateDependsOn(t *testing.T) { diff --git a/internal/config/launch_command.go b/internal/config/launch_command.go index 3cbab9bbc..c9444d6a6 100644 --- a/internal/config/launch_command.go +++ b/internal/config/launch_command.go @@ -22,12 +22,16 @@ type ProviderLaunchCommand struct { // for session startup. It starts from the raw provider command, applies // schema-managed defaults plus any explicit option overrides, and appends a // provider-owned settings file when present. -func BuildProviderLaunchCommand(cityPath string, resolved *ResolvedProvider, optionOverrides map[string]string) (ProviderLaunchCommand, error) { +// +// When transport is "acp", the ACP-specific command (ACPCommand/ACPArgs) is +// used as the base instead of the default Command/Args. Pass "" for the +// default (tmux) transport. +func BuildProviderLaunchCommand(cityPath string, resolved *ResolvedProvider, optionOverrides map[string]string, transport string) (ProviderLaunchCommand, error) { if resolved == nil { return ProviderLaunchCommand{}, fmt.Errorf("resolved provider is nil") } - command := resolved.CommandString() + command := providerLaunchBaseCommand(resolved, transport) if len(resolved.OptionsSchema) > 0 { mergedOptions := make(map[string]string, len(resolved.EffectiveDefaults)+len(optionOverrides)) for key, value := range resolved.EffectiveDefaults { @@ -48,7 +52,34 @@ func BuildProviderLaunchCommand(cityPath string, resolved *ResolvedProvider, opt } } - settingsPath, settingsRel := ProviderSettingsSource(cityPath, resolved.Name) + return appendProviderSettings(cityPath, resolved.Name, command), nil +} + +// BuildProviderLaunchCommandWithoutOptions composes the transport-specific +// provider command plus any provider-owned settings file without applying +// schema-managed defaults or explicit option overrides. +// +// Deferred agent-session creation uses this helper because option state is +// stored separately in template_overrides and applied later at actual start +// time, but the stored base command must still match the selected transport +// and provider-owned settings semantics. +func BuildProviderLaunchCommandWithoutOptions(cityPath string, resolved *ResolvedProvider, transport string) (ProviderLaunchCommand, error) { + if resolved == nil { + return ProviderLaunchCommand{}, fmt.Errorf("resolved provider is nil") + } + return appendProviderSettings(cityPath, resolved.Name, providerLaunchBaseCommand(resolved, transport)), nil +} + +func providerLaunchBaseCommand(resolved *ResolvedProvider, transport string) string { + command := resolved.CommandString() + if transport == "acp" { + command = resolved.ACPCommandString() + } + return command +} + +func appendProviderSettings(cityPath, providerName, command string) ProviderLaunchCommand { + settingsPath, settingsRel := ProviderSettingsSource(cityPath, providerName) if settingsPath != "" { command = command + " " + fmt.Sprintf("--settings %q", settingsPath) } @@ -57,7 +88,7 @@ func BuildProviderLaunchCommand(cityPath string, resolved *ResolvedProvider, opt Command: command, SettingsPath: settingsPath, SettingsRel: settingsRel, - }, nil + } } // ProviderSettingsSource returns the provider-owned settings file that should diff --git a/internal/config/launch_command_test.go b/internal/config/launch_command_test.go index 39b4c0256..93fe87f26 100644 --- a/internal/config/launch_command_test.go +++ b/internal/config/launch_command_test.go @@ -20,7 +20,7 @@ func TestBuildProviderLaunchCommandAddsDefaultsAndSettings(t *testing.T) { spec := BuiltinProviders()["claude"] rp := specToResolved("claude", &spec) - got, err := BuildProviderLaunchCommand(dir, rp, nil) + got, err := BuildProviderLaunchCommand(dir, rp, nil, "") if err != nil { t.Fatalf("BuildProviderLaunchCommand: %v", err) } @@ -44,7 +44,7 @@ func TestBuildProviderLaunchCommandAppliesOptionOverrides(t *testing.T) { got, err := BuildProviderLaunchCommand("", rp, map[string]string{ "permission_mode": "plan", "effort": "low", - }) + }, "") if err != nil { t.Fatalf("BuildProviderLaunchCommand: %v", err) } @@ -65,7 +65,7 @@ func TestBuildProviderLaunchCommandIgnoresInitialMessageOverride(t *testing.T) { got, err := BuildProviderLaunchCommand("", rp, map[string]string{ "initial_message": "hello", "effort": "low", - }) + }, "") if err != nil { t.Fatalf("BuildProviderLaunchCommand: %v", err) } @@ -75,3 +75,62 @@ func TestBuildProviderLaunchCommandIgnoresInitialMessageOverride(t *testing.T) { t.Fatalf("Command = %q, want %q", got.Command, want) } } + +func TestBuildProviderLaunchCommandUsesACPCommand(t *testing.T) { + rp := &ResolvedProvider{ + Command: "custom-opencode", + ACPArgs: []string{"acp"}, + } + + t.Run("acp transport uses ACPCommandString", func(t *testing.T) { + got, err := BuildProviderLaunchCommand("", rp, nil, "acp") + if err != nil { + t.Fatalf("BuildProviderLaunchCommand: %v", err) + } + want := "custom-opencode acp" + if got.Command != want { + t.Fatalf("Command = %q, want %q", got.Command, want) + } + }) + + t.Run("default transport uses CommandString", func(t *testing.T) { + got, err := BuildProviderLaunchCommand("", rp, nil, "") + if err != nil { + t.Fatalf("BuildProviderLaunchCommand: %v", err) + } + want := "custom-opencode" + if got.Command != want { + t.Fatalf("Command = %q, want %q", got.Command, want) + } + }) +} + +func TestBuildProviderLaunchCommandWithoutOptionsSkipsDefaultsButKeepsSettings(t *testing.T) { + dir := t.TempDir() + runtimeDir := filepath.Join(dir, ".gc") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, "settings.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + spec := BuiltinProviders()["claude"] + rp := specToResolved("claude", &spec) + + got, err := BuildProviderLaunchCommandWithoutOptions(dir, rp, "") + if err != nil { + t.Fatalf("BuildProviderLaunchCommandWithoutOptions: %v", err) + } + + wantCommand := fmt.Sprintf("claude --settings %q", filepath.Join(dir, ".gc", "settings.json")) + if got.Command != wantCommand { + t.Fatalf("Command = %q, want %q", got.Command, wantCommand) + } + if got.SettingsPath != filepath.Join(dir, ".gc", "settings.json") { + t.Fatalf("SettingsPath = %q, want %q", got.SettingsPath, filepath.Join(dir, ".gc", "settings.json")) + } + if got.SettingsRel != filepath.Join(".gc", "settings.json") { + t.Fatalf("SettingsRel = %q, want %q", got.SettingsRel, filepath.Join(".gc", "settings.json")) + } +} diff --git a/internal/config/options.go b/internal/config/options.go index 543dd5a57..1d2ed22d1 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -155,21 +155,36 @@ func ReplaceSchemaFlags(command string, schema []ProviderOption, overrideArgs [] return stripped } -// CollectAllSchemaFlags gathers all FlagArgs from all choices across all options. -// Multi-flag FlagArgs sequences are split at "--" boundaries so that each -// independent flag group can be matched separately during stripping. +// CollectAllSchemaFlags gathers all FlagArgs and FlagAliases from all choices +// across all options. Multi-flag sequences are split at "--" boundaries so that +// each independent flag group can be matched separately during stripping. func CollectAllSchemaFlags(schema []ProviderOption) [][]string { var flags [][]string + seen := make(map[string]bool) for _, opt := range schema { for _, choice := range opt.Choices { - if len(choice.FlagArgs) > 0 { - flags = append(flags, splitFlagArgs(choice.FlagArgs)...) + for _, seq := range choiceFlagSequences(choice) { + key := strings.Join(seq, "\x00") + if seen[key] { + continue + } + seen[key] = true + flags = append(flags, cloneStrings(seq)) } } } return flags } +func choiceFlagSequences(choice OptionChoice) [][]string { + var sequences [][]string + sequences = append(sequences, splitFlagArgs(choice.FlagArgs)...) + for _, alias := range choice.FlagAliases { + sequences = append(sequences, splitFlagArgs(alias)...) + } + return sequences +} + // splitFlagArgs splits a FlagArgs slice into independent flag groups at // "--" prefix boundaries. For example: // @@ -280,9 +295,9 @@ func stripArgsSlice(args []string, flags [][]string, schema []ProviderOption, in return result } -// inferChoiceFromFlags finds which schema option+choice produced the given -// flag sequence and, if the key is not already present in defaults, sets -// the inferred value. Only infers from exact full-FlagArgs matches to +// inferChoiceFromFlags finds which schema option+choice produced the given flag +// sequence and, if the key is not already present in defaults, sets the +// inferred value. Only infers from exact full FlagArgs or FlagAliases matches to // avoid ambiguity with partial multi-flag matches. func inferChoiceFromFlags(schema []ProviderOption, flagSeq []string, defaults map[string]string) { for _, opt := range schema { @@ -290,7 +305,7 @@ func inferChoiceFromFlags(schema []ProviderOption, flagSeq []string, defaults ma continue } for _, choice := range opt.Choices { - if flagsEqual(choice.FlagArgs, flagSeq) { + if choiceHasFlagSequence(choice, flagSeq) { defaults[opt.Key] = choice.Value return } @@ -298,6 +313,28 @@ func inferChoiceFromFlags(schema []ProviderOption, flagSeq []string, defaults ma } } +func choiceHasFlagSequence(choice OptionChoice, flagSeq []string) bool { + for _, seq := range choiceFullFlagSequences(choice) { + if flagsEqual(seq, flagSeq) { + return true + } + } + return false +} + +func choiceFullFlagSequences(choice OptionChoice) [][]string { + var sequences [][]string + if len(choice.FlagArgs) > 0 { + sequences = append(sequences, choice.FlagArgs) + } + for _, alias := range choice.FlagAliases { + if len(alias) > 0 { + sequences = append(sequences, alias) + } + } + return sequences +} + func flagsEqual(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/config/options_test.go b/internal/config/options_test.go index 3e9faa8ea..cd70f7124 100644 --- a/internal/config/options_test.go +++ b/internal/config/options_test.go @@ -1,6 +1,7 @@ package config import ( + "reflect" "strings" "testing" ) @@ -118,6 +119,101 @@ func TestResolveOptions_EffectiveDefaultsOverrideSchemaDefaults(t *testing.T) { } } +func TestReplaceSchemaFlagsStripsCodexAliases(t *testing.T) { + codex := BuiltinProviders()["codex"] + defaultArgs := []string{ + "--dangerously-bypass-approvals-and-sandbox", + "--model", "gpt-5.5", + "-c", "model_reasoning_effort=xhigh", + } + + got := ReplaceSchemaFlags( + `aimux run codex -- -m gpt-5.5 -c 'model_reasoning_effort="xhigh"'`, + codex.OptionsSchema, + defaultArgs, + ) + + if strings.Count(got, "gpt-5.5") != 1 { + t.Fatalf("ReplaceSchemaFlags() = %q, want one model flag", got) + } + if strings.Count(got, "model_reasoning_effort") != 1 { + t.Fatalf("ReplaceSchemaFlags() = %q, want one effort flag", got) + } + if !strings.Contains(got, "--model gpt-5.5") { + t.Fatalf("ReplaceSchemaFlags() = %q, want canonical model flag", got) + } + if strings.Contains(got, "-m gpt-5.5") || strings.Contains(got, `model_reasoning_effort=\"xhigh\"`) { + t.Fatalf("ReplaceSchemaFlags() = %q, retained non-canonical schema flag", got) + } +} + +func TestCollectAllSchemaFlagsUsesDeclaredFlagAliases(t *testing.T) { + schema := []ProviderOption{ + { + Key: "model", + Choices: []OptionChoice{ + { + Value: "opus", + FlagArgs: []string{"--model", "opus"}, + FlagAliases: [][]string{{"-m", "opus"}}, + }, + }, + }, + } + + flags := CollectAllSchemaFlags(schema) + got := StripFlags("agent -m opus --other", flags) + + if got != "agent --other" { + t.Fatalf("StripFlags() = %q, want alias stripped", got) + } +} + +func TestCollectAllSchemaFlagsDoesNotInferUndeclaredProviderAliases(t *testing.T) { + schema := []ProviderOption{ + { + Key: "model", + Choices: []OptionChoice{ + {Value: "opus", FlagArgs: []string{"--model", "opus"}}, + }, + }, + } + + flags := CollectAllSchemaFlags(schema) + got := StripFlags("agent -m opus --other", flags) + + if got != "agent -m opus --other" { + t.Fatalf("StripFlags() = %q, want undeclared alias preserved", got) + } +} + +func TestStripArgsSliceInfersChoiceFromDeclaredAlias(t *testing.T) { + schema := []ProviderOption{ + { + Key: "model", + Choices: []OptionChoice{ + { + Value: "opus", + FlagArgs: []string{"--model", "opus"}, + FlagAliases: [][]string{{"-m", "opus"}}, + }, + }, + }, + } + flags := CollectAllSchemaFlags(schema) + inferred := make(map[string]string) + + got := stripArgsSlice([]string{"run", "-m", "opus", "--other"}, flags, schema, inferred) + + want := []string{"run", "--other"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("stripArgsSlice() = %v, want %v", got, want) + } + if inferred["model"] != "opus" { + t.Fatalf("inferred model = %q, want opus", inferred["model"]) + } +} + func TestResolveOptions_UserOptionOverridesEffectiveDefault(t *testing.T) { schema := []ProviderOption{ { diff --git a/internal/config/pack.go b/internal/config/pack.go index acaa51674..524a8a1a3 100644 --- a/internal/config/pack.go +++ b/internal/config/pack.go @@ -1559,6 +1559,10 @@ func deepCopyProviderSpec(in ProviderSpec) ProviderSpec { out.OptionDefaults = deepCopyStringMap(in.OptionDefaults) out.OptionsSchema = deepCopyProviderOptions(in.OptionsSchema) out.PrintArgs = append([]string(nil), in.PrintArgs...) + if in.ACPArgs != nil { + out.ACPArgs = make([]string, len(in.ACPArgs)) + copy(out.ACPArgs, in.ACPArgs) + } out.Base = copyStringPtr(in.Base) out.EmitsPermissionWarning = copyBoolPtr(in.EmitsPermissionWarning) out.SupportsACP = copyBoolPtr(in.SupportsACP) @@ -1580,6 +1584,7 @@ func deepCopyOptionChoices(in []OptionChoice) []OptionChoice { for i := range in { out[i] = in[i] out[i].FlagArgs = append([]string(nil), in[i].FlagArgs...) + out[i].FlagAliases = cloneStringSlices(in[i].FlagAliases) } return out } diff --git a/internal/config/patch.go b/internal/config/patch.go index 2b3afbdeb..1d843b5c8 100644 --- a/internal/config/patch.go +++ b/internal/config/patch.go @@ -122,9 +122,9 @@ type AgentPatch struct { MaxActiveSessions *int `toml:"max_active_sessions,omitempty"` // MinActiveSessions overrides the minimum number of sessions to keep alive. MinActiveSessions *int `toml:"min_active_sessions,omitempty"` - // ScaleCheck overrides the command template whose output determines desired - // session count. Supports the same Go template placeholders as - // Agent.scale_check. + // ScaleCheck overrides the command template whose output reports new + // unassigned session demand for bead-backed reconciliation. Supports the + // same Go template placeholders as Agent.scale_check. ScaleCheck *string `toml:"scale_check,omitempty"` // OptionDefaults adds or overrides provider option defaults for this agent. // Keys are option keys, values are choice values. Merges additively @@ -180,8 +180,12 @@ type ProviderPatch struct { Base **string `toml:"base,omitempty"` // Command overrides the provider command. Command *string `toml:"command,omitempty"` + // ACPCommand overrides the provider command for ACP transport sessions. + ACPCommand *string `toml:"acp_command,omitempty"` // Args overrides the provider args. Args []string `toml:"args,omitempty"` + // ACPArgs overrides the provider args for ACP transport sessions. + ACPArgs []string `toml:"acp_args,omitempty"` // ArgsAppend overrides the provider args_append list. ArgsAppend []string `toml:"args_append,omitempty"` // OptionsSchemaMerge overrides the options_schema merge mode. @@ -451,10 +455,17 @@ func applyProviderPatch(cfg *City, patch *ProviderPatch) error { if patch.Command != nil { newSpec.Command = *patch.Command } + if patch.ACPCommand != nil { + newSpec.ACPCommand = *patch.ACPCommand + } if len(patch.Args) > 0 { newSpec.Args = make([]string, len(patch.Args)) copy(newSpec.Args, patch.Args) } + if patch.ACPArgs != nil { + newSpec.ACPArgs = make([]string, len(patch.ACPArgs)) + copy(newSpec.ACPArgs, patch.ACPArgs) + } if len(patch.ArgsAppend) > 0 { newSpec.ArgsAppend = make([]string, len(patch.ArgsAppend)) copy(newSpec.ArgsAppend, patch.ArgsAppend) @@ -487,10 +498,17 @@ func applyProviderPatch(cfg *City, patch *ProviderPatch) error { if patch.Command != nil { spec.Command = *patch.Command } + if patch.ACPCommand != nil { + spec.ACPCommand = *patch.ACPCommand + } if len(patch.Args) > 0 { spec.Args = make([]string, len(patch.Args)) copy(spec.Args, patch.Args) } + if patch.ACPArgs != nil { + spec.ACPArgs = make([]string, len(patch.ACPArgs)) + copy(spec.ACPArgs, patch.ACPArgs) + } if len(patch.ArgsAppend) > 0 { spec.ArgsAppend = make([]string, len(patch.ArgsAppend)) copy(spec.ArgsAppend, patch.ArgsAppend) diff --git a/internal/config/patch_test.go b/internal/config/patch_test.go index 7e42789d6..e6277effb 100644 --- a/internal/config/patch_test.go +++ b/internal/config/patch_test.go @@ -260,6 +260,8 @@ func TestApplyPatches_ProviderDeepMerge(t *testing.T) { Providers: map[string]ProviderSpec{ "custom": { Command: "agent", + ACPCommand: "agent-acp", + ACPArgs: []string{"serve"}, PromptMode: "arg", Env: map[string]string{"KEY": "val"}, }, @@ -268,10 +270,12 @@ func TestApplyPatches_ProviderDeepMerge(t *testing.T) { err := ApplyPatches(cfg, Patches{ Providers: []ProviderPatch{ { - Name: "custom", - Command: ptrStr("new-agent"), - Env: map[string]string{"KEY2": "val2"}, - EnvRemove: []string{"KEY"}, + Name: "custom", + Command: ptrStr("new-agent"), + ACPCommand: ptrStr("new-agent-acp"), + ACPArgs: []string{"rpc", "--stdio"}, + Env: map[string]string{"KEY2": "val2"}, + EnvRemove: []string{"KEY"}, }, }, }) @@ -282,6 +286,12 @@ func TestApplyPatches_ProviderDeepMerge(t *testing.T) { if p.Command != "new-agent" { t.Errorf("Command = %q, want %q", p.Command, "new-agent") } + if p.ACPCommand != "new-agent-acp" { + t.Errorf("ACPCommand = %q, want %q", p.ACPCommand, "new-agent-acp") + } + if got := strings.Join(p.ACPArgs, " "); got != "rpc --stdio" { + t.Errorf("ACPArgs = %q, want %q", got, "rpc --stdio") + } if p.PromptMode != "arg" { t.Errorf("PromptMode = %q, want %q (unchanged)", p.PromptMode, "arg") } @@ -298,6 +308,8 @@ func TestApplyPatches_ProviderReplace(t *testing.T) { Providers: map[string]ProviderSpec{ "custom": { Command: "old-agent", + ACPCommand: "old-agent-acp", + ACPArgs: []string{"serve"}, PromptMode: "arg", Env: map[string]string{"SECRET": "hidden"}, }, @@ -306,9 +318,11 @@ func TestApplyPatches_ProviderReplace(t *testing.T) { err := ApplyPatches(cfg, Patches{ Providers: []ProviderPatch{ { - Name: "custom", - Replace: true, - Command: ptrStr("new-agent"), + Name: "custom", + Replace: true, + Command: ptrStr("new-agent"), + ACPCommand: ptrStr("new-agent-acp"), + ACPArgs: []string{"rpc"}, }, }, }) @@ -319,6 +333,12 @@ func TestApplyPatches_ProviderReplace(t *testing.T) { if p.Command != "new-agent" { t.Errorf("Command = %q, want %q", p.Command, "new-agent") } + if p.ACPCommand != "new-agent-acp" { + t.Errorf("ACPCommand = %q, want %q", p.ACPCommand, "new-agent-acp") + } + if got := strings.Join(p.ACPArgs, " "); got != "rpc" { + t.Errorf("ACPArgs = %q, want %q", got, "rpc") + } // Replace clears fields not in patch. if p.PromptMode != "" { t.Errorf("PromptMode = %q, want empty (replaced)", p.PromptMode) diff --git a/internal/config/provider.go b/internal/config/provider.go index 7e9e6e327..14de5fa42 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -1,6 +1,8 @@ package config import ( + "strings" + "github.com/gastownhall/gascity/internal/shellquote" workerbuiltin "github.com/gastownhall/gascity/internal/worker/builtin" ) @@ -27,6 +29,9 @@ type OptionChoice struct { // json:"-" is intentional: FlagArgs must never appear in the public API DTO // (security boundary — prevents clients from seeing internal CLI flags). FlagArgs []string `toml:"flag_args" json:"-"` + // FlagAliases are equivalent CLI argument sequences stripped from legacy + // provider args. Like FlagArgs, they stay server-side only. + FlagAliases [][]string `toml:"flag_aliases,omitempty" json:"-"` } // ProviderSpec defines a named provider's startup parameters. @@ -127,6 +132,12 @@ type ProviderSpec struct { // Defaults to the cheapest/fastest model for each provider. // Examples: "haiku" (claude), "o4-mini" (codex), "gemini-2.5-flash" (gemini) TitleModel string `toml:"title_model,omitempty"` + // ACPCommand overrides Command when the session transport is ACP. + // When empty, Command is used for both tmux and ACP transports. + ACPCommand string `toml:"acp_command,omitempty"` + // ACPArgs overrides Args when the session transport is ACP. + // When nil, Args is used for both tmux and ACP transports. + ACPArgs []string `toml:"acp_args,omitempty"` } // Reserved prefixes for the Base field. @@ -187,6 +198,8 @@ type ResolvedProvider struct { OptionsSchema []ProviderOption PrintArgs []string TitleModel string + ACPCommand string + ACPArgs []string // EffectiveDefaults is the fully-merged option default map. // Computed from: schema Default -> provider OptionDefaults -> agent OptionDefaults. // Used by ResolveDefaultArgs() to produce CLI flags and by the API to @@ -202,6 +215,71 @@ func (rp *ResolvedProvider) CommandString() string { return rp.Command + " " + shellquote.Join(rp.Args) } +// ACPCommandString returns the command line for ACP transport sessions. +// Each field falls back independently: ACPCommand defaults to Command, +// and ACPArgs defaults to Args, so partial overrides are supported. +func (rp *ResolvedProvider) ACPCommandString() string { + cmd := rp.ACPCommand + args := rp.ACPArgs + if cmd == "" { + cmd = rp.Command + } + if args == nil { + args = rp.Args + } + if len(args) == 0 { + return cmd + } + return cmd + " " + shellquote.Join(args) +} + +// DefaultSessionTransport returns the transport used for provider-backed +// sessions when no template-level session override exists. +func (rp *ResolvedProvider) DefaultSessionTransport() string { + if rp == nil || !rp.SupportsACP { + return "" + } + family := strings.TrimSpace(rp.BuiltinAncestor) + if family == "" { + family = strings.TrimSpace(rp.Kind) + } + if family == "" { + family = strings.TrimSpace(rp.Name) + } + if family == "opencode" { + return "acp" + } + return "" +} + +// ProviderSessionCreateTransport returns the transport to use when creating a +// provider-backed session without any template-level session override. +func (rp *ResolvedProvider) ProviderSessionCreateTransport() string { + if rp == nil || !rp.SupportsACP { + return "" + } + if transport := rp.DefaultSessionTransport(); transport != "" { + return transport + } + if strings.TrimSpace(rp.ACPCommand) != "" || rp.ACPArgs != nil { + return "acp" + } + return "" +} + +// ResolveSessionCreateTransport returns the transport to use when creating a +// fresh session from an agent/template configuration. +func ResolveSessionCreateTransport(agentSession string, resolved *ResolvedProvider) string { + agentSession = strings.TrimSpace(agentSession) + if agentSession != "" { + return agentSession + } + if resolved == nil { + return "" + } + return strings.TrimSpace(resolved.ProviderSessionCreateTransport()) +} + // TitleModelFlagArgs resolves the TitleModel key against the "model" // OptionsSchema entry. Returns the CLI flag args for the title model, // or nil if TitleModel is empty or not found in the schema. @@ -307,6 +385,8 @@ func providerSpecFromWorker(spec workerbuiltin.BuiltinProviderSpec) ProviderSpec OptionsSchema: providerOptionsFromWorker(spec.OptionsSchema), PrintArgs: cloneStrings(spec.PrintArgs), TitleModel: spec.TitleModel, + ACPCommand: spec.ACPCommand, + ACPArgs: cloneStrings(spec.ACPArgs), } } @@ -334,9 +414,10 @@ func providerChoicesFromWorker(choices []workerbuiltin.BuiltinOptionChoice) []Op out := make([]OptionChoice, len(choices)) for i, choice := range choices { out[i] = OptionChoice{ - Value: choice.Value, - Label: choice.Label, - FlagArgs: cloneStrings(choice.FlagArgs), + Value: choice.Value, + Label: choice.Label, + FlagArgs: cloneStrings(choice.FlagArgs), + FlagAliases: cloneStringSlices(choice.FlagAliases), } } return out @@ -361,3 +442,14 @@ func cloneStrings(values []string) []string { copy(out, values) return out } + +func cloneStringSlices(values [][]string) [][]string { + if values == nil { + return nil + } + out := make([][]string, len(values)) + for i := range values { + out[i] = cloneStrings(values[i]) + } + return out +} diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 99e47ef8c..8fb9b48fd 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -1,6 +1,7 @@ package config import ( + "reflect" "testing" ) @@ -139,6 +140,34 @@ func TestBuiltinProvidersGemini(t *testing.T) { } } +func TestBuiltinProvidersCursor(t *testing.T) { + p := BuiltinProviders()["cursor"] + if p.Command != "cursor-agent" { + t.Errorf("Command = %q, want %q", p.Command, "cursor-agent") + } + if len(p.Args) != 1 || p.Args[0] != "-f" { + t.Errorf("Args = %v, want [-f]", p.Args) + } + if p.PromptMode != "arg" { + t.Errorf("PromptMode = %q, want %q", p.PromptMode, "arg") + } + if p.ReadyPromptPrefix != "\u2192 " { + t.Errorf("ReadyPromptPrefix = %q, want %q", p.ReadyPromptPrefix, "\u2192 ") + } + if p.ReadyDelayMs != 10000 { + t.Errorf("ReadyDelayMs = %d, want 10000", p.ReadyDelayMs) + } + if len(p.ProcessNames) != 1 || p.ProcessNames[0] != "cursor-agent" { + t.Errorf("ProcessNames = %v, want [cursor-agent]", p.ProcessNames) + } + if !derefBool(p.SupportsHooks) { + t.Error("SupportsHooks = false, want true") + } + if p.InstructionsFile != "AGENTS.md" { + t.Errorf("InstructionsFile = %q, want %q", p.InstructionsFile, "AGENTS.md") + } +} + func TestBuiltinProvidersReturnsNewMap(t *testing.T) { a := BuiltinProviders() b := BuiltinProviders() @@ -157,6 +186,12 @@ func TestBuiltinProvidersOpenCode(t *testing.T) { if p.Command != "opencode" { t.Errorf("Command = %q, want %q", p.Command, "opencode") } + if p.ACPCommand != "" { + t.Errorf("ACPCommand = %q, want empty fallback to Command", p.ACPCommand) + } + if !reflect.DeepEqual(p.ACPArgs, []string{"acp"}) { + t.Errorf("ACPArgs = %v, want [acp]", p.ACPArgs) + } if p.PromptMode != "none" { t.Errorf("PromptMode = %q, want %q", p.PromptMode, "none") } @@ -239,3 +274,196 @@ func TestCommandStringQuotesShellMetacharacters(t *testing.T) { t.Errorf("CommandString() = %q, want %q", got, want) } } + +func TestACPCommandString(t *testing.T) { + tests := []struct { + name string + rp ResolvedProvider + want string + }{ + { + name: "FullOverride", + rp: ResolvedProvider{ + Command: "opencode", + Args: []string{"--verbose"}, + ACPCommand: "opencode-acp", + ACPArgs: []string{"--json-rpc"}, + }, + want: "opencode-acp --json-rpc", + }, + { + name: "FallbackToCommand", + rp: ResolvedProvider{ + Command: "opencode", + Args: []string{"--verbose"}, + }, + want: "opencode --verbose", + }, + { + name: "PartialOverride_CommandOnly", + rp: ResolvedProvider{ + Command: "opencode", + Args: []string{"--verbose"}, + ACPCommand: "opencode-acp", + }, + want: "opencode-acp --verbose", + }, + { + name: "PartialOverride_ArgsOnly", + rp: ResolvedProvider{ + Command: "opencode", + Args: []string{"--verbose"}, + ACPArgs: []string{"--json-rpc"}, + }, + want: "opencode --json-rpc", + }, + { + name: "EmptyACPArgs", + rp: ResolvedProvider{ + Command: "opencode", + Args: []string{"--verbose"}, + ACPCommand: "opencode-acp", + ACPArgs: []string{}, + }, + want: "opencode-acp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rp.ACPCommandString() + if got != tt.want { + t.Errorf("ACPCommandString() = %q, want %q", got, tt.want) + } + }) + } + + // Verify FallbackToCommand produces same result as CommandString(). + t.Run("FallbackMatchesCommandString", func(t *testing.T) { + rp := &ResolvedProvider{Command: "opencode", Args: []string{"--verbose"}} + if rp.ACPCommandString() != rp.CommandString() { + t.Errorf("ACPCommandString() = %q, but CommandString() = %q — should match when no ACP overrides", + rp.ACPCommandString(), rp.CommandString()) + } + }) +} + +func TestDefaultSessionTransportOpenCodeFamilyDefaultsToACP(t *testing.T) { + tests := []struct { + name string + rp ResolvedProvider + }{ + { + name: "direct builtin name", + rp: ResolvedProvider{ + Name: "opencode", + SupportsACP: true, + }, + }, + { + name: "builtin ancestor", + rp: ResolvedProvider{ + Name: "custom-opencode", + BuiltinAncestor: "opencode", + SupportsACP: true, + }, + }, + { + name: "deprecated kind fallback", + rp: ResolvedProvider{ + Name: "custom-opencode", + Kind: "opencode", + SupportsACP: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.rp.DefaultSessionTransport(); got != "acp" { + t.Fatalf("DefaultSessionTransport() = %q, want %q", got, "acp") + } + }) + } +} + +func TestDefaultSessionTransportSupportsACPDoesNotImplyACPDefault(t *testing.T) { + rp := &ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + } + if got := rp.DefaultSessionTransport(); got != "" { + t.Fatalf("DefaultSessionTransport() = %q, want empty default transport", got) + } +} + +func TestProviderSessionCreateTransportUsesExplicitACPOverrides(t *testing.T) { + tests := []struct { + name string + rp ResolvedProvider + }{ + { + name: "explicit acp command", + rp: ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + ACPCommand: "/bin/custom-acp", + }, + }, + { + name: "explicit acp args", + rp: ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + ACPArgs: []string{"acp"}, + }, + }, + { + name: "opencode family remains acp", + rp: ResolvedProvider{ + Name: "custom-opencode", + BuiltinAncestor: "opencode", + SupportsACP: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.rp.ProviderSessionCreateTransport(); got != "acp" { + t.Fatalf("ProviderSessionCreateTransport() = %q, want %q", got, "acp") + } + }) + } +} + +func TestProviderSessionCreateTransportSupportsACPAloneStaysDefault(t *testing.T) { + rp := &ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + } + if got := rp.ProviderSessionCreateTransport(); got != "" { + t.Fatalf("ProviderSessionCreateTransport() = %q, want empty transport", got) + } +} + +func TestResolveSessionCreateTransportPrefersAgentSessionOverride(t *testing.T) { + got := ResolveSessionCreateTransport("acp", &ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + }) + if got != "acp" { + t.Fatalf("ResolveSessionCreateTransport() = %q, want %q", got, "acp") + } +} + +func TestResolveSessionCreateTransportFallsBackToProviderCreateTransport(t *testing.T) { + got := ResolveSessionCreateTransport("", &ResolvedProvider{ + Name: "custom-acp", + SupportsACP: true, + ACPCommand: "/bin/echo", + }) + if got != "acp" { + t.Fatalf("ResolveSessionCreateTransport() = %q, want %q", got, "acp") + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go index db34e8a7d..662cda113 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -250,6 +250,9 @@ func MergeProviderOverBuiltin(base, city ProviderSpec) ProviderSpec { if city.TitleModel != "" { result.TitleModel = city.TitleModel } + if city.ACPCommand != "" { + result.ACPCommand = city.ACPCommand + } // Slice fields: replace entirely when non-nil. if city.Args != nil { @@ -274,6 +277,9 @@ func MergeProviderOverBuiltin(base, city ProviderSpec) ProviderSpec { if city.PrintArgs != nil { result.PrintArgs = city.PrintArgs } + if city.ACPArgs != nil { + result.ACPArgs = city.ACPArgs + } // Map fields: merge additively (city keys win). if city.PermissionModes != nil { @@ -486,6 +492,7 @@ func specToResolved(name string, spec *ProviderSpec) *ResolvedProvider { ResumeCommand: spec.ResumeCommand, SessionIDFlag: spec.SessionIDFlag, TitleModel: spec.TitleModel, + ACPCommand: spec.ACPCommand, } // Deep-copy OptionsSchema to avoid aliasing the spec's slice. if len(spec.OptionsSchema) > 0 { @@ -500,6 +507,9 @@ func specToResolved(name string, spec *ProviderSpec) *ResolvedProvider { rp.OptionsSchema[i].Choices[j].FlagArgs = make([]string, len(c.FlagArgs)) copy(rp.OptionsSchema[i].Choices[j].FlagArgs, c.FlagArgs) } + if len(c.FlagAliases) > 0 { + rp.OptionsSchema[i].Choices[j].FlagAliases = cloneStringSlices(c.FlagAliases) + } } } } @@ -553,6 +563,10 @@ func specToResolved(name string, spec *ProviderSpec) *ResolvedProvider { rp.PrintArgs = make([]string, len(spec.PrintArgs)) copy(rp.PrintArgs, spec.PrintArgs) } + if spec.ACPArgs != nil { + rp.ACPArgs = make([]string, len(spec.ACPArgs)) + copy(rp.ACPArgs, spec.ACPArgs) + } return rp } @@ -700,6 +714,13 @@ func resolvedChainToSpec(r ResolvedProvider, leaf ProviderSpec) ProviderSpec { if r.TitleModel != "" { out.TitleModel = r.TitleModel } + if r.ACPCommand != "" { + out.ACPCommand = r.ACPCommand + } + if r.ACPArgs != nil { + out.ACPArgs = make([]string, len(r.ACPArgs)) + copy(out.ACPArgs, r.ACPArgs) + } if r.PrintArgs != nil { out.PrintArgs = append([]string(nil), r.PrintArgs...) } @@ -716,7 +737,7 @@ func resolvedChainToSpec(r ResolvedProvider, leaf ProviderSpec) ProviderSpec { } } if r.OptionsSchema != nil { - out.OptionsSchema = append([]ProviderOption(nil), r.OptionsSchema...) + out.OptionsSchema = deepCopyProviderOptions(r.OptionsSchema) } // EffectiveDefaults on ResolvedProvider is the merged defaults; fold // into OptionDefaults on the spec so downstream specToResolved picks diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index bd53da55b..c1bf46e6b 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" "reflect" + "strings" "testing" "github.com/gastownhall/gascity/internal/fsys" @@ -630,6 +631,48 @@ func TestResolveProviderBaseChainEmitsDangerousBypass(t *testing.T) { } } +func TestResolveProviderBaseChainStripsCodexAliases(t *testing.T) { + b := "builtin:codex" + city := map[string]ProviderSpec{ + "codex-max": { + Base: &b, + Command: "aimux", + Args: []string{ + "run", "codex", "--", + "--dangerously-bypass-approvals-and-sandbox", + "-m", "gpt-5.5", + "-c", "model_reasoning_effort=\"xhigh\"", + }, + ResumeCommand: "aimux run codex -- --dangerously-bypass-approvals-and-sandbox -m gpt-5.5 resume {{.SessionKey}}", + }, + } + agent := &Agent{Name: "codex-max", Provider: "codex-max"} + resolved, err := ResolveProvider(agent, nil, city, lookPathAll) + if err != nil { + t.Fatalf("ResolveProvider: %v", err) + } + wantArgs := []string{"run", "codex", "--"} + if !reflect.DeepEqual(resolved.Args, wantArgs) { + t.Fatalf("Args = %v, want %v", resolved.Args, wantArgs) + } + if got := resolved.EffectiveDefaults["model"]; got != "gpt-5.5" { + t.Fatalf("EffectiveDefaults[model] = %q, want gpt-5.5", got) + } + if got := resolved.EffectiveDefaults["effort"]; got != "xhigh" { + t.Fatalf("EffectiveDefaults[effort] = %q, want xhigh", got) + } + command := resolved.CommandString() + if defaultArgs := resolved.ResolveDefaultArgs(); len(defaultArgs) > 0 { + command = command + " " + strings.Join(defaultArgs, " ") + } + if strings.Count(command, "gpt-5.5") != 1 { + t.Fatalf("resolved launch command = %q, want one model flag", command) + } + if strings.Count(command, "model_reasoning_effort") != 1 { + t.Fatalf("resolved launch command = %q, want one effort flag", command) + } +} + func TestResolveProviderChainArgsAppendAffectsResolvedArgs(t *testing.T) { custom := map[string]ProviderSpec{ "codex": { @@ -860,6 +903,31 @@ func TestMergeProviderOverBuiltin(t *testing.T) { } } +func TestResolveProviderBuiltinOpenCodeCustomCommandKeepsACPArgsOnCustomBinary(t *testing.T) { + base := "builtin:opencode" + cityProviders := map[string]ProviderSpec{ + "custom-opencode": { + Base: &base, + Command: "custom-opencode", + }, + } + agent := &Agent{Name: "worker", Provider: "custom-opencode"} + + rp, err := ResolveProvider(agent, nil, cityProviders, lookPathOnly("custom-opencode")) + if err != nil { + t.Fatalf("ResolveProvider: %v", err) + } + if rp.Command != "custom-opencode" { + t.Fatalf("Command = %q, want custom-opencode", rp.Command) + } + if rp.ACPCommand != "" { + t.Fatalf("ACPCommand = %q, want empty fallback to Command", rp.ACPCommand) + } + if got := rp.ACPCommandString(); got != "custom-opencode acp" { + t.Fatalf("ACPCommandString() = %q, want custom-opencode acp", got) + } +} + // --- Tri-state capability bool tests --- // // These verify the three-way *bool semantics for SupportsHooks, @@ -1239,6 +1307,8 @@ func TestMergeProviderOverBuiltinFieldSync(t *testing.T) { OptionsSchema: []ProviderOption{{Key: "model"}}, PrintArgs: []string{"-p"}, TitleModel: "haiku", + ACPCommand: "custom-acp", + ACPArgs: []string{"acp-mode"}, } // Verify every field on city is non-zero (catches new fields not added to test data). diff --git a/internal/config/resolved_cache.go b/internal/config/resolved_cache.go index 04c9d92a9..ac6e35eee 100644 --- a/internal/config/resolved_cache.go +++ b/internal/config/resolved_cache.go @@ -183,6 +183,9 @@ func deepCopyResolvedProvider(r ResolvedProvider) ResolvedProvider { if c.FlagArgs != nil { nc.FlagArgs = append([]string(nil), c.FlagArgs...) } + if c.FlagAliases != nil { + nc.FlagAliases = cloneStringSlices(c.FlagAliases) + } nopt.Choices[j] = nc } } diff --git a/internal/config/session_model_phase0_spec_test.go b/internal/config/session_model_phase0_spec_test.go index 06f99a82c..ec85b3fa0 100644 --- a/internal/config/session_model_phase0_spec_test.go +++ b/internal/config/session_model_phase0_spec_test.go @@ -92,12 +92,16 @@ func TestPhase0ConfigDefaults_OnBootUnclaimsRoutedWorkByDefault(t *testing.T) { for _, want := range []string{ "bd list --metadata-field gc.routed_to=myrig/worker", "--status=in_progress", - "--assignee \"\"", + "--no-assignee", + "--status open", } { if !strings.Contains(got, want) { t.Fatalf("EffectiveOnBoot() = %q, want %q", got, want) } } + if strings.Contains(got, `--assignee ""`) { + t.Fatalf("EffectiveOnBoot() = %q, want to target only ownerless work instead of bulk-unassigning routed work", got) + } } func TestPhase0ConfigDefaults_OnDeathUnclaimsAssignedWorkByDefault(t *testing.T) { diff --git a/internal/configedit/configedit.go b/internal/configedit/configedit.go index 33bf295af..57f9bad72 100644 --- a/internal/configedit/configedit.go +++ b/internal/configedit/configedit.go @@ -711,7 +711,9 @@ type ProviderUpdate struct { DisplayName *string Base **string Command *string + ACPCommand *string Args []string // nil = not set, non-nil = replace + ACPArgs []string // nil = not set, non-nil = replace ArgsAppend []string // nil = not set, non-nil = replace PromptMode *string PromptFlag *string @@ -760,10 +762,17 @@ func (e *Editor) UpdateProvider(name string, patch ProviderUpdate) error { if patch.Command != nil { spec.Command = *patch.Command } + if patch.ACPCommand != nil { + spec.ACPCommand = *patch.ACPCommand + } if patch.Args != nil { spec.Args = make([]string, len(patch.Args)) copy(spec.Args, patch.Args) } + if patch.ACPArgs != nil { + spec.ACPArgs = make([]string, len(patch.ACPArgs)) + copy(spec.ACPArgs, patch.ACPArgs) + } if patch.ArgsAppend != nil { spec.ArgsAppend = make([]string, len(patch.ArgsAppend)) copy(spec.ArgsAppend, patch.ArgsAppend) diff --git a/internal/configedit/configedit_test.go b/internal/configedit/configedit_test.go index afcec12ac..cd9101185 100644 --- a/internal/configedit/configedit_test.go +++ b/internal/configedit/configedit_test.go @@ -1198,9 +1198,12 @@ func TestUpdateProvider(t *testing.T) { ed := configedit.NewEditor(fsys.OSFS{}, path) newCmd := "updated-cli" + newACPCmd := "updated-cli-acp" newName := "Updated Agent" err := ed.UpdateProvider("custom", configedit.ProviderUpdate{ Command: &newCmd, + ACPCommand: &newACPCmd, + ACPArgs: []string{"rpc", "--stdio"}, DisplayName: &newName, }) if err != nil { @@ -1212,6 +1215,12 @@ func TestUpdateProvider(t *testing.T) { if got.Command != "updated-cli" { t.Errorf("command = %q, want %q", got.Command, "updated-cli") } + if got.ACPCommand != "updated-cli-acp" { + t.Errorf("acp_command = %q, want %q", got.ACPCommand, "updated-cli-acp") + } + if len(got.ACPArgs) != 2 || got.ACPArgs[0] != "rpc" || got.ACPArgs[1] != "--stdio" { + t.Errorf("acp_args = %#v, want [rpc --stdio]", got.ACPArgs) + } if got.DisplayName != "Updated Agent" { t.Errorf("display_name = %q, want %q", got.DisplayName, "Updated Agent") } diff --git a/internal/convergence/condition.go b/internal/convergence/condition.go index 6e2a8bdcc..714d34c1a 100644 --- a/internal/convergence/condition.go +++ b/internal/convergence/condition.go @@ -52,6 +52,7 @@ type ConditionEnv struct { BeadID string Iteration int CityPath string + StorePath string WorkDir string WispID string DocPath string // from var.doc_path, may be empty @@ -66,7 +67,8 @@ type ConditionEnv struct { // Environ returns the environment variable slice for exec.Cmd. // Only whitelisted variables: PATH (safe default), HOME, TMPDIR, convergence -// vars, and GC_INTEGRATION_REAL_BD when present for integration-test bd shims. +// vars, Dolt/Beads connection env, and GC_INTEGRATION_REAL_BD when present for +// integration-test bd shims. func (ce ConditionEnv) Environ() []string { // Use CityPath as HOME to sandbox gate scripts from the // controller's home directory (which may contain .ssh, .gnupg, etc). @@ -74,11 +76,15 @@ func (ce ConditionEnv) Environ() []string { if home == "" { home = os.TempDir() } + storePath := ce.StorePath + if storePath == "" { + storePath = ce.CityPath + } env := []string{ "PATH=" + conditionPATH(), "HOME=" + home, "TMPDIR=" + os.TempDir(), - "BEADS_DIR=" + filepath.Join(ce.CityPath, ".beads"), + "BEADS_DIR=" + filepath.Join(storePath, ".beads"), "GC_BEAD_ID=" + ce.BeadID, "GC_ITERATION=" + strconv.Itoa(ce.Iteration), "GC_WISP_ID=" + ce.WispID, @@ -105,9 +111,28 @@ func (ce ConditionEnv) Environ() []string { if ce.WorkDir != "" { env = append(env, "GC_WORK_DIR="+ce.WorkDir) } + if ce.StorePath != "" { + env = append(env, "GC_STORE_PATH="+ce.StorePath) + } if realBD := os.Getenv("GC_INTEGRATION_REAL_BD"); realBD != "" { env = append(env, "GC_INTEGRATION_REAL_BD="+realBD) } + for _, key := range []string{ + "BEADS_DOLT_AUTO_START", + "BEADS_DOLT_SERVER_HOST", + "BEADS_DOLT_SERVER_PORT", + "BEADS_DOLT_SERVER_USER", + "BEADS_DOLT_PASSWORD", + "GC_DOLT", + "GC_DOLT_HOST", + "GC_DOLT_PORT", + "GC_DOLT_USER", + "GC_DOLT_PASSWORD", + } { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } return env } @@ -197,6 +222,9 @@ func runOnce(ctx context.Context, scriptPath string, env ConditionEnv, timeout t cmd := exec.CommandContext(execCtx, scriptPath) cmd.Dir = env.CityPath + if env.StorePath != "" { + cmd.Dir = env.StorePath + } if env.WorkDir != "" { cmd.Dir = env.WorkDir } diff --git a/internal/convergence/condition_test.go b/internal/convergence/condition_test.go index afcc4b69c..f415b0050 100644 --- a/internal/convergence/condition_test.go +++ b/internal/convergence/condition_test.go @@ -136,6 +136,65 @@ func TestConditionEnvEnvironPreservesIntegrationRealBD(t *testing.T) { } } +func TestConditionEnvEnvironUsesStorePathForBeadsDir(t *testing.T) { + env := ConditionEnv{ + BeadID: "bead-store", + Iteration: 1, + CityPath: "/city", + StorePath: "/rig", + } + + vars := env.Environ() + lookup := make(map[string]string) + for _, v := range vars { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + lookup[parts[0]] = parts[1] + } + } + + if got := lookup["BEADS_DIR"]; got != filepath.Join("/rig", ".beads") { + t.Fatalf("BEADS_DIR = %q, want rig beads dir", got) + } + if got := lookup["GC_STORE_PATH"]; got != "/rig" { + t.Fatalf("GC_STORE_PATH = %q, want /rig", got) + } + if got := lookup["GC_CITY"]; got != "/city" { + t.Fatalf("GC_CITY = %q, want /city", got) + } +} + +func TestConditionEnvEnvironPreservesDoltConnection(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_PORT", "33061") + t.Setenv("GC_DOLT_HOST", "127.0.0.1") + t.Setenv("GC_DOLT_PASSWORD", "secret") + + env := ConditionEnv{ + BeadID: "bead-dolt", + Iteration: 1, + CityPath: "/city", + } + + vars := env.Environ() + lookup := make(map[string]string) + for _, v := range vars { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + lookup[parts[0]] = parts[1] + } + } + + for key, want := range map[string]string{ + "BEADS_DOLT_SERVER_PORT": "33061", + "GC_DOLT_HOST": "127.0.0.1", + "GC_DOLT_PASSWORD": "secret", + } { + if got := lookup[key]; got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } +} + func TestResolveConditionPath(t *testing.T) { t.Run("absolute path", func(t *testing.T) { dir := t.TempDir() @@ -315,6 +374,40 @@ func TestRunConditionUsesWorkDir(t *testing.T) { } } +func TestRunConditionUsesStorePathAsDefaultWorkDir(t *testing.T) { + cityDir := t.TempDir() + storeDir := t.TempDir() + if err := os.WriteFile(filepath.Join(storeDir, "target.txt"), []byte("ok\n"), 0o644); err != nil { + t.Fatal(err) + } + + script := filepath.Join(cityDir, "check-store.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\npwd\nprintf '%s\\n' \"$BEADS_DIR\"\ncat target.txt\n"), 0o755); err != nil { + t.Fatal(err) + } + + env := ConditionEnv{ + BeadID: "b-store", + CityPath: cityDir, + StorePath: storeDir, + } + + result := RunCondition(context.Background(), script, env, 5*time.Second, 0) + if result.Outcome != GatePass { + t.Fatalf("Outcome = %q, want %q (stderr=%q)", result.Outcome, GatePass, result.Stderr) + } + if !strings.Contains(result.Stdout, storeDir) { + t.Errorf("Stdout = %q, want to contain store dir %q", result.Stdout, storeDir) + } + wantBeadsDir := filepath.Join(storeDir, ".beads") + if !strings.Contains(result.Stdout, wantBeadsDir) { + t.Errorf("Stdout = %q, want to contain BEADS_DIR %q", result.Stdout, wantBeadsDir) + } + if !strings.Contains(result.Stdout, "ok") { + t.Errorf("Stdout = %q, want to contain file contents", result.Stdout) + } +} + func TestConditionPATHUsesResolvedToolDirs(t *testing.T) { origPath := os.Getenv("PATH") t.Cleanup(func() { diff --git a/internal/dispatch/control.go b/internal/dispatch/control.go index abeb48213..14a1a18fc 100644 --- a/internal/dispatch/control.go +++ b/internal/dispatch/control.go @@ -38,29 +38,27 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions return ControlResult{}, fmt.Errorf("%s: no attempt found", bead.ID) } if attempt.Status != "closed" { - // Invariant violation: control bead should not be ready if attempt is open. - return ControlResult{}, fmt.Errorf("%s: latest attempt %s is %s, not closed (invariant violation)", bead.ID, attempt.ID, attempt.Status) + return ControlResult{}, ErrControlPending } attemptNum, _ := strconv.Atoi(attempt.Metadata["gc.attempt"]) result := classifyRetryAttempt(attempt) - - // Record decision in attempt log. - if err := appendAttemptLog(store, bead.ID, attemptNum, result.Outcome, result.Reason); err != nil { + attemptLog, err := appendAttemptLogValue(bead.Metadata["gc.attempt_log"], attemptNum, result.Outcome, result.Reason) + if err != nil { return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) } switch result.Outcome { case "pass": - if outputJSON := attempt.Metadata["gc.output_json"]; outputJSON != "" { - if err := store.SetMetadata(bead.ID, "gc.output_json", outputJSON); err != nil { - return ControlResult{}, fmt.Errorf("%s: propagating output: %w", bead.ID, err) - } + closeMetadata := map[string]string{ + "gc.attempt_log": attemptLog, + "gc.outcome": "pass", } - if err := propagateRetrySubjectMetadata(store, bead.ID, attempt); err != nil { - return ControlResult{}, fmt.Errorf("%s: propagating metadata: %w", bead.ID, err) + if outputJSON := attempt.Metadata["gc.output_json"]; outputJSON != "" { + closeMetadata["gc.output_json"] = outputJSON } - if err := setOutcomeAndClose(store, bead.ID, "pass"); err != nil { + copyNonGCMetadata(closeMetadata, attempt.Metadata) + if err := updateMetadataAndClose(store, bead.ID, closeMetadata); err != nil { return ControlResult{}, fmt.Errorf("%s: closing passed: %w", bead.ID, err) } scopeResult, err := reconcileClosedScopeMember(store, bead.ID) @@ -70,15 +68,14 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions return ControlResult{Processed: true, Action: "pass", Skipped: scopeResult.Skipped}, nil case "hard": - if err := store.SetMetadataBatch(bead.ID, map[string]string{ + if err := updateMetadataAndClose(store, bead.ID, map[string]string{ + "gc.attempt_log": attemptLog, + "gc.outcome": "fail", "gc.failed_attempt": strconv.Itoa(attemptNum), "gc.failure_class": "hard", "gc.failure_reason": result.Reason, "gc.final_disposition": "hard_fail", }); err != nil { - return ControlResult{}, fmt.Errorf("%s: marking hard fail: %w", bead.ID, err) - } - if err := setOutcomeAndClose(store, bead.ID, "fail"); err != nil { return ControlResult{}, fmt.Errorf("%s: closing hard-failed: %w", bead.ID, err) } scopeResult, err := reconcileClosedScopeMember(store, bead.ID) @@ -89,7 +86,7 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions case "transient": if attemptNum >= maxAttempts { - exhaustedResult, err := handleRetryExhaustion(store, bead.ID, attemptNum, result.Reason, onExhausted) + exhaustedResult, err := handleRetryExhaustion(store, bead.ID, attemptNum, result.Reason, onExhausted, attemptLog) if err != nil { return ControlResult{}, err } @@ -102,6 +99,9 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions } // Spawn next attempt. + if err := store.SetMetadata(bead.ID, "gc.attempt_log", attemptLog); err != nil { + return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) + } nextAttempt := attemptNum + 1 if err := spawnNextAttempt(context.Background(), store, bead, nextAttempt, opts); err != nil { // Controller-internal failure → close with hard error. @@ -139,7 +139,7 @@ func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions return ControlResult{}, fmt.Errorf("%s: no iteration found", bead.ID) } if iteration.Status != "closed" { - return ControlResult{}, fmt.Errorf("%s: latest iteration %s is %s, not closed (invariant violation)", bead.ID, iteration.ID, iteration.Status) + return ControlResult{}, ErrControlPending } iterationNum, _ := strconv.Atoi(iteration.Metadata["gc.attempt"]) @@ -164,17 +164,20 @@ func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions return ControlResult{}, fmt.Errorf("%s: running check: %w", bead.ID, err) } - if err := appendAttemptLog(store, bead.ID, iterationNum, checkResult.Outcome, checkResult.Stderr); err != nil { + attemptLog, err := appendAttemptLogValue(bead.Metadata["gc.attempt_log"], iterationNum, checkResult.Outcome, checkResult.Stderr) + if err != nil { return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) } if checkResult.Outcome == convergence.GatePass { + closeMetadata := map[string]string{ + "gc.attempt_log": attemptLog, + "gc.outcome": "pass", + } if outputJSON := iteration.Metadata["gc.output_json"]; outputJSON != "" { - if err := store.SetMetadata(bead.ID, "gc.output_json", outputJSON); err != nil { - return ControlResult{}, fmt.Errorf("%s: propagating output: %w", bead.ID, err) - } + closeMetadata["gc.output_json"] = outputJSON } - if err := setOutcomeAndClose(store, bead.ID, "pass"); err != nil { + if err := updateMetadataAndClose(store, bead.ID, closeMetadata); err != nil { return ControlResult{}, fmt.Errorf("%s: closing passed: %w", bead.ID, err) } scopeResult, err := reconcileClosedScopeMember(store, bead.ID) @@ -185,13 +188,11 @@ func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions } if iterationNum >= maxAttempts { - if err := store.SetMetadataBatch(bead.ID, map[string]string{ + if err := updateMetadataAndClose(store, bead.ID, map[string]string{ + "gc.attempt_log": attemptLog, "gc.outcome": "fail", "gc.failed_attempt": strconv.Itoa(iterationNum), }); err != nil { - return ControlResult{}, fmt.Errorf("%s: marking exhausted: %w", bead.ID, err) - } - if err := setOutcomeAndClose(store, bead.ID, "fail"); err != nil { return ControlResult{}, fmt.Errorf("%s: closing exhausted: %w", bead.ID, err) } scopeResult, err := reconcileClosedScopeMember(store, bead.ID) @@ -202,6 +203,9 @@ func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions } // Spawn next iteration. + if err := store.SetMetadata(bead.ID, "gc.attempt_log", attemptLog); err != nil { + return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) + } nextIteration := iterationNum + 1 if err := spawnNextAttempt(context.Background(), store, bead, nextIteration, opts); err != nil { _ = store.SetMetadataBatch(bead.ID, map[string]string{ @@ -218,31 +222,29 @@ func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions return ControlResult{Processed: true, Action: "retry", Created: 1}, nil } -func handleRetryExhaustion(store beads.Store, beadID string, attemptNum int, reason, onExhausted string) (ControlResult, error) { +func handleRetryExhaustion(store beads.Store, beadID string, attemptNum int, reason, onExhausted, attemptLog string) (ControlResult, error) { if onExhausted == "soft_fail" { - if err := store.SetMetadataBatch(beadID, map[string]string{ + if err := updateMetadataAndClose(store, beadID, map[string]string{ + "gc.attempt_log": attemptLog, + "gc.outcome": "pass", "gc.failed_attempt": strconv.Itoa(attemptNum), "gc.failure_class": "transient", "gc.failure_reason": reason, "gc.final_disposition": "soft_fail", }); err != nil { - return ControlResult{}, fmt.Errorf("%s: marking soft-fail: %w", beadID, err) - } - if err := setOutcomeAndClose(store, beadID, "pass"); err != nil { return ControlResult{}, fmt.Errorf("%s: closing soft-failed: %w", beadID, err) } return ControlResult{Processed: true, Action: "soft-fail"}, nil } - if err := store.SetMetadataBatch(beadID, map[string]string{ + if err := updateMetadataAndClose(store, beadID, map[string]string{ + "gc.attempt_log": attemptLog, + "gc.outcome": "fail", "gc.failed_attempt": strconv.Itoa(attemptNum), "gc.failure_class": "transient", "gc.failure_reason": reason, "gc.final_disposition": "hard_fail", }); err != nil { - return ControlResult{}, fmt.Errorf("%s: marking exhausted: %w", beadID, err) - } - if err := setOutcomeAndClose(store, beadID, "fail"); err != nil { return ControlResult{}, fmt.Errorf("%s: closing exhausted: %w", beadID, err) } return ControlResult{Processed: true, Action: "fail"}, nil @@ -271,7 +273,7 @@ func spawnNextAttempt(ctx context.Context, store beads.Store, control beads.Bead // Attach bypasses graph compile routing, so spawned attempts need their // execution lane restored manually. Prefer each step's explicit target when // available, and only inherit the parent execution lane as a fallback. - executionRoute := control.Metadata["gc.execution_routed_to"] + executionRoute := strings.TrimSpace(control.Metadata["gc.execution_routed_to"]) routeCfg := loadAttemptRouteConfig(opts.CityPath) for i := range recipe.Steps { if recipe.Steps[i].Metadata["gc.kind"] == "spec" { @@ -286,6 +288,8 @@ func spawnNextAttempt(ctx context.Context, store beads.Store, control beads.Bead } if target == "" { target = executionRoute + } else { + target = qualifyAttemptTargetWithSourceRoute(target, executionRoute, routeCfg) } if isAttemptControlKind(recipe.Steps[i].Metadata["gc.kind"]) { applyAttemptControlStepRoute(&recipe.Steps[i], target, routeCfg, store) @@ -315,6 +319,23 @@ func spawnNextAttempt(ctx context.Context, store beads.Store, control beads.Bead return nil } +func qualifyAttemptTargetWithSourceRoute(target, sourceRoute string, cfg *config.City) string { + target = strings.TrimSpace(target) + if target == "" || strings.Contains(target, "/") || cfg == nil { + return target + } + sourceRoute = strings.TrimSpace(sourceRoute) + slash := strings.IndexByte(sourceRoute, '/') + if slash <= 0 { + return target + } + candidate := sourceRoute[:slash] + "/" + target + if config.FindAgent(cfg, candidate) != nil || config.FindNamedSession(cfg, candidate) != nil { + return candidate + } + return target +} + // buildAttemptRecipe constructs a minimal formula.Recipe for one attempt // from the frozen step spec. func buildAttemptRecipe(step *formula.Step, control beads.Bead, attemptNum int) *formula.Recipe { @@ -574,8 +595,13 @@ func applyAttemptStepRoute(step *formula.RecipeStep, target string, cfg *config. step.Assignee = binding.directSessionID return } - step.Metadata["gc.routed_to"] = binding.qualifiedName - step.Metadata["gc.execution_routed_to"] = binding.qualifiedName + if binding.qualifiedName != "" { + step.Metadata["gc.routed_to"] = binding.qualifiedName + step.Metadata["gc.execution_routed_to"] = binding.qualifiedName + } else { + delete(step.Metadata, "gc.routed_to") + delete(step.Metadata, "gc.execution_routed_to") + } step.Labels = removeAttemptPoolLabels(step.Labels) if binding.metadataOnly { step.Assignee = "" @@ -597,9 +623,11 @@ func applyAttemptControlStepRoute(step *formula.RecipeStep, executionTarget stri if step.Metadata == nil { step.Metadata = make(map[string]string) } + resolvedExecutionTarget := strings.TrimSpace(executionTarget) if binding, ok := resolveAttemptRouteBinding(executionTarget, cfg, store); ok { switch { case binding.qualifiedName != "": + resolvedExecutionTarget = binding.qualifiedName step.Metadata["gc.execution_routed_to"] = binding.qualifiedName case executionTarget != "": // Direct session delivery still executes via the named/session target, @@ -615,18 +643,10 @@ func applyAttemptControlStepRoute(step *formula.RecipeStep, executionTarget stri } step.Labels = removeAttemptPoolLabels(step.Labels) - controlTarget := config.ControlDispatcherAgentName - if binding, ok := resolveAttemptRouteBinding(controlTarget, cfg, store); ok { - step.Metadata["gc.routed_to"] = controlTarget - if binding.directSessionID != "" { - step.Assignee = binding.directSessionID - return - } - if binding.metadataOnly { - step.Assignee = "" - return - } - step.Assignee = binding.sessionName + controlTarget := controlDispatcherTargetForExecutionTarget(resolvedExecutionTarget) + if assignee, ok := resolveAttemptControlAssignee(controlTarget, cfg, store); ok { + delete(step.Metadata, "gc.routed_to") + step.Assignee = assignee return } @@ -634,6 +654,42 @@ func applyAttemptControlStepRoute(step *formula.RecipeStep, executionTarget stri step.Assignee = "" } +func controlDispatcherTargetForExecutionTarget(executionTarget string) string { + executionTarget = strings.TrimSpace(executionTarget) + if slash := strings.IndexByte(executionTarget, '/'); slash > 0 { + return executionTarget[:slash] + "/" + config.ControlDispatcherAgentName + } + return config.ControlDispatcherAgentName +} + +func resolveAttemptControlAssignee(target string, cfg *config.City, store beads.Store) (string, bool) { + target = strings.TrimSpace(target) + if target == "" { + return "", false + } + if binding, ok := resolveAttemptRouteBinding(target, cfg, store); ok { + if binding.directSessionID != "" { + return binding.directSessionID, true + } + if binding.sessionName != "" { + return binding.sessionName, true + } + } + if cfg != nil { + if named := config.FindNamedSession(cfg, target); named != nil { + if spec, ok := session.FindNamedSessionSpec(cfg, cfg.EffectiveCityName(), named.QualifiedName()); ok && spec.SessionName != "" { + return spec.SessionName, true + } + } + if agentCfg := config.FindAgent(cfg, target); agentCfg != nil { + if sessionName := config.NamedSessionRuntimeName(cfg.EffectiveCityName(), cfg.Workspace, agentCfg.QualifiedName()); sessionName != "" { + return sessionName, true + } + } + } + return "", false +} + func isAttemptControlKind(kind string) bool { switch kind { case "check", "fanout", "retry-eval", "scope-check", "workflow-finalize", "retry", "ralph": @@ -656,14 +712,17 @@ func resolveAttemptRouteBinding(target string, cfg *config.City, store beads.Sto } if cfg != nil { if named := config.FindNamedSession(cfg, target); named != nil { - if store != nil { - if spec, ok := session.FindNamedSessionSpec(cfg, cfg.EffectiveCityName(), named.QualifiedName()); ok { + if spec, ok := session.FindNamedSessionSpec(cfg, cfg.EffectiveCityName(), named.QualifiedName()); ok { + if store != nil { if candidates, err := store.List(beads.ListQuery{Label: session.LabelSession}); err == nil { if bead, found := session.FindCanonicalNamedSessionBead(candidates, spec); found { return attemptRouteBinding{directSessionID: bead.ID}, true } } } + if spec.SessionName != "" { + return attemptRouteBinding{sessionName: spec.SessionName}, true + } } return attemptRouteBinding{ qualifiedName: named.QualifiedName(), @@ -937,10 +996,17 @@ func appendAttemptLog(store beads.Store, controlID string, attempt int, outcome, if err != nil { return err } + logJSON, err := appendAttemptLogValue(control.Metadata["gc.attempt_log"], attempt, outcome, reason) + if err != nil { + return err + } + return store.SetMetadata(controlID, "gc.attempt_log", logJSON) +} +func appendAttemptLogValue(existing string, attempt int, outcome, reason string) (string, error) { var log []map[string]string - if raw := control.Metadata["gc.attempt_log"]; raw != "" { - _ = json.Unmarshal([]byte(raw), &log) + if existing != "" { + _ = json.Unmarshal([]byte(existing), &log) } entry := map[string]string{ @@ -967,10 +1033,27 @@ func appendAttemptLog(store beads.Store, controlID string, attempt int, outcome, log = append(log, entry) logJSON, err := json.Marshal(log) if err != nil { - return err + return "", err } - return store.SetMetadata(controlID, "gc.attempt_log", string(logJSON)) + return string(logJSON), nil +} + +func copyNonGCMetadata(dst, src map[string]string) { + for key, value := range src { + if key == "" || strings.HasPrefix(key, "gc.") { + continue + } + dst[key] = value + } +} + +func updateMetadataAndClose(store beads.Store, beadID string, metadata map[string]string) error { + status := "closed" + return store.Update(beadID, beads.UpdateOpts{ + Status: &status, + Metadata: metadata, + }) } // Note: listByWorkflowRoot, setOutcomeAndClose, propagateRetrySubjectMetadata, diff --git a/internal/dispatch/control_integration_test.go b/internal/dispatch/control_integration_test.go index 2de81db6d..12a0610b3 100644 --- a/internal/dispatch/control_integration_test.go +++ b/internal/dispatch/control_integration_test.go @@ -2,6 +2,7 @@ package dispatch import ( "encoding/json" + "errors" "os" "path/filepath" "strconv" @@ -593,6 +594,11 @@ dir = "gascity" [agent.pool] min = 0 max = -1 + +[[agent]] +name = "control-dispatcher" +dir = "gascity" +max_active_sessions = 1 `), 0o644); err != nil { t.Fatalf("write city.toml: %v", err) } @@ -671,8 +677,8 @@ max = -1 if claude.ID == "" { t.Fatal("review-claude child not created") } - if claude.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("review-claude gc.routed_to = %q, want %q", claude.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if got := claude.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("review-claude gc.routed_to = %q, want empty direct dispatcher assignee", got) } if claude.Metadata["gc.execution_routed_to"] != "gascity/claude" { t.Fatalf("review-claude gc.execution_routed_to = %q, want gascity/claude", claude.Metadata["gc.execution_routed_to"]) @@ -680,16 +686,16 @@ max = -1 if containsString(claude.Labels, "pool:gascity/claude") { t.Fatalf("review-claude labels = %v, should not contain legacy pool label", claude.Labels) } - if claude.Assignee != "" { - t.Fatalf("review-claude assignee = %q, want empty metadata-only control route", claude.Assignee) + if claude.Assignee != "gascity--control-dispatcher" { + t.Fatalf("review-claude assignee = %q, want gascity--control-dispatcher", claude.Assignee) } codex := findAttemptByRef(t, store, root.ID, "mol-adopt-pr-v2.review-loop.iteration.2.review-codex") if codex.ID == "" { t.Fatal("review-codex child not created") } - if codex.Metadata["gc.routed_to"] != config.ControlDispatcherAgentName { - t.Fatalf("review-codex gc.routed_to = %q, want %q", codex.Metadata["gc.routed_to"], config.ControlDispatcherAgentName) + if got := codex.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("review-codex gc.routed_to = %q, want empty direct dispatcher assignee", got) } if codex.Metadata["gc.execution_routed_to"] != "gascity/codex" { t.Fatalf("review-codex gc.execution_routed_to = %q, want gascity/codex", codex.Metadata["gc.execution_routed_to"]) @@ -700,8 +706,8 @@ max = -1 if containsString(codex.Labels, "pool:gascity/claude") { t.Fatalf("review-codex labels = %v, should not contain pool:gascity/claude", codex.Labels) } - if codex.Assignee != "" { - t.Fatalf("review-codex assignee = %q, want empty metadata-only control route", codex.Assignee) + if codex.Assignee != "gascity--control-dispatcher" { + t.Fatalf("review-codex assignee = %q, want gascity--control-dispatcher", codex.Assignee) } synthesize := findAttemptByRef(t, store, root.ID, "mol-adopt-pr-v2.review-loop.iteration.2.synthesize") @@ -908,7 +914,7 @@ func TestResolveAttemptRouteBinding_NamedSessionTargetUsesCanonicalBeadID(t *tes } } -func TestResolveAttemptRouteBinding_NamedSessionTargetWithoutCanonicalBeadUsesMetadataOnly(t *testing.T) { +func TestResolveAttemptRouteBinding_NamedSessionTargetWithoutCanonicalBeadUsesSessionName(t *testing.T) { t.Parallel() store := beads.NewMemStore() @@ -929,11 +935,180 @@ func TestResolveAttemptRouteBinding_NamedSessionTargetWithoutCanonicalBeadUsesMe if !ok { t.Fatal("resolveAttemptRouteBinding did not resolve named target") } - if binding.directSessionID != "" || binding.sessionName != "" { - t.Fatalf("binding = %+v, want no direct or legacy session-name target without a canonical bead", binding) + if binding.directSessionID != "" { + t.Fatalf("directSessionID = %q, want empty without canonical bead", binding.directSessionID) + } + if binding.sessionName != "worker" { + t.Fatalf("sessionName = %q, want worker", binding.sessionName) + } + if binding.qualifiedName != "" || binding.metadataOnly { + t.Fatalf("binding = %+v, want concrete session-name route", binding) + } +} + +func TestApplyAttemptControlStepRoute_ImplicitControlDispatcherUsesConcreteAssignee(t *testing.T) { + t.Parallel() + + cfg := &config.City{ + Workspace: config.Workspace{Name: "maintainer-city"}, + Daemon: config.DaemonConfig{FormulaV2: true}, + Rigs: []config.Rig{{ + Name: "gascity", + Path: t.TempDir(), + }}, + Agents: []config.Agent{{ + Name: "claude", + Dir: "gascity", + }}, + } + config.InjectImplicitAgents(cfg) + + step := &formula.RecipeStep{ + Metadata: map[string]string{ + "gc.routed_to": "stale-route", + }, + } + applyAttemptControlStepRoute(step, "gascity/claude", cfg, beads.NewMemStore()) + + if step.Assignee != "gascity--control-dispatcher" { + t.Fatalf("assignee = %q, want gascity--control-dispatcher", step.Assignee) + } + if got := step.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("gc.routed_to = %q, want empty for concrete control dispatcher assignee", got) + } + if got := step.Metadata["gc.execution_routed_to"]; got != "gascity/claude" { + t.Fatalf("gc.execution_routed_to = %q, want gascity/claude", got) + } +} + +func TestSpawnNextAttemptUsesSourceRigForBareChildControlRoute(t *testing.T) { + t.Parallel() + + cityPath := t.TempDir() + if err := os.WriteFile(filepath.Join(cityPath, "city.toml"), []byte(` +[workspace] +name = "maintainer-city" + +[daemon] +formula_v2 = true + +[[rigs]] +name = "frontend" +path = "/tmp/frontend" + +[[rigs]] +name = "backend" +path = "/tmp/backend" + +[[agent]] +name = "reviewer" +dir = "frontend" + +[[agent]] +name = "control-dispatcher" +dir = "frontend" +max_active_sessions = 1 + +[[agent]] +name = "reviewer" +dir = "backend" + +[[agent]] +name = "control-dispatcher" +dir = "backend" +max_active_sessions = 1 +`), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + + store := beads.NewMemStore() + spec := &formula.Step{ + ID: "review-loop", + Title: "Review loop", + Type: "task", + Ralph: &formula.RalphSpec{MaxAttempts: 3}, + Children: []*formula.Step{ + { + ID: "review", + Title: "Review", + Type: "task", + Metadata: map[string]string{ + "gc.run_target": "reviewer", + }, + Retry: &formula.RetrySpec{MaxAttempts: 2}, + }, + }, + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal step spec: %v", err) + } + + root := mustCreate(t, store, beads.Bead{ + Title: "workflow", + Metadata: map[string]string{"gc.kind": "workflow"}, + }) + control := mustCreate(t, store, beads.Bead{ + Title: "review-loop", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-adopt-pr-v2.review-loop", + "gc.step_id": "review-loop", + "gc.source_step_spec": string(specJSON), + "gc.control_epoch": "1", + "gc.execution_routed_to": "frontend/reviewer", + }, + }) + + if err := spawnNextAttempt(t.Context(), store, control, 2, ProcessOptions{CityPath: cityPath}); err != nil { + t.Fatalf("spawnNextAttempt: %v", err) + } + + review := findAttemptByRef(t, store, root.ID, "mol-adopt-pr-v2.review-loop.iteration.2.review") + if review.ID == "" { + t.Fatal("review child not created") + } + if got := review.Metadata["gc.execution_routed_to"]; got != "frontend/reviewer" { + t.Fatalf("review gc.execution_routed_to = %q, want frontend/reviewer", got) + } + if got := review.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("review gc.routed_to = %q, want empty direct dispatcher assignee", got) + } + if review.Assignee != "frontend--control-dispatcher" { + t.Fatalf("review assignee = %q, want frontend--control-dispatcher", review.Assignee) + } +} + +func TestApplyAttemptControlStepRoute_ConfiguredControlDispatcherNeverUsesMetadataRoute(t *testing.T) { + t.Parallel() + + cfg := &config.City{ + Workspace: config.Workspace{Name: "maintainer-city"}, + Agents: []config.Agent{ + { + Name: "claude", + Dir: "gascity", + }, + { + Name: "control-dispatcher", + Dir: "gascity", + }, + }, + } + + step := &formula.RecipeStep{ + Metadata: map[string]string{ + "gc.routed_to": "stale-route", + }, + } + applyAttemptControlStepRoute(step, "gascity/claude", cfg, beads.NewMemStore()) + + if step.Assignee != "gascity--control-dispatcher" { + t.Fatalf("assignee = %q, want gascity--control-dispatcher", step.Assignee) } - if binding.qualifiedName != "worker" || !binding.metadataOnly { - t.Fatalf("binding = %+v, want metadata-only worker route", binding) + if got := step.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("gc.routed_to = %q, want empty for concrete control dispatcher assignee", got) } } @@ -999,8 +1174,8 @@ func TestApplyAttemptControlStepRoute_KeepsControlBeadsOnDispatcherForNamedExecu if got := step.Metadata["gc.execution_routed_to"]; got != "worker" { t.Fatalf("gc.execution_routed_to = %q, want worker", got) } - if got := step.Metadata["gc.routed_to"]; got != config.ControlDispatcherAgentName { - t.Fatalf("gc.routed_to = %q, want %q", got, config.ControlDispatcherAgentName) + if got := step.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("gc.routed_to = %q, want empty for concrete control-dispatcher assignee", got) } if step.Assignee != dispatcher.ID { t.Fatalf("assignee = %q, want canonical control-dispatcher bead %q", step.Assignee, dispatcher.ID) @@ -1088,14 +1263,14 @@ func TestRetryIdempotencyKeyPreventsDoubleSpawn(t *testing.T) { allAfterFirst, _ := store.ListOpen() countAfterFirst := len(allAfterFirst) - // Process again with same state — epoch conflict should prevent double spawn. + // Process again with same state -- epoch conflict should prevent double spawn. // The epoch was already incremented by the first Attach, so a second // processRetryControl with the same attempt (attempt 1 still closed, attempt 2 // still open) will find attempt 2 as the latest and see it's not closed. - // This verifies the invariant violation guard. + // This verifies the pending guard. _, err = processRetryControl(store, mustGet(t, store, control.ID), ProcessOptions{}) - if err == nil { - t.Fatal("expected error on second process (attempt 2 is open)") + if !errors.Is(err, ErrControlPending) { + t.Fatalf("second process error = %v, want %v", err, ErrControlPending) } // No new beads should have been created. diff --git a/internal/dispatch/control_test.go b/internal/dispatch/control_test.go index c1c3312fc..40342f8cb 100644 --- a/internal/dispatch/control_test.go +++ b/internal/dispatch/control_test.go @@ -2,8 +2,8 @@ package dispatch import ( "encoding/json" + "errors" "strconv" - "strings" "testing" "github.com/gastownhall/gascity/internal/beads" @@ -72,6 +72,69 @@ func TestProcessRetryControlPass(t *testing.T) { } } +func TestProcessRetryControlPassClosesWithSingleFinalMetadataUpdate(t *testing.T) { + t.Parallel() + base := beads.NewMemStore() + + root := mustCreate(t, base, beads.Bead{ + Title: "workflow", + Metadata: map[string]string{"gc.kind": "workflow"}, + }) + control := mustCreate(t, base, beads.Bead{ + Title: "review", + Metadata: map[string]string{ + "gc.kind": "retry", + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-test.review", + "gc.step_id": "review", + "gc.max_attempts": "3", + "gc.on_exhausted": "hard_fail", + "gc.source_step_spec": `{"id":"review","title":"Review","type":"task","retry":{"max_attempts":3}}`, + "gc.control_epoch": "1", + }, + }) + attempt1 := mustCreate(t, base, beads.Bead{ + Title: "review attempt 1", + Metadata: map[string]string{ + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-test.review.attempt.1", + "gc.attempt": "1", + "gc.outcome": "pass", + "gc.output_json": `{"ok":true}`, + "review.verdict": "approved", + }, + }) + mustClose(t, base, attempt1.ID) + mustDep(t, base, control.ID, attempt1.ID, "blocks") + + store := &controlCloseTrackingStore{Store: base, targetID: control.ID} + result, err := processRetryControl(store, mustGet(t, store, control.ID), ProcessOptions{}) + if err != nil { + t.Fatalf("processRetryControl: %v", err) + } + if !result.Processed || result.Action != "pass" { + t.Fatalf("result = %+v, want processed pass", result) + } + if store.setMetadataCalls != 0 || store.setMetadataBatchCalls != 0 { + t.Fatalf("metadata calls before close = SetMetadata:%d SetMetadataBatch:%d, want none", store.setMetadataCalls, store.setMetadataBatchCalls) + } + if store.closeUpdateCalls != 1 { + t.Fatalf("close update calls = %d, want 1", store.closeUpdateCalls) + } + for key, want := range map[string]string{ + "gc.outcome": "pass", + "gc.output_json": `{"ok":true}`, + "review.verdict": "approved", + } { + if got := store.closeUpdateMetadata[key]; got != want { + t.Fatalf("close metadata %s = %q, want %q", key, got, want) + } + } + if store.closeUpdateMetadata["gc.attempt_log"] == "" { + t.Fatal("close metadata missing gc.attempt_log") + } +} + func TestProcessRetryControlHardFail(t *testing.T) { t.Parallel() store := beads.NewMemStore() @@ -439,11 +502,8 @@ func TestProcessRetryControlInvariantViolation(t *testing.T) { mustDep(t, store, control.ID, attempt1.ID, "blocks") _, err := processRetryControl(store, mustGet(t, store, control.ID), ProcessOptions{}) - if err == nil { - t.Fatal("expected invariant violation error") - } - if !strings.Contains(err.Error(), "invariant violation") { - t.Fatalf("error = %v, want invariant violation", err) + if !errors.Is(err, ErrControlPending) { + t.Fatalf("error = %v, want %v", err, ErrControlPending) } } @@ -847,6 +907,42 @@ func TestProcessRalphControlClosesEnclosingScopeOnIterationFailure(t *testing.T) } } +func TestProcessRalphControlReturnsPendingForOpenIteration(t *testing.T) { + t.Parallel() + store := beads.NewMemStore() + + root := mustCreate(t, store, beads.Bead{ + Title: "workflow", + Metadata: map[string]string{"gc.kind": "workflow"}, + }) + control := mustCreate(t, store, beads.Bead{ + Title: "review loop", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-test.review-loop", + "gc.step_id": "review-loop", + "gc.max_attempts": "2", + }, + }) + iteration := mustCreate(t, store, beads.Bead{ + Title: "review loop iteration 1", + Metadata: map[string]string{ + "gc.kind": "scope", + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-test.review-loop.iteration.1", + "gc.scope_role": "body", + "gc.attempt": "1", + }, + }) + mustDep(t, store, control.ID, iteration.ID, "blocks") + + _, err := processRalphControl(store, mustGet(t, store, control.ID), ProcessOptions{}) + if !errors.Is(err, ErrControlPending) { + t.Fatalf("error = %v, want %v", err, ErrControlPending) + } +} + // TestReconcileClosedScopeMemberRalphPass covers the pass-side symmetry of // TestProcessRalphControlClosesEnclosingScopeOnIterationFailure: when a scoped // ralph control closes with gc.outcome=pass, reconcileClosedScopeMember must @@ -1204,6 +1300,40 @@ func mustDep(t *testing.T, store beads.Store, from, to, depType string) { //noli } } +type controlCloseTrackingStore struct { + beads.Store + targetID string + setMetadataCalls int + setMetadataBatchCalls int + closeUpdateCalls int + closeUpdateMetadata map[string]string +} + +func (s *controlCloseTrackingStore) SetMetadata(id, key, value string) error { + if id == s.targetID { + s.setMetadataCalls++ + } + return s.Store.SetMetadata(id, key, value) +} + +func (s *controlCloseTrackingStore) SetMetadataBatch(id string, kvs map[string]string) error { + if id == s.targetID { + s.setMetadataBatchCalls++ + } + return s.Store.SetMetadataBatch(id, kvs) +} + +func (s *controlCloseTrackingStore) Update(id string, opts beads.UpdateOpts) error { + if id == s.targetID && opts.Status != nil && *opts.Status == "closed" { + s.closeUpdateCalls++ + s.closeUpdateMetadata = make(map[string]string, len(opts.Metadata)) + for key, value := range opts.Metadata { + s.closeUpdateMetadata[key] = value + } + } + return s.Store.Update(id, opts) +} + // --------------------------------------------------------------------------- // Regression: scope bead must block on children (not parent-child deadlock) // --------------------------------------------------------------------------- diff --git a/internal/dispatch/ralph.go b/internal/dispatch/ralph.go index a9dcd9e2c..343bb61b4 100644 --- a/internal/dispatch/ralph.go +++ b/internal/dispatch/ralph.go @@ -146,6 +146,10 @@ func runRalphCheck(store beads.Store, bead, subject beads.Bead, attempt int, opt if cityPath == "" { return convergence.GateResult{}, fmt.Errorf("%s: missing city path for exec check", bead.ID) } + storePath := opts.StorePath + if storePath == "" { + storePath = cityPath + } workDir := resolveInheritedMetadata(store, bead, "work_dir", "gc.work_dir") resolvedWorkDir := "" @@ -153,10 +157,10 @@ func runRalphCheck(store beads.Store, bead, subject beads.Bead, attempt int, opt if filepath.IsAbs(workDir) { resolvedWorkDir = workDir } else { - resolvedWorkDir = filepath.Join(cityPath, workDir) + resolvedWorkDir = filepath.Join(storePath, workDir) } } - scriptBase := cityPath + scriptBase := storePath if resolvedWorkDir != "" { scriptBase = resolvedWorkDir } @@ -184,10 +188,15 @@ func runRalphCheck(store beads.Store, bead, subject beads.Bead, attempt int, opt timeout = parsed } + conditionBeadID := subject.ID + if conditionBeadID == "" { + conditionBeadID = bead.ID + } result := convergence.RunCondition(context.Background(), scriptPath, convergence.ConditionEnv{ - BeadID: bead.ID, + BeadID: conditionBeadID, Iteration: attempt, CityPath: cityPath, + StorePath: storePath, WorkDir: resolvedWorkDir, }, timeout, 0) return result, nil diff --git a/internal/dispatch/runtime.go b/internal/dispatch/runtime.go index ae4cb69b8..9337c4cce 100644 --- a/internal/dispatch/runtime.go +++ b/internal/dispatch/runtime.go @@ -22,6 +22,7 @@ type ControlResult struct { // ProcessOptions provides control-dispatcher execution context. type ProcessOptions struct { CityPath string + StorePath string FormulaSearchPaths []string PrepareFragment func(*formula.FragmentRecipe, beads.Bead) error RecycleSession func(beads.Bead) error diff --git a/internal/dispatch/runtime_test.go b/internal/dispatch/runtime_test.go index d1879390e..9b3e07656 100644 --- a/internal/dispatch/runtime_test.go +++ b/internal/dispatch/runtime_test.go @@ -3036,6 +3036,61 @@ func TestRunRalphCheckTimeoutMetadataPrecedence(t *testing.T) { } } +func TestRunRalphCheckUsesStorePathForRelativeCheckAndSubjectEnv(t *testing.T) { + cityPath := t.TempDir() + storePath := t.TempDir() + workDir := filepath.Join(storePath, "frontend") + checkDir := filepath.Join(workDir, "checks") + if err := os.MkdirAll(checkDir, 0o755); err != nil { + t.Fatalf("mkdir check dir: %v", err) + } + + checkPath := filepath.Join(checkDir, "env.sh") + script := "#!/bin/sh\n" + + "pwd\n" + + "printf 'BEAD=%s\\n' \"$GC_BEAD_ID\"\n" + + "printf 'CITY=%s\\n' \"$GC_CITY\"\n" + + "printf 'STORE=%s\\n' \"$GC_STORE_PATH\"\n" + + "printf 'BEADS=%s\\n' \"$BEADS_DIR\"\n" + if err := os.WriteFile(checkPath, []byte(script), 0o755); err != nil { + t.Fatalf("write check script: %v", err) + } + + store := beads.NewMemStore() + check := beads.Bead{ + ID: "check-1", + Type: "task", + Metadata: map[string]string{ + "gc.check_path": "checks/env.sh", + "gc.check_timeout": "30s", + "gc.work_dir": "frontend", + }, + } + subject := beads.Bead{ID: "run-1", Type: "task"} + + result, err := runRalphCheck(store, check, subject, 2, ProcessOptions{ + CityPath: cityPath, + StorePath: storePath, + }) + if err != nil { + t.Fatalf("runRalphCheck: %v", err) + } + if result.Outcome != "pass" { + t.Fatalf("result.Outcome = %q, want pass (stderr=%q)", result.Outcome, result.Stderr) + } + for _, want := range []string{ + workDir, + "BEAD=run-1", + "CITY=" + cityPath, + "STORE=" + storePath, + "BEADS=" + filepath.Join(storePath, ".beads"), + } { + if !strings.Contains(result.Stdout, want) { + t.Fatalf("stdout = %q, want to contain %q", result.Stdout, want) + } + } +} + func writeCheckScript(t *testing.T, cityPath, name, contents string) string { t.Helper() scriptDir := filepath.Join(cityPath, ".gc", "scripts") diff --git a/internal/docgen/cli.go b/internal/docgen/cli.go index e0b3dfa43..62e7e0d05 100644 --- a/internal/docgen/cli.go +++ b/internal/docgen/cli.go @@ -11,6 +11,8 @@ import ( "github.com/spf13/pflag" ) +const skipCLIDocAnnotation = "gc.docgen.skip" + func escapeMDXText(s string) string { s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") @@ -77,7 +79,7 @@ func walkCommands(w io.Writer, cmd *cobra.Command) error { return err } for _, child := range cmd.Commands() { - if child.Hidden { + if skipCLIDocCommand(child) { continue } if err := walkCommands(w, child); err != nil { @@ -87,6 +89,13 @@ func walkCommands(w io.Writer, cmd *cobra.Command) error { return nil } +func skipCLIDocCommand(cmd *cobra.Command) bool { + if cmd.Hidden { + return true + } + return cmd.Annotations[skipCLIDocAnnotation] == "true" +} + // renderCommand renders a single command section. func renderCommand(w io.Writer, cmd *cobra.Command) error { fullPath := cmd.CommandPath() @@ -234,7 +243,7 @@ func writeFlagTable(w io.Writer, flags []flagInfo) error { func renderSubcommandsTable(w io.Writer, cmd *cobra.Command) error { var children []*cobra.Command for _, c := range cmd.Commands() { - if !c.Hidden { + if !skipCLIDocCommand(c) { children = append(children, c) } } diff --git a/internal/docgen/cli_test.go b/internal/docgen/cli_test.go index fa0d21edb..54a52809b 100644 --- a/internal/docgen/cli_test.go +++ b/internal/docgen/cli_test.go @@ -97,6 +97,24 @@ func TestRenderCLIMarkdown_HiddenCommandSkipped(t *testing.T) { } } +func TestRenderCLIMarkdown_AnnotatedCommandSkipped(t *testing.T) { + root := &cobra.Command{Use: "app", Short: "test"} + root.AddCommand(&cobra.Command{ + Use: "pack", + Short: "local pack command", + Annotations: map[string]string{skipCLIDocAnnotation: "true"}, + }) + + var buf bytes.Buffer + if err := RenderCLIMarkdown(&buf, root); err != nil { + t.Fatalf("RenderCLIMarkdown: %v", err) + } + + if strings.Contains(buf.String(), "pack") { + t.Error("annotated command 'pack' should not appear in output") + } +} + func TestRenderCLIMarkdown_HiddenFlagSkipped(t *testing.T) { root := &cobra.Command{Use: "app", Short: "test"} root.Flags().String("visible", "", "shown flag") diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 147096148..c36180f77 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -167,6 +167,7 @@ func (c *ConfigRefsCheck) Run(_ *CheckContext) *CheckResult { r := &CheckResult{Name: c.Name()} var issues []string + builtinProviders := config.BuiltinProviders() for _, a := range c.cfg.Agents { qn := a.QualifiedName() if a.PromptTemplate != "" { @@ -190,7 +191,9 @@ func (c *ConfigRefsCheck) Run(_ *CheckContext) *CheckResult { } } if a.Provider != "" && len(c.cfg.Providers) > 0 { - if _, ok := c.cfg.Providers[a.Provider]; !ok { + _, declared := c.cfg.Providers[a.Provider] + _, builtin := builtinProviders[a.Provider] + if !declared && !builtin { issues = append(issues, fmt.Sprintf("agent %q: provider %q not defined in [providers]", qn, a.Provider)) } } diff --git a/internal/doctor/checks_test.go b/internal/doctor/checks_test.go index 3fa5fcada..89f64dd66 100644 --- a/internal/doctor/checks_test.go +++ b/internal/doctor/checks_test.go @@ -262,6 +262,24 @@ func TestConfigRefsCheck_UndefinedProvider(t *testing.T) { } } +func TestConfigRefsCheck_BuiltinProviderNotFlagged(t *testing.T) { + // Builtin providers (e.g. "claude") should not be flagged as undefined + // even when custom providers are declared in [providers]. + dir := t.TempDir() + cfg := &config.City{ + Providers: map[string]config.ProviderSpec{"ollama-local": {}}, + Agents: []config.Agent{ + {Name: "worker", Provider: "claude"}, + {Name: "coder", Provider: "codex"}, + }, + } + c := NewConfigRefsCheck(cfg, dir) + r := c.Run(&CheckContext{}) + if r.Status != StatusOK { + t.Errorf("status = %d, want OK (builtin providers are implicitly valid); details = %v", r.Status, r.Details) + } +} + func TestConfigRefsCheck_NoProvidersDefined(t *testing.T) { // When no providers section exists, agent provider refs are not checked. dir := t.TempDir() diff --git a/internal/execenv/execenv.go b/internal/execenv/execenv.go new file mode 100644 index 000000000..adc098ad5 --- /dev/null +++ b/internal/execenv/execenv.go @@ -0,0 +1,144 @@ +// Package execenv centralizes environment filtering and log redaction for +// subprocess boundaries. +package execenv + +import ( + "regexp" + "sort" + "strings" +) + +// Redacted is the replacement marker used when removing secrets from text. +const Redacted = "[redacted]" + +var sensitiveAssignmentRE = regexp.MustCompile(`(?i)((?:[A-Z0-9_.-]*(?:TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|OAUTH|AUTH[_-]?JSON)[A-Z0-9_.-]*|--?[A-Z0-9_.-]*(?:token|secret|password|private-key|api-key|access-key|credential|oauth)[A-Z0-9_.-]*)\s*(?:=|:|\s)\s*)([^ \t\r\n,;]+)`) + +// IsSensitiveKey reports whether an environment key is likely to contain a +// secret. Callers should strip inherited values for these keys and require +// explicit config when a child process truly needs one. +func IsSensitiveKey(key string) bool { + key = strings.ToUpper(strings.TrimSpace(key)) + if key == "" { + return false + } + for _, marker := range []string{ + "PASSWORD", + "TOKEN", + "SECRET", + "PRIVATE_KEY", + "PRIVATE-KEY", + "API_KEY", + "API-KEY", + "ACCESS_KEY", + "ACCESS-KEY", + "CREDENTIAL", + "OAUTH", + "AUTH_JSON", + "AUTH-JSON", + } { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +// FilterInherited removes sensitive KEY=VALUE entries from an inherited +// environment. Explicit overrides should be appended after filtering. +func FilterInherited(environ []string) []string { + out := make([]string, 0, len(environ)) + for _, entry := range environ { + key, _, ok := strings.Cut(entry, "=") + if ok && IsSensitiveKey(key) { + continue + } + out = append(out, entry) + } + return out +} + +// MergeMap filters inherited secrets, removes keys replaced by overrides, and +// appends overrides in deterministic order. Sensitive override values are kept +// because explicit configuration is the "required" path. +func MergeMap(environ []string, overrides map[string]string) []string { + out := FilterInherited(environ) + if len(overrides) == 0 { + return out + } + keys := make([]string, 0, len(overrides)) + for key := range overrides { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + out = removeEnvKey(out, key) + } + for _, key := range keys { + out = append(out, key+"="+overrides[key]) + } + return out +} + +// MergeEntries is like MergeMap for already-encoded KEY=VALUE override entries. +func MergeEntries(environ, overrides []string) []string { + out := FilterInherited(environ) + if len(overrides) == 0 { + return out + } + for _, entry := range overrides { + key, _, ok := strings.Cut(entry, "=") + if ok { + out = removeEnvKey(out, key) + } + } + return append(out, overrides...) +} + +// RedactText replaces known secret values and common CLI/env secret assignment +// patterns in text intended for logs or events. +func RedactText(text string, envs ...[]string) string { + if text == "" { + return "" + } + for _, secret := range sensitiveValues(envs...) { + text = strings.ReplaceAll(text, secret, Redacted) + } + return sensitiveAssignmentRE.ReplaceAllString(text, "${1}"+Redacted) +} + +func sensitiveValues(envs ...[]string) []string { + seen := map[string]struct{}{} + var values []string + for _, env := range envs { + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok || !IsSensitiveKey(key) { + continue + } + value = strings.TrimSpace(value) + if len(value) < 4 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + values = append(values, value) + } + } + sort.Slice(values, func(i, j int) bool { + return len(values[i]) > len(values[j]) + }) + return values +} + +func removeEnvKey(env []string, key string) []string { + prefix := key + "=" + out := env[:0] + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + out = append(out, entry) + } + } + return out +} diff --git a/internal/execenv/execenv_test.go b/internal/execenv/execenv_test.go new file mode 100644 index 000000000..e89472a1f --- /dev/null +++ b/internal/execenv/execenv_test.go @@ -0,0 +1,58 @@ +package execenv + +import ( + "strings" + "testing" +) + +func TestFilterInheritedStripsSensitiveEnv(t *testing.T) { + got := FilterInherited([]string{ + "PATH=/bin", + "GITHUB_TOKEN=ghs_secret", + "OPENAI_API_KEY=sk-secret", + "GC_INSTANCE_TOKEN=fence", + "HOME=/tmp/home", + }) + joined := strings.Join(got, "\n") + for _, secret := range []string{"GITHUB_TOKEN", "OPENAI_API_KEY", "GC_INSTANCE_TOKEN", "ghs_secret", "sk-secret", "fence"} { + if strings.Contains(joined, secret) { + t.Fatalf("FilterInherited leaked %q in %q", secret, joined) + } + } + if !strings.Contains(joined, "PATH=/bin") || !strings.Contains(joined, "HOME=/tmp/home") { + t.Fatalf("FilterInherited dropped non-sensitive env: %q", joined) + } +} + +func TestMergeMapPreservesExplicitSensitiveOverrides(t *testing.T) { + got := MergeMap([]string{ + "PATH=/bin", + "GC_DOLT_PASSWORD=stale", + "GITHUB_TOKEN=ambient", + }, map[string]string{ + "GC_DOLT_PASSWORD": "required", + "BEADS_DIR": "/city/.beads", + }) + joined := strings.Join(got, "\n") + if strings.Contains(joined, "GITHUB_TOKEN") || strings.Contains(joined, "ambient") || strings.Contains(joined, "stale") { + t.Fatalf("MergeMap leaked inherited secret: %q", joined) + } + if !strings.Contains(joined, "GC_DOLT_PASSWORD=required") { + t.Fatalf("MergeMap did not preserve explicit secret override: %q", joined) + } +} + +func TestRedactTextRedactsEnvValuesAndAssignments(t *testing.T) { + got := RedactText( + "token=literal-secret GITHUB_TOKEN=ghs_secret output ghs_secret --password hunter2", + []string{"GITHUB_TOKEN=ghs_secret"}, + ) + for _, secret := range []string{"literal-secret", "ghs_secret", "hunter2"} { + if strings.Contains(got, secret) { + t.Fatalf("RedactText leaked %q in %q", secret, got) + } + } + if strings.Count(got, Redacted) < 3 { + t.Fatalf("RedactText redactions = %q, want at least three", got) + } +} diff --git a/internal/execenv/testenv_import_test.go b/internal/execenv/testenv_import_test.go new file mode 100644 index 000000000..423ed568d --- /dev/null +++ b/internal/execenv/testenv_import_test.go @@ -0,0 +1,5 @@ +// Code generated by go run scripts/add-testenv-import.go; DO NOT EDIT. + +package execenv + +import _ "github.com/gastownhall/gascity/internal/testenv" diff --git a/internal/fsys/atomic.go b/internal/fsys/atomic.go index dd8745248..5534a5606 100644 --- a/internal/fsys/atomic.go +++ b/internal/fsys/atomic.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "reflect" "strconv" "time" ) @@ -34,14 +35,111 @@ func WriteFileAtomic(fs FS, path string, data []byte, perm os.FileMode) error { } // WriteFileIfChangedAtomic writes data to path atomically only when the -// existing on-disk bytes differ. Returns nil with no write when the -// content already matches. A read error other than "not exist" is -// ignored and the write proceeds — this is a best-effort optimization to -// avoid churning mtime (and fsnotify watchers) on no-op writes, not a -// safety check. +// existing on-disk bytes differ. Returns nil with no write when the content +// already matches on a stable regular file. Read or stat errors are ignored +// and the write proceeds — this is a best-effort optimization to avoid +// churning mtime on no-op writes, not a safety check. func WriteFileIfChangedAtomic(fs FS, path string, data []byte, perm os.FileMode) error { - if existing, err := fs.ReadFile(path); err == nil && bytes.Equal(existing, data) { - return nil + if info, err := fs.Lstat(path); err == nil && info.Mode().IsRegular() { + if snapshot, err := readRegularFileSnapshot(fs, path); err == nil && bytes.Equal(snapshot.data, data) { + if info, err := fs.Lstat(path); err == nil && info.Mode().IsRegular() { + if !snapshot.hasID { + return WriteFileAtomic(fs, path, data, perm) + } + currentID, ok := fileIdentityFromInfo(info) + if !ok || currentID != snapshot.id { + return WriteFileAtomic(fs, path, data, perm) + } + return nil + } + } } return WriteFileAtomic(fs, path, data, perm) } + +// WriteFileIfContentOrModeChangedAtomic writes data to path atomically when +// the existing on-disk bytes, file type, or permissions differ. Returns nil +// with no write when the path is already a regular file with matching content +// and mode. Symlinks and other non-regular entries are replaced without first +// reading through them. Read or stat errors are ignored and the write proceeds. +func WriteFileIfContentOrModeChangedAtomic(fs FS, path string, data []byte, perm os.FileMode) error { + if info, err := fs.Lstat(path); err == nil && info.Mode().IsRegular() && comparableMode(info.Mode()) == comparableMode(perm) { + if snapshot, err := readRegularFileSnapshot(fs, path); err == nil && bytes.Equal(snapshot.data, data) { + if info, err := fs.Lstat(path); err == nil && info.Mode().IsRegular() && comparableMode(info.Mode()) == comparableMode(perm) { + if !snapshot.hasID { + return WriteFileAtomic(fs, path, data, perm) + } + currentID, ok := fileIdentityFromInfo(info) + if !ok || currentID != snapshot.id { + return WriteFileAtomic(fs, path, data, perm) + } + return nil + } + } + } + return WriteFileAtomic(fs, path, data, perm) +} + +type regularFileSnapshotReader interface { + readRegularFileSnapshot(name string) (regularFileSnapshot, error) +} + +type regularFileSnapshot struct { + data []byte + id fileIdentity + hasID bool +} + +type fileIdentity struct { + dev uint64 + ino uint64 +} + +func readRegularFileSnapshot(fs FS, path string) (regularFileSnapshot, error) { + if reader, ok := fs.(regularFileSnapshotReader); ok { + return reader.readRegularFileSnapshot(path) + } + return regularFileSnapshot{}, &os.PathError{Op: "open", Path: path, Err: os.ErrInvalid} +} + +func comparableMode(mode os.FileMode) os.FileMode { + return mode & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky) +} + +func fileIdentityFromInfo(info os.FileInfo) (fileIdentity, bool) { + return fileIdentityFromSys(info.Sys()) +} + +func fileIdentityFromSys(sys any) (fileIdentity, bool) { + // Signed stat fields follow Go's direct int-to-uint conversion so the + // Fstat and Lstat paths agree on device identity across Unix variants. + stat := reflect.Indirect(reflect.ValueOf(sys)) + if !stat.IsValid() { + return fileIdentity{}, false + } + dev := stat.FieldByName("Dev") + ino := stat.FieldByName("Ino") + if !dev.IsValid() || !ino.IsValid() { + return fileIdentity{}, false + } + devValue, ok := numericFieldToUint64(dev) + if !ok { + return fileIdentity{}, false + } + inoValue, ok := numericFieldToUint64(ino) + if !ok { + return fileIdentity{}, false + } + return fileIdentity{dev: devValue, ino: inoValue}, true +} + +func numericFieldToUint64(v reflect.Value) (uint64, bool) { + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return uint64(v.Int()), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint(), true + default: + return 0, false + } +} diff --git a/internal/fsys/atomic_internal_test.go b/internal/fsys/atomic_internal_test.go new file mode 100644 index 000000000..08cc8e914 --- /dev/null +++ b/internal/fsys/atomic_internal_test.go @@ -0,0 +1,212 @@ +package fsys + +import ( + "os" + "testing" + "time" +) + +func TestWriteFileIfContentOrModeChangedAtomic_RewritesWhenIdentityChanges(t *testing.T) { + fs := &identityChangingFS{data: []byte("#!/bin/sh\n")} + + if err := WriteFileIfContentOrModeChangedAtomic(fs, "/script.sh", fs.data, 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + if !fs.renamed { + t.Fatalf("identity-changing file was not rewritten") + } +} + +func TestWriteFileIfChangedAtomic_RewritesWhenIdentityChanges(t *testing.T) { + fs := &identityChangingFS{data: []byte("hello = true\n")} + + if err := WriteFileIfChangedAtomic(fs, "/config.toml", fs.data, 0o644); err != nil { + t.Fatalf("WriteFileIfChangedAtomic: %v", err) + } + + if !fs.renamed { + t.Fatalf("identity-changing file was not rewritten") + } +} + +func TestWriteFileIfContentOrModeChangedAtomic_RewritesWithoutSnapshotIdentity(t *testing.T) { + fs := &noIdentitySnapshotFS{data: []byte("#!/bin/sh\n")} + + if err := WriteFileIfContentOrModeChangedAtomic(fs, "/script.sh", fs.data, 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + if !fs.renamed { + t.Fatalf("no-identity snapshot was not rewritten") + } +} + +func TestWriteFileIfChangedAtomic_RewritesWithoutSnapshotIdentity(t *testing.T) { + fs := &noIdentitySnapshotFS{data: []byte("hello = true\n")} + + if err := WriteFileIfChangedAtomic(fs, "/config.toml", fs.data, 0o644); err != nil { + t.Fatalf("WriteFileIfChangedAtomic: %v", err) + } + + if !fs.renamed { + t.Fatalf("no-identity snapshot was not rewritten") + } +} + +func TestFileIdentityFromSys_NormalizesSignedDeviceField(t *testing.T) { + id, ok := fileIdentityFromSys(struct { + Dev int32 + Ino uint64 + }{ + Dev: 7, + Ino: 11, + }) + if !ok { + t.Fatalf("fileIdentityFromSys returned ok=false for signed Dev field") + } + + want := fileIdentity{dev: 7, ino: 11} + if id != want { + t.Fatalf("fileIdentityFromSys = %#v, want %#v", id, want) + } +} + +func TestFileIdentityFromSys_NormalizesSignedDeviceFieldPointer(t *testing.T) { + id, ok := fileIdentityFromSys(&struct { + Dev int32 + Ino uint64 + }{ + Dev: 7, + Ino: 11, + }) + if !ok { + t.Fatalf("fileIdentityFromSys returned ok=false for pointer-shaped signed Dev field") + } + + want := fileIdentity{dev: 7, ino: 11} + if id != want { + t.Fatalf("fileIdentityFromSys = %#v, want %#v", id, want) + } +} + +func TestFileIdentityFromSys_PreservesNegativeSignedDeviceFieldBits(t *testing.T) { + id, ok := fileIdentityFromSys(struct { + Dev int32 + Ino uint64 + }{ + Dev: -1, + Ino: 11, + }) + if !ok { + t.Fatalf("fileIdentityFromSys returned ok=false for negative signed Dev field") + } + + dev := int32(-1) + want := fileIdentity{dev: uint64(dev), ino: 11} + if id != want { + t.Fatalf("fileIdentityFromSys = %#v, want %#v", id, want) + } +} + +type identityChangingFS struct { + data []byte + snapshotErr error + renamed bool + lstats int +} + +func (f *identityChangingFS) MkdirAll(string, os.FileMode) error { return nil } + +func (f *identityChangingFS) WriteFile(string, []byte, os.FileMode) error { return nil } + +func (f *identityChangingFS) ReadFile(string) ([]byte, error) { return f.data, nil } + +func (f *identityChangingFS) Stat(string) (os.FileInfo, error) { + return identityFileInfo{mode: 0o755, id: fileIdentity{dev: 1, ino: 1}}, nil +} + +func (f *identityChangingFS) Lstat(string) (os.FileInfo, error) { + f.lstats++ + id := fileIdentity{dev: 1, ino: 1} + if f.lstats > 1 { + id = fileIdentity{dev: 1, ino: 2} + } + return identityFileInfo{mode: 0o755, id: id}, nil +} + +func (f *identityChangingFS) ReadDir(string) ([]os.DirEntry, error) { return nil, nil } + +func (f *identityChangingFS) Rename(string, string) error { + f.renamed = true + return nil +} + +func (f *identityChangingFS) Remove(string) error { return nil } + +func (f *identityChangingFS) Chmod(string, os.FileMode) error { return nil } + +func (f *identityChangingFS) readRegularFileSnapshot(string) (regularFileSnapshot, error) { + if f.snapshotErr != nil { + return regularFileSnapshot{}, f.snapshotErr + } + return regularFileSnapshot{ + data: f.data, + id: fileIdentity{dev: 1, ino: 1}, + hasID: true, + }, nil +} + +type identityFileInfo struct { + mode os.FileMode + id fileIdentity +} + +func (i identityFileInfo) Name() string { return "script.sh" } +func (i identityFileInfo) Size() int64 { return int64(len("#!/bin/sh\n")) } +func (i identityFileInfo) Mode() os.FileMode { return i.mode } +func (i identityFileInfo) ModTime() time.Time { return time.Time{} } +func (i identityFileInfo) IsDir() bool { return false } +func (i identityFileInfo) Sys() any { return struct{ Dev, Ino uint64 }{i.id.dev, i.id.ino} } + +var _ FS = (*identityChangingFS)(nil) + +type noIdentitySnapshotFS struct { + data []byte + snapshotErr error + renamed bool +} + +func (f *noIdentitySnapshotFS) MkdirAll(string, os.FileMode) error { return nil } + +func (f *noIdentitySnapshotFS) WriteFile(string, []byte, os.FileMode) error { return nil } + +func (f *noIdentitySnapshotFS) ReadFile(string) ([]byte, error) { return f.data, nil } + +func (f *noIdentitySnapshotFS) Stat(string) (os.FileInfo, error) { + return identityFileInfo{mode: 0o755, id: fileIdentity{dev: 1, ino: 1}}, nil +} + +func (f *noIdentitySnapshotFS) Lstat(string) (os.FileInfo, error) { + return identityFileInfo{mode: 0o755, id: fileIdentity{dev: 1, ino: 1}}, nil +} + +func (f *noIdentitySnapshotFS) ReadDir(string) ([]os.DirEntry, error) { return nil, nil } + +func (f *noIdentitySnapshotFS) Rename(string, string) error { + f.renamed = true + return nil +} + +func (f *noIdentitySnapshotFS) Remove(string) error { return nil } + +func (f *noIdentitySnapshotFS) Chmod(string, os.FileMode) error { return nil } + +func (f *noIdentitySnapshotFS) readRegularFileSnapshot(string) (regularFileSnapshot, error) { + if f.snapshotErr != nil { + return regularFileSnapshot{}, f.snapshotErr + } + return regularFileSnapshot{data: f.data}, nil +} + +var _ FS = (*noIdentitySnapshotFS)(nil) diff --git a/internal/fsys/atomic_test.go b/internal/fsys/atomic_test.go index d7554dc0e..ed2b5d758 100644 --- a/internal/fsys/atomic_test.go +++ b/internal/fsys/atomic_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/gastownhall/gascity/internal/fsys" ) @@ -57,3 +58,210 @@ func TestWriteFileAtomic_Overwrite(t *testing.T) { } } } + +func TestWriteFileIfChangedAtomic_SkipsMatchingContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + data := []byte("hello = true\n") + + if err := fsys.WriteFileAtomic(fsys.OSFS{}, path, data, 0o644); err != nil { + t.Fatalf("WriteFileAtomic: %v", err) + } + past := time.Unix(123456789, 0) + if err := os.Chtimes(path, past, past); err != nil { + t.Fatalf("Chtimes: %v", err) + } + + if err := fsys.WriteFileIfChangedAtomic(fsys.OSFS{}, path, data, 0o644); err != nil { + t.Fatalf("WriteFileIfChangedAtomic: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(past) { + t.Fatalf("file was rewritten: modtime = %s, want %s", info.ModTime(), past) + } +} + +func TestWriteFileIfChangedAtomic_SkipsMatchingContentWhenModeDiffers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + data := []byte("hello = true\n") + + if err := fsys.WriteFileAtomic(fsys.OSFS{}, path, data, 0o644); err != nil { + t.Fatalf("WriteFileAtomic: %v", err) + } + past := time.Unix(123456789, 0) + if err := os.Chtimes(path, past, past); err != nil { + t.Fatalf("Chtimes: %v", err) + } + + if err := fsys.WriteFileIfChangedAtomic(fsys.OSFS{}, path, data, 0o755); err != nil { + t.Fatalf("WriteFileIfChangedAtomic: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(past) { + t.Fatalf("file was rewritten: modtime = %s, want %s", info.ModTime(), past) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("mode = %v, want unchanged 0644", info.Mode().Perm()) + } +} + +func TestWriteFileIfChangedAtomic_ReplacesMatchingSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.toml") + link := filepath.Join(dir, "link.toml") + data := []byte("hello = true\n") + + if err := os.WriteFile(target, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if err := fsys.WriteFileIfChangedAtomic(fsys.OSFS{}, link, data, 0o644); err != nil { + t.Fatalf("WriteFileIfChangedAtomic: %v", err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("matching symlink was preserved, want replacement with regular file") + } +} + +func TestWriteFileIfContentOrModeChangedAtomic_RepairsModeMismatch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.sh") + data := []byte("#!/bin/sh\n") + + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fsys.OSFS{}, path, data, 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode = %v, want 0755", info.Mode().Perm()) + } +} + +func TestWriteFileIfContentOrModeChangedAtomic_RepairsSpecialModeBits(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.sh") + data := []byte("#!/bin/sh\n") + + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o4755); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSetuid == 0 { + t.Skipf("filesystem did not preserve setuid bit in test mode: %v", info.Mode()) + } + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fsys.OSFS{}, path, data, 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + info, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSetuid != 0 { + t.Fatalf("setuid bit was not repaired: mode = %v", info.Mode()) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode = %v, want 0755", info.Mode().Perm()) + } +} + +func TestWriteFileIfContentOrModeChangedAtomic_ReplacesMatchingSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.sh") + link := filepath.Join(dir, "link.sh") + data := []byte("#!/bin/sh\n") + + if err := os.WriteFile(target, data, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fsys.OSFS{}, link, data, 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("matching symlink was preserved, want replacement with regular file") + } +} + +func TestWriteFileIfContentOrModeChangedAtomic_LstatsBeforeRead(t *testing.T) { + fake := fsys.NewFake() + fake.Files["/target.sh"] = []byte("#!/bin/sh\n") + fake.Symlinks["/link.sh"] = "/target.sh" + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fake, "/link.sh", []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + for i, call := range fake.Calls { + if call.Method == "Lstat" && call.Path == "/link.sh" { + return + } + if call.Method == "ReadFile" && call.Path == "/link.sh" { + t.Fatalf("ReadFile called before Lstat at call %d: %+v", i, fake.Calls) + } + } + t.Fatalf("Lstat(/link.sh) not called; calls=%+v", fake.Calls) +} + +func TestWriteFileIfContentOrModeChangedAtomic_FakeSkipsMatchingContentAndMode(t *testing.T) { + fake := fsys.NewFake() + data := []byte("hello = true\n") + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fake, "/test.toml", data, 0o644); err != nil { + t.Fatalf("initial WriteFileIfContentOrModeChangedAtomic: %v", err) + } + fake.Calls = nil + + if err := fsys.WriteFileIfContentOrModeChangedAtomic(fake, "/test.toml", data, 0o644); err != nil { + t.Fatalf("second WriteFileIfContentOrModeChangedAtomic: %v", err) + } + + for _, call := range fake.Calls { + if call.Method == "WriteFile" || call.Method == "Rename" || call.Method == "Chmod" { + t.Fatalf("matching fake file should not be rewritten; calls=%+v", fake.Calls) + } + } +} diff --git a/internal/fsys/fake.go b/internal/fsys/fake.go index 0a5bf4f0b..fff8280b6 100644 --- a/internal/fsys/fake.go +++ b/internal/fsys/fake.go @@ -1,6 +1,7 @@ package fsys import ( + "hash/fnv" "io/fs" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( type Fake struct { Dirs map[string]bool // pre-populated directories Files map[string][]byte // pre-populated files + Modes map[string]os.FileMode Symlinks map[string]string // pre-populated symlinks (path -> target) Errors map[string]error // path → injected error (checked first) Calls []Call // spy log @@ -21,7 +23,7 @@ type Fake struct { // Call records a single method invocation on [Fake]. type Call struct { - Method string // "MkdirAll", "WriteFile", "ReadFile", "Stat", "ReadDir", "Rename", "Remove", or "Chmod" + Method string // "MkdirAll", "WriteFile", "ReadFile", "ReadRegularFile", "Stat", "ReadDir", "Rename", "Remove", or "Chmod" Path string // path argument } @@ -30,33 +32,50 @@ func NewFake() *Fake { return &Fake{ Dirs: make(map[string]bool), Files: make(map[string][]byte), + Modes: make(map[string]os.FileMode), Symlinks: make(map[string]string), Errors: make(map[string]error), } } // MkdirAll records the call and adds the directory (and parents) to Dirs. -func (f *Fake) MkdirAll(path string, _ os.FileMode) error { +func (f *Fake) MkdirAll(path string, perm os.FileMode) error { f.Calls = append(f.Calls, Call{Method: "MkdirAll", Path: path}) if err, ok := f.Errors[path]; ok { return err } + if f.Dirs == nil { + f.Dirs = make(map[string]bool) + } + if f.Modes == nil { + f.Modes = make(map[string]os.FileMode) + } // Record this directory and all parents. for p := filepath.Clean(path); p != "." && p != "/" && p != string(filepath.Separator); p = filepath.Dir(p) { + if !f.Dirs[p] { + f.Modes[p] = perm.Perm() + } f.Dirs[p] = true } return nil } // WriteFile records the call and stores the data in Files. -func (f *Fake) WriteFile(name string, data []byte, _ os.FileMode) error { +func (f *Fake) WriteFile(name string, data []byte, perm os.FileMode) error { f.Calls = append(f.Calls, Call{Method: "WriteFile", Path: name}) if err, ok := f.Errors[name]; ok { return err } cp := make([]byte, len(data)) copy(cp, data) + if f.Files == nil { + f.Files = make(map[string][]byte) + } + if f.Modes == nil { + f.Modes = make(map[string]os.FileMode) + } f.Files[name] = cp + f.Modes[name] = perm.Perm() return nil } @@ -74,6 +93,37 @@ func (f *Fake) ReadFile(name string) ([]byte, error) { return nil, &os.PathError{Op: "read", Path: name, Err: os.ErrNotExist} } +// ReadRegularFile records the call and returns file contents without following +// symlinks or accepting directories. +func (f *Fake) ReadRegularFile(name string) ([]byte, error) { + f.Calls = append(f.Calls, Call{Method: "ReadRegularFile", Path: name}) + if err, ok := f.Errors[name]; ok { + return nil, err + } + if _, ok := f.Symlinks[name]; ok { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + if f.Dirs[name] { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + if data, ok := f.Files[name]; ok { + cp := make([]byte, len(data)) + copy(cp, data) + return cp, nil + } + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} +} + +// readRegularFileSnapshot returns regular file contents plus a stable fake +// identity for the path. +func (f *Fake) readRegularFileSnapshot(name string) (regularFileSnapshot, error) { + data, err := f.ReadRegularFile(name) + if err != nil { + return regularFileSnapshot{}, err + } + return regularFileSnapshot{data: data, id: fakeIdentity(name), hasID: true}, nil +} + // Stat records the call and returns info based on Dirs/Files maps. // Symlinks are followed — use Lstat to detect them without following. func (f *Fake) Stat(name string) (os.FileInfo, error) { @@ -83,18 +133,18 @@ func (f *Fake) Stat(name string) (os.FileInfo, error) { } if target, ok := f.Symlinks[name]; ok { if f.Dirs[target] { - return fakeFileInfo{name: filepath.Base(name), dir: true}, nil + return fakeFileInfo{name: filepath.Base(name), dir: true, mode: f.modeFor(target), id: fakeIdentity(target), hasID: true}, nil } if data, ok := f.Files[target]; ok { - return fakeFileInfo{name: filepath.Base(name), size: int64(len(data))}, nil + return fakeFileInfo{name: filepath.Base(name), size: int64(len(data)), mode: f.modeFor(target), id: fakeIdentity(target), hasID: true}, nil } return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} } if f.Dirs[name] { - return fakeFileInfo{name: filepath.Base(name), dir: true}, nil + return fakeFileInfo{name: filepath.Base(name), dir: true, mode: f.modeFor(name), id: fakeIdentity(name), hasID: true}, nil } if data, ok := f.Files[name]; ok { - return fakeFileInfo{name: filepath.Base(name), size: int64(len(data))}, nil + return fakeFileInfo{name: filepath.Base(name), size: int64(len(data)), mode: f.modeFor(name), id: fakeIdentity(name), hasID: true}, nil } return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} } @@ -107,13 +157,13 @@ func (f *Fake) Lstat(name string) (os.FileInfo, error) { return nil, err } if _, ok := f.Symlinks[name]; ok { - return fakeFileInfo{name: filepath.Base(name), symlink: true}, nil + return fakeFileInfo{name: filepath.Base(name), symlink: true, id: fakeIdentity(name), hasID: true}, nil } if f.Dirs[name] { - return fakeFileInfo{name: filepath.Base(name), dir: true}, nil + return fakeFileInfo{name: filepath.Base(name), dir: true, mode: f.modeFor(name), id: fakeIdentity(name), hasID: true}, nil } if data, ok := f.Files[name]; ok { - return fakeFileInfo{name: filepath.Base(name), size: int64(len(data))}, nil + return fakeFileInfo{name: filepath.Base(name), size: int64(len(data)), mode: f.modeFor(name), id: fakeIdentity(name), hasID: true}, nil } return nil, &os.PathError{Op: "lstat", Path: name, Err: os.ErrNotExist} } @@ -135,7 +185,7 @@ func (f *Fake) ReadDir(name string) ([]os.DirEntry, error) { base := filepath.Base(d) if !seen[base] { seen[base] = true - entries = append(entries, fakeDirEntry{name: base, dir: true}) + entries = append(entries, fakeDirEntry{name: base, dir: true, mode: f.modeFor(d), id: fakeIdentity(d), hasID: true}) } } } @@ -145,7 +195,7 @@ func (f *Fake) ReadDir(name string) ([]os.DirEntry, error) { base := filepath.Base(p) if !seen[base] { seen[base] = true - entries = append(entries, fakeDirEntry{name: base, size: int64(len(data))}) + entries = append(entries, fakeDirEntry{name: base, size: int64(len(data)), mode: f.modeFor(p), id: fakeIdentity(p), hasID: true}) } } } @@ -165,6 +215,13 @@ func (f *Fake) Rename(oldpath, newpath string) error { if data, ok := f.Files[oldpath]; ok { f.Files[newpath] = data delete(f.Files, oldpath) + if mode, ok := f.Modes[oldpath]; ok { + f.Modes[newpath] = mode + } else { + delete(f.Modes, newpath) + } + delete(f.Modes, oldpath) + delete(f.Symlinks, newpath) return nil } return &os.PathError{Op: "rename", Path: oldpath, Err: os.ErrNotExist} @@ -178,36 +235,56 @@ func (f *Fake) Remove(name string) error { } if _, ok := f.Files[name]; ok { delete(f.Files, name) + delete(f.Modes, name) + return nil + } + if _, ok := f.Symlinks[name]; ok { + delete(f.Symlinks, name) return nil } if f.Dirs[name] { delete(f.Dirs, name) + delete(f.Modes, name) return nil } return &os.PathError{Op: "remove", Path: name, Err: os.ErrNotExist} } -// Chmod records the call. Mode is not tracked — the spy log is sufficient -// for tests that care about which paths were chmodded. -func (f *Fake) Chmod(name string, _ os.FileMode) error { +// Chmod records the call and updates the stored mode. +func (f *Fake) Chmod(name string, mode os.FileMode) error { f.Calls = append(f.Calls, Call{Method: "Chmod", Path: name}) if err, ok := f.Errors[name]; ok { return err } + if f.Modes == nil { + f.Modes = make(map[string]os.FileMode) + } if _, ok := f.Files[name]; ok { + f.Modes[name] = mode.Perm() return nil } if f.Dirs[name] { + f.Modes[name] = mode.Perm() return nil } return &os.PathError{Op: "chmod", Path: name, Err: os.ErrNotExist} } +func (f *Fake) modeFor(name string) os.FileMode { + if mode, ok := f.Modes[name]; ok { + return mode + } + return 0o755 +} + // --- fake os.FileInfo --- type fakeFileInfo struct { name string size int64 + mode os.FileMode + id fileIdentity + hasID bool dir bool symlink bool } @@ -218,18 +295,29 @@ func (fi fakeFileInfo) Mode() os.FileMode { if fi.symlink { return 0o777 | os.ModeSymlink } - return 0o755 + if fi.dir { + return fi.mode | os.ModeDir + } + return fi.mode } func (fi fakeFileInfo) ModTime() time.Time { return time.Time{} } func (fi fakeFileInfo) IsDir() bool { return fi.dir } -func (fi fakeFileInfo) Sys() any { return nil } +func (fi fakeFileInfo) Sys() any { + if !fi.hasID { + return nil + } + return struct{ Dev, Ino uint64 }{fi.id.dev, fi.id.ino} +} // --- fake os.DirEntry --- type fakeDirEntry struct { - name string - size int64 - dir bool + name string + size int64 + mode os.FileMode + id fileIdentity + hasID bool + dir bool } func (de fakeDirEntry) Name() string { return de.name } @@ -242,7 +330,13 @@ func (de fakeDirEntry) Type() fs.FileMode { } func (de fakeDirEntry) Info() (fs.FileInfo, error) { - return fakeFileInfo{name: de.name, size: de.size, dir: de.dir}, nil + return fakeFileInfo{name: de.name, size: de.size, mode: de.mode, id: de.id, hasID: de.hasID, dir: de.dir}, nil +} + +func fakeIdentity(name string) fileIdentity { + h := fnv.New64a() + _, _ = h.Write([]byte(name)) + return fileIdentity{dev: 1, ino: h.Sum64()} } var ( diff --git a/internal/fsys/fake_test.go b/internal/fsys/fake_test.go index 6eaf083ac..eae07f515 100644 --- a/internal/fsys/fake_test.go +++ b/internal/fsys/fake_test.go @@ -23,6 +23,22 @@ func TestFakeStatDir(t *testing.T) { } } +func TestFakeStatDirModeIncludesDirBit(t *testing.T) { + f := NewFake() + f.Dirs["/city/.gc"] = true + + fi, err := f.Stat("/city/.gc") + if err != nil { + t.Fatalf("Stat existing dir: %v", err) + } + if fi.Mode().IsRegular() { + t.Fatalf("directory mode reports regular file: %v", fi.Mode()) + } + if fi.Mode()&os.ModeDir == 0 { + t.Fatalf("directory mode missing ModeDir bit: %v", fi.Mode()) + } +} + func TestFakeStatFile(t *testing.T) { f := NewFake() f.Files["/city/city.toml"] = []byte("hello") @@ -114,6 +130,17 @@ func TestFakeWriteFile(t *testing.T) { } } +func TestFakeWriteFileInitializesModes(t *testing.T) { + f := &Fake{Files: map[string][]byte{}} + + if err := f.WriteFile("/city/run.sh", []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if f.Modes["/city/run.sh"] != 0o755 { + t.Fatalf("mode = %v, want 0755", f.Modes["/city/run.sh"]) + } +} + func TestFakeWriteFileError(t *testing.T) { f := NewFake() injected := fmt.Errorf("read-only fs") @@ -159,6 +186,28 @@ func TestFakeReadDir(t *testing.T) { } } +func TestFakeReadDirInfoReportsTrackedMode(t *testing.T) { + f := NewFake() + if err := f.WriteFile("/city/rigs/run.sh", []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + entries, err := f.ReadDir("/city/rigs") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + info, err := entries[0].Info() + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("ReadDir entry mode = %v, want 0755", info.Mode().Perm()) + } +} + func TestFakeReadDirError(t *testing.T) { f := NewFake() injected := fmt.Errorf("no such directory") @@ -203,6 +252,36 @@ func TestFakeRename(t *testing.T) { } } +func TestFakeRenameClearsStaleDestinationMode(t *testing.T) { + f := NewFake() + f.Files["/city/generated.tmp"] = []byte("new") + f.Files["/city/generated"] = []byte("old") + f.Modes["/city/generated"] = 0o644 + + if err := f.Rename("/city/generated.tmp", "/city/generated"); err != nil { + t.Fatalf("Rename: %v", err) + } + + info, err := f.Stat("/city/generated") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("renamed file mode = %v, want default 0755", info.Mode().Perm()) + } +} + +func TestFakeChmodInitializesModes(t *testing.T) { + f := &Fake{Files: map[string][]byte{"/city/run.sh": []byte("#!/bin/sh\n")}} + + if err := f.Chmod("/city/run.sh", 0o755); err != nil { + t.Fatalf("Chmod: %v", err) + } + if f.Modes["/city/run.sh"] != 0o755 { + t.Fatalf("mode = %v, want 0755", f.Modes["/city/run.sh"]) + } +} + func TestFakeRenameError(t *testing.T) { f := NewFake() injected := fmt.Errorf("cross-device link") diff --git a/internal/fsys/read_regular_unix.go b/internal/fsys/read_regular_unix.go new file mode 100644 index 000000000..4ecb67550 --- /dev/null +++ b/internal/fsys/read_regular_unix.go @@ -0,0 +1,53 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package fsys + +import ( + "io" + "os" + + "golang.org/x/sys/unix" +) + +// ReadRegularFile reads name without following a final symlink. +func (OSFS) ReadRegularFile(name string) ([]byte, error) { + snapshot, err := (OSFS{}).readRegularFileSnapshot(name) + if err != nil { + return nil, err + } + return snapshot.data, nil +} + +// readRegularFileSnapshot reads name without following a final symlink and +// returns the opened file identity for post-read stability checks. +func (OSFS) readRegularFileSnapshot(name string) (regularFileSnapshot, error) { + fd, err := unix.Open(name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return regularFileSnapshot{}, &os.PathError{Op: "open", Path: name, Err: err} + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return regularFileSnapshot{}, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + defer func() { + _ = file.Close() + }() + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return regularFileSnapshot{}, &os.PathError{Op: "stat", Path: name, Err: err} + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return regularFileSnapshot{}, &os.PathError{Op: "open", Path: name, Err: os.ErrInvalid} + } + data, err := io.ReadAll(file) + if err != nil { + return regularFileSnapshot{}, &os.PathError{Op: "read", Path: name, Err: err} + } + return regularFileSnapshot{ + data: data, + id: fileIdentity{dev: uint64(stat.Dev), ino: stat.Ino}, //nolint:unconvert // int32 on darwin, uint64 on linux + hasID: true, + }, nil +} diff --git a/internal/fsys/read_regular_unix_internal_test.go b/internal/fsys/read_regular_unix_internal_test.go new file mode 100644 index 000000000..e1c181a55 --- /dev/null +++ b/internal/fsys/read_regular_unix_internal_test.go @@ -0,0 +1,38 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package fsys + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadRegularFileSnapshot_MatchesFileIdentityFromInfo(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + data := []byte("hello = true\n") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + snapshot, err := (OSFS{}).readRegularFileSnapshot(path) + if err != nil { + t.Fatalf("readRegularFileSnapshot: %v", err) + } + if !snapshot.hasID { + t.Fatalf("snapshot missing identity") + } + + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("Lstat: %v", err) + } + id, ok := fileIdentityFromInfo(info) + if !ok { + t.Fatalf("fileIdentityFromInfo returned ok=false") + } + if snapshot.id != id { + t.Fatalf("snapshot.id = %#v, want %#v", snapshot.id, id) + } +} diff --git a/internal/graphroute/graphroute.go b/internal/graphroute/graphroute.go index 6f43af4f1..72c51613a 100644 --- a/internal/graphroute/graphroute.go +++ b/internal/graphroute/graphroute.go @@ -148,6 +148,25 @@ func ApplyGraphRouteBinding(step *formula.RecipeStep, binding GraphRouteBinding) step.Assignee = binding.SessionName } +// ApplyGraphControlRouteBinding routes control steps directly to the +// control-dispatcher session when possible. gc.routed_to intentionally means +// "work for this config queue"; using it for a named dispatcher would create +// config-routed work instead of delivering to the known dispatcher session. +func ApplyGraphControlRouteBinding(step *formula.RecipeStep, binding GraphRouteBinding) { + if binding.DirectSessionID != "" { + delete(step.Metadata, "gc.routed_to") + step.Assignee = binding.DirectSessionID + return + } + if binding.SessionName != "" { + delete(step.Metadata, "gc.routed_to") + step.Assignee = binding.SessionName + return + } + delete(step.Metadata, "gc.routed_to") + step.Assignee = "" +} + // AssignGraphStepRoute applies routing to a step, optionally diverting // control steps to the control dispatcher. func AssignGraphStepRoute(step *formula.RecipeStep, executionBinding GraphRouteBinding, controlBinding *GraphRouteBinding) { @@ -157,7 +176,7 @@ func AssignGraphStepRoute(step *formula.RecipeStep, executionBinding GraphRouteB } else { delete(step.Metadata, GraphExecutionRouteMetaKey) } - ApplyGraphRouteBinding(step, *controlBinding) + ApplyGraphControlRouteBinding(step, *controlBinding) return } delete(step.Metadata, GraphExecutionRouteMetaKey) @@ -194,9 +213,6 @@ func ControlDispatcherBinding(store beads.Store, cityName string, cfg *config.Ci return GraphRouteBinding{}, fmt.Errorf("control-dispatcher agent %q not found", config.ControlDispatcherAgentName) } binding := GraphRouteBinding{QualifiedName: agentCfg.QualifiedName()} - if agentutil.IsMultiSessionAgent(&agentCfg) { - return binding, nil - } sn := agentutil.LookupSessionName(store, cityName, agentCfg.QualifiedName(), cfg.Workspace.SessionTemplate) if sn == "" { return GraphRouteBinding{}, fmt.Errorf("could not resolve session name for %q", agentCfg.QualifiedName()) diff --git a/internal/graphroute/graphroute_test.go b/internal/graphroute/graphroute_test.go index 5af79249b..2916c5ec5 100644 --- a/internal/graphroute/graphroute_test.go +++ b/internal/graphroute/graphroute_test.go @@ -367,6 +367,55 @@ func TestControlDispatcherBinding_NilResolver(t *testing.T) { } } +func TestControlDispatcherBinding_ConfiguredDispatcherUsesConcreteSessionName(t *testing.T) { + cfg := &config.City{Agents: []config.Agent{{ + Name: "control-dispatcher", + Dir: "gascity", + }}} + + binding, err := ControlDispatcherBinding(nil, "test-city", cfg, "gascity", Deps{Resolver: testAgentResolver{}}) + if err != nil { + t.Fatalf("ControlDispatcherBinding: %v", err) + } + if binding.QualifiedName != "gascity/control-dispatcher" { + t.Fatalf("QualifiedName = %q, want gascity/control-dispatcher", binding.QualifiedName) + } + if binding.SessionName != "gascity--control-dispatcher" { + t.Fatalf("SessionName = %q, want gascity--control-dispatcher", binding.SessionName) + } + if binding.MetadataOnly { + t.Fatalf("MetadataOnly = true, want false") + } +} + +func TestAssignGraphStepRoute_ControlBindingUsesDirectAssigneeWithoutRoutedTo(t *testing.T) { + step := &formula.RecipeStep{ + Metadata: map[string]string{ + "gc.routed_to": "stale-control-route", + }, + } + execution := GraphRouteBinding{ + QualifiedName: "gascity/claude", + MetadataOnly: true, + } + control := GraphRouteBinding{ + QualifiedName: "gascity/control-dispatcher", + SessionName: "gascity--control-dispatcher", + } + + AssignGraphStepRoute(step, execution, &control) + + if step.Assignee != "gascity--control-dispatcher" { + t.Fatalf("control assignee = %q, want gascity--control-dispatcher", step.Assignee) + } + if got := step.Metadata["gc.routed_to"]; got != "" { + t.Fatalf("control gc.routed_to = %q, want empty direct assignee", got) + } + if got := step.Metadata[GraphExecutionRouteMetaKey]; got != "gascity/claude" { + t.Fatalf("control execution route = %q, want gascity/claude", got) + } +} + func TestWorkflowExecutionRoute(t *testing.T) { b := beads.Bead{Metadata: map[string]string{"gc.routed_to": "myrig/worker"}} if got := WorkflowExecutionRoute(b); got != "myrig/worker" { diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 3e30b1080..39f33bd7c 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -7,6 +7,7 @@ package hooks import ( "bytes" "embed" + "encoding/json" "errors" "fmt" iofs "io/fs" @@ -150,10 +151,39 @@ func installOverlayManaged(fs fsys.FS, workDir, provider string) error { return fmt.Errorf("reading %s: %w", name, err) } dst := filepath.Join(workDir, filepath.FromSlash(rel)) - return writeEmbeddedManaged(fs, dst, data, nil) + if provider == "codex" && rel == path.Join(".codex", "hooks.json") { + return writeCodexHooksManaged(fs, dst, data) + } + return writeEmbeddedManaged(fs, dst, data, overlayManagedNeedsUpgrade(provider, rel)) }) } +func overlayManagedNeedsUpgrade(provider, rel string) func([]byte) bool { + if provider == "pi" && rel == path.Join(".pi", "extensions", "gc-hooks.js") { + return piHookNeedsUpgrade + } + return nil +} + +func piHookNeedsUpgrade(existing []byte) bool { + content := string(existing) + if !strings.Contains(content, "Gas City hooks for Pi Coding Agent") { + return false + } + for _, marker := range []string{ + "module.exports = {", + `"session.created"`, + `"session.compacted"`, + `"session.deleted"`, + `"experimental.chat.system.transform"`, + } { + if strings.Contains(content, marker) { + return true + } + } + return false +} + // installClaude writes the runtime settings file (.gc/settings.json) in the // city directory. The legacy hooks/claude.json file remains user-owned unless // gc can prove it is safe to update a stale generated copy. @@ -318,6 +348,94 @@ func readClaudeSettingsCandidate(fs fsys.FS, path string) (claudeCandidateState, return candidateUnreadable, nil, err } +func writeCodexHooksManaged(fs fsys.FS, dst string, data []byte) error { + if existing, err := fs.ReadFile(dst); err == nil { + upgraded, changed, upgradeErr := upgradeCodexHookCommands(existing) + if upgradeErr != nil || !changed { + return nil + } + return writeManagedData(fs, dst, upgraded) + } else if _, statErr := fs.Stat(dst); statErr == nil { + return nil + } + return writeManagedData(fs, dst, data) +} + +func writeManagedData(fs fsys.FS, dst string, data []byte) error { + dir := filepath.Dir(dst) + if err := fs.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + if err := fs.WriteFile(dst, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", dst, err) + } + return nil +} + +func upgradeCodexHookCommands(existing []byte) ([]byte, bool, error) { + var root any + if err := json.Unmarshal(existing, &root); err != nil { + return nil, false, err + } + if !upgradeCodexHookValue(root) { + return nil, false, nil + } + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, false, err + } + return append(data, '\n'), true, nil +} + +func upgradeCodexHookValue(v any) bool { + switch node := v.(type) { + case map[string]any: + changed := false + for key, val := range node { + if key == "command" { + if command, ok := val.(string); ok { + if upgraded, didUpgrade := upgradeCodexHookCommand(command); didUpgrade { + node[key] = upgraded + changed = true + } + } + continue + } + if upgradeCodexHookValue(val) { + changed = true + } + } + return changed + case []any: + changed := false + for _, elem := range node { + if upgradeCodexHookValue(elem) { + changed = true + } + } + return changed + default: + return false + } +} + +func upgradeCodexHookCommand(command string) (string, bool) { + if strings.Contains(command, `--hook-format codex`) { + return "", false + } + for _, needle := range []string{ + `gc prime --hook`, + `gc nudge drain --inject`, + `gc mail check --inject`, + `gc hook --inject`, + } { + if strings.Contains(command, needle) { + return strings.Replace(command, needle, needle+` --hook-format codex`, 1), true + } + } + return "", false +} + func writeManagedFile(fs fsys.FS, dst string, data []byte, policy writeManagedFilePolicy) error { existing, readErr := fs.ReadFile(dst) if readErr == nil && bytes.Equal(existing, data) { diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index a5b8a8454..c7fd9199e 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -231,6 +231,61 @@ func TestInstallClaudeUpgradesGeneratedFileSessionStartMatcher(t *testing.T) { } } +func TestInstallCodexUpgradesGeneratedFileMissingHookFormat(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/work/.codex/hooks.json"] = []byte(`{ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc prime --hook" + }] + }] + } +}`) + + if err := Install(fs, "/city", "/work", []string{"codex"}); err != nil { + t.Fatalf("Install: %v", err) + } + + got := string(fs.Files["/work/.codex/hooks.json"]) + if !strings.Contains(got, "--hook-format codex") { + t.Errorf("upgraded codex hooks missing Codex hook output format:\n%s", got) + } +} + +func TestInstallCodexUpgradePreservesCustomHooks(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/work/.codex/hooks.json"] = []byte(`{ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gc prime --hook" + }] + }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": "printf custom-codex-hook" + }] + }] + } +}`) + + if err := Install(fs, "/city", "/work", []string{"codex"}); err != nil { + t.Fatalf("Install: %v", err) + } + + got := string(fs.Files["/work/.codex/hooks.json"]) + if !strings.Contains(got, "--hook-format codex") { + t.Errorf("upgraded codex hooks missing Codex hook output format:\n%s", got) + } + if !strings.Contains(got, "printf custom-codex-hook") { + t.Errorf("custom codex hook was not preserved:\n%s", got) + } +} + func TestInstallClaudeUpgradesGeneratedFileWithCombinedKnownDrift(t *testing.T) { fs := fsys.NewFake() current, err := readEmbedded("config/claude.json") @@ -684,6 +739,81 @@ func TestInstallOverlayManagedProviders(t *testing.T) { t.Errorf("expected overlay-managed provider file %s to be written", rel) } } + codexHooks := string(fs.Files["/work/.codex/hooks.json"]) + if !strings.Contains(codexHooks, "--hook-format codex") { + t.Error("codex hooks should request Codex hook output format") + } +} + +func TestInstallPiHookUsesCurrentExtensionAPI(t *testing.T) { + fs := fsys.NewFake() + if err := Install(fs, "/city", "/work", []string{"pi"}); err != nil { + t.Fatalf("Install: %v", err) + } + + data := string(fs.Files["/work/.pi/extensions/gc-hooks.js"]) + for _, want := range []string{ + "module.exports = function gascityPiExtension(pi)", + `pi.on("session_start"`, + `pi.on("session_compact"`, + `pi.on("session_shutdown"`, + `pi.on("before_agent_start"`, + } { + if !strings.Contains(data, want) { + t.Errorf("Pi hook missing current extension API marker %q:\n%s", want, data) + } + } + for _, legacy := range []string{ + "module.exports = {", + `"session.created"`, + `"session.compacted"`, + `"session.deleted"`, + `"experimental.chat.system.transform"`, + } { + if strings.Contains(data, legacy) { + t.Errorf("Pi hook still contains legacy API marker %q:\n%s", legacy, data) + } + } +} + +func TestInstallPiHookUpgradesLegacyObjectExport(t *testing.T) { + fs := fsys.NewFake() + legacy := []byte(`// Gas City hooks for Pi Coding Agent. +module.exports = { + name: "gascity", + events: { "session.created": () => "" }, + hooks: { "experimental.chat.system.transform": (system) => system }, +}; +`) + fs.Files["/work/.pi/extensions/gc-hooks.js"] = legacy + + if err := Install(fs, "/city", "/work", []string{"pi"}); err != nil { + t.Fatalf("Install: %v", err) + } + + data := string(fs.Files["/work/.pi/extensions/gc-hooks.js"]) + if data == string(legacy) { + t.Fatal("legacy Pi object-export hook was preserved; expected managed upgrade") + } + if !strings.Contains(data, `pi.on("session_start"`) { + t.Fatalf("upgraded Pi hook does not use current extension API:\n%s", data) + } +} + +func TestInstallPiHookPreservesUserAuthoredFile(t *testing.T) { + fs := fsys.NewFake() + custom := []byte(`module.exports = function customPiExtension(pi) { + pi.on("session_start", () => {}); +}; +`) + fs.Files["/work/.pi/extensions/gc-hooks.js"] = custom + + if err := Install(fs, "/city", "/work", []string{"pi"}); err != nil { + t.Fatalf("Install: %v", err) + } + if got := string(fs.Files["/work/.pi/extensions/gc-hooks.js"]); got != string(custom) { + t.Fatalf("user-authored Pi hook was overwritten:\n%s", got) + } } func TestInstallMultipleProviders(t *testing.T) { diff --git a/internal/mail/beadmail/beadmail.go b/internal/mail/beadmail/beadmail.go index c5f3bfe6f..eb096d697 100644 --- a/internal/mail/beadmail/beadmail.go +++ b/internal/mail/beadmail/beadmail.go @@ -5,13 +5,23 @@ package beadmail import ( "crypto/rand" + "errors" "fmt" + "log" "sort" "strconv" "strings" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/mail" + "github.com/gastownhall/gascity/internal/session" +) + +const ( + fromSessionIDMetadataKey = mail.FromSessionIDMetadataKey + fromDisplayMetadataKey = mail.FromDisplayMetadataKey + toSessionIDMetadataKey = mail.ToSessionIDMetadataKey + toDisplayMetadataKey = mail.ToDisplayMetadataKey ) // Provider implements [mail.Provider] using [beads.Store] as the backend. @@ -31,6 +41,10 @@ func (p *Provider) Send(from, to, subject, body string) (mail.Message, error) { if to == "" { return mail.Message{}, fmt.Errorf("beadmail send: recipient is required") } + from, metadata, err := p.resolveSenderRoute(from) + if err != nil { + return mail.Message{}, fmt.Errorf("beadmail send: %w", err) + } threadID := generateThreadID() labels := []string{"thread:" + threadID} @@ -49,6 +63,7 @@ func (p *Provider) Send(from, to, subject, body string) (mail.Message, error) { Assignee: to, From: from, Labels: labels, + Metadata: metadata, }) if err != nil { return mail.Message{}, fmt.Errorf("beadmail send: %w", err) @@ -56,6 +71,47 @@ func (p *Provider) Send(from, to, subject, body string) (mail.Message, error) { return beadToMessage(b), nil } +func (p *Provider) resolveSenderRoute(from string) (string, map[string]string, error) { + from = strings.TrimSpace(from) + if from == "" || from == "human" || p.store == nil { + return from, nil, nil + } + sessionID, err := session.ResolveSessionID(p.store, from) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrAmbiguous) { + return from, nil, nil + } + return "", nil, fmt.Errorf("resolving sender %q: %w", from, err) + } + b, err := p.store.Get(sessionID) + if err != nil { + return "", nil, fmt.Errorf("loading sender session %q: %w", sessionID, err) + } + display := senderDisplayAddress(b, from) + metadata := map[string]string{fromSessionIDMetadataKey: sessionID} + if display != "" { + metadata[fromDisplayMetadataKey] = display + } + return display, metadata, nil +} + +func senderDisplayAddress(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 +} + // Inbox returns all unread messages for the recipient. func (p *Provider) Inbox(recipient string) ([]mail.Message, error) { return p.filterMessages(recipient, false) @@ -148,9 +204,31 @@ func (p *Provider) Reply(id, from, subject, body string) (mail.Message, error) { if err != nil { return mail.Message{}, fmt.Errorf("beadmail reply: %w", err) } - if original.From == "" { + toSessionID := strings.TrimSpace(original.Metadata[fromSessionIDMetadataKey]) + to := toSessionID + if to == "" { + to = strings.TrimSpace(original.From) + } + if to == "" { return mail.Message{}, fmt.Errorf("beadmail reply: original message %s has no sender to reply to", id) } + toDisplay := strings.TrimSpace(original.Metadata[fromDisplayMetadataKey]) + if toDisplay == "" { + toDisplay = strings.TrimSpace(original.From) + } + from, metadata, err := p.resolveSenderRoute(from) + if err != nil { + return mail.Message{}, fmt.Errorf("beadmail reply: %w", err) + } + if metadata == nil { + metadata = make(map[string]string) + } + if toSessionID != "" { + metadata[toSessionIDMetadataKey] = toSessionID + } + if toDisplay != "" { + metadata[toDisplayMetadataKey] = toDisplay + } threadID := extractLabel(original.Labels, "thread:") if threadID == "" { @@ -160,12 +238,13 @@ func (p *Provider) Reply(id, from, subject, body string) (mail.Message, error) { labels := []string{"thread:" + threadID, "reply-to:" + id} b, err := p.store.Create(beads.Bead{ - Title: subject, + Title: deriveReplyTitle(subject, original.Title, body), Description: body, Type: "message", - Assignee: original.From, // reply goes back to sender + Assignee: to, // reply goes back to sender From: from, Labels: labels, + Metadata: metadata, }) if err != nil { return mail.Message{}, fmt.Errorf("beadmail reply: %w", err) @@ -173,6 +252,32 @@ func (p *Provider) Reply(id, from, subject, body string) (mail.Message, error) { return beadToMessage(b), nil } +// deriveReplyTitle returns a non-empty title for a reply message. Callers +// that go through bd create fail validation ("title is required") if the +// reply's title is empty, so this fallback chain always returns a usable +// string. Precedence: explicit subject → "Re: " (deduped) → +// first line of reply body → literal "(reply)". +func deriveReplyTitle(subject, originalTitle, body string) string { + if subject != "" { + return subject + } + if originalTitle != "" { + trimmed := strings.TrimLeft(originalTitle, " \t") + if strings.HasPrefix(strings.ToLower(trimmed), "re:") { + return originalTitle + } + return "Re: " + originalTitle + } + snippet := strings.SplitN(body, "\n", 2)[0] + if len(snippet) > 80 { + snippet = snippet[:77] + "..." + } + if snippet != "" { + return snippet + } + return "(reply)" +} + // Thread returns all messages sharing a thread ID, ordered by creation time. func (p *Provider) Thread(threadID string) ([]mail.Message, error) { bs, err := p.store.List(beads.ListQuery{ @@ -196,16 +301,30 @@ func (p *Provider) Thread(threadID string) ([]mail.Message, error) { // Count returns (total, unread) message counts for a recipient. func (p *Provider) Count(recipient string) (int, int, error) { - candidates, err := p.messageCandidates(recipient) + total, unread, err := p.CountRecipients([]string{recipient}) if err != nil { return 0, 0, fmt.Errorf("beadmail count: %w", err) } + return total, unread, nil +} + +// CountRecipients returns deduplicated total and unread counts for all recipient +// routes represented by recipients. +func (p *Provider) CountRecipients(recipients []string) (int, int, error) { + if len(recipients) == 0 { + return 0, 0, nil + } + routes := p.recipientRoutesForAll(recipients) + candidates, err := p.messageCandidatesForRoutes(routes) + if err != nil { + return 0, 0, fmt.Errorf("listing messages: %w", err) + } var total, unread int for _, b := range candidates { if b.Status != "open" { continue } - if recipient != "" && b.Assignee != recipient { + if len(routes) > 0 && !matchesRecipientRoute(routes, b.Assignee) { continue } total++ @@ -219,7 +338,8 @@ func (p *Provider) Count(recipient string) (int, int, error) { // filterMessages returns open message beads assigned to the recipient. // When includeRead is false, messages with the "read" label are excluded. func (p *Provider) filterMessages(recipient string, includeRead bool) ([]mail.Message, error) { - candidates, err := p.messageCandidates(recipient) + routes := p.recipientRoutes(recipient) + candidates, err := p.messageCandidatesForRoutes(routes) if err != nil { return nil, fmt.Errorf("beadmail: listing beads: %w", err) } @@ -228,7 +348,7 @@ func (p *Provider) filterMessages(recipient string, includeRead bool) ([]mail.Me if b.Status != "open" { continue } - if recipient != "" && b.Assignee != recipient { + if len(routes) > 0 && !matchesRecipientRoute(routes, b.Assignee) { continue } if !includeRead && hasLabel(b.Labels, "read") { @@ -249,7 +369,102 @@ func (p *Provider) filterMessages(recipient string, includeRead bool) ([]mail.Me // // Type="message" is the authoritative discriminator; the legacy gc:message // label supplement was removed in #862 along with writes to that label. -func (p *Provider) messageCandidates(recipient string) ([]beads.Bead, error) { +func (p *Provider) recipientRoutes(recipient string) []string { + recipient = strings.TrimSpace(recipient) + if recipient == "" { + return nil + } + routes := make([]string, 0, 4) + routes = appendRecipientRoute(routes, recipient) + if recipient == "human" || p.store == nil { + return routes + } + sessions, err := p.store.List(beads.ListQuery{Label: session.LabelSession, IncludeClosed: true}) + if err != nil { + log.Printf("beadmail: listing sessions for recipient route %q: %v", recipient, err) + return routes + } + var liveMatches []beads.Bead + var closedMatches []beads.Bead + for _, b := range sessions { + if !session.IsSessionBeadOrRepairable(b) { + continue + } + addresses := sessionAddressesForRecipientRouting(b) + if !containsRecipientRoute(addresses, recipient) { + continue + } + if b.Status == "closed" { + closedMatches = append(closedMatches, b) + continue + } + liveMatches = append(liveMatches, b) + } + matches := liveMatches + if len(matches) == 0 { + matches = closedMatches + } + if len(matches) > 1 { + return []string{recipient} + } + for _, b := range matches { + for _, address := range sessionAddressesForRecipientRouting(b) { + routes = appendRecipientRoute(routes, address) + } + } + return routes +} + +func (p *Provider) recipientRoutesForAll(recipients []string) []string { + var routes []string + for _, recipient := range recipients { + recipientRoutes := p.recipientRoutes(recipient) + for _, route := range recipientRoutes { + routes = appendRecipientRoute(routes, route) + } + } + return routes +} + +func sessionAddressesForRecipientRouting(b beads.Bead) []string { + var routes []string + routes = appendRecipientRoute(routes, b.ID) + routes = appendRecipientRoute(routes, b.Metadata["alias"]) + routes = appendRecipientRoute(routes, b.Metadata["session_name"]) + for _, alias := range session.AliasHistory(b.Metadata) { + routes = appendRecipientRoute(routes, alias) + } + return routes +} + +func appendRecipientRoute(routes []string, route string) []string { + route = strings.TrimSpace(route) + if route == "" || containsRecipientRoute(routes, route) { + return routes + } + return append(routes, route) +} + +func containsRecipientRoute(routes []string, route string) bool { + route = strings.TrimSpace(route) + for _, candidate := range routes { + if candidate == route { + return true + } + } + return false +} + +func matchesRecipientRoute(routes []string, assignee string) bool { + for _, route := range routes { + if assignee == route { + return true + } + } + return false +} + +func (p *Provider) messageCandidatesForRoutes(routes []string) ([]beads.Bead, error) { seen := make(map[string]beads.Bead) order := make([]string, 0) add := func(bs []beads.Bead) { @@ -265,16 +480,18 @@ func (p *Provider) messageCandidates(recipient string) ([]beads.Bead, error) { } // Primary: targeted query scoped to recipient. - if recipient != "" { - assigned, err := p.store.List(beads.ListQuery{ - Assignee: recipient, - Type: "message", - Status: "open", - }) - if err != nil { - return nil, fmt.Errorf("listing by assignee: %w", err) + if len(routes) > 0 { + for _, route := range routes { + assigned, err := p.store.List(beads.ListQuery{ + Assignee: route, + Type: "message", + Status: "open", + }) + if err != nil { + return nil, fmt.Errorf("listing by assignee %q: %w", route, err) + } + add(assigned) } - add(assigned) } else { // No recipient filter — use type-based query for global discovery. all, err := p.store.List(beads.ListQuery{Type: "message"}) @@ -299,10 +516,18 @@ func isMessage(b beads.Bead) bool { // beadToMessage converts a bead to a mail.Message. func beadToMessage(b beads.Bead) mail.Message { + from := b.From + if display := strings.TrimSpace(b.Metadata[fromDisplayMetadataKey]); display != "" { + from = display + } + to := b.Assignee + if display := strings.TrimSpace(b.Metadata[toDisplayMetadataKey]); display != "" { + to = display + } return mail.Message{ ID: b.ID, - From: b.From, - To: b.Assignee, + From: from, + To: to, Subject: b.Title, Body: b.Description, CreatedAt: b.CreatedAt, diff --git a/internal/mail/beadmail/beadmail_test.go b/internal/mail/beadmail/beadmail_test.go index 51b93b97c..98c4b2b65 100644 --- a/internal/mail/beadmail/beadmail_test.go +++ b/internal/mail/beadmail/beadmail_test.go @@ -7,6 +7,7 @@ import ( "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/mail" + "github.com/gastownhall/gascity/internal/session" ) // noListScanStore errors when List is called without a filter, proving that @@ -185,6 +186,172 @@ func TestSend(t *testing.T) { } } +func TestSendStoresStableSessionRouteWithoutChangingDisplaySender(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sender, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-9", + "session_name": "workflows__codex-min-mc-sender", + }, + }) + if err != nil { + t.Fatalf("Create session: %v", err) + } + + msg, err := p.Send("gascity/workflows.codex-min-9", "human", "Approval", "please approve") + if err != nil { + t.Fatalf("Send: %v", err) + } + + if msg.From != "gascity/workflows.codex-min-9" { + t.Fatalf("message From = %q, want display alias", msg.From) + } + b, err := store.Get(msg.ID) + if err != nil { + t.Fatalf("Get message: %v", err) + } + if b.From != "gascity/workflows.codex-min-9" { + t.Fatalf("bead From = %q, want display alias", b.From) + } + if b.Metadata[fromSessionIDMetadataKey] != sender.ID { + t.Fatalf("%s = %q, want %q", fromSessionIDMetadataKey, b.Metadata[fromSessionIDMetadataKey], sender.ID) + } + if b.Metadata[fromDisplayMetadataKey] != "gascity/workflows.codex-min-9" { + t.Fatalf("%s = %q, want original display alias", fromDisplayMetadataKey, b.Metadata[fromDisplayMetadataKey]) + } +} + +func TestReplyUsesStoredSenderSessionIDAfterAliasRename(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sender, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "old-sender", + "session_name": "sender-gc-42", + }, + }) + if err != nil { + t.Fatalf("Create session: %v", err) + } + original, err := p.Send("old-sender", "human", "Approval", "please approve") + if err != nil { + t.Fatalf("Send: %v", err) + } + if err := store.SetMetadataBatch(sender.ID, session.UpdatedAliasMetadata(sender.Metadata, "new-sender")); err != nil { + t.Fatalf("SetMetadataBatch(alias rename): %v", err) + } + + reply, err := p.Reply(original.ID, "human", "approved", "approved") + if err != nil { + t.Fatalf("Reply: %v", err) + } + if reply.To != "old-sender" { + t.Fatalf("reply To = %q, want original display sender", reply.To) + } + b, err := store.Get(reply.ID) + if err != nil { + t.Fatalf("Get reply: %v", err) + } + if b.Assignee != sender.ID { + t.Fatalf("reply bead Assignee = %q, want stable sender session ID %q", b.Assignee, sender.ID) + } + if b.Metadata[toSessionIDMetadataKey] != sender.ID { + t.Fatalf("reply %s = %q, want %q", toSessionIDMetadataKey, b.Metadata[toSessionIDMetadataKey], sender.ID) + } + if b.Metadata[toDisplayMetadataKey] != "old-sender" { + t.Fatalf("reply %s = %q, want original display sender", toDisplayMetadataKey, b.Metadata[toDisplayMetadataKey]) + } + inbox, err := p.Inbox("new-sender") + if err != nil { + t.Fatalf("Inbox(new-sender): %v", err) + } + if len(inbox) != 1 || inbox[0].ID != reply.ID { + t.Fatalf("Inbox(new-sender) = %#v, want reply %s", inbox, reply.ID) + } + oldInbox, err := p.Inbox("old-sender") + if err != nil { + t.Fatalf("Inbox(old-sender): %v", err) + } + if len(oldInbox) != 1 || oldInbox[0].ID != reply.ID { + t.Fatalf("Inbox(old-sender) = %#v, want reply %s", oldInbox, reply.ID) + } + total, unread, err := p.Count("new-sender") + if err != nil { + t.Fatalf("Count(new-sender): %v", err) + } + if total != 1 || unread != 1 { + t.Fatalf("Count(new-sender) = (%d, %d), want (1, 1)", total, unread) + } +} + +func TestSendFallsBackToLiteralSenderWhenSessionIdentifierIsAmbiguous(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + for i := 0; i < 2; i++ { + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "duplicate", + }, + }); err != nil { + t.Fatalf("Create session %d: %v", i, err) + } + } + + msg, err := p.Send("duplicate", "human", "subject", "body") + if err != nil { + t.Fatalf("Send: %v", err) + } + if msg.From != "duplicate" { + t.Fatalf("message From = %q, want literal ambiguous sender", msg.From) + } + b, err := store.Get(msg.ID) + if err != nil { + t.Fatalf("Get message: %v", err) + } + if b.Metadata[fromSessionIDMetadataKey] != "" { + t.Fatalf("ambiguous sender stored %s = %q, want empty", fromSessionIDMetadataKey, b.Metadata[fromSessionIDMetadataKey]) + } +} + +func TestInboxFallsBackToLiteralRecipientWhenSessionIdentifierIsAmbiguous(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + for i := 0; i < 2; i++ { + if _, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "duplicate", + }, + }); err != nil { + t.Fatalf("Create session %d: %v", i, err) + } + } + msg, err := p.Send("human", "duplicate", "subject", "body") + if err != nil { + t.Fatalf("Send: %v", err) + } + + inbox, err := p.Inbox("duplicate") + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(inbox) != 1 || inbox[0].ID != msg.ID { + t.Fatalf("Inbox = %#v, want literal recipient message %s", inbox, msg.ID) + } +} + func TestSendRejectsEmptyRecipient(t *testing.T) { p := New(beads.NewMemStore()) if _, err := p.Send("human", "", "subject", "body"); err == nil { @@ -561,6 +728,358 @@ func TestReply(t *testing.T) { } } +// TestReplyDerivesSubjectFromOriginal ensures an empty subject is replaced +// with "Re: ", so underlying stores that require a +// non-empty title (e.g. BdStore → `bd create`) don't reject the reply. +func TestReplyDerivesSubjectFromOriginal(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sent, err := p.Send("alice", "bob", "Hello", "first message") + if err != nil { + t.Fatal(err) + } + + reply, err := p.Reply(sent.ID, "bob", "", "reply body") + if err != nil { + t.Fatalf("Reply with empty subject: %v", err) + } + if reply.Subject != "Re: Hello" { + t.Errorf("Reply Subject = %q, want %q", reply.Subject, "Re: Hello") + } +} + +// TestReplyPreservesExplicitSubject ensures an explicit subject is passed +// through unchanged — no automatic "Re:" prefixing. +func TestReplyPreservesExplicitSubject(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sent, err := p.Send("alice", "bob", "Hello", "first message") + if err != nil { + t.Fatal(err) + } + + reply, err := p.Reply(sent.ID, "bob", "Custom subject", "reply body") + if err != nil { + t.Fatalf("Reply: %v", err) + } + if reply.Subject != "Custom subject" { + t.Errorf("Reply Subject = %q, want %q", reply.Subject, "Custom subject") + } +} + +// TestReplyAvoidsDoubleRePrefix ensures that replying to a message whose +// subject already starts with "Re:" does not produce "Re: Re: ..." when +// the caller omits the subject. +func TestReplyAvoidsDoubleRePrefix(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sent, err := p.Send("alice", "bob", "Re: Hello", "body") + if err != nil { + t.Fatal(err) + } + + reply, err := p.Reply(sent.ID, "bob", "", "reply body") + if err != nil { + t.Fatalf("Reply: %v", err) + } + if reply.Subject != "Re: Hello" { + t.Errorf("Reply Subject = %q, want %q (no double prefix)", reply.Subject, "Re: Hello") + } +} + +// TestReplyFallsBackToBodyWhenOriginalTitleEmpty covers the degenerate case +// where an original message somehow has no title (possible in stores that +// don't enforce title). The reply still gets a non-empty title. +func TestReplyFallsBackToBodyWhenOriginalTitleEmpty(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + // Create a message bead directly without a title. + orig, err := store.Create(beads.Bead{ + Type: "message", + Assignee: "bob", + From: "alice", + Labels: []string{"thread:t1"}, + }) + if err != nil { + t.Fatal(err) + } + + reply, err := p.Reply(orig.ID, "bob", "", "a terse reply body") + if err != nil { + t.Fatalf("Reply: %v", err) + } + if reply.Subject == "" { + t.Error("Reply Subject is empty; must be non-empty so bd create won't reject") + } + if reply.Subject != "a terse reply body" { + t.Errorf("Reply Subject = %q, want %q (first line of body)", reply.Subject, "a terse reply body") + } +} + +// TestReplyAgainstBdStoreValidatesTitle is a regression test that exercises +// the real BdStore code path: the fake runner emulates `bd create`'s +// title-required validation. Without a derived title, Reply would fail here. +func TestReplyAgainstBdStoreValidatesTitle(t *testing.T) { + // Fake runner that rejects `bd create` with empty positional title, + // the same way the real bd binary does. + runner := func(_ string, name string, args ...string) ([]byte, error) { + if name != "bd" { + return nil, errors.New("unexpected command: " + name) + } + switch args[0] { + case "create": + // args: create --json -t <type> [flags...] + if len(args) < 3 { + return nil, errors.New("bd create: too few args") + } + title := args[2] + if title == "" { + return nil, errors.New(`exit status 1: {"error":"validation failed for issue : title is required"}`) + } + // Return a minimal issue JSON. + id := "bd-" + title + return []byte(`{"id":"` + id + `","title":"` + title + `","status":"open","issue_type":"message","created_at":"2026-04-24T00:00:00Z"}`), nil + case "show": + // bd show --json returns a JSON array. + return []byte(`[{"id":"bd-Hello","title":"Hello","status":"open","issue_type":"message","assignee":"bob","from":"alice","created_at":"2026-04-24T00:00:00Z","labels":["thread:t1"]}]`), nil + case "update": + return []byte(`{}`), nil + case "list": + return []byte(`[]`), nil + } + return nil, errors.New("unexpected bd subcommand: " + args[0]) + } + p := New(beads.NewBdStore(t.TempDir(), runner)) + + // Reply with empty subject — must succeed because the provider derives + // "Re: Hello" from the original message. + reply, err := p.Reply("bd-Hello", "bob", "", "reply body") + if err != nil { + t.Fatalf("Reply should derive a non-empty title to pass bd validation: %v", err) + } + if reply.Subject != "Re: Hello" { + t.Errorf("Reply Subject = %q, want %q", reply.Subject, "Re: Hello") + } +} + +func TestReplyPrefersStoredSenderSessionID(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sender, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-9", + "session_name": "workflows__codex-min-mc-sender", + }, + }) + if err != nil { + t.Fatalf("Create session: %v", err) + } + responder, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-10", + "session_name": "workflows__codex-min-mc-responder", + }, + }) + if err != nil { + t.Fatalf("Create responder session: %v", err) + } + original, err := store.Create(beads.Bead{ + Title: "Approval needed", + Description: "please approve", + Type: "message", + Assignee: "human", + From: "gascity/workflows.codex-min-9", + Labels: []string{"thread:stable-route"}, + Metadata: map[string]string{ + fromSessionIDMetadataKey: sender.ID, + fromDisplayMetadataKey: "gascity/workflows.codex-min-9", + }, + }) + if err != nil { + t.Fatalf("Create original message: %v", err) + } + + reply, err := p.Reply(original.ID, "gascity/workflows.codex-min-10", "approved", "approved") + if err != nil { + t.Fatalf("Reply: %v", err) + } + + if reply.To != "gascity/workflows.codex-min-9" { + t.Fatalf("reply To = %q, want sender display alias", reply.To) + } + if reply.From != "gascity/workflows.codex-min-10" { + t.Fatalf("reply From = %q, want display alias", reply.From) + } + b, err := store.Get(reply.ID) + if err != nil { + t.Fatalf("Get reply: %v", err) + } + if b.Metadata[fromSessionIDMetadataKey] != responder.ID { + t.Fatalf("reply %s = %q, want %q", fromSessionIDMetadataKey, b.Metadata[fromSessionIDMetadataKey], responder.ID) + } + if b.Metadata[fromDisplayMetadataKey] != "gascity/workflows.codex-min-10" { + t.Fatalf("reply %s = %q, want responder display alias", fromDisplayMetadataKey, b.Metadata[fromDisplayMetadataKey]) + } + if b.Assignee != sender.ID { + t.Fatalf("reply bead Assignee = %q, want stable sender session ID %q", b.Assignee, sender.ID) + } + if b.Metadata[toSessionIDMetadataKey] != sender.ID { + t.Fatalf("reply %s = %q, want %q", toSessionIDMetadataKey, b.Metadata[toSessionIDMetadataKey], sender.ID) + } + if b.Metadata[toDisplayMetadataKey] != "gascity/workflows.codex-min-9" { + t.Fatalf("reply %s = %q, want sender display alias", toDisplayMetadataKey, b.Metadata[toDisplayMetadataKey]) + } +} + +func TestReplyToClosedSenderSessionIsDiscoverableByHistoricalAlias(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + sender, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-9", + "alias_history": "gascity/workflows.codex-min-8", + "session_name": "workflows__codex-min-mc-sender", + }, + }) + if err != nil { + t.Fatalf("Create sender session: %v", err) + } + responder, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "gascity/workflows.codex-min-10", + "session_name": "workflows__codex-min-mc-responder", + }, + }) + if err != nil { + t.Fatalf("Create responder session: %v", err) + } + original, err := store.Create(beads.Bead{ + Title: "Approval needed", + Description: "please approve", + Type: "message", + Assignee: "human", + From: "gascity/workflows.codex-min-8", + Labels: []string{"thread:closed-sender-route"}, + Metadata: map[string]string{ + fromSessionIDMetadataKey: sender.ID, + fromDisplayMetadataKey: "gascity/workflows.codex-min-8", + }, + }) + if err != nil { + t.Fatalf("Create original message: %v", err) + } + if err := store.Close(sender.ID); err != nil { + t.Fatalf("Close sender session: %v", err) + } + + reply, err := p.Reply(original.ID, "gascity/workflows.codex-min-10", "approved", "approved") + if err != nil { + t.Fatalf("Reply: %v", err) + } + if reply.To != "gascity/workflows.codex-min-8" { + t.Fatalf("reply To = %q, want historical sender display alias", reply.To) + } + if reply.From != "gascity/workflows.codex-min-10" { + t.Fatalf("reply From = %q, want responder display alias", reply.From) + } + b, err := store.Get(reply.ID) + if err != nil { + t.Fatalf("Get reply: %v", err) + } + if b.Assignee != sender.ID { + t.Fatalf("reply bead Assignee = %q, want closed sender session ID %q", b.Assignee, sender.ID) + } + if b.Metadata[fromSessionIDMetadataKey] != responder.ID { + t.Fatalf("reply %s = %q, want %q", fromSessionIDMetadataKey, b.Metadata[fromSessionIDMetadataKey], responder.ID) + } + + msgs, err := p.Inbox("gascity/workflows.codex-min-8") + if err != nil { + t.Fatalf("Inbox by historical alias: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("Inbox by historical alias returned %d messages, want 1", len(msgs)) + } + if msgs[0].ID != reply.ID { + t.Fatalf("Inbox by historical alias returned %s, want reply %s", msgs[0].ID, reply.ID) + } +} + +func TestRecipientRoutesPreferLiveSessionOverClosedHistory(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + + closed, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "old-worker", + "alias_history": "worker", + "session_name": "workflows__codex-min-mc-old", + }, + }) + if err != nil { + t.Fatalf("Create closed session: %v", err) + } + if err := store.Close(closed.ID); err != nil { + t.Fatalf("Close session: %v", err) + } + live, err := store.Create(beads.Bead{ + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "alias": "worker", + "session_name": "workflows__codex-min-mc-live", + }, + }) + if err != nil { + t.Fatalf("Create live session: %v", err) + } + closedReply, err := store.Create(beads.Bead{ + Title: "old reply", + Type: "message", + Assignee: closed.ID, + From: "human", + }) + if err != nil { + t.Fatalf("Create closed reply: %v", err) + } + liveMail, err := store.Create(beads.Bead{ + Title: "live mail", + Type: "message", + Assignee: live.ID, + From: "human", + }) + if err != nil { + t.Fatalf("Create live mail: %v", err) + } + + msgs, err := p.Inbox("worker") + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("Inbox returned %d messages, want 1", len(msgs)) + } + if msgs[0].ID != liveMail.ID { + t.Fatalf("Inbox returned %s, want live message %s; closed reply was %s", msgs[0].ID, liveMail.ID, closedReply.ID) + } +} + // --- Thread --- func TestThread(t *testing.T) { @@ -639,6 +1158,22 @@ func TestCount(t *testing.T) { } } +func TestCountRecipientsEmptyDoesNotCountAllMessages(t *testing.T) { + store := beads.NewMemStore() + p := New(store) + if _, err := p.Send("human", "mayor", "", "msg"); err != nil { + t.Fatalf("Send: %v", err) + } + + total, unread, err := p.CountRecipients(nil) + if err != nil { + t.Fatalf("CountRecipients(nil): %v", err) + } + if total != 0 || unread != 0 { + t.Fatalf("CountRecipients(nil) = (%d,%d), want (0,0)", total, unread) + } +} + // --- Check --- func TestCheck(t *testing.T) { diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 3b46d8ffb..db1296077 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -16,6 +16,21 @@ var ErrAlreadyArchived = errors.New("already archived") // ErrNotFound is returned when a message ID does not exist. var ErrNotFound = errors.New("message not found") +const ( + // FromSessionIDMetadataKey stores the stable session bead ID used for + // reply routing when a message's display sender may later be renamed. + FromSessionIDMetadataKey = "mail.from_session_id" + // FromDisplayMetadataKey stores the human-readable sender captured when + // the message was created. + FromDisplayMetadataKey = "mail.from_display" + // ToSessionIDMetadataKey stores the stable recipient session bead ID used + // for routing replies while keeping the public To field human-readable. + ToSessionIDMetadataKey = "mail.to_session_id" + // ToDisplayMetadataKey stores the human-readable recipient captured when + // the message was created. + ToDisplayMetadataKey = "mail.to_display" +) + // Message represents a mail message between agents or humans. type Message struct { ID string `json:"id"` diff --git a/internal/materialize/mcp.go b/internal/materialize/mcp.go index aba778af0..3a8d25b32 100644 --- a/internal/materialize/mcp.go +++ b/internal/materialize/mcp.go @@ -24,6 +24,8 @@ const ( MCPTransportStdio MCPTransport = "stdio" // MCPTransportHTTP is a streamable HTTP MCP server. MCPTransportHTTP MCPTransport = "http" + // MCPTransportSSE is an SSE-connected MCP server. + MCPTransportSSE MCPTransport = "sse" ) // MCPServer is the canonical neutral MCP model after parsing, diff --git a/internal/materialize/mcp_runtime.go b/internal/materialize/mcp_runtime.go new file mode 100644 index 000000000..c593ef004 --- /dev/null +++ b/internal/materialize/mcp_runtime.go @@ -0,0 +1,133 @@ +package materialize + +import ( + "os" + "path/filepath" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/git" + "github.com/gastownhall/gascity/internal/runtime" + workdirutil "github.com/gastownhall/gascity/internal/workdir" +) + +// EffectiveMCPForSession loads, expands, and resolves the effective MCP +// catalog for one concrete session context. +func EffectiveMCPForSession( + cfg *config.City, + cityPath string, + agent *config.Agent, + identity string, + workDir string, +) (MCPCatalog, error) { + cfgForMCP := cfg + if cfg != nil && cfg.PackMCPDir == "" { + cityMCPDir := filepath.Join(cityPath, "mcp") + if info, err := os.Stat(cityMCPDir); err == nil && info.IsDir() { + clone := *cfg + clone.PackMCPDir = cityMCPDir + cfgForMCP = &clone + } + } + return EffectiveMCPForAgent(cfgForMCP, agent, MCPTemplateData(cfgForMCP, cityPath, agent, identity, workDir)) +} + +// MCPTemplateData builds the template expansion surface used by MCP catalogs. +func MCPTemplateData( + cfg *config.City, + cityPath string, + agent *config.Agent, + identity string, + workDir string, +) map[string]string { + if agent == nil { + branch := defaultMCPBranch(workDir) + return map[string]string{ + "CityRoot": cityPath, + "AgentName": identity, + "TemplateName": identity, + "WorkDir": workDir, + "Branch": branch, + "DefaultBranch": branch, + } + } + var rigs []config.Rig + if cfg != nil { + rigs = cfg.Rigs + } + rigName := workdirutil.ConfiguredRigName(cityPath, *agent, rigs) + rigRoot := workdirutil.RigRootForName(rigName, rigs) + templateName := agent.QualifiedName() + if agent.PoolName != "" { + templateName = agent.PoolName + } + if templateName == "" { + templateName = identity + } + data := make(map[string]string, len(agent.Env)+11) + for key, value := range agent.Env { + data[key] = value + } + branch := defaultMCPBranch(workDir) + data["CityRoot"] = cityPath + data["AgentName"] = identity + data["TemplateName"] = templateName + data["RigName"] = rigName + data["RigRoot"] = rigRoot + data["WorkDir"] = workDir + data["IssuePrefix"] = mcpRigPrefix(rigName, rigs) + data["Branch"] = branch + data["DefaultBranch"] = branch + data["WorkQuery"] = agent.EffectiveWorkQuery() + data["SlingQuery"] = agent.EffectiveSlingQuery() + return data +} + +// RuntimeMCPServers converts neutral MCP servers into runtime-owned ACP +// session/new server definitions. +func RuntimeMCPServers(servers []MCPServer) []runtime.MCPServerConfig { + if len(servers) == 0 { + return nil + } + out := make([]runtime.MCPServerConfig, 0, len(servers)) + for _, server := range servers { + entry := runtime.MCPServerConfig{ + Name: server.Name, + Command: server.Command, + Args: append([]string(nil), server.Args...), + Env: cloneStringMap(server.Env), + URL: server.URL, + Headers: cloneStringMap(server.Headers), + } + switch server.Transport { + case MCPTransportHTTP: + entry.Transport = runtime.MCPTransportHTTP + case MCPTransportSSE: + entry.Transport = runtime.MCPTransportSSE + default: + entry.Transport = runtime.MCPTransportStdio + } + out = append(out, entry) + } + return runtime.NormalizeMCPServerConfigs(out) +} + +func mcpRigPrefix(rigName string, rigs []config.Rig) string { + for i := range rigs { + if rigs[i].Name == rigName { + return rigs[i].EffectivePrefix() + } + } + return "" +} + +func defaultMCPBranch(dir string) string { + if dir == "" { + return "main" + } + g := git.New(filepath.Clean(dir)) + branch, _ := g.DefaultBranch() + if branch == "" { + return "main" + } + return branch +} diff --git a/internal/materialize/mcp_test.go b/internal/materialize/mcp_test.go index 248483bcf..ac9b05ef3 100644 --- a/internal/materialize/mcp_test.go +++ b/internal/materialize/mcp_test.go @@ -9,6 +9,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/fsys" + "github.com/gastownhall/gascity/internal/runtime" ) func TestMCPIdentityForFilename(t *testing.T) { @@ -196,6 +197,70 @@ func TestNormalizeMCPServerStableMapOrder(t *testing.T) { } } +func TestRuntimeMCPServersPreservesTransport(t *testing.T) { + t.Parallel() + + got := RuntimeMCPServers([]MCPServer{ + {Name: "stdio", Transport: MCPTransportStdio, Command: "uvx"}, + {Name: "http", Transport: MCPTransportHTTP, URL: "https://example.test/http"}, + {Name: "sse", Transport: MCPTransportSSE, URL: "https://example.test/sse"}, + }) + want := []runtime.MCPServerConfig{ + {Name: "http", Transport: runtime.MCPTransportHTTP, URL: "https://example.test/http"}, + {Name: "sse", Transport: runtime.MCPTransportSSE, URL: "https://example.test/sse"}, + {Name: "stdio", Transport: runtime.MCPTransportStdio, Command: "uvx"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("RuntimeMCPServers()=%#v, want %#v", got, want) + } +} + +func TestMCPTemplateDataUsesBackingTemplateName(t *testing.T) { + t.Parallel() + + agent := &config.Agent{ + Name: "worker", + Dir: "rig-a", + Env: map[string]string{"TOKEN": "abc"}, + } + got := MCPTemplateData(&config.City{}, "/tmp/city", agent, "rig-a/worker-7", "/tmp/work") + if got["AgentName"] != "rig-a/worker-7" { + t.Fatalf("AgentName = %q, want %q", got["AgentName"], "rig-a/worker-7") + } + if got["TemplateName"] != "rig-a/worker" { + t.Fatalf("TemplateName = %q, want %q", got["TemplateName"], "rig-a/worker") + } + if got["TOKEN"] != "abc" { + t.Fatalf("TOKEN = %q, want abc", got["TOKEN"]) + } +} + +func TestMCPTemplateDataUsesPoolNameForPoolInstances(t *testing.T) { + t.Parallel() + + agent := &config.Agent{ + Name: "worker-3", + PoolName: "worker", + } + got := MCPTemplateData(&config.City{}, "/tmp/city", agent, "worker-3", "/tmp/work") + if got["TemplateName"] != "worker" { + t.Fatalf("TemplateName = %q, want %q", got["TemplateName"], "worker") + } +} + +func TestMCPTemplateDataPreservesBranchAlias(t *testing.T) { + t.Parallel() + + agent := &config.Agent{Name: "worker"} + got := MCPTemplateData(&config.City{}, "/tmp/city", agent, "worker-1", "") + if got["Branch"] == "" { + t.Fatal("Branch = empty, want default branch alias") + } + if got["Branch"] != got["DefaultBranch"] { + t.Fatalf("Branch = %q, want %q", got["Branch"], got["DefaultBranch"]) + } +} + func TestMCPPackSourcesForAgentOrdersAndDedupes(t *testing.T) { t.Parallel() diff --git a/internal/orders/runtime_helpers_test.go b/internal/orders/runtime_helpers_test.go new file mode 100644 index 000000000..16fac0ce7 --- /dev/null +++ b/internal/orders/runtime_helpers_test.go @@ -0,0 +1,55 @@ +package orders + +import ( + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" +) + +func TestLastRunFuncForStoreReturnsLatestRun(t *testing.T) { + store := beads.NewMemStore() + + first, err := store.Create(beads.Bead{ + Title: "order:digest", + Status: "closed", + Labels: []string{"order-run:digest"}, + }) + if err != nil { + t.Fatal(err) + } + + time.Sleep(time.Millisecond) + + second, err := store.Create(beads.Bead{ + Title: "order:digest", + Status: "closed", + Labels: []string{"order-run:digest", "wisp-failed"}, + }) + if err != nil { + t.Fatal(err) + } + + got, err := LastRunFuncForStore(store)("digest") + if err != nil { + t.Fatalf("LastRunFuncForStore(): %v", err) + } + if !got.Equal(second.CreatedAt) { + t.Fatalf("LastRunFuncForStore() = %s, want %s (latest run should remain authoritative)", got, second.CreatedAt) + } + if !second.CreatedAt.After(first.CreatedAt) { + t.Fatalf("test setup invalid: second.CreatedAt=%s, first.CreatedAt=%s", second.CreatedAt, first.CreatedAt) + } +} + +func TestLastRunFuncForStoreReturnsZeroWhenNoRunsExist(t *testing.T) { + store := beads.NewMemStore() + + got, err := LastRunFuncForStore(store)("digest") + if err != nil { + t.Fatalf("LastRunFuncForStore(): %v", err) + } + if !got.IsZero() { + t.Fatalf("LastRunFuncForStore() = %s, want zero time", got) + } +} diff --git a/internal/orders/triggers.go b/internal/orders/triggers.go index d1286b227..ce8a1ebbf 100644 --- a/internal/orders/triggers.go +++ b/internal/orders/triggers.go @@ -3,12 +3,14 @@ package orders import ( "context" "fmt" + "os" "os/exec" "strconv" "strings" "time" "github.com/gastownhall/gascity/internal/events" + "github.com/gastownhall/gascity/internal/execenv" ) // TriggerResult holds the outcome of a trigger check. @@ -29,19 +31,32 @@ type LastRunFunc func(name string) (time.Time, error) // Returns 0 if no cursor exists. type CursorFunc func(orderName string) uint64 +// TriggerOptions carries execution context for triggers that run subprocesses. +type TriggerOptions struct { + ConditionDir string + ConditionEnv []string + ConditionTimeout time.Duration +} + // CheckTrigger evaluates an order's trigger condition and returns whether it's due. // ep is an events Provider used by event triggers to query events; may be nil for // non-event triggers. // cursorFn returns the last-processed event seq for event triggers; may be nil for // non-event triggers. func CheckTrigger(a Order, now time.Time, lastRunFn LastRunFunc, ep events.Provider, cursorFn CursorFunc) TriggerResult { + return CheckTriggerWithOptions(a, now, lastRunFn, ep, cursorFn, TriggerOptions{}) +} + +// CheckTriggerWithOptions evaluates an order trigger using explicit execution +// context for condition checks. +func CheckTriggerWithOptions(a Order, now time.Time, lastRunFn LastRunFunc, ep events.Provider, cursorFn CursorFunc, opts TriggerOptions) TriggerResult { switch a.Trigger { case "cooldown": return checkCooldown(a, now, lastRunFn) case "cron": return checkCron(a, now, lastRunFn) case "condition": - return checkCondition(a) + return checkCondition(a, opts) case "event": return checkEvent(a, ep, cursorFn) case "manual": @@ -131,12 +146,19 @@ func cronFieldMatches(field string, value int) bool { // checkCondition runs the check command and returns due if exit code is 0. // Uses a timeout to prevent hanging check scripts from blocking trigger evaluation. -func checkCondition(a Order) TriggerResult { +func checkCondition(a Order, opts TriggerOptions) TriggerResult { const triggerCheckTimeout = 10 * time.Second - timeout := triggerCheckTimeout + timeout := opts.ConditionTimeout + if timeout <= 0 { + timeout = triggerCheckTimeout + } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, "sh", "-c", a.Check) + if opts.ConditionDir != "" { + cmd.Dir = opts.ConditionDir + } + cmd.Env = mergeConditionEnv(os.Environ(), opts.ConditionEnv) if err := cmd.Run(); err != nil { if ctx.Err() == context.DeadlineExceeded { return TriggerResult{Due: false, Reason: fmt.Sprintf("check command timed out after %s", timeout)} @@ -146,6 +168,10 @@ func checkCondition(a Order) TriggerResult { return TriggerResult{Due: true, Reason: "condition: check passed (exit 0)"} } +func mergeConditionEnv(environ, extra []string) []string { + return execenv.MergeEntries(environ, extra) +} + // checkEvent checks if matching events exist after the last cursor position. func checkEvent(a Order, ep events.Provider, cursorFn CursorFunc) TriggerResult { if ep == nil { diff --git a/internal/orders/triggers_test.go b/internal/orders/triggers_test.go index 2ba4d0d11..7ae90c81c 100644 --- a/internal/orders/triggers_test.go +++ b/internal/orders/triggers_test.go @@ -97,6 +97,26 @@ func TestCheckTriggerCondition(t *testing.T) { } } +func TestCheckTriggerConditionUsesOptions(t *testing.T) { + dir := t.TempDir() + a := Order{ + Name: "check", + Trigger: "condition", + Check: `test "$GC_CITY_PATH" = "$EXPECT_CITY" && test "$(pwd)" = "$EXPECT_CITY"`, + } + now := time.Date(2026, 2, 27, 12, 0, 0, 0, time.UTC) + result := CheckTriggerWithOptions(a, now, neverRan, nil, nil, TriggerOptions{ + ConditionDir: dir, + ConditionEnv: []string{ + "EXPECT_CITY=" + dir, + "GC_CITY_PATH=" + dir, + }, + }) + if !result.Due { + t.Errorf("Due = false, want true with condition cwd/env: %s", result.Reason) + } +} + func TestCheckTriggerConditionFails(t *testing.T) { a := Order{Name: "check", Trigger: "condition", Check: "false"} now := time.Date(2026, 2, 27, 12, 0, 0, 0, time.UTC) diff --git a/internal/runtime/acp/acp.go b/internal/runtime/acp/acp.go index 80db58026..f3a851fd2 100644 --- a/internal/runtime/acp/acp.go +++ b/internal/runtime/acp/acp.go @@ -67,8 +67,9 @@ type Provider struct { // Compile-time check. var ( - _ runtime.Provider = (*Provider)(nil) - _ runtime.InteractionProvider = (*Provider)(nil) + _ runtime.Provider = (*Provider)(nil) + _ runtime.InteractionProvider = (*Provider)(nil) + _ runtime.TransportCapabilityProvider = (*Provider)(nil) ) // NewProvider returns an ACP [Provider] that stores socket files in @@ -96,6 +97,12 @@ func NewProviderWithDir(dir string, cfg Config) *Provider { } } +// SupportsTransport reports whether this provider can host the requested +// session transport. +func (p *Provider) SupportsTransport(transport string) bool { + return transport == "acp" +} + // Start spawns an ACP agent process, performs the JSON-RPC handshake, and // optionally sends the initial nudge. Returns an error if a session with // that name already exists or the handshake fails. @@ -263,7 +270,7 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e hsTimeoutCtx, hsTimeoutCancel := context.WithTimeout(hsCtx, p.cfg.handshakeTimeout()) defer hsTimeoutCancel() - if err := p.handshake(hsTimeoutCtx, sc); err != nil { + if err := p.handshake(hsTimeoutCtx, sc, cfg.WorkDir, cfg.MCPServers); err != nil { // Handshake failed — kill the process. The monitor goroutine // handles listener/socket cleanup when the process exits. _ = stdinPipe.Close() @@ -301,7 +308,7 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e } // handshake performs the ACP initialize → initialized → session/new sequence. -func (p *Provider) handshake(ctx context.Context, sc *sessionConn) error { +func (p *Provider) handshake(ctx context.Context, sc *sessionConn, workDir string, mcpServers []runtime.MCPServerConfig) error { // Step 1: Send "initialize" request. initReq, _ := newInitializeRequest() ch, err := sc.sendRequest(initReq) @@ -328,7 +335,7 @@ func (p *Provider) handshake(ctx context.Context, sc *sessionConn) error { } // Step 3: Send "session/new" request. - newReq, _ := newSessionNewRequest() + newReq, _ := newSessionNewRequest(workDir, mcpServers) ch, err = sc.sendRequest(newReq) if err != nil { return fmt.Errorf("sending session/new: %w", err) diff --git a/internal/runtime/acp/acp_test.go b/internal/runtime/acp/acp_test.go index d9b5c177b..6189bd1f7 100644 --- a/internal/runtime/acp/acp_test.go +++ b/internal/runtime/acp/acp_test.go @@ -87,11 +87,10 @@ for line in sys.stdin: respond(msg_id, {"sessionId": session_id}) elif method == "session/prompt": params = msg.get("params", {}) - messages = params.get("messages", []) + blocks = params.get("prompt", []) text = "" - for m in messages: - for c in m.get("content", []): - text += c.get("text", "") + for b in blocks: + text += b.get("text", "") # Send update notification with echoed text notify("session/update", { "sessionId": session_id, diff --git a/internal/runtime/acp/protocol.go b/internal/runtime/acp/protocol.go index fff3d5a78..3ebefd3e4 100644 --- a/internal/runtime/acp/protocol.go +++ b/internal/runtime/acp/protocol.go @@ -16,6 +16,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "sync/atomic" "github.com/gastownhall/gascity/internal/runtime" @@ -66,7 +67,8 @@ type ServerInfo struct { // InitializeParams is the params for the "initialize" request. type InitializeParams struct { - ClientInfo ClientInfo `json:"clientInfo"` + ProtocolVersion int `json:"protocolVersion"` + ClientInfo ClientInfo `json:"clientInfo"` } // InitializeResult is the result of the "initialize" request. @@ -74,6 +76,66 @@ type InitializeResult struct { ServerInfo ServerInfo `json:"serverInfo"` } +// SessionNewParams is the params for the "session/new" request. +type SessionNewParams struct { + Cwd string `json:"cwd"` + McpServers []SessionNewMCPServer `json:"mcpServers"` +} + +// SessionNewMCPServer is the ACP wire representation of one MCP server +// attached to session/new. +type SessionNewMCPServer struct { + Name string + Transport runtime.MCPTransport + Command string + Args []string + Env []runtime.MCPKeyValue + URL string + Headers []runtime.MCPKeyValue +} + +type sessionNewMCPServerStdio struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env []runtime.MCPKeyValue `json:"env"` +} + +type sessionNewMCPServerHTTP struct { + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + Headers []runtime.MCPKeyValue `json:"headers"` +} + +// MarshalJSON emits the transport-specific ACP schema shape for one MCP +// server. Stdio omits the type discriminator per spec. +func (s SessionNewMCPServer) MarshalJSON() ([]byte, error) { + switch s.Transport { + case runtime.MCPTransportHTTP: + return json.Marshal(sessionNewMCPServerHTTP{ + Type: string(runtime.MCPTransportHTTP), + Name: s.Name, + URL: s.URL, + Headers: nonNilMCPKeyValues(s.Headers), + }) + case runtime.MCPTransportSSE: + return json.Marshal(sessionNewMCPServerHTTP{ + Type: string(runtime.MCPTransportSSE), + Name: s.Name, + URL: s.URL, + Headers: nonNilMCPKeyValues(s.Headers), + }) + default: + return json.Marshal(sessionNewMCPServerStdio{ + Name: s.Name, + Command: s.Command, + Args: nonNilStrings(s.Args), + Env: nonNilMCPKeyValues(s.Env), + }) + } +} + // SessionNewResult is the result of the "session/new" request. type SessionNewResult struct { SessionID string `json:"sessionId"` @@ -81,14 +143,8 @@ type SessionNewResult struct { // SessionPromptParams is the params for the "session/prompt" request. type SessionPromptParams struct { - SessionID string `json:"sessionId"` - Messages []PromptMessage `json:"messages"` -} - -// PromptMessage is a message within a session/prompt request. -type PromptMessage struct { - Role string `json:"role"` - Content []ContentBlock `json:"content"` + SessionID string `json:"sessionId"` + Prompt []ContentBlock `json:"prompt"` } // SessionUpdateParams is the params for "session/update" notifications. @@ -123,7 +179,8 @@ func newNotification(method string) JSONRPCMessage { // newInitializeRequest creates an "initialize" request. func newInitializeRequest() (JSONRPCMessage, int64) { return newRequest("initialize", InitializeParams{ - ClientInfo: ClientInfo{Name: "gc", Version: "1.0"}, + ProtocolVersion: 1, + ClientInfo: ClientInfo{Name: "gc", Version: "1.0"}, }) } @@ -133,8 +190,61 @@ func newInitializedNotification() JSONRPCMessage { } // newSessionNewRequest creates a "session/new" request. -func newSessionNewRequest() (JSONRPCMessage, int64) { - return newRequest("session/new", nil) +func newSessionNewRequest(workDir string, mcpServers []runtime.MCPServerConfig) (JSONRPCMessage, int64) { + return newRequest("session/new", SessionNewParams{ + Cwd: workDir, + McpServers: sessionNewMCPServers(mcpServers), + }) +} + +func sessionNewMCPServers(servers []runtime.MCPServerConfig) []SessionNewMCPServer { + if len(servers) == 0 { + return []SessionNewMCPServer{} + } + normalized := runtime.NormalizeMCPServerConfigs(servers) + out := make([]SessionNewMCPServer, 0, len(normalized)) + for _, server := range normalized { + out = append(out, SessionNewMCPServer{ + Name: server.Name, + Transport: server.Transport, + Command: server.Command, + Args: append([]string(nil), server.Args...), + Env: sortedMCPKeyValues(server.Env), + URL: server.URL, + Headers: sortedMCPKeyValues(server.Headers), + }) + } + return out +} + +func sortedMCPKeyValues(values map[string]string) []runtime.MCPKeyValue { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]runtime.MCPKeyValue, 0, len(keys)) + for _, key := range keys { + out = append(out, runtime.MCPKeyValue{Name: key, Value: values[key]}) + } + return out +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func nonNilMCPKeyValues(values []runtime.MCPKeyValue) []runtime.MCPKeyValue { + if values == nil { + return []runtime.MCPKeyValue{} + } + return values } // newSessionPromptRequest creates a "session/prompt" request from @@ -157,12 +267,7 @@ func newSessionPromptRequest(sessionID string, content []runtime.ContentBlock) ( } return newRequest("session/prompt", SessionPromptParams{ SessionID: sessionID, - Messages: []PromptMessage{ - { - Role: "user", - Content: blocks, - }, - }, + Prompt: blocks, }) } diff --git a/internal/runtime/acp/protocol_test.go b/internal/runtime/acp/protocol_test.go index ebf09c7eb..1fc03f5df 100644 --- a/internal/runtime/acp/protocol_test.go +++ b/internal/runtime/acp/protocol_test.go @@ -146,14 +146,14 @@ func TestSessionPromptRequest_Structure(t *testing.T) { if params.SessionID != "sess-1" { t.Errorf("sessionId = %q, want %q", params.SessionID, "sess-1") } - if len(params.Messages) != 1 { - t.Fatalf("messages len = %d, want 1", len(params.Messages)) + if len(params.Prompt) != 1 { + t.Fatalf("prompt len = %d, want 1", len(params.Prompt)) } - if params.Messages[0].Role != "user" { - t.Errorf("role = %q, want %q", params.Messages[0].Role, "user") + if params.Prompt[0].Type != "text" { + t.Errorf("type = %q, want %q", params.Prompt[0].Type, "text") } - if len(params.Messages[0].Content) != 1 || params.Messages[0].Content[0].Text != "hello world" { - t.Errorf("content text = %q, want %q", params.Messages[0].Content[0].Text, "hello world") + if params.Prompt[0].Text != "hello world" { + t.Errorf("text = %q, want %q", params.Prompt[0].Text, "hello world") } } @@ -170,14 +170,14 @@ func TestSessionPromptRequest_MultiBlock(t *testing.T) { var params SessionPromptParams _ = json.Unmarshal(decoded.Params, ¶ms) - if len(params.Messages[0].Content) != 2 { - t.Fatalf("content blocks = %d, want 2", len(params.Messages[0].Content)) + if len(params.Prompt) != 2 { + t.Fatalf("prompt blocks = %d, want 2", len(params.Prompt)) } - if params.Messages[0].Content[0].Text != "first" { - t.Errorf("block[0] = %q, want %q", params.Messages[0].Content[0].Text, "first") + if params.Prompt[0].Text != "first" { + t.Errorf("block[0] = %q, want %q", params.Prompt[0].Text, "first") } - if params.Messages[0].Content[1].Text != "second" { - t.Errorf("block[1] = %q, want %q", params.Messages[0].Content[1].Text, "second") + if params.Prompt[1].Text != "second" { + t.Errorf("block[1] = %q, want %q", params.Prompt[1].Text, "second") } } @@ -199,10 +199,10 @@ func TestSessionPromptRequest_FilePath(t *testing.T) { var params SessionPromptParams _ = json.Unmarshal(decoded.Params, ¶ms) - if len(params.Messages[0].Content) != 1 { - t.Fatalf("content blocks = %d, want 1", len(params.Messages[0].Content)) + if len(params.Prompt) != 1 { + t.Fatalf("prompt blocks = %d, want 1", len(params.Prompt)) } - block := params.Messages[0].Content[0] + block := params.Prompt[0] if block.Type != "text" { t.Errorf("type = %q, want %q", block.Type, "text") } @@ -226,7 +226,7 @@ func TestSessionPromptRequest_FilePathError(t *testing.T) { var params SessionPromptParams _ = json.Unmarshal(decoded.Params, ¶ms) - block := params.Messages[0].Content[0] + block := params.Prompt[0] if !strings.Contains(block.Text, "Error reading") { t.Errorf("block should contain error, got %q", block.Text) } @@ -243,3 +243,189 @@ func TestNewRequest_IncrementingIDs(t *testing.T) { t.Errorf("IDs should be incrementing: %d, %d", id1, id2) } } + +func TestInitializeRequest_IncludesProtocolVersion(t *testing.T) { + msg, _ := newInitializeRequest() + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + // Verify raw JSON contains protocolVersion (not omitted via omitempty). + if !strings.Contains(string(data), `"protocolVersion":1`) { + t.Errorf("raw JSON should contain \"protocolVersion\":1, got %s", data) + } + + var decoded JSONRPCMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + var params InitializeParams + if err := json.Unmarshal(decoded.Params, ¶ms); err != nil { + t.Fatalf("Unmarshal params: %v", err) + } + if params.ProtocolVersion != 1 { + t.Errorf("protocolVersion = %d, want 1", params.ProtocolVersion) + } +} + +func TestSessionNewRequest_IncludesCwdAndMcpServers(t *testing.T) { + msg, _ := newSessionNewRequest("/home/user/project", nil) + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var decoded JSONRPCMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if decoded.Method != "session/new" { + t.Errorf("method = %q, want %q", decoded.Method, "session/new") + } + + var params SessionNewParams + if err := json.Unmarshal(decoded.Params, ¶ms); err != nil { + t.Fatalf("Unmarshal params: %v", err) + } + if params.Cwd != "/home/user/project" { + t.Errorf("cwd = %q, want %q", params.Cwd, "/home/user/project") + } + if params.McpServers == nil { + t.Fatal("mcpServers should be non-nil empty array") + } + if len(params.McpServers) != 0 { + t.Errorf("mcpServers len = %d, want 0", len(params.McpServers)) + } + // Verify raw JSON has [] not null for mcpServers. + if !strings.Contains(string(data), `"mcpServers":[]`) { + t.Errorf("raw JSON should contain \"mcpServers\":[], got %s", data) + } +} + +func TestSessionNewRequest_SerializesMCPServersByTransport(t *testing.T) { + msg, _ := newSessionNewRequest("/home/user/project", []runtime.MCPServerConfig{ + { + Name: "filesystem", + Transport: runtime.MCPTransportStdio, + Command: "/bin/mcp-fs", + Args: []string{"--stdio"}, + Env: map[string]string{ + "HOME": "/tmp/home", + "TOKEN": "secret", + }, + }, + { + Name: "remote", + Transport: runtime.MCPTransportHTTP, + URL: "https://mcp.example.test", + Headers: map[string]string{ + "Authorization": "Bearer token", + }, + }, + { + Name: "stream", + Transport: runtime.MCPTransportSSE, + URL: "https://mcp.example.test/sse", + Headers: map[string]string{ + "X-Test": "1", + }, + }, + }) + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var decoded JSONRPCMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + var params struct { + Cwd string `json:"cwd"` + McpServers []json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(decoded.Params, ¶ms); err != nil { + t.Fatalf("Unmarshal params: %v", err) + } + if len(params.McpServers) != 3 { + t.Fatalf("mcpServers len = %d, want 3", len(params.McpServers)) + } + + var stdio struct { + Type string `json:"type"` + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env []runtime.MCPKeyValue `json:"env"` + } + if err := json.Unmarshal(params.McpServers[0], &stdio); err != nil { + t.Fatalf("Unmarshal stdio server: %v", err) + } + if stdio.Type != "" { + t.Fatalf("stdio type = %q, want omitted", stdio.Type) + } + if stdio.Command != "/bin/mcp-fs" { + t.Fatalf("stdio command = %q, want /bin/mcp-fs", stdio.Command) + } + if len(stdio.Env) != 2 || stdio.Env[0].Name != "HOME" || stdio.Env[1].Name != "TOKEN" { + t.Fatalf("stdio env = %#v, want sorted HOME/TOKEN", stdio.Env) + } + + var http struct { + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + Headers []runtime.MCPKeyValue `json:"headers"` + } + if err := json.Unmarshal(params.McpServers[1], &http); err != nil { + t.Fatalf("Unmarshal http server: %v", err) + } + if http.Type != "http" { + t.Fatalf("http type = %q, want http", http.Type) + } + if http.URL != "https://mcp.example.test" { + t.Fatalf("http url = %q, want https://mcp.example.test", http.URL) + } + if len(http.Headers) != 1 || http.Headers[0].Name != "Authorization" { + t.Fatalf("http headers = %#v, want Authorization", http.Headers) + } + + var sse struct { + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + Headers []runtime.MCPKeyValue `json:"headers"` + } + if err := json.Unmarshal(params.McpServers[2], &sse); err != nil { + t.Fatalf("Unmarshal sse server: %v", err) + } + if sse.Type != "sse" { + t.Fatalf("sse type = %q, want sse", sse.Type) + } + if sse.URL != "https://mcp.example.test/sse" { + t.Fatalf("sse url = %q, want https://mcp.example.test/sse", sse.URL) + } + if len(sse.Headers) != 1 || sse.Headers[0].Name != "X-Test" { + t.Fatalf("sse headers = %#v, want X-Test", sse.Headers) + } +} + +func TestSessionPromptRequest_UsesPromptFieldNotMessages(t *testing.T) { + msg, _ := newSessionPromptRequest("sess-1", runtime.TextContent("test")) + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + raw := string(data) + if !strings.Contains(raw, `"prompt":[`) { + t.Errorf("raw JSON should contain \"prompt\":[ field, got %s", raw) + } + if strings.Contains(raw, `"messages"`) { + t.Errorf("raw JSON should NOT contain \"messages\" field, got %s", raw) + } +} diff --git a/internal/runtime/auto/auto.go b/internal/runtime/auto/auto.go index 85456fe52..fb3bffbd2 100644 --- a/internal/runtime/auto/auto.go +++ b/internal/runtime/auto/auto.go @@ -29,6 +29,7 @@ var ( _ runtime.InteractionProvider = (*Provider)(nil) _ runtime.InterruptBoundaryWaitProvider = (*Provider)(nil) _ runtime.InterruptedTurnResetProvider = (*Provider)(nil) + _ runtime.TransportCapabilityProvider = (*Provider)(nil) ) // New creates a composite provider. defaultSP handles sessions not @@ -67,6 +68,18 @@ func (p *Provider) route(name string) runtime.Provider { return p.defaultSP } +// SupportsTransport reports whether this provider can route the requested +// session transport. +func (p *Provider) SupportsTransport(transport string) bool { + if transport != "acp" { + return true + } + if provider, ok := p.acpSP.(runtime.TransportCapabilityProvider); ok { + return provider.SupportsTransport(transport) + } + return false +} + // DetectTransport reports the backend currently hosting the named session. // It returns "acp" for ACP-backed sessions and "" for default or unknown. func (p *Provider) DetectTransport(name string) string { diff --git a/internal/runtime/dialog.go b/internal/runtime/dialog.go index 39a143151..805ac434d 100644 --- a/internal/runtime/dialog.go +++ b/internal/runtime/dialog.go @@ -30,9 +30,10 @@ func StartupDialogTimeout() time.Duration { // AcceptStartupDialogs dismisses startup dialogs that can block automated // sessions. Handles (in order): -// 1. Workspace trust dialog (Claude "Quick safety check", Codex "Do you trust the contents of this directory?") -// 2. Bypass permissions warning ("Bypass Permissions mode") — requires Down+Enter -// 3. Claude custom API key confirmation — requires Up+Enter to select "Yes" +// 1. Codex update dialog ("Update available") — requires Down+Enter to skip +// 2. Workspace trust dialog (Claude "Quick safety check", Codex "Do you trust the contents of this directory?") +// 3. Bypass permissions warning ("Bypass Permissions mode") — requires Down+Enter +// 4. Claude custom API key confirmation — requires Up+Enter to select "Yes" // // The peek function should return the last N lines of the session's terminal output. // The sendKeys function should send bare tmux-style keystrokes (e.g., "Enter", "Down"). @@ -75,7 +76,18 @@ func AcceptStartupDialogsFromStreamWithStatus( return sendKeys(keys...) } - phaseObserved, err := acceptWorkspaceTrustDialogFromStream(ctx, timeout, stream, trackingSendKeys) + phaseObserved, err := acceptCodexUpdateDialogFromStream(ctx, timeout, stream, trackingSendKeys) + if err != nil { + return observed, fmt.Errorf("codex update dialog: %w", err) + } + observed = observed || phaseObserved + if !phaseObserved && !observed { + return false, nil + } + if err := ctx.Err(); err != nil { + return observed, err + } + phaseObserved, err = acceptWorkspaceTrustDialogFromStream(ctx, timeout, stream, trackingSendKeys) if err != nil { return observed, fmt.Errorf("workspace trust dialog: %w", err) } @@ -136,6 +148,12 @@ func AcceptStartupDialogsWithTimeout( peek func(lines int) (string, error), sendKeys func(keys ...string) error, ) error { + if err := acceptCodexUpdateDialog(ctx, timeout, peek, sendKeys); err != nil { + return fmt.Errorf("codex update dialog: %w", err) + } + if err := ctx.Err(); err != nil { + return err + } if err := acceptWorkspaceTrustDialog(ctx, timeout, peek, sendKeys); err != nil { return fmt.Errorf("workspace trust dialog: %w", err) } @@ -160,6 +178,74 @@ func AcceptStartupDialogsWithTimeout( return nil } +// acceptCodexUpdateDialog skips Codex's interactive update prompt. The default +// selection is "Update now", so automated sessions must move down to "Skip". +func acceptCodexUpdateDialog( + ctx context.Context, + timeout time.Duration, + peek func(lines int) (string, error), + sendKeys func(keys ...string) error, +) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := ctx.Err(); err != nil { + return err + } + + content, err := peek(startupDialogPeekLines) + if err != nil { + return err + } + + if containsCodexUpdateDialog(content) { + if err := sendKeys("Down"); err != nil { + return err + } + sleep(ctx, bypassDialogConfirmDelay) + return sendKeys("Enter") + } + + if containsPromptIndicator(content) || + containsWorkspaceTrustDialog(content) || + strings.Contains(content, "Bypass Permissions mode") || + containsCustomAPIKeyDialog(content) || + containsRateLimitDialog(content) { + return nil + } + + sleep(ctx, dialogPollInterval) + } + return nil +} + +func containsCodexUpdateDialog(content string) bool { + return strings.Contains(content, "Update available!") && + strings.Contains(content, "Skip until next version") && + strings.Contains(content, "Press enter to continue") +} + +func acceptCodexUpdateDialogFromStream( + ctx context.Context, + timeout time.Duration, + snapshots *replayableSnapshotCursor, + sendKeys func(keys ...string) error, +) (bool, error) { + return acceptDialogFromStream(ctx, timeout, snapshots, sendKeys, streamDialogSpec{ + match: containsCodexUpdateDialog, + matchKeys: []string{"Down", "Enter"}, + matchDelay: bypassDialogConfirmDelay, + ready: containsPromptIndicator, + readyOrNext: containsPostUpdateStartupDialog, + }) +} + +func containsPostUpdateStartupDialog(content string) bool { + return containsWorkspaceTrustDialog(content) || + strings.Contains(content, "Bypass Permissions mode") || + containsCustomAPIKeyDialog(content) || + containsRateLimitDialog(content) +} + // acceptWorkspaceTrustDialog dismisses workspace trust dialogs for supported // agents. Claude shows "Quick safety check"; Codex shows // "Do you trust the contents of this directory?". In both cases the safe @@ -638,9 +724,10 @@ func containsRateLimitDialog(content string) bool { strings.Contains(content, "Rate limit") } -// containsPromptIndicator checks whether any line in the content ends with -// a common shell or REPL prompt suffix, indicating the session is ready -// and no dialog is present. +// containsPromptIndicator checks whether any line in the content looks like a +// common shell or agent prompt, indicating the session is ready and no dialog is +// present. Full-screen agent UIs often render placeholder input after the prompt +// glyph, so Claude/Codex prompts are accepted as prefixes too. func containsPromptIndicator(content string) bool { for _, line := range strings.Split(content, "\n") { trimmed := strings.ReplaceAll(line, "\u00a0", " ") @@ -648,7 +735,13 @@ func containsPromptIndicator(content string) bool { if trimmed == "" { continue } - for _, suffix := range []string{">", "$", "%", "#", "\u276f"} { + for _, prefix := range []string{"\u276f", "\u203a"} { + rest, ok := strings.CutPrefix(trimmed, prefix+" ") + if trimmed == prefix || (ok && !isNumberedMenuRow(rest)) { + return true + } + } + for _, suffix := range []string{">", "$", "%", "#", "\u276f", "\u203a"} { if strings.HasSuffix(trimmed, suffix) { return true } @@ -657,6 +750,14 @@ func containsPromptIndicator(content string) bool { return false } +func isNumberedMenuRow(content string) bool { + digits := 0 + for digits < len(content) && content[digits] >= '0' && content[digits] <= '9' { + digits++ + } + return digits > 0 && digits < len(content) && content[digits] == '.' +} + // sleep waits for the given duration or until ctx is canceled. func sleep(ctx context.Context, d time.Duration) { if d <= 0 { diff --git a/internal/runtime/dialog_test.go b/internal/runtime/dialog_test.go index 03ac1957e..3ffe87228 100644 --- a/internal/runtime/dialog_test.go +++ b/internal/runtime/dialog_test.go @@ -79,12 +79,10 @@ func TestAcceptStartupDialogsAcceptsCodexTrustDialog(t *testing.T) { dialogPollTimeout = time.Second var sent []string - peekCall := 0 err := AcceptStartupDialogs( context.Background(), func(_ int) (string, error) { - peekCall++ - if peekCall == 1 { + if len(sent) == 0 { return "Do you trust the contents of this directory?", nil } return "user@host $", nil @@ -107,12 +105,10 @@ func TestAcceptStartupDialogsAcceptsGeminiTrustDialog(t *testing.T) { dialogPollTimeout = time.Second var sent []string - peekCall := 0 err := AcceptStartupDialogs( context.Background(), func(_ int) (string, error) { - peekCall++ - if peekCall == 1 { + if len(sent) == 0 { return "Do you trust the files in this folder?\n● 1. Trust folder (city)\n 2. Trust parent folder\n 3. Don't trust", nil } return "Type your message or @path/to/file", nil @@ -156,6 +152,97 @@ func TestAcceptStartupDialogsPeeksDeepEnoughForLateTrustDialog(t *testing.T) { } } +func TestAcceptStartupDialogsSkipsCodexUpdateDialog(t *testing.T) { + withZeroDialogTimings(t) + dialogPollTimeout = time.Second + + var sent []string + err := AcceptStartupDialogs( + context.Background(), + func(lines int) (string, error) { + if lines < 100 { + return "loading...", nil + } + return "✨ Update available! 0.124.0 -> 0.125.0\n" + + "› 1. Update now (runs `bun install -g @openai/codex`)\n" + + " 2. Skip\n" + + " 3. Skip until next version\n" + + "Press enter to continue", nil + }, + func(keys ...string) error { + sent = append(sent, keys...) + return nil + }, + ) + if err != nil { + t.Fatalf("AcceptStartupDialogs returned error: %v", err) + } + if got, want := strings.Join(sent, ","), "Down,Enter"; got != want { + t.Fatalf("sent keys = %q, want %q", got, want) + } +} + +func TestAcceptStartupDialogsSkipsUpdateThenHandlesTrustDialog(t *testing.T) { + withZeroDialogTimings(t) + dialogPollTimeout = time.Second + + var sent []string + staleUpdateReturned := false + err := AcceptStartupDialogs( + context.Background(), + func(lines int) (string, error) { + if lines < 100 { + return "loading...", nil + } + switch { + case len(sent) < 2: + return codexUpdateDialogFixture(), nil + case !staleUpdateReturned: + staleUpdateReturned = true + return codexUpdateDialogFixture(), nil + case len(sent) == 2: + return "Do you trust the contents of this directory?", nil + default: + return "› Implement {feature}", nil + } + }, + func(keys ...string) error { + sent = append(sent, keys...) + return nil + }, + ) + if err != nil { + t.Fatalf("AcceptStartupDialogs returned error: %v", err) + } + if got, want := strings.Join(sent, ","), "Down,Enter,Enter"; got != want { + t.Fatalf("sent keys = %q, want %q", got, want) + } +} + +func TestAcceptStartupDialogsFromStreamSkipsCodexUpdateDialog(t *testing.T) { + var sent []string + snapshots := make(chan string, 2) + snapshots <- codexUpdateDialogFixture() + snapshots <- "› Implement {feature}" + close(snapshots) + + err := AcceptStartupDialogsFromStream( + context.Background(), + time.Second, + snapshots, + func(keys ...string) error { + sent = append(sent, keys...) + return nil + }, + ) + if err != nil { + t.Fatalf("AcceptStartupDialogsFromStream() error = %v", err) + } + if got, want := strings.Join(sent, ","), "Down,Enter"; got != want { + t.Fatalf("sent keys = %q, want %q", got, want) + } +} + func TestAcceptStartupDialogsAcceptsBypassPermissionsWarning(t *testing.T) { withZeroDialogTimings(t) dialogPollTimeout = time.Second @@ -485,6 +572,11 @@ func TestContainsPromptIndicator(t *testing.T) { {name: "angle prompt", content: "claude >", want: true}, {name: "powerline prompt", content: "dir \u276f", want: true}, {name: "claude nbsp prompt", content: "❯\u00a0", want: true}, + {name: "codex prompt", content: "›", want: true}, + {name: "codex prompt with nbsp", content: "›\u00a0", want: true}, + {name: "codex prompt with placeholder", content: "› Improve documentation in @filename", want: true}, + {name: "claude prompt with text", content: "❯ run tests", want: true}, + {name: "codex numbered menu row", content: "› 1. Update now (runs `bun install -g @openai/codex`)", want: false}, {name: "empty content", content: "", want: false}, {name: "no prompt", content: "loading...", want: false}, {name: "blank lines only", content: "\n\n", want: false}, @@ -501,6 +593,14 @@ func TestContainsPromptIndicator(t *testing.T) { } } +func codexUpdateDialogFixture() string { + return "✨ Update available! 0.124.0 -> 0.125.0\n" + + "› 1. Update now (runs `bun install -g @openai/codex`)\n" + + " 2. Skip\n" + + " 3. Skip until next version\n" + + "Press enter to continue" +} + func TestExitsEarlyOnPrompt(t *testing.T) { withZeroDialogTimings(t) dialogPollTimeout = time.Second diff --git a/internal/runtime/fingerprint.go b/internal/runtime/fingerprint.go index 9b8030d65..370486273 100644 --- a/internal/runtime/fingerprint.go +++ b/internal/runtime/fingerprint.go @@ -123,6 +123,7 @@ func hashCoreFields(h hash.Hash, cfg Config) { h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors hashSortedMapIncluded(h, cfg.Env, envFingerprintInclude) + hashMCPServers(h, cfg.MCPServers) // FingerprintExtra carries additional identity fields (pool config, etc.) // that aren't part of the session command but should @@ -220,6 +221,28 @@ func hashSortedMap(h hash.Hash, m map[string]string) { } } +func hashMCPServers(h hash.Hash, servers []MCPServerConfig) { + for _, server := range NormalizeMCPServerConfigs(servers) { + h.Write([]byte(server.Name)) //nolint:errcheck // hash.Write never errors + h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors + h.Write([]byte(server.Transport)) //nolint:errcheck // hash.Write never errors + h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors + h.Write([]byte(server.Command)) //nolint:errcheck // hash.Write never errors + h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors + for _, arg := range server.Args { + h.Write([]byte(arg)) //nolint:errcheck // hash.Write never errors + h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors + } + h.Write([]byte{1}) //nolint:errcheck // sentinel between args/env + hashSortedMap(h, server.Env) + h.Write([]byte{1}) //nolint:errcheck // sentinel between env/url + h.Write([]byte(server.URL)) //nolint:errcheck // hash.Write never errors + h.Write([]byte{0}) //nolint:errcheck // hash.Write never errors + hashSortedMap(h, server.Headers) + h.Write([]byte{2}) //nolint:errcheck // sentinel between servers + } +} + // CoreFingerprintBreakdown returns per-field hash components of the core // fingerprint. Used to diagnose config-drift by comparing breakdowns // from session start vs reconcile time. @@ -236,6 +259,9 @@ func CoreFingerprintBreakdown(cfg Config) map[string]string { "Env": fieldHash(func(h hash.Hash) { hashSortedMapIncluded(h, cfg.Env, envFingerprintInclude) }), + "MCPServers": fieldHash(func(h hash.Hash) { + hashMCPServers(h, cfg.MCPServers) + }), "FPExtra": fieldHash(func(h hash.Hash) { if len(cfg.FingerprintExtra) > 0 { h.Write([]byte("fp")) @@ -314,6 +340,8 @@ func LogCoreFingerprintDrift(w io.Writer, name string, storedBreakdown map[strin fmt.Fprintf(w, " Command: %q\n", current.Command) //nolint:errcheck // best-effort diag case "Env": fmt.Fprintf(w, " Env: %v\n", filteredEnv(current.Env)) //nolint:errcheck // best-effort diag + case "MCPServers": + fmt.Fprintf(w, " MCPServers: %+v\n", NormalizeMCPServerConfigs(current.MCPServers)) //nolint:errcheck // best-effort diag case "FPExtra": fmt.Fprintf(w, " FPExtra: %v (len=%d)\n", current.FingerprintExtra, len(current.FingerprintExtra)) //nolint:errcheck // best-effort diag case "PreStart": diff --git a/internal/runtime/fingerprint_test.go b/internal/runtime/fingerprint_test.go index 088e5d70b..59e266a19 100644 --- a/internal/runtime/fingerprint_test.go +++ b/internal/runtime/fingerprint_test.go @@ -201,6 +201,42 @@ func TestConfigFingerprintExtraDifferentValues(t *testing.T) { } } +func TestConfigFingerprintIncludesMCPServers(t *testing.T) { + a := Config{Command: "claude"} + b := Config{ + Command: "claude", + MCPServers: []MCPServerConfig{{ + Name: "filesystem", + Transport: MCPTransportStdio, + Command: "/bin/mcp", + Args: []string{"--stdio"}, + }}, + } + if ConfigFingerprint(a) == ConfigFingerprint(b) { + t.Error("MCPServers should change the config fingerprint") + } +} + +func TestConfigFingerprintMCPServersOrderIndependent(t *testing.T) { + a := Config{ + Command: "claude", + MCPServers: []MCPServerConfig{ + {Name: "remote", Transport: MCPTransportHTTP, URL: "https://mcp.example", Headers: map[string]string{"Authorization": "token"}}, + {Name: "filesystem", Transport: MCPTransportStdio, Command: "/bin/mcp", Args: []string{"--stdio"}, Env: map[string]string{"TOKEN": "abc"}}, + }, + } + b := Config{ + Command: "claude", + MCPServers: []MCPServerConfig{ + {Name: "filesystem", Transport: MCPTransportStdio, Command: "/bin/mcp", Args: []string{"--stdio"}, Env: map[string]string{"TOKEN": "abc"}}, + {Name: "remote", Transport: MCPTransportHTTP, URL: "https://mcp.example", Headers: map[string]string{"Authorization": "token"}}, + }, + } + if ConfigFingerprint(a) != ConfigFingerprint(b) { + t.Error("MCPServers order should not affect hash") + } +} + func TestConfigFingerprintNilVsEmptyExtra(t *testing.T) { a := Config{Command: "claude", FingerprintExtra: nil} b := Config{Command: "claude", FingerprintExtra: map[string]string{}} diff --git a/internal/runtime/k8s/beads_script_test.go b/internal/runtime/k8s/beads_script_test.go index 172fb6f25..5bf96907f 100644 --- a/internal/runtime/k8s/beads_script_test.go +++ b/internal/runtime/k8s/beads_script_test.go @@ -57,6 +57,56 @@ func TestBeadsScriptInitUsesScopeRootAndCanonicalDoltTarget(t *testing.T) { assertCallNotContains(t, result.callLog, "3308") } +// TestBeadsScriptInitSetsBEADSDIR verifies the contrib gc-beads-k8s script +// exports BEADS_DIR inside the pod before running bd init. Without it, bd +// init creates a .git/ as a side effect in the workspace. Regression for +// #399. +func TestBeadsScriptInitSetsBEADSDIR(t *testing.T) { + result := runBeadsScript(t, beadsScriptOptions{ + Op: "init", + Args: []string{"/city/frontend", "fe"}, + Env: map[string]string{ + "GC_CITY_PATH": "/city", + "GC_STORE_ROOT": "/city/frontend", + "GC_BEADS_PREFIX": "fe", + "GC_DOLT_HOST": "canonical-dolt.example.com", + "GC_DOLT_PORT": "4406", + }, + }) + if result.err != nil { + t.Fatalf("gc-beads-k8s init error = %v\noutput:\n%s", result.err, result.output) + } + assertCallContains(t, result.callLog, `export BEADS_DIR="$workdir/.beads"`) + assertCallContains(t, result.callLog, "init --server") +} + +func TestBeadsScriptInitDoesNotPreseedIssuePrefixBeforeBdInit(t *testing.T) { + result := runBeadsScript(t, beadsScriptOptions{ + Op: "init", + Args: []string{"/city/frontend", "fe"}, + Env: map[string]string{ + "GC_CITY_PATH": "/city", + "GC_STORE_ROOT": "/city/frontend", + "GC_BEADS_PREFIX": "fe", + "GC_DOLT_HOST": "canonical-dolt.example.com", + "GC_DOLT_PORT": "4406", + }, + }) + if result.err != nil { + t.Fatalf("gc-beads-k8s init error = %v\noutput:\n%s", result.err, result.output) + } + lines := strings.Split(strings.TrimSpace(result.callLog), "\n") + if len(lines) == 0 { + t.Fatal("call log was empty") + } + if !strings.Contains(lines[0], "init --server") { + t.Fatalf("first init call = %q, want init --server", lines[0]) + } + if strings.Contains(lines[0], "config set issue_prefix") { + t.Fatalf("first init call should not preseed issue_prefix before bd init:\n%s", lines[0]) + } +} + func TestBeadsScriptInitRejectsPartialCanonicalDoltTarget(t *testing.T) { clearDoltAndCityEnv(t) result := runBeadsScript(t, beadsScriptOptions{ @@ -108,6 +158,24 @@ func TestBeadsScriptListUsesScopedWorkdir(t *testing.T) { } assertCallContains(t, result.callLog, "/workspace/frontend") assertCallContains(t, result.callLog, "list --json --limit 0 --all") + assertCallContains(t, result.callLog, `export BEADS_DIR="$workdir/.beads"`) +} + +func TestBeadsScriptConfigSetKeepsBEADSDIRScoped(t *testing.T) { + result := runBeadsScript(t, beadsScriptOptions{ + Op: "config-set", + Args: []string{"issue_prefix", "fe"}, + Env: map[string]string{ + "GC_CITY_PATH": "/city", + "GC_STORE_ROOT": "/city/frontend", + }, + }) + if result.err != nil { + t.Fatalf("gc-beads-k8s config-set error = %v\noutput:\n%s", result.err, result.output) + } + assertCallContains(t, result.callLog, "/workspace/frontend") + assertCallContains(t, result.callLog, "config set issue_prefix fe") + assertCallContains(t, result.callLog, `export BEADS_DIR="$workdir/.beads"`) } type beadsScriptOptions struct { diff --git a/internal/runtime/k8s/provider.go b/internal/runtime/k8s/provider.go index 40e5e14fb..0fb46ef1d 100644 --- a/internal/runtime/k8s/provider.go +++ b/internal/runtime/k8s/provider.go @@ -747,7 +747,7 @@ func initBeadsInPod(ctx context.Context, ops k8sOps, podName string, cfg runtime `else PREFIX=$(echo '%s' | base64 -d) && `+ `DOLT_HOST=$(echo '%s' | base64 -d) && `+ `DOLT_PORT=$(echo '%s' | base64 -d) && `+ - `yes | bd init --server --server-host "$DOLT_HOST" --server-port "$DOLT_PORT" -p "$PREFIX" --skip-hooks --skip-agents; fi`, + `yes | BEADS_DIR="$WD/.beads" bd init --server --server-host "$DOLT_HOST" --server-port "$DOLT_PORT" -p "$PREFIX" --skip-hooks --skip-agents; fi`, storeRootB64, patchB64, prefixB64, base64.StdEncoding.EncodeToString([]byte(doltHost)), base64.StdEncoding.EncodeToString([]byte(doltPort)), diff --git a/internal/runtime/k8s/provider_test.go b/internal/runtime/k8s/provider_test.go index f12a8a58f..f30169f6d 100644 --- a/internal/runtime/k8s/provider_test.go +++ b/internal/runtime/k8s/provider_test.go @@ -1386,6 +1386,37 @@ func TestStartWarnsWhenInitBeadsInPodFails(t *testing.T) { } } +// TestInitBeadsInPodBdInitSetsBEADSDIR verifies that the pod bootstrap bd init +// sets BEADS_DIR so bd does not create a .git/ as a side effect in the pod +// workspace. Regression for #399. +func TestInitBeadsInPodBdInitSetsBEADSDIR(t *testing.T) { + fake := newFakeK8sOps() + cfg := runtime.Config{ + Env: map[string]string{ + "GC_DOLT_HOST": podManagedDoltHost, + "GC_DOLT_PORT": podManagedDoltPort, + "GC_BEADS_PREFIX": "demo", + }, + } + if err := initBeadsInPod(context.Background(), fake, "gc-test-pod", cfg, "/workspace/demo-repo", podManagedDoltHost, podManagedDoltPort); err != nil { + t.Fatalf("initBeadsInPod: %v", err) + } + var script string + for _, c := range fake.calls { + if c.method == "execInPod" && len(c.cmd) >= 3 && c.cmd[0] == "sh" && c.cmd[1] == "-c" { + script = c.cmd[2] + break + } + } + if script == "" { + t.Fatal("no sh -c exec call found") + } + want := `BEADS_DIR="$WD/.beads" bd init --server` + if !strings.Contains(script, want) { + t.Errorf("bd init invocation missing BEADS_DIR env prefix: %q not found in script:\n%s", want, script) + } +} + // TestInitBeadsInPodStripsProjectIDFromMetadata verifies that the metadata // patch removes the controller's project_id so the agent pod's bd does not // fail with PROJECT IDENTITY MISMATCH against the in-cluster Dolt server. diff --git a/internal/runtime/mcp.go b/internal/runtime/mcp.go new file mode 100644 index 000000000..c6db27eb6 --- /dev/null +++ b/internal/runtime/mcp.go @@ -0,0 +1,77 @@ +package runtime + +import "sort" + +// MCPTransport identifies the ACP session/new transport type for an MCP server. +type MCPTransport string + +const ( + // MCPTransportStdio launches the MCP server over stdio. + MCPTransportStdio MCPTransport = "stdio" + // MCPTransportHTTP connects to the MCP server over streamable HTTP. + MCPTransportHTTP MCPTransport = "http" + // MCPTransportSSE connects to the MCP server over SSE. + MCPTransportSSE MCPTransport = "sse" +) + +// MCPKeyValue is a name/value pair used for MCP env vars and HTTP headers. +type MCPKeyValue struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// MCPServerConfig is the runtime-owned ACP session/new representation of one +// MCP server. Providers that do not speak ACP ignore this field. +type MCPServerConfig struct { + Name string + Transport MCPTransport + Command string + Args []string + Env map[string]string + URL string + Headers map[string]string +} + +// NormalizeMCPServerConfigs clones and deterministically sorts MCP server +// definitions so runtime configs are safe to retain and compare. +func NormalizeMCPServerConfigs(in []MCPServerConfig) []MCPServerConfig { + if len(in) == 0 { + return nil + } + out := make([]MCPServerConfig, len(in)) + for i, server := range in { + out[i] = MCPServerConfig{ + Name: server.Name, + Transport: server.Transport, + Command: server.Command, + Args: append([]string(nil), server.Args...), + Env: cloneRuntimeStringMap(server.Env), + URL: server.URL, + Headers: cloneRuntimeStringMap(server.Headers), + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + if out[i].Transport != out[j].Transport { + return out[i].Transport < out[j].Transport + } + if out[i].Command != out[j].Command { + return out[i].Command < out[j].Command + } + return out[i].URL < out[j].URL + }) + return out +} + +func cloneRuntimeStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 23414f209..bd1dc8ca6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -236,6 +236,15 @@ type DialogProvider interface { DismissKnownDialogs(ctx context.Context, name string, timeout time.Duration) error } +// TransportCapabilityProvider is an optional extension for providers that can +// report whether they support starting sessions with a specific transport. +// +// Callers use this to fail fast when a requested transport cannot be routed by +// the active session provider before session creation starts mutating state. +type TransportCapabilityProvider interface { + SupportsTransport(transport string) bool +} + // ImmediateNudgeProvider is an optional extension for runtimes that can inject // input immediately without performing their own wait-idle heuristic first. type ImmediateNudgeProvider interface { @@ -352,6 +361,10 @@ type Config struct { // Env is additional environment variables set in the session. Env map[string]string + // MCPServers is the effective ACP session/new MCP server list for this + // session. Non-ACP providers ignore it. + MCPServers []MCPServerConfig + // Startup reliability hints (all optional — zero values skip). // ReadyPromptPrefix is the prompt prefix for readiness detection (e.g. "> "). diff --git a/internal/runtime/tmux/startup_test.go b/internal/runtime/tmux/startup_test.go index 8add1e286..da2996c25 100644 --- a/internal/runtime/tmux/startup_test.go +++ b/internal/runtime/tmux/startup_test.go @@ -663,6 +663,45 @@ func TestDoStartSession_ProcessNamesAndReadyPrefix(t *testing.T) { }) } +func TestDoStartSession_CursorReadinessHintsTriggerRuntimeWait(t *testing.T) { + ops := &fakeStartOps{ + hasSessionResult: true, + } + + cfg := runtime.Config{ + Command: "cursor-agent", + ProcessNames: []string{"cursor-agent"}, + ReadyPromptPrefix: "\u2192 ", + ReadyDelayMs: 10000, + } + + err := doStartSession(context.Background(), ops, "test", cfg, DefaultConfig().SetupTimeout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertCallSequence(t, ops, []string{ + "createSession", + "setRemainOnExit", + "waitForCommand", + "acceptStartupDialogs", + "waitForReady", + "acceptStartupDialogs", + "hasSession", + }) + + wfr := ops.calls[4] + if wfr.rc.Tmux.ReadyPromptPrefix != "\u2192 " { + t.Errorf("rc.ReadyPromptPrefix = %q, want %q", wfr.rc.Tmux.ReadyPromptPrefix, "\u2192 ") + } + if wfr.rc.Tmux.ReadyDelayMs != 10000 { + t.Errorf("rc.ReadyDelayMs = %d, want %d", wfr.rc.Tmux.ReadyDelayMs, 10000) + } + if len(wfr.rc.Tmux.ProcessNames) != 1 || wfr.rc.Tmux.ProcessNames[0] != "cursor-agent" { + t.Errorf("rc.ProcessNames = %v, want [cursor-agent]", wfr.rc.Tmux.ProcessNames) + } +} + func TestDoStartSession_ProcessNamesAndReadyDelayRechecksDialogs(t *testing.T) { ops := &fakeStartOps{ hasSessionResult: true, diff --git a/internal/session/chat.go b/internal/session/chat.go index 91f8e767b..4884eae01 100644 --- a/internal/session/chat.go +++ b/internal/session/chat.go @@ -295,6 +295,9 @@ func (m *Manager) ensureRunning(ctx context.Context, id string, b beads.Bead, se if b.Metadata["transport"] == "" && (started || transportVerified) { m.persistTransport(id, b.Metadata["provider"], transport) } + if err := m.syncStoredMCPServers(id, &b, cfg.MCPServers); err != nil { + return fmt.Errorf("%w: %w", ErrStateSync, err) + } if err := m.confirmLiveSessionState(id, &b); err != nil { if started && !errors.Is(err, ErrStateSync) { _ = m.sp.Stop(sessName) diff --git a/internal/session/manager.go b/internal/session/manager.go index b2e70d00f..8cb63548b 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -64,7 +64,9 @@ type Info struct { Closed bool Title string Alias string + AgentName string // persisted concrete identity for MCP materialization Provider string + Transport string Command string // resolved command stored at creation WorkDir string SessionName string // tmux session name @@ -119,7 +121,7 @@ type Manager struct { store beads.Store sp runtime.Provider cityPath string - transportResolver func(template string) string + transportResolver func(template, provider string) transportResolution } // PruneResult reports which sessions were pruned and which queued wait nudges @@ -139,6 +141,11 @@ type transportDetector interface { DetectTransport(name string) string } +type transportResolution struct { + transport string + allowStoppedFallback bool +} + func normalizeTransport(provider, transport string) string { if transport != "" { return transport @@ -153,17 +160,39 @@ func transportFromMetadata(b beads.Bead) string { return normalizeTransport(b.Metadata["provider"], b.Metadata["transport"]) } +func (m *Manager) resolveConfiguredTransport(template, provider string) (string, bool) { + if m.transportResolver == nil { + return "", false + } + resolution := m.transportResolver(strings.TrimSpace(template), strings.TrimSpace(provider)) + return normalizeTransport(provider, resolution.transport), resolution.allowStoppedFallback +} + func (m *Manager) transportForBead(b beads.Bead, sessName string) (string, bool) { transport := transportFromMetadata(b) if transport != "" { return transport, false } + if strings.TrimSpace(b.Metadata[MCPIdentityMetadataKey]) != "" || + strings.TrimSpace(b.Metadata[MCPServersSnapshotMetadataKey]) != "" { + return "acp", false + } + if strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true" { + transport, _ = m.resolveConfiguredTransport(b.Metadata["template"], b.Metadata["provider"]) + if transport != "" { + return transport, true + } + return "", false + } if detector, ok := m.sp.(transportDetector); ok { transport = normalizeTransport(b.Metadata["provider"], detector.DetectTransport(sessName)) if transport != "" { return transport, true } } + if m.sp != nil && m.sp.IsRunning(sessName) { + return "", false + } return "", false } @@ -193,9 +222,19 @@ func NewManager(store beads.Store, sp runtime.Provider) *Manager { } // NewManagerWithTransportResolver creates a Manager that can infer session -// transport from template config when older beads do not have transport metadata. -func NewManagerWithTransportResolver(store beads.Store, sp runtime.Provider, resolver func(template string) string) *Manager { - return &Manager{store: store, sp: sp, transportResolver: resolver} +// transport from template or provider config when older beads do not have +// transport metadata. +func NewManagerWithTransportResolver(store beads.Store, sp runtime.Provider, resolver func(template, provider string) string) *Manager { + return &Manager{ + store: store, + sp: sp, + transportResolver: func(template, provider string) transportResolution { + if resolver == nil { + return transportResolution{} + } + return transportResolution{transport: resolver(template, provider)} + }, + } } // NewManagerWithCityPath creates a Manager that can persist deferred submits @@ -205,10 +244,47 @@ func NewManagerWithCityPath(store beads.Store, sp runtime.Provider, cityPath str } // NewManagerWithTransportResolverAndCityPath creates a Manager that can infer -// session transport from template config and persist deferred submits into the -// city's nudge queue. -func NewManagerWithTransportResolverAndCityPath(store beads.Store, sp runtime.Provider, cityPath string, resolver func(template string) string) *Manager { - return &Manager{store: store, sp: sp, cityPath: cityPath, transportResolver: resolver} +// session transport from template or provider config and persist deferred +// submits into the city's nudge queue. +func NewManagerWithTransportResolverAndCityPath(store beads.Store, sp runtime.Provider, cityPath string, resolver func(template, provider string) string) *Manager { + return &Manager{ + store: store, + sp: sp, + cityPath: cityPath, + transportResolver: func(template, provider string) transportResolution { + if resolver == nil { + return transportResolution{} + } + return transportResolution{transport: resolver(template, provider)} + }, + } +} + +// NewManagerWithTransportPolicyResolverAndCityPath creates a Manager that can +// infer transport from config and, when the resolver marks it safe, continue +// using that transport for stopped legacy sessions without persisted +// transport metadata. +func NewManagerWithTransportPolicyResolverAndCityPath( + store beads.Store, + sp runtime.Provider, + cityPath string, + resolver func(template, provider string) (string, bool), +) *Manager { + return &Manager{ + store: store, + sp: sp, + cityPath: cityPath, + transportResolver: func(template, provider string) transportResolution { + if resolver == nil { + return transportResolution{} + } + transport, allowStoppedFallback := resolver(template, provider) + return transportResolution{ + transport: transport, + allowStoppedFallback: allowStoppedFallback, + } + }, + } } // Create creates a new chat session bead and starts the runtime session. @@ -346,6 +422,10 @@ func (m *Manager) createAliasedNamedWithTransport(ctx context.Context, alias, ex if explicitName != "" { b.Metadata["session_name_explicit"] = "true" } + if err := m.syncStoredMCPServers(b.ID, &b, hints.MCPServers); err != nil { + _ = m.store.Close(b.ID) + return err + } unroute := m.routeACPIfNeeded(provider, transport, sessName) rollbackFailedCreate := func() error { @@ -681,6 +761,7 @@ func (m *Manager) Close(id string) error { return err } if b.Status == "closed" { + _ = clearRuntimeMCPServersSnapshot(m.cityPath, id) return nil // idempotent: already closed } // CmdClose is legal from any non-none state; this is effectively a @@ -710,7 +791,11 @@ func (m *Manager) Close(id string) error { return err } - return m.store.Close(id) + if err := m.store.Close(id); err != nil { + return err + } + _ = clearRuntimeMCPServersSnapshot(m.cityPath, id) + return nil }) } @@ -1138,8 +1223,9 @@ func (m *Manager) infoFromBead(b beads.Bead) Info { sessName = sessionNameFor(b.ID) } closed := b.Status == "closed" + transport := transportFromMetadata(b) if !closed { - transport, _ := m.transportForBead(b, sessName) + transport, _ = m.transportForBead(b, sessName) _ = m.routeACPIfNeeded(b.Metadata["provider"], transport, sessName) } @@ -1159,7 +1245,9 @@ func (m *Manager) infoFromBead(b beads.Bead) Info { Closed: closed, Title: b.Title, Alias: b.Metadata["alias"], + AgentName: b.Metadata["agent_name"], Provider: b.Metadata["provider"], + Transport: transport, Command: b.Metadata["command"], WorkDir: b.Metadata["work_dir"], SessionName: sessName, diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 21eb116f1..d88ea525c 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -328,6 +328,28 @@ func TestCreateBeadOnly(t *testing.T) { } } +func TestGetSurfacesAgentNameMetadata(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + mgr := NewManager(store, sp) + + info, err := mgr.CreateBeadOnly("helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + if err != nil { + t.Fatalf("CreateBeadOnly: %v", err) + } + if err := store.SetMetadata(info.ID, "agent_name", "myrig/helper-adhoc-123"); err != nil { + t.Fatalf("SetMetadata(agent_name): %v", err) + } + + got, err := mgr.Get(info.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.AgentName != "myrig/helper-adhoc-123" { + t.Fatalf("AgentName = %q, want %q", got.AgentName, "myrig/helper-adhoc-123") + } +} + func TestCreateNamedWithTransport_UsesExplicitSessionName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() @@ -620,6 +642,35 @@ func TestClose(t *testing.T) { } } +func TestCloseRemovesRuntimeMCPSnapshot(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + cityPath := t.TempDir() + mgr := NewManagerWithCityPath(store, sp, cityPath) + + info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := PersistRuntimeMCPServersSnapshot(cityPath, info.ID, []runtime.MCPServerConfig{{ + Name: "identity", + Transport: runtime.MCPTransportHTTP, + URL: "https://example.invalid/mcp", + }}); err != nil { + t.Fatalf("PersistRuntimeMCPServersSnapshot: %v", err) + } + if _, err := os.Stat(runtimeMCPServersSnapshotPath(cityPath, info.ID)); err != nil { + t.Fatalf("Stat(runtime snapshot): %v", err) + } + + if err := mgr.Close(info.ID); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(runtimeMCPServersSnapshotPath(cityPath, info.ID)); !os.IsNotExist(err) { + t.Fatalf("runtime snapshot still exists after close, stat err = %v", err) + } +} + func TestClose_ConfiguredNamedSessionRetiresIdentifiers(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() @@ -2089,7 +2140,7 @@ func TestSendBackfillsTransportForLegacyACPSession(t *testing.T) { t.Fatalf("Start ACP session: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template string) string { + mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { if template == "helper" { return "acp" } @@ -2147,7 +2198,7 @@ func TestGetDoesNotPersistGuessedTransportForLegacySession(t *testing.T) { t.Fatalf("Create legacy bead: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template string) string { + mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { if template == "helper" { return "acp" } @@ -2166,6 +2217,247 @@ func TestGetDoesNotPersistGuessedTransportForLegacySession(t *testing.T) { } } +func TestGetUsesConfiguredTransportForPendingCreateWithoutRuntimeProbe(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + + deferred, err := store.Create(beads.Bead{ + Title: "deferred acp", + Type: BeadType, + Labels: []string{ + LabelSession, + "template:helper", + }, + Metadata: map[string]string{ + "template": "helper", + "state": string(StateCreating), + "pending_create_claim": "true", + "provider": "claude", + "work_dir": "/tmp", + "command": "claude", + }, + }) + if err != nil { + t.Fatalf("Create deferred bead: %v", err) + } + + mgr := NewManagerWithTransportResolver(store, sp, func(template, _ string) string { + if template == "helper" { + return "acp" + } + return "" + }) + + info, err := mgr.Get(deferred.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got := info.Transport; got != "acp" { + t.Fatalf("Transport = %q, want acp", got) + } + if len(sp.Calls) != 0 { + t.Fatalf("runtime calls = %#v, want none for pending create", sp.Calls) + } +} + +func TestGetPrefersLiveTransportDetectionOverConfiguredTransportInference(t *testing.T) { + store := beads.NewMemStore() + defaultSP := runtime.NewFake() + acpSP := runtime.NewFake() + autoSP := sessionauto.New(defaultSP, acpSP) + + legacy, err := store.Create(beads.Bead{ + Title: "legacy tmux", + Type: BeadType, + Labels: []string{ + LabelSession, + "template:helper", + }, + Metadata: map[string]string{ + "template": "helper", + "state": string(StateActive), + "provider": "claude", + "work_dir": "/tmp", + "command": "claude", + }, + }) + if err != nil { + t.Fatalf("Create legacy bead: %v", err) + } + sessName := sessionNameFor(legacy.ID) + if err := store.SetMetadata(legacy.ID, "session_name", sessName); err != nil { + t.Fatalf("SetMetadata(session_name): %v", err) + } + if err := defaultSP.Start(context.Background(), sessName, runtime.Config{WorkDir: "/tmp"}); err != nil { + t.Fatalf("Start default session: %v", err) + } + + mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + if template == "helper" { + return "acp" + } + return "" + }) + + info, err := mgr.Get(legacy.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got := info.Transport; got != "" { + t.Fatalf("Transport = %q, want empty for live tmux session", got) + } + + updated, err := store.Get(legacy.ID) + if err != nil { + t.Fatalf("Get updated bead: %v", err) + } + if got := updated.Metadata["transport"]; got != "" { + t.Fatalf("transport metadata = %q, want empty for live tmux session", got) + } +} + +func TestGetDoesNotInferConfiguredTransportForStoppedLegacySession(t *testing.T) { + store := beads.NewMemStore() + defaultSP := runtime.NewFake() + acpSP := runtime.NewFake() + autoSP := sessionauto.New(defaultSP, acpSP) + + legacy, err := store.Create(beads.Bead{ + Title: "legacy tmux", + Type: BeadType, + Labels: []string{ + LabelSession, + "template:helper", + }, + Metadata: map[string]string{ + "template": "helper", + "state": string(StateAsleep), + "provider": "claude", + "work_dir": "/tmp", + "command": "claude", + }, + }) + if err != nil { + t.Fatalf("Create legacy bead: %v", err) + } + sessName := sessionNameFor(legacy.ID) + if err := store.SetMetadata(legacy.ID, "session_name", sessName); err != nil { + t.Fatalf("SetMetadata(session_name): %v", err) + } + + mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + if template == "helper" { + return "acp" + } + return "" + }) + + info, err := mgr.Get(legacy.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got := info.Transport; got != "" { + t.Fatalf("Transport = %q, want empty for stopped legacy session without stored transport", got) + } + + updated, err := store.Get(legacy.ID) + if err != nil { + t.Fatalf("Get updated bead: %v", err) + } + if got := updated.Metadata["transport"]; got != "" { + t.Fatalf("transport metadata = %q, want empty for read-only lookup", got) + } +} + +func TestGetDoesNotInferConfiguredTransportForStoppedLegacySessionWithPolicyFallback(t *testing.T) { + store := beads.NewMemStore() + defaultSP := runtime.NewFake() + acpSP := runtime.NewFake() + autoSP := sessionauto.New(defaultSP, acpSP) + + legacy, err := store.Create(beads.Bead{ + Title: "legacy acp", + Type: BeadType, + Labels: []string{ + LabelSession, + "template:helper", + }, + Metadata: map[string]string{ + "template": "helper", + "state": string(StateAsleep), + "provider": "claude", + "work_dir": "/tmp", + "command": "claude", + }, + }) + if err != nil { + t.Fatalf("Create legacy bead: %v", err) + } + sessName := sessionNameFor(legacy.ID) + if err := store.SetMetadata(legacy.ID, "session_name", sessName); err != nil { + t.Fatalf("SetMetadata(session_name): %v", err) + } + + mgr := NewManagerWithTransportPolicyResolverAndCityPath(store, autoSP, "", func(template, _ string) (string, bool) { + if template == "helper" { + return "acp", true + } + return "", false + }) + + info, err := mgr.Get(legacy.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got := info.Transport; got != "" { + t.Fatalf("Transport = %q, want empty for stopped legacy session without stored evidence", got) + } + + updated, err := store.Get(legacy.ID) + if err != nil { + t.Fatalf("Get updated bead: %v", err) + } + if got := updated.Metadata["transport"]; got != "" { + t.Fatalf("transport metadata = %q, want empty for read-only lookup", got) + } +} + +func TestGetInfersACPTransportFromStoredMCPMetadata(t *testing.T) { + store := beads.NewMemStore() + defaultSP := runtime.NewFake() + acpSP := runtime.NewFake() + autoSP := sessionauto.New(defaultSP, acpSP) + + legacy, err := store.Create(beads.Bead{ + Title: "legacy acp", + Type: BeadType, + Labels: []string{ + LabelSession, + "template:helper", + }, + Metadata: map[string]string{ + "template": "helper", + "state": string(StateAsleep), + "provider": "claude", + "work_dir": "/tmp", + "command": "claude", + MCPServersSnapshotMetadataKey: `[{"name":"filesystem","transport":"stdio","command":"/bin/mcp"}]`, + }, + }) + if err != nil { + t.Fatalf("Create legacy bead: %v", err) + } + + mgr := NewManagerWithTransportResolver(store, autoSP, nil) + info, err := mgr.Get(legacy.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got := info.Transport; got != "acp" { + t.Fatalf("Transport = %q, want acp from stored MCP metadata", got) + } +} + func TestSendConvergesWhenSessionAlreadyResumed(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() diff --git a/internal/session/mcp_metadata.go b/internal/session/mcp_metadata.go new file mode 100644 index 000000000..a8b0812d7 --- /dev/null +++ b/internal/session/mcp_metadata.go @@ -0,0 +1,319 @@ +package session + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/gastownhall/gascity/internal/runtime" +) + +const ( + // MCPIdentityMetadataKey stores the stable identity used to materialize + // MCP templates for a session. + MCPIdentityMetadataKey = "mcp_identity" + // MCPServersSnapshotMetadataKey stores the normalized ACP session/new MCP + // server snapshot used to resume sessions when the current catalog cannot + // be materialized. + MCPServersSnapshotMetadataKey = "mcp_servers_snapshot" + + redactedMCPSnapshotValue = "__redacted__" +) + +// EncodeMCPServersSnapshot returns the normalized metadata value for a +// session's persisted ACP session/new MCP server snapshot. +func EncodeMCPServersSnapshot(servers []runtime.MCPServerConfig) (string, error) { + normalized := normalizeMCPServersSnapshotForMetadata(servers) + if len(normalized) == 0 { + return "", nil + } + data, err := json.Marshal(normalized) + if err != nil { + return "", fmt.Errorf("marshal MCP server snapshot: %w", err) + } + return string(data), nil +} + +// DecodeMCPServersSnapshot parses a persisted ACP session/new MCP server +// snapshot from session metadata. +func DecodeMCPServersSnapshot(raw string) ([]runtime.MCPServerConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var servers []runtime.MCPServerConfig + if err := json.Unmarshal([]byte(raw), &servers); err != nil { + return nil, fmt.Errorf("unmarshal MCP server snapshot: %w", err) + } + return runtime.NormalizeMCPServerConfigs(servers), nil +} + +// StoredMCPSnapshotContainsRedactions reports whether a decoded persisted MCP +// snapshot contains redacted secret placeholders. +func StoredMCPSnapshotContainsRedactions(servers []runtime.MCPServerConfig) bool { + for _, server := range servers { + if snapshotMapContainsRedactions(server.Env) || + snapshotMapContainsRedactions(server.Headers) || + snapshotArgsContainRedactions(server.Args) || + strings.Contains(server.URL, redactedMCPSnapshotValue) { + return true + } + } + return false +} + +// SanitizeStoredMCPSnapshotForResume strips redacted secret placeholders from +// a stored MCP snapshot while preserving any non-secret fields that can still +// help degraded resume reconstruct MCP hints. +func SanitizeStoredMCPSnapshotForResume(servers []runtime.MCPServerConfig) []runtime.MCPServerConfig { + if len(servers) == 0 { + return nil + } + normalized := runtime.NormalizeMCPServerConfigs(servers) + for i := range normalized { + normalized[i].Args = sanitizeStoredMCPMetadataArgs(normalized[i].Args) + normalized[i].Env = sanitizeStoredMCPMetadataMap(normalized[i].Env) + normalized[i].URL = sanitizeStoredMCPMetadataURL(normalized[i].URL) + normalized[i].Headers = sanitizeStoredMCPMetadataMap(normalized[i].Headers) + } + return runtime.NormalizeMCPServerConfigs(normalized) +} + +// WithStoredMCPMetadata returns a metadata map augmented with the stable MCP +// identity and normalized ACP session/new snapshot for the session. +func WithStoredMCPMetadata(meta map[string]string, identity string, servers []runtime.MCPServerConfig) (map[string]string, error) { + if meta == nil { + meta = make(map[string]string) + } + identity = strings.TrimSpace(identity) + if identity != "" { + meta[MCPIdentityMetadataKey] = identity + } + snapshot, err := EncodeMCPServersSnapshot(servers) + if err != nil { + return nil, err + } + if snapshot != "" { + meta[MCPServersSnapshotMetadataKey] = snapshot + } else if _, ok := meta[MCPServersSnapshotMetadataKey]; ok { + meta[MCPServersSnapshotMetadataKey] = "" + } + return meta, nil +} + +func normalizeMCPServersSnapshotForMetadata(servers []runtime.MCPServerConfig) []runtime.MCPServerConfig { + normalized := runtime.NormalizeMCPServerConfigs(servers) + for i := range normalized { + normalized[i].Args = redactMCPMetadataArgs(normalized[i].Args) + normalized[i].Env = redactMCPMetadataMap(normalized[i].Env) + normalized[i].URL = redactMCPMetadataURL(normalized[i].URL) + normalized[i].Headers = redactMCPMetadataMap(normalized[i].Headers) + } + return normalized +} + +func redactMCPMetadataArgs(args []string) []string { + if len(args) == 0 { + return nil + } + out := make([]string, 0, len(args)) + redactNext := false + for _, arg := range args { + if redactNext { + out = append(out, redactedMCPSnapshotValue) + redactNext = false + continue + } + if isSensitiveMCPMetadataValue(arg) { + out = append(out, redactedMCPSnapshotValue) + continue + } + if redactedURL := redactMCPMetadataURL(arg); redactedURL != arg { + out = append(out, redactedURL) + continue + } + if key, value, ok := strings.Cut(arg, "="); ok && isSensitiveMCPMetadataToken(key) { + if strings.TrimSpace(value) == "" { + out = append(out, key+"=") + } else { + out = append(out, key+"="+redactedMCPSnapshotValue) + } + continue + } + if isSensitiveMCPMetadataToken(arg) && strings.HasPrefix(strings.TrimSpace(arg), "-") { + out = append(out, arg) + redactNext = true + continue + } + out = append(out, arg) + } + return out +} + +func redactMCPMetadataMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key := range in { + out[key] = redactedMCPSnapshotValue + } + return out +} + +func redactMCPMetadataURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + changed := false + if parsed.User != nil { + if _, hasPassword := parsed.User.Password(); hasPassword { + parsed.User = url.UserPassword(redactedMCPSnapshotValue, redactedMCPSnapshotValue) + } else { + parsed.User = url.User(redactedMCPSnapshotValue) + } + changed = true + } + if query := parsed.Query(); len(query) > 0 { + for key := range query { + query.Set(key, redactedMCPSnapshotValue) + } + parsed.RawQuery = query.Encode() + changed = true + } + if !changed { + return raw + } + return parsed.String() +} + +func snapshotMapContainsRedactions(in map[string]string) bool { + for _, value := range in { + if value == redactedMCPSnapshotValue { + return true + } + } + return false +} + +func snapshotArgsContainRedactions(args []string) bool { + for _, arg := range args { + if strings.Contains(arg, redactedMCPSnapshotValue) { + return true + } + } + return false +} + +func sanitizeStoredMCPMetadataArgs(args []string) []string { + if len(args) == 0 { + return nil + } + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + trimmed := strings.TrimSpace(arg) + if strings.HasPrefix(trimmed, "-") && + isSensitiveMCPMetadataToken(trimmed) && + i+1 < len(args) && + strings.Contains(args[i+1], redactedMCPSnapshotValue) { + i++ + continue + } + if !strings.Contains(arg, redactedMCPSnapshotValue) { + out = append(out, arg) + continue + } + if key, value, ok := strings.Cut(arg, "="); ok && + isSensitiveMCPMetadataToken(key) && + strings.Contains(value, redactedMCPSnapshotValue) { + continue + } + if sanitizedURL := sanitizeStoredMCPMetadataURL(arg); sanitizedURL != "" && sanitizedURL != arg { + out = append(out, sanitizedURL) + } + } + return out +} + +func sanitizeStoredMCPMetadataMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string) + for key, value := range in { + if strings.Contains(value, redactedMCPSnapshotValue) { + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func sanitizeStoredMCPMetadataURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if !strings.Contains(raw, redactedMCPSnapshotValue) { + return raw + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + if parsed.User != nil && strings.Contains(parsed.User.String(), redactedMCPSnapshotValue) { + parsed.User = nil + } + if query := parsed.Query(); len(query) > 0 { + for key, values := range query { + filtered := values[:0] + for _, value := range values { + if !strings.Contains(value, redactedMCPSnapshotValue) { + filtered = append(filtered, value) + } + } + if len(filtered) == 0 { + query.Del(key) + continue + } + query[key] = filtered + } + parsed.RawQuery = query.Encode() + } + if strings.Contains(parsed.String(), redactedMCPSnapshotValue) { + return "" + } + return parsed.String() +} + +func isSensitiveMCPMetadataToken(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.Contains(value, "token") || + strings.Contains(value, "secret") || + strings.Contains(value, "password") || + strings.Contains(value, "passwd") || + strings.Contains(value, "authorization") || + strings.Contains(value, "auth") || + strings.Contains(value, "bearer") || + strings.Contains(value, "cookie") || + strings.Contains(value, "api-key") || + strings.Contains(value, "apikey") +} + +func isSensitiveMCPMetadataValue(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.HasPrefix(value, "authorization:") || + strings.HasPrefix(value, "bearer ") || + strings.HasPrefix(value, "basic ") || + strings.HasPrefix(value, "token ") +} diff --git a/internal/session/mcp_metadata_test.go b/internal/session/mcp_metadata_test.go new file mode 100644 index 000000000..eecac2d0b --- /dev/null +++ b/internal/session/mcp_metadata_test.go @@ -0,0 +1,151 @@ +package session + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/runtime" +) + +func TestEncodeMCPServersSnapshotRedactsSecrets(t *testing.T) { + raw, err := EncodeMCPServersSnapshot([]runtime.MCPServerConfig{{ + Name: "remote", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{ + "--serve", + "--api-key", + "super-secret", + "--token=abc123", + "Authorization: Bearer secret", + "https://user:pass@example.invalid/mcp?token=abc123", + }, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }}) + if err != nil { + t.Fatalf("EncodeMCPServersSnapshot: %v", err) + } + + servers, err := DecodeMCPServersSnapshot(raw) + if err != nil { + t.Fatalf("DecodeMCPServersSnapshot: %v", err) + } + if len(servers) != 1 { + t.Fatalf("len(servers) = %d, want 1", len(servers)) + } + if got, want := servers[0].Env["API_TOKEN"], redactedMCPSnapshotValue; got != want { + t.Fatalf("Env[API_TOKEN] = %q, want %q", got, want) + } + if got, want := servers[0].Headers["Authorization"], redactedMCPSnapshotValue; got != want { + t.Fatalf("Headers[Authorization] = %q, want %q", got, want) + } + if got, want := servers[0].Args[0], "--serve"; got != want { + t.Fatalf("Args[0] = %q, want %q", got, want) + } + if got, want := servers[0].Args[2], redactedMCPSnapshotValue; got != want { + t.Fatalf("Args[2] = %q, want %q", got, want) + } + if got, want := servers[0].Args[3], "--token="+redactedMCPSnapshotValue; got != want { + t.Fatalf("Args[3] = %q, want %q", got, want) + } + if got, want := servers[0].Args[4], redactedMCPSnapshotValue; got != want { + t.Fatalf("Args[4] = %q, want %q", got, want) + } + if got, want := servers[0].Args[5], "https://__redacted__:__redacted__@example.invalid/mcp?token="+redactedMCPSnapshotValue; got != want { + t.Fatalf("Args[5] = %q, want %q", got, want) + } + if got, want := servers[0].URL, "https://__redacted__:__redacted__@example.invalid/mcp?token="+redactedMCPSnapshotValue; got != want { + t.Fatalf("URL = %q, want %q", got, want) + } + if !StoredMCPSnapshotContainsRedactions(servers) { + t.Fatal("StoredMCPSnapshotContainsRedactions() = false, want true") + } +} + +func TestRuntimeMCPServersSnapshotRoundTrip(t *testing.T) { + cityPath := t.TempDir() + servers := []runtime.MCPServerConfig{{ + Name: "remote", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{"--api-key", "super-secret"}, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }} + if err := PersistRuntimeMCPServersSnapshot(cityPath, "sess-1", servers); err != nil { + t.Fatalf("PersistRuntimeMCPServersSnapshot: %v", err) + } + + loaded, err := LoadRuntimeMCPServersSnapshot(cityPath, "sess-1") + if err != nil { + t.Fatalf("LoadRuntimeMCPServersSnapshot: %v", err) + } + if len(loaded) != 1 { + t.Fatalf("len(loaded) = %d, want 1", len(loaded)) + } + if got, want := loaded[0].Args[1], "super-secret"; got != want { + t.Fatalf("Args[1] = %q, want %q", got, want) + } + if got, want := loaded[0].Env["API_TOKEN"], "super-secret"; got != want { + t.Fatalf("Env[API_TOKEN] = %q, want %q", got, want) + } + if got, want := loaded[0].Headers["Authorization"], "Bearer secret"; got != want { + t.Fatalf("Headers[Authorization] = %q, want %q", got, want) + } +} + +func TestSanitizeStoredMCPSnapshotForResumePreservesNonSecretFields(t *testing.T) { + raw, err := EncodeMCPServersSnapshot([]runtime.MCPServerConfig{{ + Name: "remote", + Transport: runtime.MCPTransportHTTP, + Command: "/bin/mcp", + Args: []string{ + "--serve", + "--api-key", + "super-secret", + "--token=abc123", + "https://user:pass@example.invalid/mcp?token=abc123", + }, + Env: map[string]string{ + "API_TOKEN": "super-secret", + }, + URL: "https://user:pass@example.invalid/mcp?token=abc123", + Headers: map[string]string{ + "Authorization": "Bearer secret", + }, + }}) + if err != nil { + t.Fatalf("EncodeMCPServersSnapshot: %v", err) + } + stored, err := DecodeMCPServersSnapshot(raw) + if err != nil { + t.Fatalf("DecodeMCPServersSnapshot: %v", err) + } + + sanitized := SanitizeStoredMCPSnapshotForResume(stored) + if len(sanitized) != 1 { + t.Fatalf("len(sanitized) = %d, want 1", len(sanitized)) + } + if got, want := sanitized[0].Args, []string{"--serve"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("Args = %#v, want %#v", got, want) + } + if len(sanitized[0].Env) != 0 { + t.Fatalf("Env = %#v, want empty", sanitized[0].Env) + } + if len(sanitized[0].Headers) != 0 { + t.Fatalf("Headers = %#v, want empty", sanitized[0].Headers) + } + if got, want := sanitized[0].URL, "https://example.invalid/mcp"; got != want { + t.Fatalf("URL = %q, want %q", got, want) + } +} diff --git a/internal/session/mcp_state.go b/internal/session/mcp_state.go new file mode 100644 index 000000000..172675294 --- /dev/null +++ b/internal/session/mcp_state.go @@ -0,0 +1,123 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/citylayout" + "github.com/gastownhall/gascity/internal/runtime" +) + +func (m *Manager) syncStoredMCPServers(id string, b *beads.Bead, servers []runtime.MCPServerConfig) error { + snapshot, err := EncodeMCPServersSnapshot(servers) + if err != nil { + return err + } + current := "" + if b != nil && b.Metadata != nil { + current = strings.TrimSpace(b.Metadata[MCPServersSnapshotMetadataKey]) + } + if current != snapshot { + if err := m.store.SetMetadata(id, MCPServersSnapshotMetadataKey, snapshot); err != nil { + return fmt.Errorf("storing MCP server snapshot: %w", err) + } + if b != nil { + if b.Metadata == nil { + b.Metadata = make(map[string]string) + } + b.Metadata[MCPServersSnapshotMetadataKey] = snapshot + } + } + if err := PersistRuntimeMCPServersSnapshot(m.cityPath, id, servers); err != nil { + return fmt.Errorf("storing runtime MCP server snapshot: %w", err) + } + return nil +} + +// PersistRuntimeMCPServersSnapshot stores the full normalized MCP server +// snapshot for a session in the controller-local runtime cache. The cache is +// not exposed on the bead metadata wire and is used only as a degraded resume +// fallback when the live MCP catalog cannot be materialized. +func PersistRuntimeMCPServersSnapshot(cityPath, sessionID string, servers []runtime.MCPServerConfig) error { + path := runtimeMCPServersSnapshotPath(cityPath, sessionID) + if path == "" { + return nil + } + if len(servers) == 0 { + return clearRuntimeMCPServersSnapshot(cityPath, sessionID) + } + data, err := json.Marshal(runtime.NormalizeMCPServerConfigs(servers)) + if err != nil { + return fmt.Errorf("marshal runtime MCP snapshot: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("mkdir runtime MCP snapshot dir: %w", err) + } + temp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create runtime MCP snapshot temp file: %w", err) + } + tempPath := temp.Name() + defer func() { _ = os.Remove(tempPath) }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("chmod runtime MCP snapshot temp file: %w", err) + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return fmt.Errorf("write runtime MCP snapshot: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close runtime MCP snapshot temp file: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("rename runtime MCP snapshot: %w", err) + } + return nil +} + +// LoadRuntimeMCPServersSnapshot loads the full normalized MCP server snapshot +// for a session from the controller-local runtime cache. It returns nil, nil +// when no cache file exists. +func LoadRuntimeMCPServersSnapshot(cityPath, sessionID string) ([]runtime.MCPServerConfig, error) { + path := runtimeMCPServersSnapshotPath(cityPath, sessionID) + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read runtime MCP snapshot: %w", err) + } + var servers []runtime.MCPServerConfig + if err := json.Unmarshal(data, &servers); err != nil { + return nil, fmt.Errorf("unmarshal runtime MCP snapshot: %w", err) + } + return runtime.NormalizeMCPServerConfigs(servers), nil +} + +func runtimeMCPServersSnapshotPath(cityPath, sessionID string) string { + cityPath = strings.TrimSpace(cityPath) + sessionID = strings.TrimSpace(sessionID) + if cityPath == "" || sessionID == "" { + return "" + } + return citylayout.RuntimePath(cityPath, "session-mcp", sessionID+".json") +} + +func clearRuntimeMCPServersSnapshot(cityPath, sessionID string) error { + path := runtimeMCPServersSnapshotPath(cityPath, sessionID) + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove runtime MCP snapshot: %w", err) + } + return nil +} diff --git a/internal/session/names.go b/internal/session/names.go index d3a24c728..330fff851 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -13,6 +13,7 @@ import ( "sync" "syscall" + "github.com/gastownhall/gascity/internal/agent" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" @@ -106,6 +107,24 @@ func GenerateAdhocExplicitName(base string) (string, error) { return ValidateExplicitName(base + suffix) } +// GenerateAdhocIdentity produces a stable, MCP-safe per-session identity for +// aliasless sessions that still need a concrete unique name for templating. +func GenerateAdhocIdentity(base string) (string, error) { + token, err := GenerateSessionKey() + if err != nil { + return "", fmt.Errorf("generate adhoc identity: %w", err) + } + compact := strings.ReplaceAll(token, "-", "") + if len(compact) > 10 { + compact = compact[:10] + } + base = agent.SanitizeQualifiedNameForSession(strings.TrimSpace(base)) + if base == "" { + base = "session" + } + return base + "-adhoc-" + compact, nil +} + // ValidateAlias validates a human-chosen session alias. Empty means // "no alias". func ValidateAlias(alias string) (string, error) { diff --git a/internal/session/template_overrides.go b/internal/session/template_overrides.go new file mode 100644 index 000000000..a5293f422 --- /dev/null +++ b/internal/session/template_overrides.go @@ -0,0 +1,26 @@ +package session + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ParseTemplateOverrides decodes persisted session template_overrides metadata. +func ParseTemplateOverrides(metadata map[string]string) (map[string]string, error) { + if metadata == nil { + return nil, nil + } + raw := strings.TrimSpace(metadata["template_overrides"]) + if raw == "" { + return nil, nil + } + var overrides map[string]string + if err := json.Unmarshal([]byte(raw), &overrides); err != nil { + return nil, fmt.Errorf("unmarshal template_overrides: %w", err) + } + if len(overrides) == 0 { + return nil, nil + } + return overrides, nil +} diff --git a/internal/sessionlog/reader.go b/internal/sessionlog/reader.go index 0d8fbfbe0..02ca52556 100644 --- a/internal/sessionlog/reader.go +++ b/internal/sessionlog/reader.go @@ -427,8 +427,10 @@ func FindSessionFileForProvider(searchPaths []string, provider, workDir string) return FindCodexSessionFile(searchPaths, workDir) case "gemini": return FindGeminiSessionFile(searchPaths, workDir) - default: + case "", "auto": return FindSessionFile(searchPaths, workDir) + default: + return findSlugSessionFile(searchPaths, workDir) } } diff --git a/internal/testenv/lint_test.go b/internal/testenv/lint_test.go index 3584b9693..d1c8ccbea 100644 --- a/internal/testenv/lint_test.go +++ b/internal/testenv/lint_test.go @@ -36,18 +36,12 @@ func TestRequiresDedicatedTestenvImportFile(t *testing.T) { dirInfos := map[string]*dirInfo{} var strayImports []string - skipDirs := map[string]bool{ - "vendor": true, - "node_modules": true, - ".git": true, - } - walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if skipDirs[d.Name()] { + if skipRepoLintDir(d.Name()) { return filepath.SkipDir } return nil @@ -175,18 +169,13 @@ func TestNoLeakVectorReadsAtPackageInit(t *testing.T) { for _, name := range testenv.LeakVectorVars { leakVars[name] = true } - skipDirs := map[string]bool{ - "vendor": true, - "node_modules": true, - ".git": true, - } var offenders []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if skipDirs[d.Name()] { + if skipRepoLintDir(d.Name()) { return filepath.SkipDir } return nil @@ -233,6 +222,16 @@ func TestNoLeakVectorReadsAtPackageInit(t *testing.T) { } } +func skipRepoLintDir(name string) bool { + if name == "vendor" || name == "node_modules" { + return true + } + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + return true + } + return name == "worktrees" || strings.HasPrefix(name, "worktree-") +} + // repoRoot returns the repository root by asking git. Falls back to walking up // from this file looking for go.mod if git is unavailable. func repoRoot(t *testing.T) string { diff --git a/internal/worker/builtin/profiles.go b/internal/worker/builtin/profiles.go index 2e30cad52..f2a072220 100644 --- a/internal/worker/builtin/profiles.go +++ b/internal/worker/builtin/profiles.go @@ -22,9 +22,10 @@ type BuiltinProviderOption struct { // //nolint:revive // Mirrors the config boundary naming intentionally. type BuiltinOptionChoice struct { - Value string - Label string - FlagArgs []string + Value string + Label string + FlagArgs []string + FlagAliases [][]string } // BuiltinProviderSpec is the canonical builtin worker materialization source. @@ -55,6 +56,8 @@ type BuiltinProviderSpec struct { OptionsSchema []BuiltinProviderOption PrintArgs []string TitleModel string + ACPCommand string + ACPArgs []string } // ProfileIdentity captures the explicit production identity for a canonical @@ -137,9 +140,9 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ Type: "select", Choices: []BuiltinOptionChoice{ {Value: "", Label: "Default"}, - {Value: "opus", Label: "Opus", FlagArgs: []string{"--model", "claude-opus-4-6"}}, - {Value: "sonnet", Label: "Sonnet", FlagArgs: []string{"--model", "claude-sonnet-4-6"}}, - {Value: "haiku", Label: "Haiku", FlagArgs: []string{"--model", "claude-haiku-4-5-20251001"}}, + {Value: "opus", Label: "Opus", FlagArgs: []string{"--model", "claude-opus-4-6"}, FlagAliases: [][]string{{"-m", "claude-opus-4-6"}}}, + {Value: "sonnet", Label: "Sonnet", FlagArgs: []string{"--model", "claude-sonnet-4-6"}, FlagAliases: [][]string{{"-m", "claude-sonnet-4-6"}}}, + {Value: "haiku", Label: "Haiku", FlagArgs: []string{"--model", "claude-haiku-4-5-20251001"}, FlagAliases: [][]string{{"-m", "claude-haiku-4-5-20251001"}}}, }, }, }, @@ -185,9 +188,9 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ Type: "select", Choices: []BuiltinOptionChoice{ {Value: "", Label: "Default"}, - {Value: "gpt-5.5", Label: "GPT-5.5", FlagArgs: []string{"--model", "gpt-5.5"}}, - {Value: "o3", Label: "o3", FlagArgs: []string{"--model", "o3"}}, - {Value: "o4-mini", Label: "o4-mini", FlagArgs: []string{"--model", "o4-mini"}}, + {Value: "gpt-5.5", Label: "GPT-5.5", FlagArgs: []string{"--model", "gpt-5.5"}, FlagAliases: [][]string{{"-m", "gpt-5.5"}}}, + {Value: "o3", Label: "o3", FlagArgs: []string{"--model", "o3"}, FlagAliases: [][]string{{"-m", "o3"}}}, + {Value: "o4-mini", Label: "o4-mini", FlagArgs: []string{"--model", "o4-mini"}, FlagAliases: [][]string{{"-m", "o4-mini"}}}, }, }, { @@ -206,10 +209,10 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ Type: "select", Choices: []BuiltinOptionChoice{ {Value: "", Label: "Default"}, - {Value: "low", Label: "Low", FlagArgs: []string{"-c", "model_reasoning_effort=low"}}, - {Value: "medium", Label: "Medium", FlagArgs: []string{"-c", "model_reasoning_effort=medium"}}, - {Value: "high", Label: "High", FlagArgs: []string{"-c", "model_reasoning_effort=high"}}, - {Value: "xhigh", Label: "Extra High", FlagArgs: []string{"-c", "model_reasoning_effort=xhigh"}}, + {Value: "low", Label: "Low", FlagArgs: []string{"-c", "model_reasoning_effort=low"}, FlagAliases: [][]string{{"-c", "model_reasoning_effort=\"low\""}}}, + {Value: "medium", Label: "Medium", FlagArgs: []string{"-c", "model_reasoning_effort=medium"}, FlagAliases: [][]string{{"-c", "model_reasoning_effort=\"medium\""}}}, + {Value: "high", Label: "High", FlagArgs: []string{"-c", "model_reasoning_effort=high"}, FlagAliases: [][]string{{"-c", "model_reasoning_effort=\"high\""}}}, + {Value: "xhigh", Label: "Extra High", FlagArgs: []string{"-c", "model_reasoning_effort=xhigh"}, FlagAliases: [][]string{{"-c", "model_reasoning_effort=\"xhigh\""}}}, }, }, }, @@ -255,20 +258,22 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ Type: "select", Choices: []BuiltinOptionChoice{ {Value: "", Label: "Default"}, - {Value: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", FlagArgs: []string{"--model", "gemini-2.5-pro"}}, - {Value: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", FlagArgs: []string{"--model", "gemini-2.5-flash"}}, + {Value: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", FlagArgs: []string{"--model", "gemini-2.5-pro"}, FlagAliases: [][]string{{"-m", "gemini-2.5-pro"}}}, + {Value: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", FlagArgs: []string{"--model", "gemini-2.5-flash"}, FlagAliases: [][]string{{"-m", "gemini-2.5-flash"}}}, }, }, }, }, "cursor": { - DisplayName: "Cursor Agent", - Command: "cursor-agent", - Args: []string{"-f"}, - PromptMode: "arg", - ProcessNames: []string{"cursor-agent"}, - SupportsHooks: true, - InstructionsFile: "AGENTS.md", + DisplayName: "Cursor Agent", + Command: "cursor-agent", + Args: []string{"-f"}, + PromptMode: "arg", + ReadyPromptPrefix: "\u2192 ", + ReadyDelayMs: 10000, + ProcessNames: []string{"cursor-agent"}, + SupportsHooks: true, + InstructionsFile: "AGENTS.md", }, "copilot": { DisplayName: "GitHub Copilot", @@ -300,6 +305,7 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ SupportsACP: true, SupportsHooks: true, InstructionsFile: "AGENTS.md", + ACPArgs: []string{"acp"}, }, "auggie": { DisplayName: "Auggie CLI", @@ -387,6 +393,7 @@ func cloneBuiltinProviderSpec(spec BuiltinProviderSpec) BuiltinProviderSpec { spec.OptionDefaults = cloneStringMap(spec.OptionDefaults) spec.PrintArgs = cloneStrings(spec.PrintArgs) spec.OptionsSchema = cloneBuiltinOptions(spec.OptionsSchema) + spec.ACPArgs = cloneStrings(spec.ACPArgs) return spec } @@ -414,9 +421,10 @@ func cloneBuiltinChoices(choices []BuiltinOptionChoice) []BuiltinOptionChoice { out := make([]BuiltinOptionChoice, len(choices)) for i, choice := range choices { out[i] = BuiltinOptionChoice{ - Value: choice.Value, - Label: choice.Label, - FlagArgs: cloneStrings(choice.FlagArgs), + Value: choice.Value, + Label: choice.Label, + FlagArgs: cloneStrings(choice.FlagArgs), + FlagAliases: cloneStringSlices(choice.FlagAliases), } } return out @@ -441,3 +449,14 @@ func cloneStrings(values []string) []string { copy(out, values) return out } + +func cloneStringSlices(values [][]string) [][]string { + if values == nil { + return nil + } + out := make([][]string, len(values)) + for i := range values { + out[i] = cloneStrings(values[i]) + } + return out +} diff --git a/internal/worker/factory.go b/internal/worker/factory.go index 6d229603b..26ff7afc3 100644 --- a/internal/worker/factory.go +++ b/internal/worker/factory.go @@ -13,7 +13,7 @@ import ( // SessionRuntimeResolver resolves provider/runtime details for an existing // session-backed worker without exposing SessionSpec mutation to callers. -type SessionRuntimeResolver func(info sessionpkg.Info, sessionKind string) (*ResolvedRuntime, error) +type SessionRuntimeResolver func(info sessionpkg.Info, sessionKind string, metadata map[string]string) (*ResolvedRuntime, error) // FactoryConfig constructs worker-owned session handles and catalogs without // leaking session.Manager setup into higher layers. @@ -23,7 +23,7 @@ type FactoryConfig struct { CityPath string SearchPaths []string Recorder events.Recorder - ResolveTransport func(template string) string + ResolveTransport func(template, provider string) string ResolveSessionRuntime SessionRuntimeResolver } @@ -44,7 +44,12 @@ func NewFactory(cfg FactoryConfig) (*Factory, error) { var manager *sessionpkg.Manager switch { case cfg.ResolveTransport != nil: - manager = sessionpkg.NewManagerWithTransportResolverAndCityPath(cfg.Store, cfg.Provider, cfg.CityPath, cfg.ResolveTransport) + manager = sessionpkg.NewManagerWithTransportResolverAndCityPath( + cfg.Store, + cfg.Provider, + cfg.CityPath, + cfg.ResolveTransport, + ) case cfg.CityPath != "": manager = sessionpkg.NewManagerWithCityPath(cfg.Store, cfg.Provider, cfg.CityPath) default: @@ -113,16 +118,18 @@ func (f *Factory) SessionByID(id string) (Handle, error) { }, } sessionKind := "" + var metadata map[string]string if f.store != nil { if bead, beadErr := f.store.Get(id); beadErr == nil { sessionKind = strings.TrimSpace(bead.Metadata["mc_session_kind"]) if profile := strings.TrimSpace(bead.Metadata["worker_profile"]); profile != "" { spec.Profile = Profile(profile) } + metadata = cloneStringMap(bead.Metadata) } } if f.resolveSessionRuntime != nil { - resolved, err := f.resolveSessionRuntime(info, sessionKind) + resolved, err := f.resolveSessionRuntime(info, sessionKind, metadata) if err != nil { return nil, err } diff --git a/internal/worker/factory_test.go b/internal/worker/factory_test.go index d1baf55d8..d58b8523a 100644 --- a/internal/worker/factory_test.go +++ b/internal/worker/factory_test.go @@ -148,7 +148,7 @@ func TestFactorySessionByIDResolvesSessionRuntime(t *testing.T) { factory, err := NewFactory(FactoryConfig{ Store: store, Provider: sp, - ResolveSessionRuntime: func(_ sessionpkg.Info, sessionKind string) (*ResolvedRuntime, error) { + ResolveSessionRuntime: func(_ sessionpkg.Info, sessionKind string, _ map[string]string) (*ResolvedRuntime, error) { gotSessionKind = sessionKind return &ResolvedRuntime{ Command: "/bin/echo", @@ -205,6 +205,64 @@ func TestFactorySessionByIDResolvesSessionRuntime(t *testing.T) { } } +func TestFactoryTransportResolverReceivesProviderForLegacyProviderSession(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + manager := sessionpkg.NewManager(store, sp) + + info, err := manager.CreateBeadOnly( + "opencode", + "Probe", + "", + t.TempDir(), + "opencode", + "", + nil, + sessionpkg.ProviderResume{}, + ) + if err != nil { + t.Fatalf("CreateBeadOnly: %v", err) + } + if err := store.SetMetadata(info.ID, "mc_session_kind", "provider"); err != nil { + t.Fatalf("SetMetadata(mc_session_kind): %v", err) + } + + var gotTemplate, gotProvider string + factory, err := NewFactory(FactoryConfig{ + Store: store, + Provider: sp, + ResolveTransport: func(template, provider string) string { + gotTemplate = template + gotProvider = provider + if provider == "opencode" { + return "acp" + } + return "" + }, + }) + if err != nil { + t.Fatalf("NewFactory: %v", err) + } + + catalog, err := factory.Catalog() + if err != nil { + t.Fatalf("Catalog: %v", err) + } + got, err := catalog.Get(info.ID) + if err != nil { + t.Fatalf("catalog.Get(%q): %v", info.ID, err) + } + if gotTemplate != "opencode" { + t.Fatalf("ResolveTransport template = %q, want %q", gotTemplate, "opencode") + } + if gotProvider != "opencode" { + t.Fatalf("ResolveTransport provider = %q, want %q", gotProvider, "opencode") + } + if got.Transport != "acp" { + t.Fatalf("catalog.Get(%q).Transport = %q, want %q", info.ID, got.Transport, "acp") + } +} + func TestFactorySessionByIDPropagatesResolvedRuntimeError(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() @@ -228,7 +286,7 @@ func TestFactorySessionByIDPropagatesResolvedRuntimeError(t *testing.T) { factory, err := NewFactory(FactoryConfig{ Store: store, Provider: sp, - ResolveSessionRuntime: func(sessionpkg.Info, string) (*ResolvedRuntime, error) { + ResolveSessionRuntime: func(sessionpkg.Info, string, map[string]string) (*ResolvedRuntime, error) { return nil, wantErr }, }) diff --git a/internal/worker/handle_clone.go b/internal/worker/handle_clone.go index 9a025c2a8..4bbf15c13 100644 --- a/internal/worker/handle_clone.go +++ b/internal/worker/handle_clone.go @@ -44,6 +44,7 @@ func mergeStringMaps(base, extra map[string]string) map[string]string { func cloneRuntimeConfig(cfg runtime.Config) runtime.Config { cfg.Env = cloneStringMap(cfg.Env) + cfg.MCPServers = runtime.NormalizeMCPServerConfigs(cfg.MCPServers) cfg.ProcessNames = append([]string(nil), cfg.ProcessNames...) cfg.PreStart = append([]string(nil), cfg.PreStart...) cfg.SessionSetup = append([]string(nil), cfg.SessionSetup...) diff --git a/internal/worker/transcript/discovery_test.go b/internal/worker/transcript/discovery_test.go index 642865ba2..47cdf1d20 100644 --- a/internal/worker/transcript/discovery_test.go +++ b/internal/worker/transcript/discovery_test.go @@ -130,6 +130,34 @@ func TestDiscoverPathCodexIgnoresGCSessionID(t *testing.T) { } } +func TestDiscoverPathClaudeDoesNotScanCodexFallback(t *testing.T) { + base := t.TempDir() + workDir := filepath.Join(t.TempDir(), "claude-project") + + payload, err := json.Marshal(map[string]any{ + "type": "session_meta", + "payload": map[string]string{ + "cwd": workDir, + }, + }) + if err != nil { + t.Fatal(err) + } + codexRoot := filepath.Join(base, "sessions") + codexDir := filepath.Join(codexRoot, "2026", "04", "18") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexDir, "session.jsonl"), append(payload, '\n'), 0o644); err != nil { + t.Fatal(err) + } + + got := DiscoverPath([]string{codexRoot}, "claude/tmux-cli", workDir, "") + if got != "" { + t.Fatalf("DiscoverPath() = %q, want no Codex fallback for explicit Claude provider", got) + } +} + func TestSupportsIDLookup(t *testing.T) { tests := []struct { provider string diff --git a/renovate.json b/renovate.json index 3f875da94..71b32983a 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,9 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "config:recommended", + "helpers:pinGitHubActionDigests", + "docker:pinDigests" ], "labels": ["dependencies"], "packageRules": [ @@ -12,6 +14,127 @@ { "matchManagers": ["github-actions"], "groupName": "github actions" + }, + { + "matchManagers": ["dockerfile"], + "groupName": "container base images" + }, + { + "matchManagers": ["pip_requirements"], + "groupName": "python requirements" + }, + { + "matchManagers": ["custom.regex"], + "groupName": "pinned build tools" + } + ], + "customManagers": [ + { + "customType": "regex", + "fileMatch": [ + "/^\\.github/workflows/(ci|nightly|mac-regression|rc-gate|review-formulas)\\.yml$/", + "/^Makefile$/", + "/^contrib/k8s/Dockerfile\\.base$/" + ], + "matchStrings": [ + "DOLT_VERSION:\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\"", + "DOLT_VERSION=(?<currentValue>\\d+\\.\\d+\\.\\d+)" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "dolthub/dolt", + "extractVersionTemplate": "^v(?<version>.*)$" + }, + { + "customType": "regex", + "fileMatch": [ + "/^\\.github/workflows/(ci|nightly|mac-regression|rc-gate|review-formulas)\\.yml$/" + ], + "matchStrings": [ + "BD_VERSION:\\s*\"(?<currentValue>v?\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "gastownhall/beads" + }, + { + "customType": "regex", + "fileMatch": [ + "/^\\.github/actions/setup-gascity-(ubuntu|macos)/action\\.yml$/", + "/^contrib/k8s/Dockerfile\\.base$/", + "/^scripts/worker_inference_setup\\.py$/" + ], + "matchStrings": [ + "claude-version:[\\s\\S]*?default:\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\"", + "CLAUDE_CODE_VERSION=(?<currentValue>\\d+\\.\\d+\\.\\d+)", + "CLAUDE_CODE_VERSION\\s*=\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "npm", + "depNameTemplate": "@anthropic-ai/claude-code" + }, + { + "customType": "regex", + "fileMatch": [ + "/^contrib/k8s/Dockerfile\\.controller$/" + ], + "matchStrings": [ + "KUBECTL_VERSION=(?<currentValue>v?\\d+\\.\\d+\\.\\d+)" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "kubernetes/kubernetes" + }, + { + "customType": "regex", + "fileMatch": [ + "/^Makefile$/" + ], + "matchStrings": [ + "GOLANGCI_LINT_VERSION\\s*:=\\s*(?<currentValue>\\d+\\.\\d+\\.\\d+)" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "golangci/golangci-lint" + }, + { + "customType": "regex", + "fileMatch": [ + "/^Makefile$/" + ], + "matchStrings": [ + "BUILDX_VERSION\\s*:=\\s*(?<currentValue>\\d+\\.\\d+\\.\\d+)" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "docker/buildx" + }, + { + "customType": "regex", + "fileMatch": [ + "/^scripts/test-docker-session$/" + ], + "matchStrings": [ + "FROM alpine:(?<currentValue>[^@\\s]+)@(?<currentDigest>sha256:[a-f0-9]+)" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "alpine" + }, + { + "customType": "regex", + "fileMatch": [ + "/^scripts/worker_inference_setup\\.py$/" + ], + "matchStrings": [ + "\"@openai/codex\",\\s*\"CODEX_CLI_VERSION\",\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "npm", + "depNameTemplate": "@openai/codex" + }, + { + "customType": "regex", + "fileMatch": [ + "/^scripts/worker_inference_setup\\.py$/" + ], + "matchStrings": [ + "\"@google/gemini-cli\",\\s*\"GEMINI_CLI_VERSION\",\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "npm", + "depNameTemplate": "@google/gemini-cli" } ] } diff --git a/scripts/test-docker-session b/scripts/test-docker-session index 9fea2cd46..76166cac1 100755 --- a/scripts/test-docker-session +++ b/scripts/test-docker-session @@ -112,7 +112,7 @@ chmod +x "$BUILD_CTX/scroll-entrypoint.sh" # Primary image: Alpine + procps + tmux. cat > "$BUILD_CTX/Dockerfile" <<'DOCKERFILE' -FROM alpine:latest +FROM alpine:3.22@sha256:310c62b5e7ca5b08167e4384c68db0fd2905dd9c7493756d356e893909057601 RUN apk add --no-cache procps tmux bash COPY entrypoint.sh /entrypoint.sh COPY delay-entrypoint.sh /delay-entrypoint.sh @@ -125,7 +125,7 @@ echo " OK: built $TEST_IMAGE (with tmux)" # Secondary image: no tmux (for requirement check test). cat > "$BUILD_CTX/Dockerfile.notmux" <<'DOCKERFILE' -FROM alpine:latest +FROM alpine:3.22@sha256:310c62b5e7ca5b08167e4384c68db0fd2905dd9c7493756d356e893909057601 RUN apk add --no-cache procps CMD ["sleep", "300"] DOCKERFILE diff --git a/scripts/worker_inference_setup.py b/scripts/worker_inference_setup.py index a5e0fade4..97e824c7d 100644 --- a/scripts/worker_inference_setup.py +++ b/scripts/worker_inference_setup.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 import argparse +import os +from pathlib import Path import shutil import subprocess -PACKAGE_BY_PROVIDER = { - "claude": "@anthropic-ai/claude-code", - "codex": "@openai/codex", - "gemini": "@google/gemini-cli", +NPM_PACKAGE_BY_PROVIDER = { + "codex": ("@openai/codex", "CODEX_CLI_VERSION", "0.125.0"), + "gemini": ("@google/gemini-cli", "GEMINI_CLI_VERSION", "0.40.0"), } +CLAUDE_CODE_VERSION = "2.1.123" def parse_args() -> argparse.Namespace: @@ -26,15 +28,24 @@ def main() -> int: if args.command != "install": raise SystemExit(f"unsupported command: {args.command}") provider = args.profile.split("/", 1)[0].strip().lower() - package = PACKAGE_BY_PROVIDER.get(provider) - if not package: + if provider not in {"claude", *NPM_PACKAGE_BY_PROVIDER}: raise SystemExit(f"unsupported worker-inference profile: {args.profile!r}") if shutil.which(provider) and not args.force: print(f"{provider} already present in PATH; skipping install") return 0 - subprocess.run(["npm", "install", "-g", package], check=True) + + if provider == "claude": + version = os.environ.get("CLAUDE_CODE_VERSION", CLAUDE_CODE_VERSION) + repo_root = Path(__file__).resolve().parents[1] + installer = repo_root / ".github" / "scripts" / "install-claude-native.sh" + subprocess.run([str(installer), version], check=True) + else: + package, env_var, default_version = NPM_PACKAGE_BY_PROVIDER[provider] + version = os.environ.get(env_var, default_version) + subprocess.run(["npm", "install", "-g", f"{package}@{version}"], check=True) + if not shutil.which(provider): - raise SystemExit(f"{provider} was not found in PATH after installing {package}") + raise SystemExit(f"{provider} was not found in PATH after installation") return 0