chore: checkpoint agent launch hardening
This commit is contained in:
parent
5730ddc7af
commit
26baaf6924
26 changed files with 5512 additions and 87 deletions
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# Agent launch architecture comparison research
|
||||
|
||||
Research date: 2026-05-07
|
||||
|
||||
Purpose: record factual research on how different systems launch or execute agents. This is informational context only, not an implementation recommendation.
|
||||
|
||||
## Scope
|
||||
|
||||
Systems compared:
|
||||
|
||||
| System | Repository / source | Snapshot |
|
||||
|---|---|---|
|
||||
| Our Agent Teams | Local `claude_team` + `agent_teams_orchestrator` | Local working tree, 2026-05-07 |
|
||||
| Paperclip | `paperclipai` docs/code research from earlier pass | Public docs / local research |
|
||||
| Gastown | `github.com/gastownhall/gastown` | cloned `cfbdf3c` |
|
||||
| GoClaw Enterprise / Teams | `github.com/nextlevelbuilder/goclaw` | cloned `a97e502` |
|
||||
| GoClaw OpenClaw-compatible gateway | `github.com/roelfdiedericks/goclaw` | cloned `6a7ccdb` |
|
||||
|
||||
Primary external references:
|
||||
|
||||
| Topic | Source |
|
||||
|---|---|
|
||||
| Gastown README | https://github.com/gastownhall/gastown/blob/main/README.md |
|
||||
| Gastown agent provider integration | https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md |
|
||||
| GoClaw agent loop | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/01-agent-loop.md |
|
||||
| GoClaw agent teams | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/11-agent-teams.md |
|
||||
| GoClaw team WS events | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/13-ws-team-events.md |
|
||||
| Paperclip agent runtime | https://github.com/paperclipai/docs/blob/main/agents-runtime.md |
|
||||
| Paperclip adapters overview | https://paperclip.inc/docs/adapters/overview/ |
|
||||
|
||||
## Short answer
|
||||
|
||||
There are four distinct launch/execution models:
|
||||
|
||||
| Model | Used by | Essence |
|
||||
|---|---|---|
|
||||
| External live CLI process | Our Agent Teams | App/orchestrator launches real teammate runtimes and tracks bootstrap, PID, stderr, process health, runtime evidence, task/message state. |
|
||||
| Bounded adapter run | Paperclip | A heartbeat or job starts a short agent run, adapter invokes CLI/provider, result is captured, run exits or times out. |
|
||||
| Tmux session orchestration | Gastown | `tmux` is the universal runtime adapter. Agents run in terminal sessions, receive input through tmux, and are observed through panes/session state. |
|
||||
| In-process agent loop | GoClaw Enterprise / Teams | Agent execution is a Go `Loop.Run(ctx, RunRequest)` scheduled through lanes. The agent is a logical loop inside the gateway, not necessarily a separate CLI teammate process. |
|
||||
|
||||
## What “in-process agent loop” removes from our live teammate product
|
||||
|
||||
In-process loop does not mean “bad”. It is often cleaner. But compared to our external process teammate model, it removes or changes several product properties.
|
||||
|
||||
| Product property | External process teammate, our model | In-process GoClaw-style loop |
|
||||
|---|---|---|
|
||||
| Real process identity | Each teammate can have PID/RSS/stdout/stderr/process lifetime. | Agent run is a gateway invocation; no independent teammate PID by default. |
|
||||
| CLI-realism | Claude/Codex/OpenCode behave as their real CLI runtimes, including auth, prompts, provider errors, stderr quirks. | Provider/driver behavior is normalized inside gateway; fewer raw CLI lifecycle surfaces. |
|
||||
| Per-member restart semantics | Restart means kill/relaunch or reattach a concrete runtime for that member. | Restart is usually cancel/reschedule a logical run/session. |
|
||||
| Bootstrap evidence | We can distinguish process alive, bootstrap submitted, bootstrap confirmed, delivery proof, task proof. | The loop itself is already the controlled runtime; less need for low-level bootstrap proof. |
|
||||
| UI runtime cards | UI can show memory, process state, liveness source, failed/stalled bootstrap, exact runtime diagnostics. | UI tends to show run/session/task status rather than OS/process-level teammate state. |
|
||||
| TTY/process debugging | Process/tmux mode can expose raw CLI behavior when needed. | Debugging is gateway traces/events/logs, not a live CLI pane/process per member. |
|
||||
| Failure classes | Auth prompt, no stdin, CLI did not submit bootstrap, process died, stale PID, provider CLI stderr. | Mostly provider/tool/session/run errors inside the loop. |
|
||||
| Isolation boundary | OS process boundary per teammate. | Mostly logical/session isolation inside one gateway process, unless it delegates to external providers/tools. |
|
||||
|
||||
Important distinction: in-process loop is simpler and can be more stable for gateway/chat products. It is not a drop-in replacement for a desktop product whose value includes live external teammate runtimes.
|
||||
|
||||
## Our Agent Teams launch/execution model
|
||||
|
||||
Our current direction is app-managed live external teammate runtime.
|
||||
|
||||
Observed local architecture:
|
||||
|
||||
| Layer | Role |
|
||||
|---|---|
|
||||
| `claude_team` Electron app | UI, provisioning, runtime projection, team messages, tasks, diagnostics, retries. |
|
||||
| `agent_teams_orchestrator` | Multi-agent runtime orchestration, teammate spawning, provider/runtime bridging. |
|
||||
| Process backend | Default for app-launched teammates after recent changes. Launch-owned processes are tracked as runtime entities. |
|
||||
| Optional tmux mode | Debug/manual mode, not production default. Useful for real TTY inspection. |
|
||||
| App-managed bootstrap | Backend injects/records startup context and requires durable readiness evidence instead of trusting “process exists”. |
|
||||
| Runtime projection | Maps launch state, process liveness, bootstrap proof, delivery proof, task state and diagnostics to UI. |
|
||||
|
||||
Key properties:
|
||||
|
||||
| Dimension | Current behavior |
|
||||
|---|---|
|
||||
| Agent lifetime | Long-lived teammate process/session, not just one request. |
|
||||
| Availability proof | Process alive is not enough. Need bootstrap/runtime evidence. |
|
||||
| Provider mix | Claude, Codex, OpenCode can coexist in one team. |
|
||||
| User experience | Live team room: cards, memory, tasks, messages, runtime errors, restart/retry controls. |
|
||||
| Complexity cost | High. Many edge cases around launch, cleanup, stale state, delivery, work-sync, retries. |
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Live team product fit | 9.2/10 |
|
||||
| Mixed provider fidelity | 8.7/10 |
|
||||
| Runtime proof strictness | 8.8/10 |
|
||||
| Simplicity | 5.8/10 |
|
||||
| Maintainability today | 7.2/10 |
|
||||
| Overall technical score | 8.5/10 |
|
||||
|
||||
## Paperclip launch/execution model
|
||||
|
||||
Paperclip is closest to a bounded job/heartbeat runner.
|
||||
|
||||
Research summary from earlier pass:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Agent invocation | Heartbeat or scheduled run calls adapter execution. |
|
||||
| Runtime | Adapter starts/calls CLI or provider, captures output/status/errors. |
|
||||
| Lifecycle | Run exits, times out, or is cancelled. |
|
||||
| Concurrency | Wakeups coalesce if agent is already running. |
|
||||
| Persistence | Status/logs/tokens/errors are stored per run. |
|
||||
|
||||
This is operationally clean because there is no expectation that every teammate is a continuously alive process with card-level runtime state.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Bounded execution design | 9.1/10 |
|
||||
| Simplicity | 8.8/10 |
|
||||
| Failure boundedness | 8.7/10 |
|
||||
| Live teammate room fit | 6.3/10 |
|
||||
| External CLI fidelity | 7.5/10 |
|
||||
| Overall technical score | 8.2/10 |
|
||||
|
||||
## Gastown launch/execution model
|
||||
|
||||
Gastown is tmux-first.
|
||||
|
||||
Facts from `gastownhall/gastown`:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Main runtime adapter | `tmux` sessions. |
|
||||
| Universal integration | Any CLI that runs in terminal can be started and controlled. |
|
||||
| Work unit | Beads/issues and convoys. |
|
||||
| Worker identity | Polecats have persistent identity and reusable worktrees. |
|
||||
| Session lifetime | Sessions are ephemeral; identity and sandbox can persist. |
|
||||
| Communication | Mail, nudges, hooks, Beads state, tmux input/output. |
|
||||
| Monitoring | Witness, Deacon, Dogs, Doctor, cleanup commands. |
|
||||
| Provider integration | Built-in/custom presets with command, args, env, process names, hooks, readiness delay/prompt. |
|
||||
|
||||
Gastown explicitly documents a Tier 0 tmux shim: start CLI in tmux, send work through keystrokes, detect liveness through pane process, read output through captured pane. It also notes that this level is timing-sensitive and lacks delivery confirmation.
|
||||
|
||||
Core model:
|
||||
|
||||
```text
|
||||
gt sling <bead> <rig>
|
||||
-> allocate or reuse polecat identity/worktree
|
||||
-> create tmux session
|
||||
-> set env: GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GT_AGENT, etc.
|
||||
-> inject startup beacon / prompt / hook context
|
||||
-> nudge with instructions if provider needs fallback
|
||||
-> Witness/Deacon patrol health and cleanup
|
||||
```
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Terminal-native ops | 9.0/10 |
|
||||
| Persistent worker identity | 8.7/10 |
|
||||
| Cleanup / doctor culture | 8.8/10 |
|
||||
| Delivery proof strictness | 6.4/10 |
|
||||
| Live product state consistency | 6.8/10 |
|
||||
| Overall technical score | 8.0/10 |
|
||||
|
||||
## GoClaw Enterprise / Teams launch/execution model
|
||||
|
||||
This is `nextlevelbuilder/goclaw`, the relevant GoClaw for agent teams.
|
||||
|
||||
Core architecture from docs/code:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Agent unit | `Loop` configured with provider, model, tools, workspace and agent type. |
|
||||
| Run entrypoint | `Loop.Run(ctx, RunRequest)`. |
|
||||
| Loop pattern | Think -> Act -> Observe, with max iterations and tool execution. |
|
||||
| Scheduler | First-class lane scheduler. |
|
||||
| Lanes | `main`, `subagent`, `team`, `cron`. |
|
||||
| Queueing | Per-session queues with debounce, drop policy, max concurrent. |
|
||||
| Team model | Lead/member, task board, mailbox, delegation. |
|
||||
| Task semantics | Atomic claim, status lifecycle, dependencies, blocker escalation, task events. |
|
||||
| Events | Typed WS events for delegation, tasks, team messages and agent lifecycle. |
|
||||
|
||||
Core execution shape:
|
||||
|
||||
```text
|
||||
Inbound message / teammate message / cron / delegation
|
||||
-> Scheduler.Schedule(lane, RunRequest)
|
||||
-> SessionQueue serializes or bounds per session
|
||||
-> Lane worker admits execution
|
||||
-> Router.Get(agentID)
|
||||
-> Loop.Run(ctx, req)
|
||||
-> Provider call + tools + finalization
|
||||
-> Events + stored session/task/trace state
|
||||
```
|
||||
|
||||
GoClaw team member execution is conceptually a scheduled agent run, not an externally spawned teammate CLI process with bootstrap/check-in.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Scheduler architecture | 9.2/10 |
|
||||
| Agent loop clarity | 8.9/10 |
|
||||
| Team task model | 8.8/10 |
|
||||
| Typed event model | 8.8/10 |
|
||||
| Real external teammate runtime fidelity | 6.6/10 |
|
||||
| Live process UI fit | 6.5/10 |
|
||||
| Overall technical score | 8.7/10 |
|
||||
|
||||
## GoClaw OpenClaw-compatible gateway model
|
||||
|
||||
This is `roelfdiedericks/goclaw`. It is a different project than `nextlevelbuilder/goclaw`.
|
||||
|
||||
High-level facts:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Product class | Personal AI gateway / OpenClaw-compatible bot runtime. |
|
||||
| Main strengths | Transcript search, memory graph, channels, persistent memory, delegated runs, ACP sessions. |
|
||||
| Delegated work | `subagent_spawn`, `subagent_fanout`, `subagent_status`, `subagent_cancel`. |
|
||||
| Runner | `DefaultRunner` starts active runs as goroutines with run IDs, timeout/cancel, optional concurrency lane semaphore. |
|
||||
| UI/control | `/runners` dashboard, SSE events, Telegram/TUI summaries. |
|
||||
| Cursor integration | ACP attachment to live Cursor session. |
|
||||
|
||||
Runner shape:
|
||||
|
||||
```text
|
||||
subagent_spawn / fanout
|
||||
-> DefaultRunner.Start(ctx, RunSpec)
|
||||
-> create RunRecord queued
|
||||
-> goroutine waits for lane admission
|
||||
-> execute function runs child work
|
||||
-> registry records completed/failed/canceled/timeout
|
||||
-> events emitted
|
||||
```
|
||||
|
||||
This is closer to Paperclip-style delegated bounded runs than to our live teammate process model.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Personal gateway/memory architecture | 8.8/10 |
|
||||
| Delegated run boundedness | 8.5/10 |
|
||||
| Channel/memory richness | 9.0/10 |
|
||||
| Live external teammate fidelity | 5.8/10 |
|
||||
| Team room fit | 6.4/10 |
|
||||
| Overall technical score | 8.1/10 |
|
||||
|
||||
## Direct comparison table
|
||||
|
||||
| System | Launch/execution primitive | Separate OS process per agent? | Long-lived teammate? | Task board | Team messages | Scheduler | Tmux | Best fit |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|---|
|
||||
| Our Agent Teams | Launch-owned external CLI/process runtime | Yes | Yes | Yes | Yes | Partial/ad-hoc today | Optional/debug | Desktop live mixed-provider team room |
|
||||
| Paperclip | Bounded adapter heartbeat run | Usually per run | No | Limited/not central | Not team-room focused | Yes, job-like | No core tmux | Reliable background/job agents |
|
||||
| Gastown | Tmux session + worktree + Beads | Yes, through tmux | Session ephemeral, identity persistent | Beads/convoys | Mail/nudges | Scheduler/capacity exists | Core | Terminal-native multi-agent ops |
|
||||
| GoClaw Enterprise | In-process scheduled agent loop | Not by default | Logical sessions/runs | Yes | Yes | First-class lanes | No core tmux | Multi-agent gateway/platform |
|
||||
| GoClaw OpenClaw-compatible | Delegated goroutine runner + gateway sessions | Not by default | Logical runs/sessions | Not primary team board in same way | Channels | Runner lane semaphore | No core tmux | Personal gateway, memory, delegated runs |
|
||||
|
||||
## Honest overall scores
|
||||
|
||||
| System | Overall technical score | Why |
|
||||
|---|---:|---|
|
||||
| GoClaw Enterprise / Teams | 8.7/10 | Cleanest scheduler/team/task/event architecture among compared systems. |
|
||||
| Our Agent Teams | 8.5/10 | Best fit for real live external Claude/Codex/OpenCode teammate product, but high complexity. |
|
||||
| Paperclip | 8.2/10 | Very clean bounded runtime model, but not a live team-room system. |
|
||||
| GoClaw OpenClaw-compatible | 8.1/10 | Strong personal gateway/memory/delegated run model, less comparable to our team runtime. |
|
||||
| Gastown | 8.0/10 | Strong terminal ops and lifecycle culture, but tmux-first delivery/readiness is less proof-strict. |
|
||||
|
||||
## Research conclusions
|
||||
|
||||
The systems optimize for different truths:
|
||||
|
||||
| System | Optimized for |
|
||||
|---|---|
|
||||
| Our Agent Teams | User-visible live team of real external coding agents. |
|
||||
| Paperclip | Bounded, simple, resumable background agent runs. |
|
||||
| Gastown | Terminal-native agent ops at scale with durable work identity. |
|
||||
| GoClaw Enterprise | Clean gateway-native multi-agent scheduling and team task orchestration. |
|
||||
| GoClaw OpenClaw-compatible | Long-memory personal agent gateway with delegated subruns. |
|
||||
|
||||
Most useful conceptual takeaways for future reference:
|
||||
|
||||
| Idea | Source | Why it matters |
|
||||
|---|---|---|
|
||||
| First-class scheduler lanes | GoClaw Enterprise | Separates main/team/subagent/cron load and makes cancellation/backpressure more deterministic. |
|
||||
| Typed team event catalog | GoClaw Enterprise | Makes UI and state transitions easier to reason about. |
|
||||
| Persistent identity vs ephemeral session | Gastown | Useful framing for member identity, runtime session, task ownership and cleanup. |
|
||||
| Bounded adapter runs | Paperclip | Good model for cron, background checks and non-live workers. |
|
||||
| Patrol/doctor cleanup culture | Gastown | Good operational model for stale runtime/process/data cleanup. |
|
||||
|
||||
Non-recommendation note: this document intentionally does not propose changing our architecture. It records observed models for future design discussions.
|
||||
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Paperclip agent launch research
|
||||
|
||||
Research date: 2026-05-07
|
||||
|
||||
This note records factual findings only. It is not an implementation plan and does not make recommendations.
|
||||
|
||||
## Scope
|
||||
|
||||
Compared Paperclip agent launch/runtime behavior with the local Agent Teams orchestrator.
|
||||
|
||||
Local orchestrator inspected:
|
||||
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/processBackend.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
|
||||
|
||||
Paperclip inspected from GitHub and a shallow local clone at `/tmp/paperclip-inspect`.
|
||||
|
||||
## Paperclip runtime model
|
||||
|
||||
Paperclip agents do not run continuously. They run in heartbeat windows triggered by wakeups such as timer, assignment, on-demand, or automation.
|
||||
|
||||
Each heartbeat starts an adapter, gives it prompt/context, lets it run until exit, timeout, or cancellation, stores run status/tokens/errors/logs, and updates UI.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/docs/agents-runtime.md
|
||||
|
||||
## Paperclip process execution
|
||||
|
||||
Paperclip uses child processes for local adapters. Normal execution is not tmux-based.
|
||||
|
||||
The shared process runner uses `node:child_process.spawn`, streams stdout/stderr, records pid and process group information, supports timeout, sends graceful termination, and escalates to kill after a grace period.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapter-utils/src/server-utils.ts
|
||||
|
||||
The generic process adapter is documented as executing arbitrary shell commands as child processes with env injection and exit-code based success/failure.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/docs/adapters/process.md
|
||||
|
||||
## Paperclip adapter examples
|
||||
|
||||
Claude local adapter:
|
||||
|
||||
- Uses `claude --print - --output-format stream-json --verbose`.
|
||||
- Supports session resume with `--resume`.
|
||||
- Supports model/effort/max-turns/append-system-prompt/add-dir style options.
|
||||
- Parses stream JSON for terminal result, session id, and usage.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/claude-local/src/server/execute.ts
|
||||
|
||||
Codex local adapter:
|
||||
|
||||
- Uses `codex exec --json ... -`.
|
||||
- Supports session continuation through `resume`.
|
||||
- Manages `CODEX_HOME`, injected skills/config/auth context, and fresh-session fallback paths.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/codex-local/src/server/execute.ts
|
||||
|
||||
OpenCode local adapter:
|
||||
|
||||
- Uses `opencode run --format json`.
|
||||
- Supports `--session`, `--model`, and `--variant`.
|
||||
- Uses temp config and model/session validation paths.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/opencode-local/src/server/execute.ts
|
||||
|
||||
## Paperclip orchestration/lifecycle
|
||||
|
||||
Paperclip stores heartbeat runs and events, updates agent runtime state, publishes live events, stores run logs, supports cancellation by pid/process group, and has recovery paths for lost/orphaned running runs.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/heartbeat.ts
|
||||
|
||||
It has a per-agent start lock so concurrent starts for the same agent are coalesced or blocked.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/agent-start-lock.ts
|
||||
|
||||
It also has run liveness classification/recovery paths for cases like empty or low-signal runs.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/run-liveness.ts
|
||||
|
||||
## Comparison facts
|
||||
|
||||
Paperclip is organized around short, resumable heartbeat runs. It waits for CLI run completion and records result/logs/state.
|
||||
|
||||
Agent Teams is organized around live team members, mixed providers, direct messages, tasks, work-sync, runtime evidence, and durable bootstrap/check-in proof.
|
||||
|
||||
Paperclip does not need the same live teammate readiness model because it does not maintain a long-running team room with continuously addressable members.
|
||||
|
||||
Agent Teams still supports tmux/pane backends in the orchestrator, but current app-launched teammates can use process backend with app-managed runtime evidence.
|
||||
|
||||
Paperclip's process lifecycle primitives are more centralized. Agent Teams has more live multi-agent protocol surface and therefore more runtime states to reconcile.
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,7 @@ import type {
|
|||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||
const COMPACT_ROW_TITLE_LIMIT = 24;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 76;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ function normalizeMemberName(value: string): string {
|
|||
}
|
||||
|
||||
function buildRenderedItemKey(memberName: string, itemId: string): string {
|
||||
return `${normalizeMemberName(memberName)}:${itemId}`;
|
||||
return JSON.stringify([normalizeMemberName(memberName), itemId]);
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
|
|
@ -120,8 +121,21 @@ function resolveEmptyText(
|
|||
return 'No recent logs';
|
||||
}
|
||||
|
||||
function fallbackDisplayTitle(item: MemberLogPreviewItem): string {
|
||||
if (item.kind === 'tool_result') {
|
||||
return item.tone === 'error' ? 'Tool error' : 'Tool result';
|
||||
}
|
||||
if (item.kind === 'tool_use') {
|
||||
return item.toolName?.trim() || 'Tool use';
|
||||
}
|
||||
if (item.kind === 'thinking') {
|
||||
return 'Thinking';
|
||||
}
|
||||
return item.tone === 'error' ? 'Error' : 'Log event';
|
||||
}
|
||||
|
||||
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
||||
const title = item.title.trim();
|
||||
const title = item.title.trim() || fallbackDisplayTitle(item);
|
||||
if (title.toLowerCase() === 'tool result') {
|
||||
return title;
|
||||
}
|
||||
|
|
@ -131,6 +145,14 @@ function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
|||
return title;
|
||||
}
|
||||
|
||||
function truncateCompactTitle(value: string): string {
|
||||
const compact = value.replace(/\s+/g, ' ').trim();
|
||||
if (compact.length <= COMPACT_ROW_TITLE_LIMIT) {
|
||||
return compact;
|
||||
}
|
||||
return `${compact.slice(0, COMPACT_ROW_TITLE_LIMIT - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
||||
const normalizedPreview = preview.toLowerCase();
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
|
|
@ -146,12 +168,16 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
|||
return preview;
|
||||
}
|
||||
|
||||
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
|
||||
function compactPreviewText(
|
||||
item: MemberLogPreviewItem,
|
||||
displayTitle: string,
|
||||
rawDisplayTitle = displayTitle
|
||||
): string {
|
||||
const preview = item.preview?.trim();
|
||||
if (preview) {
|
||||
const rawTitle = item.title.trim();
|
||||
const compact = trimRepeatedTitlePrefix(
|
||||
trimRepeatedTitlePrefix(preview, rawTitle),
|
||||
trimRepeatedTitlePrefix(trimRepeatedTitlePrefix(preview, rawTitle), rawDisplayTitle),
|
||||
displayTitle
|
||||
);
|
||||
return compact || preview;
|
||||
|
|
@ -177,6 +203,13 @@ function truncateCompactRowPreview(
|
|||
return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function compactRowLabel(parts: readonly (string | null | undefined)[]): string {
|
||||
return parts
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function setShellHidden(shell: HTMLDivElement): void {
|
||||
shell.style.opacity = '0';
|
||||
shell.style.pointerEvents = 'none';
|
||||
|
|
@ -426,12 +459,11 @@ export const GraphMemberLogPreviewHud = ({
|
|||
const renderItem = useCallback(
|
||||
(memberName: string, item: MemberLogPreviewItem) => {
|
||||
const relativeTime = formatRelativeTime(item.timestamp);
|
||||
const displayTitle = compactDisplayTitle(item);
|
||||
const fullPreviewText = compactPreviewText(item, displayTitle);
|
||||
const rawDisplayTitle = compactDisplayTitle(item);
|
||||
const displayTitle = truncateCompactTitle(rawDisplayTitle);
|
||||
const fullPreviewText = compactPreviewText(item, displayTitle, rawDisplayTitle);
|
||||
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
|
||||
const titleText = relativeTime
|
||||
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
||||
: `${displayTitle} ${fullPreviewText}`;
|
||||
const titleText = compactRowLabel([rawDisplayTitle, relativeTime, fullPreviewText]);
|
||||
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
|
||||
const isError = item.tone === 'error';
|
||||
const rowStateClassName = isHighlighted
|
||||
|
|
@ -442,28 +474,29 @@ export const GraphMemberLogPreviewHud = ({
|
|||
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
||||
const iconClassName = isError
|
||||
? 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'inline align-baseline';
|
||||
const titleClassName = isError
|
||||
? 'align-baseline text-[11px] font-medium leading-[18px] text-rose-100'
|
||||
: 'align-baseline text-[11px] font-medium leading-[18px] text-slate-200';
|
||||
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100'
|
||||
: 'align-baseline text-[11px] font-medium leading-5 text-slate-200';
|
||||
const timeClassName = isError
|
||||
? 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-rose-300/70'
|
||||
: 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-slate-500';
|
||||
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70'
|
||||
: 'ml-1 align-baseline text-[9px] font-normal leading-5 text-slate-500';
|
||||
const previewClassName = isError
|
||||
? 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-rose-100/85'
|
||||
: 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-slate-300/85';
|
||||
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85'
|
||||
: 'ml-1 break-words align-baseline text-[10px] leading-5 text-slate-300/85';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
'block h-[68px] min-h-[68px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
|
||||
'block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
|
||||
rowStateClassName,
|
||||
].join(' ')}
|
||||
title={titleText}
|
||||
aria-label={titleText}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
|
|
@ -517,7 +550,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-1 flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<div className="flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<Wrench className="size-3 text-slate-500" />
|
||||
Logs
|
||||
</div>
|
||||
|
|
@ -527,7 +560,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[68px] min-h-[68px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{resolveEmptyText(preview, loading, error)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { CodexAccountSnapshotDto } from './dto';
|
||||
import type { CodexAccountSnapshotDto, CodexChatgptLoginMode } from './dto';
|
||||
|
||||
export interface CodexStartChatgptLoginOptions {
|
||||
mode?: CodexChatgptLoginMode;
|
||||
}
|
||||
|
||||
export interface CodexAccountElectronApi {
|
||||
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
|
||||
|
|
@ -6,7 +10,9 @@ export interface CodexAccountElectronApi {
|
|||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}) => Promise<CodexAccountSnapshotDto>;
|
||||
startCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
||||
startCodexChatgptLogin: (
|
||||
options?: CodexStartChatgptLoginOptions
|
||||
) => Promise<CodexAccountSnapshotDto>;
|
||||
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
||||
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
|
||||
onCodexAccountSnapshotChanged: (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type CodexAccountAppServerState =
|
|||
| 'runtime-missing'
|
||||
| 'incompatible';
|
||||
export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled';
|
||||
export type CodexChatgptLoginMode = 'browser' | 'device_code';
|
||||
export type CodexLaunchReadinessState =
|
||||
| 'ready_chatgpt'
|
||||
| 'ready_api_key'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
CODEX_ACCOUNT_LOGOUT,
|
||||
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
|
||||
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
|
||||
type CodexStartChatgptLoginOptions,
|
||||
} from '@features/codex-account/contracts';
|
||||
|
||||
import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature';
|
||||
|
|
@ -19,7 +20,10 @@ export function registerCodexAccountIpc(
|
|||
(_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) =>
|
||||
feature.refreshSnapshot(options)
|
||||
);
|
||||
ipcMain.handle(CODEX_ACCOUNT_START_CHATGPT_LOGIN, () => feature.startChatgptLogin());
|
||||
ipcMain.handle(
|
||||
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
|
||||
(_event, options?: CodexStartChatgptLoginOptions) => feature.startChatgptLogin(options)
|
||||
);
|
||||
ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin());
|
||||
ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
type CodexAccountAuthMode,
|
||||
type CodexAccountSnapshotDto,
|
||||
type CodexApiKeyAvailabilityDto,
|
||||
type CodexChatgptLoginMode,
|
||||
type CodexCreditsSnapshotDto,
|
||||
type CodexLoginStateDto,
|
||||
type CodexManagedAccountDto,
|
||||
|
|
@ -234,7 +235,7 @@ export interface CodexAccountFeatureFacade {
|
|||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto>;
|
||||
startChatgptLogin(): Promise<CodexAccountSnapshotDto>;
|
||||
startChatgptLogin(options?: { mode?: CodexChatgptLoginMode }): Promise<CodexAccountSnapshotDto>;
|
||||
cancelLogin(): Promise<CodexAccountSnapshotDto>;
|
||||
logout(): Promise<CodexAccountSnapshotDto>;
|
||||
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
|
||||
|
|
@ -323,7 +324,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async startChatgptLogin(): Promise<CodexAccountSnapshotDto> {
|
||||
async startChatgptLogin(options?: {
|
||||
mode?: CodexChatgptLoginMode;
|
||||
}): Promise<CodexAccountSnapshotDto> {
|
||||
let binaryMissing = false;
|
||||
await this.runSerializedMutation(async () => {
|
||||
const binaryPath = await CodexBinaryResolver.resolve();
|
||||
|
|
@ -333,7 +336,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
|
||||
await this.loginSessionManager.start({ binaryPath, env });
|
||||
await this.loginSessionManager.start({ binaryPath, env, mode: options?.mode });
|
||||
});
|
||||
|
||||
if (binaryMissing) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
type CodexAppServerSession,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
|
||||
import type { CodexChatgptLoginMode, CodexLoginStateDto } from '@features/codex-account/contracts';
|
||||
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
const LOGIN_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
|
@ -59,7 +59,11 @@ export class CodexLoginSessionManager {
|
|||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async start(options: { binaryPath: string; env: NodeJS.ProcessEnv }): Promise<void> {
|
||||
async start(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
mode?: CodexChatgptLoginMode;
|
||||
}): Promise<void> {
|
||||
if (this.activeSession || this.pendingStartToken) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -89,9 +93,11 @@ export class CodexLoginSessionManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const requestedResponseType =
|
||||
options.mode === 'device_code' ? 'chatgptDeviceCode' : 'chatgpt';
|
||||
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
||||
'account/login/start',
|
||||
{ type: 'chatgptDeviceCode' },
|
||||
{ type: requestedResponseType },
|
||||
LOGIN_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
|
|
@ -100,16 +106,19 @@ export class CodexLoginSessionManager {
|
|||
return;
|
||||
}
|
||||
|
||||
if (response.type !== 'chatgptDeviceCode') {
|
||||
if (response.type !== requestedResponseType) {
|
||||
throw new Error('Codex app-server returned an unexpected login response type');
|
||||
}
|
||||
|
||||
const authUrl = new URL(response.verificationUrl);
|
||||
const authUrl = new URL(
|
||||
response.type === 'chatgptDeviceCode' ? response.verificationUrl : response.authUrl
|
||||
);
|
||||
if (authUrl.protocol !== 'https:') {
|
||||
throw new Error('Codex app-server returned a non-https auth URL');
|
||||
}
|
||||
|
||||
if (!response.userCode.trim()) {
|
||||
const userCode = response.type === 'chatgptDeviceCode' ? response.userCode.trim() : null;
|
||||
if (response.type === 'chatgptDeviceCode' && !userCode) {
|
||||
throw new Error('Codex app-server returned an empty ChatGPT login code');
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +152,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: authUrl.toString(),
|
||||
userCode: response.userCode,
|
||||
userCode,
|
||||
});
|
||||
} catch (error) {
|
||||
const wasAbandonedDuringStart =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexLoginSessionManager } from '../CodexLoginSessionManager';
|
||||
|
||||
import type {
|
||||
CodexAppServerLoginAccountResponse,
|
||||
CodexAppServerSession,
|
||||
CodexAppServerSessionFactory,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
function createSessionManagerHarness(loginResponse: CodexAppServerLoginAccountResponse): {
|
||||
manager: CodexLoginSessionManager;
|
||||
session: CodexAppServerSession;
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
emitNotification: (method: string, params: unknown) => void;
|
||||
} {
|
||||
let notificationListener: ((method: string, params: unknown) => void) | null = null;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === 'account/login/start') {
|
||||
return loginResponse;
|
||||
}
|
||||
if (method === 'account/login/cancel') {
|
||||
return { status: 'canceled' };
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const session = {
|
||||
initializeResponse: {
|
||||
userAgent: 'codex-cli 0.125.0',
|
||||
codexHome: '/Users/me/.codex',
|
||||
platformFamily: 'macos',
|
||||
platformOs: 'darwin',
|
||||
},
|
||||
request,
|
||||
notify: async () => undefined,
|
||||
onNotification: (listener: (method: string, params: unknown) => void) => {
|
||||
notificationListener = listener;
|
||||
return () => {
|
||||
notificationListener = null;
|
||||
};
|
||||
},
|
||||
close: vi.fn(async () => undefined),
|
||||
} as unknown as CodexAppServerSession;
|
||||
|
||||
const factory = {
|
||||
openSession: vi.fn(async () => session),
|
||||
} as unknown as CodexAppServerSessionFactory;
|
||||
|
||||
return {
|
||||
manager: new CodexLoginSessionManager(factory, { warn: () => undefined }),
|
||||
session,
|
||||
request,
|
||||
emitNotification: (method, params) => {
|
||||
notificationListener?.(method, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexLoginSessionManager', () => {
|
||||
it('uses the documented ChatGPT browser flow by default', async () => {
|
||||
const { manager, request } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'account/login/start',
|
||||
{ type: 'chatgpt' },
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'pending',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
userCode: null,
|
||||
});
|
||||
|
||||
await manager.cancel();
|
||||
});
|
||||
|
||||
it('uses the documented device-code flow only when explicitly requested', async () => {
|
||||
const { manager, request } = createSessionManagerHarness({
|
||||
type: 'chatgptDeviceCode',
|
||||
loginId: 'device-login',
|
||||
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
});
|
||||
|
||||
await manager.start({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
mode: 'device_code',
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'account/login/start',
|
||||
{ type: 'chatgptDeviceCode' },
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'pending',
|
||||
authUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
});
|
||||
|
||||
await manager.cancel();
|
||||
});
|
||||
|
||||
it('rejects a non-https browser auth URL', async () => {
|
||||
const { manager } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl: 'http://chatgpt.com/auth',
|
||||
});
|
||||
|
||||
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
|
||||
'non-https auth URL'
|
||||
);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a device-code response for the default browser flow', async () => {
|
||||
const { manager } = createSessionManagerHarness({
|
||||
type: 'chatgptDeviceCode',
|
||||
loginId: 'device-login',
|
||||
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
});
|
||||
|
||||
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
|
||||
'unexpected login response type'
|
||||
);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a browser response for the explicit device-code flow', async () => {
|
||||
const { manager } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
mode: 'device_code',
|
||||
})
|
||||
).rejects.toThrow('unexpected login response type');
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty device-code user code', async () => {
|
||||
const { manager } = createSessionManagerHarness({
|
||||
type: 'chatgptDeviceCode',
|
||||
loginId: 'device-login',
|
||||
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: ' ',
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
mode: 'device_code',
|
||||
})
|
||||
).rejects.toThrow('empty ChatGPT login code');
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the active login pending when an unrelated completion notification arrives', async () => {
|
||||
const { manager, emitNotification } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||
emitNotification('account/login/completed', {
|
||||
loginId: 'other-login',
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'pending',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await manager.cancel();
|
||||
});
|
||||
|
||||
it('clears login state when the matching browser login completes successfully', async () => {
|
||||
const { manager, emitNotification, session } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||
emitNotification('account/login/completed', {
|
||||
loginId: 'browser-login',
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(session.close).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'idle',
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps device-code details visible when the matching login fails', async () => {
|
||||
const { manager, emitNotification } = createSessionManagerHarness({
|
||||
type: 'chatgptDeviceCode',
|
||||
loginId: 'device-login',
|
||||
verificationUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
});
|
||||
|
||||
await manager.start({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
mode: 'device_code',
|
||||
});
|
||||
emitNotification('account/login/completed', {
|
||||
loginId: 'device-login',
|
||||
success: false,
|
||||
error: 'Login was not completed',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'Login was not completed',
|
||||
authUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels an active browser login through app-server and clears copied link state', async () => {
|
||||
const { manager, request, session } = createSessionManagerHarness({
|
||||
type: 'chatgpt',
|
||||
loginId: 'browser-login',
|
||||
authUrl:
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
|
||||
});
|
||||
|
||||
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
|
||||
await manager.cancel();
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'account/login/cancel',
|
||||
{ loginId: 'browser-login' },
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(session.close).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getState()).toMatchObject({
|
||||
status: 'cancelled',
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,8 @@ export function createCodexAccountBridge({
|
|||
getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT),
|
||||
refreshCodexAccountSnapshot: (options) =>
|
||||
ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options),
|
||||
startCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN),
|
||||
startCodexChatgptLogin: (options) =>
|
||||
ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN, options),
|
||||
cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN),
|
||||
logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT),
|
||||
onCodexAccountSnapshotChanged: (callback) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type {
|
||||
CodexAccountSnapshotDto,
|
||||
CodexChatgptLoginMode,
|
||||
} from '@features/codex-account/contracts';
|
||||
|
||||
const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
||||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||
|
|
@ -47,7 +50,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
forceRefreshToken?: boolean;
|
||||
silent?: boolean;
|
||||
}) => Promise<void>;
|
||||
startChatgptLogin: () => Promise<boolean>;
|
||||
startChatgptLogin: (mode?: CodexChatgptLoginMode) => Promise<boolean>;
|
||||
cancelChatgptLogin: () => Promise<boolean>;
|
||||
logout: () => Promise<boolean>;
|
||||
} {
|
||||
|
|
@ -223,7 +226,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
loading,
|
||||
error,
|
||||
refresh,
|
||||
startChatgptLogin: () => runAction(() => api.startCodexChatgptLogin()),
|
||||
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
|
||||
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
||||
logout: () => runAction(() => api.logoutCodexAccount()),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
|
|
@ -18,9 +16,7 @@ export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSour
|
|||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||
);
|
||||
const isCodexMember =
|
||||
member?.providerId === 'codex' ||
|
||||
member?.providerBackendId === 'codex-native' ||
|
||||
(member ? false : isLeadMember({ name: input.memberName }));
|
||||
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberLogStreamSource,
|
||||
MemberLogStreamSourceInput,
|
||||
|
|
@ -18,9 +16,7 @@ export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource
|
|||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||
);
|
||||
const isCodexMember =
|
||||
member?.providerId === 'codex' ||
|
||||
member?.providerBackendId === 'codex-native' ||
|
||||
(member ? false : isLeadMember({ name: input.memberName }));
|
||||
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
|
|
|||
|
|
@ -411,6 +411,9 @@ describe('CodexNativeMemberTraceStreamSource', () => {
|
|||
members: [{ name: 'alice', providerId: 'opencode' }],
|
||||
}),
|
||||
} as never);
|
||||
const unknownLeadSource = new CodexNativeMemberTraceStreamSource({
|
||||
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||
} as never);
|
||||
|
||||
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
|
||||
status: 'skipped',
|
||||
|
|
@ -420,22 +423,39 @@ describe('CodexNativeMemberTraceStreamSource', () => {
|
|||
status: 'skipped',
|
||||
warnings: [],
|
||||
});
|
||||
await expect(
|
||||
unknownLeadSource.load(sourceInput({ memberName: 'team-lead' }))
|
||||
).resolves.toMatchObject({
|
||||
status: 'skipped',
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexNativeMemberTracePreviewSource', () => {
|
||||
it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => {
|
||||
const source = new CodexNativeMemberTracePreviewSource({
|
||||
it('returns unsupported empty coverage for known Codex previews without breaking the batch', async () => {
|
||||
const codexSource = new CodexNativeMemberTracePreviewSource({
|
||||
getConfig: vi.fn().mockResolvedValue({
|
||||
members: [{ name: 'alice', providerId: 'codex' }],
|
||||
}),
|
||||
} as never);
|
||||
const unknownLeadSource = new CodexNativeMemberTracePreviewSource({
|
||||
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||
} as never);
|
||||
|
||||
await expect(source.loadPreview(previewInput())).resolves.toMatchObject({
|
||||
await expect(codexSource.loadPreview(previewInput())).resolves.toMatchObject({
|
||||
provider: 'codex_native_trace',
|
||||
status: 'skipped',
|
||||
items: [],
|
||||
warnings: [{ code: 'codex_member_wide_not_supported' }],
|
||||
});
|
||||
await expect(
|
||||
unknownLeadSource.loadPreview(previewInput({ memberName: 'team-lead' }))
|
||||
).resolves.toMatchObject({
|
||||
provider: 'codex_native_trace',
|
||||
status: 'skipped',
|
||||
items: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1757,6 +1757,19 @@ interface OpenCodeSecondaryRetryOutcome {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
type MemberLifecycleOperationKind =
|
||||
| 'manual_restart'
|
||||
| 'opencode_retry'
|
||||
| 'opencode_member_added'
|
||||
| 'opencode_member_updated'
|
||||
| 'opencode_member_removed';
|
||||
|
||||
interface MemberLifecycleOperation {
|
||||
kind: MemberLifecycleOperationKind;
|
||||
token: symbol;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? `${Math.max(0, Math.round(value))}ms`
|
||||
|
|
@ -5372,6 +5385,7 @@ export class TeamProvisioningService {
|
|||
string,
|
||||
Promise<RetryFailedOpenCodeSecondaryLanesResult>
|
||||
>();
|
||||
private readonly memberLifecycleOperations = new Map<string, MemberLifecycleOperation>();
|
||||
private memberRuntimeAdvisoryInvalidator:
|
||||
| ((teamName: string, memberName: string) => void)
|
||||
| null = null;
|
||||
|
|
@ -13081,7 +13095,79 @@ export class TeamProvisioningService {
|
|||
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
|
||||
}
|
||||
|
||||
private getMemberLifecycleOperationKey(teamName: string, memberName: string): string {
|
||||
return `${teamName.trim().toLowerCase()}\u0000${memberName.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
private getActiveMemberLifecycleOperation(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): MemberLifecycleOperation | null {
|
||||
return (
|
||||
this.memberLifecycleOperations.get(
|
||||
this.getMemberLifecycleOperationKey(teamName, memberName)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private isMemberLifecycleOperationActive(teamName: string, memberName: string): boolean {
|
||||
return this.getActiveMemberLifecycleOperation(teamName, memberName) !== null;
|
||||
}
|
||||
|
||||
private createMemberLifecycleOperationInProgressError(memberName: string): Error {
|
||||
return new Error(`Lifecycle operation for teammate "${memberName}" is already in progress`);
|
||||
}
|
||||
|
||||
private isMemberLifecycleOperationInProgressError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
/^Lifecycle operation for teammate ".+" is already in progress$/.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
private async runMemberLifecycleOperation<T>(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
kind: MemberLifecycleOperationKind,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = this.getMemberLifecycleOperationKey(teamName, memberName);
|
||||
if (this.memberLifecycleOperations.has(key)) {
|
||||
throw this.createMemberLifecycleOperationInProgressError(memberName);
|
||||
}
|
||||
|
||||
const token = Symbol(`${kind}:${teamName}:${memberName}`);
|
||||
this.memberLifecycleOperations.set(key, {
|
||||
kind,
|
||||
token,
|
||||
startedAtMs: Date.now(),
|
||||
});
|
||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (this.memberLifecycleOperations.get(key)?.token === token) {
|
||||
this.memberLifecycleOperations.delete(key);
|
||||
}
|
||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
private getOpenCodeReattachLifecycleKind(
|
||||
reason?: 'member_added' | 'member_updated' | 'manual_restart'
|
||||
): MemberLifecycleOperationKind {
|
||||
if (reason === 'member_added') return 'opencode_member_added';
|
||||
if (reason === 'member_updated') return 'opencode_member_updated';
|
||||
return 'manual_restart';
|
||||
}
|
||||
|
||||
async restartMember(teamName: string, memberName: string): Promise<void> {
|
||||
return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () =>
|
||||
this.restartMemberUnlocked(teamName, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
private async restartMemberUnlocked(teamName: string, memberName: string): Promise<void> {
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
throw new Error(`Team "${teamName}" is not currently running`);
|
||||
|
|
@ -13142,7 +13228,7 @@ export class TeamProvisioningService {
|
|||
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
||||
const desiredSecondaryLane = desiredProviderId === 'opencode' && leadProviderId !== 'opencode';
|
||||
if (liveSecondaryLaneMemberName === memberName || desiredSecondaryLane) {
|
||||
await this.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
|
||||
await this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, {
|
||||
reason: 'manual_restart',
|
||||
});
|
||||
return;
|
||||
|
|
@ -13447,9 +13533,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.reattachOpenCodeOwnedMemberLane(teamName, candidate.memberName, {
|
||||
reason: 'manual_restart',
|
||||
});
|
||||
await this.runMemberLifecycleOperation(
|
||||
teamName,
|
||||
candidate.memberName,
|
||||
'opencode_retry',
|
||||
() =>
|
||||
this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, candidate.memberName, {
|
||||
reason: 'manual_restart',
|
||||
})
|
||||
);
|
||||
result.attempted.push(candidate.memberName);
|
||||
|
||||
const outcome = await this.readOpenCodeSecondaryRetryOutcome(
|
||||
|
|
@ -13473,10 +13565,17 @@ export class TeamProvisioningService {
|
|||
result.pending.push(candidate.memberName);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed.push({
|
||||
memberName: candidate.memberName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (this.isMemberLifecycleOperationInProgressError(error)) {
|
||||
result.skipped.push({
|
||||
memberName: candidate.memberName,
|
||||
reason: 'Lifecycle operation already in progress',
|
||||
});
|
||||
} else {
|
||||
result.failed.push({
|
||||
memberName: candidate.memberName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -13904,6 +14003,19 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
memberName: string,
|
||||
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
|
||||
): Promise<void> {
|
||||
return this.runMemberLifecycleOperation(
|
||||
teamName,
|
||||
memberName,
|
||||
this.getOpenCodeReattachLifecycleKind(options?.reason),
|
||||
() => this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, options)
|
||||
);
|
||||
}
|
||||
|
||||
private async reattachOpenCodeOwnedMemberLaneUnlocked(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
|
||||
): Promise<void> {
|
||||
const run = this.getMutableAliveRunOrThrow(teamName);
|
||||
const leadProviderId = resolveTeamProviderId(run.request.providerId);
|
||||
|
|
@ -14058,6 +14170,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
|
||||
return this.runMemberLifecycleOperation(teamName, memberName, 'opencode_member_removed', () =>
|
||||
this.detachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
private async detachOpenCodeOwnedMemberLaneUnlocked(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<void> {
|
||||
const run = this.getMutableAliveRunOrThrow(teamName);
|
||||
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
|
||||
matchesTeamMemberIdentity(lane.member.name, memberName)
|
||||
|
|
@ -16529,15 +16650,53 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private flushStdoutParserCarry(run: ProvisioningRun): void {
|
||||
const trimmed = run.stdoutParserCarry.trim();
|
||||
const stdoutParserCarry =
|
||||
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
|
||||
const trimmed = stdoutParserCarry.trim();
|
||||
if (!trimmed || !run.stdoutParserCarryIsCompleteJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[${run.teamName}] Flushing final stream-json stdout carry before process close handling`,
|
||||
this.buildStdoutCarryDiagnostic(run)
|
||||
);
|
||||
this.handleStdoutParserLine(run, trimmed);
|
||||
this.updateStdoutParserCarry(run, '');
|
||||
}
|
||||
|
||||
private buildStdoutCarryDiagnostic(run: ProvisioningRun): Record<string, unknown> {
|
||||
const stdoutParserCarry =
|
||||
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
|
||||
const diagnostic: Record<string, unknown> = {
|
||||
runId: run.runId,
|
||||
stdoutCarryLength: stdoutParserCarry.length,
|
||||
stdoutCarryCompleteJson: run.stdoutParserCarryIsCompleteJson === true,
|
||||
stdoutCarryLooksLikeClaudeJson: run.stdoutParserCarryLooksLikeClaudeJson === true,
|
||||
};
|
||||
|
||||
if (run.stdoutParserCarryIsCompleteJson === true) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdoutParserCarry.trim()) as Record<string, unknown>;
|
||||
diagnostic.messageType = typeof parsed.type === 'string' ? parsed.type : null;
|
||||
diagnostic.messageSubtype = typeof parsed.subtype === 'string' ? parsed.subtype : null;
|
||||
diagnostic.bootstrapEvent = typeof parsed.event === 'string' ? parsed.event : null;
|
||||
diagnostic.sequence = typeof parsed.seq === 'number' ? parsed.seq : null;
|
||||
} catch {
|
||||
diagnostic.messageType = null;
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
private getUnconfirmedBootstrapMemberNames(run: ProvisioningRun): string[] {
|
||||
return run.expectedMembers.filter((expected) => {
|
||||
const status = run.memberSpawnStatuses.get(expected);
|
||||
return status?.bootstrapConfirmed !== true && status?.skippedForLaunch !== true;
|
||||
});
|
||||
}
|
||||
|
||||
private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void {
|
||||
if (!trimmed) {
|
||||
return;
|
||||
|
|
@ -20366,6 +20525,9 @@ export class TeamProvisioningService {
|
|||
if (matchedRuntimeNames.length > 0) {
|
||||
continue;
|
||||
}
|
||||
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = run.memberSpawnStatuses.get(expected);
|
||||
if (
|
||||
|
|
@ -20399,22 +20561,26 @@ export class TeamProvisioningService {
|
|||
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
||||
continue;
|
||||
}
|
||||
const hasExistingFailure =
|
||||
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
|
||||
continue;
|
||||
}
|
||||
const hasExistingTerminalFailure =
|
||||
prev.status === 'error' ||
|
||||
prev.launchState === 'failed_to_start' ||
|
||||
prev.hardFailure === true ||
|
||||
Boolean(prev.error) ||
|
||||
Boolean(prev.hardFailureReason);
|
||||
if (options?.preserveExistingFailure && hasExistingFailure) {
|
||||
continue;
|
||||
}
|
||||
const preservedFailureReason =
|
||||
options?.preserveExistingFailure && hasExistingTerminalFailure
|
||||
? (prev.hardFailureReason ?? prev.error)?.trim()
|
||||
: undefined;
|
||||
|
||||
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
||||
const hardFailureReason = runtimeWasAlive
|
||||
const fallbackFailureReason = runtimeWasAlive
|
||||
? `${baseReason} Runtime process was alive after bootstrap failure${
|
||||
options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.'
|
||||
}`
|
||||
: baseReason;
|
||||
const hardFailureReason = preservedFailureReason || fallbackFailureReason;
|
||||
const next: MemberSpawnStatusEntry = {
|
||||
...prev,
|
||||
status: 'error',
|
||||
|
|
@ -28356,6 +28522,15 @@ export class TeamProvisioningService {
|
|||
: run.progress.state === 'failed' && run.progress.message.trim()
|
||||
? run.progress.message.trim()
|
||||
: 'Launch ended before teammate bootstrap completed.';
|
||||
logger.warn(`[${run.teamName}] Launch cleanup finalizing unconfirmed bootstrap members`, {
|
||||
runId: run.runId,
|
||||
progressState: run.progress.state,
|
||||
progressMessage: run.progress.message,
|
||||
progressError: run.progress.error ?? null,
|
||||
cleanupReason,
|
||||
unconfirmedMembers: this.getUnconfirmedBootstrapMemberNames(run),
|
||||
...this.buildStdoutCarryDiagnostic(run),
|
||||
});
|
||||
this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, {
|
||||
cleanupRequested: true,
|
||||
preserveExistingFailure: true,
|
||||
|
|
@ -28640,6 +28815,10 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private isProvisioningRunFailed(run: ProvisioningRun): boolean {
|
||||
return run.progress.state === 'failed';
|
||||
}
|
||||
|
||||
private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise<void> {
|
||||
if (run.finalizingByTimeout) {
|
||||
return;
|
||||
|
|
@ -28655,7 +28834,25 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry.trim() : '') &&
|
||||
!run.stdoutParserCarryIsCompleteJson &&
|
||||
run.stdoutParserCarryLooksLikeClaudeJson
|
||||
) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] Process closed with incomplete stream-json stdout carry`,
|
||||
this.buildStdoutCarryDiagnostic(run)
|
||||
);
|
||||
}
|
||||
this.flushStdoutParserCarry(run);
|
||||
if (
|
||||
this.isProvisioningRunFailed(run) ||
|
||||
run.cancelRequested ||
|
||||
run.processKilled ||
|
||||
run.authRetryInProgress
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
||||
// During respawn, the old process exit fires but run.stallCheckHandle already
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import {
|
|||
createEmptyMemberLogStreamResponse,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type {
|
||||
CodexAccountSnapshotDto,
|
||||
CodexStartChatgptLoginOptions,
|
||||
} from '@features/codex-account/contracts';
|
||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||
|
|
@ -240,7 +243,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
}): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
startCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
||||
startCodexChatgptLogin = (
|
||||
_options?: CodexStartChatgptLoginOptions
|
||||
): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ interface InstalledBannerProps {
|
|||
onProviderManage: (providerId: CliProviderId) => void;
|
||||
onProviderRefresh: (providerId: CliProviderId) => void;
|
||||
onCodexReconnect: () => void;
|
||||
onCodexDeviceCodeLogin: () => void;
|
||||
codexReconnectBusy: boolean;
|
||||
variant: BannerVariant;
|
||||
}
|
||||
|
|
@ -584,6 +585,7 @@ const InstalledBanner = ({
|
|||
onProviderManage,
|
||||
onProviderRefresh,
|
||||
onCodexReconnect,
|
||||
onCodexDeviceCodeLogin,
|
||||
codexReconnectBusy,
|
||||
variant,
|
||||
}: InstalledBannerProps): React.JSX.Element => {
|
||||
|
|
@ -899,6 +901,21 @@ const InstalledBanner = ({
|
|||
size="xs"
|
||||
/>
|
||||
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||
{!codexLoginAuthUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCodexDeviceCodeLogin}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.22)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.05)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Use code
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -1161,7 +1178,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const handleCodexDashboardLogin = useCallback(() => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin();
|
||||
await codexAccount.startChatgptLogin('browser');
|
||||
})();
|
||||
}, [codexAccount]);
|
||||
|
||||
const handleCodexDashboardDeviceCodeLogin = useCallback(() => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin('device_code');
|
||||
})();
|
||||
}, [codexAccount]);
|
||||
|
||||
|
|
@ -1412,6 +1435,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant="info"
|
||||
/>
|
||||
|
|
@ -1637,6 +1661,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
|
|
@ -1696,6 +1721,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
|
|
@ -1915,6 +1941,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexLoginLinkCopyButton, CodexLoginUserCodeBadge } from './CodexLoginLinkCopyButton';
|
||||
|
||||
async function renderNode(
|
||||
node: React.ReactElement
|
||||
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return {
|
||||
host,
|
||||
cleanup: async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubClipboard(writeText: ReturnType<typeof vi.fn>): void {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodexLoginLinkCopyButton', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders nothing until an auth URL is available', async () => {
|
||||
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={null} />);
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('copies only the browser login URL when no user code is present', async () => {
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
stubClipboard(writeText);
|
||||
const authUrl =
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
|
||||
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={authUrl} />);
|
||||
|
||||
await act(async () => {
|
||||
host.querySelector('button')?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(authUrl);
|
||||
expect(host.textContent).toContain('Copied');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('copies the device login URL and code together', async () => {
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
stubClipboard(writeText);
|
||||
const { host, cleanup } = await renderNode(
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl="https://auth.openai.com/codex/device"
|
||||
userCode="ABCD-1234"
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
host.querySelector('button')?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('https://auth.openai.com/codex/device\nCode: ABCD-1234');
|
||||
expect(host.textContent).toContain('Copied');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('shows copy failure when clipboard write fails', async () => {
|
||||
const writeText = vi.fn(async () => {
|
||||
throw new Error('clipboard denied');
|
||||
});
|
||||
stubClipboard(writeText);
|
||||
const { host, cleanup } = await renderNode(
|
||||
<CodexLoginLinkCopyButton authUrl="https://chatgpt.com/auth" />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
host.querySelector('button')?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Copy failed');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('renders a user code badge only for device-code login state', async () => {
|
||||
const empty = await renderNode(<CodexLoginUserCodeBadge userCode={null} />);
|
||||
expect(empty.host.textContent).toBe('');
|
||||
await empty.cleanup();
|
||||
|
||||
const filled = await renderNode(<CodexLoginUserCodeBadge userCode="ABCD-1234" />);
|
||||
expect(filled.host.textContent).toContain('Code');
|
||||
expect(filled.host.textContent).toContain('ABCD-1234');
|
||||
await filled.cleanup();
|
||||
});
|
||||
});
|
||||
|
|
@ -1019,9 +1019,11 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCodexStartLogin = async (): Promise<void> => {
|
||||
const handleCodexStartLogin = async (
|
||||
mode: 'browser' | 'device_code' = 'browser'
|
||||
): Promise<void> => {
|
||||
setConnectionError(null);
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
const success = await codexAccount.startChatgptLogin(mode);
|
||||
if (!success && codexAccount.error) {
|
||||
setConnectionError(codexAccount.error);
|
||||
}
|
||||
|
|
@ -1444,7 +1446,16 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexStartLogin()}
|
||||
onClick={() => void handleCodexStartLogin('device_code')}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
Use code
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexStartLogin('browser')}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function renderPrompt(
|
||||
props: React.ComponentProps<typeof CodexReconnectPrompt>
|
||||
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CodexReconnectPrompt {...props} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return {
|
||||
host,
|
||||
cleanup: async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getButton(host: HTMLElement, label: string): HTMLButtonElement {
|
||||
const button = [...host.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(label)
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button not found: ${label}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
describe('CodexReconnectPrompt', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('offers browser login and device-code fallback before a link exists', async () => {
|
||||
const onReconnect = vi.fn();
|
||||
const onDeviceCodeReconnect = vi.fn();
|
||||
const { host, cleanup } = await renderPrompt({
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
reconnectBusy: false,
|
||||
onReconnect,
|
||||
onDeviceCodeReconnect,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getButton(host, 'Generate link').click();
|
||||
getButton(host, 'Use code').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||
expect(onDeviceCodeReconnect).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('opens the generated browser link without starting another login', async () => {
|
||||
const onReconnect = vi.fn();
|
||||
const onDeviceCodeReconnect = vi.fn();
|
||||
const authUrl =
|
||||
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
|
||||
const { host, cleanup } = await renderPrompt({
|
||||
authUrl,
|
||||
userCode: null,
|
||||
reconnectBusy: false,
|
||||
onReconnect,
|
||||
onDeviceCodeReconnect,
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Use code');
|
||||
|
||||
await act(async () => {
|
||||
getButton(host, 'Open login').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.openExternal).toHaveBeenCalledWith(authUrl);
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
expect(onDeviceCodeReconnect).not.toHaveBeenCalled();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('shows the code badge for a pending device-code login', async () => {
|
||||
const { host, cleanup } = await renderPrompt({
|
||||
authUrl: 'https://auth.openai.com/codex/device',
|
||||
userCode: 'ABCD-1234',
|
||||
reconnectBusy: false,
|
||||
onReconnect: vi.fn(),
|
||||
onDeviceCodeReconnect: vi.fn(),
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Code');
|
||||
expect(host.textContent).toContain('ABCD-1234');
|
||||
expect(host.textContent).toContain('Copy link + code');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,11 +68,13 @@ export const CodexReconnectPrompt = ({
|
|||
userCode,
|
||||
reconnectBusy,
|
||||
onReconnect,
|
||||
onDeviceCodeReconnect,
|
||||
}: {
|
||||
authUrl: string | null;
|
||||
userCode: string | null;
|
||||
reconnectBusy: boolean;
|
||||
onReconnect: () => void;
|
||||
onDeviceCodeReconnect: () => void;
|
||||
}): React.JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -94,6 +96,20 @@ export const CodexReconnectPrompt = ({
|
|||
disabled={reconnectBusy}
|
||||
size="xs"
|
||||
/>
|
||||
{!authUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeviceCodeReconnect}
|
||||
disabled={reconnectBusy}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.24)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.05)',
|
||||
}}
|
||||
>
|
||||
Use code
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -711,11 +711,14 @@ export const CreateTeamDialog = ({
|
|||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
const handleCodexReconnect = useCallback(() => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin();
|
||||
})();
|
||||
}, [codexAccount]);
|
||||
const handleCodexReconnect = useCallback(
|
||||
(mode: 'browser' | 'device_code' = 'browser') => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin(mode);
|
||||
})();
|
||||
},
|
||||
[codexAccount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
|
|
@ -2179,7 +2182,8 @@ export const CreateTeamDialog = ({
|
|||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
onReconnect={() => handleCodexReconnect('browser')}
|
||||
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -588,11 +588,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
const handleCodexReconnect = React.useCallback(() => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin();
|
||||
})();
|
||||
}, [codexAccount]);
|
||||
const handleCodexReconnect = React.useCallback(
|
||||
(mode: 'browser' | 'device_code' = 'browser') => {
|
||||
void (async () => {
|
||||
await codexAccount.startChatgptLogin(mode);
|
||||
})();
|
||||
},
|
||||
[codexAccount]
|
||||
);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
|
|
@ -2912,7 +2915,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
onReconnect={() => handleCodexReconnect('browser')}
|
||||
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1237,6 +1237,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
|
||||
it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => {
|
||||
allowConsoleLogs();
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createClaudeLogsRun({
|
||||
runId: 'run-cleanup-finalizes-launch',
|
||||
|
|
@ -1296,6 +1297,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
|
||||
it('preserves specific member launch failures when cleanup applies its fallback reason', () => {
|
||||
allowConsoleLogs();
|
||||
const svc = new TeamProvisioningService();
|
||||
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||
const specificReason = 'OpenCode bridge reported member launch failure';
|
||||
|
|
@ -1326,6 +1328,8 @@ describe('TeamProvisioningService', () => {
|
|||
hardFailure: true,
|
||||
hardFailureReason: specificReason,
|
||||
bootstrapConfirmed: false,
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
}),
|
||||
],
|
||||
[
|
||||
|
|
@ -1347,17 +1351,73 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
(svc as any).cleanupRun(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
const bobStatus = run.memberSpawnStatuses.get('bob');
|
||||
expect(bobStatus).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason: specificReason,
|
||||
runtimeAlive: false,
|
||||
runtimeDiagnostic:
|
||||
'Bootstrap failed before teammate check-in; launch-owned runtime cleanup requested.',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
expect(bobStatus?.livenessSource).toBeUndefined();
|
||||
expect(run.memberSpawnStatuses.get('carol')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason: timeoutReason,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat non-terminal error text as an existing launch failure during cleanup', () => {
|
||||
allowConsoleLogs();
|
||||
const svc = new TeamProvisioningService();
|
||||
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||
const run = createClaudeLogsRun({
|
||||
runId: 'run-cleanup-non-terminal-error-text',
|
||||
teamName: 'cleanup-non-terminal-error-text-team',
|
||||
isLaunch: true,
|
||||
provisioningComplete: false,
|
||||
cancelRequested: false,
|
||||
expectedMembers: ['bob'],
|
||||
provisioningOutputParts: [],
|
||||
progress: {
|
||||
runId: 'run-cleanup-non-terminal-error-text',
|
||||
teamName: 'cleanup-non-terminal-error-text-team',
|
||||
state: 'failed',
|
||||
message: 'Deterministic bootstrap failed',
|
||||
startedAt: '2026-04-19T10:00:00.000Z',
|
||||
updatedAt: '2026-04-19T10:00:01.000Z',
|
||||
error: timeoutReason,
|
||||
},
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
error: 'Transient runtime diagnostic, not terminal launch failure',
|
||||
agentToolAccepted: true,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
(svc as any).cleanupRun(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: timeoutReason,
|
||||
error: timeoutReason,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('member spawn status launch reads', () => {
|
||||
|
|
@ -12534,6 +12594,132 @@ describe('TeamProvisioningService', () => {
|
|||
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not verify provisioning again after flushing a final newline-less error result', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'launch-close-flushes-final-error-team';
|
||||
const leadSessionId = 'lead-session-final-error-flush';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
const child = createRunningChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
const waitForValidConfig = vi
|
||||
.spyOn(svc as any, 'waitForValidConfig')
|
||||
.mockResolvedValue({ ok: false });
|
||||
const progressStates: string[] = [];
|
||||
|
||||
await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
|
||||
progressStates.push(progress.state);
|
||||
});
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
subtype: 'error',
|
||||
error: 'runtime failed before bootstrap completed',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
child.emit('close', 1);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(waitForValidConfig).not.toHaveBeenCalled();
|
||||
expect(progressStates).toContain('failed');
|
||||
expect(progressStates).not.toContain('verifying');
|
||||
});
|
||||
|
||||
it('does not verify provisioning while auth retry is scheduled from final newline-less output', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'launch-close-flushes-final-auth-team';
|
||||
const leadSessionId = 'lead-session-final-auth-flush';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
const child = createRunningChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
const waitForValidConfig = vi
|
||||
.spyOn(svc as any, 'waitForValidConfig')
|
||||
.mockResolvedValue({ ok: false });
|
||||
const respawnAfterAuthFailure = vi
|
||||
.spyOn(svc as any, 'respawnAfterAuthFailure')
|
||||
.mockResolvedValue(undefined);
|
||||
const progressStates: string[] = [];
|
||||
|
||||
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
|
||||
progressStates.push(progress.state);
|
||||
});
|
||||
const run = (svc as any).runs.get(runId);
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'invalid api key' }],
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
child.emit('close', 1);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(run.authRetryInProgress).toBe(true);
|
||||
expect(respawnAfterAuthFailure).toHaveBeenCalledWith(run);
|
||||
expect(waitForValidConfig).not.toHaveBeenCalled();
|
||||
expect(progressStates).not.toContain('verifying');
|
||||
});
|
||||
|
||||
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -17975,7 +18161,7 @@ describe('TeamProvisioningService', () => {
|
|||
{ memberName: 'nova', laneId: 'secondary:opencode:nova' },
|
||||
]);
|
||||
const reattach = vi
|
||||
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLane')
|
||||
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('OpenCode bridge crashed'));
|
||||
|
|
@ -18015,4 +18201,247 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
expect(notify).toHaveBeenCalledWith(run, result);
|
||||
});
|
||||
|
||||
it('rejects a concurrent manual restart for the same teammate', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-lifecycle-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
|
||||
const configReady = createDeferred<{
|
||||
name: string;
|
||||
members: Array<{ name: string; agentType?: string }>;
|
||||
}>();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(() => configReady.promise),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).sendMessageToRun = vi.fn(async () => undefined);
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
const firstRestart = svc.restartMember(run.teamName, 'bob');
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(svc.restartMember(run.teamName, 'bob')).rejects.toThrow(
|
||||
'Lifecycle operation for teammate "bob" is already in progress'
|
||||
);
|
||||
|
||||
configReady.resolve({
|
||||
name: 'Codex Lifecycle Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
});
|
||||
await firstRestart;
|
||||
});
|
||||
|
||||
it('does not let one teammate lifecycle operation block another teammate or team', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const aliceDone = createDeferred<void>();
|
||||
const firstOperation = (svc as any).runMemberLifecycleOperation(
|
||||
'same-team',
|
||||
'alice',
|
||||
'manual_restart',
|
||||
() => aliceDone.promise
|
||||
);
|
||||
|
||||
await expect(
|
||||
(svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () =>
|
||||
'bob-ok'
|
||||
)
|
||||
).resolves.toBe('bob-ok');
|
||||
|
||||
await expect(
|
||||
(svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () =>
|
||||
'other-ok'
|
||||
)
|
||||
).resolves.toBe('other-ok');
|
||||
|
||||
aliceDone.resolve();
|
||||
await firstOperation;
|
||||
});
|
||||
|
||||
it('skips busy OpenCode retry candidates while continuing other candidates', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'mixed-retry-busy-team',
|
||||
runId: 'run-mixed-retry-busy',
|
||||
expectedMembers: ['alice', 'tom'],
|
||||
});
|
||||
run.isLaunch = true;
|
||||
run.provisioningComplete = true;
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
const busyDone = createDeferred<void>();
|
||||
const busyOperation = (svc as any).runMemberLifecycleOperation(
|
||||
run.teamName,
|
||||
'alice',
|
||||
'manual_restart',
|
||||
() => busyDone.promise
|
||||
);
|
||||
|
||||
vi.spyOn(svc as any, 'collectFailedOpenCodeSecondaryRetryCandidates').mockResolvedValue([
|
||||
{ memberName: 'alice', laneId: 'secondary:opencode:alice' },
|
||||
{ memberName: 'tom', laneId: 'secondary:opencode:tom' },
|
||||
]);
|
||||
const reattach = vi
|
||||
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.spyOn(svc as any, 'readOpenCodeSecondaryRetryOutcome').mockResolvedValue({
|
||||
launchState: 'confirmed_alive',
|
||||
});
|
||||
vi.spyOn(svc as any, 'notifyLeadAboutConfirmedOpenCodeRetries').mockResolvedValue(undefined);
|
||||
|
||||
const result = await svc.retryFailedOpenCodeSecondaryLanes(run.teamName);
|
||||
|
||||
expect(reattach).toHaveBeenCalledTimes(1);
|
||||
expect(reattach).toHaveBeenCalledWith(run.teamName, 'tom', { reason: 'manual_restart' });
|
||||
expect(result).toMatchObject({
|
||||
attempted: ['tom'],
|
||||
confirmed: ['tom'],
|
||||
skipped: [
|
||||
{
|
||||
memberName: 'alice',
|
||||
reason: 'Lifecycle operation already in progress',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
busyDone.resolve();
|
||||
await busyOperation;
|
||||
});
|
||||
|
||||
it('blocks manual restart while an OpenCode member update reattach is active', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const reattachDone = createDeferred<void>();
|
||||
const reattachOperation = (svc as any).runMemberLifecycleOperation(
|
||||
'mixed-update-team',
|
||||
'bob',
|
||||
'opencode_member_updated',
|
||||
() => reattachDone.promise
|
||||
);
|
||||
|
||||
await expect(svc.restartMember('mixed-update-team', 'bob')).rejects.toThrow(
|
||||
'Lifecycle operation for teammate "bob" is already in progress'
|
||||
);
|
||||
|
||||
reattachDone.resolve();
|
||||
await reattachOperation;
|
||||
});
|
||||
|
||||
it('blocks manual restart while an OpenCode member removal detach is active', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const detachDone = createDeferred<void>();
|
||||
const detachOperation = (svc as any).runMemberLifecycleOperation(
|
||||
'mixed-remove-team',
|
||||
'bob',
|
||||
'opencode_member_removed',
|
||||
() => detachDone.promise
|
||||
);
|
||||
|
||||
await expect(svc.restartMember('mixed-remove-team', 'bob')).rejects.toThrow(
|
||||
'Lifecycle operation for teammate "bob" is already in progress'
|
||||
);
|
||||
|
||||
detachDone.resolve();
|
||||
await detachOperation;
|
||||
});
|
||||
|
||||
it('does not let launch cleanup overwrite a teammate with an active lifecycle operation', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'cleanup-guard-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const lifecycleDone = createDeferred<void>();
|
||||
const lifecycleOperation = (svc as any).runMemberLifecycleOperation(
|
||||
run.teamName,
|
||||
'bob',
|
||||
'manual_restart',
|
||||
() => lifecycleDone.promise
|
||||
);
|
||||
|
||||
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
|
||||
cleanupRequested: true,
|
||||
});
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
hardFailure: false,
|
||||
});
|
||||
|
||||
lifecycleDone.resolve();
|
||||
await lifecycleOperation;
|
||||
});
|
||||
|
||||
it('still marks unconfirmed teammates failed during cleanup when no lifecycle operation is active', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'cleanup-no-guard-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
livenessSource: 'process',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
|
||||
cleanupRequested: true,
|
||||
});
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
expect(row).not.toBeUndefined();
|
||||
expect(row?.querySelector('.float-left')).not.toBeNull();
|
||||
expect(row?.querySelector('.line-clamp-3')).toBeNull();
|
||||
expect(row?.className).toContain('h-[68px]');
|
||||
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-[18px]');
|
||||
expect(row?.className).toContain('h-[72px]');
|
||||
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-5');
|
||||
expect(row?.textContent).toContain('pnpm test');
|
||||
|
||||
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
|
|
@ -564,6 +564,87 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders distinct empty states for unsupported and simply empty members', async () => {
|
||||
const codexNode: GraphNode = {
|
||||
id: 'member:alpha-team:codex-dev',
|
||||
kind: 'member',
|
||||
label: 'codex-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' },
|
||||
};
|
||||
const quietNode: GraphNode = {
|
||||
id: 'member:alpha-team:quiet-dev',
|
||||
kind: 'member',
|
||||
label: 'quiet-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
|
||||
};
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'codex-dev',
|
||||
{
|
||||
memberName: 'codex-dev',
|
||||
items: [],
|
||||
coverage: [{ provider: 'codex_native_trace', status: 'skipped' }],
|
||||
warnings: [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported',
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
[
|
||||
'quiet-dev',
|
||||
{
|
||||
memberName: 'quiet-dev',
|
||||
items: [],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[codexNode, quietNode]}
|
||||
getLogWorldRect={(ownerNodeId) => ({
|
||||
left: ownerNodeId.includes('quiet') ? 360 : 40,
|
||||
top: 80,
|
||||
right: ownerNodeId.includes('quiet') ? 620 : 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Unsupported provider');
|
||||
expect(host.textContent).toContain('No recent logs');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
|
|
@ -717,4 +798,155 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates long event titles without losing the full tooltip context', async () => {
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [
|
||||
{
|
||||
id: 'long-title-preview',
|
||||
kind: 'tool_use',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: '2026-04-03T00:01:00.000Z',
|
||||
title: 'Very long custom provider tool name',
|
||||
preview:
|
||||
'Very long custom provider tool name: compact body should still remain visible',
|
||||
tone: 'warning',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('compact body')
|
||||
);
|
||||
expect(row?.textContent).toContain('Very long custom prov...');
|
||||
expect(row?.textContent).toContain('compact body should still remain visible');
|
||||
expect(row?.textContent).not.toContain('Very long custom provider tool nameVery long');
|
||||
expect(row?.getAttribute('title')).toContain('Very long custom provider tool name');
|
||||
expect(row?.getAttribute('aria-label')).toContain('Very long custom provider tool name');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses safe fallback titles for malformed compact events', async () => {
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [
|
||||
{
|
||||
id: 'empty-title-result',
|
||||
kind: 'tool_result',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: '2026-04-03T00:01:00.000Z',
|
||||
title: ' ',
|
||||
preview: 'stored',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
id: 'empty-title-error',
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: 'bad timestamp',
|
||||
title: '',
|
||||
preview: 'provider returned malformed timestamp',
|
||||
tone: 'error',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Tool result');
|
||||
expect(host.textContent).toContain('stored');
|
||||
expect(host.textContent).toContain('Error');
|
||||
expect(host.textContent).toContain('provider returned malformed timestamp');
|
||||
expect(host.textContent).not.toContain('undefined');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue