fix(team): harden process bootstrap and codex auth
This commit is contained in:
parent
26baaf6924
commit
08ab7c6b6d
40 changed files with 2707 additions and 251 deletions
6
.github/workflows/landing.yml
vendored
6
.github/workflows/landing.yml
vendored
|
|
@ -32,9 +32,9 @@ jobs:
|
||||||
- name: Generate static site
|
- name: Generate static site
|
||||||
working-directory: landing
|
working-directory: landing
|
||||||
env:
|
env:
|
||||||
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
|
NUXT_APP_BASE_URL: /agent-teams-ai/
|
||||||
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
|
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/agent-teams-ai
|
||||||
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
|
NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
|
||||||
run: npm run generate:all
|
run: npm run generate:all
|
||||||
|
|
||||||
- uses: actions/configure-pages@v5
|
- uses: actions/configure-pages@v5
|
||||||
|
|
|
||||||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
|
|
@ -497,13 +497,13 @@ jobs:
|
||||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
declare -A FILES=(
|
declare -A FILES=(
|
||||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg"
|
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
|
||||||
["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
|
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||||
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage"
|
["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
|
||||||
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
|
["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
|
||||||
["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm"
|
["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
|
||||||
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"
|
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download versioned files and re-upload with stable names
|
# Download versioned files and re-upload with stable names
|
||||||
|
|
@ -574,22 +574,22 @@ jobs:
|
||||||
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
|
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
|
||||||
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
|
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
|
||||||
# until we switch to universal packaging or an arch-aware provider.
|
# until we switch to universal packaging or an arch-aware provider.
|
||||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"
|
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
|
||||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||||
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||||
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||||
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||||
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||||
cat > latest-mac.yml <<EOF
|
cat > latest-mac.yml <<EOF
|
||||||
version: ${VERSION}
|
version: ${VERSION}
|
||||||
files:
|
files:
|
||||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
- url: Agent.Teams.AI-${VERSION}-arm64-mac.zip
|
||||||
sha512: ${MAC_ZIP_SHA}
|
sha512: ${MAC_ZIP_SHA}
|
||||||
size: ${MAC_ZIP_SIZE}
|
size: ${MAC_ZIP_SIZE}
|
||||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg
|
- url: Agent.Teams.AI-${VERSION}-arm64.dmg
|
||||||
sha512: ${MAC_DMG_SHA}
|
sha512: ${MAC_DMG_SHA}
|
||||||
size: ${MAC_DMG_SIZE}
|
size: ${MAC_DMG_SIZE}
|
||||||
path: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
path: Agent.Teams.AI-${VERSION}-arm64-mac.zip
|
||||||
sha512: ${MAC_ZIP_SHA}
|
sha512: ${MAC_ZIP_SHA}
|
||||||
releaseDate: '${RELEASE_DATE}'
|
releaseDate: '${RELEASE_DATE}'
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -10,15 +10,15 @@
|
||||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1>
|
<h1 align="center"><a href="https://777genius.github.io/agent-teams-ai/">Agent Teams</a></h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/agent-teams-ai?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
<a href="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml"><img src="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||||
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
|
||||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
|
||||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.pacman">
|
||||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -268,8 +268,8 @@ Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.cl
|
||||||
**Prerequisites:** Node.js 20+, pnpm 10+
|
**Prerequisites:** Node.js 20+, pnpm 10+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
git clone https://github.com/777genius/agent-teams-ai.git
|
||||||
cd claude_agent_teams_ui
|
cd agent-teams-ai
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -3,7 +3,7 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-agent-teams-ui",
|
"name": "agent-teams-ai",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@claude-teams/agent-graph": "workspace:*",
|
"@claude-teams/agent-graph": "workspace:*",
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Initial release: Agent Teams with reliable CLI detection in packaged builds (she
|
||||||
After CI uploads artifacts, optional notes update:
|
After CI uploads artifacts, optional notes update:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
|
gh release edit v1.0.0 --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
|
||||||
## Agent Teams v1.0.0
|
## Agent Teams v1.0.0
|
||||||
|
|
||||||
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
|
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
|
||||||
|
|
@ -37,33 +37,33 @@ First stable build: CLI/auth reliability in packaged apps, IPC hardening, and pl
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe">
|
||||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb">
|
||||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm">
|
||||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman">
|
||||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -112,7 +112,7 @@ This triggers the `release.yml` GitHub Actions workflow which:
|
||||||
After the workflow completes, edit the release notes:
|
After the workflow completes, edit the release notes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh release edit v<VERSION> --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
|
gh release edit v<VERSION> --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
|
||||||
<paste release notes here>
|
<paste release notes here>
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
|
|
@ -140,33 +140,33 @@ EOF
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-arm64.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-arm64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-x64.dmg">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-x64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI.Setup.<VERSION>.exe">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI.Setup.<VERSION>.exe">
|
||||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.AppImage">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>.AppImage">
|
||||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui_<VERSION>_amd64.deb">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai_<VERSION>_amd64.deb">
|
||||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.x86_64.rpm">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.x86_64.rpm">
|
||||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.pacman">
|
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.pacman">
|
||||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform:
|
||||||
|
|
||||||
| Platform | Versioned Name | Stable Name (for /latest/download) |
|
| Platform | Versioned Name | Stable Name (for /latest/download) |
|
||||||
|------------------|--------------------------------------------------|--------------------------------------------|
|
|------------------|--------------------------------------------------|--------------------------------------------|
|
||||||
| macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
| macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
||||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
| macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
|
| macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
|
||||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - |
|
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
|
||||||
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
| Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
||||||
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
| Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
||||||
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
| Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
||||||
| Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
|
| Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
|
||||||
| Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
|
| Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
|
||||||
|
|
||||||
## Stable Download Links
|
## Stable Download Links
|
||||||
|
|
||||||
|
|
@ -214,7 +214,7 @@ It starts only after **release-mac** (two matrix jobs), **release-win**, and **r
|
||||||
This enables permanent links in README that always point to the latest release:
|
This enables permanent links in README that always point to the latest release:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
|
https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
|
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
|
||||||
|
|
@ -251,10 +251,10 @@ git push origin v1.0.0
|
||||||
# Wait for CI to finish (~10 min), then update notes
|
# Wait for CI to finish (~10 min), then update notes
|
||||||
|
|
||||||
# Delete a release (if needed)
|
# Delete a release (if needed)
|
||||||
gh release delete v1.0.0 --repo 777genius/claude_agent_teams_ui --yes
|
gh release delete v1.0.0 --repo 777genius/agent-teams-ai --yes
|
||||||
git tag -d v1.0.0
|
git tag -d v1.0.0
|
||||||
git push origin :refs/tags/v1.0.0
|
git push origin :refs/tags/v1.0.0
|
||||||
|
|
||||||
# Check workflow status
|
# Check workflow status
|
||||||
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
|
gh run list --repo 777genius/agent-teams-ai --workflow release.yml --limit 3
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@ const runtimeFilePrefixCases = [
|
||||||
['bob.team', 'bob-team'],
|
['bob.team', 'bob-team'],
|
||||||
['bob_team', 'bob-team'],
|
['bob_team', 'bob-team'],
|
||||||
['con', '_con'],
|
['con', '_con'],
|
||||||
['con.txt', '_con-txt'],
|
['con.txt', 'con-txt'],
|
||||||
['aux', '_aux'],
|
['aux', '_aux'],
|
||||||
['COM1', '_com1'],
|
['COM1', '_com1'],
|
||||||
['LPT9', '_lpt9'],
|
['LPT9', '_lpt9'],
|
||||||
|
|
@ -425,6 +425,18 @@ These values are structured output and can be shown in diagnostics immediately.
|
||||||
|
|
||||||
`clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success.
|
`clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success.
|
||||||
|
|
||||||
|
Clean-success clear rule:
|
||||||
|
|
||||||
|
- if the normalized snapshot is truly `clean_success` after proof/provider/transport overlays and `launchPhase !== 'active'`, preserve existing behavior and clear persisted launch-state;
|
||||||
|
- process transport enrichment must not keep a stale diagnostic alive after every expected member is confirmed/skipped according to existing summary semantics;
|
||||||
|
- if any process member is still `runtime_pending_bootstrap`, `failed_to_start`, or permission-pending, the snapshot is not clean success and must not be cleared;
|
||||||
|
- do not use runtime event absence to prevent clean-success clearing. Absence of a best-effort diagnostic file is not a reason to keep launch-state;
|
||||||
|
- `clearPersistedLaunchStateNow(...)` also clears bootstrap-state. Therefore clean-success clear must be fenced by current run identity immediately before clearing, not only before building the snapshot;
|
||||||
|
- a stale clean-success finalizer from an older run must not clear launch-state or bootstrap-state for a newer active/restarted run;
|
||||||
|
- if the current run identity cannot be proven at clear time, skip clear and keep the conservative persisted state;
|
||||||
|
- tests should cover clean-success launch with missing runtime event files and assert launch-state can still clear.
|
||||||
|
- tests should cover stale clean-success finalizer racing with a newer launch and assert neither launch-state nor bootstrap-state is cleared for the newer run.
|
||||||
|
|
||||||
### 27. Shared/public launch status types must not expose transport internals
|
### 27. Shared/public launch status types must not expose transport internals
|
||||||
|
|
||||||
`PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`.
|
`PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`.
|
||||||
|
|
@ -636,7 +648,11 @@ type LaunchTransitionReason =
|
||||||
function mergeProcessBootstrapLaunchState(
|
function mergeProcessBootstrapLaunchState(
|
||||||
previous: PersistedTeamLaunchMemberState,
|
previous: PersistedTeamLaunchMemberState,
|
||||||
evidence: ProcessBootstrapTransportEvidence,
|
evidence: ProcessBootstrapTransportEvidence,
|
||||||
context: { launchPhase: 'active' | 'terminal'; currentAttempt: boolean },
|
context: {
|
||||||
|
launchPhase: PersistedTeamLaunchPhase
|
||||||
|
projectionPhase: 'active' | 'final'
|
||||||
|
currentAttempt: boolean
|
||||||
|
},
|
||||||
): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } {
|
): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } {
|
||||||
// pure function, no filesystem, no process table, no renderer imports
|
// pure function, no filesystem, no process table, no renderer imports
|
||||||
}
|
}
|
||||||
|
|
@ -644,6 +660,36 @@ function mergeProcessBootstrapLaunchState(
|
||||||
|
|
||||||
Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers.
|
Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers.
|
||||||
|
|
||||||
|
### Persisted launch phase compatibility
|
||||||
|
|
||||||
|
Current shared type is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled'
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add new persisted values like `finalizing` or `terminal` for this phase. If merge logic needs an internal terminal/final concept, derive it as a local non-persisted value:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ProcessTransportProjectionPhase = 'active' | 'final'
|
||||||
|
|
||||||
|
function deriveProcessTransportProjectionPhase(input: {
|
||||||
|
launchPhase: PersistedTeamLaunchPhase
|
||||||
|
finalTimeoutReached?: boolean
|
||||||
|
}): ProcessTransportProjectionPhase {
|
||||||
|
if (input.launchPhase !== 'active') return 'final'
|
||||||
|
return input.finalTimeoutReached === true ? 'final' : 'active'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- persisted snapshots keep `launchPhase: 'active' | 'finished' | 'reconciled'` only;
|
||||||
|
- runner UI/progress step `finalizing` is not the same thing as persisted `launchPhase`;
|
||||||
|
- process transport merge can use internal `projectionPhase: 'final'` to decide timeout-to-failure behavior;
|
||||||
|
- do not cast new phase strings with `as PersistedTeamLaunchPhase`;
|
||||||
|
- tests must assert unknown phase strings are normalized by existing evaluator behavior, not introduced by this change.
|
||||||
|
|
||||||
## Transport failure taxonomy
|
## Transport failure taxonomy
|
||||||
|
|
||||||
Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings.
|
Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings.
|
||||||
|
|
@ -1852,12 +1898,17 @@ Reader safety:
|
||||||
- do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected;
|
- do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected;
|
||||||
- use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line;
|
- use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line;
|
||||||
- tolerate corrupt/partial JSON lines by skipping only those lines;
|
- tolerate corrupt/partial JSON lines by skipping only those lines;
|
||||||
|
- impose a per-line byte/char cap before `JSON.parse`, for example `16 KiB`. Oversized lines are treated as corrupt diagnostic noise and skipped;
|
||||||
|
- if a single oversized line consumes the whole tail slice, return an empty list and rely on timeout/fallback. Do not retry with a larger read just to recover diagnostics;
|
||||||
|
- parse at most a bounded number of lines/events from one file, for example last `256` candidate lines, after tail slicing;
|
||||||
- normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers;
|
- normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers;
|
||||||
- when reading a tail slice, drop the first line because it can be a partial middle-of-file line;
|
- when reading a tail slice, drop the first line because it can be a partial middle-of-file line;
|
||||||
- if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape;
|
- if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape;
|
||||||
- never throw from malformed runtime event content during launch finalization;
|
- never throw from malformed runtime event content during launch finalization;
|
||||||
- keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`.
|
- keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`.
|
||||||
|
|
||||||
|
Reader output must preserve append order among accepted parsed events. Skipped corrupt/oversized lines do not create placeholder events and do not affect stage rank except through absence.
|
||||||
|
|
||||||
## Phase 4 - Add bootstrap submission outcome waiter
|
## Phase 4 - Add bootstrap submission outcome waiter
|
||||||
|
|
||||||
Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
|
Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
|
||||||
|
|
@ -2608,7 +2659,8 @@ export interface ProcessBootstrapTransportMergeInput {
|
||||||
evidence: ProcessBootstrapTransportEvidence
|
evidence: ProcessBootstrapTransportEvidence
|
||||||
diagnostic: ProcessBootstrapTransportDiagnostic
|
diagnostic: ProcessBootstrapTransportDiagnostic
|
||||||
attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match'
|
attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match'
|
||||||
launchPhase: 'active' | 'finalizing' | 'terminal'
|
launchPhase: PersistedTeamLaunchPhase
|
||||||
|
projectionPhase: 'active' | 'final'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -2618,40 +2670,92 @@ Merge helper rules:
|
||||||
- `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state;
|
- `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state;
|
||||||
- `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary;
|
- `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary;
|
||||||
- `strict-current` is required for immediate terminal transport failure;
|
- `strict-current` is required for immediate terminal transport failure;
|
||||||
- `launchPhase: 'active'` cannot convert retryable rejection into `failed_to_start`;
|
- `projectionPhase: 'active'` cannot convert retryable rejection or timeout-only pending stages into `failed_to_start`;
|
||||||
- `launchPhase: 'terminal'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
|
- `projectionPhase: 'final'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
|
||||||
|
|
||||||
Projection coordinator shape:
|
Projection coordinator shape:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
async function enrichAndPersistCurrentLaunchSnapshot(run: MutableTeamRun): Promise<void> {
|
async function writeLaunchStateSnapshotNow(teamName, snapshot, options) {
|
||||||
const identitySnapshot = buildCurrentAttemptIdentities(run)
|
const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null)
|
||||||
const evidenceSnapshot = await readProcessTransportEvidence(identitySnapshot)
|
const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => [])
|
||||||
|
|
||||||
if (!isStillCurrentRun(run, identitySnapshot)) {
|
const openCodeOverlaid = await applyOpenCodeSecondaryEvidenceOverlay({
|
||||||
return
|
teamName,
|
||||||
|
snapshot,
|
||||||
|
previousSnapshot,
|
||||||
|
metaMembers,
|
||||||
|
})
|
||||||
|
|
||||||
|
const processOverlaid = await applyProcessTransportEvidenceOverlay({
|
||||||
|
teamName,
|
||||||
|
snapshot: openCodeOverlaid,
|
||||||
|
previousSnapshot,
|
||||||
|
metaMembers,
|
||||||
|
runIdentity: options?.runIdentity,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isStillCurrentBeforeLaunchStateWrite(teamName, options?.runIdentity)) {
|
||||||
|
return { snapshot: previousSnapshot ?? processOverlaid, wrote: false, stale: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = mergeProcessTransportEvidence(run.memberStatuses, evidenceSnapshot)
|
const normalizedSnapshot =
|
||||||
const snapshot = buildLiveLaunchSnapshotForRun({ ...run, memberStatuses: statuses })
|
applyOpenCodeSecondaryBootstrapStallOverlay(processOverlaid) ?? processOverlaid
|
||||||
await this.teamLaunchStateStore.write(run.teamName, snapshot)
|
|
||||||
|
if (await canSkipLaunchStateWriteAndSummaryIsFresh(previousSnapshot, normalizedSnapshot, options)) {
|
||||||
|
return { snapshot: previousSnapshot, wrote: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSnapshot.teamLaunchState === 'clean_success' && normalizedSnapshot.launchPhase !== 'active') {
|
||||||
|
if (!isStillCurrentBeforeLaunchStateClear(teamName, options?.runIdentity)) {
|
||||||
|
return { snapshot: previousSnapshot ?? normalizedSnapshot, wrote: false, stale: true }
|
||||||
|
}
|
||||||
|
await clearPersistedLaunchStateNow(teamName)
|
||||||
|
return { snapshot: null, wrote: true, cleared: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.launchStateStore.write(teamName, normalizedSnapshot)
|
||||||
|
return { snapshot: normalizedSnapshot, wrote: true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `identitySnapshot` is immutable for the projection pass;
|
- the existing per-team `enqueueLaunchStateStoreOperation(...)` remains the serialization boundary. Do not create a parallel writer or a second read/compare/write queue;
|
||||||
|
- `previousSnapshot` is read once inside the queued operation and passed into overlays. Do not let each overlay call `launchStateStore.read(...)` independently;
|
||||||
|
- `metaMembers` is read once inside the queued operation and passed into overlays. Do not let OpenCode and process overlays race on separate member metadata reads;
|
||||||
|
- `runIdentity` / attempt identity is immutable for the projection pass;
|
||||||
- evidence read failures degrade to no enrichment, not launch crash;
|
- evidence read failures degrade to no enrichment, not launch crash;
|
||||||
- evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only;
|
- evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only;
|
||||||
- `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available;
|
- `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available;
|
||||||
- `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections;
|
- `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections;
|
||||||
- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store.
|
- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store;
|
||||||
- integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow;
|
- integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow;
|
||||||
- do not add a second launch-state writer for transport enrichment;
|
- do not add a second launch-state writer for transport enrichment;
|
||||||
- if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover.
|
- if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover.
|
||||||
- account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior.
|
- account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior.
|
||||||
- preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write.
|
- preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write.
|
||||||
- if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments.
|
- if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments.
|
||||||
|
- do not evaluate clean-success clear before process transport enrichment and final current-run fence. A pre-enrichment clean-success check can erase evidence that would have kept a partial launch visible.
|
||||||
|
|
||||||
|
Queue and IO budget:
|
||||||
|
|
||||||
|
- bounded runtime event tail reads may happen inside the queued operation only if the total budget is small and deterministic. This keeps previous-snapshot comparison consistent;
|
||||||
|
- do not perform broad process table scans, project transcript scans, or network/provider checks inside the queued operation;
|
||||||
|
- if runtime event IO budget would be exceeded, stop reading more members and return no enrichment for the rest. Do not hold the queue for best-effort diagnostics;
|
||||||
|
- do not recursively call `persistLaunchStateSnapshot(...)` or `writeLaunchStateSnapshot(...)` from inside an overlay;
|
||||||
|
- overlay helpers return new snapshot objects. They do not write files, emit IPC, notify lead/user, or mutate live run maps.
|
||||||
|
|
||||||
|
No-op skip and summary repair:
|
||||||
|
|
||||||
|
- transport enrichment must be included before `areLaunchStateSnapshotsSemanticallyEqual(...)`;
|
||||||
|
- no-op skip is allowed only after checking whether `launch-summary.json` is present/fresh enough for the normalized detailed snapshot;
|
||||||
|
- if summary is missing/stale and detailed state is semantically unchanged, force `TeamLaunchStateStore.write(...)` to repair both files;
|
||||||
|
- the summary repair check must be bounded and must run inside the same serialized operation as the detailed-state comparison;
|
||||||
|
- do not direct-write `launch-summary.json`;
|
||||||
|
- do not update `launchStateWrittenRunIdByTeam` before the detailed + summary write path has succeeded or a verified no-op skip has occurred.
|
||||||
|
- clean-success clear is a write operation and must happen inside the same serialized queue with the same final run-identity fence as normal writes;
|
||||||
|
- because clear also removes bootstrap-state, it needs a stricter current-run check than ordinary diagnostic enrichment.
|
||||||
|
|
||||||
Normalizer interaction hazard:
|
Normalizer interaction hazard:
|
||||||
|
|
||||||
|
|
@ -3072,6 +3176,8 @@ Cases:
|
||||||
|
|
||||||
- bounded reader reads tail and drops first partial line;
|
- bounded reader reads tail and drops first partial line;
|
||||||
- bounded reader skips corrupt and final partial JSONL lines without throwing;
|
- bounded reader skips corrupt and final partial JSONL lines without throwing;
|
||||||
|
- bounded reader skips oversized JSONL lines before parse and does not increase read budget to recover diagnostics;
|
||||||
|
- bounded reader caps candidate lines/events per file and preserves append order for accepted events;
|
||||||
- missing event file returns empty list;
|
- missing event file returns empty list;
|
||||||
- append order beats misleading future/past timestamps for stage selection;
|
- append order beats misleading future/past timestamps for stage selection;
|
||||||
- low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic;
|
- low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic;
|
||||||
|
|
@ -3220,6 +3326,10 @@ Cases:
|
||||||
- no evidence still allows `never spawned`;
|
- no evidence still allows `never spawned`;
|
||||||
- terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`;
|
- terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`;
|
||||||
- terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`;
|
- terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`;
|
||||||
|
- process transport merge uses internal `projectionPhase` and never writes persisted launchPhase values outside `active | finished | reconciled`;
|
||||||
|
- unknown persisted launchPhase strings continue to normalize through existing evaluator fallback and are not produced by new code;
|
||||||
|
- clean-success finished launch can still clear persisted launch-state even when runtime event files are missing/unreadable;
|
||||||
|
- partial/pending process member prevents clean-success clearing and keeps launch-state visible;
|
||||||
- `bootstrap_submitted` yields pending/unconfirmed, not confirmed;
|
- `bootstrap_submitted` yields pending/unconfirmed, not confirmed;
|
||||||
- retryable rejection remains pending/warning during active launch;
|
- retryable rejection remains pending/warning during active launch;
|
||||||
- parent failed event yields `failed_to_start` with exact reason;
|
- parent failed event yields `failed_to_start` with exact reason;
|
||||||
|
|
@ -3360,6 +3470,7 @@ Use this checklist before committing implementation.
|
||||||
- No selected user-facing transport diagnostic is stored only in `diagnostics[]`.
|
- No selected user-facing transport diagnostic is stored only in `diagnostics[]`.
|
||||||
- No pending transport state uses `runtimeDiagnosticSeverity: 'error'`.
|
- No pending transport state uses `runtimeDiagnosticSeverity: 'error'`.
|
||||||
- No ordinary submitted/waiting transport state sets `bootstrapStalled`.
|
- No ordinary submitted/waiting transport state sets `bootstrapStalled`.
|
||||||
|
- No new persisted `launchPhase` string is introduced. Internal final/terminal concepts stay internal.
|
||||||
- No stale finalizer/timeout path can persist after stop/cancel/restart identity changed.
|
- No stale finalizer/timeout path can persist after stop/cancel/restart identity changed.
|
||||||
- No pending-only evidence clears provider/runtime hard failure.
|
- No pending-only evidence clears provider/runtime hard failure.
|
||||||
- No generic transport timeout overwrites provider/auth/quota/model root cause.
|
- No generic transport timeout overwrites provider/auth/quota/model root cause.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export const useGithubRepo = () => {
|
export const useGithubRepo = () => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const githubRepo = computed(
|
const githubRepo = computed(
|
||||||
() => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui',
|
() => (config.public.githubRepo as string) || '777genius/agent-teams-ai',
|
||||||
);
|
);
|
||||||
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
|
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
|
||||||
const releasesUrl = computed(
|
const releasesUrl = computed(
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void {
|
||||||
|
|
||||||
export const useReleaseDownloads = () => {
|
export const useReleaseDownloads = () => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui";
|
const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
|
||||||
|
|
||||||
const fallbackUrl =
|
const fallbackUrl =
|
||||||
(config.public.githubReleasesUrl as string) ||
|
(config.public.githubReleasesUrl as string) ||
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
declare const process: any;
|
declare const process: any;
|
||||||
|
|
||||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui";
|
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
|
||||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
|
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
|
||||||
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||||
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig, type DefaultTheme } from "vitepress";
|
import { defineConfig, type DefaultTheme } from "vitepress";
|
||||||
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
|
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
|
||||||
|
|
||||||
const REPO = "777genius/claude_agent_teams_ui";
|
const REPO = "777genius/agent-teams-ai";
|
||||||
const SITE_TITLE = "Agent Teams Docs";
|
const SITE_TITLE = "Agent Teams Docs";
|
||||||
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
|
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
|
||||||
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
|
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
|
||||||
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
|
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
|
||||||
const siteUrl = trimTrailingSlash(
|
const siteUrl = trimTrailingSlash(
|
||||||
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"
|
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai"
|
||||||
);
|
);
|
||||||
const publicBaseUrl =
|
const publicBaseUrl =
|
||||||
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
|
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const props = withDefaults(
|
||||||
copiedLabel?: string;
|
copiedLabel?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
command: "git clone https://github.com/777genius/claude_agent_teams_ui.git",
|
command: "git clone https://github.com/777genius/agent-teams-ai.git",
|
||||||
label: "Click to copy",
|
label: "Click to copy",
|
||||||
copiedLabel: "Copied"
|
copiedLabel: "Copied"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ For source development, use:
|
||||||
|
|
||||||
## Run from source
|
## Run from source
|
||||||
|
|
||||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" />
|
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" />
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
git clone https://github.com/777genius/agent-teams-ai.git
|
||||||
cd claude_agent_teams_ui
|
cd agent-teams-ai
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ Agent Teams распространяется как desktop-приложение
|
||||||
|
|
||||||
## Запуск из исходников
|
## Запуск из исходников
|
||||||
|
|
||||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
git clone https://github.com/777genius/agent-teams-ai.git
|
||||||
cd claude_agent_teams_ui
|
cd agent-teams-ai
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||||
|
|
||||||
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||||
|
|
||||||
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
||||||
|
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "claude-agent-teams-ui",
|
"name": "agent-teams-ai",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
||||||
|
|
@ -8,13 +8,13 @@
|
||||||
"name": "Илия (777genius)",
|
"name": "Илия (777genius)",
|
||||||
"email": "quantjumppro@gmail.com"
|
"email": "quantjumppro@gmail.com"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
|
"homepage": "https://github.com/777genius/agent-teams-ai",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
|
"url": "https://github.com/777genius/agent-teams-ai.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
|
"url": "https://github.com/777genius/agent-teams-ai/issues"
|
||||||
},
|
},
|
||||||
"main": "dist-electron/main/index.cjs",
|
"main": "dist-electron/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -270,7 +270,7 @@
|
||||||
"main": "dist-electron/main/index.cjs"
|
"main": "dist-electron/main/index.cjs"
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
|
"artifactName": "Agent.Teams.AI-${version}-${arch}-mac.${ext}",
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"minimumSystemVersion": "12.0",
|
"minimumSystemVersion": "12.0",
|
||||||
"target": [
|
"target": [
|
||||||
|
|
@ -286,7 +286,7 @@
|
||||||
},
|
},
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"sign": false,
|
"sign": false,
|
||||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
|
"artifactName": "Agent.Teams.AI-${version}-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
|
|
@ -305,13 +305,13 @@
|
||||||
"category": "Development"
|
"category": "Development"
|
||||||
},
|
},
|
||||||
"appImage": {
|
"appImage": {
|
||||||
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
|
"artifactName": "Agent.Teams.AI-${version}.${ext}"
|
||||||
},
|
},
|
||||||
"deb": {
|
"deb": {
|
||||||
"afterInstall": "resources/afterInstall.sh"
|
"afterInstall": "resources/afterInstall.sh"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
|
"artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
|
|
@ -320,7 +320,7 @@
|
||||||
{
|
{
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
"owner": "777genius",
|
"owner": "777genius",
|
||||||
"repo": "claude_agent_teams_ui",
|
"repo": "agent-teams-ai",
|
||||||
"releaseType": "release"
|
"releaseType": "release"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"version": "0.0.22",
|
"version": "0.0.22",
|
||||||
"sourceRef": "v0.0.22",
|
"sourceRef": "v0.0.22",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/agent-teams-ai",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/Cod
|
||||||
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
|
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
|
||||||
import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder';
|
import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder';
|
||||||
import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager';
|
import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager';
|
||||||
import { detectCodexLocalAccountState } from '../infrastructure/detectCodexLocalAccountArtifacts';
|
import {
|
||||||
|
detectCodexLocalAccountState,
|
||||||
|
ensureCodexLegacyAuthFromActiveAccount,
|
||||||
|
} from '../infrastructure/detectCodexLocalAccountArtifacts';
|
||||||
|
|
||||||
import type { Logger } from '@shared/utils/logger';
|
import type { Logger } from '@shared/utils/logger';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
@ -47,6 +50,7 @@ interface CodexLastKnownAccount {
|
||||||
interface CodexLastKnownRateLimits {
|
interface CodexLastKnownRateLimits {
|
||||||
payload: CodexAppServerGetAccountRateLimitsResponse;
|
payload: CodexAppServerGetAccountRateLimitsResponse;
|
||||||
observedAt: number;
|
observedAt: number;
|
||||||
|
accountSignature: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodexRuntimeContext {
|
interface CodexRuntimeContext {
|
||||||
|
|
@ -96,6 +100,20 @@ function asCodexManagedAccount(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCodexAccountSignature(
|
||||||
|
account: CodexAppServerGetAccountResponse['account']
|
||||||
|
): string | null {
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.type === 'apiKey') {
|
||||||
|
return 'api_key';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `chatgpt:${account.email ?? 'unknown'}:${account.planType ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function asRateLimitWindow(
|
function asRateLimitWindow(
|
||||||
window: CodexAppServerRateLimitSnapshot['primary']
|
window: CodexAppServerRateLimitSnapshot['primary']
|
||||||
): CodexRateLimitWindowDto | null {
|
): CodexRateLimitWindowDto | null {
|
||||||
|
|
@ -471,6 +489,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localActiveChatgptAccountPresent) {
|
||||||
|
await ensureCodexLegacyAuthFromActiveAccount().catch((error) => {
|
||||||
|
this.logger.warn('codex account legacy auth compatibility sync failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
|
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
|
||||||
let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy';
|
let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy';
|
||||||
let appServerStatusMessage: string | null = null;
|
let appServerStatusMessage: string | null = null;
|
||||||
|
|
@ -497,7 +523,6 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const canReuseLastKnownManagedAccount =
|
const canReuseLastKnownManagedAccount =
|
||||||
options?.forceRefreshToken !== true &&
|
|
||||||
localActiveChatgptAccountPresent &&
|
localActiveChatgptAccountPresent &&
|
||||||
accountResult.account.account == null &&
|
accountResult.account.account == null &&
|
||||||
accountResult.account.requiresOpenaiAuth === true &&
|
accountResult.account.requiresOpenaiAuth === true &&
|
||||||
|
|
@ -520,6 +545,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
this.lastKnownRateLimits = {
|
this.lastKnownRateLimits = {
|
||||||
payload: accountResult.rateLimits.payload,
|
payload: accountResult.rateLimits.payload,
|
||||||
observedAt: now,
|
observedAt: now,
|
||||||
|
accountSignature: getCodexAccountSignature(accountResult.account.account),
|
||||||
};
|
};
|
||||||
} else if (accountResult.rateLimits) {
|
} else if (accountResult.rateLimits) {
|
||||||
rateLimitsReadFailure = accountResult.rateLimits.error;
|
rateLimitsReadFailure = accountResult.rateLimits.error;
|
||||||
|
|
@ -552,10 +578,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
||||||
const shouldLoadRateLimits =
|
const shouldLoadRateLimits =
|
||||||
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
|
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
|
||||||
|
const currentAccountSignature = getCodexAccountSignature(accountPayload?.account ?? null);
|
||||||
|
const reusableLastKnownRateLimits =
|
||||||
|
this.lastKnownRateLimits?.accountSignature === currentAccountSignature
|
||||||
|
? this.lastKnownRateLimits
|
||||||
|
: null;
|
||||||
|
|
||||||
if (shouldLoadRateLimits) {
|
if (shouldLoadRateLimits) {
|
||||||
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
|
if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) {
|
||||||
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
|
||||||
} else if (rateLimitsReadFailure) {
|
} else if (rateLimitsReadFailure) {
|
||||||
this.logger.warn('codex account rate limits refresh failed', {
|
this.logger.warn('codex account rate limits refresh failed', {
|
||||||
error:
|
error:
|
||||||
|
|
@ -563,6 +594,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
? rateLimitsReadFailure.message
|
? rateLimitsReadFailure.message
|
||||||
: String(rateLimitsReadFailure),
|
: String(rateLimitsReadFailure),
|
||||||
});
|
});
|
||||||
|
if (reusableLastKnownRateLimits) {
|
||||||
|
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs, type Dirent } from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts');
|
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts');
|
||||||
|
const LEGACY_AUTH_SYNC_MARKER_FILE = '.agent-teams-legacy-auth-sync.json';
|
||||||
|
|
||||||
interface CodexAccountsRegistry {
|
interface CodexAccountsRegistry {
|
||||||
|
active_account_id?: string | null;
|
||||||
active_account_key?: string | null;
|
active_account_key?: string | null;
|
||||||
|
activeAccountId?: string | null;
|
||||||
activeAccountKey?: string | null;
|
activeAccountKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodexAuthFile {
|
interface CodexAuthFile {
|
||||||
auth_mode?: string | null;
|
auth_mode?: string | null;
|
||||||
authMode?: string | null;
|
authMode?: string | null;
|
||||||
|
tokens?: {
|
||||||
|
refresh_token?: string | null;
|
||||||
|
refreshToken?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexLocalAccountState {
|
export interface CodexLocalAccountState {
|
||||||
|
|
@ -19,6 +26,25 @@ export interface CodexLocalAccountState {
|
||||||
hasActiveChatgptAccount: boolean;
|
hasActiveChatgptAccount: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexActiveChatgptAuthFile {
|
||||||
|
codexHome: string;
|
||||||
|
authFilePath: string;
|
||||||
|
source: 'accounts' | 'legacy';
|
||||||
|
activeAccountKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexLegacyAuthCompatibilityResult {
|
||||||
|
codexHome: string;
|
||||||
|
authFilePath: string;
|
||||||
|
source: 'accounts' | 'legacy';
|
||||||
|
materializedLegacyAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyAuthSyncMarker {
|
||||||
|
activeAccountKey?: string | null;
|
||||||
|
sourceAuthFilePath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function encodeAccountKeyForAuthFilename(accountKey: string): string {
|
function encodeAccountKeyForAuthFilename(accountKey: string): string {
|
||||||
return Buffer.from(accountKey, 'utf8')
|
return Buffer.from(accountKey, 'utf8')
|
||||||
.toString('base64')
|
.toString('base64')
|
||||||
|
|
@ -45,15 +71,66 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasChatgptRefreshToken(authFile: CodexAuthFile | null): boolean {
|
||||||
|
if (!authFile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authMode = authFile.auth_mode ?? authFile.authMode ?? null;
|
||||||
|
const refreshToken = authFile.tokens?.refresh_token ?? authFile.tokens?.refreshToken ?? null;
|
||||||
|
return (
|
||||||
|
authMode === 'chatgpt' && typeof refreshToken === 'string' && refreshToken.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCodexAuthFile(filePath: string): Promise<CodexAuthFile | null> {
|
||||||
|
return readJsonFile<CodexAuthFile>(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyAuthFilePath(accountsDir: string): string {
|
||||||
|
return path.join(path.dirname(accountsDir), 'auth.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveAccountKey(registry: CodexAccountsRegistry | null): string | null {
|
||||||
|
return (
|
||||||
|
registry?.active_account_key?.trim() ||
|
||||||
|
registry?.activeAccountKey?.trim() ||
|
||||||
|
registry?.active_account_id?.trim() ||
|
||||||
|
registry?.activeAccountId?.trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveAccountAuthFileCandidates(
|
||||||
|
accountsDir: string,
|
||||||
|
activeAccountKey: string
|
||||||
|
): string[] {
|
||||||
|
const candidates = [
|
||||||
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
|
||||||
|
];
|
||||||
|
if (!activeAccountKey.includes('/') && !activeAccountKey.includes('\\')) {
|
||||||
|
candidates.push(path.join(accountsDir, `${activeAccountKey}.auth.json`));
|
||||||
|
}
|
||||||
|
return Array.from(new Set(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
export async function detectCodexLocalAccountState(
|
export async function detectCodexLocalAccountState(
|
||||||
accountsDir = CODEX_ACCOUNTS_DIR
|
accountsDir = CODEX_ACCOUNTS_DIR
|
||||||
): Promise<CodexLocalAccountState> {
|
): Promise<CodexLocalAccountState> {
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(accountsDir, { withFileTypes: true });
|
let entries: Dirent[] = [];
|
||||||
const hasArtifacts = entries.some(
|
try {
|
||||||
|
entries = await fs.readdir(accountsDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
const hasAccountsArtifacts = entries.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json'))
|
entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json'))
|
||||||
);
|
);
|
||||||
|
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
|
||||||
|
const hasLegacyAuthFile = await fileExists(legacyAuthFilePath);
|
||||||
|
const hasArtifacts = hasAccountsArtifacts || hasLegacyAuthFile;
|
||||||
|
|
||||||
if (!hasArtifacts) {
|
if (!hasArtifacts) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -62,36 +139,9 @@ export async function detectCodexLocalAccountState(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = await readJsonFile<CodexAccountsRegistry>(
|
|
||||||
path.join(accountsDir, 'registry.json')
|
|
||||||
);
|
|
||||||
const activeAccountKey =
|
|
||||||
registry?.active_account_key?.trim() || registry?.activeAccountKey?.trim() || null;
|
|
||||||
|
|
||||||
if (!activeAccountKey) {
|
|
||||||
return {
|
|
||||||
hasArtifacts: true,
|
|
||||||
hasActiveChatgptAccount: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authFilePath = path.join(
|
|
||||||
accountsDir,
|
|
||||||
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
|
|
||||||
);
|
|
||||||
if (!(await fileExists(authFilePath))) {
|
|
||||||
return {
|
|
||||||
hasArtifacts: true,
|
|
||||||
hasActiveChatgptAccount: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authFile = await readJsonFile<CodexAuthFile>(authFilePath);
|
|
||||||
const authMode = authFile?.auth_mode ?? authFile?.authMode ?? null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasArtifacts: true,
|
hasArtifacts: true,
|
||||||
hasActiveChatgptAccount: authMode === 'chatgpt',
|
hasActiveChatgptAccount: (await resolveCodexActiveChatgptAuthFile(accountsDir)) !== null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
|
|
@ -101,6 +151,128 @@ export async function detectCodexLocalAccountState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveCodexActiveChatgptAuthFile(
|
||||||
|
accountsDir = CODEX_ACCOUNTS_DIR
|
||||||
|
): Promise<CodexActiveChatgptAuthFile | null> {
|
||||||
|
const codexHome = path.dirname(accountsDir);
|
||||||
|
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
|
||||||
|
const registryPath = path.join(accountsDir, 'registry.json');
|
||||||
|
const hasRegistry = await fileExists(registryPath);
|
||||||
|
|
||||||
|
if (!hasRegistry) {
|
||||||
|
const legacyAuthFile = await readCodexAuthFile(legacyAuthFilePath);
|
||||||
|
return hasChatgptRefreshToken(legacyAuthFile)
|
||||||
|
? {
|
||||||
|
codexHome,
|
||||||
|
authFilePath: legacyAuthFilePath,
|
||||||
|
source: 'legacy',
|
||||||
|
activeAccountKey: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = await readJsonFile<CodexAccountsRegistry>(registryPath);
|
||||||
|
const activeAccountKey = getActiveAccountKey(registry);
|
||||||
|
if (!activeAccountKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const authFilePath of getActiveAccountAuthFileCandidates(accountsDir, activeAccountKey)) {
|
||||||
|
if (!(await fileExists(authFilePath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hasChatgptRefreshToken(await readCodexAuthFile(authFilePath))) {
|
||||||
|
return {
|
||||||
|
codexHome,
|
||||||
|
authFilePath,
|
||||||
|
source: 'accounts',
|
||||||
|
activeAccountKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLegacyAuthSyncMarker(markerPath: string): Promise<LegacyAuthSyncMarker | null> {
|
||||||
|
return readJsonFile<LegacyAuthSyncMarker>(markerPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMtimeMs(filePath: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
return (await fs.stat(filePath)).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFileAtomic(filePath: string, content: string, mode = 0o600): Promise<void> {
|
||||||
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
await fs.chmod(filePath, mode).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCodexLegacyAuthFromActiveAccount(
|
||||||
|
accountsDir = CODEX_ACCOUNTS_DIR
|
||||||
|
): Promise<CodexLegacyAuthCompatibilityResult | null> {
|
||||||
|
const activeAuth = await resolveCodexActiveChatgptAuthFile(accountsDir);
|
||||||
|
if (!activeAuth) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeAuth.source === 'legacy') {
|
||||||
|
return {
|
||||||
|
codexHome: activeAuth.codexHome,
|
||||||
|
authFilePath: activeAuth.authFilePath,
|
||||||
|
source: activeAuth.source,
|
||||||
|
materializedLegacyAuth: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
|
||||||
|
const markerPath = path.join(activeAuth.codexHome, LEGACY_AUTH_SYNC_MARKER_FILE);
|
||||||
|
const [sourceRaw, sourceMtimeMs, legacyMtimeMs, legacyAuthFile, marker] = await Promise.all([
|
||||||
|
fs.readFile(activeAuth.authFilePath, 'utf8'),
|
||||||
|
getMtimeMs(activeAuth.authFilePath),
|
||||||
|
getMtimeMs(legacyAuthFilePath),
|
||||||
|
readCodexAuthFile(legacyAuthFilePath),
|
||||||
|
readLegacyAuthSyncMarker(markerPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const legacyUsable = hasChatgptRefreshToken(legacyAuthFile);
|
||||||
|
const activeAccountChanged =
|
||||||
|
marker?.activeAccountKey !== activeAuth.activeAccountKey ||
|
||||||
|
marker?.sourceAuthFilePath !== activeAuth.authFilePath;
|
||||||
|
const activeAuthNewerThanLegacy =
|
||||||
|
sourceMtimeMs !== null && (legacyMtimeMs === null || sourceMtimeMs > legacyMtimeMs + 1);
|
||||||
|
const shouldMaterialize = !legacyUsable || activeAccountChanged || activeAuthNewerThanLegacy;
|
||||||
|
|
||||||
|
if (shouldMaterialize) {
|
||||||
|
await writeFileAtomic(legacyAuthFilePath, sourceRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFileAtomic(
|
||||||
|
markerPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
activeAccountKey: activeAuth.activeAccountKey,
|
||||||
|
sourceAuthFilePath: activeAuth.authFilePath,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
0o600
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
codexHome: activeAuth.codexHome,
|
||||||
|
authFilePath: legacyAuthFilePath,
|
||||||
|
source: activeAuth.source,
|
||||||
|
materializedLegacyAuth: shouldMaterialize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function detectCodexLocalAccountArtifacts(
|
export async function detectCodexLocalAccountArtifacts(
|
||||||
accountsDir = CODEX_ACCOUNTS_DIR
|
accountsDir = CODEX_ACCOUNTS_DIR
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
|
|
||||||
// Keep userData stable before any integration can initialize Electron storage.
|
// Keep userData stable before any integration can initialize Electron storage.
|
||||||
// Sentry must stay near the top to capture early errors after storage migration.
|
// Sentry must stay near the top to capture early errors after storage migration.
|
||||||
|
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||||
import './sentry';
|
import './sentry';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -166,7 +167,6 @@ import {
|
||||||
markRendererUnavailable,
|
markRendererUnavailable,
|
||||||
safeSendToRenderer,
|
safeSendToRenderer,
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
ActiveTeamRegistry,
|
ActiveTeamRegistry,
|
||||||
|
|
@ -221,6 +221,13 @@ if (
|
||||||
logger.info(
|
logger.info(
|
||||||
`Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}`
|
`Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}`
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
earlyElectronUserDataMigrationResult.reason === 'legacy-reused' &&
|
||||||
|
earlyElectronUserDataMigrationResult.legacyPath
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`Reusing legacy Electron userData at ${earlyElectronUserDataMigrationResult.legacyPath}`
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
earlyElectronUserDataMigrationResult.fallbackToLegacy &&
|
earlyElectronUserDataMigrationResult.fallbackToLegacy &&
|
||||||
earlyElectronUserDataMigrationResult.legacyPath
|
earlyElectronUserDataMigrationResult.legacyPath
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export class ApiKeyService {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
private cache: StoredApiKey[] | null = null;
|
private cache: StoredApiKey[] | null = null;
|
||||||
private aesKey: Buffer | null = null;
|
private aesKey: Buffer | null = null;
|
||||||
|
private readonly reportedDecryptFailures = new Set<string>();
|
||||||
private readonly originalProcessEnv = new Map<string, string | undefined>();
|
private readonly originalProcessEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
constructor(claudeDir?: string) {
|
constructor(claudeDir?: string) {
|
||||||
|
|
@ -288,7 +289,7 @@ export class ApiKeyService {
|
||||||
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
|
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to decrypt API key "${stored.name}":`, err);
|
this.reportDecryptFailure(stored, err);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +314,30 @@ export class ApiKeyService {
|
||||||
return matching.find((key) => key.scope === 'user') ?? null;
|
return matching.find((key) => key.scope === 'user') ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reportDecryptFailure(stored: StoredApiKey, err: unknown): void {
|
||||||
|
const method = this.resolveMethod(stored);
|
||||||
|
const failureKey = [stored.id, stored.updatedAt ?? stored.createdAt, method].join(':');
|
||||||
|
|
||||||
|
if (this.reportedDecryptFailures.has(failureKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportedDecryptFailures.add(failureKey);
|
||||||
|
logger.debug(
|
||||||
|
[
|
||||||
|
'Stored API key could not be decrypted; ignoring it until it is saved again.',
|
||||||
|
`envVarName=${stored.envVarName}`,
|
||||||
|
`method=${method}`,
|
||||||
|
`reason=${this.getErrorMessage(err)}`,
|
||||||
|
].join(' ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(err: unknown): string {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return message.replace(/\s+/g, ' ').trim() || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
// ── AES-256-GCM local encryption ───────────────────────────────────────
|
// ── AES-256-GCM local encryption ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
const REPO_OWNER = '777genius';
|
const REPO_OWNER = '777genius';
|
||||||
const REPO_NAME = 'claude_agent_teams_ui';
|
const REPO_NAME = 'agent-teams-ai';
|
||||||
const PLANNED_REPO_NAME = 'agent-teams-ai';
|
const LEGACY_REPO_NAME = 'claude_agent_teams_ui';
|
||||||
|
|
||||||
export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
|
export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
|
||||||
return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`;
|
return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReleaseAssetBases(version: string): readonly string[] {
|
export function buildReleaseAssetBases(version: string): readonly string[] {
|
||||||
return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, PLANNED_REPO_NAME)];
|
return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, LEGACY_REPO_NAME)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExpectedReleaseAssetUrl(
|
export function getExpectedReleaseAssetUrl(
|
||||||
|
|
@ -20,12 +20,12 @@ export function getExpectedReleaseAssetUrl(
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case 'darwin':
|
case 'darwin':
|
||||||
return arch === 'arm64'
|
return arch === 'arm64'
|
||||||
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg`
|
? `${base}/Agent.Teams.AI-${version}-arm64.dmg`
|
||||||
: `${base}/Claude.Agent.Teams.UI-${version}-x64.dmg`;
|
: `${base}/Agent.Teams.AI-${version}-x64.dmg`;
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
|
return `${base}/Agent.Teams.AI.Setup.${version}.exe`;
|
||||||
case 'linux':
|
case 'linux':
|
||||||
return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`;
|
return `${base}/Agent.Teams.AI-${version}.AppImage`;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -58,11 +58,8 @@ export function getExpectedLatestMacArtifacts(
|
||||||
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
|
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
|
||||||
): readonly string[] {
|
): readonly string[] {
|
||||||
return arch === 'arm64'
|
return arch === 'arm64'
|
||||||
? [
|
? [`Agent.Teams.AI-${version}-arm64-mac.zip`, `Agent.Teams.AI-${version}-arm64.dmg`]
|
||||||
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
|
: [`Agent.Teams.AI-${version}-x64-mac.zip`, `Agent.Teams.AI-${version}-x64.dmg`];
|
||||||
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
|
|
||||||
]
|
|
||||||
: [`Claude.Agent.Teams.UI-${version}-x64-mac.zip`, `Claude.Agent.Teams.UI-${version}-x64.dmg`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripYamlScalar(rawValue: string): string {
|
function stripYamlScalar(rawValue: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
||||||
|
|
@ -70,6 +71,19 @@ const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||||
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
||||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||||
|
const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
|
||||||
|
|
||||||
|
interface CodexCliLoginStatusCheckResult {
|
||||||
|
status: CodexCliLoginStatus;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodexCliLoginStatusChecker = (params: {
|
||||||
|
binaryPath: string | null;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
}) => Promise<CodexCliLoginStatusCheckResult>;
|
||||||
|
|
||||||
function isCodexExecBinary(binaryPath?: string | null): boolean {
|
function isCodexExecBinary(binaryPath?: string | null): boolean {
|
||||||
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
|
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
|
||||||
|
|
@ -119,6 +133,57 @@ function applyCodexForcedLoginMethodEnv(
|
||||||
delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR];
|
delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCodexLoginStatusDetail(detail: string): string {
|
||||||
|
return detail
|
||||||
|
.replace(/sk-[A-Za-z0-9_-]+/g, '[redacted-api-key]')
|
||||||
|
.replace(
|
||||||
|
/"?(access_token|refresh_token|id_token)"?\s*[:=]\s*"?[^"\s,}]+/gi,
|
||||||
|
'$1=[redacted-token]'
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCodexCliLoginStatus({
|
||||||
|
binaryPath,
|
||||||
|
env,
|
||||||
|
}: {
|
||||||
|
binaryPath: string | null;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<CodexCliLoginStatusCheckResult> {
|
||||||
|
const executable = binaryPath?.trim() || 'codex';
|
||||||
|
const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status'];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(
|
||||||
|
executable,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
maxBuffer: 128 * 1024,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
const detail = sanitizeCodexLoginStatusDetail(`${stdout ?? ''}\n${stderr ?? ''}`);
|
||||||
|
if (!error) {
|
||||||
|
resolve({ status: 'logged_in', detail: detail || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/not logged in/i.test(detail)) {
|
||||||
|
resolve({ status: 'not_logged_in', detail: detail || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback =
|
||||||
|
error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null;
|
||||||
|
resolve({ status: 'unknown', detail: detail || fallback || null });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class ProviderConnectionService {
|
export class ProviderConnectionService {
|
||||||
private static instance: ProviderConnectionService | null = null;
|
private static instance: ProviderConnectionService | null = null;
|
||||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||||
|
|
@ -127,7 +192,8 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiKeyService = new ApiKeyService(),
|
private apiKeyService = new ApiKeyService(),
|
||||||
private readonly configManager = ConfigManager.getInstance()
|
private readonly configManager = ConfigManager.getInstance(),
|
||||||
|
private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static getInstance(): ProviderConnectionService {
|
static getInstance(): ProviderConnectionService {
|
||||||
|
|
@ -331,7 +397,7 @@ export class ProviderConnectionService {
|
||||||
async getConfiguredConnectionIssue(
|
async getConfiguredConnectionIssue(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
_runtimeBackendOverride?: string | null
|
runtimeBackendOverride?: string | null
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||||
|
|
@ -358,6 +424,8 @@ export class ProviderConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||||
|
const runtimeEnv = { ...env };
|
||||||
|
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
preferredAuthMode: snapshot.preferredAuthMode,
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
managedAccount: snapshot.managedAccount,
|
managedAccount: snapshot.managedAccount,
|
||||||
|
|
@ -368,7 +436,41 @@ export class ProviderConnectionService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (readiness.launchAllowed) {
|
if (readiness.launchAllowed) {
|
||||||
return null;
|
if (
|
||||||
|
readiness.effectiveAuthMode !== 'chatgpt' ||
|
||||||
|
this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) !== CODEX_NATIVE_BACKEND_ID
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.appServerState === 'healthy' && snapshot.managedAccount?.type === 'chatgpt') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete runtimeEnv.OPENAI_API_KEY;
|
||||||
|
delete runtimeEnv[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||||
|
applyCodexForcedLoginMethodEnv(runtimeEnv, 'chatgpt');
|
||||||
|
|
||||||
|
const loginStatus = await this.codexCliLoginStatusChecker({
|
||||||
|
binaryPath: snapshot.runtimeContext?.binaryPath?.trim() || null,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
if (loginStatus.status === 'logged_in') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base =
|
||||||
|
loginStatus.status === 'not_logged_in'
|
||||||
|
? 'Codex ChatGPT account mode is selected, but the Codex CLI login status is not active for the launch runtime.'
|
||||||
|
: 'Codex ChatGPT account mode is selected, but the Codex CLI login status could not be verified for the launch runtime.';
|
||||||
|
const reconnectHint = snapshot.localActiveChatgptAccountPresent
|
||||||
|
? 'Reconnect ChatGPT to refresh the current Codex subscription session.'
|
||||||
|
: snapshot.localAccountArtifactsPresent
|
||||||
|
? 'Local Codex account data exists, but the launch runtime cannot use it. Reconnect ChatGPT.'
|
||||||
|
: 'Connect ChatGPT again or switch Codex auth mode to API key.';
|
||||||
|
return `${base} ${reconnectHint}${
|
||||||
|
loginStatus.detail ? ` Details: ${loginStatus.detail}` : ''
|
||||||
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (readiness.state === 'missing_auth') {
|
if (readiness.state === 'missing_auth') {
|
||||||
|
|
|
||||||
217
src/main/services/team/ProcessBootstrapTransportEvidence.ts
Normal file
217
src/main/services/team/ProcessBootstrapTransportEvidence.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import type { PersistedTeamLaunchPhase } from '@shared/types';
|
||||||
|
|
||||||
|
export type ProcessBootstrapTransportEvent = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ProcessBootstrapTransportTerminalKind =
|
||||||
|
| 'non_retryable_submit_rejection'
|
||||||
|
| 'accepted_without_message_id'
|
||||||
|
| 'process_exited_before_confirmation'
|
||||||
|
| 'runtime_failed_before_confirmation';
|
||||||
|
|
||||||
|
export interface ProcessBootstrapTransportSummary {
|
||||||
|
lastStage?: string;
|
||||||
|
lastObservedAt?: string;
|
||||||
|
submitted: boolean;
|
||||||
|
hasProgress: boolean;
|
||||||
|
terminalFailure?: {
|
||||||
|
kind: ProcessBootstrapTransportTerminalKind;
|
||||||
|
reason: string;
|
||||||
|
observedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProcessBootstrapTransportProjectionPhase = 'active' | 'final';
|
||||||
|
|
||||||
|
// These helpers intentionally summarize process transport only. They explain
|
||||||
|
// where bootstrap got stuck, but never prove teammate readiness by themselves.
|
||||||
|
const MAX_TRANSPORT_DETAIL_CHARS = 500;
|
||||||
|
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||||
|
'con',
|
||||||
|
'prn',
|
||||||
|
'aux',
|
||||||
|
'nul',
|
||||||
|
'com1',
|
||||||
|
'com2',
|
||||||
|
'com3',
|
||||||
|
'com4',
|
||||||
|
'com5',
|
||||||
|
'com6',
|
||||||
|
'com7',
|
||||||
|
'com8',
|
||||||
|
'com9',
|
||||||
|
'lpt1',
|
||||||
|
'lpt2',
|
||||||
|
'lpt3',
|
||||||
|
'lpt4',
|
||||||
|
'lpt5',
|
||||||
|
'lpt6',
|
||||||
|
'lpt7',
|
||||||
|
'lpt8',
|
||||||
|
'lpt9',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSPORT_STAGE_LABELS: Record<string, string> = {
|
||||||
|
process_spawned: 'process spawned',
|
||||||
|
stdout_attached: 'stdout attached',
|
||||||
|
cli_started: 'CLI started',
|
||||||
|
runtime_ready: 'runtime ready',
|
||||||
|
inbox_poller_ready: 'inbox poller ready',
|
||||||
|
mailbox_bootstrap_written: 'bootstrap mailbox row written',
|
||||||
|
bootstrap_prompt_observed: 'bootstrap prompt observed',
|
||||||
|
bootstrap_submit_attempted: 'bootstrap submit attempted',
|
||||||
|
bootstrap_submit_deferred: 'bootstrap submit deferred',
|
||||||
|
bootstrap_submit_rejected: 'bootstrap submit rejected',
|
||||||
|
bootstrap_submit_accepted_without_uuid: 'bootstrap submit accepted without message id',
|
||||||
|
bootstrap_submitted: 'bootstrap submitted',
|
||||||
|
failed: 'runtime failed',
|
||||||
|
exited: 'runtime exited',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitizeProcessRuntimeEventFilePrefix(value: string): string {
|
||||||
|
const normalized = String(value)
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
const normalizedStem =
|
||||||
|
normalized
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
.split('.')[0] ?? normalized;
|
||||||
|
return normalizedStem && WINDOWS_RESERVED_BASENAMES.has(normalizedStem)
|
||||||
|
? `_${normalized}`
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProcessTransportProjectionPhase(input: {
|
||||||
|
launchPhase: PersistedTeamLaunchPhase;
|
||||||
|
finalTimeoutReached?: boolean;
|
||||||
|
}): ProcessBootstrapTransportProjectionPhase {
|
||||||
|
if (input.launchPhase !== 'active') {
|
||||||
|
return 'final';
|
||||||
|
}
|
||||||
|
return input.finalTimeoutReached === true ? 'final' : 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeProcessBootstrapTransportDetail(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const sanitized = value
|
||||||
|
.replace(/\b(sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{32,})\b/g, '[redacted]')
|
||||||
|
.replace(/\/[^\s"'`]+/g, '[path]')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, MAX_TRANSPORT_DETAIL_CHARS);
|
||||||
|
return sanitized.length > 0 ? sanitized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventType(event: ProcessBootstrapTransportEvent): string {
|
||||||
|
return typeof event.type === 'string' ? event.type : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimestamp(event: ProcessBootstrapTransportEvent): string | undefined {
|
||||||
|
return typeof event.timestamp === 'string' && Number.isFinite(Date.parse(event.timestamp))
|
||||||
|
? event.timestamp
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(event: ProcessBootstrapTransportEvent): string | undefined {
|
||||||
|
const type = eventType(event);
|
||||||
|
const label = TRANSPORT_STAGE_LABELS[type];
|
||||||
|
if (!label) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const detail = sanitizeProcessBootstrapTransportDetail(event.detail);
|
||||||
|
if (type === 'process_spawned' || type === 'stdout_attached' || type === 'cli_started') {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
return detail ? `${label}: ${detail}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalFailureForEvent(
|
||||||
|
event: ProcessBootstrapTransportEvent
|
||||||
|
): ProcessBootstrapTransportSummary['terminalFailure'] | undefined {
|
||||||
|
const type = eventType(event);
|
||||||
|
const label = stageLabel(event);
|
||||||
|
const observedAt = eventTimestamp(event);
|
||||||
|
if (type === 'failed') {
|
||||||
|
return {
|
||||||
|
kind: 'runtime_failed_before_confirmation',
|
||||||
|
reason: label ?? 'runtime failed before bootstrap confirmation',
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'exited') {
|
||||||
|
return {
|
||||||
|
kind: 'process_exited_before_confirmation',
|
||||||
|
reason: label ?? 'runtime exited before bootstrap confirmation',
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'bootstrap_submit_accepted_without_uuid') {
|
||||||
|
return {
|
||||||
|
kind: 'accepted_without_message_id',
|
||||||
|
reason: label ?? 'bootstrap submit accepted without message id',
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'bootstrap_submit_rejected' && event.retryable === false) {
|
||||||
|
return {
|
||||||
|
kind: 'non_retryable_submit_rejection',
|
||||||
|
reason: label ?? 'bootstrap submit rejected',
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeProcessBootstrapTransportEvents(
|
||||||
|
events: readonly ProcessBootstrapTransportEvent[]
|
||||||
|
): ProcessBootstrapTransportSummary | null {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let lastStage: string | undefined;
|
||||||
|
let lastObservedAt: string | undefined;
|
||||||
|
let submitted = false;
|
||||||
|
let terminalFailure: ProcessBootstrapTransportSummary['terminalFailure'];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const label = stageLabel(event);
|
||||||
|
if (!label) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastStage = label;
|
||||||
|
lastObservedAt = eventTimestamp(event) ?? lastObservedAt;
|
||||||
|
if (eventType(event) === 'bootstrap_submitted') {
|
||||||
|
submitted = true;
|
||||||
|
}
|
||||||
|
terminalFailure = terminalFailureForEvent(event) ?? terminalFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastStage && !terminalFailure) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(lastStage ? { lastStage } : {}),
|
||||||
|
...(lastObservedAt ? { lastObservedAt } : {}),
|
||||||
|
submitted,
|
||||||
|
hasProgress: Boolean(lastStage),
|
||||||
|
...(terminalFailure ? { terminalFailure } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProcessBootstrapPendingDiagnostic(
|
||||||
|
summary: ProcessBootstrapTransportSummary
|
||||||
|
): string {
|
||||||
|
return summary.lastStage
|
||||||
|
? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.`
|
||||||
|
: 'Bootstrap transport is waiting for bootstrap confirmation.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProcessBootstrapTimeoutDiagnostic(
|
||||||
|
summary: ProcessBootstrapTransportSummary
|
||||||
|
): string {
|
||||||
|
return summary.lastStage
|
||||||
|
? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
|
||||||
|
: 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { isProcessAlive } from '@main/utils/processHealth';
|
import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
|
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||||
import {
|
import {
|
||||||
|
|
@ -156,6 +157,15 @@ import {
|
||||||
parseBootstrapRuntimeProofDetail,
|
parseBootstrapRuntimeProofDetail,
|
||||||
validateBootstrapRuntimeProofEnvelope,
|
validateBootstrapRuntimeProofEnvelope,
|
||||||
} from './bootstrap/BootstrapProofValidation';
|
} from './bootstrap/BootstrapProofValidation';
|
||||||
|
import {
|
||||||
|
buildProcessBootstrapPendingDiagnostic,
|
||||||
|
buildProcessBootstrapTimeoutDiagnostic,
|
||||||
|
deriveProcessTransportProjectionPhase,
|
||||||
|
sanitizeProcessRuntimeEventFilePrefix,
|
||||||
|
summarizeProcessBootstrapTransportEvents,
|
||||||
|
type ProcessBootstrapTransportEvent,
|
||||||
|
type ProcessBootstrapTransportSummary,
|
||||||
|
} from './ProcessBootstrapTransportEvidence';
|
||||||
import {
|
import {
|
||||||
buildNativeAppManagedBootstrapSpecs,
|
buildNativeAppManagedBootstrapSpecs,
|
||||||
type NativeAppManagedBootstrapSpec,
|
type NativeAppManagedBootstrapSpec,
|
||||||
|
|
@ -372,11 +382,46 @@ interface LaunchStateWriteResult {
|
||||||
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
||||||
|
|
||||||
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
|
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
|
||||||
|
const BOOTSTRAP_RUNTIME_EVENT_MAX_LINES = 256;
|
||||||
|
const BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES = 16 * 1024;
|
||||||
|
|
||||||
function sanitizeRuntimeEventFilePrefix(value: string): string {
|
function getTeamRuntimeEventsDir(teamName: string): string {
|
||||||
return String(value || 'default')
|
return path.join(getTeamsBasePath(), teamName, 'runtime');
|
||||||
.replace(/[^a-zA-Z0-9]/g, '-')
|
}
|
||||||
.toLowerCase();
|
|
||||||
|
function isProcessBootstrapTransportDiagnostic(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(value.startsWith('Bootstrap transport ') ||
|
||||||
|
value.includes('Last transport stage:') ||
|
||||||
|
value.startsWith('bootstrap submit ') ||
|
||||||
|
value.startsWith('runtime failed') ||
|
||||||
|
value.startsWith('runtime exited'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realpathIfExists(inputPath: string): string | null {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync.native(inputPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContainedTeamRuntimeEventsPath(teamName: string, candidatePath: string): boolean {
|
||||||
|
const runtimeDir = getTeamRuntimeEventsDir(teamName);
|
||||||
|
const resolvedRuntimeDir = path.resolve(runtimeDir);
|
||||||
|
const resolvedCandidate = path.resolve(candidatePath);
|
||||||
|
if (!isPathWithinRoot(resolvedCandidate, resolvedRuntimeDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realRuntimeDir = realpathIfExists(resolvedRuntimeDir);
|
||||||
|
const realCandidate = realpathIfExists(resolvedCandidate);
|
||||||
|
if (realCandidate) {
|
||||||
|
return isPathWithinRoot(realCandidate, realRuntimeDir ?? resolvedRuntimeDir);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapTranscriptOutcome =
|
type BootstrapTranscriptOutcome =
|
||||||
|
|
@ -1543,6 +1588,12 @@ function looksLikeClaudeStdoutJsonFragment(text: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS = 12_000;
|
||||||
|
|
||||||
|
function isTerminalFailureProvisioningState(state: TeamProvisioningProgress['state']): boolean {
|
||||||
|
return state === 'failed' || state === 'cancelled' || state === 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
interface ProvisioningRun {
|
interface ProvisioningRun {
|
||||||
runId: string;
|
runId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -20660,7 +20711,14 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
const shouldPreserveProcessBootstrapTransportDiagnostic =
|
||||||
|
current.bootstrapConfirmed !== true &&
|
||||||
|
(current.launchState === 'runtime_pending_bootstrap' ||
|
||||||
|
current.launchState === 'failed_to_start') &&
|
||||||
|
isProcessBootstrapTransportDiagnostic(current.runtimeDiagnostic);
|
||||||
|
const runtimeDiagnostic = shouldPreserveProcessBootstrapTransportDiagnostic
|
||||||
|
? current.runtimeDiagnostic
|
||||||
|
: buildRuntimeDiagnosticForSpawn(metadata);
|
||||||
const metadataLivenessKind =
|
const metadataLivenessKind =
|
||||||
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
|
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
|
||||||
? metadata.livenessKind === 'runtime_process' ||
|
? metadata.livenessKind === 'runtime_process' ||
|
||||||
|
|
@ -20673,9 +20731,11 @@ export class TeamProvisioningService {
|
||||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||||
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
|
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
|
||||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
...(metadata.runtimeDiagnosticSeverity
|
...(shouldPreserveProcessBootstrapTransportDiagnostic
|
||||||
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity }
|
||||||
: {}),
|
: metadata.runtimeDiagnosticSeverity
|
||||||
|
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||||
|
: {}),
|
||||||
livenessLastCheckedAt: nowIso(),
|
livenessLastCheckedAt: nowIso(),
|
||||||
};
|
};
|
||||||
const failureReason = current.hardFailureReason ?? current.error;
|
const failureReason = current.hardFailureReason ?? current.error;
|
||||||
|
|
@ -21583,6 +21643,28 @@ export class TeamProvisioningService {
|
||||||
processTableAvailable: memberProcessTableAvailable,
|
processTableAvailable: memberProcessTableAvailable,
|
||||||
nowIso: nowIso(),
|
nowIso: nowIso(),
|
||||||
});
|
});
|
||||||
|
const bootstrapTransportDiagnostic =
|
||||||
|
status?.runtimeDiagnostic ?? launchMember?.runtimeDiagnostic;
|
||||||
|
const bootstrapTransportDiagnosticSeverity =
|
||||||
|
status?.runtimeDiagnosticSeverity ?? launchMember?.runtimeDiagnosticSeverity;
|
||||||
|
const bootstrapTransportLaunchState = status?.launchState ?? launchMember?.launchState;
|
||||||
|
const bootstrapTransportConfirmed =
|
||||||
|
status?.bootstrapConfirmed === true || launchMember?.bootstrapConfirmed === true;
|
||||||
|
const hasProcessBootstrapTransportDiagnostic =
|
||||||
|
(metadata.backendType === 'process' || metadata.tmuxPaneId?.startsWith('process:')) &&
|
||||||
|
!bootstrapTransportConfirmed &&
|
||||||
|
(bootstrapTransportLaunchState === 'runtime_pending_bootstrap' ||
|
||||||
|
bootstrapTransportLaunchState === 'failed_to_start') &&
|
||||||
|
isProcessBootstrapTransportDiagnostic(bootstrapTransportDiagnostic);
|
||||||
|
// Prefer bootstrap transport diagnostics over generic pid/liveness text
|
||||||
|
// while launch is unconfirmed, otherwise the UI hides the exact stage
|
||||||
|
// where process bootstrap got stuck.
|
||||||
|
const runtimeDiagnostic = hasProcessBootstrapTransportDiagnostic
|
||||||
|
? bootstrapTransportDiagnostic
|
||||||
|
: resolved.runtimeDiagnostic;
|
||||||
|
const runtimeDiagnosticSeverity = hasProcessBootstrapTransportDiagnostic
|
||||||
|
? (bootstrapTransportDiagnosticSeverity ?? resolved.runtimeDiagnosticSeverity)
|
||||||
|
: resolved.runtimeDiagnosticSeverity;
|
||||||
metadataByMember.set(memberName, {
|
metadataByMember.set(memberName, {
|
||||||
...metadata,
|
...metadata,
|
||||||
alive: resolved.alive,
|
alive: resolved.alive,
|
||||||
|
|
@ -21599,9 +21681,11 @@ export class TeamProvisioningService {
|
||||||
...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}),
|
...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}),
|
||||||
...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}),
|
...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}),
|
||||||
...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}),
|
...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}),
|
||||||
runtimeDiagnostic: resolved.runtimeDiagnostic,
|
runtimeDiagnostic,
|
||||||
runtimeDiagnosticSeverity: resolved.runtimeDiagnosticSeverity,
|
runtimeDiagnosticSeverity,
|
||||||
diagnostics: resolved.diagnostics,
|
diagnostics: hasProcessBootstrapTransportDiagnostic
|
||||||
|
? mergeRuntimeDiagnostics(resolved.diagnostics, [bootstrapTransportDiagnostic])
|
||||||
|
: resolved.diagnostics,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21659,13 +21743,43 @@ export class TeamProvisioningService {
|
||||||
return rssBytesByPid;
|
return rssBytesByPid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearPersistedLaunchState(teamName: string): Promise<void> {
|
private async clearPersistedLaunchState(
|
||||||
|
teamName: string,
|
||||||
|
options?: { expectedRunId?: string }
|
||||||
|
): Promise<void> {
|
||||||
await this.enqueueLaunchStateStoreOperation(teamName, () =>
|
await this.enqueueLaunchStateStoreOperation(teamName, () =>
|
||||||
this.clearPersistedLaunchStateNow(teamName)
|
this.clearPersistedLaunchStateNow(teamName, options)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearPersistedLaunchStateNow(teamName: string): Promise<void> {
|
private canClearPersistedLaunchStateForRun(
|
||||||
|
teamName: string,
|
||||||
|
expectedRunId: string | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!expectedRunId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const trackedRunId = this.getTrackedRunId(teamName);
|
||||||
|
if (trackedRunId && trackedRunId !== expectedRunId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lastWrittenRunId = this.launchStateWrittenRunIdByTeam.get(teamName);
|
||||||
|
if (lastWrittenRunId && lastWrittenRunId !== expectedRunId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearPersistedLaunchStateNow(
|
||||||
|
teamName: string,
|
||||||
|
options?: { expectedRunId?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.canClearPersistedLaunchStateForRun(teamName, options?.expectedRunId)) {
|
||||||
|
logger.debug(
|
||||||
|
`[${teamName}] Skipping stale launch-state clear for run ${options?.expectedRunId}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.launchStateStore.clear(teamName);
|
await this.launchStateStore.clear(teamName);
|
||||||
this.launchStateWrittenRunIdByTeam.delete(teamName);
|
this.launchStateWrittenRunIdByTeam.delete(teamName);
|
||||||
await clearBootstrapState(teamName);
|
await clearBootstrapState(teamName);
|
||||||
|
|
@ -22512,6 +22626,130 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleDeterministicBootstrapCompletionRecovery(run: ProvisioningRun): void {
|
||||||
|
if (!run.deterministicBootstrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
void this.recoverDeterministicBootstrapCompletion(run).catch((error: unknown) => {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Failed to recover completed deterministic bootstrap state: ${getErrorMessage(
|
||||||
|
error
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS);
|
||||||
|
handle.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recoverDeterministicBootstrapCompletion(run: ProvisioningRun): Promise<void> {
|
||||||
|
if (
|
||||||
|
!run.provisioningComplete ||
|
||||||
|
run.cancelRequested ||
|
||||||
|
run.processKilled ||
|
||||||
|
isTerminalFailureProvisioningState(run.progress.state) ||
|
||||||
|
this.isProvisioningRunPromotedToAlive(run) ||
|
||||||
|
this.provisioningRunByTeam.get(run.teamName) !== run.runId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((run.mixedSecondaryLanes ?? []).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await readBootstrapLaunchSnapshot(run.teamName).catch(() => null);
|
||||||
|
if (
|
||||||
|
!snapshot ||
|
||||||
|
(snapshot.launchPhase !== 'finished' && snapshot.launchPhase !== 'reconciled')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runStartedAtMs = Date.parse(run.startedAt);
|
||||||
|
const snapshotUpdatedAtMs = Date.parse(snapshot.updatedAt);
|
||||||
|
if (
|
||||||
|
Number.isFinite(runStartedAtMs) &&
|
||||||
|
Number.isFinite(snapshotUpdatedAtMs) &&
|
||||||
|
snapshotUpdatedAtMs < runStartedAtMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberNames = this.getPersistedLaunchMemberNames(snapshot);
|
||||||
|
if (memberNames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncRunMemberSpawnStatusesFromSnapshot(run, snapshot);
|
||||||
|
await this.writeLaunchStateSnapshot(run.teamName, snapshot).catch((error: unknown) => {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Failed to persist recovered deterministic bootstrap snapshot: ${getErrorMessage(
|
||||||
|
error
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedSpawnMembers = memberNames
|
||||||
|
.filter((memberName) => snapshot.members[memberName]?.launchState === 'failed_to_start')
|
||||||
|
.map((memberName) => ({
|
||||||
|
name: memberName,
|
||||||
|
error: snapshot.members[memberName]?.hardFailureReason,
|
||||||
|
updatedAt: snapshot.members[memberName]?.lastEvaluatedAt ?? nowIso(),
|
||||||
|
}));
|
||||||
|
const launchSummary = snapshot.summary ?? this.getMemberLaunchSummary(run);
|
||||||
|
const hasSpawnFailures = failedSpawnMembers.length > 0;
|
||||||
|
const hasPendingBootstrap =
|
||||||
|
!hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, snapshot);
|
||||||
|
const messagePrefix = run.isLaunch ? 'Launch completed' : 'Team provisioned';
|
||||||
|
const readyMessage = hasSpawnFailures
|
||||||
|
? `${messagePrefix} with teammate errors - ${failedSpawnMembers
|
||||||
|
.map((member) => member.name)
|
||||||
|
.join(', ')} failed to start`
|
||||||
|
: hasPendingBootstrap
|
||||||
|
? this.buildAggregatePendingLaunchMessage(messagePrefix, run, launchSummary, snapshot)
|
||||||
|
: run.isLaunch
|
||||||
|
? 'Team launched - process alive and ready'
|
||||||
|
: 'Team provisioned - process alive and ready';
|
||||||
|
|
||||||
|
const progress = updateProgress(run, 'ready', readyMessage, {
|
||||||
|
cliLogsTail: extractCliLogsFromRun(run),
|
||||||
|
messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined,
|
||||||
|
});
|
||||||
|
run.onProgress(progress);
|
||||||
|
this.provisioningRunByTeam.delete(run.teamName);
|
||||||
|
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Recovered ready state from completed deterministic bootstrap snapshot after post-bootstrap finalization delay.`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'lead-message',
|
||||||
|
teamName: run.teamName,
|
||||||
|
runId: run.runId,
|
||||||
|
detail: 'lead-session-sync',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasSpawnFailures && !hasPendingBootstrap) {
|
||||||
|
void this.fireTeamLaunchedNotification(run);
|
||||||
|
} else if (hasSpawnFailures) {
|
||||||
|
void this.fireTeamLaunchIncompleteNotification(
|
||||||
|
run,
|
||||||
|
failedSpawnMembers,
|
||||||
|
launchSummary,
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProvisioningRunPromotedToAlive(run: ProvisioningRun): boolean {
|
||||||
|
return (
|
||||||
|
this.aliveRunByTeam.get(run.teamName) === run.runId &&
|
||||||
|
this.provisioningRunByTeam.get(run.teamName) !== run.runId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private syncRunMemberSpawnStatusesFromSnapshot(
|
private syncRunMemberSpawnStatusesFromSnapshot(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
snapshot: PersistedTeamLaunchSnapshot
|
snapshot: PersistedTeamLaunchSnapshot
|
||||||
|
|
@ -22995,7 +23233,7 @@ export class TeamProvisioningService {
|
||||||
const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase);
|
const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
if (run.isLaunch) {
|
if (run.isLaunch) {
|
||||||
await this.clearPersistedLaunchStateNow(run.teamName);
|
await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -23004,7 +23242,7 @@ export class TeamProvisioningService {
|
||||||
const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers);
|
const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers);
|
||||||
|
|
||||||
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
|
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
|
||||||
await this.clearPersistedLaunchStateNow(run.teamName);
|
await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
|
||||||
this.invalidateRuntimeSnapshotCaches(run.teamName);
|
this.invalidateRuntimeSnapshotCaches(run.teamName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -23943,11 +24181,11 @@ export class TeamProvisioningService {
|
||||||
runtimeMember: PersistedRuntimeMemberLike | undefined
|
runtimeMember: PersistedRuntimeMemberLike | undefined
|
||||||
): string {
|
): string {
|
||||||
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
|
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
|
||||||
if (configuredPath) {
|
if (configuredPath && isContainedTeamRuntimeEventsPath(teamName, configuredPath)) {
|
||||||
return configuredPath;
|
return configuredPath;
|
||||||
}
|
}
|
||||||
const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
|
const filePrefix = sanitizeProcessRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
|
||||||
return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`);
|
return path.join(getTeamRuntimeEventsDir(teamName), `${filePrefix}.runtime.jsonl`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readRuntimeBootstrapProofEvents(
|
private async readRuntimeBootstrapProofEvents(
|
||||||
|
|
@ -23955,6 +24193,10 @@ export class TeamProvisioningService {
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
let handle: fs.promises.FileHandle | null = null;
|
let handle: fs.promises.FileHandle | null = null;
|
||||||
try {
|
try {
|
||||||
|
const pathStat = await fs.promises.lstat(eventsPath);
|
||||||
|
if (!pathStat.isFile()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
handle = await fs.promises.open(eventsPath, 'r');
|
handle = await fs.promises.open(eventsPath, 'r');
|
||||||
const stat = await handle.stat();
|
const stat = await handle.stat();
|
||||||
if (!stat.isFile() || stat.size <= 0) {
|
if (!stat.isFile() || stat.size <= 0) {
|
||||||
|
|
@ -23970,10 +24212,16 @@ export class TeamProvisioningService {
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
lines.shift();
|
lines.shift();
|
||||||
}
|
}
|
||||||
|
if (lines.length > BOOTSTRAP_RUNTIME_EVENT_MAX_LINES) {
|
||||||
|
lines.splice(0, lines.length - BOOTSTRAP_RUNTIME_EVENT_MAX_LINES);
|
||||||
|
}
|
||||||
const events: Record<string, unknown>[] = [];
|
const events: Record<string, unknown>[] = [];
|
||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
if (Buffer.byteLength(line, 'utf8') > BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line) as unknown;
|
const parsed = JSON.parse(line) as unknown;
|
||||||
if (
|
if (
|
||||||
|
|
@ -24077,6 +24325,216 @@ export class TeamProvisioningService {
|
||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isRuntimeBootstrapTransportEventCurrent(input: {
|
||||||
|
event: Record<string, unknown>;
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
runtimeMember?: PersistedRuntimeMemberLike;
|
||||||
|
expectedPid?: number;
|
||||||
|
expectedBootstrapRunId?: string;
|
||||||
|
boundaryMs: number;
|
||||||
|
}): boolean {
|
||||||
|
const { event, teamName, memberName, runtimeMember, expectedPid, expectedBootstrapRunId } =
|
||||||
|
input;
|
||||||
|
const eventTeamName = typeof event.teamName === 'string' ? event.teamName.trim() : '';
|
||||||
|
if (eventTeamName && eventTeamName !== teamName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : '';
|
||||||
|
const expectedAgentId = runtimeMember?.agentId?.trim() ?? '';
|
||||||
|
if (eventAgentId && expectedAgentId && eventAgentId !== expectedAgentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
|
||||||
|
const runtimeName = runtimeMember?.name?.trim() ?? '';
|
||||||
|
if (
|
||||||
|
eventAgentName &&
|
||||||
|
!matchesMemberNameOrBase(eventAgentName, memberName) &&
|
||||||
|
!(runtimeName && matchesTeamMemberIdentity(eventAgentName, runtimeName))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventBootstrapRunId =
|
||||||
|
typeof event.bootstrapRunId === 'string' ? event.bootstrapRunId.trim() : '';
|
||||||
|
if (
|
||||||
|
expectedBootstrapRunId &&
|
||||||
|
eventBootstrapRunId &&
|
||||||
|
eventBootstrapRunId !== expectedBootstrapRunId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventPid = typeof event.pid === 'number' && Number.isFinite(event.pid) ? event.pid : NaN;
|
||||||
|
if (typeof expectedPid === 'number' && expectedPid > 0 && eventPid !== expectedPid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(input.boundaryMs)) {
|
||||||
|
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
||||||
|
const timestampMs = Date.parse(timestamp);
|
||||||
|
if (!Number.isFinite(timestampMs) || timestampMs < input.boundaryMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readProcessBootstrapTransportSummary(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
member: PersistedTeamLaunchMemberState;
|
||||||
|
}): Promise<ProcessBootstrapTransportSummary | null> {
|
||||||
|
const { teamName, memberName, member } = input;
|
||||||
|
const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName);
|
||||||
|
const memberRecord = member as unknown as Record<string, unknown>;
|
||||||
|
const runtimeBackendType =
|
||||||
|
runtimeMember?.backendType?.trim() ||
|
||||||
|
(typeof memberRecord.backendType === 'string' ? memberRecord.backendType.trim() : '');
|
||||||
|
const processPaneId =
|
||||||
|
runtimeMember?.tmuxPaneId?.trim() ||
|
||||||
|
(typeof memberRecord.tmuxPaneId === 'string' ? memberRecord.tmuxPaneId.trim() : '');
|
||||||
|
if (runtimeBackendType !== 'process' && !processPaneId?.startsWith('process:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
|
||||||
|
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
|
||||||
|
const expectedPid =
|
||||||
|
typeof member.runtimePid === 'number' && member.runtimePid > 0
|
||||||
|
? member.runtimePid
|
||||||
|
: typeof runtimeMember?.runtimePid === 'number' && runtimeMember.runtimePid > 0
|
||||||
|
? runtimeMember.runtimePid
|
||||||
|
: undefined;
|
||||||
|
const expectedBootstrapRunId =
|
||||||
|
runtimeMember?.bootstrapRunId?.trim() ||
|
||||||
|
(typeof member.runtimeRunId === 'string' ? member.runtimeRunId.trim() : '') ||
|
||||||
|
(typeof memberRecord.bootstrapRunId === 'string' ? memberRecord.bootstrapRunId.trim() : '');
|
||||||
|
if (!expectedBootstrapRunId && !Number.isFinite(boundaryMs) && !expectedPid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember);
|
||||||
|
// Runtime event paths are persisted by process teammates. Keep both path
|
||||||
|
// containment and payload identity checks so stale or foreign JSONL cannot
|
||||||
|
// affect another team/member launch.
|
||||||
|
if (!isContainedTeamRuntimeEventsPath(teamName, eventsPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const events = await this.readRuntimeBootstrapProofEvents(eventsPath);
|
||||||
|
const currentEvents = events.filter((event) =>
|
||||||
|
this.isRuntimeBootstrapTransportEventCurrent({
|
||||||
|
event,
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
runtimeMember,
|
||||||
|
expectedPid,
|
||||||
|
expectedBootstrapRunId,
|
||||||
|
boundaryMs,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return summarizeProcessBootstrapTransportEvents(
|
||||||
|
currentEvents as ProcessBootstrapTransportEvent[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyProcessBootstrapTransportOverlay(input: {
|
||||||
|
member: PersistedTeamLaunchMemberState;
|
||||||
|
summary: ProcessBootstrapTransportSummary | null;
|
||||||
|
launchPhase: PersistedTeamLaunchPhase;
|
||||||
|
finalTimeoutReached?: boolean;
|
||||||
|
}): PersistedTeamLaunchMemberState {
|
||||||
|
const { member, summary } = input;
|
||||||
|
if (
|
||||||
|
!summary ||
|
||||||
|
member.bootstrapConfirmed ||
|
||||||
|
member.launchState === 'confirmed_alive' ||
|
||||||
|
member.launchState === 'skipped_for_launch' ||
|
||||||
|
member.launchState === 'runtime_pending_permission' ||
|
||||||
|
member.skippedForLaunch === true
|
||||||
|
) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
const existingFailure = member.hardFailureReason ?? member.runtimeDiagnostic;
|
||||||
|
if (
|
||||||
|
member.launchState === 'failed_to_start' &&
|
||||||
|
member.hardFailure === true &&
|
||||||
|
!isAutoClearableLaunchFailureReason(existingFailure)
|
||||||
|
) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectionPhase = deriveProcessTransportProjectionPhase({
|
||||||
|
launchPhase: input.launchPhase,
|
||||||
|
finalTimeoutReached: input.finalTimeoutReached,
|
||||||
|
});
|
||||||
|
const base: PersistedTeamLaunchMemberState = {
|
||||||
|
...member,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
lastEvaluatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (summary.terminalFailure) {
|
||||||
|
// Terminal transport events are failures for bootstrap only. They are
|
||||||
|
// surfaced as hard failure text, but still do not create readiness proof.
|
||||||
|
const reason = summary.terminalFailure.reason;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: reason,
|
||||||
|
runtimeDiagnostic: reason,
|
||||||
|
runtimeDiagnosticSeverity: 'error',
|
||||||
|
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]),
|
||||||
|
sources: {
|
||||||
|
...(base.sources ?? {}),
|
||||||
|
hardFailureSignal: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.hasProgress) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectionPhase === 'final') {
|
||||||
|
const reason = buildProcessBootstrapTimeoutDiagnostic(summary);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: reason,
|
||||||
|
runtimeDiagnostic: reason,
|
||||||
|
runtimeDiagnosticSeverity: 'error',
|
||||||
|
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]),
|
||||||
|
sources: {
|
||||||
|
...(base.sources ?? {}),
|
||||||
|
hardFailureSignal: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeDiagnostic = buildProcessBootstrapPendingDiagnostic(summary);
|
||||||
|
// Active launch progress remains pending. A submitted bootstrap prompt is
|
||||||
|
// not enough for confirmed_alive; durable bootstrap proof is handled by
|
||||||
|
// the runtime/transcript evidence path above.
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: summary.submitted ? 'info' : 'warning',
|
||||||
|
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [
|
||||||
|
runtimeDiagnostic,
|
||||||
|
summary.lastStage,
|
||||||
|
]),
|
||||||
|
sources: {
|
||||||
|
...(base.sources ?? {}),
|
||||||
|
hardFailureSignal: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async applyBootstrapTranscriptEvidenceOverlay(
|
private async applyBootstrapTranscriptEvidenceOverlay(
|
||||||
snapshot: PersistedTeamLaunchSnapshot | null
|
snapshot: PersistedTeamLaunchSnapshot | null
|
||||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||||
|
|
@ -24376,7 +24834,7 @@ export class TeamProvisioningService {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
for (const expected of persistedMemberNames) {
|
for (const expected of persistedMemberNames) {
|
||||||
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
||||||
const current = nextMembers[expected] ?? {
|
let current = nextMembers[expected] ?? {
|
||||||
name: expected,
|
name: expected,
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
agentToolAccepted: false,
|
agentToolAccepted: false,
|
||||||
|
|
@ -24514,9 +24972,19 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const graceExpired =
|
const graceExpired =
|
||||||
current.agentToolAccepted === true &&
|
Number.isFinite(acceptedAtMs) && Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
|
||||||
Number.isFinite(acceptedAtMs) &&
|
if (!isOpenCodeSecondaryLaneMember) {
|
||||||
Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
|
current = this.applyProcessBootstrapTransportOverlay({
|
||||||
|
member: current,
|
||||||
|
summary: await this.readProcessBootstrapTransportSummary({
|
||||||
|
teamName,
|
||||||
|
memberName: expected,
|
||||||
|
member: current,
|
||||||
|
}),
|
||||||
|
launchPhase: persistedWithCommittedEvidence.launchPhase,
|
||||||
|
finalTimeoutReached: graceExpired,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
isOpenCodeSecondaryLaneMember &&
|
isOpenCodeSecondaryLaneMember &&
|
||||||
shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now())
|
shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now())
|
||||||
|
|
@ -24540,6 +25008,7 @@ export class TeamProvisioningService {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
current.agentToolAccepted === true &&
|
||||||
!current.bootstrapConfirmed &&
|
!current.bootstrapConfirmed &&
|
||||||
!current.runtimeAlive &&
|
!current.runtimeAlive &&
|
||||||
!current.hardFailure &&
|
!current.hardFailure &&
|
||||||
|
|
@ -27665,6 +28134,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
run.provisioningComplete = true;
|
run.provisioningComplete = true;
|
||||||
|
this.scheduleDeterministicBootstrapCompletionRecovery(run);
|
||||||
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
|
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
||||||
|
|
@ -27743,6 +28213,9 @@ export class TeamProvisioningService {
|
||||||
const hasPendingBootstrap =
|
const hasPendingBootstrap =
|
||||||
!hasSpawnFailures &&
|
!hasSpawnFailures &&
|
||||||
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
|
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
|
||||||
|
if (this.isProvisioningRunPromotedToAlive(run)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const readyMessage = hasSpawnFailures
|
const readyMessage = hasSpawnFailures
|
||||||
? `Launch completed with teammate errors — ${failedSpawnMembers
|
? `Launch completed with teammate errors — ${failedSpawnMembers
|
||||||
.map((member) => member.name)
|
.map((member) => member.name)
|
||||||
|
|
@ -27923,6 +28396,9 @@ export class TeamProvisioningService {
|
||||||
const hasPendingBootstrap =
|
const hasPendingBootstrap =
|
||||||
!hasSpawnFailures &&
|
!hasSpawnFailures &&
|
||||||
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
|
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
|
||||||
|
if (this.isProvisioningRunPromotedToAlive(run)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const progress = updateProgress(
|
const progress = updateProgress(
|
||||||
run,
|
run,
|
||||||
'ready',
|
'ready',
|
||||||
|
|
@ -29357,30 +29833,36 @@ export class TeamProvisioningService {
|
||||||
const envPatch: NodeJS.ProcessEnv = {};
|
const envPatch: NodeJS.ProcessEnv = {};
|
||||||
let usesAnthropicApiKeyHelper = false;
|
let usesAnthropicApiKeyHelper = false;
|
||||||
for (const providerId of crossProviderIds) {
|
for (const providerId of crossProviderIds) {
|
||||||
|
let env: ProvisioningEnvResolution;
|
||||||
try {
|
try {
|
||||||
const env = await this.buildProvisioningEnv(providerId, undefined, {
|
env = await this.buildProvisioningEnv(providerId, undefined, {
|
||||||
teamRuntimeAuth: options?.teamRuntimeAuth,
|
teamRuntimeAuth: options?.teamRuntimeAuth,
|
||||||
});
|
});
|
||||||
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
|
|
||||||
const providerArgs = env.providerArgs ?? [];
|
|
||||||
providerArgsByProvider.set(providerId, providerArgs);
|
|
||||||
if (env.anthropicApiKeyHelper) {
|
|
||||||
usesAnthropicApiKeyHelper = true;
|
|
||||||
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
|
|
||||||
}
|
|
||||||
const flattenedArgs =
|
|
||||||
providerId === 'anthropic' && env.anthropicApiKeyHelper
|
|
||||||
? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath)
|
|
||||||
: providerArgs;
|
|
||||||
if (flattenedArgs.length > 0) {
|
|
||||||
args.push(...flattenedArgs);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`,
|
`[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
// Best-effort: don't block launch if cross-provider env resolution fails
|
// Best-effort: don't block launch if cross-provider env resolution fails
|
||||||
|
// before the provider can report a concrete auth/readiness issue.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (env.warning) {
|
||||||
|
throw new Error(`${getTeamProviderLabel(providerId)}: ${env.warning}`);
|
||||||
|
}
|
||||||
|
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
|
||||||
|
const providerArgs = env.providerArgs ?? [];
|
||||||
|
providerArgsByProvider.set(providerId, providerArgs);
|
||||||
|
if (env.anthropicApiKeyHelper) {
|
||||||
|
usesAnthropicApiKeyHelper = true;
|
||||||
|
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
|
||||||
|
}
|
||||||
|
const flattenedArgs =
|
||||||
|
providerId === 'anthropic' && env.anthropicApiKeyHelper
|
||||||
|
? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath)
|
||||||
|
: providerArgs;
|
||||||
|
if (flattenedArgs.length > 0) {
|
||||||
|
args.push(...flattenedArgs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper };
|
return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import * as path from 'path';
|
||||||
const LEGACY_USER_DATA_DIR_NAMES = [
|
const LEGACY_USER_DATA_DIR_NAMES = [
|
||||||
'Claude Agent Teams UI',
|
'Claude Agent Teams UI',
|
||||||
'claude-agent-teams-ui',
|
'claude-agent-teams-ui',
|
||||||
|
'agent-teams-ai',
|
||||||
|
'Agent Teams UI',
|
||||||
'claude-devtools',
|
'claude-devtools',
|
||||||
'claude-code-context',
|
'claude-code-context',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -20,6 +22,7 @@ export interface ElectronUserDataMigrationResult {
|
||||||
fallbackToLegacy: boolean;
|
fallbackToLegacy: boolean;
|
||||||
reason:
|
reason:
|
||||||
| 'migrated'
|
| 'migrated'
|
||||||
|
| 'legacy-reused'
|
||||||
| 'current-populated'
|
| 'current-populated'
|
||||||
| 'current-path-exists'
|
| 'current-path-exists'
|
||||||
| 'legacy-missing'
|
| 'legacy-missing'
|
||||||
|
|
@ -35,8 +38,42 @@ interface LoggerLike {
|
||||||
interface ElectronUserDataMigrationOptions {
|
interface ElectronUserDataMigrationOptions {
|
||||||
logger?: LoggerLike;
|
logger?: LoggerLike;
|
||||||
copyDirectory?: (sourcePath: string, targetPath: string) => void;
|
copyDirectory?: (sourcePath: string, targetPath: string) => void;
|
||||||
|
strategy?: 'reuse-legacy' | 'copy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSIENT_CHROMIUM_DIRECTORY_NAMES = new Set([
|
||||||
|
'Cache',
|
||||||
|
'Code Cache',
|
||||||
|
'Crashpad',
|
||||||
|
'Crash Reports',
|
||||||
|
'DawnGraphiteCache',
|
||||||
|
'DawnWebGPUCache',
|
||||||
|
'GPUCache',
|
||||||
|
'GrShaderCache',
|
||||||
|
'ShaderCache',
|
||||||
|
'Session Storage',
|
||||||
|
'Shared Dictionary',
|
||||||
|
'Service Worker',
|
||||||
|
'VideoDecodeStats',
|
||||||
|
'blob_storage',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
|
||||||
|
'DIPS',
|
||||||
|
'DIPS-journal',
|
||||||
|
'DIPS-wal',
|
||||||
|
'LOCK',
|
||||||
|
'Network Persistent State',
|
||||||
|
'SingletonCookie',
|
||||||
|
'SingletonLock',
|
||||||
|
'SingletonSocket',
|
||||||
|
'TransportSecurity',
|
||||||
|
'Trust Tokens',
|
||||||
|
'Trust Tokens-journal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
||||||
const parent = path.dirname(currentPath);
|
const parent = path.dirname(currentPath);
|
||||||
const normalizedCurrent = path.resolve(currentPath);
|
const normalizedCurrent = path.resolve(currentPath);
|
||||||
|
|
@ -55,6 +92,7 @@ export function migrateElectronUserDataDirectory(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentPath = app.getPath('userData');
|
currentPath = app.getPath('userData');
|
||||||
|
scheduleStaleMigrationTempCleanup(currentPath, logger);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`);
|
logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`);
|
||||||
return {
|
return {
|
||||||
|
|
@ -66,7 +104,7 @@ export function migrateElectronUserDataDirectory(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) {
|
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
||||||
return {
|
return {
|
||||||
currentPath,
|
currentPath,
|
||||||
legacyPath: null,
|
legacyPath: null,
|
||||||
|
|
@ -98,6 +136,29 @@ export function migrateElectronUserDataDirectory(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((options.strategy ?? 'reuse-legacy') === 'reuse-legacy') {
|
||||||
|
try {
|
||||||
|
setLegacyElectronPaths(app, legacyPath, logger);
|
||||||
|
logger?.info(`Reusing legacy Electron userData at ${legacyPath}`);
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger?.warn(`Electron userData legacy reuse failed: ${stringifyError(error)}`);
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const migrated = copyLegacyUserDataDirectory(
|
const migrated = copyLegacyUserDataDirectory(
|
||||||
legacyPath,
|
legacyPath,
|
||||||
currentPath,
|
currentPath,
|
||||||
|
|
@ -115,7 +176,7 @@ export function migrateElectronUserDataDirectory(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) {
|
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
||||||
return {
|
return {
|
||||||
currentPath,
|
currentPath,
|
||||||
legacyPath: null,
|
legacyPath: null,
|
||||||
|
|
@ -150,7 +211,10 @@ export function migrateElectronUserDataDirectory(
|
||||||
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
||||||
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
|
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
|
||||||
return (
|
return (
|
||||||
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? candidates[0] ?? null
|
candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ??
|
||||||
|
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ??
|
||||||
|
candidates[0] ??
|
||||||
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,9 +276,73 @@ function copyDirectorySync(sourcePath: string, targetPath: string): void {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
errorOnExist: false,
|
errorOnExist: false,
|
||||||
force: false,
|
force: false,
|
||||||
|
filter: (sourceEntryPath) => shouldCopyElectronUserDataEntry(sourcePath, sourceEntryPath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleStaleMigrationTempCleanup(currentPath: string, logger?: LoggerLike): void {
|
||||||
|
const parent = path.dirname(currentPath);
|
||||||
|
const prefix = `${path.basename(currentPath)}.migrating-`;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
fs.readdir(parent, { withFileTypes: true }, (readError, entries) => {
|
||||||
|
if (readError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stalePath = path.join(parent, entry.name);
|
||||||
|
fs.stat(stalePath, (statError, stats) => {
|
||||||
|
if (statError || now - stats.mtimeMs < STALE_MIGRATION_TEMP_MAX_AGE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rm(stalePath, { recursive: true, force: true }, (removeError) => {
|
||||||
|
if (removeError) {
|
||||||
|
logger?.warn(
|
||||||
|
`Failed to remove stale Electron userData migration temp path: ${stringifyError(
|
||||||
|
removeError
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger?.info(`Removed stale Electron userData migration temp path: ${stalePath}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
timeout.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCopyElectronUserDataEntry(
|
||||||
|
sourceRootPath: string,
|
||||||
|
sourceEntryPath: string
|
||||||
|
): boolean {
|
||||||
|
const relativePath = path.relative(sourceRootPath, sourceEntryPath);
|
||||||
|
if (!relativePath || relativePath === '.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = relativePath.split(path.sep).filter(Boolean);
|
||||||
|
if (segments.some((segment) => TRANSIENT_CHROMIUM_DIRECTORY_NAMES.has(segment))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = segments[segments.length - 1];
|
||||||
|
if (TRANSIENT_CHROMIUM_FILE_NAMES.has(basename)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function pathExists(targetPath: string): boolean {
|
function pathExists(targetPath: string): boolean {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(targetPath);
|
fs.accessSync(targetPath);
|
||||||
|
|
@ -240,6 +368,35 @@ function directoryHasEntries(targetPath: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function directoryHasDurableUserDataEntries(targetPath: string): boolean {
|
||||||
|
try {
|
||||||
|
return directoryHasDurableUserDataEntriesWithin(targetPath, targetPath);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath: string): boolean {
|
||||||
|
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(targetPath, entry.name);
|
||||||
|
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directoryHasDurableUserDataEntriesWithin(rootPath, entryPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyError(error: unknown): string {
|
function stringifyError(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
: releaseNotes;
|
: releaseNotes;
|
||||||
|
|
||||||
const releaseUrl = availableVersion
|
const releaseUrl = availableVersion
|
||||||
? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}`
|
? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const openReleaseOnGitHub = (): void => {
|
const openReleaseOnGitHub = (): void => {
|
||||||
|
|
|
||||||
|
|
@ -117,13 +117,13 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (isElectronMode()) {
|
if (isElectronMode()) {
|
||||||
await window.electronAPI.openExternal(
|
await window.electronAPI.openExternal(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui'
|
'https://github.com/777genius/agent-teams-ai'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui',
|
'https://github.com/777genius/agent-teams-ai',
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener,noreferrer'
|
'noopener,noreferrer'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,6 @@ function normalizeLaunchFailureReason(value: string | undefined): string | null
|
||||||
return normalized && normalized.length > 0 ? normalized : null;
|
return normalized && normalized.length > 0 ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateLaunchFailureReason(value: string, maxLength = 220): string {
|
|
||||||
if (value.length <= maxLength) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLaunchFailureLinkLabel(url: string): string {
|
function getLaunchFailureLinkLabel(url: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|
@ -298,9 +291,6 @@ export const MemberCard = memo(function MemberCard({
|
||||||
const launchFailureReason = showFailedLaunchBadge
|
const launchFailureReason = showFailedLaunchBadge
|
||||||
? normalizeLaunchFailureReason(rawLaunchFailureReason)
|
? normalizeLaunchFailureReason(rawLaunchFailureReason)
|
||||||
: null;
|
: null;
|
||||||
const displayedLaunchFailureReason = launchFailureReason
|
|
||||||
? truncateLaunchFailureReason(launchFailureReason)
|
|
||||||
: null;
|
|
||||||
const hasLiveLaunchControls =
|
const hasLiveLaunchControls =
|
||||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||||
const hasRestartMemberControl =
|
const hasRestartMemberControl =
|
||||||
|
|
@ -512,14 +502,14 @@ export const MemberCard = memo(function MemberCard({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{displayedLaunchFailureReason ? (
|
{launchFailureReason ? (
|
||||||
<div
|
<div
|
||||||
data-testid="member-launch-failure-reason"
|
data-testid="member-launch-failure-reason"
|
||||||
className="mt-1 min-w-0 text-[10px] font-medium leading-snug text-red-300/90"
|
className="mt-1 min-w-0 whitespace-pre-wrap break-words text-[10px] font-medium leading-snug text-red-300/90"
|
||||||
title={rawLaunchFailureReason}
|
title={rawLaunchFailureReason}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-2 break-words">
|
<span>
|
||||||
{renderLinkifiedText(displayedLaunchFailureReason, {
|
{renderLinkifiedText(launchFailureReason, {
|
||||||
linkClassName: 'underline underline-offset-2 hover:text-red-200',
|
linkClassName: 'underline underline-offset-2 hover:text-red-200',
|
||||||
stopPropagation: true,
|
stopPropagation: true,
|
||||||
getLinkLabel: getLaunchFailureLinkLabel,
|
getLinkLabel: getLaunchFailureLinkLabel,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import packageJson from '../../../package.json';
|
import packageJson from '../../../package.json';
|
||||||
|
|
||||||
const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/claude_agent_teams_ui/issues/new';
|
const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/agent-teams-ai/issues/new';
|
||||||
const MAX_TITLE_LENGTH = 120;
|
const MAX_TITLE_LENGTH = 120;
|
||||||
const URL_MAX_STACK_LENGTH = 1800;
|
const URL_MAX_STACK_LENGTH = 1800;
|
||||||
const URL_MAX_COMPONENT_STACK_LENGTH = 1200;
|
const URL_MAX_COMPONENT_STACK_LENGTH = 1200;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ vi.mock(
|
||||||
detectCodexLocalAccountState: detectLocalAccountStateMock,
|
detectCodexLocalAccountState: detectLocalAccountStateMock,
|
||||||
detectCodexLocalAccountArtifacts: async () =>
|
detectCodexLocalAccountArtifacts: async () =>
|
||||||
(await detectLocalAccountStateMock()).hasArtifacts,
|
(await detectLocalAccountStateMock()).hasArtifacts,
|
||||||
|
ensureCodexLegacyAuthFromActiveAccount: vi.fn().mockResolvedValue(null),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -610,6 +611,99 @@ describe('createCodexAccountFeature', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps last known rate limits visible during a transient optional rate limit refresh failure', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readRateLimitsMock
|
||||||
|
.mockResolvedValueOnce(createRateLimitsResponse())
|
||||||
|
.mockRejectedValueOnce(new Error('codex account authentication required to read rate limits'));
|
||||||
|
const logger = createLoggerPort();
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger,
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||||
|
|
||||||
|
try {
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
|
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_060_000);
|
||||||
|
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
|
||||||
|
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(secondSnapshot.appServerState).toBe('healthy');
|
||||||
|
expect(secondSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', {
|
||||||
|
error: 'codex account authentication required to read rate limits',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
dateNowSpy.mockRestore();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse stale rate limits after the active ChatGPT account changes', async () => {
|
||||||
|
readAccountMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
account: createAccountResponse({
|
||||||
|
account: {
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'first@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
account: createAccountResponse({
|
||||||
|
account: {
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'second@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readRateLimitsMock
|
||||||
|
.mockResolvedValueOnce(createRateLimitsResponse())
|
||||||
|
.mockRejectedValueOnce(new Error('rate limit service unavailable'));
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||||
|
|
||||||
|
try {
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
|
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_060_000);
|
||||||
|
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
|
||||||
|
expect(firstSnapshot.managedAccount?.email).toBe('first@example.com');
|
||||||
|
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(secondSnapshot.managedAccount?.email).toBe('second@example.com');
|
||||||
|
expect(secondSnapshot.rateLimits).toBeNull();
|
||||||
|
} finally {
|
||||||
|
dateNowSpy.mockRestore();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the last known managed account during a transient degraded read', async () => {
|
it('keeps the last known managed account during a transient degraded read', async () => {
|
||||||
readAccountMock
|
readAccountMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|
@ -686,7 +780,7 @@ describe('createCodexAccountFeature', () => {
|
||||||
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
const firstSnapshot = await feature.refreshSnapshot();
|
const firstSnapshot = await feature.refreshSnapshot();
|
||||||
dateNowSpy.mockReturnValue(1_776_000_006_000);
|
dateNowSpy.mockReturnValue(1_776_000_006_000);
|
||||||
const secondSnapshot = await feature.refreshSnapshot();
|
const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
|
||||||
|
|
||||||
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
expect(secondSnapshot.managedAccount).toMatchObject({
|
expect(secondSnapshot.managedAccount).toMatchObject({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'fs/promises';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
detectCodexLocalAccountArtifacts,
|
detectCodexLocalAccountArtifacts,
|
||||||
detectCodexLocalAccountState,
|
detectCodexLocalAccountState,
|
||||||
|
ensureCodexLegacyAuthFromActiveAccount,
|
||||||
|
resolveCodexActiveChatgptAuthFile,
|
||||||
} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
|
} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
@ -18,6 +20,13 @@ async function makeTempDir(): Promise<string> {
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function makeCodexHome(): Promise<{ codexHome: string; accountsDir: string }> {
|
||||||
|
const codexHome = await makeTempDir();
|
||||||
|
const accountsDir = path.join(codexHome, 'accounts');
|
||||||
|
await mkdir(accountsDir, { recursive: true });
|
||||||
|
return { codexHome, accountsDir };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
@ -55,7 +64,7 @@ describe('detectCodexLocalAccountArtifacts', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects a locally selected ChatGPT account from the registry and active auth file', async () => {
|
it('detects a locally selected ChatGPT account from the registry and active auth file', async () => {
|
||||||
const accountsDir = await makeTempDir();
|
const { accountsDir } = await makeCodexHome();
|
||||||
const activeAccountKey = 'user-test::chatgpt-account';
|
const activeAccountKey = 'user-test::chatgpt-account';
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(accountsDir, 'registry.json'),
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
|
@ -64,7 +73,171 @@ describe('detectCodexLocalAccountArtifacts', () => {
|
||||||
);
|
);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
|
||||||
JSON.stringify({ auth_mode: 'chatgpt' }),
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
|
||||||
|
hasArtifacts: true,
|
||||||
|
hasActiveChatgptAccount: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the active accounts-format auth file before legacy auth when a registry exists', async () => {
|
||||||
|
const { codexHome, accountsDir } = await makeCodexHome();
|
||||||
|
const activeAccountKey = 'user-active::chatgpt-account';
|
||||||
|
await writeFile(
|
||||||
|
path.join(codexHome, 'auth.json'),
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ active_account_key: activeAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const activeAuthPath = path.join(
|
||||||
|
accountsDir,
|
||||||
|
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
activeAuthPath,
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'active-refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(resolveCodexActiveChatgptAuthFile(accountsDir)).resolves.toMatchObject({
|
||||||
|
authFilePath: activeAuthPath,
|
||||||
|
source: 'accounts',
|
||||||
|
activeAccountKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes active accounts-format auth into legacy auth.json for Codex CLI compatibility', async () => {
|
||||||
|
const { codexHome, accountsDir } = await makeCodexHome();
|
||||||
|
const activeAccountKey = 'user-active::chatgpt-account';
|
||||||
|
const authPayload = {
|
||||||
|
auth_mode: 'chatgpt',
|
||||||
|
tokens: { refresh_token: 'active-refresh-token', access_token: 'active-access-token' },
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ active_account_key: activeAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
|
||||||
|
JSON.stringify(authPayload),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
codexHome,
|
||||||
|
authFilePath: path.join(codexHome, 'auth.json'),
|
||||||
|
source: 'accounts',
|
||||||
|
materializedLegacyAuth: true,
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toBe(
|
||||||
|
JSON.stringify(authPayload)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite a newer synced legacy auth file for the same active account', async () => {
|
||||||
|
const { codexHome, accountsDir } = await makeCodexHome();
|
||||||
|
const activeAccountKey = 'user-active::chatgpt-account';
|
||||||
|
const activeAuthPath = path.join(
|
||||||
|
accountsDir,
|
||||||
|
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ active_account_key: activeAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
activeAuthPath,
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
|
||||||
|
|
||||||
|
const refreshedLegacyPayload = JSON.stringify({
|
||||||
|
auth_mode: 'chatgpt',
|
||||||
|
tokens: { refresh_token: 'runtime-refreshed-token' },
|
||||||
|
});
|
||||||
|
const legacyAuthPath = path.join(codexHome, 'auth.json');
|
||||||
|
await writeFile(legacyAuthPath, refreshedLegacyPayload, 'utf8');
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
await utimes(legacyAuthPath, future, future);
|
||||||
|
|
||||||
|
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
|
||||||
|
|
||||||
|
expect(result?.materializedLegacyAuth).toBe(false);
|
||||||
|
await expect(readFile(legacyAuthPath, 'utf8')).resolves.toBe(refreshedLegacyPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes legacy auth when the selected accounts-format account changes', async () => {
|
||||||
|
const { codexHome, accountsDir } = await makeCodexHome();
|
||||||
|
const firstAccountKey = 'user-first::chatgpt-account';
|
||||||
|
const secondAccountKey = 'user-second::chatgpt-account';
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ active_account_key: firstAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(firstAccountKey)}.auth.json`),
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(secondAccountKey)}.auth.json`),
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'second-refresh-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ active_account_key: secondAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
|
||||||
|
|
||||||
|
expect(result?.materializedLegacyAuth).toBe(true);
|
||||||
|
await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toContain(
|
||||||
|
'second-refresh-token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a ChatGPT refresh token for the selected account', async () => {
|
||||||
|
const { accountsDir } = await makeCodexHome();
|
||||||
|
const activeAccountKey = 'user-test::chatgpt-account';
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, 'registry.json'),
|
||||||
|
JSON.stringify({ activeAccountId: activeAccountKey }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access-token' } }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
|
||||||
|
hasArtifacts: true,
|
||||||
|
hasActiveChatgptAccount: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy auth.json when the accounts registry is absent', async () => {
|
||||||
|
const { codexHome, accountsDir } = await makeCodexHome();
|
||||||
|
await writeFile(
|
||||||
|
path.join(codexHome, 'auth.json'),
|
||||||
|
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -75,7 +248,7 @@ describe('detectCodexLocalAccountArtifacts', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => {
|
it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => {
|
||||||
const accountsDir = await makeTempDir();
|
const { accountsDir } = await makeCodexHome();
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(accountsDir, 'registry.json'),
|
path.join(accountsDir, 'registry.json'),
|
||||||
JSON.stringify({ active_account_key: 'user-test::missing-auth' }),
|
JSON.stringify({ active_account_key: 'user-test::missing-auth' }),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ vi.mock('electron', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { safeStorage } from 'electron';
|
||||||
|
|
||||||
import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService';
|
import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService';
|
||||||
|
|
||||||
describe('ApiKeyService', () => {
|
describe('ApiKeyService', () => {
|
||||||
|
|
@ -20,6 +22,11 @@ describe('ApiKeyService', () => {
|
||||||
let service: ApiKeyService;
|
let service: ApiKeyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
|
||||||
|
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('basic_text');
|
||||||
|
vi.mocked(safeStorage.encryptString).mockReset();
|
||||||
|
vi.mocked(safeStorage.decryptString).mockReset();
|
||||||
|
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-'));
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-'));
|
||||||
service = new ApiKeyService(tempDir);
|
service = new ApiKeyService(tempDir);
|
||||||
});
|
});
|
||||||
|
|
@ -117,4 +124,35 @@ describe('ApiKeyService', () => {
|
||||||
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
|
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
|
||||||
await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull();
|
await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not print decrypt failures to the normal console', async () => {
|
||||||
|
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||||
|
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('gnome_libsecret');
|
||||||
|
vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-value'));
|
||||||
|
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||||
|
throw new Error('Error while decrypting the ciphertext provided to safeStorage.decryptString.');
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.save({
|
||||||
|
name: 'Anthropic API Key',
|
||||||
|
envVarName: 'ANTHROPIC_API_KEY',
|
||||||
|
value: 'secret',
|
||||||
|
scope: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(service.lookupPreferred('ANTHROPIC_API_KEY')).resolves.toEqual({
|
||||||
|
envVarName: 'ANTHROPIC_API_KEY',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
expect(consoleError).not.toHaveBeenCalled();
|
||||||
|
expect(consoleWarn).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
consoleError.mockRestore();
|
||||||
|
consoleWarn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,23 @@ import {
|
||||||
describe('updaterReleaseMetadata', () => {
|
describe('updaterReleaseMetadata', () => {
|
||||||
it('builds platform-specific asset URLs', () => {
|
it('builds platform-specific asset URLs', () => {
|
||||||
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe(
|
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
|
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg'
|
||||||
);
|
);
|
||||||
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe(
|
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-x64.dmg'
|
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-x64.dmg'
|
||||||
);
|
);
|
||||||
expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe(
|
expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI.Setup.1.2.3.exe'
|
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI.Setup.1.2.3.exe'
|
||||||
);
|
);
|
||||||
expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe(
|
expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe(
|
||||||
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3.AppImage'
|
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3.AppImage'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds current and planned repo asset URLs while the GitHub repo rename is pending', () => {
|
it('builds primary and legacy repo asset URLs after the GitHub repo rename', () => {
|
||||||
expect(getExpectedReleaseAssetUrls('1.2.3', 'darwin', 'arm64')).toEqual([
|
expect(getExpectedReleaseAssetUrls('1.2.3', 'darwin', 'arm64')).toEqual([
|
||||||
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg',
|
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg',
|
||||||
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg',
|
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -37,19 +37,19 @@ describe('updaterReleaseMetadata', () => {
|
||||||
const metadata = `
|
const metadata = `
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
files:
|
files:
|
||||||
- url: "Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip"
|
- url: "Agent.Teams.AI-1.2.3-arm64-mac.zip"
|
||||||
sha512: abc
|
sha512: abc
|
||||||
size: 123
|
size: 123
|
||||||
- url: 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
|
- url: 'Agent.Teams.AI-1.2.3-arm64.dmg'
|
||||||
sha512: def
|
sha512: def
|
||||||
size: 456
|
size: 456
|
||||||
path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip
|
path: Agent.Teams.AI-1.2.3-arm64-mac.zip
|
||||||
`;
|
`;
|
||||||
|
|
||||||
expect(parseReleaseMetadataAssetNames(metadata)).toEqual(
|
expect(parseReleaseMetadataAssetNames(metadata)).toEqual(
|
||||||
new Set([
|
new Set([
|
||||||
'Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip',
|
'Agent.Teams.AI-1.2.3-arm64-mac.zip',
|
||||||
'Claude.Agent.Teams.UI-1.2.3-arm64.dmg',
|
'Agent.Teams.AI-1.2.3-arm64.dmg',
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -59,29 +59,29 @@ path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip
|
||||||
const arm64Metadata = `
|
const arm64Metadata = `
|
||||||
version: ${version}
|
version: ${version}
|
||||||
files:
|
files:
|
||||||
- url: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
|
- url: Agent.Teams.AI-${version}-arm64-mac.zip
|
||||||
sha512: abc
|
sha512: abc
|
||||||
size: 123
|
size: 123
|
||||||
- url: Claude.Agent.Teams.UI-${version}-arm64.dmg
|
- url: Agent.Teams.AI-${version}-arm64.dmg
|
||||||
sha512: def
|
sha512: def
|
||||||
size: 456
|
size: 456
|
||||||
path: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
|
path: Agent.Teams.AI-${version}-arm64-mac.zip
|
||||||
`;
|
`;
|
||||||
|
|
||||||
expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([
|
expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([
|
||||||
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
|
`Agent.Teams.AI-${version}-arm64-mac.zip`,
|
||||||
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
|
`Agent.Teams.AI-${version}-arm64.dmg`,
|
||||||
]);
|
]);
|
||||||
expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([
|
expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([
|
||||||
`Claude.Agent.Teams.UI-${version}-x64-mac.zip`,
|
`Agent.Teams.AI-${version}-x64-mac.zip`,
|
||||||
`Claude.Agent.Teams.UI-${version}-x64.dmg`,
|
`Agent.Teams.AI-${version}-x64.dmg`,
|
||||||
]);
|
]);
|
||||||
expect(getLatestMacMetadataUrl(version)).toBe(
|
expect(getLatestMacMetadataUrl(version)).toBe(
|
||||||
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`
|
`https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`
|
||||||
);
|
);
|
||||||
expect(getLatestMacMetadataUrls(version)).toEqual([
|
expect(getLatestMacMetadataUrls(version)).toEqual([
|
||||||
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`,
|
|
||||||
`https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`,
|
`https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`,
|
||||||
|
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`,
|
||||||
]);
|
]);
|
||||||
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true);
|
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true);
|
||||||
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false);
|
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,149 @@ describe('ProviderConnectionService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not block launch when the Codex app-server freshly verifies ChatGPT auth', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
const loginStatusChecker = vi.fn().mockResolvedValue({
|
||||||
|
status: 'not_logged_in',
|
||||||
|
detail: 'Not logged in',
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never,
|
||||||
|
loginStatusChecker
|
||||||
|
);
|
||||||
|
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
preferredAuthMode: 'chatgpt',
|
||||||
|
effectiveAuthMode: 'chatgpt',
|
||||||
|
launchAllowed: true,
|
||||||
|
launchIssueMessage: null,
|
||||||
|
launchReadinessState: 'ready_chatgpt',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
appServerStatusMessage: null,
|
||||||
|
managedAccount: {
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'user@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
available: false,
|
||||||
|
source: null,
|
||||||
|
sourceLabel: null,
|
||||||
|
},
|
||||||
|
requiresOpenaiAuth: true,
|
||||||
|
localAccountArtifactsPresent: true,
|
||||||
|
localActiveChatgptAccountPresent: true,
|
||||||
|
runtimeContext: {
|
||||||
|
binaryPath: '/opt/codex/bin/codex',
|
||||||
|
codexHome: '/Users/tester/.codex-custom',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
rateLimits: null,
|
||||||
|
updatedAt: '2026-04-20T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getConfiguredConnectionIssue(
|
||||||
|
{
|
||||||
|
OPENAI_API_KEY: 'ambient-openai-key',
|
||||||
|
CODEX_API_KEY: 'ambient-codex-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
)
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(loginStatusChecker).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks launch when managed ChatGPT is selected but degraded exact runtime login is logged out', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
const loginStatusChecker = vi.fn().mockResolvedValue({
|
||||||
|
status: 'not_logged_in',
|
||||||
|
detail: 'Not logged in',
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never,
|
||||||
|
loginStatusChecker
|
||||||
|
);
|
||||||
|
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
preferredAuthMode: 'chatgpt',
|
||||||
|
effectiveAuthMode: 'chatgpt',
|
||||||
|
launchAllowed: true,
|
||||||
|
launchIssueMessage: null,
|
||||||
|
launchReadinessState: 'warning_degraded_but_launchable',
|
||||||
|
appServerState: 'degraded',
|
||||||
|
appServerStatusMessage: 'Using cached ChatGPT account after transient app-server failure.',
|
||||||
|
managedAccount: {
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'user@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
available: false,
|
||||||
|
source: null,
|
||||||
|
sourceLabel: null,
|
||||||
|
},
|
||||||
|
requiresOpenaiAuth: true,
|
||||||
|
localAccountArtifactsPresent: true,
|
||||||
|
localActiveChatgptAccountPresent: true,
|
||||||
|
runtimeContext: {
|
||||||
|
binaryPath: '/opt/codex/bin/codex',
|
||||||
|
codexHome: '/Users/tester/.codex-custom',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
rateLimits: null,
|
||||||
|
updatedAt: '2026-04-20T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const issue = await service.getConfiguredConnectionIssue(
|
||||||
|
{
|
||||||
|
OPENAI_API_KEY: 'ambient-openai-key',
|
||||||
|
CODEX_API_KEY: 'ambient-codex-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue).toContain('Codex CLI login status is not active');
|
||||||
|
expect(issue).toContain('Reconnect ChatGPT');
|
||||||
|
expect(loginStatusChecker).toHaveBeenCalledWith({
|
||||||
|
binaryPath: '/opt/codex/bin/codex',
|
||||||
|
env: expect.objectContaining({
|
||||||
|
CODEX_CLI_PATH: '/opt/codex/bin/codex',
|
||||||
|
CODEX_HOME: '/Users/tester/.codex-custom',
|
||||||
|
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(loginStatusChecker.mock.calls[0]?.[0].env.OPENAI_API_KEY).toBeUndefined();
|
||||||
|
expect(loginStatusChecker.mock.calls[0]?.[0].env.CODEX_API_KEY).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a pinned Codex API-key mode as missing only the API key credential', async () => {
|
it('reports a pinned Codex API-key mode as missing only the API key credential', async () => {
|
||||||
const { ProviderConnectionService } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/ProviderConnectionService');
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildProcessBootstrapPendingDiagnostic,
|
||||||
|
buildProcessBootstrapTimeoutDiagnostic,
|
||||||
|
deriveProcessTransportProjectionPhase,
|
||||||
|
sanitizeProcessRuntimeEventFilePrefix,
|
||||||
|
summarizeProcessBootstrapTransportEvents,
|
||||||
|
} from '@main/services/team/ProcessBootstrapTransportEvidence';
|
||||||
|
|
||||||
|
describe('ProcessBootstrapTransportEvidence', () => {
|
||||||
|
it('keeps retryable submit rejection non-terminal when a later submit succeeds', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'runtime_ready',
|
||||||
|
timestamp: '2026-05-07T10:00:00.000Z',
|
||||||
|
detail: 'ready',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submit_rejected',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail: 'temporary backoff',
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submitted',
|
||||||
|
timestamp: '2026-05-07T10:00:02.000Z',
|
||||||
|
detail: 'messageId=abc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
submitted: true,
|
||||||
|
hasProgress: true,
|
||||||
|
});
|
||||||
|
expect(summary?.terminalFailure).toBeUndefined();
|
||||||
|
expect(summary?.lastStage).toContain('bootstrap submitted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats non-retryable submit rejection as terminal', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submit_rejected',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail: 'fatal submit rejection',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary?.terminalFailure).toMatchObject({
|
||||||
|
kind: 'non_retryable_submit_rejection',
|
||||||
|
reason: 'bootstrap submit rejected: fatal submit rejection',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats accepted submit without a message id as terminal', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submit_accepted_without_uuid',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail: 'accepted but missing message id',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary?.terminalFailure).toMatchObject({
|
||||||
|
kind: 'accepted_without_message_id',
|
||||||
|
reason: 'bootstrap submit accepted without message id: accepted but missing message id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts secrets and paths from transport diagnostics', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submit_rejected',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail:
|
||||||
|
'failed in /Users/belief/dev/project with token sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary?.terminalFailure?.reason).toContain('[path]');
|
||||||
|
expect(summary?.terminalFailure?.reason).toContain('[redacted]');
|
||||||
|
expect(summary?.terminalFailure?.reason).not.toContain('/Users/belief');
|
||||||
|
expect(summary?.terminalFailure?.reason).not.toContain('sk-ant-api03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not surface raw command or cwd details for parent-owned process stages', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'process_spawned',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail: 'spawned /Users/belief/project with command secret',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary?.lastStage).toBe('process spawned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds stable pending and timeout diagnostics from the last transport stage', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'bootstrap_prompt_observed',
|
||||||
|
timestamp: '2026-05-07T10:00:01.000Z',
|
||||||
|
detail: 'prompt seen',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
||||||
|
'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.'
|
||||||
|
);
|
||||||
|
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||||
|
'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active phase pending and turns final timeout into final projection', () => {
|
||||||
|
expect(deriveProcessTransportProjectionPhase({ launchPhase: 'active' })).toBe('active');
|
||||||
|
expect(
|
||||||
|
deriveProcessTransportProjectionPhase({
|
||||||
|
launchPhase: 'active',
|
||||||
|
finalTimeoutReached: true,
|
||||||
|
})
|
||||||
|
).toBe('final');
|
||||||
|
expect(deriveProcessTransportProjectionPhase({ launchPhase: 'finished' })).toBe('final');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches orchestrator runtime-event filename sanitization for important names', () => {
|
||||||
|
expect(sanitizeProcessRuntimeEventFilePrefix('jack')).toBe('jack');
|
||||||
|
expect(sanitizeProcessRuntimeEventFilePrefix('con.txt')).toBe('con-txt');
|
||||||
|
expect(sanitizeProcessRuntimeEventFilePrefix('CON')).toBe('_con');
|
||||||
|
expect(sanitizeProcessRuntimeEventFilePrefix('alice/bob')).toBe('alice-bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12594,6 +12594,122 @@ describe('TeamProvisioningService', () => {
|
||||||
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recovers ready progress when deterministic create finalization stalls after completed bootstrap-state', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const teamName = 'create-completed-bootstrap-finalization-stall';
|
||||||
|
const child = createRunningChild();
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const mcpConfigBuilder = {
|
||||||
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'),
|
||||||
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const membersMetaStore = {
|
||||||
|
writeMembers: vi.fn(async () => {}),
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const teamMetaStore = {
|
||||||
|
writeMeta: vi.fn(async () => {}),
|
||||||
|
deleteMeta: vi.fn(async () => {}),
|
||||||
|
getMeta: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
membersMetaStore as any,
|
||||||
|
undefined,
|
||||||
|
mcpConfigBuilder as any,
|
||||||
|
teamMetaStore as any
|
||||||
|
);
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { CODEX_API_KEY: 'test' },
|
||||||
|
authSource: 'codex_runtime',
|
||||||
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).startStallWatchdog = vi.fn();
|
||||||
|
(svc as any).stopStallWatchdog = vi.fn();
|
||||||
|
(svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
selectedModel: 'gpt-5.5',
|
||||||
|
selectedModelKind: 'explicit',
|
||||||
|
resolvedLaunchModel: 'gpt-5.5',
|
||||||
|
catalogId: 'gpt-5.5',
|
||||||
|
catalogSource: 'test',
|
||||||
|
catalogFetchedAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
selectedEffort: 'medium',
|
||||||
|
resolvedEffort: 'medium',
|
||||||
|
selectedFastMode: null,
|
||||||
|
resolvedFastMode: null,
|
||||||
|
fastResolutionReason: null,
|
||||||
|
}));
|
||||||
|
const waitForValidConfig = vi.fn(() => new Promise(() => {}));
|
||||||
|
(svc as any).waitForValidConfig = waitForValidConfig;
|
||||||
|
|
||||||
|
const progressStates: string[] = [];
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
members: [{ name: 'alice' }, { name: 'tom' }],
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
progressStates.push(progress.state);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
expect(run).toBeTruthy();
|
||||||
|
run.deterministicBootstrap = true;
|
||||||
|
const scheduleRecovery = vi.spyOn(
|
||||||
|
svc as any,
|
||||||
|
'scheduleDeterministicBootstrapCompletionRecovery'
|
||||||
|
);
|
||||||
|
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{ name: 'alice', status: 'bootstrap_confirmed' },
|
||||||
|
{ name: 'tom', status: 'bootstrap_confirmed' },
|
||||||
|
],
|
||||||
|
new Date(Date.now() + 1_000).toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'team_bootstrap',
|
||||||
|
event: 'completed',
|
||||||
|
run_id: runId,
|
||||||
|
team_name: teamName,
|
||||||
|
seq: 1,
|
||||||
|
failed_members: [],
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(waitForValidConfig).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scheduleRecovery).toHaveBeenCalledWith(run);
|
||||||
|
expect(progressStates.at(-1)).not.toBe('ready');
|
||||||
|
|
||||||
|
await (svc as any).recoverDeterministicBootstrapCompletion(run);
|
||||||
|
expect(progressStates.at(-1)).toBe('ready');
|
||||||
|
expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false);
|
||||||
|
expect((svc as any).aliveRunByTeam.get(teamName)).toBe(runId);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not verify provisioning again after flushing a final newline-less error result', async () => {
|
it('does not verify provisioning again after flushing a final newline-less error result', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'launch-close-flushes-final-error-team';
|
const teamName = 'launch-close-flushes-final-error-team';
|
||||||
|
|
@ -13437,6 +13553,266 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps active process bootstrap transport progress pending without turning retryable rejection into failure', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-process-bootstrap-transport-pending';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 15_000).toISOString();
|
||||||
|
const bootstrapRunId = 'run-process-transport-pending';
|
||||||
|
const runtimePid = 1234;
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
backendType: 'process',
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
runtimePid,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapRunId,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeLaunchState(
|
||||||
|
teamName,
|
||||||
|
leadSessionId,
|
||||||
|
{
|
||||||
|
jack: {
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
runtimePid,
|
||||||
|
runtimeRunId: bootstrapRunId,
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
backendType: 'process',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
firstSpawnAcceptedAt: acceptedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ launchPhase: 'active' }
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
type: 'runtime_ready',
|
||||||
|
timestamp: acceptedAt,
|
||||||
|
pid: runtimePid,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapRunId,
|
||||||
|
detail: 'ready',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_submit_rejected',
|
||||||
|
timestamp: new Date(Date.now() - 10_000).toISOString(),
|
||||||
|
pid: runtimePid,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapRunId,
|
||||||
|
retryable: true,
|
||||||
|
detail: 'cooldown before retry',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.map((event) => JSON.stringify(event))
|
||||||
|
.join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
|
});
|
||||||
|
expect(result.statuses.jack?.runtimeDiagnostic).toContain(
|
||||||
|
'Bootstrap transport reached bootstrap submit rejected'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the last process transport stage when active launch grace expires', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-process-bootstrap-transport-timeout';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 15 * 60_000).toISOString();
|
||||||
|
const bootstrapRunId = 'run-process-transport-timeout';
|
||||||
|
const runtimePid = 1235;
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
backendType: 'process',
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
runtimePid,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapRunId,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeLaunchState(
|
||||||
|
teamName,
|
||||||
|
leadSessionId,
|
||||||
|
{
|
||||||
|
jack: {
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
runtimePid,
|
||||||
|
runtimeRunId: bootstrapRunId,
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
backendType: 'process',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
firstSpawnAcceptedAt: acceptedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ launchPhase: 'active' }
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_prompt_observed',
|
||||||
|
timestamp: new Date(Date.now() - 14 * 60_000).toISOString(),
|
||||||
|
pid: runtimePid,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapRunId,
|
||||||
|
detail: 'prompt seen',
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
runtimeDiagnosticSeverity: 'error',
|
||||||
|
});
|
||||||
|
expect(result.statuses.jack?.hardFailureReason).toContain(
|
||||||
|
'Last transport stage: bootstrap prompt observed: prompt seen'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses non-retryable process transport rejection as terminal launch failure', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-process-bootstrap-transport-terminal';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 15_000).toISOString();
|
||||||
|
const bootstrapRunId = 'run-process-transport-terminal';
|
||||||
|
const runtimePid = 1236;
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
backendType: 'process',
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
runtimePid,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapRunId,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeLaunchState(
|
||||||
|
teamName,
|
||||||
|
leadSessionId,
|
||||||
|
{
|
||||||
|
jack: {
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
runtimePid,
|
||||||
|
runtimeRunId: bootstrapRunId,
|
||||||
|
tmuxPaneId: `process:${runtimePid}`,
|
||||||
|
backendType: 'process',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
firstSpawnAcceptedAt: acceptedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ launchPhase: 'active' }
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_submit_rejected',
|
||||||
|
timestamp: new Date(Date.now() - 10_000).toISOString(),
|
||||||
|
pid: runtimePid,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapRunId,
|
||||||
|
retryable: false,
|
||||||
|
detail: 'fatal submit rejection',
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
runtimeDiagnosticSeverity: 'error',
|
||||||
|
});
|
||||||
|
expect(result.statuses.jack?.hardFailureReason).toBe(
|
||||||
|
'bootstrap submit rejected: fatal submit rejection'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not classify the bootstrap instruction prompt as a member launch failure', async () => {
|
it('does not classify the bootstrap instruction prompt as a member launch failure', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-bootstrap-prompt-not-failure';
|
const teamName = 'zz-unit-bootstrap-prompt-not-failure';
|
||||||
|
|
|
||||||
|
|
@ -2101,6 +2101,28 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks launch args when a secondary Codex provider reports a concrete auth issue', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
buildProviderAwareCliEnvMock.mockImplementation(
|
||||||
|
({ providerId, env }: { providerId?: string; env: NodeJS.ProcessEnv }) =>
|
||||||
|
Promise.resolve({
|
||||||
|
env,
|
||||||
|
authSource: providerId === 'codex' ? 'configured_api_key_missing' : 'none',
|
||||||
|
geminiRuntimeAuth: null,
|
||||||
|
connectionIssues:
|
||||||
|
providerId === 'codex' ? { codex: 'Codex CLI login status is not active' } : {},
|
||||||
|
warning: providerId === 'codex' ? 'Codex CLI login status is not active' : undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).buildCrossProviderMemberArgs('anthropic', [
|
||||||
|
{ name: 'alice', providerId: 'anthropic' },
|
||||||
|
{ name: 'jack', providerId: 'codex' },
|
||||||
|
])
|
||||||
|
).rejects.toThrow('Codex: Codex CLI login status is not active');
|
||||||
|
});
|
||||||
|
|
||||||
it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => {
|
it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import {
|
import {
|
||||||
getLegacyElectronUserDataCandidates,
|
getLegacyElectronUserDataCandidates,
|
||||||
migrateElectronUserDataDirectory,
|
migrateElectronUserDataDirectory,
|
||||||
|
shouldCopyElectronUserDataEntry,
|
||||||
type ElectronUserDataMigrationApp,
|
type ElectronUserDataMigrationApp,
|
||||||
} from '../../../src/main/utils/electronUserDataMigration';
|
} from '../../../src/main/utils/electronUserDataMigration';
|
||||||
|
|
||||||
|
|
@ -75,12 +76,63 @@ describe('electron userData migration', () => {
|
||||||
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
|
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
|
||||||
path.join(parentPath, 'Claude Agent Teams UI'),
|
path.join(parentPath, 'Claude Agent Teams UI'),
|
||||||
path.join(parentPath, 'claude-agent-teams-ui'),
|
path.join(parentPath, 'claude-agent-teams-ui'),
|
||||||
|
path.join(parentPath, 'agent-teams-ai'),
|
||||||
path.join(parentPath, 'claude-devtools'),
|
path.join(parentPath, 'claude-devtools'),
|
||||||
path.join(parentPath, 'claude-code-context'),
|
path.join(parentPath, 'claude-code-context'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('copies the complete legacy userData tree, including all current app-owned stores', async () => {
|
it('reuses populated legacy userData by default instead of copying it during startup', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(currentPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a cache-only new userData directory as populated', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(currentPath, 'Cache/Cache_Data/blob', 'cache');
|
||||||
|
writeFile(currentPath, 'Code Cache/js/cache', 'code cache');
|
||||||
|
writeFile(currentPath, 'Partitions/dev/Cache/Cache_Data/blob', 'partition cache');
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
|
||||||
const root = createTempRoot();
|
const root = createTempRoot();
|
||||||
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
||||||
const currentPath = path.join(root, 'Agent Teams UI');
|
const currentPath = path.join(root, 'Agent Teams UI');
|
||||||
|
|
@ -102,14 +154,43 @@ describe('electron userData migration', () => {
|
||||||
['opencode-bridge/command-leases.json', '{"leases":[]}'],
|
['opencode-bridge/command-leases.json', '{"leases":[]}'],
|
||||||
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
|
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
|
||||||
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
|
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
|
||||||
|
['IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'renderer indexeddb bytes'],
|
||||||
|
['Partitions/dev/Local Storage/leveldb/000003.log', 'dev partition localStorage bytes'],
|
||||||
|
[
|
||||||
|
'Partitions/dev/IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log',
|
||||||
|
'dev partition indexeddb bytes',
|
||||||
|
],
|
||||||
['future-feature/state.json', '{"kept":true}'],
|
['future-feature/state.json', '{"kept":true}'],
|
||||||
] as const;
|
] as const;
|
||||||
|
const transientFiles = [
|
||||||
|
['Cache/Cache_Data/blob', 'http cache'],
|
||||||
|
['Code Cache/js/cache', 'code cache'],
|
||||||
|
['GPUCache/data_0', 'gpu cache'],
|
||||||
|
['DawnGraphiteCache/data_0', 'graphite cache'],
|
||||||
|
['DawnWebGPUCache/data_0', 'webgpu cache'],
|
||||||
|
['Crashpad/settings.dat', 'crashpad state'],
|
||||||
|
['Session Storage/000003.log', 'session storage'],
|
||||||
|
['Local Storage/leveldb/LOCK', 'stale leveldb lock'],
|
||||||
|
['IndexedDB/http_localhost_5173.indexeddb.leveldb/LOCK', 'stale indexeddb lock'],
|
||||||
|
['Network Persistent State', 'network state'],
|
||||||
|
['DIPS', 'tracking protection state'],
|
||||||
|
['Trust Tokens', 'trust tokens'],
|
||||||
|
['Partitions/dev/Cache/Cache_Data/blob', 'partition http cache'],
|
||||||
|
['Partitions/dev/Code Cache/js/cache', 'partition code cache'],
|
||||||
|
['Partitions/dev/GPUCache/data_0', 'partition gpu cache'],
|
||||||
|
['Partitions/dev/Session Storage/000003.log', 'partition session storage'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
for (const [relativePath, content] of knownFiles) {
|
for (const [relativePath, content] of knownFiles) {
|
||||||
writeFile(legacyPath, relativePath, content);
|
writeFile(legacyPath, relativePath, content);
|
||||||
}
|
}
|
||||||
|
for (const [relativePath, content] of transientFiles) {
|
||||||
|
writeFile(legacyPath, relativePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath));
|
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath), {
|
||||||
|
strategy: 'copy',
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
currentPath,
|
currentPath,
|
||||||
|
|
@ -122,6 +203,9 @@ describe('electron userData migration', () => {
|
||||||
for (const [relativePath, content] of knownFiles) {
|
for (const [relativePath, content] of knownFiles) {
|
||||||
expect(readFile(currentPath, relativePath)).toBe(content);
|
expect(readFile(currentPath, relativePath)).toBe(content);
|
||||||
}
|
}
|
||||||
|
for (const [relativePath] of transientFiles) {
|
||||||
|
expect(fs.existsSync(path.join(currentPath, relativePath))).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
setAppDataBasePath(currentPath);
|
setAppDataBasePath(currentPath);
|
||||||
expect(getAppDataPath()).toBe(path.join(currentPath, 'data'));
|
expect(getAppDataPath()).toBe(path.join(currentPath, 'data'));
|
||||||
|
|
@ -143,6 +227,39 @@ describe('electron userData migration', () => {
|
||||||
).resolves.toBe(Buffer.from('task attachment').toString('base64'));
|
).resolves.toBe(Buffer.from('task attachment').toString('base64'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps unknown durable state but skips transient Chromium cache entries', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCopyElectronUserDataEntry(legacyPath, path.join(legacyPath, 'data/state.json'))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldCopyElectronUserDataEntry(
|
||||||
|
legacyPath,
|
||||||
|
path.join(legacyPath, 'future-feature/state.json')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldCopyElectronUserDataEntry(
|
||||||
|
legacyPath,
|
||||||
|
path.join(legacyPath, 'Partitions/dev/Local Storage/leveldb/000003.log')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldCopyElectronUserDataEntry(
|
||||||
|
legacyPath,
|
||||||
|
path.join(legacyPath, 'Partitions/dev/Cache/Cache_Data/blob')
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldCopyElectronUserDataEntry(
|
||||||
|
legacyPath,
|
||||||
|
path.join(legacyPath, 'Local Storage/leveldb/LOCK')
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not merge legacy data into an already populated new userData directory', () => {
|
it('does not merge legacy data into an already populated new userData directory', () => {
|
||||||
const root = createTempRoot();
|
const root = createTempRoot();
|
||||||
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
||||||
|
|
@ -172,6 +289,7 @@ describe('electron userData migration', () => {
|
||||||
|
|
||||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
const result = migrateElectronUserDataDirectory(app, {
|
const result = migrateElectronUserDataDirectory(app, {
|
||||||
|
strategy: 'copy',
|
||||||
copyDirectory: () => {
|
copyDirectory: () => {
|
||||||
throw new Error('copy denied');
|
throw new Error('copy denied');
|
||||||
},
|
},
|
||||||
|
|
@ -199,6 +317,7 @@ describe('electron userData migration', () => {
|
||||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(app, {
|
const result = migrateElectronUserDataDirectory(app, {
|
||||||
|
strategy: 'copy',
|
||||||
copyDirectory: () => {
|
copyDirectory: () => {
|
||||||
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
|
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
|
||||||
throw new Error('destination appeared');
|
throw new Error('destination appeared');
|
||||||
|
|
@ -226,6 +345,7 @@ describe('electron userData migration', () => {
|
||||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(app, {
|
const result = migrateElectronUserDataDirectory(app, {
|
||||||
|
strategy: 'copy',
|
||||||
copyDirectory: () => {
|
copyDirectory: () => {
|
||||||
throw new Error('copy denied');
|
throw new Error('copy denied');
|
||||||
},
|
},
|
||||||
|
|
@ -270,16 +390,21 @@ describe('electron userData migration', () => {
|
||||||
|
|
||||||
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath));
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
currentPath,
|
currentPath,
|
||||||
legacyPath,
|
legacyPath,
|
||||||
migrated: true,
|
migrated: false,
|
||||||
fallbackToLegacy: false,
|
fallbackToLegacy: false,
|
||||||
reason: 'migrated',
|
reason: 'legacy-reused',
|
||||||
});
|
});
|
||||||
expect(readFile(currentPath, 'mcp-configs/legacy.json')).toBe('{}');
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers populated older legacy data over an empty newer legacy directory', () => {
|
it('prefers populated older legacy data over an empty newer legacy directory', () => {
|
||||||
|
|
@ -291,16 +416,23 @@ describe('electron userData migration', () => {
|
||||||
fs.mkdirSync(emptyNewerLegacyPath, { recursive: true });
|
fs.mkdirSync(emptyNewerLegacyPath, { recursive: true });
|
||||||
writeFile(populatedOlderLegacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
|
writeFile(populatedOlderLegacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath));
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
currentPath,
|
currentPath,
|
||||||
legacyPath: populatedOlderLegacyPath,
|
legacyPath: populatedOlderLegacyPath,
|
||||||
migrated: true,
|
migrated: false,
|
||||||
fallbackToLegacy: false,
|
fallbackToLegacy: false,
|
||||||
reason: 'migrated',
|
reason: 'legacy-reused',
|
||||||
});
|
});
|
||||||
expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release');
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: populatedOlderLegacyPath },
|
||||||
|
{ name: 'sessionData', value: populatedOlderLegacyPath },
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the pre-1.0 claude-devtools legacy directory when newer legacy data is absent', () => {
|
it('uses the pre-1.0 claude-devtools legacy directory when newer legacy data is absent', () => {
|
||||||
|
|
@ -310,15 +442,22 @@ describe('electron userData migration', () => {
|
||||||
|
|
||||||
writeFile(legacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
|
writeFile(legacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
|
||||||
|
|
||||||
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath));
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
currentPath,
|
currentPath,
|
||||||
legacyPath,
|
legacyPath,
|
||||||
migrated: true,
|
migrated: false,
|
||||||
fallbackToLegacy: false,
|
fallbackToLegacy: false,
|
||||||
reason: 'migrated',
|
reason: 'legacy-reused',
|
||||||
});
|
});
|
||||||
expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release');
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -837,6 +837,48 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not truncate long failed launch reasons on the member row', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const reason = `APIError - ${'Codex runtime context includes missing login session. '.repeat(
|
||||||
|
8
|
||||||
|
)}final diagnostic marker`;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberCard, {
|
||||||
|
member,
|
||||||
|
memberColor: 'blue',
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
spawnStatus: 'error',
|
||||||
|
spawnLaunchState: 'failed_to_start',
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
spawnError: reason,
|
||||||
|
spawnEntry: {
|
||||||
|
...failedSpawnEntry,
|
||||||
|
hardFailureReason: reason,
|
||||||
|
runtimeDiagnostic: reason,
|
||||||
|
},
|
||||||
|
onRestartMember: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]');
|
||||||
|
expect(failureReason?.textContent).toContain('final diagnostic marker');
|
||||||
|
expect(failureReason?.querySelector('.line-clamp-2')).toBeNull();
|
||||||
|
expect(failureReason?.textContent).not.toContain('...');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
|
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue