docs: address README review feedback
This commit is contained in:
commit
fcbcc4b493
21
README.md
21
README.md
|
|
@ -104,9 +104,18 @@ Reviewer agents are configured separately. The current reviewer harnesses are:
|
|||
|
||||
**If it runs in a terminal, it runs on Agent Orchestrator.**
|
||||
|
||||
## Download
|
||||
## Install
|
||||
|
||||
Download the latest desktop build for your platform:
|
||||
The fastest path is the same flow used by the installation docs:
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
ao start
|
||||
```
|
||||
|
||||
Run `ao start` from the repository you want AO to manage. See the [installation guide](https://aoagents.dev/docs/installation) for pnpm, yarn, source installs, agent CLI setup, and troubleshooting.
|
||||
|
||||
You can also download the latest desktop build for your platform:
|
||||
|
||||
| Platform | Download |
|
||||
| -------- | ------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -120,17 +129,17 @@ Download the latest desktop build for your platform:
|
|||
<tr>
|
||||
<td width="33%">
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180">
|
||||
<img src="screenshots/first.png" width="100%" alt="Agent Orchestrator journey screenshot one" />
|
||||
<img src="screenshots/tweet1.png" width="100%" alt="Agent Orchestrator journey screenshot one" />
|
||||
</a>
|
||||
</td>
|
||||
<td width="37.5%">
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945">
|
||||
<img src="screenshots/second.png" width="100%" alt="Agent Orchestrator journey screenshot two" />
|
||||
<img src="screenshots/tweet2.png" width="100%" alt="Agent Orchestrator journey screenshot two" />
|
||||
</a>
|
||||
</td>
|
||||
<td width="29.5%">
|
||||
<a href="https://x.com/agent_wrapper/status/2024885035774738700">
|
||||
<img src="screenshots/image.png" width="100%" alt="Agent Orchestrator journey screenshot three" />
|
||||
<img src="screenshots/tweet3.png" width="100%" alt="Agent Orchestrator journey screenshot three" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -148,7 +157,7 @@ Download the latest desktop build for your platform:
|
|||
|
||||
## Telemetry
|
||||
|
||||
Agent Orchestrator collects minimal telemetry for reliability and product understanding. Data is stored locally by default; remote transmission is opt-in through environment variables. See [docs/telemetry.md](docs/telemetry.md).
|
||||
Agent Orchestrator's Electron renderer sends anonymous usage events to PostHog for reliability and product understanding, and PostHog session recording is enabled with local paths and local URLs redacted before transmission. Set `VITE_AO_POSTHOG_KEY` to an empty string before building to disable transmission. See [docs/telemetry.md](docs/telemetry.md).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ const (
|
|||
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
||||
// embedded template to diff against.
|
||||
kiroHookCommandPrefix = "ao hooks kiro "
|
||||
|
||||
kiroAgentName = "ao"
|
||||
kiroAgentDescription = "Agent Orchestrator session instructions"
|
||||
)
|
||||
|
||||
// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by
|
||||
// tests to decode the written file. Kiro hooks are a map of camelCase event
|
||||
// name to a flat array of {matcher?, command} entries.
|
||||
type kiroHookFile struct {
|
||||
Hooks map[string][]kiroHookEntry `json:"hooks"`
|
||||
Name string `json:"name"`
|
||||
Prompt *string `json:"prompt"`
|
||||
Hooks map[string][]kiroHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type kiroHookEntry struct {
|
||||
|
|
@ -97,7 +102,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
|
|||
}
|
||||
}
|
||||
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil {
|
||||
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil {
|
||||
|
|
@ -137,7 +142,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error
|
|||
}
|
||||
}
|
||||
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil {
|
||||
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -210,7 +215,11 @@ func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMess
|
|||
|
||||
// writeKiroHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
|
|
@ -235,6 +244,44 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess
|
|||
return nil
|
||||
}
|
||||
|
||||
func setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
defaults := map[string]any{
|
||||
"name": kiroAgentName,
|
||||
"description": kiroAgentDescription,
|
||||
"prompt": nil,
|
||||
"mcpServers": map[string]any{},
|
||||
"tools": []string{"*"},
|
||||
"toolAliases": map[string]any{},
|
||||
"allowedTools": []any{},
|
||||
"resources": []any{},
|
||||
"toolsSettings": map[string]any{},
|
||||
"includeMcpJson": true,
|
||||
}
|
||||
if systemPrompt != "" {
|
||||
defaults["prompt"] = systemPrompt
|
||||
}
|
||||
if model := strings.TrimSpace(agentConfig.Model); model != "" {
|
||||
defaults["model"] = model
|
||||
} else {
|
||||
delete(topLevel, "model")
|
||||
}
|
||||
|
||||
for key, value := range defaults {
|
||||
managedKey := key == "name" || key == "prompt" || key == "model"
|
||||
if !managedKey {
|
||||
if _, ok := topLevel[key]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode agent %s: %w", key, err)
|
||||
}
|
||||
topLevel[key] = data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
|
||||
// each event's array is rewritten once.
|
||||
func groupKiroHooksByEvent() map[string][]kiroHookSpec {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
// and reading hook-derived session info.
|
||||
//
|
||||
// Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the
|
||||
// `kiro-cli` binary and exposes a non-interactive ("headless") mode via
|
||||
// `kiro-cli chat --no-interactive "<prompt>"`, suitable for AO-driven worker
|
||||
// sessions. See https://kiro.dev/docs/cli/headless/ and
|
||||
// `kiro-cli` binary. AO launches Kiro with a workspace-local custom agent so
|
||||
// both worker and orchestrator sessions can use Kiro's normal interactive
|
||||
// approval flow. See https://kiro.dev/docs/cli/headless/ and
|
||||
// https://kiro.dev/docs/cli/reference/cli-commands/.
|
||||
//
|
||||
// Launch delivers the initial prompt as a positional argument after `--` so a
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -58,29 +59,47 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new headless Kiro session:
|
||||
// `kiro-cli chat --no-interactive [trust flags] -- <prompt>`.
|
||||
// GetLaunchCommand builds the argv to start a new Kiro session:
|
||||
// `kiro-cli chat --agent ao [trust flags] [-- <prompt>]`.
|
||||
//
|
||||
// The prompt is passed as a positional argument after `--` so a leading "-" is
|
||||
// not read as a flag. Kiro's --no-interactive mode requires a prompt argument.
|
||||
// not read as a flag. Kiro runs interactively for both workers and orchestrators;
|
||||
// standing instructions come from the generated custom agent.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.kiroBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "chat", "--no-interactive"}
|
||||
cmd = []string{binary, "chat", "--agent", kiroAgentName}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
prompt := cfg.Prompt
|
||||
if prompt != "" {
|
||||
cmd = append(cmd, "--", prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session:
|
||||
// `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`.
|
||||
// GetPromptDeliveryStrategy reports how Kiro receives the initial task prompt.
|
||||
// Orchestrator standing instructions are delivered through the generated
|
||||
// custom-agent prompt, so no command or post-start prompt injection is needed
|
||||
// there.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.Prompt != "" {
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
if cfg.Kind == domain.KindOrchestrator {
|
||||
return ports.PromptDeliveryCustomAgent, nil
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session.
|
||||
// ok is false when the hook-derived native session id has not landed yet, so
|
||||
// callers can fall back to fresh launch behavior.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
|
|
@ -97,8 +116,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 8)
|
||||
cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID)
|
||||
cmd = []string{binary, "chat", "--agent", kiroAgentName, "--resume-id", agentSessionID}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
|
@ -152,14 +170,14 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
|
|||
return binary, nil
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust
|
||||
// flags. Default emits no flag so Kiro defers to the user's own configuration
|
||||
// (the interactive per-tool prompt). accept-edits grants the write-capable
|
||||
// built-in tools; auto/bypass grant all tools.
|
||||
// appendApprovalFlags maps AO's permission modes onto Kiro's tool-trust flags.
|
||||
// Default emits no flag so Kiro uses its normal interactive approval flow.
|
||||
// accept-edits grants the write-capable built-in tools; auto/bypass grant all
|
||||
// tools.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Kiro config / per-tool prompting.
|
||||
// No flag: defer to Kiro's normal interactive approval flow.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--trust-tools=fs_read,fs_write")
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ func TestManifestIDIsKiro(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
||||
func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
|
|
@ -39,7 +40,8 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
|||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat", "--no-interactive",
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--trust-all-tools",
|
||||
"--", "-fix this",
|
||||
}
|
||||
|
|
@ -48,6 +50,64 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
SystemPrompt: "You are the human-facing coordinator.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "fix the failing test",
|
||||
SystemPrompt: "standing role instructions",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--", "fix the failing test",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -124,6 +184,33 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{Kind: domain.KindOrchestrator})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryCustomAgent {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
Prompt: "do this explicit task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
|
|
@ -182,7 +269,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
hooksPath := filepath.Join(hooksDir, kiroAgentFileName)
|
||||
existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
existing := `{"name":"stale","hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -190,6 +277,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
|
|
@ -204,13 +292,23 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The unmanaged top-level "name" key must be preserved.
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := topLevel["name"]; !ok {
|
||||
t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data)
|
||||
var name string
|
||||
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
|
||||
t.Fatalf("decode name from %s: %v", data, err)
|
||||
}
|
||||
if name != kiroAgentName {
|
||||
t.Fatalf("name = %q, want %q", name, kiroAgentName)
|
||||
}
|
||||
var prompt string
|
||||
if err := json.Unmarshal(topLevel["prompt"], &prompt); err != nil {
|
||||
t.Fatalf("decode prompt from %s: %v", data, err)
|
||||
}
|
||||
if prompt != "standing AO instructions" {
|
||||
t.Fatalf("prompt = %q, want system prompt", prompt)
|
||||
}
|
||||
|
||||
var config kiroHookFile
|
||||
|
|
@ -232,6 +330,167 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksCreatesNamedKiroAgentFile(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "exact orchestrator system prompt",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
|
||||
t.Fatalf("decode name from %s: %v", data, err)
|
||||
}
|
||||
if name != kiroAgentName {
|
||||
t.Fatalf("name = %q, want %q", name, kiroAgentName)
|
||||
}
|
||||
var config kiroHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Prompt == nil || *config.Prompt != "exact orchestrator system prompt" {
|
||||
t.Fatalf("prompt = %#v, want exact system prompt", config.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksWritesConfiguredModel(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
Config: ports.AgentConfig{Model: "claude-sonnet-4-5"},
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var model string
|
||||
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
|
||||
t.Fatalf("decode model from %s: %v", data, err)
|
||||
}
|
||||
if model != "claude-sonnet-4-5" {
|
||||
t.Fatalf("model = %q, want configured model", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksOverwritesStaleConfiguredModel(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
Config: ports.AgentConfig{Model: "project-model"},
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var model string
|
||||
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
|
||||
t.Fatalf("decode model from %s: %v", data, err)
|
||||
}
|
||||
if model != "project-model" {
|
||||
t.Fatalf("model = %q, want project override", model)
|
||||
}
|
||||
var tools []string
|
||||
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
|
||||
t.Fatalf("decode tools from %s: %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(tools, []string{"custom"}) {
|
||||
t.Fatalf("tools = %#v, want preserved custom tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksClearsStaleModelWhenConfigRemoved(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := topLevel["model"]; ok {
|
||||
t.Fatalf("model still present in %s, want cleared when config has no model", data)
|
||||
}
|
||||
var tools []string
|
||||
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
|
||||
t.Fatalf("decode tools from %s: %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(tools, []string{"custom"}) {
|
||||
t.Fatalf("tools = %#v, want preserved custom tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesKiroHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
|
|
@ -309,7 +568,8 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
|||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"kiro-cli", "chat", "--no-interactive",
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--resume-id", "uuid-123",
|
||||
"--trust-all-tools",
|
||||
}
|
||||
|
|
@ -318,6 +578,32 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
Permissions: ports.PermissionModeDefault,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--resume-id", "uuid-123",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ const (
|
|||
type LaunchConfig struct {
|
||||
Config AgentConfig
|
||||
IssueID string
|
||||
Kind domain.SessionKind
|
||||
Permissions PermissionMode
|
||||
Prompt string
|
||||
SessionID string
|
||||
|
|
@ -151,12 +152,14 @@ type WorkspaceHookConfig struct {
|
|||
Config AgentConfig
|
||||
DataDir string
|
||||
SessionID string
|
||||
SystemPrompt string
|
||||
WorkspacePath string
|
||||
}
|
||||
|
||||
// RestoreConfig carries inputs needed to continue an existing native agent session.
|
||||
type RestoreConfig struct {
|
||||
Config AgentConfig
|
||||
Kind domain.SessionKind
|
||||
Permissions PermissionMode
|
||||
Session SessionRef
|
||||
// SystemPrompt carries the session's standing instructions (e.g. the
|
||||
|
|
@ -217,6 +220,7 @@ type PromptDeliveryStrategy string
|
|||
|
||||
// How the orchestrator hands the initial prompt to a freshly launched agent.
|
||||
const (
|
||||
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
|
||||
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
|
||||
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
|
||||
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
|
||||
PromptDeliveryCustomAgent PromptDeliveryStrategy = "custom_agent"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -256,15 +256,16 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
|
||||
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: ws.Path,
|
||||
Kind: cfg.Kind,
|
||||
Prompt: prompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
IssueID: string(cfg.IssueID),
|
||||
|
|
@ -575,9 +576,6 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
if !ok {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
// The system prompt is derived, not persisted: recompute it so a restored
|
||||
// session keeps its standing instructions across the relaunch.
|
||||
systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID)
|
||||
|
|
@ -586,7 +584,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
}
|
||||
// Restore re-applies the project's resolved agent config so a configured
|
||||
// model/permissions carry across a restore, matching fresh spawn.
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config), rec.Kind)
|
||||
agentConfig := effectiveAgentConfig(rec.Kind, project.Config)
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind)
|
||||
if err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
|
|
@ -1297,11 +1299,13 @@ type preLauncher interface {
|
|||
// starts the agent: installing the workspace-local activity hooks (so early
|
||||
// startup hooks can update the already-created session row), then any optional
|
||||
// PreLaunch step. Shared by Spawn and Restore.
|
||||
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error {
|
||||
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
DataDir: m.dataDir,
|
||||
SystemPrompt: systemPrompt,
|
||||
Config: agentConfig,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("install hooks: %w", err)
|
||||
}
|
||||
|
|
@ -1326,7 +1330,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
|
|||
WorkspacePath: workspacePath,
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
|
||||
}
|
||||
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
|
||||
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Kind: kind, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore command: %w", err)
|
||||
}
|
||||
|
|
@ -1343,6 +1347,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
|
|||
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
Kind: kind,
|
||||
Prompt: meta.Prompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
Config: agentConfig,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ process.stderr.on("error", ignoreStdStreamError);
|
|||
// Must run before app ready so the About panel and default-menu role labels use it.
|
||||
app.setName("Agent Orchestrator");
|
||||
|
||||
// Windows shows native toasts only when the app declares an AppUserModelID that
|
||||
// matches its installer shortcut (the NSIS maker's appId). Without it,
|
||||
// Notification.isSupported() still returns true but show() silently drops the
|
||||
// toast, so notifications never appear. No-op on macOS/Linux.
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("dev.agent-orchestrator.desktop");
|
||||
}
|
||||
|
||||
// Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage,
|
||||
// crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS
|
||||
// default ~/Library/Application Support/<name>. Keeps the app's entire footprint
|
||||
|
|
@ -478,6 +486,7 @@ async function refreshDaemonStatus(): Promise<DaemonStatus> {
|
|||
setDaemonStatus({
|
||||
state: "stopped",
|
||||
message: "AO daemon is no longer reachable.",
|
||||
code: "daemon_unreachable",
|
||||
});
|
||||
}
|
||||
return daemonStatus;
|
||||
|
|
@ -518,6 +527,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
|||
setDaemonStatus({
|
||||
state: "stopped",
|
||||
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
|
||||
code: "not_configured",
|
||||
});
|
||||
return daemonStatus;
|
||||
}
|
||||
|
|
@ -640,6 +650,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
|||
setDaemonStatus({
|
||||
state: "error",
|
||||
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
|
||||
code: "binary_missing",
|
||||
});
|
||||
return daemonStatus;
|
||||
}
|
||||
|
|
@ -737,6 +748,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
|||
state: "ready",
|
||||
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
|
||||
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
|
||||
code: "port_unconfirmed",
|
||||
});
|
||||
}, PORT_DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
|
|
@ -745,17 +757,29 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
|||
if (daemonProcess !== child) return;
|
||||
daemonProcess = null;
|
||||
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
||||
setDaemonStatus({ state: "error", message: error.message });
|
||||
setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" });
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
stopDiscovery();
|
||||
if (daemonProcess !== child) return;
|
||||
daemonProcess = null;
|
||||
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
||||
// An explicit stopDaemon() already set a clean `{ state: "stopped" }`.
|
||||
// daemon-telemetry reports any status carrying a `code` as
|
||||
// ao.renderer.daemon_failure, so don't stamp `code: "exited"` on a stop
|
||||
// the user or app asked for — that would count intentional stops as
|
||||
// failures. Preserve the clean stopped status instead.
|
||||
if (daemonStoppingProcess === child) {
|
||||
daemonStoppingProcess = null;
|
||||
setDaemonStatus({ state: "stopped" });
|
||||
return;
|
||||
}
|
||||
setDaemonStatus({
|
||||
state: "stopped",
|
||||
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
|
||||
code: "exited",
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -38,20 +38,10 @@ function respondWithProjectAndPRs() {
|
|||
projectId: "proj-1",
|
||||
displayName: "fix the bug",
|
||||
harness: "claude-code",
|
||||
status: "pr_open",
|
||||
status: "draft",
|
||||
isTerminated: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
prs: [
|
||||
{
|
||||
number: 278,
|
||||
state: "open",
|
||||
url: "https://github.com/aoagents/ReverbCode/pull/278",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "clean",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
{
|
||||
number: 279,
|
||||
state: "draft",
|
||||
|
|
@ -62,6 +52,46 @@ function respondWithProjectAndPRs() {
|
|||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:20:04Z",
|
||||
},
|
||||
{
|
||||
number: 278,
|
||||
state: "open",
|
||||
url: "https://github.com/aoagents/ReverbCode/pull/278",
|
||||
ci: "passing",
|
||||
review: "review_required",
|
||||
mergeability: "clean",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
{
|
||||
number: 280,
|
||||
state: "open",
|
||||
url: "https://github.com/aoagents/ReverbCode/issues/280",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "clean",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:25:04Z",
|
||||
},
|
||||
{
|
||||
number: 281,
|
||||
state: "merged",
|
||||
url: "https://github.com/aoagents/ReverbCode/pull/281",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:30:04Z",
|
||||
},
|
||||
{
|
||||
number: 282,
|
||||
state: "closed",
|
||||
url: "https://github.com/aoagents/ReverbCode/pull/282",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "unknown",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:35:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -179,11 +209,40 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe("PR hydration for a normal project (#251)", () => {
|
||||
it("renders every session PR on the Board card instead of 'no PR yet'", async () => {
|
||||
it("renders Board card PR numbers with lifecycle statuses only instead of 'no PR yet'", async () => {
|
||||
renderWithProviders(<SessionsBoard />);
|
||||
|
||||
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument();
|
||||
expect(screen.getByText("PR #279 · draft")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/278",
|
||||
);
|
||||
expect(screen.getByText("open")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "#279" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/279",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "#280" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/280",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "#281" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/281",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "#282" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/282",
|
||||
);
|
||||
expect(screen.getByLabelText("#278, #280 open")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("#279 draft")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("#281 merged")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("#282 closed")).toBeInTheDocument();
|
||||
expect(screen.getByText("draft")).toBeInTheDocument();
|
||||
expect(screen.getByText("merged")).toHaveClass("text-accent");
|
||||
expect(screen.getByText("closed")).toHaveClass("text-error");
|
||||
expect(screen.queryByText("review pending")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CI")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs attention")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -191,14 +250,13 @@ describe("PR hydration for a normal project (#251)", () => {
|
|||
respondWithAttentionPR();
|
||||
renderWithProviders(<SessionsBoard />);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "PR" })).toHaveAttribute(
|
||||
expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/278",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "conflicts" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/aoagents/ReverbCode/pull/278/conflicts",
|
||||
);
|
||||
expect(screen.getByText("open")).toBeInTheDocument();
|
||||
expect(screen.queryByText("changes requested")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "conflicts" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every session PR on the PR page instead of being empty", async () => {
|
||||
|
|
@ -206,7 +264,10 @@ describe("PR hydration for a normal project (#251)", () => {
|
|||
|
||||
expect(await screen.findByText("#278")).toBeInTheDocument();
|
||||
expect(screen.getByText("#279")).toBeInTheDocument();
|
||||
expect(screen.getByText("#280")).toBeInTheDocument();
|
||||
expect(screen.getByText("#281")).toBeInTheDocument();
|
||||
expect(screen.getByText("#282")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("fix the bug")).toHaveLength(2);
|
||||
expect(screen.getAllByText("fix the bug")).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ export function CreateProjectAgentSheet({
|
|||
? agentsQuery.error.message
|
||||
: "Could not load agent catalog."
|
||||
: null;
|
||||
const displayError = refreshAgentsMutation.isError
|
||||
? refreshAgentsMutation.error instanceof Error
|
||||
? refreshAgentsMutation.error.message
|
||||
: "Could not refresh agent catalog."
|
||||
: agentsError;
|
||||
const [workerAgent, setWorkerAgent] = useState("");
|
||||
const [orchestratorAgent, setOrchestratorAgent] = useState("");
|
||||
const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE);
|
||||
|
|
@ -143,12 +148,13 @@ export function CreateProjectAgentSheet({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{agentsError && (
|
||||
{displayError && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
<span>{agentsError}</span>
|
||||
<span>{displayError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded text-foreground underline-offset-2 hover:underline"
|
||||
className="shrink-0 rounded text-foreground underline-offset-2 hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={refreshAgentsMutation.isPending}
|
||||
onClick={() => refreshAgentsMutation.mutate()}
|
||||
>
|
||||
Retry
|
||||
|
|
@ -156,14 +162,6 @@ export function CreateProjectAgentSheet({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{refreshAgentsMutation.isError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{refreshAgentsMutation.error instanceof Error
|
||||
? refreshAgentsMutation.error.message
|
||||
: "Could not refresh agent catalog."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Input } from "./ui/input";
|
|||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { captureRendererEvent } from "../lib/telemetry";
|
||||
import type { AgentProvider } from "../types/workspace";
|
||||
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
|||
|
||||
setIsSubmitting(true);
|
||||
setError(undefined);
|
||||
void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId });
|
||||
try {
|
||||
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
|
||||
body: {
|
||||
|
|
@ -101,9 +103,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
|||
});
|
||||
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
|
||||
if (!data?.session?.id) throw new Error("Task creation returned no session");
|
||||
void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId });
|
||||
onCreated(data.session.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId });
|
||||
void queryClient.invalidateQueries({ queryKey: agentsQueryKey });
|
||||
setError(err instanceof Error ? err.message : "Unable to start task");
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { aoBridge } from "../lib/bridge";
|
||||
import { formatTimeCompact } from "../lib/format-time";
|
||||
import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications";
|
||||
import { captureRendererEvent } from "../lib/telemetry";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
|||
(notification: NotificationDTO) => {
|
||||
const target = notification.target;
|
||||
if (target.kind === "pr" && target.prUrl) {
|
||||
void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" });
|
||||
window.open(target.prUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
const sessionId = target.sessionId || notification.sessionId;
|
||||
if (!sessionId) return;
|
||||
void captureRendererEvent("ao.renderer.notification_opened", { target: "session" });
|
||||
if (notification.projectId) {
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
|||
|
||||
const markOneRead = async (id: string) => {
|
||||
setActionError(null);
|
||||
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" });
|
||||
try {
|
||||
await markRead.mutateAsync(id);
|
||||
} catch (error) {
|
||||
|
|
@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
|||
|
||||
const markAll = async () => {
|
||||
setActionError(null);
|
||||
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" });
|
||||
try {
|
||||
await markAllRead.mutateAsync();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
import { PRSummaryParts } from "./PRSummaryDisplay";
|
||||
|
||||
const summary = (overrides: Partial<SessionPRSummary> = {}): SessionPRSummary => ({
|
||||
url: "https://github.com/acme/repo/pull/7",
|
||||
htmlUrl: "https://github.com/acme/repo/pull/7",
|
||||
number: 7,
|
||||
title: "Fix dashboard",
|
||||
state: "open",
|
||||
provider: "github",
|
||||
repo: "acme/repo",
|
||||
author: "ada",
|
||||
sourceBranch: "fix/dashboard",
|
||||
targetBranch: "main",
|
||||
headSha: "abc123",
|
||||
additions: 10,
|
||||
deletions: 3,
|
||||
changedFiles: 2,
|
||||
ci: { state: "passing", failingChecks: [] },
|
||||
review: { decision: "approved", hasUnresolvedHumanComments: false, unresolvedBy: [] },
|
||||
mergeability: { state: "mergeable", reasons: [], prUrl: "https://github.com/acme/repo/pull/7" },
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
observedAt: "2026-06-15T00:00:00Z",
|
||||
ciObservedAt: "2026-06-15T00:00:00Z",
|
||||
reviewObservedAt: "2026-06-15T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("PRSummaryParts", () => {
|
||||
it("counts overflow from the rendered maxLinks limit", () => {
|
||||
render(
|
||||
<PRSummaryParts
|
||||
interactiveLinks={false}
|
||||
maxLinks={2}
|
||||
pr={summary({
|
||||
ci: {
|
||||
state: "failing",
|
||||
failingChecks: [
|
||||
{ name: "unit", status: "failed", conclusion: "failure", url: "https://checks.example/unit" },
|
||||
{ name: "lint", status: "failed", conclusion: "failure", url: "https://checks.example/lint" },
|
||||
{ name: "types", status: "failed", conclusion: "failure", url: "https://checks.example/types" },
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("unit")).toBeInTheDocument();
|
||||
expect(screen.getByText("lint")).toBeInTheDocument();
|
||||
expect(screen.queryByText("types")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("+1 check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts overflow beyond helper-truncated links", () => {
|
||||
render(
|
||||
<PRSummaryParts
|
||||
interactiveLinks={false}
|
||||
pr={summary({
|
||||
ci: {
|
||||
state: "failing",
|
||||
failingChecks: [
|
||||
{ name: "unit", status: "failed", conclusion: "failure", url: "https://checks.example/unit" },
|
||||
{ name: "lint", status: "failed", conclusion: "failure", url: "https://checks.example/lint" },
|
||||
{ name: "types", status: "failed", conclusion: "failure", url: "https://checks.example/types" },
|
||||
{ name: "build", status: "failed", conclusion: "failure", url: "https://checks.example/build" },
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("unit")).toBeInTheDocument();
|
||||
expect(screen.getByText("lint")).toBeInTheDocument();
|
||||
expect(screen.getByText("types")).toBeInTheDocument();
|
||||
expect(screen.queryByText("build")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("+1 check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { ArrowUpDown, ArrowUpRight } from "lucide-react";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
import { prAttentionItems, prStatusRows, type PRAttentionLink, type PRDisplayTone } from "../lib/pr-display";
|
||||
import { prSummaryParts, type PRDisplayTone, type PRSummaryLink } from "../lib/pr-display";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const toneClass: Record<PRDisplayTone, string> = {
|
||||
|
|
@ -12,20 +12,6 @@ const toneClass: Record<PRDisplayTone, string> = {
|
|||
error: "text-error",
|
||||
};
|
||||
|
||||
export function PRStatusStrip({ className, pr }: { className?: string; pr: SessionPRSummary }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-x-3 gap-y-1 font-mono text-[10.5px]", className)}>
|
||||
{prStatusRows(pr).map((row) => (
|
||||
<span key={row.key} className="min-w-0">
|
||||
<span className="text-passive">{row.label}</span>{" "}
|
||||
<span className={cn("font-medium", toneClass[row.tone])}>{row.value}</span>
|
||||
{row.detail ? <span className="text-passive"> · {row.detail}</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRSummaryMeta({
|
||||
className,
|
||||
leading,
|
||||
|
|
@ -85,56 +71,66 @@ function PRDiffMeta({ pr }: { pr: SessionPRSummary }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PRAttentionPanel({
|
||||
export function PRSummaryParts({
|
||||
className,
|
||||
interactiveLinks = true,
|
||||
maxItems = 3,
|
||||
maxLinks = 3,
|
||||
pr,
|
||||
variant = "compact",
|
||||
}: {
|
||||
className?: string;
|
||||
interactiveLinks?: boolean;
|
||||
maxItems?: number;
|
||||
maxLinks?: number;
|
||||
pr: SessionPRSummary;
|
||||
variant?: "compact" | "stacked";
|
||||
}) {
|
||||
const items = prAttentionItems(pr);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const visible = items.slice(0, maxItems);
|
||||
const extra = items.length - visible.length;
|
||||
const parts = prSummaryParts(pr);
|
||||
const stacked = variant === "stacked";
|
||||
return (
|
||||
<div className={cn("mt-2 border-t border-border pt-2", className)}>
|
||||
<div className="mb-1 font-mono text-[9.5px] font-semibold uppercase tracking-[0.08em] text-passive">
|
||||
Needs attention
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{visible.map((item) => (
|
||||
<div key={item.kind} className="min-w-0 text-[11px] leading-4">
|
||||
<div className={cn("font-medium", toneClass[item.tone])}>{item.title}</div>
|
||||
{item.summary ? (
|
||||
<div className="truncate font-mono text-[10.5px] text-muted-foreground">{item.summary}</div>
|
||||
) : null}
|
||||
{item.links.length > 0 ? (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-1.5 gap-y-1 font-mono text-[10.5px]">
|
||||
{item.links.map((link, index) => (
|
||||
<AttentionLink
|
||||
interactive={interactiveLinks}
|
||||
key={`${item.kind}-${index}-${link.label}`}
|
||||
link={link}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
stacked
|
||||
? "flex flex-col gap-1.5 font-mono text-[10.5px] leading-4"
|
||||
: "flex flex-wrap gap-x-3 gap-y-1 font-mono text-[10.5px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{parts.map((part) => {
|
||||
const links = part.links.slice(0, maxLinks);
|
||||
const overflowLabel = overflowPartLabel(
|
||||
(part.linkTotal ?? part.links.length) - links.length,
|
||||
part.overflowNoun,
|
||||
);
|
||||
return (
|
||||
<div key={part.key} className={cn("min-w-0", stacked ? "flex flex-col" : "inline-flex flex-wrap gap-x-1")}>
|
||||
<div className="min-w-0 truncate">
|
||||
<span className="text-passive">{part.label}</span>{" "}
|
||||
<span className={cn("font-medium", toneClass[part.tone])}>{part.status}</span>
|
||||
{part.summary ? <span className="text-passive"> · {part.summary}</span> : null}
|
||||
</div>
|
||||
{links.length > 0 || overflowLabel ? (
|
||||
<div className={cn("flex min-w-0 flex-wrap gap-x-1.5 gap-y-1", stacked ? "mt-0.5" : "")}>
|
||||
{links.map((link, index) => (
|
||||
<SummaryLink interactive={interactiveLinks} key={`${part.key}-${index}-${link.label}`} link={link} />
|
||||
))}
|
||||
{item.overflowLabel ? <span className="text-passive">{item.overflowLabel}</span> : null}
|
||||
{overflowLabel ? <span className="text-passive">{overflowLabel}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{extra > 0 ? <div className="font-mono text-[10.5px] text-passive">+{extra} more</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionLink({ interactive, link }: { interactive: boolean; link: PRAttentionLink }) {
|
||||
function overflowPartLabel(extra: number, noun?: string): string | undefined {
|
||||
if (extra <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return noun ? `+${extra} ${pluralize(noun, extra)}` : `+${extra}`;
|
||||
}
|
||||
|
||||
function SummaryLink({ interactive, link }: { interactive: boolean; link: PRSummaryLink }) {
|
||||
if (interactive && link.href) {
|
||||
return (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { components } from "../../api/schema";
|
|||
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
||||
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { captureRendererEvent } from "../lib/telemetry";
|
||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||
import { newestActiveOrchestrator } from "../types/workspace";
|
||||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||
|
|
@ -120,6 +121,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId });
|
||||
// PUT replaces the whole config; merge the edited fields over what loaded
|
||||
// so we don't drop env/symlinks/postCreate the form doesn't expose.
|
||||
const next: ProjectConfig = {
|
||||
|
|
@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
|
||||
) {
|
||||
try {
|
||||
await spawnOrchestrator(projectId, true);
|
||||
await spawnOrchestrator(projectId, "settings", true);
|
||||
} catch (error) {
|
||||
return {
|
||||
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
|
||||
|
|
@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
return { replacementError: null };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId });
|
||||
setSavedAt(Date.now());
|
||||
setReplacementError(result.replacementError);
|
||||
setValidationError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||
onSaved();
|
||||
},
|
||||
onError: () => {
|
||||
void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { WorkspaceSession } from "../types/workspace";
|
|||
import { DashboardSubhead } from "./DashboardSubhead";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
|
||||
import { PRSummaryParts } from "./PRSummaryDisplay";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
|
|
@ -145,8 +145,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
|
|||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
<PRStatusStrip className="mt-1" pr={row.pr} />
|
||||
<PRAttentionPanel className="mt-1.5 pt-1.5" maxItems={2} pr={row.pr} />
|
||||
<PRSummaryParts className="mt-1" maxLinks={2} pr={row.pr} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.pr.state])}>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
|
|||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const id = await spawnOrchestrator(session.workspaceId, true);
|
||||
const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true);
|
||||
onOpenChange(false);
|
||||
onRecreated(id);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { apiClient, apiErrorMessage } from "../lib/api-client";
|
|||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import { formatTimeCompact } from "../lib/format-time";
|
||||
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
|
||||
import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display";
|
||||
import type { SessionActivityState, WorkspaceSession } from "../types/workspace";
|
||||
import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace";
|
||||
import { BrowserPanelView } from "./BrowserPanel";
|
||||
|
|
@ -14,7 +14,7 @@ import type { BrowserViewModel } from "../hooks/useBrowserView";
|
|||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay";
|
||||
import { PRSummaryMeta, PRSummaryParts } from "./PRSummaryDisplay";
|
||||
|
||||
type ProjectConfig = components["schemas"]["ProjectConfig"];
|
||||
type PRReviewState = components["schemas"]["PRReviewState"];
|
||||
|
|
@ -127,7 +127,15 @@ export function SessionInspector({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="session-inspector__body">
|
||||
<div
|
||||
className={cn(
|
||||
"session-inspector__body",
|
||||
// The Browser tab renders its own bordered panel edge-to-edge, so
|
||||
// drop the body padding for it (except when popped out, where the
|
||||
// body only holds the "return to panel" empty state).
|
||||
view === "browser" && !browserPoppedOut && "session-inspector__body--browser",
|
||||
)}
|
||||
>
|
||||
{view === "summary" ? <SummaryView session={session} /> : null}
|
||||
{view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
|
||||
{view === "browser" ? (
|
||||
|
|
@ -225,40 +233,11 @@ function PRSummaryCard({ pr }: { pr: SessionPRSummary }) {
|
|||
</div>
|
||||
{pr.title ? <div className="mt-2 text-[12px] font-medium leading-snug text-foreground">{pr.title}</div> : null}
|
||||
<PRSummaryMeta className="mt-1.5" pr={pr} />
|
||||
<PRStatusStack className="mt-2" pr={pr} />
|
||||
<PRAttentionPanel pr={pr} />
|
||||
<PRSummaryParts className="mt-2" pr={pr} variant="stacked" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRStatusStack({ className, pr }: { className?: string; pr: SessionPRSummary }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5 font-mono text-[10.5px] leading-4", className)}>
|
||||
{prStatusRows(pr).map((row) => (
|
||||
<div key={row.key} className="min-w-0 truncate">
|
||||
<span className="text-passive">{row.label}</span>{" "}
|
||||
<span className={cn("font-medium", inspectorStatusToneClass(row.tone))}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inspectorStatusToneClass(tone: PRDisplayTone): string {
|
||||
switch (tone) {
|
||||
case "success":
|
||||
return "text-success";
|
||||
case "warning":
|
||||
return "text-warning";
|
||||
case "error":
|
||||
return "text-error";
|
||||
case "neutral":
|
||||
return "text-muted-foreground";
|
||||
case "passive":
|
||||
return "text-passive";
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineTone = "now" | "good" | "warn" | "neutral";
|
||||
|
||||
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,8 @@ import { OrchestratorIcon } from "./icons";
|
|||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
|
||||
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
|
||||
import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
|
||||
import { useUiStore } from "../stores/ui-store";
|
||||
|
||||
type SessionsBoardProps = {
|
||||
|
|
@ -118,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
}
|
||||
setIsSpawning(true);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(projectId);
|
||||
const sessionId = await spawnOrchestrator(projectId, "board");
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
@ -320,73 +319,103 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
|
|||
onOpen();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong"
|
||||
onClick={onOpen}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3">
|
||||
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
|
||||
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
|
||||
{badge.label}
|
||||
</span>
|
||||
{issueId && (
|
||||
<span
|
||||
className="inline-flex max-w-[13rem] items-center truncate rounded-[4px] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-accent"
|
||||
title={`Intake issue: ${issueId}`}
|
||||
>
|
||||
{issueId}
|
||||
<div className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong">
|
||||
<div onClick={onOpen} onKeyDown={handleKeyDown} role="button" tabIndex={0}>
|
||||
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3">
|
||||
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
|
||||
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">
|
||||
{agentLabel(session.provider)}
|
||||
</span>
|
||||
{issueId && (
|
||||
<span
|
||||
className="inline-flex max-w-[13rem] items-center truncate rounded-[4px] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-accent"
|
||||
title={`Intake issue: ${issueId}`}
|
||||
>
|
||||
{issueId}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">
|
||||
{agentLabel(session.provider)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"px-[13px] text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
|
||||
showBranch ? "pb-2" : "pb-3",
|
||||
"line-clamp-2 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{session.title}
|
||||
</div>
|
||||
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"px-[13px] text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
|
||||
showBranch ? "pb-2" : "pb-3",
|
||||
"line-clamp-2 overflow-hidden",
|
||||
)}
|
||||
className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{session.title}
|
||||
</div>
|
||||
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
|
||||
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
|
||||
{prSummaries.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{prSummaries.map((prSummary, index) => (
|
||||
<BoardPRSummary
|
||||
className={cn(index > 0 && "border-t border-border pt-2")}
|
||||
key={prSummary.number}
|
||||
pr={prSummary}
|
||||
/>
|
||||
{prSummaries.length === 0 ? (
|
||||
"no PR yet"
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupPRsByLifecycle(prSummaries).map((group) => (
|
||||
<BoardPRGroup group={group} key={group.status.label} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"no PR yet"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSummary }) {
|
||||
const diffSummary = prDiffSummary(pr);
|
||||
type BoardPRLifecycleStatus = { label: "closed" | "open" | "draft" | "merged"; className: string };
|
||||
type BoardPRGroup = { status: BoardPRLifecycleStatus; prs: SessionPRSummary[] };
|
||||
|
||||
function BoardPRGroup({ group }: { group: BoardPRGroup }) {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col gap-1", className)}>
|
||||
<span>
|
||||
PR #{pr.number} · {pr.state}
|
||||
</span>
|
||||
{diffSummary ? <span className="truncate">{diffSummary}</span> : null}
|
||||
<PRStatusStrip pr={pr} />
|
||||
<PRAttentionPanel className="mt-1.5 pt-1.5" maxItems={2} pr={pr} />
|
||||
</div>
|
||||
<span
|
||||
aria-label={`${group.prs.map((pr) => `#${pr.number}`).join(", ")} ${group.status.label}`}
|
||||
className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1"
|
||||
>
|
||||
<span>PR</span>
|
||||
{group.prs.map((pr, index) => (
|
||||
<span key={pr.number}>
|
||||
<a
|
||||
className="text-passive underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
href={prBrowserUrl(pr)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
{index < group.prs.length - 1 ? "," : null}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn("font-medium", group.status.className)}>{group.status.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function groupPRsByLifecycle(prs: SessionPRSummary[]): BoardPRGroup[] {
|
||||
const groups = new Map<BoardPRLifecycleStatus["label"], BoardPRGroup>();
|
||||
for (const pr of prs) {
|
||||
const status = prLifecycleStatus(pr);
|
||||
const group = groups.get(status.label);
|
||||
if (group) {
|
||||
group.prs.push(pr);
|
||||
} else {
|
||||
groups.set(status.label, { status, prs: [pr] });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
function prLifecycleStatus(pr: SessionPRSummary): BoardPRLifecycleStatus {
|
||||
if (pr.state === "draft") return { label: "draft", className: "text-passive" };
|
||||
if (pr.state === "merged") return { label: "merged", className: "text-accent" };
|
||||
if (pr.state === "closed") return { label: "closed", className: "text-error" };
|
||||
return { label: "open", className: "text-success" };
|
||||
}
|
||||
|
||||
function sameLabel(a: string, b: string): boolean {
|
||||
const normalize = (value: string) =>
|
||||
value
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function ShellTopbar() {
|
|||
}
|
||||
setIsSpawning(true);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(projectId);
|
||||
const sessionId = await spawnOrchestrator(projectId, "topbar");
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
@ -252,16 +252,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
|
|||
|
||||
const kill = useMutation({
|
||||
mutationFn: async () => {
|
||||
void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId });
|
||||
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
|
||||
params: { path: { sessionId: session.id } },
|
||||
});
|
||||
if (apiError) throw new Error(apiErrorMessage(apiError));
|
||||
},
|
||||
onSuccess: () => {
|
||||
void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId });
|
||||
setConfirming(false);
|
||||
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
},
|
||||
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
|
||||
onError: (e) => {
|
||||
void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId });
|
||||
setError(e instanceof Error ? e.message : "Kill failed");
|
||||
},
|
||||
});
|
||||
|
||||
if (confirming) {
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ function ProjectItem({
|
|||
}
|
||||
setIsSpawning(true);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(workspace.id);
|
||||
const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
selection.goSession(workspace.id, sessionId);
|
||||
} catch (err) {
|
||||
|
|
@ -754,7 +754,7 @@ function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreate
|
|||
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ function reviewerPreviewLines(session: WorkspaceSession | undefined): string[] {
|
|||
];
|
||||
}
|
||||
|
||||
// Agents whose full-screen TUI keeps its own transcript and scrolls it only by
|
||||
// keyboard, ignoring SGR wheel reports. The terminal routes the wheel to
|
||||
// PageUp/PageDown for these (see XtermTerminal's paneScrollsByKeyboard).
|
||||
const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode"]);
|
||||
|
||||
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
|
||||
if (state === "reattaching") return "Terminal disconnected — reattaching…";
|
||||
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
|
||||
|
|
@ -139,6 +144,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
|
|||
const queryClient = useQueryClient();
|
||||
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
|
||||
const handleId = attachSession?.terminalHandleId;
|
||||
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : session?.provider;
|
||||
const hadAttachmentRef = useRef(false);
|
||||
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
|
||||
|
||||
|
|
@ -219,6 +225,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
|
|||
fontSize={fontSize}
|
||||
onError={handleInitError}
|
||||
onReady={handleReady}
|
||||
paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false}
|
||||
theme={theme}
|
||||
/>
|
||||
{showEmptyState && (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({
|
|||
wheelHandler?: (event: WheelEvent) => boolean;
|
||||
selection: string;
|
||||
options: Record<string, unknown>;
|
||||
modes: { bracketedPasteMode: boolean };
|
||||
modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
|
||||
dataListeners: Set<(data: string) => void>;
|
||||
keyListeners: Set<(event: { key: string }) => void>;
|
||||
selectionListeners: Set<() => void>;
|
||||
|
|
@ -31,7 +31,7 @@ vi.mock("@xterm/xterm", () => ({
|
|||
selection = "";
|
||||
keyHandler?: (event: KeyboardEvent) => boolean;
|
||||
wheelHandler?: (event: WheelEvent) => boolean;
|
||||
modes = { bracketedPasteMode: false };
|
||||
modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
|
||||
dataListeners = new Set<(data: string) => void>();
|
||||
keyListeners = new Set<(event: { key: string }) => void>();
|
||||
selectionListeners = new Set<() => void>();
|
||||
|
|
@ -492,6 +492,46 @@ describe("XtermTerminal", () => {
|
|||
expect(onInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => {
|
||||
const onInput = vi.fn();
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
state.lastTerminal!.modes.mouseTrackingMode = "none";
|
||||
|
||||
// A keyboard-scroll TUI: one page key per notch regardless of line count,
|
||||
// so 3 lines up => a single PageUp.
|
||||
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
|
||||
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
|
||||
|
||||
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
|
||||
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
|
||||
});
|
||||
|
||||
it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => {
|
||||
setNavigatorPlatform("Win32");
|
||||
const onInput = vi.fn();
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
// opencode enables full mouse tracking but scrolls its transcript only by
|
||||
// keyboard; with no mux to consume SGR reports, Windows must use page keys.
|
||||
state.lastTerminal!.modes.mouseTrackingMode = "any";
|
||||
|
||||
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
|
||||
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
|
||||
|
||||
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
|
||||
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
|
||||
});
|
||||
|
||||
it("sends PageUp/PageDown for keyboard-scroll panes even under a mux (opencode on macOS/Linux)", () => {
|
||||
const onInput = vi.fn();
|
||||
render(<XtermTerminal theme="dark" paneScrollsByKeyboard onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
// Linux (beforeEach) + mouse tracking on: without the paneScrollsByKeyboard
|
||||
// hint this would send SGR reports; the hint forces page keys.
|
||||
state.lastTerminal!.modes.mouseTrackingMode = "any";
|
||||
|
||||
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
|
||||
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
|
||||
});
|
||||
|
||||
it("opens terminal links via window.open so Electron routes them to the OS browser", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
render(<XtermTerminal theme="dark" />);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export type XtermTerminalProps = {
|
|||
className?: string;
|
||||
fontSize?: number;
|
||||
theme: Theme;
|
||||
/**
|
||||
* The pane app scrolls its transcript by keyboard (PageUp/PageDown) rather
|
||||
* than acting on SGR wheel reports — e.g. opencode, which enables mouse
|
||||
* tracking but never scrolls on wheel reports. Routes the wheel to page keys
|
||||
* on every platform (see the wheel handler), fixing it under a mux too.
|
||||
*/
|
||||
paneScrollsByKeyboard?: boolean;
|
||||
/** Terminal construction failed; the owner decides how to surface it. */
|
||||
onError?: (error: unknown) => void;
|
||||
/**
|
||||
|
|
@ -176,6 +183,16 @@ function sgrWheelReport(button: number, count: number): string {
|
|||
return `\x1b[<${button};1;1M`.repeat(count);
|
||||
}
|
||||
|
||||
// PageUp (CSI 5~) / PageDown (CSI 6~) for pane apps that scroll their transcript
|
||||
// by keyboard rather than mouse reports. One page key per wheel notch: a page
|
||||
// already scrolls a full screen, so scaling by line count would over-scroll.
|
||||
const PAGE_UP = "\x1b[5~";
|
||||
const PAGE_DOWN = "\x1b[6~";
|
||||
|
||||
function pageKeyReport(lines: number): string {
|
||||
return lines < 0 ? PAGE_UP : PAGE_DOWN;
|
||||
}
|
||||
|
||||
function forceSelectionMode(term: Terminal): void {
|
||||
const internal = term as XtermInternal;
|
||||
const selectionService = internal._core?._selectionService;
|
||||
|
|
@ -486,6 +503,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
wheelAccumPx -= lines * rowHeight;
|
||||
}
|
||||
if (lines === 0) return false;
|
||||
// The SGR wheel path exists to drive tmux/zellij copy-mode on
|
||||
// macOS/Linux. It cannot scroll a full-screen TUI that keeps its own
|
||||
// transcript and only scrolls on PageUp/PageDown (opencode): the report
|
||||
// is either consumed by the mux or handed to an app that ignores it.
|
||||
// Send page keys for such apps (paneScrollsByKeyboard), on Windows
|
||||
// (conpty has no mux, so SGR reaches the app and is ignored), and for
|
||||
// any pane app with mouse tracking fully off.
|
||||
if (
|
||||
callbacksRef.current.paneScrollsByKeyboard ||
|
||||
isWindowsPlatform() ||
|
||||
term.modes.mouseTrackingMode === "none"
|
||||
) {
|
||||
emitUserInput(pageKeyReport(lines), "wheel");
|
||||
return false;
|
||||
}
|
||||
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
|
||||
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient } from "../lib/api-client";
|
||||
import { mockSessionScmSummaries } from "../lib/mock-data";
|
||||
|
||||
export type SessionPRSummary = components["schemas"]["SessionPRSummary"];
|
||||
|
||||
|
|
@ -20,8 +21,9 @@ export async function fetchSessionScmSummary(sessionId: string): Promise<Session
|
|||
export function sessionScmSummaryQueryOptions(sessionId: string) {
|
||||
return {
|
||||
queryKey: sessionScmSummaryQueryKey(sessionId),
|
||||
enabled: Boolean(sessionId) && !usePreviewData,
|
||||
queryFn: () => fetchSessionScmSummary(sessionId),
|
||||
enabled: Boolean(sessionId),
|
||||
queryFn: () =>
|
||||
usePreviewData ? Promise.resolve(mockSessionScmSummaries[sessionId] ?? []) : fetchSessionScmSummary(sessionId),
|
||||
retry: 1,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,8 +31,9 @@ export function sessionScmSummaryQueryOptions(sessionId: string) {
|
|||
export function useSessionScmSummary(sessionId?: string) {
|
||||
return useQuery({
|
||||
queryKey: sessionScmSummaryQueryKey(sessionId),
|
||||
enabled: Boolean(sessionId) && !usePreviewData,
|
||||
queryFn: () => fetchSessionScmSummary(sessionId!),
|
||||
enabled: Boolean(sessionId),
|
||||
queryFn: () =>
|
||||
usePreviewData ? Promise.resolve(mockSessionScmSummaries[sessionId!] ?? []) : fetchSessionScmSummary(sessionId!),
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getApiBaseUrl } from "../lib/api-client";
|
||||
import { captureRendererEvent } from "../lib/telemetry";
|
||||
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { workspaceQueryKey } from "./useWorkspaceQuery";
|
||||
|
|
@ -213,6 +214,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
|
||||
setError(message);
|
||||
transition("error");
|
||||
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" });
|
||||
invalidateWorkspaces();
|
||||
}),
|
||||
mux.onConnectionChange((connectionState) => {
|
||||
|
|
@ -268,6 +270,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
r.openTimer = setTimeout(() => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
r.openTimer = null;
|
||||
// Only the first timeout of a reattach sequence is reported; the
|
||||
// backoff loop retrying against a restarting daemon is not news.
|
||||
if (r.attempts === 0) {
|
||||
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" });
|
||||
}
|
||||
transition("reattaching");
|
||||
teardownMux();
|
||||
scheduleReattach();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiClient,
|
||||
apiErrorMessage,
|
||||
getApiBaseUrl,
|
||||
hasTrustedApiBaseUrl,
|
||||
normalizeApiOperation,
|
||||
setApiBaseUrl,
|
||||
subscribeApiBaseUrl,
|
||||
} from "./api-client";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
vi.mock("./telemetry", () => ({
|
||||
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const captureMock = vi.mocked(captureRendererEvent);
|
||||
|
||||
describe("apiClient runtime base URL", () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -162,6 +170,123 @@ describe("subscribeApiBaseUrl", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("normalizeApiOperation", () => {
|
||||
it("replaces identifier segments after resource collections", () => {
|
||||
expect(normalizeApiOperation("get", "/api/v1/projects/my project id")).toBe("GET /api/v1/projects/:id");
|
||||
expect(normalizeApiOperation("POST", "/api/v1/sessions/ao-42/kill")).toBe("POST /api/v1/sessions/:id/kill");
|
||||
expect(normalizeApiOperation("PUT", "/api/v1/projects/p1/config")).toBe("PUT /api/v1/projects/:id/config");
|
||||
});
|
||||
|
||||
it("leaves collection and non-resource paths untouched", () => {
|
||||
expect(normalizeApiOperation("GET", "/api/v1/projects")).toBe("GET /api/v1/projects");
|
||||
expect(normalizeApiOperation("POST", "/api/v1/orchestrators")).toBe("POST /api/v1/orchestrators");
|
||||
});
|
||||
|
||||
it("keeps static child routes instead of treating them as ids", () => {
|
||||
// These match an exact OpenAPI template, so the trailing segment must not
|
||||
// be collapsed to :id (which would break aggregation and hide the route).
|
||||
expect(normalizeApiOperation("POST", "/api/v1/notifications/read-all")).toBe("POST /api/v1/notifications/read-all");
|
||||
expect(normalizeApiOperation("POST", "/api/v1/sessions/cleanup")).toBe("POST /api/v1/sessions/cleanup");
|
||||
});
|
||||
|
||||
it("normalizes ids for resources a collection heuristic would miss", () => {
|
||||
expect(normalizeApiOperation("GET", "/api/v1/orchestrators/orch-abc")).toBe("GET /api/v1/orchestrators/:id");
|
||||
expect(normalizeApiOperation("POST", "/api/v1/prs/pr-1/merge")).toBe("POST /api/v1/prs/:id/merge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("api error telemetry", () => {
|
||||
// The dedupe window keys off Date.now(); jump the clock far past any
|
||||
// earlier test's reports so each test starts with a clean window.
|
||||
let clock = Date.UTC(2100, 0, 1);
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
clock += 10 * 60_000;
|
||||
vi.setSystemTime(clock);
|
||||
captureMock.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
setApiBaseUrl("http://127.0.0.1:3001");
|
||||
});
|
||||
|
||||
it("reports http_5xx with a normalized operation", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 }));
|
||||
setApiBaseUrl("http://127.0.0.1:3037");
|
||||
|
||||
await apiClient.GET("/api/v1/projects");
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||
operation: "GET /api/v1/projects",
|
||||
error_category: "http_5xx",
|
||||
status: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports http_4xx with ids stripped from the operation", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 404 }));
|
||||
setApiBaseUrl("http://127.0.0.1:3037");
|
||||
|
||||
await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
|
||||
params: { path: { sessionId: "ao-raw-id" } },
|
||||
});
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||
operation: "POST /api/v1/sessions/:id/kill",
|
||||
error_category: "http_4xx",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports network_error and rethrows", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
setApiBaseUrl("http://127.0.0.1:3037");
|
||||
|
||||
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Failed to fetch");
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||
operation: "GET /api/v1/projects",
|
||||
error_category: "network_error",
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report caller-initiated aborts", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
||||
setApiBaseUrl("http://127.0.0.1:3037");
|
||||
|
||||
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Aborted");
|
||||
|
||||
expect(captureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports daemon_unavailable when the base URL is untrusted", async () => {
|
||||
setApiBaseUrl(null);
|
||||
|
||||
await apiClient.GET("/api/v1/projects");
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||
operation: "GET /api/v1/projects",
|
||||
error_category: "daemon_unavailable",
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes repeated identical failures within the 30s window", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("oops", { status: 502 }));
|
||||
setApiBaseUrl("http://127.0.0.1:3037");
|
||||
|
||||
await apiClient.GET("/api/v1/projects");
|
||||
await apiClient.GET("/api/v1/projects");
|
||||
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(clock + 31_000);
|
||||
await apiClient.GET("/api/v1/projects");
|
||||
expect(captureMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiErrorMessage", () => {
|
||||
it("preserves daemon error codes next to human messages", () => {
|
||||
expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "../../api/schema";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
function devApiBaseUrl(): string {
|
||||
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
|
||||
|
|
@ -39,43 +40,179 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void {
|
|||
baseUrlListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// Route templates from the generated OpenAPI schema (frontend/src/api/schema.ts).
|
||||
// Operation strings sent to telemetry must never contain raw IDs (project IDs
|
||||
// are user-chosen strings), so we match each request path against these
|
||||
// templates and report the template — collapsing `{param}` to `:id` — rather
|
||||
// than guessing which segments are identifiers. Matching from the schema keeps
|
||||
// static child routes (notifications/read-all, sessions/cleanup) intact and
|
||||
// still normalizes IDs for every resource, including ones a segment heuristic
|
||||
// would miss (orchestrators/{id}). Keep in sync with schema.ts.
|
||||
const ROUTE_TEMPLATES = [
|
||||
"/api/v1/events",
|
||||
"/api/v1/import",
|
||||
"/api/v1/notifications",
|
||||
"/api/v1/notifications/{id}",
|
||||
"/api/v1/notifications/read-all",
|
||||
"/api/v1/notifications/stream",
|
||||
"/api/v1/orchestrators",
|
||||
"/api/v1/orchestrators/{id}",
|
||||
"/api/v1/projects",
|
||||
"/api/v1/projects/{id}",
|
||||
"/api/v1/projects/{id}/config",
|
||||
"/api/v1/prs/{id}/merge",
|
||||
"/api/v1/prs/{id}/resolve-comments",
|
||||
"/api/v1/sessions",
|
||||
"/api/v1/sessions/{sessionId}",
|
||||
"/api/v1/sessions/{sessionId}/activity",
|
||||
"/api/v1/sessions/{sessionId}/kill",
|
||||
"/api/v1/sessions/{sessionId}/pr",
|
||||
"/api/v1/sessions/{sessionId}/pr/claim",
|
||||
"/api/v1/sessions/{sessionId}/preview",
|
||||
"/api/v1/sessions/{sessionId}/preview/files/*",
|
||||
"/api/v1/sessions/{sessionId}/restore",
|
||||
"/api/v1/sessions/{sessionId}/reviews",
|
||||
"/api/v1/sessions/{sessionId}/reviews/submit",
|
||||
"/api/v1/sessions/{sessionId}/reviews/trigger",
|
||||
"/api/v1/sessions/{sessionId}/rollback",
|
||||
"/api/v1/sessions/{sessionId}/send",
|
||||
"/api/v1/sessions/cleanup",
|
||||
] as const;
|
||||
|
||||
// Resource collections whose next path segment is an identifier. Only used as a
|
||||
// defensive fallback for paths not covered by ROUTE_TEMPLATES; keeps IDs out of
|
||||
// telemetry for known collections even if a route is ever missed above.
|
||||
const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs", "orchestrators"]);
|
||||
|
||||
// Match a path against one template. `{param}` matches any single segment
|
||||
// (reported as `:id`), a trailing `*` matches the remaining path, and every
|
||||
// other segment must match literally. Returns the normalized template plus a
|
||||
// score = number of literal segments matched, so the most specific template
|
||||
// wins when several match (e.g. `read-all` beats `{id}`).
|
||||
function matchRouteTemplate(pathname: string, template: string): { normalized: string; score: number } | null {
|
||||
const pathSegs = pathname.split("/");
|
||||
const tmplSegs = template.split("/");
|
||||
const out: string[] = [];
|
||||
let score = 0;
|
||||
for (let i = 0; i < tmplSegs.length; i += 1) {
|
||||
const t = tmplSegs[i];
|
||||
if (t === "*") {
|
||||
out.push("*");
|
||||
return { normalized: out.join("/"), score };
|
||||
}
|
||||
const p = pathSegs[i];
|
||||
if (p === undefined) return null;
|
||||
if (t.startsWith("{") && t.endsWith("}")) {
|
||||
out.push(":id");
|
||||
} else if (t === p) {
|
||||
out.push(t);
|
||||
score += 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (pathSegs.length !== tmplSegs.length) return null;
|
||||
return { normalized: out.join("/"), score };
|
||||
}
|
||||
|
||||
function fallbackNormalize(pathname: string): string {
|
||||
const segments = pathname.split("/");
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (RESOURCE_SEGMENTS.has(segments[i]) && segments[i + 1]) {
|
||||
segments[i + 1] = ":id";
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
export function normalizeApiOperation(method: string, pathname: string): string {
|
||||
let best: { normalized: string; score: number } | null = null;
|
||||
for (const template of ROUTE_TEMPLATES) {
|
||||
const match = matchRouteTemplate(pathname, template);
|
||||
if (match && (best === null || match.score > best.score)) best = match;
|
||||
}
|
||||
return `${method.toUpperCase()} ${best?.normalized ?? fallbackNormalize(pathname)}`;
|
||||
}
|
||||
|
||||
type ApiErrorCategory = "daemon_unavailable" | "network_error" | "http_4xx" | "http_5xx";
|
||||
|
||||
// One event per (operation, category, status) per window: a daemon outage
|
||||
// makes every polling query fail at once and on every retry — the failure
|
||||
// signal matters, the storm does not.
|
||||
const API_ERROR_DEDUPE_MS = 30_000;
|
||||
const lastApiErrorAt = new Map<string, number>();
|
||||
|
||||
function reportApiError(operation: string, category: ApiErrorCategory, status?: number): void {
|
||||
const key = `${operation}|${category}|${status ?? ""}`;
|
||||
const now = Date.now();
|
||||
const last = lastApiErrorAt.get(key);
|
||||
if (last !== undefined && now - last < API_ERROR_DEDUPE_MS) return;
|
||||
lastApiErrorAt.set(key, now);
|
||||
void captureRendererEvent("ao.renderer.api_error", {
|
||||
operation,
|
||||
error_category: category,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
async function runtimeFetch(input: Request): Promise<Response> {
|
||||
const operation = normalizeApiOperation(input.method, new URL(input.url).pathname);
|
||||
const baseUrl = runtimeApiBaseUrl;
|
||||
if (baseUrl === null) {
|
||||
reportApiError(operation, "daemon_unavailable", 503);
|
||||
return new Response(JSON.stringify({ message: "AO daemon is not ready." }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (!baseUrl) {
|
||||
return fetch(input);
|
||||
}
|
||||
|
||||
const url = new URL(input.url);
|
||||
const target = new URL(url.pathname + url.search + url.hash, baseUrl);
|
||||
if (target.href === input.url) {
|
||||
return fetch(input);
|
||||
}
|
||||
const send = async (): Promise<Response> => {
|
||||
if (!baseUrl) {
|
||||
return fetch(input);
|
||||
}
|
||||
|
||||
// Rebase onto the runtime base URL by copying fields explicitly and
|
||||
// buffering the body. `new Request(target, input)` reads the source
|
||||
// request's `duplex` getter, which Electron's Chromium lacks — it throws
|
||||
// "The duplex member must be specified" for any request with a body, so
|
||||
// every POST would fail in the packaged app. API bodies are small JSON;
|
||||
// buffering sidesteps streaming-duplex semantics entirely.
|
||||
const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
|
||||
return fetch(target, {
|
||||
method: input.method,
|
||||
headers: input.headers,
|
||||
body,
|
||||
signal: input.signal,
|
||||
credentials: input.credentials,
|
||||
cache: input.cache,
|
||||
redirect: input.redirect,
|
||||
referrerPolicy: input.referrerPolicy,
|
||||
integrity: input.integrity,
|
||||
keepalive: input.keepalive,
|
||||
});
|
||||
const url = new URL(input.url);
|
||||
const target = new URL(url.pathname + url.search + url.hash, baseUrl);
|
||||
if (target.href === input.url) {
|
||||
return fetch(input);
|
||||
}
|
||||
|
||||
// Rebase onto the runtime base URL by copying fields explicitly and
|
||||
// buffering the body. `new Request(target, input)` reads the source
|
||||
// request's `duplex` getter, which Electron's Chromium lacks — it throws
|
||||
// "The duplex member must be specified" for any request with a body, so
|
||||
// every POST would fail in the packaged app. API bodies are small JSON;
|
||||
// buffering sidesteps streaming-duplex semantics entirely.
|
||||
const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
|
||||
return fetch(target, {
|
||||
method: input.method,
|
||||
headers: input.headers,
|
||||
body,
|
||||
signal: input.signal,
|
||||
credentials: input.credentials,
|
||||
cache: input.cache,
|
||||
redirect: input.redirect,
|
||||
referrerPolicy: input.referrerPolicy,
|
||||
integrity: input.integrity,
|
||||
keepalive: input.keepalive,
|
||||
});
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await send();
|
||||
} catch (error) {
|
||||
// Caller-initiated aborts (unmounted components cancelling queries) are not failures.
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
reportApiError(operation, "network_error");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
reportApiError(operation, response.status >= 500 ? "http_5xx" : "http_4xx", response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const apiClient = createClient<paths>({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonStatus } from "../../shared/daemon-status";
|
||||
import { aoBridge } from "./bridge";
|
||||
import { startDaemonFailureTelemetry } from "./daemon-telemetry";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
vi.mock("./telemetry", () => ({
|
||||
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./bridge", () => ({
|
||||
aoBridge: {
|
||||
daemon: {
|
||||
getStatus: vi.fn(),
|
||||
onStatus: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const captureMock = vi.mocked(captureRendererEvent);
|
||||
const getStatusMock = vi.mocked(aoBridge.daemon.getStatus);
|
||||
const onStatusMock = vi.mocked(aoBridge.daemon.onStatus);
|
||||
|
||||
describe("daemon failure telemetry", () => {
|
||||
let pushStatus!: (status: DaemonStatus) => void;
|
||||
let stop: () => void = () => undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
captureMock.mockClear();
|
||||
onStatusMock.mockClear();
|
||||
getStatusMock.mockResolvedValue({ state: "starting" });
|
||||
onStatusMock.mockImplementation((listener) => {
|
||||
pushStatus = listener;
|
||||
return () => undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
it("reports a failing status with coarse fields only", () => {
|
||||
stop = startDaemonFailureTelemetry();
|
||||
|
||||
pushStatus({ state: "error", message: "spawn /Users/alice/ao failed", code: "spawn_failed" });
|
||||
|
||||
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||
daemon_state: "error",
|
||||
code: "spawn_failed",
|
||||
exit_code: undefined,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes exit code and signal for daemon exits", () => {
|
||||
stop = startDaemonFailureTelemetry();
|
||||
|
||||
pushStatus({ state: "stopped", code: "exited", exitCode: 1, signal: "SIGKILL" });
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||
daemon_state: "stopped",
|
||||
code: "exited",
|
||||
exit_code: 1,
|
||||
signal: "SIGKILL",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores statuses without a failure code (healthy or user-initiated stop)", () => {
|
||||
stop = startDaemonFailureTelemetry();
|
||||
|
||||
pushStatus({ state: "ready", port: 3037 });
|
||||
pushStatus({ state: "stopped" });
|
||||
|
||||
expect(captureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dedupes identical consecutive failures and resets on recovery", () => {
|
||||
stop = startDaemonFailureTelemetry();
|
||||
|
||||
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A different failure is a new event.
|
||||
pushStatus({ state: "stopped", code: "exited", exitCode: 137 });
|
||||
expect(captureMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Recovery resets dedupe: the same failure recurring is reported again.
|
||||
pushStatus({ state: "ready", port: 3037 });
|
||||
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||
expect(captureMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("reports the initial status from getStatus on start", async () => {
|
||||
getStatusMock.mockResolvedValue({ state: "error", code: "binary_missing" });
|
||||
|
||||
stop = startDaemonFailureTelemetry();
|
||||
await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||
daemon_state: "error",
|
||||
code: "binary_missing",
|
||||
exit_code: undefined,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent while started and restartable after stop", () => {
|
||||
stop = startDaemonFailureTelemetry();
|
||||
const noop = startDaemonFailureTelemetry();
|
||||
expect(onStatusMock).toHaveBeenCalledTimes(1);
|
||||
noop();
|
||||
|
||||
stop();
|
||||
stop = startDaemonFailureTelemetry();
|
||||
expect(onStatusMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Daemon failures happen in the Electron main process, which has no PostHog
|
||||
// client. Main stamps a machine-readable `code` on every failing DaemonStatus
|
||||
// (shared/daemon-status.ts); this module rides the existing daemon:status IPC
|
||||
// push and reports those failures through the renderer's telemetry client.
|
||||
import type { DaemonStatus } from "../../shared/daemon-status";
|
||||
import { aoBridge } from "./bridge";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
let started = false;
|
||||
let lastFailureKey: string | null = null;
|
||||
|
||||
function failureKey(status: DaemonStatus): string | null {
|
||||
if (!status.code) return null;
|
||||
return [status.state, status.code, status.exitCode ?? "", status.signal ?? ""].join("|");
|
||||
}
|
||||
|
||||
function reportStatus(status: DaemonStatus): void {
|
||||
const key = failureKey(status);
|
||||
if (!key) {
|
||||
// Healthy status resets dedupe so a repeat failure after recovery is a new event.
|
||||
lastFailureKey = null;
|
||||
return;
|
||||
}
|
||||
if (key === lastFailureKey) return;
|
||||
lastFailureKey = key;
|
||||
void captureRendererEvent("ao.renderer.daemon_failure", {
|
||||
daemon_state: status.state,
|
||||
code: status.code,
|
||||
exit_code: typeof status.exitCode === "number" ? status.exitCode : undefined,
|
||||
signal: status.signal ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Idempotent; returns a stop function (used by tests). */
|
||||
export function startDaemonFailureTelemetry(): () => void {
|
||||
if (started) return () => undefined;
|
||||
started = true;
|
||||
void aoBridge.daemon
|
||||
.getStatus()
|
||||
.then(reportStatus)
|
||||
.catch(() => undefined);
|
||||
let stopListener: () => void = () => undefined;
|
||||
try {
|
||||
stopListener = aoBridge.daemon.onStatus(reportStatus);
|
||||
} catch {
|
||||
// Preload bridge unavailable (browser preview): initial getStatus already handled.
|
||||
}
|
||||
return () => {
|
||||
started = false;
|
||||
lastFailureKey = null;
|
||||
stopListener();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PRState, PullRequestFacts, WorkspaceSummary } from "../types/workspace";
|
||||
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000).toISOString();
|
||||
|
|
@ -202,3 +203,199 @@ export const mockWorkspaces: WorkspaceSummary[] = [
|
|||
],
|
||||
},
|
||||
];
|
||||
|
||||
const prSummary = (sessionId: string, number: number, overrides: Partial<SessionPRSummary> = {}): SessionPRSummary => {
|
||||
const session = mockWorkspaces.flatMap((workspace) => workspace.sessions).find((item) => item.id === sessionId);
|
||||
const facts = session?.prs.find((item) => item.number === number);
|
||||
const url = facts?.url ?? `https://github.com/me/${session?.workspaceName ?? "preview"}/pull/${number}`;
|
||||
return {
|
||||
url,
|
||||
htmlUrl: url,
|
||||
number,
|
||||
title: session?.title ?? `PR #${number}`,
|
||||
state: facts?.state ?? "open",
|
||||
provider: "github",
|
||||
repo: `me/${session?.workspaceName ?? "preview"}`,
|
||||
author: "preview-agent",
|
||||
sourceBranch: session?.branch ?? "",
|
||||
targetBranch: "main",
|
||||
headSha: `preview-${number}`,
|
||||
additions: 42,
|
||||
deletions: 8,
|
||||
changedFiles: 3,
|
||||
ci: {
|
||||
state: facts?.ci === "failing" ? "failing" : facts?.ci === "pending" ? "pending" : "passing",
|
||||
failingChecks: [],
|
||||
},
|
||||
review: {
|
||||
decision:
|
||||
facts?.review === "changes_requested"
|
||||
? "changes_requested"
|
||||
: facts?.review === "approved"
|
||||
? "approved"
|
||||
: "none",
|
||||
hasUnresolvedHumanComments: facts?.reviewComments ?? false,
|
||||
unresolvedBy: [],
|
||||
},
|
||||
mergeability: {
|
||||
state:
|
||||
facts?.mergeability === "conflicting"
|
||||
? "conflicting"
|
||||
: facts?.mergeability === "blocked"
|
||||
? "blocked"
|
||||
: facts?.mergeability === "unstable"
|
||||
? "unstable"
|
||||
: facts?.mergeability === "unknown"
|
||||
? "unknown"
|
||||
: "mergeable",
|
||||
reasons: [],
|
||||
prUrl: url,
|
||||
conflictFiles: [],
|
||||
},
|
||||
updatedAt: facts?.updatedAt ?? now,
|
||||
observedAt: facts?.updatedAt ?? now,
|
||||
ciObservedAt: facts?.updatedAt ?? now,
|
||||
reviewObservedAt: facts?.updatedAt ?? now,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockSessionScmSummaries: Record<string, SessionPRSummary[]> = {
|
||||
"fix-auth-timeouts": [
|
||||
prSummary("fix-auth-timeouts", 184, {
|
||||
changedFiles: 5,
|
||||
additions: 91,
|
||||
deletions: 17,
|
||||
ci: {
|
||||
state: "failing",
|
||||
failingChecks: [
|
||||
{
|
||||
name: "backend / go test ./...",
|
||||
status: "failed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/me/api-gateway/actions/runs/184001/job/1",
|
||||
},
|
||||
{
|
||||
name: "lint / golangci",
|
||||
status: "failed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/me/api-gateway/actions/runs/184001/job/2",
|
||||
},
|
||||
{
|
||||
name: "api contract drift",
|
||||
status: "failed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/me/api-gateway/actions/runs/184001/job/3",
|
||||
},
|
||||
{
|
||||
name: "frontend typecheck",
|
||||
status: "failed",
|
||||
conclusion: "",
|
||||
url: "https://github.com/me/api-gateway/actions/runs/184001/job/4",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"texture-leak": [
|
||||
prSummary("texture-leak", 51, {
|
||||
changedFiles: 4,
|
||||
additions: 74,
|
||||
deletions: 22,
|
||||
ci: {
|
||||
state: "failing",
|
||||
failingChecks: [
|
||||
{
|
||||
name: "render tests",
|
||||
status: "failed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/me/webgl-preview/actions/runs/51001/job/1",
|
||||
},
|
||||
{
|
||||
name: "visual regression",
|
||||
status: "failed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/me/webgl-preview/actions/runs/51001/job/2",
|
||||
},
|
||||
],
|
||||
},
|
||||
mergeability: {
|
||||
state: "conflicting",
|
||||
reasons: ["conflicts"],
|
||||
prUrl: "https://github.com/me/webgl-preview/pull/51",
|
||||
conflictFiles: [
|
||||
{
|
||||
path: "src/render/texture-cache.ts",
|
||||
url: "https://github.com/me/webgl-preview/pull/51/conflicts#src-render-texture-cache-ts",
|
||||
},
|
||||
{
|
||||
path: "src/render/webgl-context.ts",
|
||||
url: "https://github.com/me/webgl-preview/pull/51/conflicts#src-render-webgl-context-ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"review-camera-pan": [
|
||||
prSummary("review-camera-pan", 52, {
|
||||
changedFiles: 6,
|
||||
additions: 128,
|
||||
deletions: 31,
|
||||
review: {
|
||||
decision: "review_required",
|
||||
hasUnresolvedHumanComments: false,
|
||||
unresolvedBy: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"input-pointer-lock": [
|
||||
prSummary("input-pointer-lock", 56, {
|
||||
changedFiles: 3,
|
||||
additions: 48,
|
||||
deletions: 14,
|
||||
review: {
|
||||
decision: "changes_requested",
|
||||
hasUnresolvedHumanComments: true,
|
||||
unresolvedBy: [
|
||||
{
|
||||
reviewerId: "maya",
|
||||
count: 3,
|
||||
reviewUrl: "https://github.com/me/webgl-preview/pull/56#pullrequestreview-1001",
|
||||
links: [
|
||||
{
|
||||
url: "https://github.com/me/webgl-preview/pull/56#discussion_r1001",
|
||||
file: "src/input/pointer-lock.ts",
|
||||
line: 88,
|
||||
},
|
||||
{
|
||||
url: "https://github.com/me/webgl-preview/pull/56#discussion_r1002",
|
||||
file: "src/input/keyboard.ts",
|
||||
line: 41,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
reviewerId: "copilot",
|
||||
count: 1,
|
||||
isBot: true,
|
||||
reviewUrl: "https://github.com/me/webgl-preview/pull/56#pullrequestreview-1002",
|
||||
links: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"invoice-export": [
|
||||
prSummary("invoice-export", 117, {
|
||||
changedFiles: 8,
|
||||
additions: 212,
|
||||
deletions: 36,
|
||||
mergeability: {
|
||||
state: "blocked",
|
||||
reasons: ["behind_base", "review_required", "blocked_by_provider", "ci_failing"],
|
||||
prUrl: "https://github.com/me/billing-portal/pull/117",
|
||||
conflictFiles: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
import { prAttentionItems, prBrowserUrl, prDiffSummary, prStatusRows } from "./pr-display";
|
||||
import { prBrowserUrl, prDiffSummary, prStatusRows, prSummaryParts } from "./pr-display";
|
||||
|
||||
const summary = (overrides: Partial<SessionPRSummary> = {}): SessionPRSummary => ({
|
||||
url: "https://github.com/acme/repo/pull/7",
|
||||
|
|
@ -37,7 +37,7 @@ describe("prStatusRows", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => `${row.label}:${row.value}`)).toEqual(["CI:Checking", "Review:None", "Merge:Checking"]);
|
||||
expect(rows.map((row) => `${row.label}:${row.value}`)).toEqual(["CI:Checking", "Merge:Checking", "Review:None"]);
|
||||
});
|
||||
|
||||
it("includes minimal diff detail on the merge row", () => {
|
||||
|
|
@ -69,13 +69,13 @@ describe("prBrowserUrl", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("prAttentionItems", () => {
|
||||
it("returns no attention for clean open PRs", () => {
|
||||
expect(prAttentionItems(summary())).toEqual([]);
|
||||
describe("prSummaryParts", () => {
|
||||
it("always returns CI, Merge, and Review parts", () => {
|
||||
expect(prSummaryParts(summary()).map((part) => part.label)).toEqual(["CI", "Merge", "Review"]);
|
||||
});
|
||||
|
||||
it("details active CI, review, and merge blockers", () => {
|
||||
const items = prAttentionItems(
|
||||
it("details active CI, merge, and review blockers under their parts", () => {
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
ci: {
|
||||
state: "failing",
|
||||
|
|
@ -102,19 +102,34 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual(["merge_blocked", "ci_failing", "review_changes_requested"]);
|
||||
expect(items.find((item) => item.kind === "ci_failing")?.links[0]).toMatchObject({
|
||||
expect(parts.map((part) => part.key)).toEqual(["ci", "merge", "review"]);
|
||||
expect(parts.find((part) => part.key === "ci")).toMatchObject({
|
||||
status: "Failing",
|
||||
summary: undefined,
|
||||
tone: "error",
|
||||
});
|
||||
expect(parts.find((part) => part.key === "ci")?.links[0]).toMatchObject({
|
||||
label: "copy-check",
|
||||
href: "https://checks.example/copy",
|
||||
});
|
||||
expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "merge")).toMatchObject({
|
||||
status: "Blocked",
|
||||
summary: undefined,
|
||||
tone: "warning",
|
||||
});
|
||||
expect(parts.find((part) => part.key === "review")).toMatchObject({
|
||||
status: "Changes requested",
|
||||
summary: undefined,
|
||||
tone: "warning",
|
||||
});
|
||||
expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
|
||||
label: "alice +5",
|
||||
href: "https://github.com/acme/repo/pull/7#discussion_r1",
|
||||
});
|
||||
});
|
||||
|
||||
it("links failing CI checks to their provider URLs", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
ci: {
|
||||
state: "failing",
|
||||
|
|
@ -128,17 +143,17 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const ciItem = items.find((item) => item.kind === "ci_failing");
|
||||
expect(ciItem?.links).toEqual([
|
||||
const ciPart = parts.find((part) => part.key === "ci");
|
||||
expect(ciPart?.links).toEqual([
|
||||
{ label: "unit", href: "https://checks.example/unit", title: "failure" },
|
||||
{ label: "lint", href: "https://checks.example/lint", title: "failure" },
|
||||
{ label: "build", href: "https://checks.example/build", title: "failure" },
|
||||
]);
|
||||
expect(ciItem?.overflowLabel).toBe("+1 check");
|
||||
expect(ciPart?.overflowLabel).toBe("+1 check");
|
||||
});
|
||||
|
||||
it("prefers the submitted review summary over inline comments", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
review: {
|
||||
decision: "changes_requested",
|
||||
|
|
@ -158,7 +173,7 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
|
||||
label: "alice +1",
|
||||
href: "https://github.com/acme/repo/pull/7#pullrequestreview-1",
|
||||
title: "Open requested-changes review from alice",
|
||||
|
|
@ -166,7 +181,7 @@ describe("prAttentionItems", () => {
|
|||
});
|
||||
|
||||
it("falls back to the first inline comment when no review summary exists", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
review: {
|
||||
decision: "changes_requested",
|
||||
|
|
@ -185,7 +200,7 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
|
||||
label: "alice +1",
|
||||
href: "https://github.com/acme/repo/pull/7#discussion_r1",
|
||||
title: "2 unresolved comments from alice",
|
||||
|
|
@ -193,7 +208,7 @@ describe("prAttentionItems", () => {
|
|||
});
|
||||
|
||||
it("falls back to the PR page when review summary and inline comment URLs are missing", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
url: "https://github.com/acme/repo/issues/7",
|
||||
htmlUrl: "https://github.com/acme/repo/issues/7",
|
||||
|
|
@ -205,7 +220,7 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
|
||||
label: "alice",
|
||||
href: "https://github.com/acme/repo/pull/7",
|
||||
title: "Open pull request for alice",
|
||||
|
|
@ -213,7 +228,7 @@ describe("prAttentionItems", () => {
|
|||
});
|
||||
|
||||
it("shows bot reviewers with a bot label", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
review: {
|
||||
decision: "changes_requested",
|
||||
|
|
@ -231,7 +246,7 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
|
||||
label: "copilot bot",
|
||||
href: "https://github.com/acme/repo/pull/7#pullrequestreview-2",
|
||||
title: "Open requested-changes review from copilot bot",
|
||||
|
|
@ -239,7 +254,7 @@ describe("prAttentionItems", () => {
|
|||
});
|
||||
|
||||
it("links merge conflicts to GitHub's conflict resolution page", () => {
|
||||
const items = prAttentionItems(
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
url: "https://github.com/acme/repo/issues/7",
|
||||
htmlUrl: "https://github.com/acme/repo/issues/7",
|
||||
|
|
@ -251,22 +266,39 @@ describe("prAttentionItems", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.kind === "merge_conflict")?.links[0]).toMatchObject({
|
||||
expect(parts.find((part) => part.key === "merge")).toMatchObject({
|
||||
status: "Conflict",
|
||||
summary: undefined,
|
||||
});
|
||||
expect(parts.find((part) => part.key === "merge")?.links[0]).toMatchObject({
|
||||
label: "conflicts",
|
||||
href: "https://github.com/acme/repo/pull/7/conflicts",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses attention once the PR is closed or merged", () => {
|
||||
expect(
|
||||
prAttentionItems(
|
||||
summary({
|
||||
state: "merged",
|
||||
ci: { state: "failing", failingChecks: [{ name: "unit", status: "failed", conclusion: "failure" }] },
|
||||
review: { decision: "changes_requested", hasUnresolvedHumanComments: true, unresolvedBy: [] },
|
||||
mergeability: { state: "conflicting", reasons: ["conflicts"], prUrl: "https://github.com/acme/repo/pull/7" },
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
it("keeps closed or merged PR summaries to the three status parts", () => {
|
||||
const parts = prSummaryParts(
|
||||
summary({
|
||||
state: "merged",
|
||||
ci: { state: "failing", failingChecks: [{ name: "unit", status: "failed", conclusion: "failure" }] },
|
||||
review: { decision: "changes_requested", hasUnresolvedHumanComments: true, unresolvedBy: [] },
|
||||
mergeability: { state: "conflicting", reasons: ["conflicts"], prUrl: "https://github.com/acme/repo/pull/7" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(parts.find((part) => part.key === "merge")?.links).toEqual([]);
|
||||
expect(parts.find((part) => part.key === "review")?.links).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts draft readiness under Review", () => {
|
||||
const parts = prSummaryParts(
|
||||
summary({ state: "draft", review: { decision: "none", hasUnresolvedHumanComments: false, unresolvedBy: [] } }),
|
||||
);
|
||||
|
||||
expect(parts.find((part) => part.key === "review")).toMatchObject({
|
||||
status: "None",
|
||||
summary: "Draft PR · Not ready for review",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,18 +27,23 @@ export type PRStatusRow = {
|
|||
tone: PRDisplayTone;
|
||||
};
|
||||
|
||||
export type PRAttentionLink = {
|
||||
export type PRSummaryPartKey = "ci" | "review" | "merge";
|
||||
|
||||
export type PRSummaryLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type PRAttentionItem = {
|
||||
kind: "draft" | "ci_failing" | "review_changes_requested" | "review_pending" | "merge_conflict" | "merge_blocked";
|
||||
title: string;
|
||||
export type PRSummaryPart = {
|
||||
key: PRSummaryPartKey;
|
||||
label: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
links: PRAttentionLink[];
|
||||
links: PRSummaryLink[];
|
||||
linkTotal?: number;
|
||||
overflowLabel?: string;
|
||||
overflowNoun?: string;
|
||||
tone: PRDisplayTone;
|
||||
};
|
||||
|
||||
|
|
@ -103,26 +108,53 @@ function sessionPRFactToSummary(session: WorkspaceSession, pr: PullRequestFacts)
|
|||
}
|
||||
|
||||
export function prStatusRows(pr: SessionPRSummary): PRStatusRow[] {
|
||||
return prSummaryParts(pr).map((part) => ({
|
||||
key: part.key,
|
||||
label: part.label,
|
||||
value: part.status,
|
||||
detail: part.key === "merge" ? formatDiffSummary(pr) : undefined,
|
||||
tone: part.tone,
|
||||
}));
|
||||
}
|
||||
|
||||
export function prSummaryParts(pr: SessionPRSummary): PRSummaryPart[] {
|
||||
return [
|
||||
{
|
||||
key: "ci",
|
||||
label: "CI",
|
||||
value: ciLabel(pr.ci.state),
|
||||
status: ciLabel(pr.ci.state),
|
||||
summary: ciSummary(pr),
|
||||
links: ciLinks(pr),
|
||||
linkTotal: pr.ci.state === "failing" ? pr.ci.failingChecks.length : 0,
|
||||
overflowLabel: pr.ci.state === "failing" ? overflowLabel(pr.ci.failingChecks.length, 3, "check") : undefined,
|
||||
overflowNoun: "check",
|
||||
tone: ciTone(pr.ci.state),
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
label: "Review",
|
||||
value: reviewLabel(pr.review.decision),
|
||||
tone: reviewTone(pr.review.decision, pr.review.hasUnresolvedHumanComments),
|
||||
},
|
||||
{
|
||||
key: "merge",
|
||||
label: "Merge",
|
||||
value: mergeabilityLabel(pr.mergeability.state),
|
||||
detail: formatDiffSummary(pr),
|
||||
status: mergeabilityLabel(pr.mergeability.state),
|
||||
summary: mergeSummary(pr),
|
||||
links: mergeLinks(pr),
|
||||
linkTotal: mergeLinkTotal(pr),
|
||||
overflowLabel: mergeOverflowLabel(pr),
|
||||
overflowNoun: mergeOverflowNoun(pr),
|
||||
tone: mergeabilityTone(pr.mergeability.state),
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
label: "Review",
|
||||
status: reviewLabel(pr.review.decision),
|
||||
summary: reviewSummary(pr),
|
||||
links: reviewLinks(pr),
|
||||
linkTotal: reviewLinkTotal(pr),
|
||||
overflowLabel:
|
||||
pr.state === "draft" || pr.review.decision === "review_required"
|
||||
? undefined
|
||||
: overflowLabel(pr.review.unresolvedBy.length, 3, "reviewer"),
|
||||
overflowNoun: "reviewer",
|
||||
tone: reviewTone(pr.review.decision, pr.review.hasUnresolvedHumanComments),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -138,69 +170,120 @@ export function prDiffSummary(pr: SessionPRSummary): string | undefined {
|
|||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
export function prAttentionItems(pr: SessionPRSummary): PRAttentionItem[] {
|
||||
function ciSummary(pr: SessionPRSummary): string | undefined {
|
||||
if (pr.ci.state === "failing") {
|
||||
return pr.ci.failingChecks.length === 0 ? "No failing check link observed" : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ciLinks(pr: SessionPRSummary): PRSummaryLink[] {
|
||||
if (pr.ci.state !== "failing") {
|
||||
return [];
|
||||
}
|
||||
return pr.ci.failingChecks.slice(0, 3).map((check) => ({
|
||||
label: check.name,
|
||||
href: check.url || undefined,
|
||||
title: check.conclusion || check.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function reviewSummary(pr: SessionPRSummary): string | undefined {
|
||||
if (pr.state === "merged" || pr.state === "closed") {
|
||||
return undefined;
|
||||
}
|
||||
if (pr.state === "draft") {
|
||||
return "Draft PR · Not ready for review";
|
||||
}
|
||||
if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) {
|
||||
return reviewLinks(pr).length === 0 ? "Requested changes still active" : undefined;
|
||||
}
|
||||
if (pr.review.decision === "review_required") {
|
||||
return "Required review not submitted";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reviewLinks(pr: SessionPRSummary): PRSummaryLink[] {
|
||||
if (pr.state === "merged" || pr.state === "closed" || pr.state === "draft") {
|
||||
return [];
|
||||
}
|
||||
if (pr.review.decision !== "changes_requested" && !pr.review.hasUnresolvedHumanComments) {
|
||||
return [];
|
||||
}
|
||||
const links = pr.review.unresolvedBy.slice(0, 3).map((reviewer) => reviewAttentionLink(pr, reviewer));
|
||||
if (links.length === 0 && pr.review.decision === "changes_requested") {
|
||||
links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function mergeSummary(pr: SessionPRSummary): string | undefined {
|
||||
if (pr.state === "merged" || pr.state === "closed") {
|
||||
return formatDiffSummary(pr);
|
||||
}
|
||||
if (pr.mergeability.state === "conflicting") {
|
||||
return mergeLinks(pr).length === 0 ? "Conflicts with the base branch" : undefined;
|
||||
}
|
||||
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
|
||||
return mergeLinks(pr).length === 0 ? "Provider reports merge is blocked" : undefined;
|
||||
}
|
||||
return formatDiffSummary(pr);
|
||||
}
|
||||
|
||||
function mergeLinks(pr: SessionPRSummary): PRSummaryLink[] {
|
||||
if (pr.state === "merged" || pr.state === "closed") {
|
||||
return [];
|
||||
}
|
||||
if (pr.state === "draft") {
|
||||
return [
|
||||
{
|
||||
kind: "draft",
|
||||
title: "Draft PR",
|
||||
summary: "Not ready for review",
|
||||
links: [],
|
||||
tone: "passive",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const items: PRAttentionItem[] = [];
|
||||
if (pr.mergeability.state === "conflicting") {
|
||||
items.push(
|
||||
mergeAttention(pr, "merge_conflict", "Resolve merge conflict", "Conflicts with the base branch", "error"),
|
||||
);
|
||||
} else if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
|
||||
items.push(mergeAttention(pr, "merge_blocked", "Merge blocked", "Provider reports merge is blocked", "warning"));
|
||||
return mergeAttentionLinks(pr, "merge_conflict");
|
||||
}
|
||||
if (pr.ci.state === "failing") {
|
||||
const links = pr.ci.failingChecks.slice(0, 3).map((check) => ({
|
||||
label: check.name,
|
||||
href: check.url || undefined,
|
||||
title: check.conclusion || check.status,
|
||||
}));
|
||||
items.push({
|
||||
kind: "ci_failing",
|
||||
title: "Fix failing CI",
|
||||
summary: links.length === 0 ? "No failing check link observed" : undefined,
|
||||
links,
|
||||
overflowLabel: overflowLabel(pr.ci.failingChecks.length, 3, "check"),
|
||||
tone: "error",
|
||||
});
|
||||
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
|
||||
return mergeAttentionLinks(pr, "merge_blocked");
|
||||
}
|
||||
if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) {
|
||||
const reviewers = pr.review.unresolvedBy.slice(0, 3);
|
||||
const links = reviewers.map((reviewer) => reviewAttentionLink(pr, reviewer));
|
||||
if (links.length === 0 && pr.review.decision === "changes_requested") {
|
||||
links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" });
|
||||
}
|
||||
items.push({
|
||||
kind: "review_changes_requested",
|
||||
title: "Address requested changes",
|
||||
summary: links.length === 0 ? "Requested changes still active" : undefined,
|
||||
links,
|
||||
overflowLabel: overflowLabel(pr.review.unresolvedBy.length, 3, "reviewer"),
|
||||
tone: "warning",
|
||||
});
|
||||
} else if (pr.review.decision === "review_required") {
|
||||
items.push({
|
||||
kind: "review_pending",
|
||||
title: "Review pending",
|
||||
summary: "Required review not submitted",
|
||||
links: [],
|
||||
tone: "neutral",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
function mergeOverflowLabel(pr: SessionPRSummary): string | undefined {
|
||||
if (pr.state === "merged" || pr.state === "closed") {
|
||||
return undefined;
|
||||
}
|
||||
return items;
|
||||
const hasFileLinks = (pr.mergeability.conflictFiles ?? []).length > 0;
|
||||
if (hasFileLinks) {
|
||||
return overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file");
|
||||
}
|
||||
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
|
||||
return overflowLabel(pr.mergeability.reasons.length, 3, "reason");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mergeLinkTotal(pr: SessionPRSummary): number {
|
||||
if (pr.state === "merged" || pr.state === "closed") {
|
||||
return 0;
|
||||
}
|
||||
if (pr.mergeability.state === "conflicting") {
|
||||
const conflictFileCount = pr.mergeability.conflictFiles?.length ?? 0;
|
||||
return conflictFileCount > 0 ? conflictFileCount : mergeLinks(pr).length;
|
||||
}
|
||||
if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
|
||||
return pr.mergeability.reasons.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeOverflowNoun(pr: SessionPRSummary): string {
|
||||
return (pr.mergeability.conflictFiles ?? []).length > 0 ? "file" : "reason";
|
||||
}
|
||||
|
||||
function reviewLinkTotal(pr: SessionPRSummary): number {
|
||||
if (pr.state === "merged" || pr.state === "closed" || pr.state === "draft") {
|
||||
return 0;
|
||||
}
|
||||
if (pr.review.decision !== "changes_requested" && !pr.review.hasUnresolvedHumanComments) {
|
||||
return 0;
|
||||
}
|
||||
return pr.review.unresolvedBy.length > 0 ? pr.review.unresolvedBy.length : reviewLinks(pr).length;
|
||||
}
|
||||
|
||||
function toCIState(value: string): SessionPRSummary["ci"]["state"] {
|
||||
|
|
@ -327,13 +410,7 @@ function formatLineDelta(additions: number, deletions: number): string | undefin
|
|||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function mergeAttention(
|
||||
pr: SessionPRSummary,
|
||||
kind: Extract<PRAttentionItem["kind"], "merge_conflict" | "merge_blocked">,
|
||||
title: string,
|
||||
fallback: string,
|
||||
tone: PRDisplayTone,
|
||||
): PRAttentionItem {
|
||||
function mergeAttentionLinks(pr: SessionPRSummary, kind: "merge_conflict" | "merge_blocked"): PRSummaryLink[] {
|
||||
const href =
|
||||
kind === "merge_conflict" ? mergeConflictUrl(pr) : pr.mergeability.prUrl || pr.htmlUrl || pr.url || undefined;
|
||||
const fileLinks = (pr.mergeability.conflictFiles ?? []).slice(0, 3).map((file) => ({
|
||||
|
|
@ -350,18 +427,7 @@ function mergeAttention(
|
|||
}));
|
||||
const fallbackLink =
|
||||
kind === "merge_conflict" && href ? [{ label: "conflicts", href, title: "Open merge conflicts" }] : [];
|
||||
const links = fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink;
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
summary: links.length === 0 ? fallback : undefined,
|
||||
links,
|
||||
overflowLabel:
|
||||
fileLinks.length > 0
|
||||
? overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file")
|
||||
: overflowLabel(pr.mergeability.reasons.length, 3, "reason"),
|
||||
tone,
|
||||
};
|
||||
return fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink;
|
||||
}
|
||||
|
||||
function mergeConflictUrl(pr: SessionPRSummary): string | undefined {
|
||||
|
|
@ -412,7 +478,7 @@ function reviewerDisplayName(reviewer: SessionPRSummary["review"]["unresolvedBy"
|
|||
function reviewAttentionLink(
|
||||
pr: SessionPRSummary,
|
||||
reviewer: SessionPRSummary["review"]["unresolvedBy"][number],
|
||||
): PRAttentionLink {
|
||||
): PRSummaryLink {
|
||||
const inlineURL = reviewer.links.find((link) => link.url)?.url;
|
||||
if (reviewer.reviewUrl) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("restartProjectOrchestrator", () => {
|
|||
onError,
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
|
||||
expect(spawnMock).toHaveBeenCalledWith("proj-1", "restart", true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
|
||||
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
|
||||
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export async function restartProjectOrchestrator({
|
|||
setProjectRestarting(projectId, true);
|
||||
setOrchestratorReplacementError(projectId, null);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(projectId, true);
|
||||
const sessionId = await spawnOrchestrator(projectId, "restart", true);
|
||||
await refreshWorkspaceState(queryClient);
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { spawnOrchestrator } from "./spawn-orchestrator";
|
||||
import { apiClient } from "./api-client";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
vi.mock("./api-client", () => ({
|
||||
apiClient: { POST: vi.fn() },
|
||||
|
|
@ -14,6 +15,12 @@ vi.mock("./api-client", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("./telemetry", () => ({
|
||||
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const captureMock = vi.mocked(captureRendererEvent);
|
||||
|
||||
describe("spawnOrchestrator", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
|
@ -23,7 +30,7 @@ describe("spawnOrchestrator", () => {
|
|||
error: undefined,
|
||||
response: { status: 201 },
|
||||
});
|
||||
const id = await spawnOrchestrator("proj", true);
|
||||
const id = await spawnOrchestrator("proj", "restore_dialog", true);
|
||||
expect(id).toBe("proj-9");
|
||||
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
||||
body: { projectId: "proj", clean: true },
|
||||
|
|
@ -36,12 +43,43 @@ describe("spawnOrchestrator", () => {
|
|||
error: undefined,
|
||||
response: { status: 201 },
|
||||
});
|
||||
await spawnOrchestrator("proj");
|
||||
await spawnOrchestrator("proj", "board");
|
||||
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
||||
body: { projectId: "proj", clean: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the requested + succeeded triad keyed by source", async () => {
|
||||
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { orchestrator: { id: "proj-7" } },
|
||||
error: undefined,
|
||||
response: { status: 201 },
|
||||
});
|
||||
await spawnOrchestrator("proj", "sidebar");
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_requested", {
|
||||
project_id: "proj",
|
||||
source: "sidebar",
|
||||
});
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", {
|
||||
project_id: "proj",
|
||||
source: "sidebar",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the failed event and rethrows when the daemon rejects the spawn", async () => {
|
||||
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { message: "boom" },
|
||||
response: { status: 500 },
|
||||
});
|
||||
await expect(spawnOrchestrator("proj", "topbar")).rejects.toThrow("boom");
|
||||
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_failed", {
|
||||
project_id: "proj",
|
||||
source: "topbar",
|
||||
});
|
||||
expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", expect.anything());
|
||||
});
|
||||
|
||||
it("surfaces daemon spawn error messages and codes", async () => {
|
||||
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: undefined,
|
||||
|
|
@ -49,6 +87,8 @@ describe("spawnOrchestrator", () => {
|
|||
response: { status: 400 },
|
||||
});
|
||||
|
||||
await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)");
|
||||
await expect(spawnOrchestrator("proj", "board")).rejects.toThrow(
|
||||
"agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,46 @@
|
|||
import { apiClient, apiErrorMessage } from "./api-client";
|
||||
import { captureRendererEvent } from "./telemetry";
|
||||
|
||||
// Every UI entry point that spawns an orchestrator: the board CTA, the topbar
|
||||
// and sidebar launchers, the restore-unavailable dialog, and the auto-spawn
|
||||
// right after a project is added. Emitting the triad from inside
|
||||
// spawnOrchestrator (keyed by source) guarantees each path reports, instead of
|
||||
// each call site remembering to instrument itself. Keep in sync with the
|
||||
// allowed-source list in telemetry.ts.
|
||||
export type OrchestratorSpawnSource =
|
||||
| "board"
|
||||
| "restore_dialog"
|
||||
| "topbar"
|
||||
| "sidebar"
|
||||
| "project_add"
|
||||
| "settings"
|
||||
| "restart";
|
||||
|
||||
/** Spawn the project's orchestrator session via the daemon API. When clean is
|
||||
* true the daemon first tears down any active orchestrator for the project, then
|
||||
* re-spawns one on the canonical branch (reattaching the existing branch). */
|
||||
export async function spawnOrchestrator(projectId: string, clean = false): Promise<string> {
|
||||
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
|
||||
body: { projectId, clean },
|
||||
});
|
||||
export async function spawnOrchestrator(
|
||||
projectId: string,
|
||||
source: OrchestratorSpawnSource,
|
||||
clean = false,
|
||||
): Promise<string> {
|
||||
void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source });
|
||||
try {
|
||||
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
|
||||
body: { projectId, clean },
|
||||
});
|
||||
|
||||
if (error || !data?.orchestrator?.id) {
|
||||
const message = error
|
||||
? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
|
||||
: `Failed to spawn orchestrator (${response.status})`;
|
||||
throw new Error(message);
|
||||
if (error || !data?.orchestrator?.id) {
|
||||
const message = error
|
||||
? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
|
||||
: `Failed to spawn orchestrator (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source });
|
||||
return data.orchestrator.id;
|
||||
} catch (err) {
|
||||
void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source });
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data.orchestrator.id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,4 +111,95 @@ describe("telemetry sanitizers", () => {
|
|||
"https://api.example.com/endpoint",
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes project ids and drops everything else on CTA triads", async () => {
|
||||
const triads = [
|
||||
"ao.renderer.task_create_requested",
|
||||
"ao.renderer.task_create_succeeded",
|
||||
"ao.renderer.task_create_failed",
|
||||
"ao.renderer.session_kill_requested",
|
||||
"ao.renderer.session_kill_succeeded",
|
||||
"ao.renderer.session_kill_failed",
|
||||
"ao.renderer.settings_save_requested",
|
||||
"ao.renderer.settings_save_succeeded",
|
||||
"ao.renderer.settings_save_failed",
|
||||
];
|
||||
for (const event of triads) {
|
||||
const props = await sanitizeRendererProperties(event, {
|
||||
project_id: "demo-project",
|
||||
title: "raw user text",
|
||||
error: "raw error with /Users/alice/path",
|
||||
});
|
||||
expect(Object.keys(props)).toEqual(["project_id_hash"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only the source enum on orchestrator_spawn events", async () => {
|
||||
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_requested", {
|
||||
project_id: "demo-project",
|
||||
source: "board",
|
||||
});
|
||||
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
|
||||
expect(props.source).toBe("board");
|
||||
|
||||
const badSource = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_failed", {
|
||||
project_id: "demo-project",
|
||||
source: "/Users/alice/private",
|
||||
});
|
||||
expect(badSource).not.toHaveProperty("source");
|
||||
});
|
||||
|
||||
it("keeps every whitelisted spawn source, including topbar/sidebar/project_add/settings/restart", async () => {
|
||||
for (const source of ["board", "restore_dialog", "topbar", "sidebar", "project_add", "settings", "restart"]) {
|
||||
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_succeeded", {
|
||||
project_id: "demo-project",
|
||||
source,
|
||||
});
|
||||
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
|
||||
expect(props.source).toBe(source);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only enum values on notification events", async () => {
|
||||
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "pr" })).toEqual({
|
||||
target: "pr",
|
||||
});
|
||||
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "http://x" })).toEqual({});
|
||||
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "all" })).toEqual({
|
||||
scope: "all",
|
||||
});
|
||||
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "everything" })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("whitelists coarse daemon failure fields and drops messages", async () => {
|
||||
const props = await sanitizeRendererProperties("ao.renderer.daemon_failure", {
|
||||
daemon_state: "error",
|
||||
code: "spawn_failed",
|
||||
exit_code: 1,
|
||||
signal: "SIGKILL",
|
||||
message: "spawn /Users/alice/ao failed",
|
||||
});
|
||||
expect(props).toEqual({ daemon_state: "error", code: "spawn_failed", exit_code: 1, signal: "SIGKILL" });
|
||||
});
|
||||
|
||||
it("whitelists normalized api_error fields", async () => {
|
||||
const props = await sanitizeRendererProperties("ao.renderer.api_error", {
|
||||
operation: "GET /api/v1/projects/:id",
|
||||
error_category: "http_5xx",
|
||||
status: 500,
|
||||
body: "raw response body",
|
||||
});
|
||||
expect(props).toEqual({ operation: "GET /api/v1/projects/:id", error_category: "http_5xx", status: 500 });
|
||||
});
|
||||
|
||||
it("keeps only the reason enum on terminal_attach_failed", async () => {
|
||||
expect(await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "open_timeout" })).toEqual({
|
||||
reason: "open_timeout",
|
||||
});
|
||||
expect(
|
||||
await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "something else" }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,6 +128,19 @@ async function sanitizeRendererContextProperties(properties?: TelemetryPropertie
|
|||
return safe;
|
||||
}
|
||||
|
||||
// Allowed `source` enum for the orchestrator-spawn triad. Kept as a literal set
|
||||
// here (rather than imported from spawn-orchestrator.ts, which imports this
|
||||
// module) to avoid a cycle; keep in sync with OrchestratorSpawnSource.
|
||||
const ORCHESTRATOR_SPAWN_SOURCES = new Set([
|
||||
"board",
|
||||
"restore_dialog",
|
||||
"topbar",
|
||||
"sidebar",
|
||||
"project_add",
|
||||
"settings",
|
||||
"restart",
|
||||
]);
|
||||
|
||||
export async function sanitizeRendererProperties(
|
||||
event: string,
|
||||
properties?: TelemetryProperties,
|
||||
|
|
@ -147,11 +160,52 @@ export async function sanitizeRendererProperties(
|
|||
break;
|
||||
case "ao.renderer.project_add_succeeded":
|
||||
case "ao.renderer.project_removed":
|
||||
case "ao.renderer.orchestrator_open_requested": {
|
||||
case "ao.renderer.orchestrator_open_requested":
|
||||
case "ao.renderer.task_create_requested":
|
||||
case "ao.renderer.task_create_succeeded":
|
||||
case "ao.renderer.task_create_failed":
|
||||
case "ao.renderer.session_kill_requested":
|
||||
case "ao.renderer.session_kill_succeeded":
|
||||
case "ao.renderer.session_kill_failed":
|
||||
case "ao.renderer.settings_save_requested":
|
||||
case "ao.renderer.settings_save_succeeded":
|
||||
case "ao.renderer.settings_save_failed": {
|
||||
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
||||
if (projectIDHash) safe.project_id_hash = projectIDHash;
|
||||
break;
|
||||
}
|
||||
case "ao.renderer.orchestrator_spawn_requested":
|
||||
case "ao.renderer.orchestrator_spawn_succeeded":
|
||||
case "ao.renderer.orchestrator_spawn_failed": {
|
||||
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
||||
if (projectIDHash) safe.project_id_hash = projectIDHash;
|
||||
if (typeof properties?.source === "string" && ORCHESTRATOR_SPAWN_SOURCES.has(properties.source)) {
|
||||
safe.source = properties.source;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ao.renderer.notification_opened":
|
||||
if (properties?.target === "pr" || properties?.target === "session") safe.target = properties.target;
|
||||
break;
|
||||
case "ao.renderer.notification_marked_read":
|
||||
if (properties?.scope === "single" || properties?.scope === "all") safe.scope = properties.scope;
|
||||
break;
|
||||
case "ao.renderer.daemon_failure":
|
||||
if (typeof properties?.daemon_state === "string") safe.daemon_state = properties.daemon_state;
|
||||
if (typeof properties?.code === "string") safe.code = properties.code;
|
||||
if (typeof properties?.exit_code === "number") safe.exit_code = properties.exit_code;
|
||||
if (typeof properties?.signal === "string") safe.signal = properties.signal;
|
||||
break;
|
||||
case "ao.renderer.api_error":
|
||||
if (typeof properties?.operation === "string") safe.operation = properties.operation;
|
||||
if (typeof properties?.error_category === "string") safe.error_category = properties.error_category;
|
||||
if (typeof properties?.status === "number") safe.status = properties.status;
|
||||
break;
|
||||
case "ao.renderer.terminal_attach_failed":
|
||||
if (properties?.reason === "open_timeout" || properties?.reason === "pane_error") {
|
||||
safe.reason = properties.reason;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import { queryClient } from "./lib/query-client";
|
|||
import { createAppRouter } from "./router";
|
||||
import { TelemetryBoundary } from "./components/TelemetryBoundary";
|
||||
import { initTelemetry } from "./lib/telemetry";
|
||||
import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry";
|
||||
|
||||
const router = createAppRouter(queryClient);
|
||||
void initTelemetry();
|
||||
startDaemonFailureTelemetry();
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function ShellLayout() {
|
|||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(workspace.id);
|
||||
const sessionId = await spawnOrchestrator(workspace.id, "project_add");
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
|
|||
|
|
@ -773,6 +773,18 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
padding: 18px 18px 40px;
|
||||
}
|
||||
|
||||
/* Browser tab: the panel fills the rail edge-to-edge, so drop the body padding
|
||||
and the panel's own border/radius so it sits flush against the resize handle. */
|
||||
.session-inspector__body.session-inspector__body--browser {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-inspector__body--browser .browser-panel {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Inspector resize handle (shadcn resizable separator, AO visual): a 7px
|
||||
* transparent grab strip whose centered 1px line lights accent on hover, drag
|
||||
* (rrp sets data-separator="active"), and keyboard focus. */
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ describe("resolveDaemonFromRunFile", () => {
|
|||
executablePath: undefined,
|
||||
workingDirectory: undefined,
|
||||
message: "An AO daemon is already running, but it is not ready yet.",
|
||||
code: "not_ready",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -271,6 +272,7 @@ describe("resolveDaemonFromPort", () => {
|
|||
executablePath: undefined,
|
||||
workingDirectory: undefined,
|
||||
message: "An AO daemon is already running, but it is not ready yet.",
|
||||
code: "not_ready",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ async function readinessStatus(
|
|||
executablePath: health.executablePath,
|
||||
workingDirectory: health.workingDirectory,
|
||||
message: "An AO daemon is already running, but it is not ready yet.",
|
||||
code: "not_ready",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +160,7 @@ async function readinessStatus(
|
|||
executablePath: ready.executablePath,
|
||||
workingDirectory: ready.workingDirectory,
|
||||
message,
|
||||
code: "identity_mismatch",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
|
||||
// Electron main process (which derives it) and the preload bridge (which types
|
||||
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
|
||||
// Machine-readable failure classification for telemetry. `message` is
|
||||
// human-facing and may contain local paths; `code` is what gets reported.
|
||||
// Statuses without a code (normal ready, user-initiated stop) are not failures.
|
||||
export type DaemonFailureCode =
|
||||
| "not_configured"
|
||||
| "daemon_unreachable"
|
||||
| "binary_missing"
|
||||
| "spawn_failed"
|
||||
| "exited"
|
||||
| "port_unconfirmed"
|
||||
| "not_ready"
|
||||
| "identity_mismatch";
|
||||
|
||||
export type DaemonStatus = {
|
||||
state: "starting" | "ready" | "stopped" | "error";
|
||||
port?: number;
|
||||
|
|
@ -8,4 +21,7 @@ export type DaemonStatus = {
|
|||
executablePath?: string;
|
||||
workingDirectory?: string;
|
||||
message?: string;
|
||||
code?: DaemonFailureCode;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue