diff --git a/.claude/skills/tycho/SKILL.md b/.claude/skills/tycho/SKILL.md index c514e22..4535c1b 100644 --- a/.claude/skills/tycho/SKILL.md +++ b/.claude/skills/tycho/SKILL.md @@ -1,21 +1,15 @@ --- name: tycho -description: Manages Tycho-monitored projects and managed agents. Use when the user asks to deploy, check status, manage maintenance mode, create/list/run/stop/send/archive/clone agents, or control schedules for any project. +description: Manages Tycho-monitored projects and managed agents. Use when the user asks to check status, create/list/run/stop/send/archive/clone agents, or control schedules for any project. --- # Tycho CLI Skill -Manage Kamal-deployed projects, managed agents, and scheduled runs via the `tycho` CLI. ## Quick Reference | Group | Command | Description | |-------|---------|-------------| -| **app** | `app list` | List projects with Kamal deployment | -| | `app status ` | Check a project's health and last action | -| | `app deploy ` | Start a deploy | -| | `app maintenance ` | Enable maintenance mode | -| | `app live ` | Resume live traffic | | **project** | `project update --pr-url ` | Set / clear open PR URL | | **agent** | `agent create ` | Create (and optionally run) a managed agent | | | `agent list []` | List agents, optionally filtered by project | @@ -161,20 +155,6 @@ tycho agent clone my-project-agent-3 --run # clone and start immediately --- -## `tycho app` — Deployment Commands - -```bash -tycho app list -tycho app status my-project -tycho app deploy my-project -tycho app maintenance my-project -tycho app live my-project -``` - -Actions run as detached background processes. Check progress via `app status` or the TUI. - ---- - ## `tycho schedule` — Schedule Management ```bash @@ -200,7 +180,6 @@ tycho project update my-project --pr-url "" # clear ## Project Keys ```bash -tycho app list # Kamal-enabled projects grep "^- key:" ~/.tycho/config/hq.yml # all projects (including agent-only) ``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b9e5591..2ee57f7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -14,5 +14,4 @@ assignees: "" ## Notes -Mention affected areas such as TUI, Remote UI, Kamal actions, managed agents, hooks, config, or docs. diff --git a/AGENTS.md b/AGENTS.md index 2b1fb11..ead7ca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,10 @@ `bin/tycho` is the executable and boots through `HQ::CLI` in `lib/hq/cli.rb`. Keep the main Bubbletea model and screen-level update flow in `lib/hq/app.rb`, config loading in `lib/hq/registry.rb`, terminal input shims in `lib/hq/bubbletea_input.rb`, domain and process-management logic under `lib/hq/domain/`, form/composer components under `lib/hq/ui/components/`, and rendering split between the aggregator in `lib/hq/ui/rendering.rb` and focused modules under `lib/hq/ui/rendering/`. -Domain files are intentionally small: `constants.rb` owns log/schema paths, `version_lookup.rb` handles RubyGems lookups, `kamal_action.rb` manages detached Kamal commands, `app_project.rb` handles project metadata and health checks, `managed_agent.rb` manages Codex/Claude-compatible execution and structured results, and `agent_store.rb` persists managed agents. Project definitions live in `~/.tycho/config/hq.yml`, system prompt templates live in `~/.tycho/config/system_prompts.yml`, and structured managed-agent output is described by `~/.tycho/config/schemas/agent_result.json`. Project status, key decisions, and roadmap live in `docs/PROJECT_STATUS.md`; update it when durable priorities, milestones, or architectural decisions change. Research and workflow notes live under `docs/`, including `docs/GOTCHAS.md`, `docs/REMOTE_SERVER.md`, `docs/research/charm-ruby.md`, `docs/research/codex-json-schema-research.md`, and `docs/research/claude-json-schema-research.md`. -Runtime artifacts are written to `~/.tycho/logs/`, including app state files such as `actions.json` and `managed_agents.json`, application logs in `hq.log`, project logs under `~/.tycho/logs/projects/{project}/`, archived project logs under `~/.tycho/logs/projects/archived/`, and per-agent logs/status/result files under `~/.tycho/logs/agents/`. Keep automated checks under `test/`; the existing rendering regression coverage lives in `test/rendering_test.rb`. +Runtime artifacts are written to `~/.tycho/logs/`, including app state files such as `managed_agents.json`, application logs in `hq.log`, project logs under `~/.tycho/logs/projects/{project}/`, archived project logs under `~/.tycho/logs/projects/archived/`, and per-agent logs/status/result files under `~/.tycho/logs/agents/`. Keep automated checks under `test/`; the existing rendering regression coverage lives in `test/rendering_test.rb`. ## Build, Test, and Development Commands @@ -37,7 +36,6 @@ If you introduce new tooling, document the command here and keep it runnable fro Follow the existing Ruby style across `bin/tycho` and `lib/hq/**/*.rb`: two-space indentation, snake_case for methods and variables, SCREAMING_SNAKE_CASE for constants, and short guard clauses where they simplify flow. Keep classes and modules focused, and prefer small helper methods over deeply nested conditionals. -Preserve the current separation of concerns: registry/config parsing stays out of the TUI layer, managed-agent and Kamal behavior belongs in domain objects, and screen/layout code should stay in the UI modules. Preserve the current file-level conventions: `# frozen_string_literal: true`, double-quoted strings, and concise comments only where the code is not obvious. @@ -45,11 +43,9 @@ The repo ships a `.rubocop.yml` that pins double-quoted string style and Ruby 3. ## Runtime Behavior & External Integrations -Health checks run concurrently via Ruby threads. `AppProject#check_health!` uses HEAD requests against the configured healthcheck path and root URL; keep HEAD semantics because kamal-proxy maintenance detection depends on them. A root URL 503 means the app is in maintenance mode even if the health endpoint looks healthy. -Kamal actions run as detached background processes through `mise exec`, using `TYCHO_MISE_BIN` first, then common local install paths, then `mise` on `PATH`. Prefer a project's `bin/kamal` binstub and fall back to `bundle exec kamal`. Logs go to `~/.tycho/logs/projects/{project}/action.log`, action state is persisted to `~/.tycho/logs/actions.json`, and actions are restored and checked for liveness on startup. Archiving a project moves its config entry from `~/.tycho/config/hq.yml` to `~/.tycho/config/hq.archived.yml`, moves `~/.tycho/logs/projects/{project}/` to `~/.tycho/logs/projects/archived/YYYY-MM-DD_project-name/`, and archives related managed-agent logs. -The app auto-refreshes every 30 seconds. Action and agent status is polled every 10 seconds. Multiple projects can have concurrent actions running, and completed actions trigger a health refresh. +The app auto-refreshes every 30 seconds. Agent status is polled every 10 seconds. Managed agents are configured from project settings in `~/.tycho/config/hq.yml`, with prompt templates loaded from `~/.tycho/config/system_prompts.yml`. Codex agents use JSON output and `~/.tycho/config/schemas/agent_result.json`; Claude and custom Claude-compatible harnesses use `--output-format stream-json` so logs stream incrementally. Use `TYCHO_CODEX_BIN` and `TYCHO_CLAUDE_BIN` to override built-in agent executables. Custom Claude-compatible wrappers belong in `custom_harnesses` with `adapter: claude` and an `execution_command`; provider-specific details should live in that wrapper or command configuration, not in HQ. Native Claude/Codex `session_id` values are persisted on managed agents and reused with `--resume` after the first run; HQ still treats `memory.jsonl` as the canonical transcript and only replays the bounded memory prompt when no native session is known. Preserve structured inquiry submission and focus-aware chat behavior when changing agent flows. @@ -71,7 +67,7 @@ For input components, keep paste handling compatible with Bubbles `TextInput` an This repository now has lightweight automated coverage under `test/`. Every change should at minimum pass `bin/test` and a manual run of `bin/tycho` when TUI behavior is affected. -Validate the affected key paths in the UI, especially grouped project rows, table alignment, detail views, sidebar log inspection, agent create/edit flows, agent chat and structured inquiry submission, refresh, deploy, maintenance toggle, and the `g` shortcut that opens the selected project in a terminal. +Validate the affected key paths in the UI, especially grouped project rows, table alignment, detail views, sidebar log inspection, agent create/edit flows, agent chat and structured inquiry submission, refresh, and the `g` shortcut that opens the selected project in a terminal. For Remote UI `/ui` changes, run `bundle exec ruby test/remote_server_test.rb` and do browser verification for user-visible behavior. A safe fallback pattern is to start `bin/tycho serve` on a spare localhost port with temp env vars (`TYCHO_CONFIG_PATH`, `TYCHO_SYSTEM_PROMPTS_PATH`, `TYCHO_LOGS_ROOT`), create fixture data through the JSON API, then drive `http://127.0.0.1:{port}/ui` with Playwright + local Google Chrome. Check concrete browser facts such as focused form values surviving `refresh({ force: true })`, details toggles preserving state across polling, mutually exclusive panels closing as expected, and sticky/fixed docks staying pinned inside the viewport. @@ -83,6 +79,6 @@ When adding tests, keep using simple Ruby test files under `test/` with `*_test. Recent commits use short, imperative subjects such as `Fix table column alignment across screens` and `Add custom Claude harness support`. Keep commit messages in that style and scope each commit to one logical change. -Pull requests should include a brief summary, manual verification steps, and screenshots or terminal captures for visible TUI changes. Link related issues when applicable and note any changes to `~/.tycho/config/hq.yml`, `~/.tycho/config/system_prompts.yml`, structured agent schemas, hardcoded project paths, logs, or external dependencies such as `mise`, RubyGems API access, Codex, Claude, or Bedrock-backed Claude execution. +Pull requests should include a brief summary, manual verification steps, and screenshots or terminal captures for visible TUI changes. Link related issues when applicable and note any changes to `~/.tycho/config/hq.yml`, `~/.tycho/config/system_prompts.yml`, structured agent schemas, hardcoded project paths, logs, or external dependencies such as Codex, Claude, or Bedrock-backed Claude execution. Also call out changes to Bubbletea input handling, bracketed paste behavior, or Bubbles text components because they affect chat, inquiry, and agent editor typing flows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7e4dd..7a33073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,7 @@ All notable changes to Tycho will be documented in this file. ### Notable Releases - Add persisted Remote UI server switching for configured `remote_servers`, - including backend proxy routes, selected-server persistence, and peer health - display. (#39) + including backend proxy routes and selected-server persistence. (#39) - Add Remote UI skill autocomplete and a quick agent switcher for faster chat composition and agent navigation. (#38) - Show run summaries in Remote UI conversations and allow any Remote UI @@ -87,8 +86,6 @@ All notable changes to Tycho will be documented in this file. - Improve the Remote UI Summary surface and keep run summary blocks out of the main conversation stream. (#11) -- Fix Remote readiness checks by sharing executable resolution for `mise`, - Kamal, Codex, Claude, and compatible harnesses. (#12) - Fix Claude structured output schema handling so Claude-compatible runs can produce and parse structured results reliably. (#13) - Resume stopped schedules safely after interactive scheduled agents are diff --git a/CLAUDE.md b/CLAUDE.md index e5a61d3..c40eb04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -HQ is a terminal-based dashboard for monitoring and managing Kamal-deployed Ruby on Rails projects and managed coding agents. `bin/tycho` is the executable and boots through `HQ::CLI` in `lib/hq/cli.rb`. Use Bubbletea for the TUI structure, Lipgloss for styling, and Bubbles for components like the spinner. Keep the Elm Architecture in mind: `init` sets up initial state and commands, `update(message)` handles events and returns state plus commands, and `view` renders the current state. @@ -17,9 +16,7 @@ Terminal input handling lives in `lib/hq/bubbletea_input.rb`. It patches `Bubble Domain and process-management code lives under `lib/hq/domain/`: - `constants.rb` defines log paths and schema paths. -- `version_lookup.rb` fetches latest gem versions from RubyGems. -- `kamal_action.rb` manages detached deploy/maintenance/live processes. -- `app_project.rb` represents a configured project and handles deploy metadata, git metadata, and health checks. +- `project.rb` represents a configured project and handles workspace and git metadata. - `managed_agent.rb` manages Codex and Claude-compatible agent execution, logs, messages, structured results, and inquiry state. - `agent_store.rb` persists managed agents. - `agent_log_parser.rb` parses raw Codex JSON streams and `chat.log` blocks into conversation, system, and chat entries. @@ -31,9 +28,9 @@ UI components live under `lib/hq/ui/components/`, including chat composer, inqui Project definitions live in `~/.tycho/config/hq.yml`, and system prompt templates live in `~/.tycho/config/system_prompts.yml`. Structured managed-agent output is described by `~/.tycho/config/schemas/agent_result.json`. Project status, key decisions, and roadmap live in `docs/PROJECT_STATUS.md`; update it when durable priorities, milestones, or architectural decisions change. Operational pitfalls live in `docs/GOTCHAS.md`, and research notes live under `docs/research/`, including Charm Ruby guidance and Codex/Claude JSON schema notes. -Runtime artifacts are written to `~/.tycho/logs/`, including `hq.log` (application log), app state files such as `actions.json` and `managed_agents.json`, project logs under `~/.tycho/logs/projects/{project}/`, archived project logs under `~/.tycho/logs/projects/archived/`, and per-agent files under `~/.tycho/logs/agents/`. Each agent run produces: `.raw.log` (raw stdout/JSON stream), `.conversation.log` (user/assistant turns), `.system.log` (tool calls, system events), and `.memory.jsonl` (canonical event log). The TUI chat viewport reads directly from `memory.jsonl` (history) and `raw.log` (live streaming) — there is no intermediate `chat.log` file. Automated rendering checks live in `test/rendering_test.rb`. +Runtime artifacts are written to `~/.tycho/logs/`, including `hq.log` (application log), app state files such as `managed_agents.json`, project logs under `~/.tycho/logs/projects/{project}/`, archived project logs under `~/.tycho/logs/projects/archived/`, and per-agent files under `~/.tycho/logs/agents/`. Each agent run produces: `.raw.log` (raw stdout/JSON stream), `.conversation.log` (user/assistant turns), `.system.log` (tool calls, system events), and `.memory.jsonl` (canonical event log). The TUI chat viewport reads directly from `memory.jsonl` (history) and `raw.log` (live streaming) - there is no intermediate `chat.log` file. Automated rendering checks live in `test/rendering_test.rb`. -`HQ.logger` is a centralized application logger backed by Ruby's stdlib `Logger`, writing to `~/.tycho/logs/hq.log` with daily rotation. It captures app lifecycle events, config loading, process start/stop, health check failures, and silently-rescued errors. The log level defaults to `INFO` and can be overridden via `TYCHO_LOG_LEVEL` (e.g., `DEBUG`, `WARN`). Rotated log files older than 7 days are cleaned up at startup. Use `HQ.logger.info("Component") { "message" }` with the component name as `progname`. +`HQ.logger` is a centralized application logger backed by Ruby's stdlib `Logger`, writing to `~/.tycho/logs/hq.log` with daily rotation. It captures app lifecycle events, config loading, process start/stop, and silently-rescued errors. The log level defaults to `INFO` and can be overridden via `TYCHO_LOG_LEVEL` (e.g., `DEBUG`, `WARN`). Rotated log files older than 7 days are cleaned up at startup. Use `HQ.logger.info("Component") { "message" }` with the component name as `progname`. ## Running and Checks @@ -55,11 +52,9 @@ If you introduce new tooling, document the command here and keep it runnable fro ## Runtime Behavior -Health checks run concurrently via Ruby threads. `AppProject#check_health!` uses HEAD requests against the configured healthcheck path and root URL; keep HEAD semantics because kamal-proxy maintenance detection depends on them. A root URL 503 means the app is in maintenance mode even if the health endpoint looks healthy. -Kamal actions run as detached background processes through `mise exec`, using `TYCHO_MISE_BIN` first, then common local install paths, then `mise` on `PATH`. Prefer a project's `bin/kamal` binstub and fall back to `bundle exec kamal`. Logs go to `~/.tycho/logs/projects/{project}/action.log`, action state is persisted to `~/.tycho/logs/actions.json`, and actions are restored and checked for liveness on startup. Archiving a project moves its config entry from `~/.tycho/config/hq.yml` to `~/.tycho/config/hq.archived.yml`, moves `~/.tycho/logs/projects/{project}/` to `~/.tycho/logs/projects/archived/YYYY-MM-DD_project-name/`, and archives related managed-agent logs. -The app auto-refreshes every 30 seconds. Action and agent status is polled every 10 seconds. Multiple projects can have concurrent actions running, and completed actions trigger a health refresh. +The app auto-refreshes every 30 seconds. Agent status is polled every 10 seconds. Managed agents are configured from project settings in `~/.tycho/config/hq.yml`, with prompt templates loaded from `~/.tycho/config/system_prompts.yml`. Codex agents use JSON output and `~/.tycho/config/schemas/agent_result.json`; Claude and custom Claude-compatible harnesses use `--output-format stream-json` so logs stream incrementally. Use `TYCHO_CODEX_BIN` and `TYCHO_CLAUDE_BIN` to override built-in executables. Custom Claude-compatible wrappers belong in `custom_harnesses` with `adapter: claude` and an `execution_command`; provider-specific details should live in that wrapper or command configuration, not in HQ. Both paths funnel through `AgentLogParser` so the raw stream is demultiplexed into conversation and system entries. `ManagedAgent` persists a native `session_id` for Codex and Claude-compatible harnesses in `~/.tycho/logs/managed_agents.json`: Claude-like agents get a generated `--session-id` on first run and use `--resume` afterward, while Codex captures the first `thread_id` from the JSON stream and then runs `codex exec resume`. Once a native session is known, follow-up runs send only the latest user message instead of replaying the full HQ memory window; `memory.jsonl` remains the canonical HQ transcript and bounded replay source for first runs or agents without a native session. This keeps prompt budgets smaller and recovers native prompt-cache reuse, with the tradeoff that Codex resumed runs cannot currently pass `--output-schema` through the resume subcommand. The TUI chat viewport uses a hybrid rendering approach via `AgentChatLog`: historical conversation comes from `memory.jsonl` (user messages, assistant messages, tool summaries from past runs), while live streaming content comes from parsing `raw.log` for the current active run. User messages appear immediately because they are written to `memory.jsonl` on send. When a run finishes, `capture_run_memory!` commits the full assistant messages and tool summaries into `memory.jsonl`, so the conversation history is preserved across the live-to-history transition. `AgentMemory` preserves the canonical `memory.jsonl` across runs and caps conversation/tool/run history when rebuilding prompts. Structured inquiry submission is gated behind a review step - the inquiry form renders inside a rounded box with the question heading and requires confirmation before sending. Keep that review gate and focus-aware chat behavior intact when changing agent flows. @@ -75,13 +70,12 @@ Follow the existing Ruby style across `bin/tycho` and `lib/hq/**/*.rb`: two-spac Preserve the current file-level conventions: `# frozen_string_literal: true`, double-quoted strings, and concise comments only where the code is not obvious. The repo ships a `.rubocop.yml` that pins `Style/StringLiterals` and `Style/StringLiteralsInInterpolation` to `double_quotes` and sets `TargetRubyVersion: 3.4`; do not let autoformatters flip strings to single quotes. -Preserve the separation of concerns: registry/config parsing stays out of the TUI layer, managed-agent and Kamal behavior belongs in domain objects, and screen/layout code should stay in UI modules. ## UI Guidance Reuse shared color/style helpers in `lib/hq/ui/rendering/styles.rb`, keep table columns aligned across Agents and Projects screens, and preserve compact path rendering plus focus-aware chat/inquiry flows that the rendering tests cover. -When changing visible UI, validate the affected key paths: grouped project rows, table alignment, detail view, sidebar log inspection, agent create/edit flows, agent chat streaming from `chat.log`, the skill picker invoked from the chat composer, the gated inquiry review step, refresh, deploy, maintenance toggle, and the terminal-opening shortcut. +When changing visible UI, validate the affected key paths: grouped project rows, table alignment, detail view, sidebar log inspection, agent create/edit flows, agent chat streaming from `memory.jsonl`/`raw.log`, the skill picker invoked from the chat composer, the gated inquiry review step, refresh, and the terminal-opening shortcut. When changing input components, keep paste behavior compatible with Bubbles `TextInput` and `TextArea`. Multi-character paste should insert as text, not trigger global shortcuts one character at a time. @@ -91,12 +85,12 @@ Keep automated checks under `test/` with `*_test.rb` names unless the repo adopt When touching terminal input or text components, include paste regression coverage in `test/rendering_test.rb`; the existing tests cover both raw and bracketed paste for `lib/hq/ui/components/chat_composer.rb`. -Manual verification should include the affected UI paths and any external dependencies touched by the change, especially `mise`, RubyGems API access, Codex execution, Claude-compatible execution, and config or schema changes. +Manual verification should include the affected UI paths and any external dependencies touched by the change, especially Codex execution, Claude-compatible execution, and config or schema changes. ## Commit and Pull Request Guidelines Recent commits use short, imperative subjects such as `Fix table column alignment across screens` and `Add custom Claude harness support`. Keep commit messages in that style and scope each commit to one logical change. -Pull requests should include a brief summary, manual verification steps, and screenshots or terminal captures for visible TUI changes. Link related issues when applicable and note any changes to `~/.tycho/config/hq.yml`, `~/.tycho/config/system_prompts.yml`, structured agent schemas, hardcoded project paths, logs, or external dependencies such as `mise`, RubyGems API access, Codex, Claude, or Bedrock-backed Claude execution. +Pull requests should include a brief summary, manual verification steps, and screenshots or terminal captures for visible TUI changes. Link related issues when applicable and note any changes to `~/.tycho/config/hq.yml`, `~/.tycho/config/system_prompts.yml`, structured agent schemas, hardcoded project paths, logs, or external dependencies such as Codex, Claude, or Bedrock-backed Claude execution. Also call out changes to Bubbletea input handling, bracketed paste behavior, or Bubbles text components because they affect chat, inquiry, and agent editor typing flows. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e36f50f..9f665e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,6 @@ Follow the existing Ruby style: - `SCREAMING_SNAKE_CASE` constants. - Small domain objects and focused rendering modules. -Keep registry/config parsing out of the TUI layer. Keep managed-agent and Kamal behavior in domain objects. Keep rendering split between `lib/hq/ui/rendering.rb` and focused modules under `lib/hq/ui/rendering/`. diff --git a/README.md b/README.md index 5d886f3..df7cd73 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,19 @@ # Tycho -Tycho is a local-first terminal dashboard for monitoring Kamal-deployed -projects and supervising managed coding agents. +Tycho - Factorio for Agents. + +Tycho is a local-first control center for supervising managed coding agents +across many projects. It keeps project context, agent sessions, schedules, +logs, attachments, and follow-up questions in one operator workflow, with both +a terminal UI and an optional lightweight Remote UI for checking agent state +from a browser on your local network or tailnet. + +## Screenshots

Tycho terminal dashboard and remote agent control interface

-It combines a Bubbletea/Lipgloss TUI, detached Kamal actions, persistent agent -logs, and an optional lightweight Remote UI for checking agent state from a -browser on your local network or tailnet. - -## Status - -Tycho is early open-source software. It is designed around a single-operator -workflow and is currently macOS-first, with Linux expected to work where the -same Ruby and CLI dependencies are available. - -## Features - -- Project registry from `~/.tycho/config/hq.yml`. -- Concurrent HEAD-based health checks for Kamal apps. -- Detached Kamal deploy, maintenance, and live actions. -- Managed Codex, Claude, and custom Claude-compatible agents with persistent chat memory. -- Scheduled managed-agent runs from cron-style local config. -- In-app log views, agent detail views, project detail views, and omnisearch. -- Local JSON API and mobile Remote UI through `tycho serve`. -- Optional Tailscale MagicDNS URL and terminal QR code for Remote UI access. -- Optional browser push notifications for agent completions and inquiries. - -## Screenshots - ### TUI | Agents dashboard | New project | @@ -51,13 +34,37 @@ same Ruby and CLI dependencies are available. |---------------|--------------------| | Tycho Remote UI agent chat composer with a selected agent conversation | Tycho Remote UI Markdown attachment preview with copy and delete actions | +| Remote connection switcher | Agent switcher | +|----------------------------|----------------| +| Tycho Remote UI menu showing multiple Remote servers with Local, VPS, and ATASGG options | Tycho Remote UI agent switcher showing recent managed agents and status badges | + +## Status + +Tycho is early open-source software. It is designed around a single-operator +workflow and is currently macOS-first for packaged installs. Source installs +also work on Linux-style environments where Ruby and the optional agent CLIs +are available, and Tycho has been tested on Windows 11 through WSL. + +## Features + +- Project registry from `~/.tycho/config/hq.yml`. +- Managed Codex, Claude, OpenCode, and custom Claude-compatible agents with + persistent chat memory. +- Scheduled managed-agent runs from cron-style local config. +- In-app log views, agent detail views, project detail views, and omnisearch. +- Local JSON API and mobile Remote UI through `tycho serve`. +- Remote UI server switching across multiple Tycho servers through a local + broker. +- Optional Tailscale MagicDNS URL and terminal QR code for Remote UI access. +- Optional browser push notifications for agent completions and inquiries. + ## Requirements - Homebrew for the packaged macOS install. - Ruby 3.2 or newer for source installs. - Bundler for source installs. - Go, when Charm Ruby native gems need to be compiled during install. -- Optional: `mise`, `kamal`, `tailscale`, `codex`, and `claude`. +- Windows 11 users should run Tycho inside WSL. Tycho can run without every optional tool, but features backed by missing tools will show as unavailable. @@ -86,7 +93,6 @@ tycho schedule daemon ``` Optional integrations are intentionally not installed by the formula. Install -and configure `mise`, `kamal`, `tailscale`, `codex`, `claude`, or custom Claude-compatible harnesses only for the features you use. ### Source Checkout @@ -122,9 +128,8 @@ bin/tycho `bin/setup` installs gems, creates missing user config files from examples under `~/.tycho`, and prints hard failures plus soft feature warnings for optional tools. Use `bin/setup --check` to inspect readiness without changing -files, or pass feature profiles such as `bin/setup --profile app`, -`bin/setup --profile codex`, or `bin/setup --profile claude` to make those -optional tools mandatory. +files, or pass feature profiles such as `bin/setup --profile codex` or +`bin/setup --profile claude` to make those optional tools mandatory. Run through Bundler if your shell has conflicting gem versions: @@ -146,11 +151,10 @@ Project definitions live in `~/.tycho/config/hq.yml` by default. ```yaml projects: - - key: my-app - name: My App + - key: my-workspace + name: My Workspace group: Personal - path: /Users/you/Code/my-app - apps: true + path: /Users/you/Code/my-workspace agent: codex ``` @@ -172,6 +176,7 @@ Homebrew and source installs use the same user-scoped defaults: | Schedules | `~/.tycho/config/schedules.yml` | | Schedule prompt files | `~/.tycho/schedules/` | | Hooks | `~/.tycho/config/hooks.yml` | +| Remote server peers | `remote_servers` in `~/.tycho/config/hq.yml` | | Runtime state and logs | `~/.tycho/logs/` | | Project logs | `~/.tycho/logs/projects/` | | Agent logs and artifacts | `~/.tycho/logs/agents/` | @@ -197,7 +202,6 @@ Use the `TYCHO_` prefix for runtime overrides. | `TYCHO_LOGS_ROOT` | Override runtime state and logs root. | | `TYCHO_SCHEDULES_STATE_PATH` | Override scheduler runtime state path. | | `TYCHO_SCHEDULER_DAEMON_PATH` | Override scheduler daemon heartbeat path. | -| `TYCHO_MISE_BIN` | Override `mise` executable lookup. | | `TYCHO_CODEX_BIN` | Override Codex executable lookup. | | `TYCHO_CLAUDE_BIN` | Override Claude executable lookup. | | `TYCHO_TAILSCALE_BIN` | Override Tailscale executable lookup. | @@ -218,16 +222,6 @@ Open the TUI: tycho ``` -Run app commands: - -```bash -tycho app list -tycho app status -tycho app deploy -tycho app maintenance -tycho app live -``` - Start the Remote Sessions server: ```bash @@ -240,6 +234,22 @@ Bind explicitly to localhost: tycho serve --host 127.0.0.1 --port 7373 ``` +Connect one Remote UI to multiple Tycho servers by adding `remote_servers` to +`~/.tycho/config/hq.yml`: + +```yaml +remote_servers: + - key: vps + name: VPS + url: http://vps-cd946cb7.tail952bf7.ts.net:7373 + token_env: TYCHO_VPS_REMOTE_TOKEN +``` + +The Remote UI always includes the local server and can switch to configured +peers from Settings or the top-right menu. Agents, projects, schedules, drafts, +attachments, and mutations are scoped to the active server; Tycho does not +merge state across servers. + Run scheduled agents: ```bash @@ -276,9 +286,8 @@ humanized cron cadence such as `every 15 minutes`. ## Scheduled Agents Schedules create fresh managed agents for projects. They do not run shell -commands directly, do not model project actions as first-class schedule targets, -and do not resume old agent sessions. This keeps recurring work reviewable and -prevents stale context from accumulating across runs. +commands directly and do not resume old agent sessions. This keeps recurring +work reviewable and prevents stale context from accumulating across runs. Definitions live in `~/.tycho/config/schedules.yml`, which is local and gitignored. Long prompts should live as Markdown files under `~/.tycho/schedules/`. @@ -292,7 +301,7 @@ schedules: timezone: local target: type: agent - project_key: my-app + project_key: my-workspace name: Pull request review message_source: file message_file: schedules/pull-request-review.md @@ -324,7 +333,7 @@ Prompt tips for reliable schedules: the current date. - Separate review from mutation. For risky workflows, ask the agent to write a review file or plan first and wait for human approval before posting, - deploying, deleting, merging, or sending messages. + deleting, merging, or sending messages. - Keep prompts narrow. A scheduled agent should have one recurring job, one project, and a clear completion condition. - Include a no-op result. Tell the agent what to output when there is no work, @@ -407,11 +416,16 @@ When Tailscale HTTPS Serve is available, Tycho can print an HTTPS MagicDNS Remote UI URL and QR code. Public screenshots should redact MagicDNS URLs, Tailscale IPs, and QR codes. +For multiple Remote servers, the browser still talks only to the Tycho server +that served the UI. That local server brokers requests to the selected peer, +using `token_env` values from local config or per-browser peer tokens entered +in Settings. Browser-entered peer tokens are not written to `hq.yml`. + ## Custom Claude Harnesses -Tycho has built-in `codex` and `claude` harnesses. To run Claude through a -wrapper, define a custom harness in `~/.tycho/config/hq.yml` and use its key as -a project or template agent: +Tycho has built-in `codex`, `claude`, and `opencode` harnesses. To run Claude +through a wrapper, define a custom harness in `~/.tycho/config/hq.yml` and use +its key as a project or template agent: ```yaml custom_harnesses: @@ -420,11 +434,10 @@ custom_harnesses: execution_command: /Users/you/bin/claude-wrapper projects: - - key: my-app - name: My App + - key: my-workspace + name: My Workspace group: Personal - path: /Users/you/Code/my-app - apps: true + path: /Users/you/Code/my-workspace agent: claude-wrapper ``` @@ -452,12 +465,12 @@ bundle exec ruby test/rendering_test.rb ## Known Limitations - Tycho is macOS-first for the initial Homebrew release. -- Linux is expected to work where Ruby, native build tools, and optional CLIs - are available, but it is not the primary packaged target yet. +- Linux and Windows 11 through WSL are source-install targets where Ruby, + native build tools, and optional CLIs are available, but they are not the + primary packaged target yet. - Remote UI is local-first. Set `TYCHO_REMOTE_TOKEN` before binding to a non-loopback interface. -- Tycho does not install `mise`, `kamal`, `codex`, `claude`, `tailscale`, or - custom harness dependencies. +- Custom harness dependencies remain the operator's responsibility. - Managed agents can run powerful local tools. Review prompts, project paths, and sandbox settings before starting agents. diff --git a/bin/remote-ui-smoke b/bin/remote-ui-smoke index 03c64c5..14004c3 100755 --- a/bin/remote-ui-smoke +++ b/bin/remote-ui-smoke @@ -36,10 +36,9 @@ def write_fixture(dir) - key: web name: Web path: #{workspace.inspect} - apps: false agent: codex YAML - File.write(prompts_path, "---\ncustom: \"Check the app.\"\n") + File.write(prompts_path, "---\ncustom: \"Check the workspace.\"\n") File.write(schedules_path, "---\nschedules: []\n") skill_dir = File.join(workspace, ".agents", "skills", "review") FileUtils.mkdir_p(skill_dir) @@ -58,8 +57,8 @@ def write_fixture(dir) } end -def wait_for_health(base_url, log_path) - uri = URI("#{base_url}/health") +def wait_for_remote(base_url, log_path) + uri = URI("#{base_url}/setup") Timeout.timeout(15) do loop do response = Net::HTTP.get_response(uri) @@ -71,7 +70,7 @@ def wait_for_health(base_url, log_path) end end rescue Timeout::Error - raise "Remote server did not become healthy:\n#{File.read(log_path)}" + raise "Remote server did not become reachable:\n#{File.read(log_path)}" end def process_alive?(pid) @@ -263,7 +262,7 @@ Dir.mktmpdir("tycho-remote-ui-smoke") do |dir| ) begin - wait_for_health(base_url, log_path) + wait_for_remote(base_url, log_path) agent_key = create_agent(base_url) node_dir = File.join(dir, "node") install_playwright!(node_dir) diff --git a/bin/setup b/bin/setup index 752dd1e..179872f 100755 --- a/bin/setup +++ b/bin/setup @@ -19,7 +19,7 @@ Options: --check Run dependency checks only; do not install gems or copy config files. --skip-bundle Skip bundle install. --profile NAME Escalate optional dependencies for a feature profile. - Supported: app, codex, claude, opencode, all. May be repeated. + Supported: codex, claude, opencode, all. May be repeated. --remote Check Remote UI readiness hints. --push Check browser push notification readiness hints. -h, --help Show this help. @@ -27,7 +27,7 @@ Options: Examples: bin/setup bin/setup --check - bin/setup --profile app --profile codex + bin/setup --profile codex --profile claude USAGE } @@ -46,7 +46,7 @@ while (($#)); do exit 2 fi case "$1" in - app|codex|claude|opencode|all) + codex|claude|opencode|all) PROFILES+=("$1") ;; *) @@ -413,43 +413,6 @@ check_custom_harnesses() { done <<< "$output" } -check_kamal_readiness() { - if is_executable kamal; then - ok "Kamal CLI found: kamal" - return - fi - - local count="0" - local config_path - config_path="$(tycho_config_path)" - if [[ -f "$config_path" ]] && has_command ruby; then - count="$( - TYCHO_SETUP_CONFIG_PATH="$config_path" ruby -ryaml -e ' - data = YAML.load_file(ENV.fetch("TYCHO_SETUP_CONFIG_PATH")) || {} - projects = Array(data["projects"]).select { |project| project["apps"] } - ready = projects.count do |project| - path = File.expand_path(project["path"].to_s) - binstub = File.join(path, "bin", "kamal") - lockfile = File.join(path, "Gemfile.lock") - gemfile = File.join(path, "Gemfile") - File.executable?(binstub) || - (File.file?(lockfile) && File.read(lockfile).match?(/^ kamal \(/)) || - (File.file?(gemfile) && File.read(gemfile).match?(/gem ["'"'"']kamal["'"'"']/)) - end - puts ready - ' 2>/dev/null || printf '0' - )" - fi - - if [[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]]; then - ok "Project Kamal dependency found for $count app project(s)" - elif profile_enabled app; then - fail "Kamal command not found for requested 'app' profile. Install Kamal globally or configure app projects with bin/kamal or a Kamal gem dependency." - else - warn "Kamal command not found. Deploy, maintenance, and live actions need global kamal or project Kamal dependencies." - fi -} - check_tailscale() { local tailscale_bin tailscale_bin="$(resolve_tool TAILSCALE_BIN tailscale)" @@ -540,14 +503,11 @@ main() { info "Checking optional runtime integrations" check_optional_tool "Git CLI" "git" "" "Project git metadata will render as n/a." - local mise_bin codex_bin claude_bin opencode_bin - mise_bin="$(resolve_tool MISE_BIN mise "$HOME/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mise)" + local codex_bin claude_bin opencode_bin codex_bin="$(resolve_tool CODEX_BIN codex "$HOME/.local/bin/codex" /opt/homebrew/bin/codex /usr/local/bin/codex)" claude_bin="$(resolve_tool CLAUDE_BIN claude "$HOME/.local/bin/claude" /opt/homebrew/bin/claude /usr/local/bin/claude)" opencode_bin="$(resolve_tool OPENCODE_BIN opencode "$HOME/.local/bin/opencode" /opt/homebrew/bin/opencode /usr/local/bin/opencode)" - check_optional_tool "mise" "$mise_bin" "app" "Deploy, maintenance, and live actions require mise." - check_kamal_readiness check_optional_tool "Codex CLI" "$codex_bin" "codex" "Set TYCHO_CODEX_BIN or install codex." check_optional_tool "Claude CLI" "$claude_bin" "claude" "Set TYCHO_CLAUDE_BIN or install claude." check_optional_tool "OpenCode CLI" "$opencode_bin" "opencode" "Set TYCHO_OPENCODE_BIN or install opencode." diff --git a/config/hq.yml.example b/config/hq.yml.example index e9b5a19..08be0f2 100644 --- a/config/hq.yml.example +++ b/config/hq.yml.example @@ -2,14 +2,12 @@ # Tycho copies this file to ~/.tycho/config/hq.yml on first setup. # Add entries for your local projects when you are ready. # -# Each project entry describes a Kamal-deployed app or a managed-agent target. # # Fields: # key (required) unique identifier used for logs, agent files, and UI keys # name (required) display name in the TUI # group (optional) group header projects are bucketed under # path (required) absolute path to the project checkout on this machine -# apps (required) true if the project is Kamal-deployed and should show app/health columns # agent (optional) managed-agent backend: "codex", "claude", or a custom_harnesses key # model (optional) default model string for new managed agents # reasoning_effort (optional) default effort string for new managed agents @@ -41,11 +39,10 @@ projects: [] # hidden: false # # projects: -# - key: my-app -# name: My App +# - key: my-workspace +# name: My Workspace # group: Personal -# path: "/Users/you/Code/my-app" -# apps: true +# path: "/Users/you/Code/my-workspace" # agent: codex # model: gpt-5.1-codex-max # reasoning_effort: high @@ -54,11 +51,9 @@ projects: [] # name: Notes # group: Personal # path: "/Users/you/Documents/Notes" -# apps: false # # - key: work-web # name: Work Web # group: Work # path: "/Users/you/Code/work/web" -# apps: true # agent: claude-wrapper diff --git a/config/system_prompts.yml.example b/config/system_prompts.yml.example index 5e59757..ce1ab39 100644 --- a/config/system_prompts.yml.example +++ b/config/system_prompts.yml.example @@ -32,7 +32,7 @@ reviewer: >- maintenance: >- Your task is to perform maintenance by: - - List down at most 3 todo notes (e.g. bin/rails notes) + - List down at most 3 todo notes - List down at most 3 open github issues - List down at most 3 open github pull requests - Assess priority by evaluating the urgency, severity, and complexity diff --git a/docs/GOTCHAS.md b/docs/GOTCHAS.md index 70ba5fe..4387b46 100644 --- a/docs/GOTCHAS.md +++ b/docs/GOTCHAS.md @@ -26,7 +26,6 @@ brew install ruby go ``` If optional features show as unavailable, install the corresponding CLI -yourself. Tycho does not install `mise`, `kamal`, `codex`, `claude`, `tailscale`, terminal apps, or custom harness dependencies. If an install appears stale, reinstall from a clean tap: diff --git a/docs/MODEL_ARGUMENTS_PLAN.md b/docs/MODEL_ARGUMENTS_PLAN.md index 5c16b25..214e5e2 100644 --- a/docs/MODEL_ARGUMENTS_PLAN.md +++ b/docs/MODEL_ARGUMENTS_PLAN.md @@ -148,7 +148,6 @@ projects: - key: app name: App path: /Users/you/Code/app - apps: true agent: codex model: gpt-5.1-codex-max reasoning_effort: high diff --git a/docs/MULTISERVER_BROKER_PLAN.md b/docs/MULTISERVER_BROKER_PLAN.md index 97d18e3..89a236e 100644 --- a/docs/MULTISERVER_BROKER_PLAN.md +++ b/docs/MULTISERVER_BROKER_PLAN.md @@ -40,11 +40,10 @@ Expose broker-only routes from the UI-serving Remote server: ```text GET /servers -GET /servers/:server_key/health ANY /servers/:server_key/proxy/* ``` -`GET /servers` returns local plus configured remote entries with display metadata and coarse health state. `/servers/:server_key/proxy/*` forwards the original HTTP method, JSON body, query string, and target-specific Authorization header to the selected server. +`GET /servers` returns local plus configured remote entries with display metadata. `/servers/:server_key/proxy/*` forwards the original HTTP method, JSON body, query string, and target-specific Authorization header to the selected server. The existing local routes stay unchanged: @@ -85,7 +84,7 @@ Top-level lists (`agents`, `projects`, `schedules`, `setup`) represent only the ### Request Ownership -Agents, projects, schedules, project actions, and attachments are owned by the active server. The UI should show the active server name in the header/status area so operators know where a mutation will run. +Agents, projects, schedules, and attachments are owned by the active server. The UI should show the active server name in the header/status area so operators know where a mutation will run. No cross-server bulk archive, search, unread count, or dashboard aggregation in the first slice. @@ -117,7 +116,7 @@ Add small focused classes rather than expanding `RemoteService` too much: - `HQ::RemoteServerRegistry`: loads and validates configured target servers - `HQ::RemoteClient`: forwards authenticated requests to one target server -- `HQ::RemoteBroker`: lists targets, health-checks targets, and performs proxy calls +- `HQ::RemoteBroker`: lists targets and performs proxy calls ### Frontend @@ -144,10 +143,9 @@ Keep plain JavaScript with no build step: 1. Add config loading for `remote_servers`. 2. Add validation for `key`, `url`, and token source. 3. Add `RemoteClient` with JSON request forwarding and timeout handling. -4. Add `RemoteBroker` with server listing and health checks. +4. Add `RemoteBroker` with server listing and request proxying. 5. Add routes: - `GET /servers` - - `GET /servers/:key/health` - `GET|POST|PUT|PATCH|DELETE /servers/:key/proxy/*` 6. Add `test/remote_server_test.rb` coverage with a local fixture target server. diff --git a/docs/OPEN_SOURCE_PLAN.md b/docs/OPEN_SOURCE_PLAN.md index 0c4a4b1..b9ef20a 100644 --- a/docs/OPEN_SOURCE_PLAN.md +++ b/docs/OPEN_SOURCE_PLAN.md @@ -6,7 +6,6 @@ Prepare HQ to become a public open-source project without exposing private configuration, local runtime artifacts, company-specific data, or unclear security assumptions. -HQ should be presented as a local-first developer dashboard for Kamal-deployed applications and managed coding agents. ## Readiness Snapshot @@ -43,12 +42,10 @@ Current blockers: Define the public positioning: -- HQ is a local-first dashboard for monitoring Kamal apps and supervising managed coding agents. - Supported runtime: Ruby 3.2+. - Initial platform expectation: macOS-first, with Linux support where the runtime paths work. -- Optional integrations: `mise`, `kamal`, `tailscale`, Codex, Claude, and custom Claude-compatible harnesses. - License: MIT. @@ -125,7 +122,6 @@ bin/tycho Review local-machine assumptions and document or soften them: -- `/opt/homebrew/bin/mise` should have a clear fallback or setup note. - Missing optional tools should produce clear messages. - `bin/tycho serve` should warn or require `TYCHO_REMOTE_TOKEN` when binding to a non-loopback address. @@ -134,8 +130,6 @@ Review local-machine assumptions and document or soften them: Add startup validation for optional tools: -- `kamal` -- `mise` - `tailscale` - `codex` - `claude` diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 37ecc94..4221408 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -14,7 +14,6 @@ type: project ## Strategic Direction -HQ is a terminal-based dashboard (Bubbletea + Lipgloss + Bubbles via Charm Ruby) for monitoring Kamal-deployed Rails projects and orchestrating managed coding agents (Codex and Claude-compatible harnesses). It is a single-operator tool: the dashboard lives next to the developer's checkout, runs Kamal actions detached, and supervises managed-agent processes with persistent chat history. Key references: @@ -35,9 +34,6 @@ Key references: |----------|--------|-----------| | TUI framework | Bubbletea + Lipgloss-compatible styling + Bubbles (Charm Ruby) | Elm Architecture fits the dashboard's event-driven model; Tycho uses native Lipgloss except on Intel macOS, where a Ruby compatibility backend avoids Go cgo callback crashes from multiple Charm native runtimes | | Default data root | `~/.tycho` for config, schedule prompts, runtime state, and logs | Source and packaged installs should never write runtime data into the repository or Homebrew Cellar by default | -| Process model | Detached background processes via `mise exec` | Kamal actions outlive the TUI; state is restored on startup from `~/.tycho/logs/actions.json` | -| Kamal invocation | Prefer project `bin/kamal` binstub, fall back to `bundle exec kamal` | Matches per-project Ruby/gem versions | -| Health checks | HEAD requests, concurrent via Ruby threads | HEAD is required for kamal-proxy maintenance detection (503 on root URL) | | Config split | `~/.tycho/config/hq.yml` (active) + `~/.tycho/config/hq.archived.yml` (archived) | Archive without losing history; logs move to `~/.tycho/logs/projects/archived/` | | Agent transport | Codex JSON output; Claude-compatible `--output-format stream-json` | Streaming logs render incrementally in the chat viewport | | Agent model controls | Optional per-agent `model` and `reasoning_effort`, inherited from project/template config and passed as harness run arguments | Model catalogs change outside Tycho; use harness discovery for suggestions where available, keep free-form fallback everywhere, and keep provider-specific thinking budgets out of first-version scope | @@ -53,12 +49,11 @@ Key references: | Input handling | `BubbleteaInput` patches `Bubbletea::Program#poll_event` with a Ruby-side queue | Fixes multi-byte / bracketed paste truncation in Bubbles `TextInput`/`TextArea` | | Logging | Centralized `HQ.logger` (stdlib `Logger`), daily rotation, 7-day retention | Single sink for lifecycle, config, process, and silently-rescued errors | | Skill discovery | Enumerate SKILL.md from `~/.claude/skills` + workspace `.claude/skills` (Claude-compatible harnesses) and `~/.codex/skills` + `~/.agents/skills` + workspace `.agents/skills` (Codex) | Per-agent trigger character (`/` vs `$`) surfaced in the chat composer | -| Refresh cadence | App auto-refresh 30s; action/agent polling 10s | Balances responsiveness against Kamal/healthcheck cost | | Remote Sessions | Local JSON API and web UI via `tycho serve`; Tailscale auto-bind; terminal QR startup URL | Remote clients can inspect and control managed agents through the same `AgentStore` / `ManagedAgent` paths as the TUI | | Remote multiserver broker | Configured `remote_servers` let one Remote UI switch between local and peer `tycho serve` instances through backend proxy routes | Browser clients stay connected to one origin; peer credentials remain server-side; each view and mutation is scoped to the selected server | | Scheduled runs | Dedicated `tycho schedule daemon`, definitions in `~/.tycho/config/schedules.yml`, runtime state in `~/.tycho/logs/schedules.json`, validated standard cron syntax | Scheduled work should continue independently from the TUI and Remote UI while still reusing existing agent execution paths | | Schedule daemon freshness | `tycho schedule daemon` writes heartbeat state to `~/.tycho/logs/scheduler_daemon.json`; UI surfaces derive running/stale/stopped from heartbeat age and process liveness, and report untracked running daemons without heartbeat state | Users need to know whether cron work is actually ticking, not only whether definitions are valid | -| Schedule command scope | Agent-only schedules; each run creates a fresh managed agent, archives the previous schedule-created agent, and accepts only inline messages or files under `schedules/` | Avoid stale sessions, arbitrary shell execution, and first-class scheduled project actions while keeping recurring automation reviewable | +| Schedule command scope | Agent-only schedules; each run creates a fresh managed agent, archives the previous schedule-created agent, and accepts only inline messages or files under `schedules/` | Avoid stale sessions and arbitrary shell execution while keeping recurring automation reviewable | | Schedule statuses | Schedules expose `scheduled`, `paused`, or `stopped`; last outcome and error reason are tracked separately | Operators need a small action-oriented state model without losing diagnostic context | | Schedule interactive protection | A due run stops with reason `interactive` instead of archiving when the previous scheduled agent has later user messages; resuming a stopped schedule archives the active scheduled session and waits for the next scheduled run | User conversations in scheduled sessions must not disappear under the next cron tick, and recovery should be one explicit action | | Schedule management | Expose schedule list/detail/run/pause/resume/reload in both TUI and Remote UI | Interfaces should manage and observe schedules, but the daemon owns ticking, locks, missed-run policy, and dispatch | @@ -81,9 +76,8 @@ verification. - [x] Bubbletea/Lipgloss/Bubbles TUI scaffold (`hq.rb` + `lib/hq/app.rb`) - [x] Project registry from `~/.tycho/config/hq.yml` (`lib/hq/registry.rb`) -- [x] Concurrent HEAD-based health checks with maintenance detection -- [x] Kamal deploy / maintenance / live actions as detached `mise exec` processes -- [x] Action persistence and restoration via `~/.tycho/logs/actions.json` +- [x] Concurrent project metadata refresh +- [x] Managed-agent persistence and restoration via `~/.tycho/logs/managed_agents.json` - [x] In-app sidebar log viewer; `g` shortcut to open project terminal ### v0.2 — Managed Agents ✓ @@ -114,11 +108,9 @@ verification. - [x] Project archiving (config + logs move to archived locations) - [x] New-project form with live path autocomplete -- [x] Auto-detection of Kamal apps from `config/deploy.yml` - [x] Per-project log organization under `~/.tycho/logs/projects/{project}/` - [x] Centralized `HQ.logger` with daily rotation and 7-day cleanup - [x] Loading screen with progress bar; deferred slow startup work -- [x] Connection-reused, tightened-timeout health checks - [x] Global `ctrl-g` terminal shortcut; `ctrl-r` HQ restart ### v0.6 — Agent UX Polish and Stabilize ✓ @@ -172,7 +164,7 @@ verification. - [x] Decide scheduler owner: dedicated `tycho schedule daemon` - [x] Decide config source: `~/.tycho/config/schedules.yml` with cron syntax validation - [x] Decide management surfaces: TUI and Remote UI -- [x] Decide command scope: fresh agent only; no project actions, health checks, shell, templates, existing-agent resumes, or clones +- [x] Decide command scope: fresh agent only; no shell, templates, existing-agent resumes, or clones - [x] Decide prompt sources: inline text or files under `schedules/` - [x] Decide failure/success notifications: stop and web-push on failure; notify only first success and first success after failure - [x] Add `ScheduleRegistry` / `ScheduleStore` and persisted runtime state in `~/.tycho/logs/schedules.json` @@ -212,7 +204,7 @@ verification. ### Observability -- [ ] Structured event metrics (action durations, healthcheck latencies, agent run timings) +- [ ] Structured event metrics for agent run timings and lifecycle events - [ ] In-app log filter / search for `~/.tycho/logs/hq.log` - [ ] Agent run timeline view @@ -221,10 +213,8 @@ verification. - [ ] Revamp header: inverse background with the font color, let font color to white. Add quote of the day - [ ] Implement Claude wordy quirkiness to Header and Loading Screen -### Integrate Kamal Actions as Tools -- [x] Inventorize HQ's Kamal Actions -- [x] Add as HQ's Tools to be executed by Harness (available through `bin/tycho app ...`) +- [x] Add Tycho tools to be executed by harnesses ### Planning Flow Tools @@ -239,7 +229,6 @@ verification. ### Agent Chat Follow-ups -- [ ] [bug] App deployment does not show an error when it stops because Docker is inactive - [x] [ui] Move Conversation block diagnostics (`Block 71/81`, visible line count / viewport height) to the right side of the Conversation section header as compact icons and numbers - [x] [ux] In Conversation block detail view, allow left/right to move to previous/next block and show compact (`Block 71/81`, visible line count / viewport height) detail metadata on the right side of the detail header @@ -262,8 +251,7 @@ verification. - [x] Start / Stop an agent (`POST /agents/{key}/start`, `POST /agents/{key}/stop`) - [x] Creates / Edit an agent (`POST /agents`, `PATCH /agents/{key}`) - [x] Archive one agent (`DELETE /agents/{key}` or `POST /agents/{key}/archive`) or bulk archive idle agents (`POST /agents/archive`) -- [x] Project list/detail endpoints and mobile project health/detail screens -- [x] Guarded deploy/maintenance/live preflight and start endpoints +- [x] Project list/detail endpoints and mobile project detail screens - [x] Remote setup/readiness endpoint and Settings screen - [x] Client-side Remote UI filtering across agents and projects - [x] Remote UI skill discovery for chat insertion diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b09f219..2eb400f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -125,7 +125,7 @@ After publishing: ```sh bin/tycho --help bin/tycho doctor - bin/tycho app list + bin/tycho agent list bin/tycho schedule list ``` diff --git a/docs/REMOTE_SERVER.md b/docs/REMOTE_SERVER.md index 32e55e3..d69a9b5 100644 --- a/docs/REMOTE_SERVER.md +++ b/docs/REMOTE_SERVER.md @@ -8,11 +8,11 @@ The Remote Sessions server is HQ's local JSON API for inspecting and controlling - Transport: HTTP/1.1 over `TCPServer` from `socket`. - Body format: JSON request and response bodies. - Routing: manual method/path dispatch in `HQ::RemoteServer`. -- Domain layer: `HQ::RemoteService`, backed by `Registry`, `AppProject`, `AgentStore`, `ManagedAgent`, and `AgentChatLog`. +- Domain layer: `HQ::RemoteService`, backed by `Registry`, `Project`, `AgentStore`, `ManagedAgent`, and `AgentChatLog`. - QR rendering: `rqrcode` generates the QR matrix; HQ renders a compact terminal half-block QR for the remote UI URL. - Logging: request/lifecycle lines are printed to stdout and also sent to `HQ.logger`, which writes to `~/.tycho/logs/hq.log`. -No Rack, Rails, Puma, WEBrick, or external webserver gem is used. +No Rack, Puma, WEBrick, or external webserver gem is used. ## Running @@ -121,7 +121,7 @@ The restart flow is intentionally ordered so the browser gets a clean acknowledg 4. The current HTTP response is written as `202 Accepted` with `{ "restarting": true }`. 5. After the response is flushed and the client socket is closed, the server loop exits. 6. The server closes the listener if needed and calls `exec(*restart_command)`. -7. The browser polls `/health` until the replacement process is reachable again, then refreshes `/agents`, `/projects`, and `/setup`. +7. The browser polls `/setup` until the replacement process is reachable again, then refreshes `/agents`, `/projects`, and `/setup`. If a `RemoteServer` is constructed without a restart command, `POST /server/restart` returns `409 Conflict`. That keeps test and embedded server instances from accidentally attempting to replace their process. @@ -135,7 +135,7 @@ http://127.0.0.1:7373/ The UI is plain server-served HTML/CSS/JavaScript, with no frontend build step or JavaScript package dependencies. It uses the existing JSON endpoints, stores the optional bearer token in browser local storage, and sends it as `Authorization: Bearer ...` for API requests. -Home-screen launches are treated as normal browser sessions, but mobile browsers can be more aggressive about reusing an old app shell. The root UI references `/ui.css` and `/ui.js` with a content digest query string, and `POST /server/restart` is the explicit cache-reset path: the restart response sends cache-reset headers, the browser clears Cache Storage when available, and the UI reloads itself with a restart query string after the replacement server is healthy. +Home-screen launches are treated as normal browser sessions, but mobile browsers can be more aggressive about reusing an old app shell. The root UI references `/ui.css` and `/ui.js` with a content digest query string, and `POST /server/restart` is the explicit cache-reset path: the restart response sends cache-reset headers, the browser clears Cache Storage when available, and the UI reloads itself with a restart query string after the replacement server responds to setup requests. The top-level mobile tabs are `Now`, `Agents`, and `Settings`. Agents is the canonical project-and-agent workspace: it filters agents and project metadata, keeps zero-agent projects reachable for first-agent creation, and links to project detail routes. Legacy `#search`, `#projects`, and `#setup` hashes are redirected to the closest surviving tab. Detail routes use hash navigation such as `#agent/{key}`, `#project/{key}`, and `#project/{key}/action/{action}`. The footer nav is fixed on top-level routes, hides while scrolling down, shows again while scrolling up, and is hidden on detail subpages. @@ -161,7 +161,7 @@ remote_servers: The Settings screen shows the configured servers in a server list. Use a row's **Switch to** button to change the active server; the default **Local** server is always present. Top-level agents, projects, schedules, setup state, drafts, attachment previews, and mutations are scoped to the active server. There is no cross-server aggregation in this mode. -For ad hoc peers, open Settings, use the **Add server** toggle in the **Servers** header, and enter a display name plus a loopback or Tailscale MagicDNS URL such as `tycho-peer` and `http://127.0.0.1:7374/` or `http://vps-cd946cb7.tail952bf7.ts.net:7373`. If the peer requires a bearer token, enter it in **Remote token**. The UI checks `/health`, writes the peer metadata to `remote_servers` in `~/.tycho/config/hq.yml`, reloads the registry, and switches the active server to that peer. Removing a non-local server from the list also removes it from `hq.yml`. Ad hoc UI-added peers are intentionally limited to loopback and Tailscale MagicDNS hosts; edit `remote_servers` directly for broader LAN, public, or credentialed peers. +For ad hoc peers, open Settings, use the **Add server** toggle in the **Servers** header, and enter a display name plus a loopback or Tailscale MagicDNS URL such as `tycho-peer` and `http://127.0.0.1:7374/` or `http://vps-cd946cb7.tail952bf7.ts.net:7373`. If the peer requires a bearer token, enter it in **Remote token**. The UI verifies the peer through the agent API, writes the peer metadata to `remote_servers` in `~/.tycho/config/hq.yml`, reloads the registry, and switches the active server to that peer. Removing a non-local server from the list also removes it from `hq.yml`. Ad hoc UI-added peers are intentionally limited to loopback and Tailscale MagicDNS hosts; edit `remote_servers` directly for broader LAN, public, or credentialed peers. UI-entered remote tokens are not written to `hq.yml`. The browser stores them in local storage under `hq.remote.serverTokens`, keyed by server key, and sends the selected peer token to the local broker as `X-Tycho-Remote-Server-Token`. The broker uses that value only for the outgoing peer request. If another browser can see an existing remote server but does not have its token, use that server row's **Token** action in Settings to re-authenticate it for the current browser. The UI's own bearer token remains separate under `hq.remote.token` and is sent as the normal `Authorization` header to the server that served the UI. For durable server-side peer credentials, configure `token_env` manually in `hq.yml`. @@ -176,7 +176,7 @@ Auto-refresh uses polling with backoff: - `/agents`, `/projects`, and `/setup` are polled while the page is visible. - The selected agent's `/conversation` is fetched only when that agent's `revision` changes. - Polling uses the server-advertised refresh intervals from `/setup`, slows down when agents are idle, pauses while the browser tab is hidden, and backs off after network errors. -- Start, stop, send, and project action operations trigger an immediate refresh. +- Start, stop, and send operations trigger an immediate refresh. ## Screenshot Safety @@ -210,7 +210,7 @@ TYCHO_REMOTE_TOKEN="$(ruby -rsecurerandom -e 'puts SecureRandom.hex(24)')" tycho ``` ```bash -curl http://127.0.0.1:7373/health \ +curl http://127.0.0.1:7373/agents \ -H "Authorization: Bearer $TYCHO_REMOTE_TOKEN" ``` @@ -228,7 +228,7 @@ While running, the server prints request logs to stdout: ```text [Remote] 00:14:22 Remote server listening on http://127.0.0.1:7373 -[Remote] 00:14:26 GET /health 200 4.2ms +[Remote] 00:14:26 GET /agents 200 4.2ms [Remote] 00:14:31 POST /agents/web-charlie-agent-8/messages 200 18.7ms ``` @@ -286,8 +286,6 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl | Method | Path | Description | |--------|------|-------------| -| `GET` | `/` | Health check alias. | -| `GET` | `/health` | Health check. | | `GET` | `/agents` | List active managed agents. | | `POST` | `/agents` | Create a managed agent. | | `GET` | `/agents/{key}` | Read one managed agent. | @@ -314,14 +312,9 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl | `GET` | `/servers` | List the local server and configured broker targets for Remote UI server switching. | | `POST` | `/servers` | Add or update one loopback or Tailscale MagicDNS Remote server in `remote_servers` inside `hq.yml`. | | `DELETE` | `/servers/{key}` | Remove one non-local Remote server from `remote_servers` inside `hq.yml`. | -| `GET` | `/servers/{key}/health` | Read broker-visible health for one local or configured remote server. | | `GET` / `POST` / `PUT` / `PATCH` / `DELETE` | `/servers/{key}/proxy/{path}` | Forward an API request to one configured remote server. | -| `GET` | `/projects` | List active projects with health, latency, agent counts, and action state. | +| `GET` | `/projects` | List active projects with metadata and agent counts. | | `GET` | `/projects/{key}` | Read one project detail payload. | -| `GET` | `/projects/{key}/actions` | List guarded action preflights for deploy, maintenance, and live. | -| `POST` | `/projects/{key}/actions` | Start a guarded project action from a request body `action`. | -| `GET` | `/projects/{key}/actions/{action}` | Read one guarded project action preflight. | -| `POST` | `/projects/{key}/actions/{action}` | Start one guarded project action with `confirm: true`. | | `GET` | `/projects/{key}/skills/{agent}` | Discover skills for a project workspace and agent harness. | | `GET` | `/attachments/{id}` | Read normalized attachment metadata and inline preview content when available. | | `GET` | `/attachments/{id}/blob` | Stream the attachment file bytes for image and binary previews. | @@ -331,22 +324,10 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl | `GET` | `/`, `/ui`, `/ui.css`, `/ui.js` | Serve the Remote UI. `/ui` remains a compatibility alias. | | `GET` | `/favicon.svg`, `/favicon.ico` | Serve the Remote UI favicon. | -For `/servers/{key}/health` and `/servers/{key}/proxy/{path}`, the browser may send `X-Tycho-Remote-Server-Token` when the selected peer token lives in browser local storage. The broker converts it to the peer request's `Authorization: Bearer ...` header and does not persist it. +For `/servers/{key}/proxy/{path}`, the browser may send `X-Tycho-Remote-Server-Token` when the selected peer token lives in browser local storage. The broker converts it to the peer request's `Authorization: Bearer ...` header and does not persist it. ## Endpoint Details -### `GET /health` - -Returns basic process-visible counts: - -```json -{ - "status": "ok", - "agents": 4, - "projects": 12 -} -``` - ### `POST /server/restart` Requests a Remote server self-restart. The endpoint is authenticated like other JSON API endpoints. When restart is available, the response is sent before the server exits: @@ -354,7 +335,7 @@ Requests a Remote server self-restart. The endpoint is authenticated like other ```json { "restarting": true, - "command": "/Users/example/.local/share/mise/installs/ruby/3.4.7/bin/ruby" + "command": "/usr/local/bin/ruby" } ``` @@ -503,7 +484,7 @@ Response: "title": "Notes", "path": "/Users/example/project/docs/notes.md", "blob_path": "/attachments/att_abc123/blob", - "content": "# Notes\n\n- Check deployment" + "content": "# Notes\n\n- Check follow-up work" } } ``` @@ -707,7 +688,7 @@ Sends a test notification to an enabled subscription. Automatic agent-transition ### `GET /projects` -Returns active projects after refreshing metadata and health: +Returns active projects after refreshing metadata: ```json { @@ -716,12 +697,7 @@ Returns active projects after refreshing metadata and health: "key": "web", "name": "Web", "group": "Core", - "status": "healthy", - "apps_enabled": true, - "app_status": "running", - "health_status": "healthy", - "latency_ms": 42, - "maintenance": false, + "status": "configured", "agent_count": 2, "unread_agent_count": 1, "running_agent_count": 0 @@ -732,42 +708,6 @@ Returns active projects after refreshing metadata and health: ### `GET /projects/{key}` -Returns project detail data for the mobile detail view, including branch/commit metadata, Kamal deploy details, versions, agent template summaries with model/effort defaults, action log path, managed-agent count, and recent agent summary. - -### `GET /projects/{key}/actions/{action}` - -Returns a guarded action preflight for `deploy`, `maintenance`, or `live`: - -```json -{ - "action": "deploy", - "label": "deploying", - "can_start": true, - "checks": [ - { - "key": "kamal", - "label": "Kamal deployment configured", - "passed": true - } - ], - "consequences": [ - "Starts a detached Kamal deploy for the selected project." - ] -} -``` - -### `POST /projects/{key}/actions/{action}` - -Starts a detached Kamal action through `KamalAction`. The request must include confirmation: - -```json -{ - "confirm": true -} -``` - -Without confirmation the server returns `400`. If a preflight check blocks the action, the server returns `409`. - ### `GET /projects/{key}/skills/{agent}` Discovers skills for the project workspace and agent harness, reusing `HQ::SkillDiscovery`. diff --git a/docs/REMOTE_UI_AUDIT_CHECKLIST.md b/docs/REMOTE_UI_AUDIT_CHECKLIST.md index 3c3c867..dc9930a 100644 --- a/docs/REMOTE_UI_AUDIT_CHECKLIST.md +++ b/docs/REMOTE_UI_AUDIT_CHECKLIST.md @@ -5,7 +5,7 @@ ## Inventory - Live endpoint was reachable and served `/`, `/ui.css`, `/ui.js`, and JSON API endpoints. -- Checked main routes: Now, Agents, Search, Projects, Setup, agent detail, project detail, and project action preflight. +- Checked main routes: Now, Agents, Search, Projects, Setup, agent detail, and project detail. - Observed live state: 20 managed agents, 14 active projects, 3 unread agents, no bearer token required, and Tailscale MagicDNS available. ## Fix Checklist diff --git a/docs/REMOTE_UI_DESIGN_PROCESS.md b/docs/REMOTE_UI_DESIGN_PROCESS.md index 6cb16f6..e805d64 100644 --- a/docs/REMOTE_UI_DESIGN_PROCESS.md +++ b/docs/REMOTE_UI_DESIGN_PROCESS.md @@ -9,7 +9,6 @@ The design pass started from `docs/UI_FEATURE_INVENTORY.md`, which captures the - Terminal-first project and agent cockpit. - Remote `/` agent controls. - Managed-agent conversation, structured inquiry, logs, and process state. -- Project health, Kamal actions, action logs, and remote access constraints. The inventory made it clear that the current feature set is broad enough to support a full cockpit, but the mobile web UI should not mirror the CLI/TUI hierarchy directly. @@ -20,12 +19,11 @@ The first static mockup explored a fuller remote cockpit: - Agent queue. - Agent conversation. - Structured inquiry. -- Projects with health and actions. -- Action log. +- Projects with workspace metadata. - Remote access/token state. - Desktop adaptation. -This helped expose the product surface area, but it also showed the main risk: the mobile UI felt like a compressed version of the TUI. It put Agents, Projects, Logs, Deploy, Terminal, Archive, tokens, health, action logs, structured inquiry, and chat into one parallel hierarchy. +This helped expose the product surface area, but it also showed the main risk: the mobile UI felt like a compressed version of the TUI. It put Agents, Projects, Logs, Terminal, Archive, tokens, structured inquiry, and chat into one parallel hierarchy. ## V1 Drawbacks @@ -47,10 +45,9 @@ V2 reorganizes the mobile app around task-first surfaces: 2. **Decision** - structured inquiry becomes an explicit decision flow with clear consequences. 3. **Conversation** - chat focuses on the active agent, current activity, and a compact composer. 4. **Activity Detail** - logs and tool activity are available on demand, with summary first and raw detail secondary. -5. **Project Health** - project operations start from health triage rather than a full project object view. -6. **Guarded Action** - deploy and other local actions require a dedicated confirmation flow with preflight context. +5. **Project Context** - project operations start from workspace and agent context rather than a full project object view. -The selected V2 artifact is `docs/hq_ui_mockups_v2.html`. +The old V2 static mockup artifact was retired when Tycho's project-action surface was removed. ## Icon Direction diff --git a/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md b/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md index fa5186b..ffc7594 100644 --- a/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md +++ b/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md @@ -23,7 +23,7 @@ Current implementation files: - JavaScript: `lib/hq/remote_ui/assets/app.js` - Static asset helper: `lib/hq/remote_ui.rb` -The old phase checklist has been retired because the shell, simplified top-level screens, main detail routes, project payloads, setup/readiness payloads, skill discovery, client-side filtering, guarded project actions, audit fixes, and mobile nav polish are now implemented. +The old phase checklist has been retired because the shell, simplified top-level screens, main detail routes, project payloads, setup/readiness payloads, skill discovery, client-side filtering, audit fixes, and mobile nav polish are now implemented. ## Implemented @@ -36,8 +36,7 @@ The old phase checklist has been retired because the shell, simplified top-level - Project detail routes remain reachable from Agents project headers. - Settings screen shows URL, Tailscale/MagicDNS state, auth state, harness readiness, schema/config readiness, logs/storage, refresh intervals, and safety defaults. - Agent detail supports conversation viewing, current activity, run metadata, skill insertion, prompt submission, start run, and stop confirmation. -- Project detail shows health, revision, deploy details, versions/templates, recent agent summary, and guarded project actions. -- Guarded deploy/maintenance/live action screens show consequences and preflight checks before starting a detached Kamal action. +- Project detail shows workspace metadata, revision, versions/templates, and recent agent summary. - Copy actions show feedback. - Long paths and summaries wrap without horizontal page overflow at mobile widths. - `/favicon.svg` and `/favicon.ico` are served to avoid browser 404 noise. @@ -52,7 +51,6 @@ Implemented Remote UI API surfaces: - Setup/readiness: `/setup`. - Search index: `/search`. - Skill discovery: `/projects/{key}/skills/{agent}`. -- Guarded project action preflight/start: `/projects/{key}/actions`, `/projects/{key}/actions/{action}`. API behavior is covered in `test/remote_server_test.rb`. @@ -62,7 +60,6 @@ API behavior is covered in `test/remote_server_test.rb`. - Dedicated activity/log detail page with tabs, find/filter, and raw log inspection. - Full mobile agent create/edit form. The API supports create/edit, but the current mobile UI sends users through project context and keeps advanced setup TUI-first. - In-browser project modification. This remains intentionally TUI-only because it needs local filesystem handling. -- More explicit action progress polling and post-action health refresh UI around project action logs. - Optional QR display inside Setup. Startup terminal QR is implemented; in-page QR remains a future enhancement. ## Verification @@ -86,13 +83,10 @@ Manual smoke: - Check `Now`, `Agents`, and `Settings`. - Confirm the footer nav hides while scrolling down and shows while scrolling up. - Deep-link to an agent and project detail route. -- Open a guarded project action preflight. - Confirm no horizontal page overflow at `390px`. ## Notes For Future Agents - Preserve the no-build frontend approach unless the user explicitly approves frontend tooling. -- Keep Remote UI state transitions backed by `RemoteService`, `AgentStore`, `ManagedAgent`, `AppProject`, and `KamalAction`. -- Keep dangerous/local project actions guarded by confirmation screens. - Keep raw paths, logs, and diagnostics behind disclosure or detail routes. - Avoid changing TUI behavior unless a shared domain contract requires it. diff --git a/docs/SCHEDULED_RUNS.md b/docs/SCHEDULED_RUNS.md index 34b7643..0a8ccfb 100644 --- a/docs/SCHEDULED_RUNS.md +++ b/docs/SCHEDULED_RUNS.md @@ -10,7 +10,7 @@ type: reference Tycho scheduled runs are driven by `tycho schedule daemon`. Definitions live in `~/.tycho/config/schedules.yml`, mutable runtime state lives in `~/.tycho/logs/schedules.json`, daemon heartbeat state lives in `~/.tycho/logs/scheduler_daemon.json`, and cron syntax is validated before any long-running scheduler loop starts. The TUI and Remote UI are management surfaces, but neither owns the clock. -The current scope is intentionally narrow: schedules create fresh managed agents only. There are no first-class scheduled project actions, health checks, shell commands, agent-template selections, existing-agent resumes, or agent clones. Each due run creates a brand-new agent with fresh context, starts it through the existing `ManagedAgent` path, and archives the previous schedule-created agent for that schedule before the next repetitive run. +The current scope is intentionally narrow: schedules create fresh managed agents only. There are no shell commands, agent-template selections, existing-agent resumes, or agent clones. Each due run creates a brand-new agent with fresh context, starts it through the existing `ManagedAgent` path, and archives the previous schedule-created agent for that schedule before the next repetitive run. Prompt input is limited to inline text in `~/.tycho/config/schedules.yml` or a file under `~/.tycho/schedules/`. On failure, stop the schedule and notify via web push. On success, notify only on the first successful run and the first successful run after a prior failure; both success notifications should include the next scheduled run time. @@ -24,7 +24,7 @@ Prompt input is limited to inline text in `~/.tycho/config/schedules.yml` or a f - Runtime state: persist mutable schedule state separately from config under `~/.tycho/logs/schedules.json`. - Daemon state: persist `tycho schedule daemon` heartbeat and last tick metadata under `~/.tycho/logs/scheduler_daemon.json`. - Target scope: agent-only. Each run creates a brand-new managed agent with fresh context. -- Unsupported commands: `project_action`, `health_check`, `shell`, `agent_template`, `agent_existing`, and `agent_clone`. +- Unsupported commands: `shell`, `agent_template`, `agent_existing`, and `agent_clone`. - Prompt sources: only inline text or files under `~/.tycho/schedules/`. - Scheduled prompts always include the final-output attachment checklist so created or referenced durable artifacts are reported in `attachments`. - Scheduled agent display names are prefixed with `[Scheduled]` and do not include the internal agent-key number. @@ -62,7 +62,7 @@ Schedules support exactly one target type: - Archives the previous schedule-created agent for the same schedule before creating the next repetitive run. - Keeps session context fresh and avoids stale native agent sessions. -An agent can technically be prompted to run a Tycho project command, but schedules should not model project actions as their own target type. +An agent can technically be prompted to run local commands, but schedules should not model shell commands as their own target type. ### Prompt Sources diff --git a/docs/SETUP_REQUIREMENTS.md b/docs/SETUP_REQUIREMENTS.md index 39f0fc0..8ee9458 100644 --- a/docs/SETUP_REQUIREMENTS.md +++ b/docs/SETUP_REQUIREMENTS.md @@ -27,8 +27,6 @@ install/build dependency, not a Tycho runtime subprocess dependency. | Dependency | Used by | Current failure behavior | Setup behavior | |------------|---------|--------------------------|----------------| | `git` | Project metadata: branch, commit SHA, dirty file count | Soft fail. Tycho checks for `.git`, redirects Git stderr to `/dev/null`, and falls back to `n/a` / clean-looking values | Warn if missing; do not block basic TUI usage | -| `mise` | Detached Kamal actions through `mise exec` | Feature failure. Tycho looks at `TYCHO_MISE_BIN`, common install paths, then `mise` on `PATH`; missing `mise` breaks deploy/maintenance/live actions | Warn by default; hard fail only for an app-deployment profile | -| `kamal` | Deploy, maintenance, and live actions | Feature failure. Tycho prefers project `bin/kamal`, then `bundle exec kamal` inside the project | Warn if no usable project Kamal command is found for app projects | | `codex` | Built-in Codex managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for a Codex-agent profile | | `claude` | Built-in Claude managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for a Claude-agent profile | | `opencode` | Built-in OpenCode managed-agent harness | Soft feature fail. Agent start records a failed run if the executable is missing | Warn if missing; hard fail only for an OpenCode-agent profile | @@ -42,8 +40,6 @@ install/build dependency, not a Tycho runtime subprocess dependency. | Capability | Used by | Setup behavior | |------------|---------|----------------| -| RubyGems access | `bundle install` and latest `kamal` / `rails` version lookup | Hard fail for first install if gems are unavailable; latest-version lookup can remain best effort | -| App health URLs | HEAD checks for configured projects | Do not preflight globally; projects can be offline | | Web Push endpoints | Browser push notifications through `web-push` | Optional; warn only when push notifications are enabled | | HTTPS secure context | Browser push notifications from non-localhost Remote UI origins | Warn when Remote UI is exposed over non-local HTTP; Tailscale Serve HTTPS is preferred | @@ -62,7 +58,6 @@ The setup script should respect the same executable overrides that Tycho uses: | `TYCHO_HOOKS_PATH` | Override global hooks config path | | `TYCHO_SCHEDULES_STATE_PATH` | Override scheduler runtime state path | | `TYCHO_SCHEDULER_DAEMON_PATH` | Override scheduler daemon heartbeat path | -| `TYCHO_MISE_BIN` | Override `mise` executable | | `TYCHO_CODEX_BIN` | Override Codex executable | | `TYCHO_CLAUDE_BIN` | Override Claude executable | | `TYCHO_TAILSCALE_BIN` | Override Tailscale executable | @@ -89,7 +84,6 @@ bin/setup --check Escalate optional tools to hard requirements for specific feature profiles: ```bash -bin/setup --profile app bin/setup --profile codex --profile claude bin/setup --profile all ``` @@ -104,7 +98,6 @@ bin/setup --profile all - `config/schedules.yml.example` to `~/.tycho/config/schedules.yml` - `config/hooks.example.yml` to `~/.tycho/config/hooks.yml` 3. Check optional CLIs and print a feature readiness summary. -4. For app projects, validate whether `mise` and a Kamal command are available. 5. For managed-agent projects, validate the selected built-in or custom harness. 6. For Remote UI setup, check `TYCHO_REMOTE_TOKEN` when binding outside loopback and check Tailscale/HTTPS readiness when phone or push-notification use is requested. @@ -125,7 +118,6 @@ Recommended hard failures: - Go or native build tools are missing for a source install that must compile Charm Ruby native extensions. - `bundle install` fails. -- A requested profile cannot run, such as app deployment without `mise`/Kamal or a Codex-only setup without a Codex executable. Recommended soft failures: diff --git a/docs/TECH_DEBT_AUDIT_2026-06-15.md b/docs/TECH_DEBT_AUDIT_2026-06-15.md index b6431d4..4901ee6 100644 --- a/docs/TECH_DEBT_AUDIT_2026-06-15.md +++ b/docs/TECH_DEBT_AUDIT_2026-06-15.md @@ -8,7 +8,7 @@ This pass looked for debt that is likely to slow near-term feature work or creat ### 1. Remote UI Client Monolith And Browser Verification -`lib/hq/remote_ui/assets/app.js` is 8,612 lines and owns routing, state preservation, rendering, polling, Markdown rendering, attachments, schedules, project actions, push notification UI, and header/menu behavior in one script. Server-side tests assert many static expectations against the served asset, but there is no dedicated JavaScript or browser automation harness in the repo. That leaves the most fragile Remote UI promises, especially polling preservation, sticky docks, mobile layout, and real form behavior, dependent on manual checks. +`lib/hq/remote_ui/assets/app.js` is large and owns routing, state preservation, rendering, polling, Markdown rendering, attachments, schedules, push notification UI, and header/menu behavior in one script. Server-side tests assert many static expectations against the served asset, but there is no dedicated JavaScript or browser automation harness in the repo. That leaves the most fragile Remote UI promises, especially polling preservation, sticky docks, mobile layout, and real form behavior, dependent on manual checks. Recommended first slice: diff --git a/docs/UI_FEATURE_INVENTORY.md b/docs/UI_FEATURE_INVENTORY.md index 17b5aec..7ec4726 100644 --- a/docs/UI_FEATURE_INVENTORY.md +++ b/docs/UI_FEATURE_INVENTORY.md @@ -8,27 +8,23 @@ HQ is a local-first operations cockpit for a developer's projects. It combines: -- A terminal dashboard for monitoring Rails/Kamal projects. - A managed-agent workspace for Codex and Claude-compatible sessions. - A lightweight remote web UI at `/` for inspecting and controlling agents from a browser or phone. - A small JSON API and CLI command surface for automation. -The primary user is a single operator working from a local machine that has project checkouts, Kamal credentials, agent CLIs, and optional Tailscale access. ## Core Objects ### Projects -Projects are configured in `~/.tycho/config/hq.yml` and represent either deployable apps or non-app workspaces. +Projects are configured in `~/.tycho/config/hq.yml` and represent local workspaces for managed agents. Project data includes: - Key, name, group, and local path. -- Whether app/Kamal features are enabled. - Default managed-agent backend. - Agent prompt templates loaded from `~/.tycho/config/system_prompts.yml`. - Optional pull request URL metadata. -- Parsed Kamal deploy metadata: service, image, hosts, proxy host, and healthcheck path. - Git metadata: branch, commit hash, dirty-file count, and related GitHub links when a PR URL exists. - Per-project logs under `~/.tycho/logs/projects/{project}/`. @@ -47,18 +43,6 @@ Agent data includes: - Skills discovered from the workspace and agent type. - Logs and transcript artifacts under `~/.tycho/logs/agents/`. -### Actions - -Actions are detached Kamal commands tracked per project. - -Supported action types: - -- Deploy. -- Enter maintenance mode. -- Return to live traffic. - -Action state persists in `~/.tycho/logs/actions.json` and action output is written to each project's `action.log`. - ## Main TUI Features ### Navigation Shell @@ -85,7 +69,7 @@ Action state persists in `~/.tycho/logs/actions.json` and action output is writt ### Project List - Shows all configured projects grouped by project group. -- Displays app/project status icons. +- Displays project status icons. - Handles empty state when no projects are configured. - Keeps selection visible while scrolling long lists. - Shows a status legend when space allows. @@ -99,24 +83,17 @@ The selected project detail panel shows: - Git branch chip. - Commit hash chip. - Git clean/dirty indicator. -- Kamal service metadata for app projects: service, image, hosts, and proxy. -- Health metadata: health status, latency, healthcheck path, Kamal version, and Rails version. -- Local filesystem links for project path, log directory, and action log. +- Local filesystem links for project path and log directory. - Available agent templates. - Managed-agent count for the project. -- Current or recent Kamal action state and log path. - Most recent agent summary for the project. -- Contextual action shortcuts. +- Contextual project shortcuts. -### Project Health Monitoring +### Project Metadata Refresh -- Concurrent refresh of project metadata and health checks. -- HEAD requests against the configured healthcheck path. -- Separate root URL check to detect kamal-proxy maintenance mode. -- Health outcomes include healthy, maintenance, unhealthy, down, stopped, timeout, unreachable, and error. -- Response latency is tracked in milliseconds. -- Healthcheck history is appended to `~/.tycho/logs/projects/healthcheck.log`. -- App refresh runs automatically every 30 seconds. +- Concurrent refresh of project metadata. +- Git metadata and managed-agent counts refresh automatically. +- The app refreshes automatically every 30 seconds. ### Project Creation @@ -125,7 +102,6 @@ The selected project detail panel shows: - Path autocomplete for local directories. - Group autocomplete from existing project groups. - Key/name prefill from selected path. -- Detection of Kamal app capability via `config/deploy.yml`. - Validation for required fields and existing local path. - Saves to `~/.tycho/config/hq.yml`. - Starts asynchronous project metadata refresh after creation. @@ -133,26 +109,12 @@ The selected project detail panel shows: ### Project Archiving - Confirmation dialog before archiving. -- Blocks archive while a project action is active. - Blocks archive while related agents are running. - Moves config from `~/.tycho/config/hq.yml` to `~/.tycho/config/hq.archived.yml`. - Moves project logs to `~/.tycho/logs/projects/archived/YYYY-MM-DD_project-name/`. - Archives related managed-agent logs. - Removes related active agents from the dashboard. -### Kamal Actions - -- Deploy selected app project. -- Toggle maintenance mode for selected app project. -- Restore live traffic when already in maintenance mode. -- Confirmation before running actions. -- Detached process execution through `/opt/homebrew/bin/mise exec`. -- Prefers project `bin/kamal`; falls back to `bundle exec kamal`. -- Persists action state so actions survive TUI restarts. -- Polls active actions and reports success/failure. -- Triggers project health refresh after an action completes. -- Displays action log in a sidebar viewer. - ### Agent List - Shows all managed agents grouped by project. @@ -247,8 +209,6 @@ The selected agent detail panel shows: - In-app sidebar log viewers for: - Agent conversation log. - Agent raw log. - - Project action log. - - Healthcheck log. - Log viewers support scrolling, horizontal panning, reload, and close. - Log content is UTF-8 normalized and can be tailed/truncated for large files. - Detail views expose clickable terminal OSC 8 links for local paths where supported. @@ -303,9 +263,8 @@ Current Remote UI capabilities: Current Remote UI constraints: -- It is agent-focused; it does not expose project lists or Kamal actions. - It does not create, edit, clone, or archive agents from the current JavaScript UI, although the JSON API supports create/edit/archive. -- It does not expose project creation, project archiving, healthcheck logs, project action logs, terminal integration, omnisearch, or structured inquiry forms. +- It does not expose project creation, project archiving, terminal integration, omnisearch, or structured inquiry forms. - It uses plain server-served HTML/CSS/JavaScript with no frontend build step. ## Remote Server And JSON API @@ -326,7 +285,6 @@ Runtime features: API endpoints: -- `GET /health`. - `GET /agents`. - `POST /agents`. - `GET /agents/{key}`. @@ -346,14 +304,8 @@ Running `bin/tycho` without a subcommand opens the TUI. Command surface: - `bin/tycho --help`. -- `bin/tycho app list`: list projects with Kamal deployment configured. -- `bin/tycho app status `: refresh and print one app's status. -- `bin/tycho app deploy `: start deploy action. -- `bin/tycho app maintenance `: enter maintenance mode. -- `bin/tycho app live `: return to live traffic. - `bin/tycho project update --pr-url `: update project PR metadata. -The app commands are intended to let external harnesses invoke HQ's Kamal actions as tools. ## Hooks And Automation @@ -393,11 +345,9 @@ Configuration: Runtime state: -- Active Kamal actions: `~/.tycho/logs/actions.json`. - Managed agents: `~/.tycho/logs/managed_agents.json`. - App log: `~/.tycho/logs/hq.log`. - Hook log: `~/.tycho/logs/hooks.log`. -- Healthcheck log: `~/.tycho/logs/projects/healthcheck.log`. - Project logs: `~/.tycho/logs/projects/{project}/`. - Archived project logs: `~/.tycho/logs/projects/archived/`. - Agent raw logs, conversation logs, system logs, memory logs, status files, and result files: `~/.tycho/logs/agents/`. @@ -416,8 +366,6 @@ High-value design surfaces to consider: - Agent conversation with tool-call grouping and block detail. - Prompt composer with start-after-send, multi-line input, and pending inquiry state. - Agent create/edit/archive flows using the existing API. -- Project list and detail states if the Remote UI should manage app operations. -- Kamal action controls with confirmation and log inspection. -- Health status timeline and current latency. +- Project list and detail states for workspace metadata and agent ownership. - Mobile-friendly remote access flow for token entry and QR-opened sessions. - Clear distinction between local-only operations, remote-safe operations, and destructive operations. diff --git a/docs/WEB_PUSH_BEHAVIOR.md b/docs/WEB_PUSH_BEHAVIOR.md index 0c197bb..4758c24 100644 --- a/docs/WEB_PUSH_BEHAVIOR.md +++ b/docs/WEB_PUSH_BEHAVIOR.md @@ -67,7 +67,7 @@ Example agent payload: ```json { "title": "Agent requires response", - "body": "Smoke Project reviewer: Needs deployment confirmation (2 unread agents)", + "body": "Smoke Project reviewer: Needs review confirmation (2 unread agents)", "tag": "hq:agents", "renotify": true, "silent": false, diff --git a/docs/WEB_PUSH_PLAN.md b/docs/WEB_PUSH_PLAN.md index 3167081..f4b5e4f 100644 --- a/docs/WEB_PUSH_PLAN.md +++ b/docs/WEB_PUSH_PLAN.md @@ -75,7 +75,7 @@ Send notifications for managed-agent state transitions: - Agent transitions from running to `succeeded`. - Agent transitions from running to `failed`, `stopped`, or `blocked`. -Defer project action notifications until the agent notification path is stable. +Keep notification scope focused on managed-agent state transitions. ## Configuration diff --git a/docs/assets/readme/web-agent-switcher.png b/docs/assets/readme/web-agent-switcher.png new file mode 100644 index 0000000..6e705bb Binary files /dev/null and b/docs/assets/readme/web-agent-switcher.png differ diff --git a/docs/assets/readme/web-remote-connection-switcher.png b/docs/assets/readme/web-remote-connection-switcher.png new file mode 100644 index 0000000..4dec6f7 Binary files /dev/null and b/docs/assets/readme/web-remote-connection-switcher.png differ diff --git a/docs/hq_ui_mockups_v2.html b/docs/hq_ui_mockups_v2.html deleted file mode 100644 index 45679d0..0000000 --- a/docs/hq_ui_mockups_v2.html +++ /dev/null @@ -1,2501 +0,0 @@ - - - - - - HQ Remote UI Mockups V2 - - - - - -
-

HQ Remote UI Mockups V2

-

Mobile-first redesign focused on attention, decisions, conversation, project health, and guarded operations instead of mirroring the full TUI hierarchy.

-
- -
-
-
-

01 Attention

- start here -
-
-
-
- 09:41 - 5G -
-
-
-
-
- -
-

Needs attention

-

Connected / refreshed 18s ago

-
-
- -
-
- -
-
-
- 2 - items waiting on you -
-

Answer paused agents first. Running work and project health stay visible below.

- -
- - -
-
-
-
- Kamal deploy audit - hq / claude reviewer -
- Answer -
-

Agent is paused until you choose verification scope for production deploy.

-
- Started 12m ago - Release decision -
-
- -
-
-
- Parser triage - agents / claude-wrapper debugger -
- Blocked -
-

Last run failed after parser fixture verification. Raw log has one error group.

-
-
- - -
-
-
- Checkout guardrails - web / codex implementer -
- Running -
-

Tests are running. Latest activity: rendering regression check.

-
- - -
- - -
-
-
-
- -
-
-

02 Omnisearch

- typed results -
-
-
-
- 09:42 - 5G -
-
-
-
-
- -
-

Search HQ

-

Unread agents first when the query is empty

-
-
- -
- -
- -
- -
-
- -
- Kamal deploy audit - Agent / hq / claude reviewer / paused -
-   -
-
- -
- Parser triage - Agent / agents / claude-wrapper / blocked -
-   -
-
- -
- Storefront - Project / maintenance / root returned 503 -
- Open -
-
- - -
-
- -
- HQ - Project / healthy / main / clean -
- Project -
-
- -
- Remote UI polish - Agent / hq / codex / running 8m -
- Agent -
-
-
- - -
-
-
-
- -
-
-

03 Decision

- agent paused -
-
-
-
- 09:44 - 5G -
-
-
-
-
- -
-

Release decision

-

Kamal deploy audit / paused

-
-
- Answer -
-
- -
-
- Agent is waiting for your decision -

Your answer will be sent to the agent. It will not run the deploy command until you submit.

-
- -
- - -
- -
- -
-
- -
Automated testsSyntax, registry, rendering, remote server
-
-
- -
Manual remote UI smokeOpen mobile view and check interaction paths
-
-
- -
Deploy after checksRequires one more confirmation before command runs
-
-
-
- -
- - -
- -
- Review before sending -

This keeps the agent moving, but the deploy action remains gated behind a separate confirmation.

-
- -
- - -
-
-
-
-
-
- -
-
-

04 Conversation

- agent room -
-
-
-
- 09:47 - 5G -
-
-
-
-
- -
-

Checkout guardrails

-

web / codex / running 8m

-
-
- -
-
- Running - Latest: tests - Activity -
-
- -
-
-
- 1 -
Current activityRunning rendering regression tests
- now -
-
- -
-
-
Summaryupdated now
-

The checkout validation path is isolated to the payment form and one server-side guard.

-
- -
-
Activity summaryopen
-

4 shell commands, 2 test files, no failures yet.

-
- -
-
You09:33
-

Keep the fix focused and include the regression test.

-
- -
-
Assistantstreaming
-

I found the edge case in the amount parser. I am adding a narrow guard and checking the UI flow next.

-
-
-
- -
- -
-
- Options - Skill -
- -
-
-
-
-
-
- -
-
-

05 Activity Detail

- logs on demand -
-
-
-
- 09:50 - 5G -
-
-
-
-
- -
-

Run activity

-

Checkout guardrails / raw log available

-
-
- Live -
-
-
Summary
-
Tools
-
Raw
-
-
- -
-
-
- 1 -
Read code3 files inspected
- done -
-
- 2 -
Edit guardAmount parser updated
- done -
-
- 3 -
Run testsrendering_test.rb still running
- now -
-
- -
-
-
- Important lines - Filtered from raw log -
- -
-
09:48bundle exec ruby test/rendering_test.rb
-
09:4832 assertions started
-
09:49checking chat composer paste regression
-
09:50no failures reported so far
-
- -
- - -
- -
- - -
-
-
-
-
-
- -
-
-

06 Project Health

- operations -
-
-
-
- 09:53 - 5G -
-
-
-
-
- -
-

Project health

-

12 projects / refreshed 17s ago

-
-
- -
-
- -
-
-
- 1 - project needs attention -
-

Storefront is in maintenance. Payments timed out once but recovered.

-
- - - - -
-
-
-
- Storefront - maintenance / root returned 503 -
- Review -
-
- -
-
- Health endpoint ok - maintenance mode detected -
-
- -
-
-
- HQ - healthy / 184ms / branch main -
- Healthy -
-
- -
-
- -
-
-
- Payments API - healthy now / one timeout 1h ago -
- Watch -
-
- -
-
-
- - -
- When no projects are configured -

Point to the TUI because project setup still needs local file-system selection.

- -
-
- - -
-
-
-
- -
-
-

07 Project Detail

- status plus context -
-
-
-
- 09:54 - 5G -
-
-
-
-
- -
-

Storefront

-

public apps / key storefront

-
-
- Maintenance -
-
- PR #481 - main - dirty -
-
- -
-
-
-
- Root returned 503 - Health path /up is green in 212ms -
- Review -
-

Root 503 means kamal-proxy maintenance is active even though the health endpoint is healthy.

-
- -
-
-
Managed agents3 active or recent on this project
- 3 -
-
-
Current actionmaintenance enable completed / action log available
- Done -
-
-
RevisionShort commit a18f4c2 on main
- Copy -
-
-
Latest agent summaryDeploy audit recommends switching live after dirty files are reviewed.
- Open -
-
- -
- Deploy details -
-
Servicestorefront-web
-
Imageghcr.io/acme/storefront
-
Hostsweb-1, web-2
-
Proxykamal-proxy
-
Action loglogs/projects/storefront/action.log
-
-
- -
- Health and versions -
-
Path/up
-
Kamal2.6.1
-
Rails7.2.2
-
Latency212ms
-
-
- -
- -
- codex implementer - claude reviewer - claude-wrapper debug -
-
- -
- Project editing opens in the TUI -

Shown only after tapping Modify, because setup still needs terminal directory access.

-
-
-
-
-
-
- -
-
-

08 Agent List

- grouped by project -
-
-
-
- 09:57 - 5G -
-
-
-
-
- -
-

Agents

-

Grouped by project / 2 unread

-
-
- -
- -
- -
-
-
hq2 agents
-
- -
- Kamal deploy audit - Paused / claude reviewer / answer needed -
-   -
-
- -
- Remote UI polish - Running / codex implementer / 8m elapsed -
- Open -
-
- -
-
agentscollapsed
-
- -
- Parser triage - Blocked / claude-wrapper debugger / latest failed -
-   -
-
- - -
- No agents yet -

Shown for an empty project. Create one from project context, or use the TUI/API if mobile creation is disabled.

- -
-
- - -
-
-
-
- -
-
-

09 Setup

- readiness -
-
-
-
- 10:00 - 5G -
-
-
-
-
- -
-

Setup

-

Remote access / refreshed 12s ago

-
-
- -
-
- -
-
-
-
- Remote UI connected - https://hq-device.tailnet-name.ts.net:7373 -
-
-         -         -         -         -         -         -         -
-
-
- API ok - Tailscale - MagicDNS -
-
- - -
-
- -
- -
-
CodexSigned in / default for code changes
- Ready -
-
-
ClaudeSession resume available
- Ready -
-
-
claude-wrapperCustom harness configured
- Ready -
-
-
Result schemaconfig/schemas/agent_result.json
- Valid -
-
- -
- -
-
Projects12 active
-
Archived4 hidden
-
Confighq.yml loaded
-
Prompts8 templates
-
- -
- -
- Safety defaults -

Deploy and maintenance actions require confirmation. Running agents lock edit fields, and existing workspaces stay read-only.

-
- -
- Logs and storage -
-
Rootlogs/
-
Agents18 runs
-
Projects7 action logs
-
Archiveprojects/archived
-
-
- -
- Notifications and preferences -
-
Paused agent alertsNotify when an agent needs input
- On -
-
-
Action completionNotify after deploy or maintenance command
- On -
-
-
Auto-refreshEvery 30 seconds
- 30s -
-
-
- - -
-
-
-
- -
-
-

10 Agent Detail

- summary first -
-
-
-
- 10:01 - 5G -
-
-
-
-
- -
-

Remote UI polish

-

hq / codex implementer / idle

-
-
- Succeeded -
-
- template polish - codex - PR #481 - main - clean -
-
- -
-
-
-
- Latest summary - completed 4m ago / elapsed 16m -
- Run 3 -
-

Implemented the mobile mockup pass, verified compact states, and left deploy work behind confirmation.

-
- -
-
-
Run metadataStarted 09:41 / finished 09:57 / 16m elapsed
- 0 -
-
-
Resultexit 0 / latest label success / 3 total runs
- ok -
-
-
Project revisionPR #481 / main / commit a18f4c2
- Copy -
-
-
Current promptApply the UI mockup checklist and keep mobile controls compact...
- Edit -
-
-
Workspace/Users/example/Code/hq
- Copy -
-
- -
- Diagnostics -
-
Sandboxdanger-full-access
-
CreatedMay 9, 09:21
-
Sessionclaude-a7f...
-
Raw loglogs/agents/ui.log
-
-
- -
- Run again creates run 4 -

Previous summaries and logs stay available, so the action is clear before starting another run.

-
- -
- - -
-
-
-
-
-
- -
-
-

11 Agent Setup

- create or edit -
-
-
-
- 10:04 - 5G -
-
-
-
-
- -
-

New HQ agent

-

Created from selected project

-
-
- Draft -
-
- -
-
- Idle agents can be edited -

Name, template, harness, and prompt are editable here; running agents show these fields locked.

-
- -
- - -
- -
- - -
- -
- -
-
- -
CodexRecommended for code changes
-
-
-   -
ClaudeReview and analysis
-
-
-   -
claude-wrapperDebugging runs
-
-
-
- -
- - - Name, prompt, and workspace are required. - Workspace is required before creating an agent. -
- -
- - - Read-only when editing an existing agent. -
- -
- - -
- -
- - -
-
- -
github:gh-address-commentsAddress PR review threads
- Add -
-
-

Skill discovery runs after creation and appears here passively.

-
-
-
-
-
-
- -
-
-

12 Guarded Action

- confirm flow -
-
-
-
- 09:56 - 5G -
-
-
-
-
- -
-

Switch Storefront live

-

Maintenance mode / local command

-
-
- Confirm -
-
- -
-
-

Confirm before switching live

-

Storefront is currently in maintenance. This action restores production traffic through kamal-proxy and starts a detached local command.

-
- Now maintenance - bin/kamal app boot - logs/action.log -
-
- -
- -
-
No project action runningSafe to start a new action
-
Health endpoint is greenLast HEAD /up returned 200 in 212ms
-
Dirty files reviewedGit status is understood before restoring traffic
-
-
- -
-
-
- After confirmation - Expected maintenance flow -
- 4 steps -
-
- 1 -
Start commandmise exec / bin/kamal app boot
- -
-
- 2 -
Stream action logTail important output first
- -
-
- 3 -
Refresh healthHEAD /up and root URL should both pass
- -
-
- -
- - -
-
-
-
-
-
-
- - diff --git a/docs/omnisearch-plan.md b/docs/omnisearch-plan.md index 7a5808e..07e5742 100644 --- a/docs/omnisearch-plan.md +++ b/docs/omnisearch-plan.md @@ -97,8 +97,8 @@ Omnisearch should not read config files or logs directly while the user types. I Sources: -- Projects: use `@projects`, which is populated from `Registry#projects` and wrapped as `AppProject` objects in `load_registry!`. -- Project searchable/display text: `AppProject#name` only. Do not search `key`, `path`, `group`, host, or repo metadata for the first version. +- Projects: use `@projects`, which is populated from `Registry#projects` and wrapped as `Project` objects in `load_registry!`. +- Project searchable/display text: `Project#name` only. Do not search `key`, `path`, `group`, host, or repo metadata for the first version. - Project target: keep the project object or its `key`, plus its current index in `@projects`. - Agents: use `@agents`, which is loaded from `AgentStore#load` and sorted by `sort_agents`. - Agent searchable/display text: `ManagedAgent#name` plus the project name shown beside it. Do not search harness/provider, workspace path, prompt, template, or logs for the first version. diff --git a/docs/research/a2a-protocol-research.md b/docs/research/a2a-protocol-research.md index 2ee195a..0fd150e 100644 --- a/docs/research/a2a-protocol-research.md +++ b/docs/research/a2a-protocol-research.md @@ -228,7 +228,7 @@ The official specification defines a JSON-RPC 2.0 binding over HTTP(S) with Serv - SSE for streaming, - HTTP headers for A2A service parameters such as version and extensions. -This is a pragmatic design. JSON-RPC gives method structure, HTTP keeps deployment familiar, and SSE covers streaming without introducing a more complex baseline transport. +This is a pragmatic design. JSON-RPC gives method structure, HTTP keeps hosting familiar, and SSE covers streaming without introducing a more complex baseline transport. ### Multiple bindings @@ -255,7 +255,7 @@ This matters operationally. Many agent tasks are slow, externally dependent, or ## Security Model -A2A is designed with enterprise deployment in mind. The specification includes: +A2A is designed with enterprise operation in mind. The specification includes: - authentication and authorization sections, - declared security schemes in the Agent Card, @@ -264,9 +264,9 @@ A2A is designed with enterprise deployment in mind. The specification includes: - guidance for push notification security, - guidance for signed Agent Cards. -The current spec states that production deployments must use encrypted communication and recommends modern TLS configurations. It also requires authorization checks on every A2A request and insists that responses be scoped to the caller’s authorized access boundaries. +The current spec states that production systems must use encrypted communication and recommends modern TLS configurations. It also requires authorization checks on every A2A request and insists that responses be scoped to the caller’s authorized access boundaries. -That is a strong signal that A2A is trying to be deployable in real organizations, not only as a developer demo protocol. +That is a strong signal that A2A is intended for real organizations, not only as a developer demo protocol. ## Versioning and Compatibility @@ -395,7 +395,7 @@ That matches the official framing and keeps protocol responsibilities clean. ## Conclusion -A2A is the most important current open protocol for agent-to-agent interoperability. Its key ideas are strong: discoverable agents, explicit capabilities, structured messages and artifacts, durable task lifecycles, streaming updates, async notifications, and security-conscious deployment. +A2A is the most important current open protocol for agent-to-agent interoperability. Its key ideas are strong: discoverable agents, explicit capabilities, structured messages and artifacts, durable task lifecycles, streaming updates, async notifications, and security-conscious operations. Its biggest advantage is conceptual clarity. It does not try to turn agents into tools. Instead, it gives autonomous systems a shared contract for collaboration. That makes it more appropriate for real multi-agent systems than plain RPC or tool-calling abstractions alone. diff --git a/docs/research/agent-communication-protocol-research.md b/docs/research/agent-communication-protocol-research.md index 9e9bf6b..64f171d 100644 --- a/docs/research/agent-communication-protocol-research.md +++ b/docs/research/agent-communication-protocol-research.md @@ -59,9 +59,9 @@ ACP documentation frames the system around clients, servers, and agents: - An ACP server exposes one or more agents through a REST interface. - An ACP agent is the unit of capability that actually performs the work. -This supports several deployment models: +This supports several runtime topologies: -- Single-agent deployment: one client talks to one ACP-exposed agent. +- Single-agent topology: one client talks to one ACP-exposed agent. - Multi-agent single server: several agents are hosted behind one ACP server. - Distributed multi-agent systems: an agent can also act as a client and call other agents. @@ -188,7 +188,7 @@ ACP had strong backing from IBM Research and BeeAI, but it did not become the so ### Not an orchestration framework -ACP standardizes communication, not workflow management, deployment, scheduling, or policy coordination. It helps agents interoperate, but teams still need orchestration, monitoring, governance, and runtime controls outside the protocol itself. +ACP standardizes communication, not workflow management, hosting, scheduling, or policy coordination. It helps agents interoperate, but teams still need orchestration, monitoring, governance, and runtime controls outside the protocol itself. ### Operational complexity remains diff --git a/docs/research/git-diff-viewer-study.md b/docs/research/git-diff-viewer-study.md index ec59f74..58906a7 100644 --- a/docs/research/git-diff-viewer-study.md +++ b/docs/research/git-diff-viewer-study.md @@ -12,7 +12,7 @@ A first-class diff viewer would make Tycho much more useful as an operator conso ## Current State in the Codebase -Current Git metadata is collected in `HQ::AppProject#parse_git_status`: +Current Git metadata is collected in `HQ::Project#parse_git_status`: - `git rev-parse --short HEAD` - `git branch --show-current` @@ -38,7 +38,7 @@ Patch application or staging should be a later step. The first implementation sh ## TUI Concept -Add a `D` or `ctrl+d` project action that opens a diff view for the selected project. +Add a `D` or `ctrl+d` project shortcut that opens a diff view for the selected project. Useful TUI layout: diff --git a/docs/research/hq-a2a-vs-acp-recommendation.md b/docs/research/hq-a2a-vs-acp-recommendation.md index be974c9..9a2cdf4 100644 --- a/docs/research/hq-a2a-vs-acp-recommendation.md +++ b/docs/research/hq-a2a-vs-acp-recommendation.md @@ -2,7 +2,7 @@ ## Executive Summary -For HQ as it exists today, neither A2A nor ACP is a necessary core dependency. HQ is currently a local terminal control plane for project metadata, deployment actions, app health checks, and locally managed coding agents. Its main responsibilities are local process orchestration, run tracking, log inspection, and user interaction inside the TUI. +For HQ as it exists today, neither A2A nor ACP is a necessary core dependency. HQ is currently a local terminal control plane for project metadata and locally managed coding agents. Its main responsibilities are local process orchestration, run tracking, log inspection, and user interaction inside the TUI. If HQ evolves to support remote agents, cross-machine orchestration, or interoperability with third-party agent systems, A2A is the better fit. ACP is conceptually relevant, but it is no longer the active standalone direction and has been incorporated into A2A. For new work, A2A is the stronger strategic choice. @@ -18,7 +18,6 @@ HQ loads projects from configuration, including: - project name, - group, - local filesystem path, -- app enablement, - agent templates. This behavior is defined in `config/hq.yml` and loaded through `HQ::Registry`. @@ -29,36 +28,22 @@ For each configured project, HQ reads: - Git branch, - Git commit hash, -- dirty file count, -- Kamal version from `Gemfile.lock`, -- Rails version from `Gemfile.lock`. +- dirty file count. This is local repository introspection, not network agent communication. -### 3. App health monitoring +### 3. Agent orchestration state -For app-enabled projects, HQ: +For managed agents, HQ: -- parses deploy configuration, -- resolves proxy host and healthcheck path, -- performs health requests, -- measures latency, -- classifies app and health status, -- writes healthcheck logs. +- tracks run status, +- persists session memory, +- records structured results, +- exposes logs and follow-up prompts. -This is application operations monitoring, not agent interoperability. +This is local orchestration state, not network agent interoperability. -### 4. Deployment operations - -HQ can trigger and track Kamal actions: - -- deploy, -- maintenance, -- live. - -It spawns local processes, stores running action state, restores action state after restart, and exposes action logs in the UI. - -### 5. Managed agent lifecycle +### 4. Managed agent lifecycle HQ creates and manages local coding agents per project. It supports: @@ -71,7 +56,7 @@ HQ creates and manages local coding agents per project. It supports: - structured result summaries, - log file persistence. -### 6. Agent chat workflow +### 5. Agent chat workflow HQ lets the user continue interacting with a managed agent through a chat-like interface: @@ -80,7 +65,7 @@ HQ lets the user continue interacting with a managed agent through a chat-like i - recent messages and result summaries are displayed in the TUI, - conversation state is persisted locally. -### 7. Local-first execution model +### 6. Local-first execution model HQ runs agents as local `codex exec` processes. The current architecture is built around: diff --git a/docs/research/logging-architecture.md b/docs/research/logging-architecture.md index 9807e28..088a460 100644 --- a/docs/research/logging-architecture.md +++ b/docs/research/logging-architecture.md @@ -19,7 +19,6 @@ one file to grep when debugging. Sources: +-------------+ +-------------+ +--------------+ +-----------+ - | App | | Config | | KamalAction | | Agent | | lifecycle | | loading | | start/stop | | start/stop| | errors | | errors | | completion | | exit code | | refresh | | project | | | | errors | @@ -30,13 +29,13 @@ one file to grep when debugging. | | v v +---------------+---+ +------------+---------+ - | Health check | | ActionStore / | - | failures | | AgentStore errors | + | Remote UI | | AgentStore errors | + | request failures | | | +--------------------+ +----------------------+ Log format: [INFO] [2026-04-25 10:30:00] [App] Starting HQ - [WARN] [2026-04-25 10:30:05] [Health] myapp: Net::ReadTimeout + [WARN] [2026-04-25 10:30:05] [Remote] GET /agents 502 1002.4ms [ERROR] [2026-04-25 10:31:00] [Agent] Memory capture failed for agent-1: ... Configurable via TYCHO_LOG_LEVEL env var (default: INFO) @@ -46,12 +45,10 @@ one file to grep when debugging. Tier 2: Process Capture Logs (raw output, unchanged) ---------------------------------------- - Kamal Actions Managed Agents +--------------------------+ +-------------------------------+ | logs/{project}.log | | logs/agents/{project}-{time}-{id}.raw.log | | | | | | Raw stdout/stderr from | | Raw stdout/stderr from | - | kamal deploy/maintenance | | codex/claude-compatible | +--------------------------+ +-------------------------------+ | | AgentLogParser @@ -79,9 +76,7 @@ one file to grep when debugging. State Files ---------------------------------------- - logs/actions.json Kamal action state (PIDs, status) logs/managed_agents.json Agent metadata (PIDs, config, status) - logs/healthcheck.log Health check results per project ============================================================================ @@ -100,7 +95,6 @@ one file to grep when debugging. HQ.logger <----+ (summary lines) | | - Kamal process stdout -> {project}.log ---+ TUI Chat Viewport (hybrid rendering) ---------------------------------------- diff --git a/docs/research/tool_log_shapes.md b/docs/research/tool_log_shapes.md index 16f2e52..6373c5e 100644 --- a/docs/research/tool_log_shapes.md +++ b/docs/research/tool_log_shapes.md @@ -25,7 +25,7 @@ Tool calls appear in `assistant` events: "name": "Bash", "input": { "description": "Inspect demo project status", - "command": "bin/tycho app status demo --verbose" + "command": "bin/tycho agent status demo-agent" } } ] @@ -44,7 +44,7 @@ Tool results appear in `user` events and are correlated by `tool_use_id`: { "type": "tool_result", "tool_use_id": "toolu_demo_bash", - "content": "Demo project status: healthy\nApp: live\nHealth: ok" + "content": "Demo agent status: complete\nResult: ready" } ] } @@ -58,7 +58,7 @@ of text parts: [ { "type": "text", - "text": "Here are the demo findings:\n\n- The status path returns healthy." + "text": "Here are the demo findings:\n\n- Parser fixtures use synthetic data." } ] ``` diff --git a/hq.gemspec b/hq.gemspec index eb31231..c27c2e7 100644 --- a/hq.gemspec +++ b/hq.gemspec @@ -6,8 +6,8 @@ Gem::Specification.new do |spec| spec.name = "hq" spec.version = HQ::VERSION spec.authors = ["Tycho contributors"] - spec.summary = "Local-first dashboard for Kamal projects and managed coding agents." - spec.description = "Tycho provides a Ruby TUI and local Remote UI for monitoring Kamal projects and supervising managed coding agents." + spec.summary = "Local-first dashboard for orchestrating managed coding agents." + spec.description = "Tycho provides a Ruby TUI and local Remote UI for supervising managed coding agents and scheduled runs." spec.homepage = "https://github.com/firewalker06/tycho" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" diff --git a/lib/hq/app.rb b/lib/hq/app.rb index 654e5e7..9253672 100644 --- a/lib/hq/app.rb +++ b/lib/hq/app.rb @@ -12,9 +12,7 @@ require_relative "domain/constants" require_relative "domain/file_store" require_relative "domain/log_paths" -require_relative "domain/version_lookup" -require_relative "domain/kamal_action" -require_relative "domain/app_project" +require_relative "domain/project" require_relative "domain/managed_agent" require_relative "domain/agent_store" require_relative "domain/visibility" @@ -61,8 +59,6 @@ class App def initialize HQ.logger.info("App") { "Starting HQ" } HQ.log_boot_step("App#initialize enter") - @latest_kamal = nil - @latest_rails = nil @config_error = nil @last_refresh = nil @loading = false @@ -83,8 +79,6 @@ def initialize @sidebar_viewport = nil @sidebar_content_proc = nil @confirming = nil - @actions = {} - @action_results = {} @all_projects = [] @projects = [] @all_agents = [] @@ -119,8 +113,6 @@ def initialize @agent_store = AgentStore.new(@all_projects) load_agents! HQ.log_boot_step("agents loaded (#{@agents.length} agents)") - load_actions! - HQ.log_boot_step("actions loaded (#{@actions.length} actions)") load_schedules! HQ.log_boot_step("schedules loaded (#{@schedules.length} schedules)") sync_agent_chat_workspace! @@ -181,7 +173,6 @@ def update(message) cmds << begin_refresh! unless refreshing? [self, Bubbletea.batch(*cmds.compact)] when ActionPollMessage - poll_actions! poll_agents! refresh_omnisearch_index! if omnisearch_open? cmds = [schedule_action_poll] @@ -255,16 +246,10 @@ def handle_key(key) stop_selected_agent when "x" @screen == :projects ? maybe_confirm_project_archive : delete_selected_agent - when "d" - maybe_confirm(:deploy) - when "m" - maybe_confirm(:maintenance) when "l" open_context_log when "L" open_raw_log - when "h" - open_healthcheck_log when "v" open_detail_view when "ctrl+g" @@ -352,7 +337,6 @@ def create_onboarding_current_directory_project(candidate) key: candidate[:key], name: candidate[:name], path: candidate[:path], - apps: candidate[:apps], agent: HQ.harness_keys.first } create_onboarding_project(attrs, success_message: "Created project from current directory") @@ -384,7 +368,7 @@ def load_registry! retried_missing_config = false begin @registry = Registry.new - @all_projects = @registry.projects.map { |config| AppProject.new(config) } + @all_projects = @registry.projects.map { |config| Project.new(config) } apply_project_visibility! @registry_mtime = File.mtime(@registry.path) if File.exist?(@registry.path) @config_error = nil @@ -460,7 +444,7 @@ def refresh_onboarding_options! options << { key: :add_project, title: "Add Local Project", - detail: "Choose a project folder and let Tycho detect app support" + detail: "Choose a project folder for managed agents" } @onboarding_options = options @onboarding_selected = clamp_selection(@onboarding_selected, @onboarding_options.length) @@ -479,11 +463,8 @@ def reload_registry_if_changed! @registry_mtime = current @all_projects = @registry.projects.map do |config| prior = previous[config.key] - project = AppProject.new(config) + project = Project.new(config) if prior - project.instance_variable_set(:@health_status, prior.health_status) - project.instance_variable_set(:@app_status, prior.app_status) - project.instance_variable_set(:@response_time, prior.response_time) project.instance_variable_set(:@commit_hash, prior.commit_hash) project.instance_variable_set(:@branch, prior.branch) project.instance_variable_set(:@dirty_files, prior.dirty_files) @@ -534,16 +515,12 @@ def begin_refresh! projects = @projects agents = @agents - apps = @projects.select(&:apps_enabled?) - @progress_mutex.synchronize do @progress_activity = [] @progress_done = 0 - @progress_total = projects.length + agents.length + apps.length + @progress_total = projects.length + agents.length end - kick_off_version_lookups! if @latest_kamal.nil? || @latest_rails.nil? - if @progress_total.zero? finish_refresh! return nil @@ -572,20 +549,7 @@ def begin_refresh! end end - metadata_by_project = projects.zip(metadata_threads).to_h - - health_threads = apps.map do |project| - Thread.new do - timed_step("health:#{project.key}") do - metadata_by_project[project]&.join - log_activity("Checking health: #{project.name}") - project.check_health! - log_activity("#{project.name} → #{project.health_status} (#{project.response_time}ms)") - end - end - end - - (metadata_threads + agent_threads + health_threads).each(&:join) + (metadata_threads + agent_threads).each(&:join) end @progress_threads = [worker] @@ -598,23 +562,11 @@ def refresh_project_async!(project_key) Thread.new do project.refresh_metadata! - project.check_health! if project.apps_enabled? rescue StandardError => e HQ.logger.warn("Project") { "Async refresh failed for #{project_key}: #{e.class}: #{e.message}" } end end - def kick_off_version_lookups! - return if @version_lookup_thread&.alive? - - @version_lookup_thread = Thread.new do - kamal_thread = Thread.new { VersionLookup.fetch_latest_gem_version("kamal") } - rails_thread = Thread.new { VersionLookup.fetch_latest_gem_version("rails") } - @latest_kamal = kamal_thread.value - @latest_rails = rails_thread.value - end - end - def timed_step(label) start = Process.clock_gettime(Process::CLOCK_MONOTONIC) yield @@ -929,24 +881,11 @@ def select_omnisearch_project(project_key) [self, nil] end - def maybe_confirm(action) - return [self, nil] unless @screen == :projects - - project = selected_project - return [self, nil] unless project - return [self, nil] unless project.apps_enabled? - return [self, nil] if @actions.key?(project.key) - - @confirming = action - [self, nil] - end - def maybe_confirm_project_archive return [self, nil] unless @screen == :projects project = selected_project return [self, nil] unless project - return [self, nil] if @actions.key?(project.key) return [self, nil] if @agents_by_project[project.key].any?(&:running?) @project_archive_confirm = UI::ProjectArchiveConfirm.new(project, agents: @agents_by_project[project.key]) @@ -1237,15 +1176,7 @@ def handle_confirm(key) return [self, Bubbletea.quit] if action == :quit return rebuild_memory_for_chat_agent if action == :rebuild_memory - project = selected_project - return [self, nil] unless project - action_type = if action == :maintenance - project.app_status == "maintenance" ? :live : :maintenance - else - :deploy - end - start_action!(project, action_type) - [self, schedule_action_poll] + [self, nil] when "n", "escape" @confirming = nil [self, nil] @@ -1256,7 +1187,6 @@ def handle_confirm(key) def archive_project(project) return [self, nil] unless project - return [self, nil] if @actions.key?(project.key) return [self, nil] if @agents_by_project[project.key].any?(&:running?) archived_config = @registry.archive_project!(project.key) @@ -1272,8 +1202,6 @@ def archive_project(project) load_registry! @agent_store = AgentStore.new(@all_projects) rebuild_agent_index! - @actions.delete(project.key) - @action_results.delete(project.key) @selected[:projects] = [@selected[:projects], @projects.length - 1].min @selected[:projects] = 0 if @selected[:projects].negative? @selected[:agents] = [@selected[:agents], @agents.length - 1].min @@ -1528,62 +1456,6 @@ def agent_chat_visible_for?(agent) @sidebar&.fetch(:kind, nil) == :agent_chat && @agent_chat_form&.agent == agent end - def start_action!(project, action_type) - action = KamalAction.new( - project_key: project.key, - project_name: project.name, - project_path: project.path, - action: action_type - ) - action.start! - @actions[project.key] = action - save_actions! - end - - def poll_actions! - finished = false - @actions.each do |key, action| - action.poll! - next unless action.done? - - @actions.delete(key) - @action_results[key] = { - success: action.success?, - action: action.action, - action_label: action.label, - at: Time.now, - log_path: action.log_path - } - finished = true - end - - return unless finished - - save_actions! - @loading = true - end - - def save_actions! - FileStore.write_json(ACTIONS_FILE, @actions.values.map(&:to_hash)) - rescue StandardError => e - HQ.logger.error("ActionStore") { "Failed to save actions: #{e.message}" } - end - - def load_actions! - return unless File.exist?(ACTIONS_FILE) - - data = FileStore.read_json(ACTIONS_FILE, fallback: []) - data.each do |hash| - action = KamalAction.from_hash(hash) - action.poll! - @actions[action.project_key] = action unless action.done? - end - save_actions! - rescue StandardError => e - HQ.logger.error("ActionStore") { "Failed to load actions: #{e.message}" } - @actions = {} - end - def schedule_refresh Bubbletea.tick(30) { TickMessage.new } end @@ -1601,15 +1473,12 @@ def handle_chat_render_poll end def schedule_action_poll - return nil if @actions.empty? && @agents.none? { |agent| agent.pid || agent.running? } + return nil if @agents.none? { |agent| agent.pid || agent.running? } Bubbletea.tick(10) { ActionPollMessage.new } end def clear_stale_results - @action_results.delete_if do |_, result| - !UI::Rendering::ProjectStatusBadge.result_active?(result) - end end def open_context_log @@ -1622,18 +1491,6 @@ def open_context_log kind: :chat_log, title: "Agent Chat Log #{Styles::MARKERS[:bullet_sep]} #{agent.name}" ) { read_agent_chat_log(agent) } - when :projects - project = selected_project - return [self, nil] unless project - - path = project.action_log_path - return [self, nil] unless File.exist?(path) - - return open_sidebar_text( - kind: :project_log, - title: "Action Log #{Styles::MARKERS[:bullet_sep]} #{project.name}", - path: path - ) { read_log_file(path) } end [self, nil] @@ -1659,19 +1516,6 @@ def agent_chat_text(agent) "(chat log unavailable)" end - def open_healthcheck_log - return [self, nil] unless @screen == :projects - - path = LogPaths.project_healthcheck_log_path - return [self, nil] unless File.exist?(path) - - open_sidebar_text( - kind: :healthcheck_log, - title: "Healthchecks", - path: path - ) { read_log_file(path) } - end - def open_sidebar_text(kind:, title:, path: nil, &content_proc) close_sidebar! @sidebar = { @@ -1691,7 +1535,7 @@ def open_sidebar_text(kind:, title:, path: nil, &content_proc) end def log_sidebar_kind?(kind) - %i[chat_log raw_log project_log healthcheck_log].include?(kind) + %i[chat_log raw_log project_log].include?(kind) end def read_agent_chat_log(agent) @@ -1879,7 +1723,6 @@ def handle_sidebar(message) apply_window_size(message.width, message.height) return [self, nil] when ActionPollMessage - poll_actions! poll_agents! sync_sidebar_text! return [self, schedule_action_poll] @@ -1929,7 +1772,6 @@ def handle_detail_overlay(message) apply_window_size(message.width, message.height) return [self, nil] when ActionPollMessage - poll_actions! poll_agents! sync_detail_overlay! return [self, schedule_action_poll] diff --git a/lib/hq/cli_command.rb b/lib/hq/cli_command.rb index cfe0119..99da935 100644 --- a/lib/hq/cli_command.rb +++ b/lib/hq/cli_command.rb @@ -6,9 +6,8 @@ require "dry/cli" require "lipgloss" -require_relative "domain/app_project" +require_relative "domain/project" require_relative "domain/file_store" -require_relative "domain/kamal_action" require_relative "domain/scheduler" require_relative "domain/agent_store" @@ -32,76 +31,6 @@ def usage_template(value = nil) end end - class App < Dry::CLI::Command - desc "Manage projects with Kamal deployment" - - def call(**) - exit CLICommand.usage("Missing app command", err: err) - end - end - - class ListApps < Dry::CLI::Command - extend CommandMetadata - - desc "List projects with Kamal deployment" - usage_template "app list" - - def call(**) - exit CLICommand.list_kamal_actions(out: out) - end - end - - class AppStatus < Dry::CLI::Command - extend CommandMetadata - - desc "Check this project's app status" - argument :project_key, required: true, desc: "Project key" - usage_template "app status %{project_key}" - - def call(project_key:, **) - exit CLICommand.check_app_status(project_key, out: out, err: err) - end - end - - class AppAction < Dry::CLI::Command - extend CommandMetadata - - argument :project_key, required: true, desc: "Project key" - - def self.action_name(value = nil, usage: nil) - @action_name = value if value - usage_template(usage) if usage - @action_name - end - - def call(project_key:, **) - exit CLICommand.start_kamal_action(self.class.action_name, project_key, out: out, err: err) - end - end - - class DeployApp < AppAction - desc "Deploy this project" - action_name "deploy", usage: "app deploy %{project_key}" - end - - class MaintenanceApp < AppAction - desc "Put this project into maintenance mode" - action_name "maintenance", usage: "app maintenance %{project_key}" - end - - class LiveApp < AppAction - desc "Resume live traffic for this project" - action_name "live", usage: "app live %{project_key}" - end - - register "app", App do |prefix| - prefix.register "list", ListApps - prefix.register "status", AppStatus - prefix.register "deploy", DeployApp - prefix.register "maintenance", MaintenanceApp - prefix.register "live", LiveApp - end - class Project < Dry::CLI::Command desc "Manage project metadata" @@ -353,13 +282,6 @@ def call(**) end - APP_COMMANDS = [ - Commands::ListApps, - Commands::AppStatus, - Commands::DeployApp, - Commands::MaintenanceApp, - Commands::LiveApp - ].freeze PROJECT_COMMANDS = [ Commands::ProjectUpdate ].freeze @@ -388,12 +310,10 @@ def call(**) " #{COMMAND_NAME} schedule daemon [--once] [--dry-run] [--interval SECONDS]", " #{COMMAND_NAME} doctor" ].freeze - ACTIONS = APP_COMMANDS.filter_map { |command| command.action_name if command.respond_to?(:action_name) }.freeze USAGE = [ "Usage:", " #{COMMAND_NAME} --help", *RUNTIME_COMMANDS, - *APP_COMMANDS.map { |command| " #{COMMAND_NAME} #{format(command.usage_template, project_key: "")}" }, *PROJECT_COMMANDS.map { |command| " #{COMMAND_NAME} #{format(command.usage_template, project_key: "")}" }, *AGENT_COMMANDS.map { |command| template = command.usage_template @@ -458,160 +378,10 @@ def doctor(argv, out: $stdout, err: $stderr) 1 end - def list_kamal_actions(out: $stdout) - projects = registry_projects.select(&:apps_enabled?) - if projects.empty? - out.puts "No projects with Kamal deployment configured." - return 0 - end - - out.puts app_list_table(projects) - 0 - end - - def check_app_status(project_key, out: $stdout, err: $stderr) - project = find_app_project(project_key) - return failure("Unknown project: #{project_key}", err: err) unless project - return failure("Project does not have Kamal deployment: #{project.key}", err: err) unless project.apps_enabled? - - project.refresh_metadata! - project.check_health! - out.puts app_status_table(project, action_status: action_status_for(project)) - 0 - end - - def app_list_table(projects) - headers = %w[Key Name Actions] - rows = projects.map { |project| [project.key, project.name, ACTIONS.join(", ")] } - Lipgloss::Table.new - .headers(headers) - .rows(rows) - .border_style(Lipgloss::Style.new.foreground(COLORS[:accent_alt])) - .style_func(rows: rows.length, columns: headers.length) do |row, column| - if row == Lipgloss::Table::HEADER_ROW - Lipgloss::Style.new.bold(true).foreground(COLORS[:accent]) - elsif column.zero? - Lipgloss::Style.new.bold(true).foreground(COLORS[:notice]) - else - Lipgloss::Style.new.foreground(COLORS[:text]) - end - end.render - end - - def app_status_table(project, action_status: nil) - rows = [ - ["Key", project.key], - ["Name", project.name], - ["Path", project.path], - ["Service", project.service || "n/a"], - ["Image", project.image || "n/a"], - ["Hosts", Array(project.hosts).empty? ? "n/a" : Array(project.hosts).join(", ")], - ["Proxy", project.proxy_host || "n/a"], - ["Health Path", project.healthcheck_path || "n/a"], - ["App", project.app_status], - ["Health", project.health_status], - ["Latency", project.response_time ? "#{project.response_time}ms" : "n/a"], - ["Action Log", project.action_log_path] - ] - if action_status - rows.insert(-2, ["Last Action", action_status.fetch(:label)]) - rows.insert(-2, ["Last Action At", action_status.fetch(:started_at)]) - end - Lipgloss::Table.new - .rows(rows) - .border_style(Lipgloss::Style.new.foreground(COLORS[:accent_alt])) - .style_func(rows: rows.length, columns: 2) do |_row, column| - if column.zero? - Lipgloss::Style.new.bold(true).foreground(COLORS[:notice]) - else - Lipgloss::Style.new.foreground(COLORS[:text]) - end - end.render - end - - def action_status_for(project) - active = load_actions[project.key] - if active - active.poll! - return { - label: active.done? ? action_result_label(active) : "#{active.label} - running", - started_at: active.started_at.strftime("%Y-%m-%d %H:%M:%S") - } - end - - last_action_status_from_log(project) - end - - def last_action_status_from_log(project) - return nil unless File.exist?(project.action_log_path) - - content = File.read(project.action_log_path) - matches = content.to_enum(:scan, /^=== \[(.+?)\] ([a-z_]+) ===$/).map { Regexp.last_match } - last = matches.last - return nil unless last - - action = last[2].to_sym - section = content[last.begin(0)..] - { - label: "#{KamalAction.label_for(action)} - #{action_status_label(project, section)}", - started_at: last[1] - } - rescue StandardError - nil - end - - def action_status_label(project, section) - status_path = "#{project.action_log_path}.status" - if File.exist?(status_path) - return File.read(status_path).to_i.zero? ? "success" : "failed" - end - - return "failed" if section.match?(/\bERROR\b|\bexit status:\s*[1-9]\d*\b|failed to/i) - return "success" if section.match?(/\bexit status 0\b|successful/i) - - "unknown" - rescue StandardError - "unknown" - end - - def action_result_label(action) - "#{action.label} - #{action.success? ? "success" : "failed"}" - end - - def start_kamal_action(action_name, project_key, out: $stdout, err: $stderr) - return usage("Missing project key") if project_key.to_s.strip.empty? - - project = find_app_project(project_key) - return failure("Unknown project: #{project_key}", err: err) unless project - return failure("Project does not have Kamal deployment: #{project.key}", err: err) unless project.apps_enabled? - - actions = load_actions - active = actions[project.key] - if active - active.poll! - return failure("#{project.key} already has an active action: #{active.label}", err: err) unless active.done? - - actions.delete(project.key) - end - - action = KamalAction.new( - project_key: project.key, - project_name: project.name, - project_path: project.path, - action: action_name.to_sym - ) - action.start! - actions[project.key] = action - save_actions(actions) - out.puts "Started #{action.label} for #{project.key}." - out.puts "Log: #{action.log_path}" - 0 - end - def registry_projects require_relative "registry" - Registry.new.projects.map { |config| AppProject.new(config) } + Registry.new.projects.map { |config| Project.new(config) } end def update_project(project_key, opts, out: $stdout, err: $stderr) @@ -1044,44 +814,12 @@ def agent_table(headers, rows) end.render end - def find_app_project(project_key) - registry_projects.find { |candidate| candidate.key == project_key.to_s } - end - - def load_actions - return {} unless File.exist?(ACTIONS_FILE) - - FileStore.read_json(ACTIONS_FILE, fallback: []).each_with_object({}) do |hash, actions| - action = KamalAction.from_hash(hash) - actions[action.project_key] = action - end - rescue StandardError => e - warn "Failed to load actions: #{e.message}" - {} - end - - def save_actions(actions) - FileStore.write_json(ACTIONS_FILE, actions.values.map(&:to_hash)) - end - def usage(error = nil, err: $stderr) err.puts error if error err.puts USAGE error ? 64 : 0 end - def prompt_reference(project_key:) - command_lines = APP_COMMANDS.map do |command| - "- #{command.description}: `#{File.join(ROOT_DIR, "bin", "tycho")} #{format(command.usage_template, project_key:)}`" - end - - [ - "Available Tycho commands for projects with Kamal deployment:", - *command_lines, - "Use these commands only when the user explicitly asks you to operate deployment or maintenance." - ].join("\n") - end - def failure(message, err: $stderr) err.puts message 1 diff --git a/lib/hq/domain/agent_store.rb b/lib/hq/domain/agent_store.rb index 36e1652..6bd46d0 100644 --- a/lib/hq/domain/agent_store.rb +++ b/lib/hq/domain/agent_store.rb @@ -2,7 +2,6 @@ require_relative "constants" require_relative "file_store" -require_relative "../cli_command" require_relative "managed_agent" require_relative "../ui/rendering/styles" @@ -194,7 +193,6 @@ def template_for(project, template_key) def backfill_project_context_prompt!(agent) project = project_for(agent.project_key) return false unless project - return false unless project_apps_enabled?(project) ensure_project_context_prompt!(agent, project) end @@ -220,25 +218,15 @@ def system_messages_for(project, prompt) end def project_context_prompt(project) - return "" unless project_apps_enabled?(project) - lines = [ "Project:", "- Key: #{project.key}", "- Name: #{project.name}", - "- Path: #{project.path}", - CLICommand.prompt_reference(project_key: project.key), - "Ensure to check the Last Action when performing HQ command." + "- Path: #{project.path}" ] lines.join("\n") end - def project_apps_enabled?(project) - return project.apps_enabled? if project.respond_to?(:apps_enabled?) - - project.respond_to?(:apps) && project.apps - end - def project_for(project_key) @projects.find { |project| project.key == project_key } end diff --git a/lib/hq/domain/app_project.rb b/lib/hq/domain/app_project.rb deleted file mode 100644 index a8d4638..0000000 --- a/lib/hq/domain/app_project.rb +++ /dev/null @@ -1,334 +0,0 @@ -# frozen_string_literal: true - -require_relative "constants" -require_relative "log_paths" - -module HQ - class AppProject - DEPLOY_CONFIG_ENV_MUTEX = Mutex.new - - attr_reader :config, :key, :name, :path, :service, :image, :hosts, :proxy_host, - :healthcheck_path, :kamal_version, :rails_version, :health_status, - :app_status, :response_time, :commit_hash, :branch, :dirty_files, :group - - def initialize(config) - @config = config - @key = config.key - @name = config.name - @group = config.group.to_s - @path = config.path - @health_status = "pending" - @app_status = "pending" - @response_time = nil - @commit_hash = nil - @dirty_files = 0 - end - - def apps_enabled? - @config.apps - end - - def hidden? - @config.hidden == true - end - - def hidden_config - @config.hidden_config - end - - def group_hidden - @config.group_hidden - end - - def visibility_source - return "project" unless @config.hidden_config.nil? - return "group" unless @config.group_hidden.nil? - - "default" - end - - def agent_templates - @config.agent_templates - end - - def pr_url - @config.pr_url - end - - def pr_number - return nil unless pr_url - - match = pr_url.match(%r{/pull/(\d+)}) - match && match[1] - end - - def github_repo_url - return nil unless pr_url - - match = pr_url.match(%r{\A(https?://[^/]+/[^/]+/[^/]+)/pull/\d+}) - match && match[1] - end - - def branch_url(branch_name) - return nil if branch_name.to_s.empty? - return nil unless (repo = github_repo_url) - - "#{repo}/tree/#{branch_name}" - end - - def commit_url(sha) - return nil if sha.to_s.empty? - return nil unless (repo = github_repo_url) - - "#{repo}/commit/#{sha}" - end - - def refresh_metadata! - parse_deploy_config if apps_enabled? - parse_versions - parse_git_status - end - - def check_health! - return unless apps_enabled? - - target = healthcheck_target - unless target - @response_time = nil - @health_status = "not checked" - @app_status = "unknown" - return - end - - http = Net::HTTP.new(target.host, target.port) - http.use_ssl = target.scheme == "https" - http.open_timeout = 2 - http.read_timeout = 3 - http.keep_alive_timeout = 5 - - hc_response = nil - http.start do |conn| - start_time = Time.now - hc_response = conn.head(target.healthcheck_path) - @response_time = ((Time.now - start_time) * 1000).round - - if hc_response.code.to_s == "503" - @health_status = "maintenance" - @app_status = "maintenance" - next - end - - @health_status = hc_response.code.start_with?("2", "3") ? "healthy" : "unhealthy (#{hc_response.code})" - - app_response = conn.head(target.root_path) - @app_status = case app_response.code - when /^2/, /^3/ then "running" - when "503" then "maintenance" - when /^4/ then "error (#{app_response.code})" - when /^5/ then "down (#{app_response.code})" - else "unknown (#{app_response.code})" - end - - @health_status = "maintenance" if @app_status == "maintenance" - end - rescue OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e - @response_time = nil - @health_status = "down" - @app_status = "stopped" - HQ.logger.warn("Health") { "#{@name}: #{e.class}" } - rescue Net::OpenTimeout, Net::ReadTimeout => e - @response_time = nil - @health_status = "timeout" - @app_status = "unreachable" - HQ.logger.warn("Health") { "#{@name}: #{e.class}" } - rescue StandardError => e - @response_time = nil - @health_status = "error: #{e.message.slice(0, 40)}" - @app_status = "error" - HQ.logger.warn("Health") { "#{@name}: #{e.class} - #{e.message}" } - ensure - log_healthcheck(hc_response&.code) - end - - def action_log_path - LogPaths.project_action_log_path(@key) - end - - def log_dir - LogPaths.project_log_dir(@key) - end - - def archive_logs!(root = PROJECT_ARCHIVE_DIR, now: Time.now) - return nil unless Dir.exist?(log_dir) - - FileUtils.mkdir_p(root) - destination = unique_archive_destination(root, now) - FileUtils.mv(log_dir, destination) - destination - end - - private - - HealthcheckTarget = Struct.new(:scheme, :host, :port, :healthcheck_path, :root_path, keyword_init: true) - - def healthcheck_target - if @proxy_host - uri = normalize_health_uri(@proxy_host, default_scheme: "https", default_path: @healthcheck_path || "/up") - return nil unless uri&.host - - return HealthcheckTarget.new( - scheme: uri.scheme, - host: uri.host, - port: uri.port, - healthcheck_path: uri.path.to_s.empty? ? (@healthcheck_path || "/up") : uri.path, - root_path: "/" - ) - end - - nil - rescue URI::InvalidURIError - nil - end - - def normalize_health_uri(value, default_scheme:, default_path:) - raw = value.to_s.strip - raw = "#{default_scheme}://#{raw}" unless raw.match?(%r{\A[a-z][a-z0-9+\-.]*://}i) - uri = URI(raw) - return uri unless uri.path.to_s.empty? - - URI("#{uri.scheme}://#{uri.host}:#{uri.port}#{default_path}") - end - - def unique_archive_destination(root, now) - LogPaths.project_archive_destination(root, archive_name, now:) - end - - def archive_name - slug = @name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") - slug.empty? ? @key : slug - end - - def log_healthcheck(code) - log_path = LogPaths.project_healthcheck_log_path - now = Time.now - date_str = now.strftime("%Y-%m-%d") - timestamp = now.strftime("%Y-%m-%d %H:%M:%S") - latency = @response_time ? "#{@response_time}ms" : "n/a" - - File.open(log_path, "a") do |file| - @last_healthcheck_date ||= nil - if @last_healthcheck_date != date_str - file.puts - file.puts "=== #{date_str} ===" - file.puts - @last_healthcheck_date = date_str - end - file.puts "[#{timestamp}] #{@name} code=#{code} health=#{@health_status} status=#{@app_status} latency=#{latency}" - end - rescue StandardError - nil - end - - def parse_deploy_config - config_path = File.join(@path, "config", "deploy.yml") - return unless File.exist?(config_path) - - raw = File.read(config_path) - rendered = render_deploy_config(raw) - config = YAML.safe_load(rendered, permitted_classes: [Symbol]) - - @service = config["service"] - @image = config["image"] - @hosts = extract_hosts(config) - @proxy_host = config.dig("proxy", "host") - @healthcheck_path = config.dig("proxy", "healthcheck", "path") || "/up" - rescue StandardError => e - HQ.logger.warn("Project") { "Deploy config parse failed for #{@key}: #{e.class}: #{e.message}" } - @service = nil - @image = nil - @hosts = [] - @proxy_host = nil - @healthcheck_path = nil - end - - def render_deploy_config(raw) - old_env = {} - DEPLOY_CONFIG_ENV_MUTEX.synchronize do - begin - load_dotenv.each do |key, value| - next if ENV.key?(key) - - old_env[key] = nil - ENV[key] = value - end - - ERB.new(raw).result(binding) - ensure - old_env.each_key { |key| ENV.delete(key) } - end - end - end - - def extract_hosts(config) - hosts = [] - servers = config["servers"] - return hosts unless servers - - if servers.is_a?(Array) - hosts.concat(servers) - elsif servers.is_a?(Hash) - servers.each_value do |role_config| - if role_config.is_a?(Hash) && role_config["hosts"] - Array(role_config["hosts"]).each { |host| hosts << host unless host.to_s.empty? } - elsif role_config.is_a?(Array) - role_config.each { |host| hosts << host unless host.to_s.empty? } - end - end - end - - hosts.uniq - end - - def load_dotenv - dotenv_path = File.join(@path, ".env") - return {} unless File.exist?(dotenv_path) - - File.readlines(dotenv_path).each_with_object({}) do |line, vars| - stripped = line.strip - next if stripped.empty? || stripped.start_with?("#") - - key, value = stripped.split("=", 2) - next unless key && value - - vars[key.strip] = value.strip.gsub(/\A["']|["']\z/, "") - end - end - - def parse_versions - lockfile = File.join(@path, "Gemfile.lock") - return unless File.exist?(lockfile) - - content = File.read(lockfile) - @kamal_version = content[/^\s*kamal \((.+)\)/, 1] - @rails_version = content[/^\s*rails \((.+)\)/, 1] - end - - def parse_git_status - git_dir = File.join(@path, ".git") - return unless File.exist?(git_dir) - - @commit_hash = `git -C #{@path.shellescape} rev-parse --short HEAD 2>/dev/null`.strip - @commit_hash = nil if @commit_hash.to_s.empty? - - @branch = `git -C #{@path.shellescape} branch --show-current 2>/dev/null`.strip - @branch = nil if @branch.to_s.empty? - - porcelain = `git -C #{@path.shellescape} status --porcelain 2>/dev/null` - @dirty_files = porcelain.lines.count - rescue StandardError - @commit_hash = nil - @branch = nil - @dirty_files = 0 - end - end -end diff --git a/lib/hq/domain/constants.rb b/lib/hq/domain/constants.rb index 7a88bdc..edaa665 100644 --- a/lib/hq/domain/constants.rb +++ b/lib/hq/domain/constants.rb @@ -67,7 +67,6 @@ def self.ensure_user_config_file(name, example_name) end LOGS_DIR = USER_LOGS_DIR - ACTIONS_FILE = File.join(LOGS_DIR, "actions.json") AGENTS_FILE = File.join(LOGS_DIR, "managed_agents.json") SCHEDULES_FILE = env_present("SCHEDULES_PATH", default_schedules_path) SCHEDULES_STATE_FILE = env_present("SCHEDULES_STATE_PATH", File.join(LOGS_DIR, "schedules.json")) diff --git a/lib/hq/domain/executable_resolver.rb b/lib/hq/domain/executable_resolver.rb index c636878..886e055 100644 --- a/lib/hq/domain/executable_resolver.rb +++ b/lib/hq/domain/executable_resolver.rb @@ -14,7 +14,6 @@ def available? "claude" => "CLAUDE_BIN", "codex" => "CODEX_BIN", "opencode" => "OPENCODE_BIN", - "mise" => "MISE_BIN", "tailscale" => "TAILSCALE_BIN" }.freeze @@ -59,7 +58,7 @@ def executable_path(command) def fallback_paths_for(name) case name.to_s - when "claude", "codex", "opencode", "mise" + when "claude", "codex", "opencode" [ File.join(Dir.home, ".local", "bin", name.to_s), "/opt/homebrew/bin/#{name}", diff --git a/lib/hq/domain/kamal_action.rb b/lib/hq/domain/kamal_action.rb deleted file mode 100644 index fce9f89..0000000 --- a/lib/hq/domain/kamal_action.rb +++ /dev/null @@ -1,192 +0,0 @@ -# frozen_string_literal: true - -require_relative "constants" -require_relative "executable_resolver" -require_relative "log_paths" -require_relative "process_liveness" - -module HQ - class KamalAction - attr_reader :project_key, :project_name, :action, :log_path, :started_at, :pid - - def initialize(project_key:, project_name:, project_path:, action:, pid: nil, started_at: nil) - @project_key = project_key - @project_name = project_name - @project_path = project_path - @action = action - @started_at = started_at || Time.now - @log_path = LogPaths.project_action_log_path(@project_key) - @status_path = "#{@log_path}.status" - @pid = pid - @done = false - @success = nil - end - - def self.from_hash(hash) - new( - project_key: hash["project_key"], - project_name: hash["project_name"], - project_path: hash["project_path"], - action: hash["action"].to_sym, - pid: hash["pid"], - started_at: Time.parse(hash["started_at"]) - ) - end - - def to_hash - { - "project_key" => @project_key, - "project_name" => @project_name, - "project_path" => @project_path, - "action" => @action.to_s, - "pid" => @pid, - "started_at" => @started_at.iso8601 - } - end - - def start! - args = { - deploy: ["deploy"], - maintenance: %w[app maintenance], - live: %w[app live] - }[@action] - - bin_kamal = File.join(@project_path, "bin", "kamal") - mise = mise_executable - command = if File.executable?(bin_kamal) - [mise, "exec", "--", bin_kamal, *args] - else - [mise, "exec", "--", "bundle", "exec", "kamal", *args] - end - - FileUtils.mkdir_p(File.dirname(@log_path)) - FileUtils.rm_f(@status_path) - File.open(@log_path, "a") do |file| - file.puts - file.puts "=== [#{@started_at.strftime("%Y-%m-%d %H:%M:%S")}] #{@action} ===" - file.puts - end - - log_file = File.open(@log_path, "a") - @pid = spawn( - RbConfig.ruby, - "-e", - action_runner_script, - @status_path, - @project_path, - @log_path, - *command, - out: log_file, - err: %i[child out], - pgroup: true - ) - log_file.close - Process.detach(@pid) - HQ.logger.info("KamalAction") { "Started #{@action} for #{@project_name} (pid=#{@pid})" } - end - - def poll! - return if @done - return if ProcessLiveness.alive?(@pid) - - @done = true - @success = action_exit_success? && !action_log_failure? - HQ.logger.info("KamalAction") { "#{@action} finished for #{@project_name} (success=#{@success})" } - end - - def done? - @done - end - - def success? - @success - end - - def label - self.class.label_for(@action) - end - - def self.label_for(action) - { - deploy: "deploying", - maintenance: "going maintenance", - live: "going live" - }[action.to_sym] - end - - def self.project_ready?(project_path) - !project_readiness_source(project_path).to_s.empty? - end - - def self.project_readiness_source(project_path) - path = project_path.to_s - return nil if path.empty? - - binstub = File.join(path, "bin", "kamal") - return "bin/kamal" if File.executable?(binstub) - - lockfile = File.join(path, "Gemfile.lock") - return "Gemfile.lock" if File.file?(lockfile) && File.read(lockfile).match?(/^ kamal \(/) - - gemfile = File.join(path, "Gemfile") - return "Gemfile" if File.file?(gemfile) && File.read(gemfile).match?(/gem ["']kamal["']/) - - nil - rescue StandardError - nil - end - - private - - def mise_executable - ExecutableResolver.command_for_tool("mise") - end - - def action_exit_success? - return true unless File.exist?(@status_path) - - File.read(@status_path).to_i.zero? - rescue StandardError - false - end - - def action_log_failure? - section = current_log_section - return false if section.empty? - - section.match?(/\bERROR\b|\bexit status:\s*[1-9]\d*\b|failed to/i) - rescue StandardError - false - end - - def current_log_section - return "" unless File.exist?(@log_path) - - marker = "=== [#{@started_at.strftime("%Y-%m-%d %H:%M:%S")}] #{@action} ===" - content = File.read(@log_path) - index = content.rindex(marker) - return "" unless index - - content[index..] - end - - def action_runner_script - <<~RUBY - status_path = ARGV.shift - project_path = ARGV.shift - log_path = ARGV.shift - command = ARGV - exit_status = 1 - - Dir.chdir(project_path) - File.open(log_path, "a") do |log| - pid = spawn(*command, out: log, err: [:child, :out], pgroup: true) - _, status = Process.wait2(pid) - exit_status = status.exitstatus || 1 - end - File.write(status_path, exit_status.to_s) - exit(exit_status) - RUBY - end - end -end diff --git a/lib/hq/domain/log_paths.rb b/lib/hq/domain/log_paths.rb index 13d6501..419f966 100644 --- a/lib/hq/domain/log_paths.rb +++ b/lib/hq/domain/log_paths.rb @@ -14,14 +14,6 @@ def project_log_dir(project_key) File.join(PROJECT_LOGS_DIR, project_key.to_s) end - def project_action_log_path(project_key) - File.join(project_log_dir(project_key), "action.log") - end - - def project_healthcheck_log_path - File.join(PROJECT_LOGS_DIR, "healthcheck.log") - end - def project_archive_destination(root, archive_name, now: Time.now) unique_path(File.join(root, "#{now.strftime("%Y-%m-%d")}_#{safe_segment(archive_name)}")) end diff --git a/lib/hq/domain/onboarding.rb b/lib/hq/domain/onboarding.rb index e357c1e..94cf3e5 100644 --- a/lib/hq/domain/onboarding.rb +++ b/lib/hq/domain/onboarding.rb @@ -28,7 +28,6 @@ def welcome_project_attrs(agent: nil) name: WELCOME_PROJECT_NAME, group: WELCOME_PROJECT_GROUP, path: ensure_welcome_workspace!, - apps: false, agent: agent.to_s } end @@ -45,7 +44,6 @@ def current_directory_candidate(cwd = Dir.pwd) key: project_key_for(basename), name: basename, path: path, - apps: File.exist?(File.join(path, "config", "deploy.yml")), kind: kind } end @@ -56,7 +54,6 @@ def project_key_for(name) end def workspace_kind(path) - return "Kamal app" if File.exist?(File.join(path, "config", "deploy.yml")) return "Git repository" if File.directory?(File.join(path, ".git")) return "Ruby project" if File.exist?(File.join(path, "Gemfile")) return "JavaScript project" if File.exist?(File.join(path, "package.json")) diff --git a/lib/hq/domain/project.rb b/lib/hq/domain/project.rb new file mode 100644 index 0000000..065c0eb --- /dev/null +++ b/lib/hq/domain/project.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require_relative "constants" +require_relative "log_paths" + +module HQ + class Project + attr_reader :config, :key, :name, :path, :commit_hash, :branch, :dirty_files, :group + + def initialize(config) + @config = config + @key = config.key + @name = config.name + @group = config.group.to_s + @path = config.path + @commit_hash = nil + @branch = nil + @dirty_files = 0 + end + + def status + "configured" + end + + def hidden? + @config.hidden == true + end + + def hidden_config + @config.hidden_config + end + + def group_hidden + @config.group_hidden + end + + def visibility_source + return "project" unless @config.hidden_config.nil? + return "group" unless @config.group_hidden.nil? + + "default" + end + + def agent_templates + @config.agent_templates + end + + def pr_url + @config.pr_url + end + + def pr_number + return nil unless pr_url + + match = pr_url.match(%r{/pull/(\d+)}) + match && match[1] + end + + def github_repo_url + return nil unless pr_url + + match = pr_url.match(%r{\A(https?://[^/]+/[^/]+/[^/]+)/pull/\d+}) + match && match[1] + end + + def branch_url(branch_name) + return nil if branch_name.to_s.empty? + return nil unless (repo = github_repo_url) + + "#{repo}/tree/#{branch_name}" + end + + def commit_url(sha) + return nil if sha.to_s.empty? + return nil unless (repo = github_repo_url) + + "#{repo}/commit/#{sha}" + end + + def refresh_metadata! + parse_git_status + end + + def log_dir + LogPaths.project_log_dir(@key) + end + + def archive_logs!(root = PROJECT_ARCHIVE_DIR, now: Time.now) + return nil unless Dir.exist?(log_dir) + + FileUtils.mkdir_p(root) + destination = unique_archive_destination(root, now) + FileUtils.mv(log_dir, destination) + destination + end + + private + + def unique_archive_destination(root, now) + LogPaths.project_archive_destination(root, archive_name, now:) + end + + def archive_name + slug = @name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") + slug.empty? ? @key : slug + end + + def parse_git_status + git_dir = File.join(@path, ".git") + return unless File.exist?(git_dir) + + @commit_hash = `git -C #{@path.shellescape} rev-parse --short HEAD 2>/dev/null`.strip + @commit_hash = nil if @commit_hash.to_s.empty? + + @branch = `git -C #{@path.shellescape} branch --show-current 2>/dev/null`.strip + @branch = nil if @branch.to_s.empty? + + porcelain = `git -C #{@path.shellescape} status --porcelain 2>/dev/null` + @dirty_files = porcelain.lines.count + rescue StandardError + @commit_hash = nil + @branch = nil + @dirty_files = 0 + end + end +end diff --git a/lib/hq/domain/scheduler.rb b/lib/hq/domain/scheduler.rb index 4e579fa..518e7a9 100644 --- a/lib/hq/domain/scheduler.rb +++ b/lib/hq/domain/scheduler.rb @@ -4,7 +4,7 @@ require_relative "../registry" require_relative "agent_store" -require_relative "app_project" +require_relative "project" require_relative "push_notification_store" require_relative "schedule_registry" require_relative "schedule_store" @@ -20,7 +20,7 @@ class Scheduler def initialize(registry: Registry.new, schedule_registry: nil, store: ScheduleStore.new, push_notification_store: PushNotificationStore.new, web_push_notifier: nil) @registry = registry - @projects = registry.projects.map { |config| AppProject.new(config) } + @projects = registry.projects.map { |config| Project.new(config) } @agent_store = AgentStore.new(@projects) @schedule_registry = schedule_registry || ScheduleRegistry.new(projects: @projects) @store = store diff --git a/lib/hq/domain/version_lookup.rb b/lib/hq/domain/version_lookup.rb deleted file mode 100644 index 3f9616e..0000000 --- a/lib/hq/domain/version_lookup.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative "constants" - -module HQ - module VersionLookup - module_function - - def fetch_latest_gem_version(gem_name) - uri = URI.parse("https://rubygems.org/api/v1/versions/#{gem_name}.json") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.open_timeout = 2 - http.read_timeout = 3 - - response = http.get(uri.request_uri) - return nil unless response.code.to_s == "200" - - versions = JSON.parse(response.body) - stable = versions.find { |version| !version["prerelease"] } - stable&.dig("number") - rescue StandardError - nil - end - end -end diff --git a/lib/hq/registry.rb b/lib/hq/registry.rb index 357eded..63740fc 100644 --- a/lib/hq/registry.rb +++ b/lib/hq/registry.rb @@ -40,7 +40,6 @@ def resolved_token :name, :group, :path, - :apps, :agent, :model, :reasoning_effort, @@ -82,7 +81,6 @@ def load! HQ.custom_harnesses = @custom_harnesses remove_instance_variable(:@normalized_system_prompts) if instance_variable_defined?(:@normalized_system_prompts) remove_instance_variable(:@system_prompt_templates) if instance_variable_defined?(:@system_prompt_templates) - write_yaml(@path, data) if persist_detected_apps!(data) @projects = build_projects(data["projects"] || []) validate! HQ.logger.info("Config") { "Loaded #{@projects.size} projects" } @@ -96,13 +94,6 @@ def load! raise ConfigError, "Invalid YAML in #{e.file}: #{e.message}" end - def self.kamal_app?(project_path) - path = project_path.to_s - return false if path.empty? - - File.exist?(File.join(path, "config", "deploy.yml")) - end - def add_project!(attrs) data = load_yaml(@path) projects = Array(data["projects"]) @@ -113,7 +104,6 @@ def add_project!(attrs) entry = { "key" => key, "name" => attrs[:name].to_s } entry["group"] = attrs[:group].to_s unless attrs[:group].to_s.strip.empty? entry["path"] = attrs[:path].to_s - entry["apps"] = attrs[:apps] == true unless attrs[:apps].nil? entry["agent"] = attrs[:agent].to_s unless attrs[:agent].to_s.strip.empty? group = entry["group"].to_s @@ -328,7 +318,6 @@ def build_projects(raw_projects) key = fetch_key(project, "project") path = expand_path(project["path"]) name = (project["name"] || File.basename(path)).to_s - apps_enabled = apps_enabled_for(project) group_name = project["group"].to_s.strip hidden_config = hidden_value(project, "project #{key}") group_hidden = @groups[group_name]&.hidden @@ -338,7 +327,6 @@ def build_projects(raw_projects) name: name, group: group_name, path: path, - apps: apps_enabled, agent: normalize_agent(project["agent"]), model: normalize_model(project["model"]), reasoning_effort: normalize_reasoning_effort(project["reasoning_effort"]), @@ -375,32 +363,6 @@ def normalize_hidden(value, label) raise ConfigError, "Invalid hidden value for #{label}: #{value.inspect}" end - def apps_enabled_for(project) - return Registry.kamal_app?(expand_path(project["path"])) unless project.key?("apps") - - value = project["apps"] - return value if [true, false].include?(value) - return false if value.nil? - - normalized = value.to_s.strip.downcase - return true if %w[true yes on 1].include?(normalized) - return false if %w[false no off 0].include?(normalized) - - !!value - end - - def persist_detected_apps!(data) - projects = Array(data["projects"]) - changed = false - projects.each do |project| - next if project.key?("apps") - - project["apps"] = Registry.kamal_app?(expand_path(project["path"])) - changed = true - end - changed - end - def validate! validate_uniqueness!(@custom_harnesses.map(&:key), "custom harness") validate_uniqueness!(@projects.map(&:key), "project") diff --git a/lib/hq/remote_server.rb b/lib/hq/remote_server.rb index ca3fd23..adfb0b7 100644 --- a/lib/hq/remote_server.rb +++ b/lib/hq/remote_server.rb @@ -12,7 +12,7 @@ require_relative "remote_ui" require_relative "terminal_qr" require_relative "version" -require_relative "domain/app_project" +require_relative "domain/project" require_relative "domain/attachment_normalizer" require_relative "domain/constants" require_relative "domain/agent_attachment_store" @@ -22,7 +22,6 @@ require_relative "domain/file_store" require_relative "domain/git_diff" require_relative "domain/harness_catalog" -require_relative "domain/kamal_action" require_relative "domain/push_notification_store" require_relative "domain/push_subscription_store" require_relative "domain/pull_request_diff" @@ -212,15 +211,11 @@ def poll_agent_push_notifications! def route(service, method, path, body, request = nil) parts = path.split("/").reject(&:empty?) - return ok(service.health) if method == "GET" && parts == ["health"] if parts.first == "servers" broker = RemoteBroker.new(registry: service.registry, server_url: service.server_url) return ok(servers: broker.servers) if method == "GET" && parts == ["servers"] return created(service.add_remote_server(body)) if method == "POST" && parts == ["servers"] return ok(service.remove_remote_server(parts[1])) if method == "DELETE" && parts.length == 2 - if method == "GET" && parts.length == 3 && parts[2] == "health" - return ok(server: broker.health(parts[1], request)) - end if parts.length >= 3 && parts[2] == "proxy" proxy_path = "/#{parts.drop(3).join("/")}" proxy_path = "/" if proxy_path == "/" @@ -303,12 +298,6 @@ def route(service, method, path, body, request = nil) if method == "GET" && tail[0, 2] == ["git", "diff"] && tail.length <= 3 return ok(diff: service.project_git_diff(key, scope: tail[2] || request&.query_params&.fetch("scope", nil))) end - return ok(actions: service.project_action_preflights(key)) if method == "GET" && tail == ["actions"] - return ok(service.start_project_action(key, body["action"], body)) if method == "POST" && tail == ["actions"] - if tail.length == 2 && tail.first == "actions" - return ok(service.project_action_preflight(key, tail[1])) if method == "GET" - return ok(service.start_project_action(key, tail[1], body)) if method == "POST" - end return ok(service.skills(key, tail[1])) if method == "GET" && tail.length == 2 && tail.first == "skills" end @@ -649,29 +638,10 @@ def initialize(registry:, server_url: nil, timeout: RemoteClient::DEFAULT_TIMEOU end def servers - [server_payload(local_config, local: true, healthy: true)] + + [server_payload(local_config, local: true)] + remote_configs.map { |config| server_payload(config, local: false) } end - def health(key, request = nil) - config = find_config!(key) - payload = server_payload(config, local: local_key?(key)) - if local_key?(key) - payload[:healthy] = true - return payload - end - - response = RemoteClient.new( - config, - timeout: @timeout, - token_override: remote_server_token(request) - ).request("GET", "/health") - payload[:healthy] = response[:status].to_i.between?(200, 299) - payload[:status] = response.dig(:body, "status") || response.dig(:body, :status) || response[:status] - payload[:error] = response.dig(:body, "error") || response.dig(:body, :error) unless payload[:healthy] - payload - end - def proxy(key, method, path, body, request) config = find_config!(key) raise RemoteServer::Error.new("Cannot proxy to local server", status: 400) if local_key?(config.key) @@ -734,16 +704,14 @@ def remote_server_token(request) value.empty? ? request&.[]("x-tycho-remote-server-token").to_s : value end - def server_payload(config, local:, healthy: nil) - payload = { + def server_payload(config, local:) + { key: config.key, name: config.name, url: config.url, local: local, auth_configured: !config.resolved_token.to_s.empty? } - payload[:healthy] = healthy unless healthy.nil? - payload end end @@ -760,8 +728,6 @@ class RemoteService }.freeze Error = RemoteServer::Error - PROJECT_ACTIONS = %w[deploy maintenance live].freeze - attr_reader :registry, :server_url def initialize(registry: Registry.new, server_url: nil, public_url: nil, auth_required: false, @@ -771,7 +737,7 @@ def initialize(registry: Registry.new, server_url: nil, public_url: nil, auth_re schedule_daemon_supervisor: nil, restartable: false) @registry = registry - @projects = registry.projects.map { |config| AppProject.new(config) } + @projects = registry.projects.map { |config| Project.new(config) } @agent_store = AgentStore.new(@projects) @push_subscription_store = push_subscription_store @push_notification_store = push_notification_store @@ -783,10 +749,6 @@ def initialize(registry: Registry.new, server_url: nil, public_url: nil, auth_re @restartable = restartable ? true : false end - def health - { status: "ok", agents: load_agents.length, projects: visible_projects.length } - end - def add_remote_server(body) name = body["name"].to_s.strip url = body["url"].to_s.strip @@ -795,17 +757,16 @@ def add_remote_server(body) validate_ad_hoc_remote_url!(url) config = RemoteServerConfig.new(key: "candidate", name: name, url: url.sub(%r{/+\z}, ""), token:, token_env: "") - response = RemoteClient.new(config).request("GET", "/health") + response = RemoteClient.new(config).request("GET", "/agents") unless response[:status].to_i.between?(200, 299) detail = response.dig(:body, "error") || response.dig(:body, :error) || response[:status] - raise Error.new("#{name} is not healthy: #{detail}", status: 502) + raise Error.new("#{name} rejected the agent request: #{detail}", status: 502) end stored = @registry.add_remote_server!(name:, url:) broker = RemoteBroker.new(registry: @registry, server_url: @server_url) - health_request = token.empty? ? nil : { "X-Tycho-Remote-Server-Token" => token } { - server: broker.health(stored.key, health_request), + server: broker.servers.find { |server| server[:key] == stored.key }, servers: broker.servers } rescue ConfigError => e @@ -1111,9 +1072,8 @@ def delete_attachment(id) def projects refresh_projects!(visible_projects) agents_by_project = load_agents.group_by(&:project_key) - actions = active_actions visible_projects.map do |project| - project_list_payload(project, agents: agents_by_project.fetch(project.key, []), active_action: actions[project.key]) + project_list_payload(project, agents: agents_by_project.fetch(project.key, [])) end end @@ -1121,7 +1081,7 @@ def project(key) target = find_project!(key) refresh_project!(target) agents = load_agents.select { |agent| agent.project_key == target.key } - project_detail_payload(target, agents:, active_action: active_actions[target.key]) + project_detail_payload(target, agents:) end def project_git_status(key) @@ -1209,8 +1169,7 @@ def create_welcome_project if existing refresh_project!(existing) return project_detail_payload(existing, - agents: load_agents.select { |agent| agent.project_key == existing.key }, - active_action: active_actions[existing.key]) + agents: load_agents.select { |agent| agent.project_key == existing.key }) end unless @projects.empty? @@ -1320,57 +1279,6 @@ def skills(project_key, agent_kind) } end - def project_action_preflights(project_key) - PROJECT_ACTIONS.map { |action| project_action_preflight(project_key, action) } - end - - def project_action_preflight(project_key, action_name) - project = find_project!(project_key) - action = normalize_project_action!(action_name) - refresh_project!(project) - active_action = active_actions[project.key] - checks = project_action_checks(project, active_action) - { - project: project_list_payload(project, agents: load_agents.select { |agent| agent.project_key == project.key }, - active_action:), - action: action, - label: KamalAction.label_for(action), - can_start: checks.all? { |check| check.fetch(:passed) }, - checks: checks, - consequences: project_action_consequences(action), - log_path: project.action_log_path - } - end - - def start_project_action(project_key, action_name, attrs) - project = find_project!(project_key) - action = normalize_project_action!(action_name) - unless truthy?(attrs["confirm"]) - raise Error.new("Project action requires confirm=true", status: 400) - end - - preflight = project_action_preflight(project.key, action) - unless preflight.fetch(:can_start) - failed = preflight.fetch(:checks).reject { |check| check.fetch(:passed) }.map { |check| check.fetch(:label) } - raise Error.new("Project action cannot start: #{failed.join(", ")}", status: 409) - end - - action_record = KamalAction.new( - project_key: project.key, - project_name: project.name, - project_path: project.path, - action: action.to_sym - ) - action_record.start! - actions = active_actions - actions[project.key] = action_record - save_actions(actions) - { - action: action_state_payload(project, action_record).merge(started: true), - log_path: action_record.log_path - } - end - def conversation(key) target = find_agent!(key) blocks = AgentChatLog.new(target).chat_blocks @@ -1719,7 +1627,7 @@ def schedule_daemon_supervisor end def reload_projects_from_registry! - @projects = @registry.projects.map { |config| AppProject.new(config) } + @projects = @registry.projects.map { |config| Project.new(config) } @agent_store = AgentStore.new(@projects) end @@ -1732,7 +1640,6 @@ def refresh_projects!(projects = @projects) def refresh_project!(project) project.refresh_metadata! - project.check_health! project rescue StandardError => e HQ.logger.warn("Remote") { "Project refresh failed for #{project.key}: #{e.class} - #{e.message}" } @@ -1787,39 +1694,6 @@ def find_project!(key) raise(Error.new("Unknown project: #{key}", status: 404)) end - def load_actions - return {} unless File.exist?(ACTIONS_FILE) - - FileStore.read_json(ACTIONS_FILE, fallback: []).each_with_object({}) do |hash, actions| - action = KamalAction.from_hash(hash) - actions[action.project_key] = action - end - rescue StandardError => e - HQ.logger.warn("Remote") { "Failed to load actions: #{e.message}" } - {} - end - - def save_actions(actions) - FileStore.write_json(ACTIONS_FILE, actions.values.map(&:to_hash)) - rescue StandardError => e - HQ.logger.warn("Remote") { "Failed to save actions: #{e.message}" } - end - - def active_actions - actions = load_actions - changed = false - actions.keys.each do |key| - action = actions[key] - action.poll! - next unless action.done? - - actions.delete(key) - changed = true - end - save_actions(actions) if changed - actions - end - def dispatch_agent_push_events(events, agents:) totals = { events: 0, sent: 0, failed: 0, attempted: 0 } unread_count = agents.count(&:unread?) @@ -1893,83 +1767,22 @@ def agent_push_notification_id(agent, event) ].join(":") end - def normalize_project_action!(value) - action = value.to_s.strip - unless PROJECT_ACTIONS.include?(action) - raise Error.new("Unsupported project action #{value.inspect}. Supported actions: #{PROJECT_ACTIONS.join(", ")}") - end - - action - end - - def project_action_checks(project, active_action) - [ - { - key: "kamal", - label: "Kamal deployment configured", - passed: project.apps_enabled?, - detail: project.apps_enabled? ? "Project has deployment metadata" : "Project is not configured for app actions" - }, - { - key: "action", - label: "No project action running", - passed: active_action.nil?, - detail: active_action ? "#{active_action.label} started #{time_text(active_action.started_at)}" : "No active action" - }, - { - key: "health", - label: "Health state reviewed", - passed: true, - detail: [project.health_status, project.app_status].compact.join(" / ") - }, - { - key: "git", - label: "Git state reviewed", - passed: true, - detail: project.dirty_files.to_i.positive? ? "#{project.dirty_files} dirty files" : "clean or unavailable" - } - ] - end - - def project_action_consequences(action) - { - "deploy" => [ - "Starts a detached Kamal deploy for the selected project.", - "Output is appended to the project action log." - ], - "maintenance" => [ - "Puts the app into Kamal maintenance mode.", - "Root health can return 503 while the healthcheck path remains healthy." - ], - "live" => [ - "Removes Kamal maintenance mode and resumes live traffic.", - "Health is refreshed after the action completes." - ] - }.fetch(action) - end - - def project_list_payload(project, agents:, active_action:) + def project_list_payload(project, agents:) { key: project.key, name: project.name, group: empty_to_nil(project.group), path: project.path, - status: project_status(project, active_action), - apps_enabled: project.apps_enabled?, - app_status: project.app_status, - health_status: project.health_status, - latency_ms: project.response_time, - maintenance: maintenance?(project), - action_state: action_state_for(project, active_action), + status: project.status, agent_count: agents.length, unread_agent_count: agents.count(&:unread?), running_agent_count: agents.count(&:running?) } end - def project_detail_payload(project, agents:, active_action:) + def project_detail_payload(project, agents:) recent_agent = agents.max_by(&:last_activity_at) - project_list_payload(project, agents:, active_action:).merge( + project_list_payload(project, agents:).merge( pr_url: project.pr_url, pr_number: project.pr_number, branch: project.branch, @@ -1981,83 +1794,12 @@ def project_detail_payload(project, agents:, active_action:) agent: project.config.agent, model: project.config.model, reasoning_effort: project.config.reasoning_effort, - service: project.service, - image: project.image, - hosts: Array(project.hosts), - proxy: project.proxy_host, - healthcheck_path: project.healthcheck_path, - kamal_version: project.kamal_version, - rails_version: project.rails_version, agent_template_summaries: agent_template_summaries(project), managed_agent_count: agents.length, - action_log_path: project.action_log_path, recent_agent_summary: recent_agent ? recent_agent_payload(recent_agent) : nil ) end - def project_status(project, active_action) - return "#{active_action.label} running" if active_action - return "maintenance" if maintenance?(project) - return project.health_status unless project.health_status.to_s.empty? || project.health_status == "pending" - return project.app_status unless project.app_status.to_s.empty? || project.app_status == "pending" - - "configured" - end - - def maintenance?(project) - project.health_status == "maintenance" || project.app_status == "maintenance" - end - - def action_state_for(project, active_action) - return action_state_payload(project, active_action) if active_action - - last_action_state_from_log(project) - end - - def action_state_payload(_project, action) - { - action: action.action.to_s, - label: action.label, - status: "running", - started_at: action.started_at&.iso8601, - log_path: action.log_path - } - end - - def last_action_state_from_log(project) - return nil unless File.exist?(project.action_log_path) - - content = File.read(project.action_log_path) - matches = content.to_enum(:scan, /^=== \[(.+?)\] ([a-z_]+) ===$/).map { Regexp.last_match } - last = matches.last - return nil unless last - - action = last[2] - { - action: action, - label: KamalAction.label_for(action), - status: action_status_label(project, content[last.begin(0)..]), - started_at: Time.parse(last[1]).iso8601, - log_path: project.action_log_path - } - rescue StandardError - nil - end - - def action_status_label(project, section) - status_path = "#{project.action_log_path}.status" - if File.exist?(status_path) - return File.read(status_path).to_i.zero? ? "success" : "failed" - end - - return "failed" if section.match?(/\bERROR\b|\bexit status:\s*[1-9]\d*\b|failed to/i) - return "success" if section.match?(/\bexit status 0\b|successful/i) - - "unknown" - rescue StandardError - "unknown" - end - def agent_template_summaries(project) project.agent_templates.map do |template| { @@ -2116,9 +1858,6 @@ def harness_readiness def tool_readiness [ - resolver_payload("mise", ExecutableResolver.resolve_tool("mise")), - resolver_payload("kamal", ExecutableResolver.resolve("kamal")), - kamal_project_payload, resolver_payload("tailscale", ExecutableResolver.resolve_tool("tailscale")) ] end @@ -2167,34 +1906,6 @@ def executable_detail(resolution) end end - def kamal_project_payload - app_projects = @projects.select(&:apps_enabled?) - ready_projects = app_projects.select { |project| KamalAction.project_ready?(project.path) } - ready = app_projects.empty? || ready_projects.length == app_projects.length - { - name: "kamal-projects", - ready: ready, - detail: kamal_project_detail(app_projects, ready_projects), - commands: ["bin/kamal", "bundle exec kamal"], - project_count: app_projects.length, - ready_project_count: ready_projects.length - } - end - - def kamal_project_detail(app_projects, ready_projects) - return "no app projects configured" if app_projects.empty? - - ready_count = ready_projects.length - total = app_projects.length - if ready_count == total - sources = ready_projects.map { |project| KamalAction.project_readiness_source(project.path) }.compact.uniq - return "#{ready_count}/#{total} app project(s) ready via #{sources.join(", ")}" - end - return "missing project bin/kamal or Kamal gem dependency for #{total} app project(s)" if ready_count.zero? - - "#{ready_count}/#{total} app project(s) ready; #{total - ready_count} missing project Kamal" - end - def readiness_command_for(parts) values = Array(parts).map(&:to_s).reject(&:empty?) return nil if values.empty? @@ -2234,7 +1945,6 @@ def log_summary(agents) root: LOGS_DIR, agent_runs: agents.sum(&:run_count), agent_log_files: Dir.glob(File.join(AGENT_LOGS_DIR, "*")).count { |path| File.file?(path) }, - project_action_logs: Dir.glob(File.join(PROJECT_LOGS_DIR, "*", "action.log")).length, project_archive_dir: PROJECT_ARCHIVE_DIR } rescue StandardError @@ -2242,7 +1952,6 @@ def log_summary(agents) root: LOGS_DIR, agent_runs: agents.sum(&:run_count), agent_log_files: 0, - project_action_logs: 0, project_archive_dir: PROJECT_ARCHIVE_DIR } end @@ -2300,7 +2009,6 @@ def agent_attrs(target, attrs, project:, creating:) def project_attrs(target, attrs) immutable_project_field!(attrs, "key", target.key) immutable_project_field!(attrs, "path", target.path) - immutable_project_field!(attrs, "apps", target.apps_enabled?) immutable_project_field!(attrs, "pr_url", target.pr_url.to_s) name = attrs.key?("name") ? attrs["name"].to_s.strip : target.name.to_s @@ -2562,7 +2270,6 @@ def token_recommended? def safety_guidance lines = [ - "Deploy and maintenance actions require confirmation.", "Running agents cannot be edited.", "Existing workspaces are read-only in the mobile UI." ] diff --git a/lib/hq/remote_ui.rb b/lib/hq/remote_ui.rb index 0f625e2..09e3247 100644 --- a/lib/hq/remote_ui.rb +++ b/lib/hq/remote_ui.rb @@ -70,7 +70,7 @@ def self.manifest_json version = asset_version JSON.pretty_generate( { - name: "Tycho - its Factorio for agents", + name: "Tycho - Factorio for Agents", short_name: "Tycho", description: "Remote control for managed HQ agents and projects.", id: "/", diff --git a/lib/hq/remote_ui/assets/app.js b/lib/hq/remote_ui/assets/app.js index e300c1e..99d9e3e 100644 --- a/lib/hq/remote_ui/assets/app.js +++ b/lib/hq/remote_ui/assets/app.js @@ -1,5 +1,4 @@ const TOP_TABS = ["now", "agents", "settings"]; -const PROJECT_ACTIONS = ["deploy", "maintenance", "live"]; const BUILTIN_AGENT_HARNESSES = ["codex", "claude", "opencode"]; const DEFAULT_REFRESH_INTERVALS = { runningAgentMs: 2_000, @@ -457,7 +456,6 @@ const state = { openedAttachmentLinks: {}, conversationTailMarkers: {}, skills: {}, - preflights: {}, pendingFormKeys: new Set(), scheduleMessages: {}, filters: { @@ -692,7 +690,6 @@ function resetActiveServerData() { state.openedAttachmentLinks = {}; state.conversationTailMarkers = {}; state.skills = {}; - state.preflights = {}; state.scheduleMessages = {}; state.bulkArchiveSelection.clear(); state.bulkArchiveMode = false; @@ -775,12 +772,9 @@ async function saveRemoteServerToken(key, value) { if (!serverKey || serverKey === "local") throw new Error("Choose a remote server"); if (!tokenValue) throw new Error("Enter a remote token"); - const data = await brokerGetWithHeaders(`/servers/${encodeURIComponent(serverKey)}/health`, { + await brokerGetWithHeaders(`/servers/${encodeURIComponent(serverKey)}/proxy/agents`, { "X-Tycho-Remote-Server-Token": tokenValue, }); - if (data.server && data.server.healthy === false) { - throw new Error(data.server.error || "Remote server rejected broker credentials"); - } storeRemoteServerToken(serverKey, tokenValue); state.serverTokenFormKey = ""; if (activeServerKey() === serverKey) { @@ -973,7 +967,7 @@ function scrollPageToTop() { function currentTopTab(route) { if (route.type === "tab") return route.tab; - if (route.type === "project" || route.type === "projectDiff" || route.type === "projectForm" || route.type === "guard") return "agents"; + if (route.type === "project" || route.type === "projectDiff" || route.type === "projectForm") return "agents"; if (route.type === "agentForm") return "agents"; if (route.type === "agent" || route.type === "agentSummary" || route.type === "agentAttachment" || route.type === "agentPullRequests" || route.type === "agentArchive" || route.type === "attachment") return "agents"; if (route.type === "hiddenSettings") return "settings"; @@ -1132,7 +1126,7 @@ async function ensureRouteData(options = {}) { } await ensureAttachmentPreview(route.id, options.forceAttachment); } - if (route.type === "project" || route.type === "guard") { + if (route.type === "project") { await ensureProject(route.key, true); } if (route.type === "projectDiff") { @@ -1145,9 +1139,6 @@ async function ensureRouteData(options = {}) { workspaceAgent?.project_key ? ensureProject(workspaceAgent.project_key) : null, ].filter(Boolean)); } - if (route.type === "guard") { - await ensurePreflight(route.key, route.action, options.forcePreflight); - } if (route.type === "scheduleMessage") { await ensureScheduleMessage(route.key, options.forceScheduleMessage || options.force); } @@ -1358,13 +1349,6 @@ async function ensureSkillsForProject(projectKey, harness, options = {}) { state.skills[key] = data.skills || []; } -async function ensurePreflight(projectKey, action, force = false) { - const key = preflightKey(projectKey, action); - if (!force && state.preflights[key]) return; - const data = await apiGet(`/projects/${encodeURIComponent(projectKey)}/actions/${encodeURIComponent(action)}`); - state.preflights[key] = data; -} - async function ensureHiddenSettings(force = false) { if (!force && state.hiddenSettings) return; const data = await apiGet("/settings/hidden"); @@ -1487,8 +1471,6 @@ function render() { renderAgentForm(route); } else if (route.type === "agentArchive") { renderAgentArchive(route.key); - } else if (route.type === "guard") { - renderGuardedAction(route.key, route.action); } else if (route.type === "scheduleForm") { renderScheduleForm(route); } else if (route.type === "scheduleMessage") { @@ -1903,11 +1885,6 @@ function restorePageScroll(scroll) { } function syncViewControls() { - const confirm = document.getElementById("confirm-action"); - const submit = document.querySelector("#guard-form button[type='submit']"); - if (confirm && submit) { - submit.disabled = confirm.disabled || !confirm.checked; - } } function syncAgentDockLayout() { @@ -1963,7 +1940,7 @@ function renderNow() { ? `
${waiting.length}items waiting on you
-

Answer paused agents first. Running work and project health stay visible below.

+

Answer paused agents first. Running work and project context stay visible below.

` @@ -2602,7 +2579,6 @@ function renderSetup() { ${copyableKv("Root", setup.logs?.root)} ${copyableKv("Agent runs", setup.logs?.agent_runs)} ${copyableKv("Agent logs", setup.logs?.agent_log_files)} - ${copyableKv("Action logs", setup.logs?.project_action_logs)}
@@ -3739,13 +3715,8 @@ function renderProject(key) {
-
${escapeHtml(capitalize(project.status || "configured"))}${escapeHtml(project.health_status || "health unknown")} / ${latencyText(project)}
- ${escapeHtml(project.maintenance ? "Maintenance" : project.health_status || "Status")} -
-
- -
Current action${escapeHtml(actionText(project.action_state))}
- ${escapeHtml(project.action_state?.status || "None")} +
${escapeHtml(capitalize(project.status || "configured"))}${escapeHtml(project.path || "workspace n/a")}
+ ${escapeHtml(project.status || "configured")}
@@ -3759,31 +3730,12 @@ function renderProject(key) {
${renderProjectAgents(project, agents)}
- Deploy details + Templates and workspace
- ${copyableKv("Service", project.service)} - ${copyableKv("Image", project.image)} - ${copyableKv("Hosts", (project.hosts || []).join(", ") || "n/a")} - ${copyableKv("Proxy", project.proxy)} - ${copyableKv("Action log", project.action_log_path)} - ${copyableKv("Health path", project.healthcheck_path)} -
-
-
- Versions and templates -
- ${copyableKv("Kamal", project.kamal_version)} - ${copyableKv("Rails", project.rails_version)} ${copyableKv("Templates", (project.agent_template_summaries || []).map((item) => item.name).join(", ") || "n/a")} ${copyableKv("Path", project.path)}
-
- Guarded actions -
- ${PROJECT_ACTIONS.map((action) => ``).join("")} -
-
`); } @@ -4074,11 +4026,6 @@ function renderProjectForm(key) {
PR URL${escapeHtml(project.pr_url || "n/a")}
Read only -
- -
Kamal deploy${escapeHtml(kamalDeploymentText(project))}
- ${project.apps_enabled ? "Available" : "Unavailable"} -
@@ -4118,66 +4065,6 @@ function renderProjectForm(key) { `); } -function renderGuardedAction(projectKey, action) { - const project = findProject(projectKey); - const preflight = state.preflights[preflightKey(projectKey, action)]; - setHeader(`${capitalize(action)} confirmation`, project ? `${project.name || project.key} / guarded action` : "guarded action", "folder"); - - if (!project && initialDataPending()) { - replaceView(emptyState("Loading project", "Fetching project state before the guarded action can run.")); - return; - } - - if (!project) { - replaceView(emptyState("Project not found", "Return to the Agents tab and choose an active project.")); - return; - } - - if (!preflight) { - replaceView(emptyState("Loading preflight", "Checking project state before the guarded action can run.")); - ensurePreflight(projectKey, action).then(render).catch((error) => setConnection(error.message)); - return; - } - - replaceView(` -
-
- ${escapeHtml(project.name || project.key)} - ${escapeHtml(project.status || "status unknown")} / ${escapeHtml(project.health_status || "health unknown")} -
-

${escapeHtml((preflight.consequences || []).join(" "))}

-
-
-
- - ${(preflight.checks || []).map((check) => ` -
- -
${escapeHtml(check.label)}${escapeHtml(check.detail || "")}
- ${check.passed ? "Pass" : "Block"} -
- `).join("")} -
-
-
- Expected flow and log behavior -
- ${kv("Action", preflight.label)} - ${kv("Log", preflight.log_path)} - ${kv("Health", project.health_status)} - ${kv("Git", project.dirty ? `${project.dirty_files} dirty files` : "clean or unavailable")} -
-
-
- - -
- `); -} - function renderProjectAgents(project, agents) { const countLabel = `${agents.length} ${agents.length === 1 ? "agent" : "agents"}`; return ` @@ -4206,7 +4093,7 @@ function renderAgentSection(title, subtitle, agents, emptyText) { function renderAgentGroup(projectKey, agents) { const project = findProject(projectKey); const projectName = project?.name || projectKey; - const projectMeta = project ? `${agents.length} agents / ${project.health_status || project.status || "configured"}` : `${agents.length} agents`; + const projectMeta = project ? `${agents.length} agents / ${project.status || "configured"}` : `${agents.length} agents`; return `
@@ -5592,12 +5479,8 @@ function projectMatches(project, query) { project.key, project.group, project.status, - project.health_status, - project.app_status, project.branch, project.commit_hash, - project.service, - project.image, ].some((value) => String(value || "").toLowerCase().includes(query)); } @@ -6562,10 +6445,6 @@ function skillKey(projectKey, agent) { return `${projectKey || ""}:${agent || ""}`; } -function preflightKey(projectKey, action) { - return `${projectKey || ""}:${action || ""}`; -} - function groupBy(list, callback) { return list.reduce((groups, item) => { const key = callback(item); @@ -6844,10 +6723,6 @@ function shouldAutoScrollAgentConversation(agentKey) { } function projectStatusClass(project) { - if (project.maintenance) return "need"; - if (project.action_state?.status === "running") return "running"; - if (String(project.health_status || "").includes("down") || String(project.health_status || "").includes("error")) return "fail"; - if (project.health_status === "healthy" || project.app_status === "running") return "done"; return "info"; } @@ -6863,19 +6738,6 @@ function agentListSubtextHtml(agent) { return [relativeTimeHtml(agentUpdatedAt(agent)), escapeHtml(agentMeta(agent))].filter(Boolean).join(" / "); } -function actionText(actionState) { - if (!actionState) return "No active or recent action"; - return [actionState.label, actionState.status, timeShort(actionState.started_at)].filter(Boolean).join(" / "); -} - -function kamalDeploymentText(project) { - return project?.apps_enabled ? "Can be deployed with Kamal" : "Kamal deploy actions are not configured"; -} - -function latencyText(project) { - return project.latency_ms === null || project.latency_ms === undefined ? "latency n/a" : `${project.latency_ms}ms`; -} - function blockLabel(block) { if (block.kind === "run_summary") return "summary"; if (block.tool_name) return block.tool_name; @@ -7159,7 +7021,7 @@ async function waitForRemoteRestart(timeoutMs = 15_000) { await delay(500); while (Date.now() < deadline) { try { - await apiGet("/health"); + await apiGet("/setup"); return; } catch (_error) { await delay(700); @@ -7244,7 +7106,7 @@ els.back.addEventListener("click", () => { else if (route.type === "projectForm") navigate({ type: "project", key: route.key }); else if (route.type === "projectDiff") navigate(route.backTo || { type: "project", key: route.key }); else if (route.type === "project" && route.backTo) navigate(route.backTo); - else if (route.type === "project" || route.type === "guard") navigate({ type: "tab", tab: "agents" }); + else if (route.type === "project") navigate({ type: "tab", tab: "agents" }); else if (route.type === "scheduleForm") navigate({ type: "tab", tab: "now" }); else if (route.type === "scheduleMessage") navigate({ type: "tab", tab: "now" }); else if (route.type === "hiddenSettings") navigate({ type: "tab", tab: "settings" }); @@ -7636,12 +7498,6 @@ els.view.addEventListener("click", (event) => { return; } - const guardButton = event.target.closest("[data-guard-action]"); - if (guardButton) { - navigate({ type: "guard", key: guardButton.dataset.projectKey, action: guardButton.dataset.guardAction }); - return; - } - const newScheduleButton = event.target.closest("[data-new-schedule]"); if (newScheduleButton) { navigate({ type: "scheduleForm", mode: "create" }); @@ -7950,11 +7806,6 @@ els.view.addEventListener("input", (event) => { return; } - if (event.target.id === "confirm-action") { - const submit = document.querySelector("#guard-form button[type='submit']"); - if (submit) submit.disabled = !event.target.checked; - } - if (event.target.closest("#inquiry-form")) { syncViewControls(); } @@ -8116,16 +7967,6 @@ els.view.addEventListener("submit", (event) => { }, { form }); } - if (event.target.id === "guard-form") { - const button = event.submitter; - const projectKey = button?.dataset.projectKey; - const action = button?.dataset.action; - if (!projectKey || !action) return; - mutate(async () => { - await apiPost(`/projects/${encodeURIComponent(projectKey)}/actions/${encodeURIComponent(action)}`, { confirm: true }); - navigate({ type: "project", key: projectKey }); - }, { form: event.target }); - } if (event.target.id === "project-form") { const form = event.target; diff --git a/lib/hq/remote_ui/templates/index.html.erb b/lib/hq/remote_ui/templates/index.html.erb index 582feb4..4b5f512 100644 --- a/lib/hq/remote_ui/templates/index.html.erb +++ b/lib/hq/remote_ui/templates/index.html.erb @@ -6,9 +6,9 @@ - + - Tycho - its Factorio for agents + Tycho - Factorio for Agents @@ -38,7 +38,7 @@
-

Tycho - its Factorio for agents

+

Tycho - Factorio for Agents

Connecting

diff --git a/lib/hq/ui/components/project_editor.rb b/lib/hq/ui/components/project_editor.rb index 8bbf46d..32a3634 100644 --- a/lib/hq/ui/components/project_editor.rb +++ b/lib/hq/ui/components/project_editor.rb @@ -2,7 +2,6 @@ require "bubbles" require_relative "../../harness_registry" -require_relative "../../registry" module HQ module UI @@ -47,10 +46,6 @@ def agent_index agent_options.index(@selected_agent) || 0 end - def kamal_app_detected? - Registry.kamal_app?(@path_input.value.strip) - end - def existing_groups @existing_groups end diff --git a/lib/hq/ui/rendering/layout.rb b/lib/hq/ui/rendering/layout.rb index a82f1c7..a95cc8f 100644 --- a/lib/hq/ui/rendering/layout.rb +++ b/lib/hq/ui/rendering/layout.rb @@ -101,12 +101,11 @@ def list_body_height def project_table_widths total = table_content_width - fixed = { agents: 3, kamal: 8, rails: 8, status: 14 } - remaining = [total - fixed.values.sum - 5, 30].max + fixed = { agents: 3, status: 14 } + remaining = [total - fixed.values.sum - 3, 30].max project = [(remaining * 0.25).floor, 12].max git = [remaining - project, 16].max - { project: project, git: git, status: fixed[:status], agents: fixed[:agents], kamal: fixed[:kamal], - rails: fixed[:rails] } + { project: project, git: git, status: fixed[:status], agents: fixed[:agents] } end def agent_table_widths diff --git a/lib/hq/ui/rendering/project_status_badge.rb b/lib/hq/ui/rendering/project_status_badge.rb index 857da8c..6c663f1 100644 --- a/lib/hq/ui/rendering/project_status_badge.rb +++ b/lib/hq/ui/rendering/project_status_badge.rb @@ -5,98 +5,23 @@ module HQ module UI module Rendering - # Resolves what a project's status cell should show right now. - # - # The same decision tree (active action > recent result > steady state) - # drove four near-duplicate methods in StatusHelpers. Callers build a - # badge once and ask for the plain text, the spinner hint, or the style - # key separately. class ProjectStatusBadge - SUCCESS_RESULT_TTL = 15 # seconds - FAILED_RESULT_TTL = 300 # seconds - - Kind = Module.new - ACTIVE = :active - RESULT = :result STEADY = :steady - attr_reader :kind, :text, :style_key, :spinner, :action_label - - def self.for(project, action:, result:, now: Time.now, steady: :app) - return active_badge(action) if action - return result_badge(project, result) if result_active?(result, now:) - - steady == :health ? steady_health_badge(project) : steady_badge(project) - end - - def self.result_active?(result, now: Time.now) - return false unless result - - ttl = result[:success] ? SUCCESS_RESULT_TTL : FAILED_RESULT_TTL - now - result[:at] < ttl - end - - def self.steady_health_badge(project) - text = health_text(project) - text += " #{project.response_time}ms" if project.response_time - new(kind: STEADY, text: text, style_key: health_style_key(project)) - end - - def self.health_text(project) - case project.health_status - when "healthy" then "#{Styles::MARKERS[:dot]} up" - when "maintenance" then "#{Styles::MARKERS[:triangle]} maint" - when "pending" then "#{Styles::MARKERS[:circle]} ..." - when "not checked" then "#{Styles::MARKERS[:circle]} n/a" - else "#{Styles::MARKERS[:dot]} down" - end - end - - def self.health_style_key(project) - case project.health_status - when "healthy" then :healthy - when "maintenance" then :maintenance - when "pending" then :pending - when "not checked" then :dim - else :fail - end - end - - def self.active_badge(action) - new(kind: ACTIVE, text: action.label, style_key: :dim, spinner: true, action_label: action.label) - end - - def self.result_badge(project, result) - success = result[:success] - app_healthy = project.health_status == "healthy" || project.app_status == "running" - mark = success ? Styles::MARKERS[:check] : app_healthy ? Styles::MARKERS[:bang] : Styles::MARKERS[:cross] - new( - kind: RESULT, - text: "#{mark} #{result[:action]}", - style_key: success ? :success : app_healthy ? :warning : :fail, - spinner: false - ) - end + attr_reader :kind, :text, :style_key - def self.steady_badge(project) - case project.app_status - when "running" then new(kind: STEADY, text: "#{Styles::MARKERS[:dot]} running", style_key: :healthy) - when "maintenance" then new(kind: STEADY, text: "#{Styles::MARKERS[:triangle]} maintenance", style_key: :maintenance) - when "pending" then new(kind: STEADY, text: "#{Styles::MARKERS[:circle]} ...", style_key: :pending) - else new(kind: STEADY, text: "#{Styles::MARKERS[:dot]} #{project.app_status}", style_key: :fail) - end + def self.for(_project) + new(kind: STEADY, text: "#{Styles::MARKERS[:dot]} configured", style_key: :healthy) end - def initialize(kind:, text:, style_key:, spinner: false, action_label: nil) + def initialize(kind:, text:, style_key:) @kind = kind @text = text @style_key = style_key - @spinner = spinner - @action_label = action_label end def spinner? - @spinner + false end end end diff --git a/lib/hq/ui/rendering/status_helpers.rb b/lib/hq/ui/rendering/status_helpers.rb index 94be18c..955538d 100644 --- a/lib/hq/ui/rendering/status_helpers.rb +++ b/lib/hq/ui/rendering/status_helpers.rb @@ -8,13 +8,8 @@ module Rendering module StatusHelpers private - def project_status_badge(project, steady: :app) - ProjectStatusBadge.for( - project, - action: @actions[project.key], - result: @action_results[project.key], - steady: steady - ) + def project_status_badge(project) + ProjectStatusBadge.for(project) end def style_for_badge_key(key) @@ -30,66 +25,42 @@ def style_for_badge_key(key) end end - def plain_health_text(project) - ProjectStatusBadge.health_text(project) - end - - def health_style_for(project) - style_for_badge_key(ProjectStatusBadge.health_style_key(project)) - end - - def health_text(project) - health_style_for(project).render(plain_health_text(project)) - end - def render_badge_text(badge) - badge.spinner? ? "#{@spinner.view} #{badge.action_label}" : badge.text + badge.text end def plain_status_text_for(project) - badge = project_status_badge(project) - return render_badge_text(badge) unless badge.kind == ProjectStatusBadge::RESULT - - # status-text variant uses "complete"/"failed" suffixes instead of bare action name - result = @action_results[project.key] - result[:success] ? "#{Styles::MARKERS[:check]} #{result[:action]} complete" : "#{Styles::MARKERS[:cross]} #{result[:action]} failed" + render_badge_text(project_status_badge(project)) end def status_text_for(project) - app_status_style_for(project).render(plain_status_text_for(project)) + project_status_style_for(project).render(plain_status_text_for(project)) end - def app_status_style_for(project) + def project_status_style_for(project) style_for_badge_key(project_status_badge(project).style_key) end def plain_status_text_for_row(project) - render_badge_text(project_status_badge(project, steady: :health)) + render_badge_text(project_status_badge(project)) end def project_status_styled_cell(project, width) - return dim_style.render(fit_cell("--", width)) unless project.apps_enabled? - - badge = project_status_badge(project, steady: :health) + badge = project_status_badge(project) text = render_badge_text(badge) pad_visible(style_for_badge_key(badge.style_key).render(text), width) end PROJECT_STATUS_META = { - healthy: { label: "up", icon: Styles::STATUS_ICONS[:healthy], style: :healthy }, - maintenance: { label: "maint", icon: Styles::STATUS_ICONS[:maintenance], style: :maintenance }, - warning: { label: "action failed", icon: Styles::STATUS_ICONS[:warning], style: :warning }, - fail: { label: "down", icon: Styles::STATUS_ICONS[:fail], style: :fail }, + healthy: { label: "configured", icon: Styles::STATUS_ICONS[:healthy], style: :healthy }, pending: { label: "pending", icon: Styles::STATUS_ICONS[:pending], style: :pending }, - dim: { label: "not app", icon: Styles::STATUS_ICONS[:dim], style: :dim } + dim: { label: "workspace", icon: Styles::STATUS_ICONS[:dim], style: :dim } }.freeze - PROJECT_STATUS_LEGEND_KEYS = %i[healthy maintenance warning fail pending dim].freeze + PROJECT_STATUS_LEGEND_KEYS = %i[healthy pending dim].freeze def styled_project_status_icon(project, spinner: false) - return project_status_meta_style(:dim).render(project_status_icon(:dim)) unless project.apps_enabled? - - badge = project_status_badge(project, steady: :health) + badge = project_status_badge(project) glyph = spinner && badge.spinner? ? @spinner.view : project_status_icon(badge.style_key) project_status_meta_style(badge.style_key).render(glyph) end @@ -252,22 +223,6 @@ def project_git_styled_cell(project, width) pad_visible(styled, width) end - def decorate_version(current, latest) - value = current || "n/a" - return outdated_style.render(value) if minor_outdated?(current, latest) - - value - end - - def minor_outdated?(current, latest) - return false unless current && latest - - current_parts = current.split(".").map(&:to_i) - latest_parts = latest.split(".").map(&:to_i) - return false if current_parts.length < 2 || latest_parts.length < 2 - - current_parts[0] < latest_parts[0] || (current_parts[0] == latest_parts[0] && current_parts[1] < latest_parts[1]) - end end end end diff --git a/lib/hq/ui/rendering/styles.rb b/lib/hq/ui/rendering/styles.rb index f63a213..098941b 100644 --- a/lib/hq/ui/rendering/styles.rb +++ b/lib/hq/ui/rendering/styles.rb @@ -9,10 +9,7 @@ module Styles ICONS = { agent: "\u{f06a9}", project: "\u{f07b}", - web_project: "\u{f059f}", group: "\u{f0849}", - kamal: "\u{f1382}", - rails: "\u{e73b}", pr: "\u{ea64}", github: "\u{ea84}", branch: "\u{e725}", @@ -167,10 +164,6 @@ def summary_style @summary_style ||= Lipgloss::Style.new.italic(true).foreground(COLORS[:text_muted]) end - def outdated_style - @outdated_style ||= Lipgloss::Style.new.bold(true).foreground(COLORS[:warning]) - end - def confirm_style @confirm_style ||= Lipgloss::Style.new.bold(true).foreground(COLORS[:highlight]) end diff --git a/lib/hq/ui/rendering/text_helpers.rb b/lib/hq/ui/rendering/text_helpers.rb index badc36e..6680d77 100644 --- a/lib/hq/ui/rendering/text_helpers.rb +++ b/lib/hq/ui/rendering/text_helpers.rb @@ -13,8 +13,6 @@ def icon_label(kind, value, label: nil) end def icon_for(kind, value) - return Styles::ICONS[:web_project] if kind == :project && value.respond_to?(:apps_enabled?) && value.apps_enabled? - Styles::ICONS[kind] end diff --git a/lib/hq/ui/rendering/views.rb b/lib/hq/ui/rendering/views.rb index 0993f77..23a8e76 100644 --- a/lib/hq/ui/rendering/views.rb +++ b/lib/hq/ui/rendering/views.rb @@ -107,7 +107,7 @@ def onboarding_option_shortcut(key) def config_error_view [ - title_style.render("Tycho - Ops Cockpit"), + title_style.render("Tycho - Factorio for Agents"), "", fail_style.render(" Configuration error"), " #{@config_error}", @@ -221,11 +221,6 @@ def project_editor_body width: field_width ) - detected = @project_editor.kamal_app_detected? - apps_info_line = dim_style.render( - " Kamal app: #{detected ? "detected (config/deploy.yml found)" : "not detected"}" - ) - submit_label = "Create Project" submit_style = @project_editor.submit_focused? ? selected_button_style : button_style submit_line = " #{submit_style.render(" #{submit_label} ")}" @@ -247,8 +242,6 @@ def project_editor_body body_lines << "" body_lines.concat(agent_lines) body_lines << "" - body_lines << apps_info_line - body_lines << "" if @project_editor.error_message body_lines << fail_style.render(" #{@project_editor.error_message}") body_lines << "" @@ -472,12 +465,9 @@ def header screen == @screen ? selected_tab_style.render(" #{label} ") : tab_style.render(" #{label} ") end.join(" ") - latest = dim_style.render("Latest: #{icon_label(:kamal, - @latest_kamal || "?")} #{Styles::MARKERS[:bullet_sep]} #{icon_label(:rails, - @latest_rails || "?")}") [ - title_style.render("Tycho - Ops Cockpit"), - join_left_right(nav, latest) + title_style.render("Tycho - Factorio for Agents"), + nav ].join("\n") end @@ -825,13 +815,7 @@ def footer return footer_confirm_style.render(truncate("Rebuild conversation and summary? (y/n)", footer_content_width)) end - project = selected_project - action_name = if @confirming == :maintenance && project&.app_status == "maintenance" - "Set live" - else - @confirming.to_s - end - return footer_confirm_style.render(truncate("#{action_name} #{project&.name}? (y/n)", footer_content_width)) + return footer_confirm_style.render(truncate("#{@confirming}? (y/n)", footer_content_width)) end refresh_text = @last_refresh ? "Last refresh: #{@last_refresh.strftime("%H:%M:%S")}" : "Loading..." @@ -839,7 +823,7 @@ def footer sidebar_hint_text = "#{Styles::KEYS[:ctrl]}B: #{sidebar_visible? ? "hide" : "show"} sidebar" hint = case @screen when :agents then "#{Styles::KEYS[:tab]}/1-3: switch #{Styles::MARKERS[:bullet_sep]} j/k: nav #{Styles::MARKERS[:bullet_sep]} v: detail #{Styles::MARKERS[:bullet_sep]} #{sidebar_hint_text} #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}G: term #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}T: agent term #{Styles::MARKERS[:bullet_sep]} c/C: chat/clone #{Styles::MARKERS[:bullet_sep]} s: start #{Styles::MARKERS[:bullet_sep]} R: rerun #{Styles::MARKERS[:bullet_sep]} t: stop #{Styles::MARKERS[:bullet_sep]} e: edit #{Styles::MARKERS[:bullet_sep]} x: delete #{Styles::MARKERS[:bullet_sep]} l/L: log #{Styles::MARKERS[:bullet_sep]} r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}R: restart #{Styles::MARKERS[:bullet_sep]} q: quit" - when :projects then "#{Styles::KEYS[:tab]}/1-3: switch #{Styles::MARKERS[:bullet_sep]} j/k: nav #{Styles::MARKERS[:bullet_sep]} v: detail #{Styles::MARKERS[:bullet_sep]} #{sidebar_hint_text} #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}G: term #{Styles::MARKERS[:bullet_sep]} n: new agent #{Styles::MARKERS[:bullet_sep]} N: new project #{Styles::MARKERS[:bullet_sep]} d: deploy #{Styles::MARKERS[:bullet_sep]} m: maint #{Styles::MARKERS[:bullet_sep]} x: archive #{Styles::MARKERS[:bullet_sep]} l: log #{Styles::MARKERS[:bullet_sep]} h: health #{Styles::MARKERS[:bullet_sep]} r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}R: restart #{Styles::MARKERS[:bullet_sep]} q: quit" + when :projects then "#{Styles::KEYS[:tab]}/1-3: switch #{Styles::MARKERS[:bullet_sep]} j/k: nav #{Styles::MARKERS[:bullet_sep]} v: detail #{Styles::MARKERS[:bullet_sep]} #{sidebar_hint_text} #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}G: term #{Styles::MARKERS[:bullet_sep]} n: new agent #{Styles::MARKERS[:bullet_sep]} N: new project #{Styles::MARKERS[:bullet_sep]} x: archive #{Styles::MARKERS[:bullet_sep]} r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}R: restart #{Styles::MARKERS[:bullet_sep]} q: quit" when :schedules then "#{Styles::KEYS[:tab]}/1-3: switch #{Styles::MARKERS[:bullet_sep]} j/k: nav #{Styles::MARKERS[:bullet_sep]} v: detail #{Styles::MARKERS[:bullet_sep]} #{sidebar_hint_text} #{Styles::MARKERS[:bullet_sep]} r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}R: restart #{Styles::MARKERS[:bullet_sep]} q: quit" end hint = "esc: close #{Styles::MARKERS[:bullet_sep]} #{hint}" if overlay_open? @@ -1107,23 +1091,9 @@ def project_detail_text lines.concat(project_hero_block(project)) lines << "" lines << divider - if project.apps_enabled? - lines << "" - lines.concat(project_service_health_block(project)) - end - lines << "" - lines << divider lines << "" lines.concat(project_footer_meta(project)) - action_block = project_action_block(project) - unless action_block.empty? - lines << "" - lines << divider - lines << "" - lines.concat(action_block) - end - recent = project_recent_agent_block(project) unless recent.empty? lines << "" @@ -1135,7 +1105,7 @@ def project_detail_text lines << "" lines << divider lines << "" - lines << footer_style.render("d: deploy #{Styles::MARKERS[:bullet_sep]} m: maint #{Styles::MARKERS[:bullet_sep]} h: health #{Styles::MARKERS[:bullet_sep]} r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}G: term #{Styles::MARKERS[:bullet_sep]} n: new agent #{Styles::MARKERS[:bullet_sep]} l: log #{Styles::MARKERS[:bullet_sep]} x: archive") + lines << footer_style.render("r: refresh #{Styles::MARKERS[:bullet_sep]} #{Styles::KEYS[:ctrl]}G: term #{Styles::MARKERS[:bullet_sep]} n: new agent #{Styles::MARKERS[:bullet_sep]} x: archive") lines.join("\n") end @@ -1151,9 +1121,8 @@ def empty_project_detail_text def project_hero_block(project) width = detail_content_width lines = [] - icon_key = project.apps_enabled? ? :web_project : :project - name_line = "#{Styles::ICONS[icon_key]} #{project.name}" - status = project.apps_enabled? ? status_text_for(project) : dim_style.render("not an app") + name_line = "#{Styles::ICONS[:project]} #{project.name}" + status = status_text_for(project) status_width = visible_width(status) name_budget = [width - status_width - 2, 10].max name_line = truncate_display(name_line, name_budget) @@ -1188,38 +1157,6 @@ def project_chip_row(project) parts.join(" ") end - def project_service_health_block(project) - width = detail_content_width - col_width = [(width / 2) - 1, 20].max - - hosts = Array(project.hosts).map { |host| obfuscate_ip(host) }.join(", ") - service_rows = [ - ["Service", project.service.to_s.empty? ? "n/a" : project.service.to_s], - ["Image", project.image.to_s.empty? ? "n/a" : project.image.to_s], - ["Hosts", hosts.empty? ? "n/a" : hosts], - ["Proxy", project.proxy_host.to_s.empty? ? "n/a" : project.proxy_host.to_s] - ] - - latency = project.response_time ? "#{project.response_time}ms" : "n/a" - health_rows = [ - ["Status", project.health_status.to_s], - ["Latency", latency], - ["Health", project.healthcheck_path.to_s.empty? ? "n/a" : project.healthcheck_path.to_s], - ["Versions", "kamal #{project.kamal_version || "?"} · rails #{project.rails_version || "?"}"] - ] - - lines = [] - service_header = "#{Styles::ICONS[:kamal]} #{title_mini("Service")}" - health_header = "#{Styles::ICONS[:rails]} #{title_mini("Health")}" - lines << "#{pad_visible(service_header, col_width)}#{health_header}" - service_rows.zip(health_rows).each do |left_row, right_row| - left = format_detail_row(left_row[0], left_row[1]) - right = format_detail_row(right_row[0], right_row[1]) - lines << "#{pad_visible(left, col_width)}#{right}" - end - lines - end - def project_footer_meta(project) width = detail_content_width templates = project.agent_templates.map(&:name).join(", ") @@ -1229,7 +1166,6 @@ def project_footer_meta(project) rows = [] rows << [:workspace, "Path", project.path.to_s, :path] rows << [:log, "Log Dir", project.log_dir.to_s, :path] - rows << [:log, "Action Log", project.action_log_path.to_s, :path] rows << [:template, "Templates", templates, :text] rows << [:agent, "Agents", agents_count.to_s, :text] @@ -1245,29 +1181,6 @@ def project_footer_meta(project) end end - def project_action_block(project) - width = detail_content_width - lines = [] - if @actions.key?(project.key) - action = @actions[project.key] - elapsed = "#{(Time.now - action.started_at).to_i}s" - lines << "#{Styles::ICONS[:run]} #{title_mini("Action")}" - lines << format_detail_row("Running", "#{@spinner.view} #{action.label} (#{elapsed})") - prefix = "#{Styles::ICONS[:log]} #{dim_style.render("Log".ljust(10))}" - budget = [width - visible_width(prefix), 10].max - lines << prefix + render_path_value(action.log_path.to_s, budget) - elsif (result = @action_results[project.key]) && UI::Rendering::ProjectStatusBadge.result_active?(result) - lines << "#{Styles::ICONS[:result]} #{title_mini("Last Action")}" - lines << format_detail_row("Result", project_action_result_label(result)) - if result[:log_path] - prefix = "#{Styles::ICONS[:log]} #{dim_style.render("Log".ljust(10))}" - budget = [width - visible_width(prefix), 10].max - lines << prefix + render_path_value(result[:log_path].to_s, budget) - end - end - lines - end - def project_recent_agent_block(project) agent = @agents_by_project[project.key].first return [] unless agent @@ -1290,12 +1203,6 @@ def project_recent_agent_block(project) lines end - def project_action_result_label(result) - action = result[:action_label] || KamalAction.label_for(result[:action]) - status = result[:success] ? "success" : "failed" - "#{action} - #{status}" - end - def group_row(name) "#{horizontal_margin_prefix}#{selected_tab_style.render(" #{name} ")}" end @@ -1400,7 +1307,7 @@ def sidebar_hint case @sidebar[:kind] when :agent_detail, :project_detail "j/k/g/G #{Styles::KEYS[:arrow_up]}/#{Styles::KEYS[:arrow_down]}: scroll #{Styles::MARKERS[:bullet_sep]} esc: close" - when :chat_log, :raw_log, :project_log, :healthcheck_log + when :chat_log, :raw_log, :project_log total_lines = @sidebar_viewport ? @sidebar_viewport.total_line_count : 0 current_line = total_lines.zero? ? 0 : @sidebar_viewport.y_offset + 1 "line #{current_line}/#{total_lines} #{Styles::MARKERS[:bullet_sep]} h/l #{Styles::KEYS[:arrow_left]}/#{Styles::KEYS[:arrow_right]}: pan #{Styles::MARKERS[:bullet_sep]} j/k/g/G #{Styles::KEYS[:arrow_up]}/#{Styles::KEYS[:arrow_down]}: scroll #{Styles::MARKERS[:bullet_sep]} r: reload #{Styles::MARKERS[:bullet_sep]} esc: close" diff --git a/test/app_agent_persistence_test.rb b/test/app_agent_persistence_test.rb index 1e63094..2b6a7d6 100644 --- a/test/app_agent_persistence_test.rb +++ b/test/app_agent_persistence_test.rb @@ -117,12 +117,10 @@ def assert_tui_hides_configured_projects_without_dropping_persisted_agents - key: web name: Web path: #{visible_workspace} - apps: false - key: secret name: Secret group: Hidden path: #{hidden_workspace} - apps: false YAML File.write(prompts_path, <<~YAML) custom: Default prompt for %{project_key}. @@ -192,7 +190,6 @@ def finish_tui_refresh(app) def with_temp_agent_store Dir.mktmpdir("hq-app-agent-persistence-test") do |dir| old_agents_file = replace_constant(HQ, :AGENTS_FILE, File.join(dir, "managed_agents.json")) - old_actions_file = replace_constant(HQ, :ACTIONS_FILE, File.join(dir, "actions.json")) old_logs_dir = replace_constant(HQ, :AGENT_LOGS_DIR, File.join(dir, "agents")) old_archive_dir = replace_constant(HQ, :AGENT_ARCHIVE_DIR, File.join(dir, "agents", "archive")) old_project_logs_dir = replace_constant(HQ, :PROJECT_LOGS_DIR, File.join(dir, "projects")) @@ -205,7 +202,6 @@ def with_temp_agent_store yield dir ensure replace_constant(HQ, :AGENTS_FILE, old_agents_file) if old_agents_file - replace_constant(HQ, :ACTIONS_FILE, old_actions_file) if old_actions_file replace_constant(HQ, :AGENT_LOGS_DIR, old_logs_dir) if old_logs_dir replace_constant(HQ, :AGENT_ARCHIVE_DIR, old_archive_dir) if old_archive_dir replace_constant(HQ, :PROJECT_LOGS_DIR, old_project_logs_dir) if old_project_logs_dir @@ -221,7 +217,6 @@ def write_config(dir, workspace) - key: web name: Web path: #{workspace} - apps: false YAML File.write(prompts_path, <<~YAML) custom: Default prompt for %{project_key}. diff --git a/test/fixtures/agents/multi_field_inquiry_result.json b/test/fixtures/agents/multi_field_inquiry_result.json index 948d73c..dd45a2f 100644 --- a/test/fixtures/agents/multi_field_inquiry_result.json +++ b/test/fixtures/agents/multi_field_inquiry_result.json @@ -20,7 +20,7 @@ "Admin Panel", "Discord Notifications", "Typefully Webhook", - "Deployment / Infrastructure", + "Infrastructure", "Other" ] }, diff --git a/test/fixtures/parser/claude/bash.jsonl b/test/fixtures/parser/claude/bash.jsonl index b3a93ac..2fcb729 100644 --- a/test/fixtures/parser/claude/bash.jsonl +++ b/test/fixtures/parser/claude/bash.jsonl @@ -1,2 +1,2 @@ -{"type":"assistant","message":{"model":"claude-sonnet-demo","id":"msg_demo_bash","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_demo_bash","name":"Bash","input":{"command":"bin/tycho app status demo --verbose","description":"Inspect demo project status","timeout":30000}}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000001","uuid":"00000000-0000-4000-8000-000000000101"} -{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_demo_bash","type":"tool_result","content":"Demo project status: healthy\nApp: live\nHealth: ok\nLatency: 42ms","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000001","uuid":"00000000-0000-4000-8000-000000000102","timestamp":"2026-05-12T00:00:00Z"} +{"type":"assistant","message":{"model":"claude-sonnet-demo","id":"msg_demo_bash","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_demo_bash","name":"Bash","input":{"command":"bin/tycho agent status demo-agent","description":"Inspect demo agent status","timeout":30000}}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000001","uuid":"00000000-0000-4000-8000-000000000101"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_demo_bash","type":"tool_result","content":"Demo agent status: succeeded\nRuns: 1\nExit: 0","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000001","uuid":"00000000-0000-4000-8000-000000000102","timestamp":"2026-05-12T00:00:00Z"} diff --git a/test/managed_agent_test.rb b/test/managed_agent_test.rb index dfbab70..bda42a8 100644 --- a/test/managed_agent_test.rb +++ b/test/managed_agent_test.rb @@ -514,12 +514,12 @@ def assert_claude_scalar_json_structured_output_normalizes log_path = File.join(dir, "scalar-structured.raw.log") started_at = Time.parse("2026-05-12 10:15:00") inquiry = { - "message" => "Deploy now?", + "message" => "Release now?", "fields" => [ { "key" => "confirm", - "label" => "Confirm deploy", - "description" => "Approve the deployment.", + "label" => "Confirm release", + "description" => "Approve the release.", "input_type" => "boolean", "required" => true, "options" => nil @@ -548,7 +548,7 @@ def assert_claude_scalar_json_structured_output_normalizes "name" => "StructuredOutput", "input" => { "status" => "input_required", - "summary" => "Need deploy approval.", + "summary" => "Need release approval.", "inquiry_json" => JSON.generate(inquiry), "attachments_json" => JSON.generate(attachments) } @@ -584,8 +584,8 @@ def assert_claude_scalar_json_structured_output_normalizes summary = agent.build_summary! structured = agent.structured_result - assert(summary == "Need deploy approval.", "expected scalar structured summary") - assert(structured["inquiry"]["message"] == "Deploy now?", "expected scalar inquiry JSON to normalize") + assert(summary == "Need release approval.", "expected scalar structured summary") + assert(structured["inquiry"]["message"] == "Release now?", "expected scalar inquiry JSON to normalize") assert(structured["inquiry"]["fields"].first["input_type"] == "boolean", "expected scalar inquiry fields to normalize") assert(structured["attachments"].first["url"] == "https://github.com/firewalker06/tycho/issues/9", diff --git a/test/memory_entries_test.rb b/test/memory_entries_test.rb index 4db4a36..563f857 100644 --- a/test/memory_entries_test.rb +++ b/test/memory_entries_test.rb @@ -43,8 +43,8 @@ def run! def assert_bash_entries call, result = capture_summaries("bash") - assert_summary(call, "Bash", "Bash: Inspect demo project status") - assert_summary(result, "Bash", "tool result: Demo project status: healthy") + assert_summary(call, "Bash", "Bash: Inspect demo agent status") + assert_summary(result, "Bash", "tool result: Demo agent status: succeeded") end def assert_read_entries diff --git a/test/parser_test.rb b/test/parser_test.rb index 89daa61..aa2cd4f 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -33,8 +33,8 @@ def run! def assert_bash_tool call, result = parse_fixture("bash") - assert_call(call, "Bash", "Inspect demo project status\nbin/tycho app status demo --verbose") - assert_result(result, "Bash", "Demo project status: healthy") + assert_call(call, "Bash", "Inspect demo agent status\nbin/tycho agent status demo-agent") + assert_result(result, "Bash", "Demo agent status: succeeded") end def assert_read_tool diff --git a/test/registry_test.rb b/test/registry_test.rb index ecf68a4..c6b2657 100644 --- a/test/registry_test.rb +++ b/test/registry_test.rb @@ -8,7 +8,7 @@ require_relative "../lib/hq/registry" require_relative "../lib/hq/cli_command" -require_relative "../lib/hq/domain/app_project" +require_relative "../lib/hq/domain/project" require_relative "../lib/hq/domain/agent_store" module RegistryTest @@ -19,7 +19,6 @@ def run! assert_registry_loads_model_and_effort_defaults assert_registry_ignores_hq_env_aliases assert_registry_uses_tycho_home_defaults - assert_registry_preserves_false_apps_flags assert_registry_resolves_hidden_groups_and_project_overrides assert_registry_loads_custom_claude_harnesses assert_registry_persists_remote_servers @@ -27,8 +26,6 @@ def run! assert_registry_rejects_unsupported_custom_harness_adapters assert_agent_store_prepends_project_tool_system_prompt assert_agent_store_backfills_project_tool_system_prompt - assert_hq_tool_lists_kamal_actions - assert_hq_tool_checks_app_status puts "registry_test: ok" end @@ -231,30 +228,6 @@ def assert_registry_uses_tycho_home_defaults end end - def assert_registry_preserves_false_apps_flags - Dir.mktmpdir("hq-registry-apps-test") do |dir| - config_path = File.join(dir, "hq.yml") - - File.write(config_path, <<~YAML) - projects: - - key: web - name: Web - path: #{File.join(dir, "web")} - apps: true - - key: docs - name: Docs - path: #{File.join(dir, "docs")} - apps: false - YAML - - registry = HQ::Registry.new(path: config_path) - web, docs = registry.projects - - assert(web.apps == true, "expected apps: true to stay true") - assert(docs.apps == false, "expected apps: false to stay false") - end - end - def assert_registry_resolves_hidden_groups_and_project_overrides Dir.mktmpdir("hq-registry-hidden-test") do |dir| config_path = File.join(dir, "hq.yml") @@ -309,7 +282,6 @@ def assert_registry_loads_custom_claude_harnesses - key: web name: Web path: #{File.join(dir, "web")} - apps: false agent: claude-wrapper YAML @@ -357,7 +329,6 @@ def assert_registry_rejects_unsupported_custom_harness_adapters - key: web name: Web path: #{File.join(dir, "web")} - apps: false YAML begin @@ -382,11 +353,9 @@ def assert_agent_store_prepends_project_tool_system_prompt - key: web name: Web path: #{File.join(dir, "web")} - apps: true - key: docs name: Docs path: #{File.join(dir, "docs")} - apps: false YAML File.write(prompts_path, <<~YAML) custom: Work on %{project_key}. @@ -395,124 +364,35 @@ def assert_agent_store_prepends_project_tool_system_prompt registry = HQ::Registry.new(path: config_path) web, docs = registry.projects - assert(!web.agent_templates.first.prompt.include?("Available Tycho commands for projects with Kamal deployment"), - "expected template prompt to stay separate from project tool prompt") - agent = HQ::AgentStore.new(registry.projects).create_from_template(web, "custom") system_messages = agent.messages.select { |message| message.role == "system" } assert(system_messages.length == 2, "expected project context and template prompt to be separate system messages") assert(system_messages[0].content.include?("Project:"), "expected first system message to include project context") - assert(system_messages[0].content.include?("Available Tycho commands for projects with Kamal deployment"), - "expected first system message to include HQ Kamal command reference") - assert(system_messages[0].content.include?("Ensure to check the Last Action when performing HQ command."), - "expected first system message to include last-action instruction") - HQ::CLICommand::APP_COMMANDS.each do |command| - assert(!command.description.to_s.strip.empty?, - "expected every app command exposed to prompts to define a description") - usage = format(command.usage_template, project_key: "web") - assert(system_messages[0].content.include?("bin/tycho #{usage}"), - "expected project system prompt to include #{usage}") - end + assert(system_messages[0].content.include?("- Path: #{File.join(dir, "web")}"), + "expected first system message to include workspace path") assert(system_messages[1].content == "Work on web.", "expected second system message to be the selected template prompt") conversation_roles = agent.conversation_messages.map(&:role) assert(conversation_roles.first(2) == %w[system system], "expected chat conversation to render both leading system messages") - app_project_agent = HQ::AgentStore.new(registry.projects).create_from_template(HQ::AppProject.new(web), "custom") - app_project_context = app_project_agent.messages.select { |message| message.role == "system" }.first.content - assert(app_project_context.include?("Available Tycho commands for projects with Kamal deployment"), - "expected AppProject-created agents to include HQ Kamal command reference") + project_agent = HQ::AgentStore.new(registry.projects).create_from_template(HQ::Project.new(web), "custom") + project_context = project_agent.messages.select { |message| message.role == "system" }.first.content + assert(project_context.include?("Project:"), + "expected Project-created agents to include project context") docs_agent = HQ::AgentStore.new(registry.projects).create_from_template(docs, "custom") docs_system_messages = docs_agent.messages.select { |message| message.role == "system" } - assert(docs_system_messages.length == 1, - "expected non-Kamal project to only include the template system prompt") - assert(docs_system_messages.first.content == "Work on docs.", - "expected non-Kamal project to omit project context system prompt") + assert(docs_system_messages.length == 2, + "expected every project to include project context and template prompt") + assert(docs_system_messages.last.content == "Work on docs.", + "expected final system prompt to be the template prompt") ensure replace_constant(HQ, :AGENTS_FILE, old_agents_file) if old_agents_file end end - def assert_hq_tool_lists_kamal_actions - old_config_path = ENV["TYCHO_CONFIG_PATH"] - Dir.mktmpdir("hq-tool-list-test") do |dir| - config_path = File.join(dir, "hq.yml") - File.write(config_path, <<~YAML) - projects: - - key: web - name: Web - path: #{File.join(dir, "web")} - apps: true - - key: docs - name: Docs - path: #{File.join(dir, "docs")} - apps: false - YAML - ENV["TYCHO_CONFIG_PATH"] = config_path - - output = capture_stdout { HQ::CLICommand.run(%w[app list]) } - - assert(output.include?("Key") && output.include?("Name") && output.include?("Actions"), - "expected bin/tycho app list to render table headers") - assert(output.include?("web") && output.include?("Web") && output.include?("deploy, maintenance, live"), - "expected bin/tycho app list to inventory Kamal actions") - assert(!output.include?("docs"), "expected bin/tycho app list to omit non-Kamal projects") - ensure - ENV["TYCHO_CONFIG_PATH"] = old_config_path - end - end - - def assert_hq_tool_checks_app_status - old_config_path = ENV["TYCHO_CONFIG_PATH"] - old_actions_file = nil - old_project_logs_dir = nil - Dir.mktmpdir("hq-tool-status-test") do |dir| - project_path = File.join(dir, "web") - project_logs_dir = File.join(dir, "project-logs") - FileUtils.mkdir_p(project_path) - old_actions_file = replace_constant(HQ, :ACTIONS_FILE, File.join(dir, "actions.json")) - old_project_logs_dir = replace_constant(HQ, :PROJECT_LOGS_DIR, project_logs_dir) - config_path = File.join(dir, "hq.yml") - File.write(config_path, <<~YAML) - projects: - - key: web - name: Web - path: #{project_path} - apps: true - YAML - ENV["TYCHO_CONFIG_PATH"] = config_path - action_log = File.join(project_logs_dir, "web", "action.log") - FileUtils.mkdir_p(File.dirname(action_log)) - File.write(action_log, <<~LOG) - === [2026-05-02 09:12:00] deploy === - - ERROR failed to deploy - LOG - File.write("#{action_log}.status", "1") - File.write(HQ::ACTIONS_FILE, "[]") - - output = capture_stdout { HQ::CLICommand.run(%w[app status web]) } - - assert(output.include?("Key") && output.include?("web"), - "expected app status to include project key") - assert(output.include?("App") && output.include?("unknown"), - "expected app status to include app status") - assert(output.include?("Health") && output.include?("not checked"), - "expected app status to include health status") - assert(output.include?("Action Log"), - "expected app status to include action log location") - assert(output.include?("Last Action") && output.include?("deploying - failed"), - "expected app status to include last action result") - ensure - ENV["TYCHO_CONFIG_PATH"] = old_config_path - replace_constant(HQ, :ACTIONS_FILE, old_actions_file) if old_actions_file - replace_constant(HQ, :PROJECT_LOGS_DIR, old_project_logs_dir) if old_project_logs_dir - end - end - def assert_agent_store_backfills_project_tool_system_prompt old_agents_file = nil Dir.mktmpdir("hq-agent-context-backfill-test") do |dir| @@ -527,7 +407,6 @@ def assert_agent_store_backfills_project_tool_system_prompt - key: web name: Web path: #{File.join(dir, "web")} - apps: true YAML agent_data = [{ "key" => "web-agent-1", @@ -555,10 +434,10 @@ def assert_agent_store_backfills_project_tool_system_prompt agent = HQ::AgentStore.new(registry.projects).load.fetch(0) system_messages = agent.messages.select { |message| message.role == "system" } assert(system_messages.length == 2, "expected existing agent messages to gain project context") - assert(system_messages.first.content.include?("bin/tycho app deploy web"), - "expected backfilled project context to include deploy command") + assert(system_messages.first.content.include?("Project:"), + "expected backfilled project context") memory_events = File.readlines(memory_path, chomp: true).map { |line| JSON.parse(line) } - assert(memory_events.first["content"].include?("bin/tycho app deploy web"), + assert(memory_events.first["content"].include?("Project:"), "expected existing memory file to be prepended with project context") ensure replace_constant(HQ, :AGENTS_FILE, old_agents_file) if old_agents_file diff --git a/test/remote_server_test.rb b/test/remote_server_test.rb index 2b073dd..24702e9 100644 --- a/test/remote_server_test.rb +++ b/test/remote_server_test.rb @@ -46,7 +46,6 @@ def run! assert_remote_agent_push_notifications assert_remote_search_index_includes_agents_and_projects assert_remote_skills_payload_uses_discovery - assert_remote_project_action_requires_confirmation assert_remote_ui_routes_load_without_auth assert_write_http_keeps_keyword_body_compatibility assert_server_prints_public_url @@ -103,7 +102,7 @@ def assert_remote_archive_reconciles_scheduled_agent_state with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) File.write(HQ::SCHEDULES_FILE, <<~YAML) schedules: - key: weekday @@ -323,7 +322,7 @@ def assert_remote_inquiry_payload_has_stable_id_and_guarded_answer with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) created = service.create_agent( "project_key" => "web", @@ -399,7 +398,7 @@ def assert_remote_agent_payload_includes_attachments with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) created = service.create_agent( "project_key" => "web", @@ -551,7 +550,7 @@ def assert_remote_agent_pull_request_diff_payload with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) server = HQ::RemoteServer.new created = service.create_agent( @@ -631,7 +630,7 @@ def assert_remote_prompt_accepts_uploaded_attachments with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) created = service.create_agent( "project_key" => "web", @@ -773,7 +772,7 @@ def assert_remote_prompt_start_accepts_dash_prefixed_message File.chmod(0o755, fake_codex) ENV["TYCHO_CODEX_BIN"] = fake_codex - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) created = service.create_agent( "project_key" => "web", @@ -903,7 +902,7 @@ def assert_remote_project_payloads_include_status_and_detail with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) agent = service.create_agent( "project_key" => "web", @@ -917,16 +916,9 @@ def assert_remote_project_payloads_include_status_and_detail project = projects.find { |item| item[:key] == "web" } assert(project, "expected project list to include web") assert(project[:group] == "Core", "expected project group") - assert(project.key?(:health_status), "expected project health status") - assert(project.key?(:latency_ms), "expected project latency") - assert(project.key?(:action_state), "expected project action state key") + assert(project[:status] == "configured", "expected project status") detail = service.project("web") assert(detail[:pr_number] == "123", "expected PR number") - assert(detail[:service] == "web-service", "expected parsed Kamal service") - assert(detail[:image] == "ghcr.io/example/web", "expected parsed Kamal image") - assert(detail[:hosts] == ["web-1"], "expected parsed Kamal hosts") - assert(detail[:kamal_version] == "2.6.1", "expected parsed Kamal version") - assert(detail[:rails_version] == "7.2.2", "expected parsed Rails version") assert(detail[:managed_agent_count] == 1, "expected managed-agent count") assert(detail[:agent_template_summaries].first[:prompt] == "Default prompt for web.", "expected Remote UI project detail to expose full template prompt for agent creation") @@ -948,7 +940,7 @@ def assert_remote_project_git_diff_payload File.write(File.join(workspace, "tracked.txt"), "one\ntwo\n") File.write(File.join(workspace, "notes draft.txt"), "draft\n") - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) diff = service.project_git_diff("web", scope: "worktree") tracked = diff[:files].find { |file| file[:path] == "tracked.txt" } @@ -981,7 +973,7 @@ def assert_remote_project_update_route_edits_metadata with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) server = HQ::RemoteServer.new @@ -1003,7 +995,6 @@ def assert_remote_project_update_route_edits_metadata assert(project[:name] == "Web Renamed", "expected updated project name in response") assert(project[:group] == "Ops", "expected updated project group in response") assert(project[:path] == workspace, "expected project workspace path to stay unchanged") - assert(project[:apps_enabled] == true, "expected app actions setting to stay unchanged") assert(project[:agent] == "claude", "expected default harness to update") assert(project[:model] == "sonnet", "expected project model to update") assert(project[:reasoning_effort] == "low", "expected project effort to update") @@ -1014,7 +1005,6 @@ def assert_remote_project_update_route_edits_metadata assert(entry["name"] == "Web Renamed", "expected updated project name to persist") assert(entry["group"] == "Ops", "expected updated project group to persist") assert(entry["path"] == workspace, "expected persisted workspace path to stay unchanged") - assert(entry["apps"] == true, "expected apps setting to stay unchanged") assert(entry["agent"] == "claude", "expected default harness to persist") assert(entry["model"] == "sonnet", "expected model to persist") assert(entry["reasoning_effort"] == "low", "expected effort to persist") @@ -1027,13 +1017,6 @@ def assert_remote_project_update_route_edits_metadata assert(e.message.include?("path cannot be changed"), "expected immutable path error") end - begin - server.send(:route, service, "PATCH", "/projects/web", { "apps" => false }, nil) - raise "expected Remote project app action edits to be rejected" - rescue HQ::RemoteServer::Error => e - assert(e.message.include?("apps cannot be changed"), "expected immutable apps error") - end - begin server.send(:route, service, "PATCH", "/projects/web", { "pr_url" => "https://github.com/example/web/pull/12" }, nil) raise "expected Remote project PR URL edits to be rejected" @@ -1054,7 +1037,6 @@ def assert_remote_agent_model_and_effort_payloads - key: web name: Web path: #{workspace} - apps: false agent: codex model: gpt-5.1-codex-max reasoning_effort: low @@ -1122,18 +1104,15 @@ def assert_remote_hidden_settings_filter_projects_and_agents name: Web Charlie group: Cookpad path: #{workspaces["web-charlie"]} - apps: false hidden: false - key: worker name: Worker group: Cookpad path: #{workspaces["worker"]} - apps: false - key: docs name: Docs group: Personal path: #{workspaces["docs"]} - apps: false YAML File.write(prompts_path, <<~YAML) custom: Default prompt for %{project_key}. @@ -1310,7 +1289,7 @@ def assert_remote_setup_payload_includes_readiness workspace = File.join(dir, "workspace") write_project_workspace(workspace) write_archived_config(dir) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new( registry: registry, server_url: "http://127.0.0.1:7373", @@ -1332,11 +1311,11 @@ def assert_remote_setup_payload_includes_readiness assert(setup.dig(:counts, :archived_projects) == 1, "expected archived project count") assert(setup[:harnesses].map { |item| item[:name] }.sort == %w[claude claude-wrapper codex opencode], "expected harness readiness entries") - assert(setup[:tools].map { |item| item[:name] }.sort == %w[kamal kamal-projects mise tailscale], + assert(setup[:tools].map { |item| item[:name] }.sort == %w[tailscale], "expected optional tool readiness entries") assert(setup.dig(:schema, :valid) == true, "expected valid result schema") assert(setup.dig(:config, :prompt_template_count) == 1, "expected prompt template count") - assert(setup[:safety].any? { |line| line.include?("confirmation") }, "expected safety defaults") + assert(setup[:safety].any? { |line| line.include?("Running agents") }, "expected safety defaults") end end @@ -1345,29 +1324,24 @@ def assert_remote_setup_uses_shared_executable_resolution home = File.join(dir, "home") empty_path = File.join(dir, "empty-bin") workspace = File.join(dir, "workspace") - %w[claude codex opencode mise].each do |command| + %w[claude codex opencode].each do |command| write_test_executable(File.join(home, ".local", "bin", command)) end FileUtils.mkdir_p(empty_path) write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) with_env_values( "HOME" => home, "PATH" => empty_path, "TYCHO_CLAUDE_BIN" => nil, "TYCHO_CODEX_BIN" => nil, - "TYCHO_MISE_BIN" => nil, "TYCHO_OPENCODE_BIN" => nil ) do setup = HQ::RemoteService.new(registry: registry).setup codex = setup[:harnesses].find { |item| item[:name] == "codex" } claude = setup[:harnesses].find { |item| item[:name] == "claude" } opencode = setup[:harnesses].find { |item| item[:name] == "opencode" } - mise = setup[:tools].find { |item| item[:name] == "mise" } - global_kamal = setup[:tools].find { |item| item[:name] == "kamal" } - project_kamal = setup[:tools].find { |item| item[:name] == "kamal-projects" } - assert(codex[:ready] && codex[:path].end_with?("/.local/bin/codex"), "expected Remote setup to find fallback Codex") assert(codex.key?(:model_suggestions), "expected Codex readiness to expose model suggestions") @@ -1380,11 +1354,6 @@ def assert_remote_setup_uses_shared_executable_resolution "expected Remote setup to find fallback OpenCode") assert(opencode[:reasoning_effort_suggestions].include?("high"), "expected OpenCode readiness to expose variant suggestions") - assert(mise[:ready] && mise[:path].end_with?("/.local/bin/mise"), - "expected Remote setup to find fallback mise") - assert(!global_kamal[:ready], "expected global Kamal to remain PATH-only") - assert(project_kamal[:ready], "expected project-level Kamal readiness") - assert(project_kamal[:detail].include?("1/1 app project"), "expected project Kamal count detail") end end end @@ -1393,7 +1362,7 @@ def assert_remote_setup_warns_when_public_url_has_no_token with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new( registry: registry, server_url: "http://127.0.0.1:7373", @@ -1434,7 +1403,6 @@ def assert_remote_welcome_onboarding_creates_project assert(project[:key] == "welcome", "expected welcome project payload") assert(entry["key"] == "welcome", "expected welcome project to persist") assert(entry["path"] == welcome_path, "expected persisted welcome workspace path") - assert(entry["apps"] == false, "expected welcome project to disable app checks") assert(File.exist?(File.join(welcome_path, "README.md")), "expected welcome README") assert(service.setup.dig(:onboarding, :active) == false, "expected onboarding to finish after project creation") ensure @@ -1493,7 +1461,6 @@ def assert_remote_broker_lists_configured_servers - key: web name: Web path: #{workspace} - apps: false YAML File.write(prompts_path, "custom: Default prompt for %{project_key}.\n") registry = HQ::Registry.new(path: config_path, system_prompts_path: prompts_path) @@ -1505,8 +1472,9 @@ def assert_remote_broker_lists_configured_servers assert(servers.map { |item| item[:key] } == %w[local office-mac], "expected broker server list to include local and configured remotes") - assert(servers.first[:local] == true && servers.first[:healthy] == true, - "expected local broker server to be marked healthy") + assert(servers.first[:local] == true, "expected local broker server to be marked local") + assert(servers.first.keys.sort == %i[auth_configured key local name url], + "expected broker server list to include only display metadata") remote = servers.last assert(remote[:name] == "Office Mac", "expected configured remote display name") assert(remote[:url] == "http://office-mac.example.test:7373", @@ -1523,12 +1491,6 @@ def assert_remote_broker_proxies_configured_server_requests handler = lambda do |request| requests << request case [request[:method], request[:path]] - when ["GET", "/health"] - { - status: 200, - content_type: "application/json", - body: JSON.generate(status: "ok", node: "target") - } when ["POST", "/agents/web-agent-1/messages"] { status: 200, @@ -1576,17 +1538,12 @@ def assert_remote_broker_proxies_configured_server_requests - key: web name: Web path: #{workspace} - apps: false YAML File.write(prompts_path, "custom: Default prompt for %{project_key}.\n") registry = HQ::Registry.new(path: config_path, system_prompts_path: prompts_path) service = HQ::RemoteService.new(registry: registry) server = HQ::RemoteServer.new - health = server.send(:route, service, "GET", "/servers/target/health", {}, nil) - assert(health.dig(:body, :server, :healthy) == true, "expected target health to be proxied") - assert(health.dig(:body, :server, :status) == "ok", "expected target health status") - request = HQ::RemoteServer.const_get(:Request).new( method: "POST", path: "/servers/target/proxy/agents/web-agent-1/messages", @@ -1635,12 +1592,6 @@ def assert_remote_broker_proxies_loopback_peer_requests handler = lambda do |request| requests << request case [request[:method], request[:path]] - when ["GET", "/health"] - { - status: 200, - content_type: "application/json", - body: JSON.generate(status: "ok", node: "loopback") - } when ["GET", "/agents"] { status: 200, @@ -1666,10 +1617,6 @@ def assert_remote_broker_proxies_loopback_peer_requests service = HQ::RemoteService.new(registry: registry) server = HQ::RemoteServer.new - health = server.send(:route, service, "GET", "/servers/#{key}/health", {}, nil) - assert(health.dig(:body, :server, :healthy) == true, "expected loopback peer health to be proxied") - assert(health.dig(:body, :server, :status) == "ok", "expected loopback peer health status") - proxied = server.send(:route, service, "GET", "/servers/#{key}/proxy/agents", {}, nil) assert(proxied.dig(:body, "agents", 0, "key") == "peer-agent-1", "expected loopback peer API request to be proxied") @@ -1684,7 +1631,7 @@ def assert_remote_server_persists_added_servers handler = lambda do |request| requests << request case [request[:method], request[:path]] - when ["GET", "/health"] + when ["GET", "/agents"] unless request.dig(:headers, "authorization") == "Bearer target-secret" next({ status: 401, @@ -1695,7 +1642,7 @@ def assert_remote_server_persists_added_servers { status: 200, content_type: "application/json", - body: JSON.generate(status: "ok", node: "persisted-peer") + body: JSON.generate(agents: [{ key: "peer-agent-1", name: "Peer Agent" }]) } when ["GET", "/agents"] unless request.dig(:headers, "authorization") == "Bearer target-secret" @@ -1730,7 +1677,6 @@ def assert_remote_server_persists_added_servers - key: web name: Web path: #{workspace} - apps: false YAML File.write(prompts_path, "custom: Default prompt for %{project_key}.\n") registry = HQ::Registry.new(path: config_path, system_prompts_path: prompts_path) @@ -1758,7 +1704,7 @@ def assert_remote_server_persists_added_servers assert(!File.read(config_path).include?("target-secret"), "expected Remote server route to avoid writing UI-entered tokens") assert(requests.all? { |request| request.dig(:headers, "authorization") == "Bearer target-secret" }, - "expected health checks to use the provided browser-local token") + "expected broker requests to use the provided browser-local token") proxy_request = HQ::RemoteServer.const_get(:Request).new( method: "GET", @@ -1829,7 +1775,7 @@ def assert_serve_command_accepts_daemon_mode def assert_remote_push_subscription_lifecycle with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) config = service.push_config @@ -1860,7 +1806,7 @@ def assert_remote_agent_push_notifications with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: false) + registry = registry_for_project(dir, workspace) notifier = RecordingPushNotifier.new service = HQ::RemoteService.new(registry: registry, web_push_notifier: notifier) started_at = Time.now - 60 @@ -1872,8 +1818,8 @@ def assert_remote_agent_push_notifications started_at: started_at, structured_result: { "status" => "input_required", - "summary" => "Needs deployment confirmation", - "inquiry" => { "message" => "Deploy now?" } + "summary" => "Needs release confirmation", + "inquiry" => { "message" => "Release now?" } } ) finished_agent = stale_running_agent( @@ -1923,7 +1869,7 @@ def assert_remote_search_index_includes_agents_and_projects with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) service.create_agent( "project_key" => "web", @@ -1960,7 +1906,7 @@ def assert_remote_skills_payload_uses_discovery opencode_skill_dir = File.join(workspace, ".opencode", "skills", "plan") FileUtils.mkdir_p(opencode_skill_dir) File.write(File.join(opencode_skill_dir, "SKILL.md"), "# Plan\n") - registry = registry_for_project(dir, workspace, apps: true) + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new(registry: registry) payload = service.skills("web", "codex") @@ -1982,27 +1928,6 @@ def assert_remote_skills_payload_uses_discovery end end - def assert_remote_project_action_requires_confirmation - with_remote_temp_store do |dir| - workspace = File.join(dir, "workspace") - write_project_workspace(workspace) - registry = registry_for_project(dir, workspace, apps: true) - service = HQ::RemoteService.new(registry: registry) - - preflight = service.project_action_preflight("web", "deploy") - assert(preflight[:action] == "deploy", "expected deploy preflight") - assert(preflight[:checks].any? { |check| check[:key] == "kamal" && check[:passed] }, - "expected Kamal preflight check") - - begin - service.start_project_action("web", "deploy", {}) - raise "expected missing confirmation to fail" - rescue HQ::RemoteServer::Error => e - assert(e.status == 400, "expected missing confirmation to return 400") - end - end - end - def assert_remote_ui_routes_load_without_auth server = HQ::RemoteServer.new(token: "secret", logger: Logger.new(StringIO.new), output: StringIO.new) ui_request = HQ::RemoteServer.const_get(:Request).new( @@ -2015,7 +1940,7 @@ def assert_remote_ui_routes_load_without_auth assert(server.send(:ui_request?, ui_request), "expected / to be recognized as a UI route") response = server.send(:route_ui, "/") assert(response[:content_type].include?("text/html"), "expected / to return HTML") - assert(response[:body].include?("Tycho - its Factorio for agents"), "expected / body to include app shell title") + assert(response[:body].include?("Tycho - Factorio for Agents"), "expected / body to include app shell title") legacy_request = HQ::RemoteServer.const_get(:Request).new( method: "GET", path: "/ui", @@ -2461,9 +2386,9 @@ def assert_remote_ui_routes_load_without_auth "expected copyable key/value rows to render a copy control") assert(js[:body].include?('copyableKv("Raw log", agent.log_path)'), "expected Conversation settings rows to be copyable") - assert(js[:body].include?('copyableKv("Service", project.service)') && + assert(js[:body].include?('copyableKv("Path", project.path)') && js[:body].include?('copyableKv("Templates",'), - "expected Project deploy and template details to be copyable") + "expected Project workspace and template details to be copyable") assert(js[:body].include?('copyableKv("Root", setup.logs?.root)') && js[:body].include?('copyableKv("Auth", setup.auth?.status)'), "expected Settings configuration, logs, and preferences rows to be copyable") @@ -2703,16 +2628,10 @@ def assert_remote_ui_routes_load_without_auth "expected Project edit form to expose default harness") assert(js[:body].include?("data-project-model-select"), "expected Project edit form to expose model choices") - assert(js[:body].include?("Can be deployed with Kamal"), - "expected Project edit form to describe app actions as Kamal deploy capability") assert(!js[:body].include?('id="project-pr-url"'), "expected Project edit form to keep PR URL readonly") - assert(!js[:body].include?('id="project-apps"'), - "expected Project edit form to keep app actions readonly") assert(!js[:body].include?('formData.get("pr_url")'), "expected Project edit form payload to omit PR URL") - assert(!js[:body].include?('formData.get("apps")'), - "expected Project edit form payload to omit app actions") assert(js[:body].include?("function projectFormPayload"), "expected Project edit form to serialize project metadata") assert(js[:body].include?('apiPatch(`/projects/${encodeURIComponent(projectKey)}`'), @@ -3331,7 +3250,7 @@ def assert_remote_ui_routes_load_without_auth assert(manifest[:content_type].include?("application/manifest+json"), "expected manifest route to return a web app manifest") parsed_manifest = JSON.parse(manifest[:body]) - assert(parsed_manifest["name"] == "Tycho - its Factorio for agents", + assert(parsed_manifest["name"] == "Tycho - Factorio for Agents", "expected manifest name to match the Remote UI page title") assert(parsed_manifest["short_name"] == "Tycho", "expected manifest short name to use Tycho") assert(parsed_manifest["display"] == "standalone", "expected manifest to install as a standalone PWA") @@ -3505,7 +3424,6 @@ def registry_for(dir, workspace) - key: web name: Web path: #{workspace} - apps: false YAML File.write(prompts_path, <<~YAML) custom: Default prompt. @@ -3516,7 +3434,6 @@ def registry_for(dir, workspace) def with_remote_temp_store Dir.mktmpdir("hq-remote-test") do |dir| old_agents_file = replace_constant(HQ, :AGENTS_FILE, File.join(dir, "managed_agents.json")) - old_actions_file = replace_constant(HQ, :ACTIONS_FILE, File.join(dir, "actions.json")) old_schedules_file = replace_constant(HQ, :SCHEDULES_FILE, File.join(dir, "config", "schedules.yml")) old_schedules_state_file = replace_constant(HQ, :SCHEDULES_STATE_FILE, File.join(dir, "schedules.json")) old_scheduler_daemon_file = replace_constant(HQ, :SCHEDULER_DAEMON_FILE, File.join(dir, "scheduler_daemon.json")) @@ -3541,7 +3458,6 @@ def with_remote_temp_store yield dir ensure replace_constant(HQ, :AGENTS_FILE, old_agents_file) if old_agents_file - replace_constant(HQ, :ACTIONS_FILE, old_actions_file) if old_actions_file replace_constant(HQ, :SCHEDULES_FILE, old_schedules_file) if old_schedules_file replace_constant(HQ, :SCHEDULES_STATE_FILE, old_schedules_state_file) if old_schedules_state_file replace_constant(HQ, :SCHEDULER_DAEMON_FILE, old_scheduler_daemon_file) if old_scheduler_daemon_file @@ -3561,7 +3477,7 @@ def with_remote_temp_store end end - def registry_for_project(dir, workspace, apps:) + def registry_for_project(dir, workspace) config_path = File.join(dir, "hq.yml") prompts_path = File.join(dir, "system_prompts.yml") File.write(config_path, <<~YAML) @@ -3574,7 +3490,6 @@ def registry_for_project(dir, workspace, apps:) name: Web group: Core path: #{workspace} - apps: #{apps} pr_url: https://github.com/example/web/pull/123 YAML File.write(prompts_path, <<~YAML) @@ -3584,21 +3499,7 @@ def registry_for_project(dir, workspace, apps:) end def write_project_workspace(workspace) - FileUtils.mkdir_p(File.join(workspace, "config")) - File.write(File.join(workspace, "config", "deploy.yml"), <<~YAML) - service: web-service - image: ghcr.io/example/web - servers: - web: - hosts: - - web-1 - YAML - File.write(File.join(workspace, "Gemfile.lock"), <<~LOCK) - GEM - specs: - kamal (2.6.1) - rails (7.2.2) - LOCK + FileUtils.mkdir_p(workspace) end def with_fixture_http_server(handler) @@ -3730,7 +3631,7 @@ def assert_server_prints_request_logs server = HQ::RemoteServer.new(logger: logger, output: output) request = HQ::RemoteServer.const_get(:Request).new( method: "GET", - path: "/health", + path: "/agents", headers: {}, body: "" ) @@ -3739,7 +3640,7 @@ def assert_server_prints_request_logs line = output.string assert(line.include?("[Remote]"), "expected console log to include Remote prefix") - assert(line.include?("GET /health 200"), "expected console log to include request method, path, and status") + assert(line.include?("GET /agents 200"), "expected console log to include request method, path, and status") assert(line.include?("ms"), "expected console log to include duration") end diff --git a/test/rendering_test.rb b/test/rendering_test.rb index 8dc9e30..4efb0ce 100644 --- a/test/rendering_test.rb +++ b/test/rendering_test.rb @@ -25,11 +25,6 @@ def run! fixture_dir = Dir.mktmpdir("hq-rendering-config-test") ENV["TYCHO_CONFIG_PATH"] = write_rendering_config_fixture(fixture_dir) - original_fetch = HQ::VersionLookup.method(:fetch_latest_gem_version) - HQ::VersionLookup.singleton_class.send(:define_method, :fetch_latest_gem_version) do |gem_name| - { "kamal" => "2.11.0", "rails" => "8.1.3" }[gem_name] - end - original_load = HQ::AgentStore.instance_method(:load) original_save = HQ::AgentStore.instance_method(:save) HQ::AgentStore.define_method(:load) { [] } @@ -40,8 +35,6 @@ def run! assert_app_boot_refreshes_project_metadata assert_missing_config_opens_onboarding_panel assert_onboarding_welcome_creates_sandbox_project - assert_concurrent_project_metadata_uses_each_dotenv - assert_project_without_proxy_host_is_not_healthchecked assert_main_screen_shows_detail_panel_and_footer assert_empty_project_state_mentions_new_project_shortcut assert_empty_agent_state_mentions_new_agent_shortcut @@ -52,7 +45,6 @@ def run! assert_list_sidebar_renders_agent_names assert_list_sidebar_renders_project_status_icons assert_list_sidebar_scrolls_to_selected_agent - assert_failed_project_action_with_healthy_app_renders_warning_status assert_agent_unread_cursor_and_chat_clear assert_finished_agent_poll_marks_unread assert_exited_agent_poll_marks_unread_after_status_turns_idle @@ -69,10 +61,8 @@ def run! assert_agent_detail_omits_conversation_and_recent_runs assert_agent_detail_renders_project_git_metadata assert_project_detail_renders_log_locations - assert_project_detail_renders_last_action_status assert_projects_list_renders_group_rows assert_log_overlay_renders_in_right_panel - assert_project_log_shortcut_opens_action_log assert_chat_screen_renders_transcript_and_composer assert_chat_section_labels_use_tag_backgrounds assert_chat_attachments_render_compact_and_overlay @@ -106,10 +96,9 @@ def run! assert_chat_block_selection_scrolls_with_large_messages assert_agent_editor_renders_template_and_harness_choices assert_agent_editor_preserves_dirty_model_and_effort_on_template_change - assert_web_project_icon_renders_for_project_contexts - assert_non_app_project_uses_folder_icon + assert_project_icon_renders_for_project_contexts + assert_project_list_uses_folder_icon assert_project_archive_moves_config_logs_and_agents - assert_failed_kamal_action_log_marks_action_failed assert_create_agent_keeps_project_tool_system_prompt assert_create_and_run_raw_log_includes_project_tool_system_prompt assert_create_agent_starts_immediately_and_uses_selected_harness @@ -126,11 +115,6 @@ def run! assert_glamour_worker_renders_sample_markdown puts "rendering_test: ok" ensure - if defined?(original_fetch) && original_fetch - HQ::VersionLookup.singleton_class.send(:define_method, :fetch_latest_gem_version) do |gem_name| - original_fetch.call(gem_name) - end - end HQ::AgentStore.define_method(:load, original_load) if defined?(original_load) && original_load HQ::AgentStore.define_method(:save, original_save) if defined?(original_save) && original_save ENV["TYCHO_CONFIG_PATH"] = old_config_path if defined?(old_config_path) @@ -155,17 +139,14 @@ def write_rendering_config_fixture(dir) name: hq group: Personal path: #{File.join(dir, "hq")} - apps: false - key: warehouse name: warehouse group: Personal path: #{File.join(dir, "warehouse")} - apps: true - key: demo-web name: Demo Web group: Example path: #{File.join(dir, "demo-web")} - apps: false YAML File.write(File.join(dir, "system_prompts.yml"), <<~YAML) @@ -182,12 +163,9 @@ def assert_main_screen_keeps_header_visible lines = output.lines.map(&:chomp) assert(lines.length <= 30, "expected output to fit within 30 lines, got #{lines.length}") - assert(lines[0].include?("Tycho - Ops Cockpit"), "expected title on first line") + assert(lines[0].include?("Tycho - Factorio for Agents"), "expected title on first line") assert(lines[1].include?("1. Agents"), "expected agents tab on second line") assert(lines[1].include?("2. Projects"), "expected projects tab on second line") - assert(lines[1].include?("Latest:"), "expected latest versions on second line") - assert(lines[1].include?("2.11.0"), "expected kamal version text on second line") - assert(lines[1].include?("8.1.3"), "expected rails version text on second line") end def assert_loading_screen_renders_tycho_logotype @@ -212,27 +190,19 @@ def assert_app_boot_refreshes_project_metadata Dir.mktmpdir("hq-app-boot-metadata-test") do |dir| project_path = File.join(dir, "demo") FileUtils.mkdir_p(project_path) - File.write(File.join(project_path, "Gemfile.lock"), <<~LOCK) - GEM - specs: - kamal (2.8.1) - rails (8.0.2) - LOCK config_path = File.join(dir, "hq.yml") File.write(config_path, <<~YAML) projects: - key: demo name: Demo path: #{project_path} - apps: false YAML ENV["TYCHO_CONFIG_PATH"] = config_path app = HQ::App.new project = app.instance_variable_get(:@projects).fetch(0) - assert(project.kamal_version == "2.8.1", "expected Kamal version to load during app boot") - assert(project.rails_version == "8.0.2", "expected Rails version to load during app boot") + assert(project.path == project_path, "expected project metadata to load during app boot") assert(app.instance_variable_get(:@loading) == false, "expected App.new to render the main UI before async refresh") end ensure @@ -289,7 +259,6 @@ def assert_onboarding_welcome_creates_sandbox_project assert(project["key"] == "welcome", "expected welcome project to be persisted") assert(project["path"] == welcome_path, "expected welcome project to use sandbox workspace") - assert(project["apps"] == false, "expected welcome project to disable app checks") assert(File.exist?(File.join(welcome_path, "README.md")), "expected welcome workspace README") assert(!app.instance_variable_get(:@onboarding), "expected onboarding to close after sandbox creation") assert(app.instance_variable_get(:@projects).map(&:key).include?("welcome"), @@ -306,104 +275,6 @@ def assert_onboarding_welcome_creates_sandbox_project end end - def assert_project_without_proxy_host_is_not_healthchecked - Dir.mktmpdir("hq-host-health-test") do |dir| - project_path = File.join(dir, "host-only") - FileUtils.mkdir_p(File.join(project_path, "config")) - File.write(File.join(project_path, "config", "deploy.yml"), <<~YAML) - service: host-only - image: host-only - servers: - web: - hosts: - - 10.0.0.42 - proxy: false - YAML - project = HQ::AppProject.new( - HQ::ProjectConfig.new( - key: "host-only", - name: "Host Only", - group: "Tests", - path: project_path, - apps: true, - agent_templates: [] - ) - ) - project.refresh_metadata! - - original_new = Net::HTTP.method(:new) - fake_http = Class.new do - attr_accessor :use_ssl, :open_timeout, :read_timeout, :keep_alive_timeout - attr_reader :host, :port, :paths - - def initialize(host, port) - @host = host - @port = port - @paths = [] - end - - def start - yield self - end - - def head(path) - @paths << path - Struct.new(:code).new("200") - end - end - created = [] - Net::HTTP.singleton_class.define_method(:new) do |host, port| - fake_http.new(host, port).tap { |http| created << http } - end - - project.check_health! - - assert(project.health_status == "not checked", "expected host-only project without proxy.host to be neutral") - assert(project.app_status == "unknown", "expected host-only project without proxy.host to have unknown app status") - assert(created.empty?, "expected host-only project without proxy.host not to issue HTTP checks") - ensure - Net::HTTP.define_singleton_method(:new) { |*args| original_new.call(*args) } if original_new - end - end - - def assert_concurrent_project_metadata_uses_each_dotenv - Dir.mktmpdir("hq-concurrent-deploy-config-test") do |dir| - projects = %w[one two].map do |name| - path = File.join(dir, name) - FileUtils.mkdir_p(File.join(path, "config")) - File.write(File.join(path, ".env"), "PROXY_HOST=#{name}.example.test\n") - File.write(File.join(path, "config", "deploy.yml"), <<~YAML) - service: #{name} - image: #{name} - servers: - web: - hosts: - - 127.0.0.1 - proxy: - ssl: true - host: <%= ENV.fetch("PROXY_HOST") %> - YAML - HQ::AppProject.new( - HQ::ProjectConfig.new( - key: name, - name: name, - group: "Tests", - path: path, - apps: true, - agent_templates: [] - ) - ) - end - - 20.times do - projects.map { |project| Thread.new { project.refresh_metadata! } }.each(&:join) - end - - assert(projects.map(&:proxy_host) == %w[one.example.test two.example.test], - "expected concurrent deploy config parsing to keep each project's dotenv isolated") - end - end - def assert_main_screen_shows_detail_panel_and_footer output = render_main_screen(:agents, width: 120, height: 30) lines = output.lines.map(&:chomp) @@ -565,31 +436,6 @@ def assert_list_sidebar_scrolls_to_selected_agent "expected overflowing list sidebar to scroll early agents out of view") end - def assert_failed_project_action_with_healthy_app_renders_warning_status - project = HQ::AppProject.new( - HQ::ProjectConfig.new( - key: "healthy-failed-action", - name: "Healthy Failed Action", - group: "Tests", - path: "/tmp", - apps: true, - agent_templates: [] - ) - ) - project.instance_variable_set(:@health_status, "healthy") - project.instance_variable_set(:@app_status, "running") - result = { success: false, action: :deploy, at: Time.now, log_path: "/tmp/action.log" } - - badge = HQ::UI::Rendering::ProjectStatusBadge.for(project, action: nil, result: result, steady: :health) - - assert(badge.style_key == :warning, "expected failed action with healthy app to render as warning") - assert(badge.text == "! deploy", "expected failed action with healthy app to use warning result text") - assert(HQ::UI::Rendering::ProjectStatusBadge.result_active?(result.merge(at: Time.now - 299)), - "expected failed action result to remain active for the longer failure window") - assert(!HQ::UI::Rendering::ProjectStatusBadge.result_active?(result.merge(at: Time.now - 301)), - "expected failed action result to expire after the failure window") - end - def assert_agent_unread_cursor_and_chat_clear app = app_with_default_agent(width: 120, height: 30) first_agent = app.instance_variable_get(:@agents).first @@ -916,27 +762,7 @@ def assert_project_detail_renders_log_locations plain = Bubbles::ANSI.strip(app.send(:project_detail_text)) assert(plain.include?("Log Dir"), "expected project detail to label project log directory") - assert(plain.include?("Action Log"), "expected project detail to label action log path") - end - - def assert_project_detail_renders_last_action_status - app = app_with_default_agent(width: 120, height: 30) - app.instance_variable_set(:@screen, :projects) - project = app.send(:selected_project) - app.instance_variable_get(:@action_results)[project.key] = { - success: false, - action: :deploy, - action_label: "deploying", - at: Time.now, - log_path: project.action_log_path - } - - detail = app.send(:project_detail_text) - - plain = Bubbles::ANSI.strip(detail) - assert(plain.include?("Last Action"), "expected project detail to label last action") - assert(plain.include?("deploying - failed"), - "expected project detail to show last action result") + assert(plain.include?("Templates"), "expected project detail to label templates") end def assert_detail_sidebar_renders_in_split_layout @@ -966,7 +792,7 @@ def assert_log_overlay_renders_in_right_panel assert(output.include?("line one"), "expected log overlay content") assert(output.include?("line 1/"), "expected log overlay status line") - %i[chat_log healthcheck_log].each do |kind| + %i[chat_log raw_log].each do |kind| app.send(:open_sidebar_text, kind:, title: kind.to_s) { "one\ntwo\n" } assert(app.instance_variable_get(:@sidebar_viewport).is_a?(HQ::UI::LogViewer), "expected #{kind} to use the log viewer") @@ -974,23 +800,6 @@ def assert_log_overlay_renders_in_right_panel end end - def assert_project_log_shortcut_opens_action_log - app = app_with_default_agent(width: 120, height: 30) - app.instance_variable_set(:@screen, :projects) - project = app.send(:selected_project) - FileUtils.mkdir_p(project.log_dir) - File.write(project.action_log_path, "deploy output\n") - - app.send(:handle_key, "l") - output = Bubbles::ANSI.strip(app.view) - - assert(output.include?("Action Log"), "expected l on Projects to open the selected project's action log") - assert(app.instance_variable_get(:@sidebar_viewport).is_a?(HQ::UI::LogViewer), - "expected action logs to use the log viewer") - assert(output.include?("deploy output"), "expected project action.log content") - assert(!output.include?("Agent Chat Log"), "expected Projects log shortcut not to open an agent chat log") - end - def assert_agent_raw_log_shortcut_displays_full_file app = app_with_default_agent(width: 120, height: 30) app.instance_variable_set(:@screen, :agents) @@ -1683,7 +1492,7 @@ def assert_multi_field_inquiry_shows_one_field_at_a_time "expected requested_schema 'required' list to mark the first four fields required") assert(form.answer_fields[1].options == [ "Meetup Scheduling", "Admin Panel", "Discord Notifications", - "Typefully Webhook", "Deployment / Infrastructure", "Other" + "Typefully Webhook", "Infrastructure", "Other" ], "expected enum values to carry over as select options") assert(form.fields.length == 6, "expected inquiry form to append a review step as the last field, got #{form.fields.length}") @@ -2346,25 +2155,26 @@ def assert_agent_editor_preserves_dirty_model_and_effort_on_template_change "expected dirty reasoning effort field to survive template change") end - def assert_web_project_icon_renders_for_project_contexts + def assert_project_icon_renders_for_project_contexts projects_output = Bubbles::ANSI.strip(render_main_screen(:projects, width: 120, height: 30)) editor_output = Bubbles::ANSI.strip(render_agent_editor(width: 120, height: 30)) chat_output = Bubbles::ANSI.strip(render_chat_screen(width: 120, height: 30)) - assert(projects_output.include?("#{WEB_PROJECT_ICON} warehouse"), "expected app project row to use the web icon") - assert(editor_output.include?("Create Agent for #{WEB_PROJECT_ICON} warehouse"), - "expected agent editor project label to use the web icon") - assert(chat_output.include?("#{WEB_PROJECT_ICON} warehouse #{CURSOR_MARKER}"), - "expected chat header project label to use the web icon") + assert(projects_output.include?("#{FOLDER_PROJECT_ICON} warehouse"), + "expected project row to use the folder icon") + assert(editor_output.include?("Create Agent for #{FOLDER_PROJECT_ICON} warehouse"), + "expected agent editor project label to use the folder icon") + assert(chat_output.include?("#{FOLDER_PROJECT_ICON} warehouse #{CURSOR_MARKER}"), + "expected chat header project label to use the folder icon") end - def assert_non_app_project_uses_folder_icon + def assert_project_list_uses_folder_icon projects_output = Bubbles::ANSI.strip(render_main_screen(:projects, width: 120, height: 30)) assert(projects_output.include?("#{FOLDER_PROJECT_ICON} hq"), - "expected non-app project row to use the folder icon") + "expected project row to use the folder icon") assert(projects_output.include?("#{FOLDER_PROJECT_ICON} Demo Web"), - "expected grouped non-app project row to use the folder icon") + "expected grouped project row to use the folder icon") end def assert_project_archive_moves_config_logs_and_agents @@ -2381,12 +2191,10 @@ def assert_project_archive_moves_config_logs_and_agents name: Demo Project group: Test path: #{demo_path} - apps: false - key: keep name: Keep Project group: Test path: #{keep_path} - apps: false YAML ENV["TYCHO_CONFIG_PATH"] = config_path @@ -2410,8 +2218,7 @@ def assert_project_archive_moves_config_logs_and_agents app.instance_variable_get(:@selected)[:projects] = app.instance_variable_get(:@projects).index(project) || 0 FileUtils.mkdir_p(project.log_dir) - File.write(project.action_log_path, "deploy output") - File.write(File.join(project.log_dir, "deploy_20260321_084122.log"), "old deploy output") + File.write(File.join(project.log_dir, "notes.log"), "old project output") app.send(:handle_key, "x") archive_confirm = app.instance_variable_get(:@project_archive_confirm) @@ -2439,8 +2246,7 @@ def assert_project_archive_moves_config_logs_and_agents archived = archives.first assert(!Dir.exist?(project.log_dir), "expected original project log directory to be moved") - assert(File.exist?(File.join(archived, "action.log")), "expected action log in archive") - assert(File.exist?(File.join(archived, "deploy_20260321_084122.log")), "expected historical deploy log in archive") + assert(File.exist?(File.join(archived, "notes.log")), "expected project log in archive") assert(Dir.glob(File.join(HQ::AGENT_ARCHIVE_DIR, "*demo-agent-1", "*.raw.log")).any?, "expected related agent logs to be archived") ensure @@ -2448,32 +2254,6 @@ def assert_project_archive_moves_config_logs_and_agents end end - def assert_failed_kamal_action_log_marks_action_failed - started_at = Time.parse("2026-05-02 05:30:23") - action = HQ::KamalAction.new( - project_key: "failed-deploy-test", - project_name: "Failed Deploy", - project_path: "/tmp", - action: :deploy, - pid: 999_999, - started_at: started_at - ) - FileUtils.mkdir_p(File.dirname(action.log_path)) - File.write(action.log_path, <<~LOG) - - === [#{started_at.strftime("%Y-%m-%d %H:%M:%S")}] deploy === - - Build and push app image... - ERROR (SSHKit::Command::Failed): docker exit status: 32000 - docker stderr: Cannot connect to the Docker daemon - LOG - - action.poll! - - assert(action.done?, "expected finished action to be marked done") - assert(action.success? == false, "expected failed deploy log to mark action failed") - end - def assert_create_agent_starts_immediately_and_uses_selected_harness app = HQ::App.new app.define_singleton_method(:save_agents!) { nil } @@ -2534,8 +2314,8 @@ def assert_create_agent_keeps_project_tool_system_prompt app.define_singleton_method(:save_agents!) { nil } projects = app.instance_variable_get(:@projects) - project = projects.find { |item| item.key == "warehouse" } || projects.find(&:apps_enabled?) - raise "expected an app project for agent prompt test" unless project + project = projects.find { |item| item.key == "warehouse" } || projects.first + raise "expected a project for agent prompt test" unless project app.instance_variable_set(:@screen, :projects) app.instance_variable_get(:@selected)[:projects] = projects.index(project) || 0 @@ -2553,10 +2333,6 @@ def assert_create_agent_keeps_project_tool_system_prompt "expected created agent to keep project context and template prompts as separate system messages") assert(system_messages[0].content.include?("Project:"), "expected first created-agent system prompt to include project context") - assert(system_messages[0].content.include?("bin/tycho app deploy #{project.key}"), - "expected first created-agent system prompt to include project deploy command") - assert(system_messages[0].content.include?("Ensure to check the Last Action when performing HQ command."), - "expected first created-agent system prompt to include last-action instruction") assert(system_messages[1].content == "Maintenance for #{project.key}.", "expected second created-agent system prompt to be the edited agent prompt") end @@ -2566,8 +2342,8 @@ def assert_create_and_run_raw_log_includes_project_tool_system_prompt app.define_singleton_method(:save_agents!) { nil } projects = app.instance_variable_get(:@projects) - project = projects.find { |item| item.key == "warehouse" } || projects.find(&:apps_enabled?) - raise "expected an app project for raw log prompt test" unless project + project = projects.find { |item| item.key == "warehouse" } || projects.first + raise "expected a project for raw log prompt test" unless project store = app.instance_variable_get(:@agent_store) original_create = store.method(:create_from_template) @@ -2594,8 +2370,6 @@ def assert_create_and_run_raw_log_includes_project_tool_system_prompt raw_log = File.read(created_agent.raw_log_path) assert(raw_log.include?("prompt=SYSTEM:\nProject:"), "expected raw log prompt to start with project context system prompt") - assert(raw_log.include?("bin/tycho app deploy #{project.key}"), - "expected raw log prompt to include project deploy command") assert(raw_log.include?("SYSTEM:\nMaintenance for #{project.key}."), "expected raw log prompt to include edited agent system prompt after project context") ensure @@ -3026,8 +2800,6 @@ def render_main_screen(screen, width:, height:) def app_with_default_agent(width:, height:) app = HQ::App.new - app.instance_variable_set(:@latest_kamal, "2.11.0") - app.instance_variable_set(:@latest_rails, "8.1.3") if app.instance_variable_get(:@agents).empty? project = app.instance_variable_get(:@projects).find do |item| item.key == "warehouse" diff --git a/test/scheduler_test.rb b/test/scheduler_test.rb index b72d0b4..e4b4853 100644 --- a/test/scheduler_test.rb +++ b/test/scheduler_test.rb @@ -41,7 +41,7 @@ def assert_schedule_registry_validates_scope_and_prompt_paths project_key: web message: "Run maintenance." YAML - schedules = HQ::ScheduleRegistry.new(path: schedule_path, projects: registry.projects.map { |config| HQ::AppProject.new(config) }).schedules + schedules = HQ::ScheduleRegistry.new(path: schedule_path, projects: registry.projects.map { |config| HQ::Project.new(config) }).schedules assert(schedules.length == 1, "expected valid inline schedule") _registry, invalid_target = write_registry_and_schedule(dir, <<~YAML, suffix: "invalid-target") @@ -54,7 +54,7 @@ def assert_schedule_registry_validates_scope_and_prompt_paths message: "Nope." YAML assert_raises(HQ::ScheduleRegistry::Error, "expected shell target to be rejected") do - HQ::ScheduleRegistry.new(path: invalid_target, projects: registry.projects.map { |config| HQ::AppProject.new(config) }).schedules + HQ::ScheduleRegistry.new(path: invalid_target, projects: registry.projects.map { |config| HQ::Project.new(config) }).schedules end _registry, invalid_file = write_registry_and_schedule(dir, <<~YAML, suffix: "invalid-file") @@ -67,7 +67,7 @@ def assert_schedule_registry_validates_scope_and_prompt_paths message_file: "../secret.md" YAML assert_raises(HQ::ScheduleRegistry::Error, "expected message_file outside schedules/ to be rejected") do - HQ::ScheduleRegistry.new(path: invalid_file, projects: registry.projects.map { |config| HQ::AppProject.new(config) }).schedules + HQ::ScheduleRegistry.new(path: invalid_file, projects: registry.projects.map { |config| HQ::Project.new(config) }).schedules end end end @@ -164,7 +164,7 @@ def assert_scheduler_stops_interactive_scheduled_agent_until_resume store.save(states) first_agent.add_user_message!("Can you explain that result?") - projects = registry.projects.map { |config| HQ::AppProject.new(config) } + projects = registry.projects.map { |config| HQ::Project.new(config) } HQ::AgentStore.new(projects).save([first_agent]) loaded = HQ::AgentStore.new(projects).load assert(loaded.length == 1, "expected interactive agent to reload from agent store") @@ -306,7 +306,7 @@ def assert_failed_scheduled_agent_stops_and_notifies project_key: web message: "Run maintenance." YAML - projects = registry.projects.map { |config| HQ::AppProject.new(config) } + projects = registry.projects.map { |config| HQ::Project.new(config) } failed = HQ::ManagedAgent.new( key: "web-agent-1", name: "Failed schedule", @@ -392,7 +392,7 @@ def assert_schedule_daemon_supervisor_spawns_external_daemon end def build_scheduler(registry, schedule_path, web_push_notifier: FakeNotifier.new) - projects = registry.projects.map { |config| HQ::AppProject.new(config) } + projects = registry.projects.map { |config| HQ::Project.new(config) } HQ::Scheduler.new( registry: registry, schedule_registry: HQ::ScheduleRegistry.new(path: schedule_path, projects: projects), @@ -413,7 +413,6 @@ def write_registry_and_schedule(dir, schedule_content, suffix: "main") - key: web name: Web path: #{workspace} - apps: false agent: codex YAML schedule_path = File.join(config_dir, "schedules-#{suffix}.yml")