From d6c782f04b55f01e7a649185e2a23ecfe37a383c Mon Sep 17 00:00:00 2001 From: "Lucas N. Munhoz" Date: Sun, 6 Sep 2026 11:26:12 +0700 Subject: [PATCH 1/2] feat: workflows prototype --- .agents/skills/workflow-prototype | 1 + .claude/MEMORY.md | 1 + ...hand-editing-config-json-needs-a-tui-re.md | 30 ++ ...ed-agent-workflows-use-the-sqlite-store.md | 19 + .claude/memory/gotchas.md | 1 + .claude/skills/workflow-prototype/SKILL.md | 40 ++ .nebula/workflow.json | 20 + Makefile | 16 +- TERMS.md | 9 +- crates/nebula-core/src/lib.rs | 1 + crates/nebula-core/src/protocol.rs | 11 +- crates/nebula-core/src/workflow.rs | 131 ++++++ crates/nebula-daemon/src/git.rs | 10 + crates/nebula-daemon/src/lib.rs | 2 + crates/nebula-daemon/src/registry.rs | 3 + crates/nebula-daemon/src/server.rs | 10 + crates/nebula-daemon/src/store.rs | 17 + crates/nebula-daemon/src/store/workflows.rs | 99 ++++ crates/nebula-daemon/src/workflow.rs | 209 +++++++++ crates/nebula-daemon/src/workflow/prompts.rs | 106 +++++ crates/nebula-daemon/src/workflow/tests.rs | 119 +++++ crates/nebula-daemon/src/workflow/watcher.rs | 185 ++++++++ crates/nebula/Cargo.toml | 2 +- crates/nebula/src/cli.rs | 5 + crates/nebula/src/main.rs | 2 + crates/nebula/src/workflow_cli.rs | 254 ++++++++++ crates/nebula/tests/e2e_pty.rs | 3 + crates/nebula/tests/e2e_pty/workflow.rs | 433 ++++++++++++++++++ crates/nebula/tests/help_cli.rs | 11 +- docs/commands.md | 8 + docs/workflows.md | 167 +++++++ 31 files changed, 1913 insertions(+), 12 deletions(-) create mode 120000 .agents/skills/workflow-prototype create mode 100644 .claude/memory/entries/2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md create mode 100644 .claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md create mode 100644 .claude/skills/workflow-prototype/SKILL.md create mode 100644 .nebula/workflow.json create mode 100644 crates/nebula-core/src/workflow.rs create mode 100644 crates/nebula-daemon/src/store/workflows.rs create mode 100644 crates/nebula-daemon/src/workflow.rs create mode 100644 crates/nebula-daemon/src/workflow/prompts.rs create mode 100644 crates/nebula-daemon/src/workflow/tests.rs create mode 100644 crates/nebula-daemon/src/workflow/watcher.rs create mode 100644 crates/nebula/src/workflow_cli.rs create mode 100644 crates/nebula/tests/e2e_pty/workflow.rs create mode 100644 docs/workflows.md diff --git a/.agents/skills/workflow-prototype b/.agents/skills/workflow-prototype new file mode 120000 index 0000000..de02c2e --- /dev/null +++ b/.agents/skills/workflow-prototype @@ -0,0 +1 @@ +../../.claude/skills/workflow-prototype \ No newline at end of file diff --git a/.claude/MEMORY.md b/.claude/MEMORY.md index 85a308a..ee93f59 100644 --- a/.claude/MEMORY.md +++ b/.claude/MEMORY.md @@ -16,6 +16,7 @@ Line shape: `- DATE · [Title](memory/entries/DATE-slug.md) · TERMS: A; B · fi > `.claude/MEMORY.md` was split into this index plus per-entry files on 2026-08-28. ## Index +- 2026-09-06 · [Ordered AGENT Workflows Use The SQLITE STORE](memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md) · TERMS: SQLITE STORE; DAEMON; WORKTREE; SESSION; MODEL / EFFORT; STARTING PROMPT · files: workflow.rs; watcher.rs; workflows.rs; workflow_cli.rs · gotchas: 5 - 2026-09-05 · [Released v0.22.0 Level With `origin/main`: Nothing To Carry, 38 Commits To Explain](memory/entries/2026-09-05-released-v0-22-0-level-with-origin-nothing-to-carry.md) · TERMS: RELEASE SKILL; RELEASE NOTES; RELEASE WORKTREE; SHARED CHECKOUT; CARRY SET; PROTOCOL VERSION · files: Cargo.toml; Cargo.lock; commands.md; gotchas.md · gotchas: 3 - 2026-09-05 · [The `for … in $(…)` GUARD HOOK Block Was Right, But Its zsh Premise Is False](memory/entries/2026-09-05-for-in-guard-block-was-right-its-zsh-premise-is-false.md) · TERMS: GUARD HOOK; SESSION; RELEASE SKILL; RELEASE WORKTREE; WORKTREE · files: guard.py; gotchas.md · gotchas: 4 - 2026-09-05 · [A SKILL AUDIT HOOK On Stop, Housekeeping Skips For PROMPT DADDY And NEBULA-MEMORY, A Quote-Aware GUARD HOOK](memory/entries/2026-09-05-skill-audit-hook-housekeeping-skips-quote-aware-guard.md) · TERMS: SELF-IMPROVING LOOP; GUARD HOOK; PROMPT DADDY; OUTPUT DOCTOR; NEBULA-MEMORY SKILL; SCREENSHOT HARNESS · files: skill_audit.py; guard.py; shot.sh; SKILL.md · gotchas: 7 diff --git a/.claude/memory/entries/2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md b/.claude/memory/entries/2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md new file mode 100644 index 0000000..594fec8 --- /dev/null +++ b/.claude/memory/entries/2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md @@ -0,0 +1,30 @@ +# Resetting HOTKEYS By Hand-Editing CONFIG.JSON Needs A TUI Restart — 2026-08-29 + +**Asked:** "Please reset my keybindings to the default" +→ refined: Reset my HOTKEYS to the KEYMAP defaults: drop the `keybindings` SETTING from CONFIG.JSON in +the DATA DIR (the main instance, `~/Library/Application Support/dev.nebula.nebula/config.json`), leaving +every other SETTING untouched. Don't use the SETTINGS OVERLAY's `R` reset — that resets everything, not +just the HOTKEYS. + +**Did:** No code changed. The user's four overrides (`focus_left: "]"`, `focus_right: "["`, +`focus_terminal: "\\"`, `unlock_terminal: "ctrl+q, d"`) were not in the app-support DATA DIR at all — +they lived in the DEV INSTANCE's, `~/.nebula-dev/nebula-d87bd951/config.json` (`Makefile:32`, +`DEV_DATA = $(HOME)/.nebula-dev/$(notdir $(CURDIR))-$(DEV_SLOT)`). Backed that file up to +`config.json.bak-20260829-133803` and removed the whole `keybindings` key; every other SETTING kept its +value. The four actions go back to `h`/`←`, `l`/`→`, `Ctrl+→`, and `Ctrl+Q`/`Ctrl+Shift+H`/`Ctrl+]`/ +`Ctrl+Esc`/`Ctrl+←` (`crates/nebula-tui/src/keymap.rs:159,168,177,381`). + +**Gotchas:** +- **CONFIG.JSON is *not* read fresh for HOTKEYS.** `TERMS.md`'s CONFIG.JSON row says hand edits apply + live, and that holds for everything the code reads through `Config::load()` — but the KEYMAP is cached + once into `app.keymap` at startup (`event_loop.rs:279`, field at `app.rs:2128`). A hand edit to + `keybindings` does nothing until the TUI restarts. +- **Worse, the next rebind silently reverts the hand edit.** `save_keymap` (`event_loop.rs:3773`) writes + `app.keymap.overrides()` — the *cached* map — so one `⌫`/Enter on the HOTKEYS TAB after a hand edit + writes all the old overrides back. Restart before touching the tab, or skip the file entirely. +- **The in-app equivalent needs no restart:** `⌫` on a HOTKEYS TAB row is `SettingsCmd::ResetHotkey` → + `Keymap::reset(index)` (`event_loop.rs:3612`, `keymap.rs:971`), per row. The overlay's `R` is + `PendingAction::ResetSettings` → `Config::reset_to_defaults`, which rewrites the file from `json!({})` + and so resets *every* SETTING, not just the HOTKEYS. +- **Look in the DEV INSTANCE DATA DIR first on this machine.** The app-support `config.json` does not + exist at all here; `~/.nebula-dev/-/` is where a nebula developer's real settings are. diff --git a/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md b/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md new file mode 100644 index 0000000..d86434c --- /dev/null +++ b/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md @@ -0,0 +1,19 @@ +# Ordered AGENT Workflows Use The SQLITE STORE - 2026-09-06 + +**Asked:** "Investigate how nebula works and all its commands. ... the idea is to build something on top of nebula to create agent workflows, so I can kick off a task that will be self executed in this order of agents we define" Follow-up: "Hey just one thing I noticed, I guess the state could be tracked inside nebula sql already instead of a .nebula folder, that could be more reliable" +→ refined: Investigate NEBULA's commands and build a runnable prototype for ordered AGENT workflows. Start a task from `main`, create a separate WORKTREE, and run a planner followed by an implementer in NEBULA SESSIONS, with a reviewer configurable later (assuming two stages initially). Store the workflow definition under `.nebula` and durable run state in the existing SQLITE STORE, including each stage's AGENT KIND and MODEL / EFFORT. A watcher advances only after an explicit stage result. Create a kickoff skill, demonstrate the handoff, and explain limitations. "Managed workflow" has no TERM yet; record it as a candidate. (Asked: two or three stages, provider mix; no answer received. Defaulted to Claude planner, Codex implementer, NEBULA-configured MODEL / EFFORT.) + +**Did:** Investigated the CLI, WORKTREE/SESSION creation, STARTING PROMPTS, status hooks, and persistence. Added `workflow start/status/list/report/pause/resume`, MIGRATION 24, PROTOCOL VERSION 39, atomic workflow snapshots plus SESSION associations, and a DAEMON-owned watcher. `.nebula/workflow.json` holds only the versioned definition. Added `/workflow-prototype` under `.claude/skills` with an `.agents/skills` symlink, and `docs/workflows.md` with setup, command inventory, state transitions, and recovery. The engine is in `crates/nebula-daemon/src/workflow.rs` and `workflow/watcher.rs`; persistence is in `store/workflows.rs`. Workspace lint and 906 tests passed, including an isolated real DAEMON/WORKTREE/PTY handoff with STUB AGENTS. No real provider execution, live DAEMON restart, commit, or install. MEMORY CHECK already failed on the unrelated unindexed August 29 HOTKEYS entry before this entry was added. + +**Gotchas:** +- FINISHED is a turn state, not task success. The watcher requires both an explicit completed report and FINISHED. The E2E test covers both missing-report and report-before-FINISHED ordering, wrong callers/stages, blocked-result recovery, and immutable completed results. +- WORKTREE and provider process creation cannot share a SQLite transaction. Persist creation/launch intent first; after interruption, adopt the unique recorded-name SESSION or block for inspection. Never blindly retry an uncertain launch. DAEMON restart preserves data but does not automatically restart provider conversations. +- A development DAEMON can be newer than `nebula` on the AGENT's PATH (installed 0.21.0 versus this checkout 0.22.0). Worker STARTING PROMPTS use the shell-quoted DAEMON executable path. The new WORKTREE starts from committed `main`, so the DAEMON supplies the frozen definition; uncommitted prototype files need not exist there. +- STUB AGENTS replace provider argv, so these E2E checks prove orchestration and durability, not real model instruction following. Codex hook setup still touches CODEX_HOME even with a STUB AGENT; the new E2E isolates CODEX_HOME, and the full suite ran with a temporary CODEX_HOME. +- MAKE DEV's database seed originally copied all tables except live SESSIONS. Pending workflow rows would let a development DAEMON launch copied work in the real WORKTREE. The Makefile now strips workflow rows and only publishes a cleaned seed. Three isolated runs of the actual `dev-seed` recipe passed: legacy schema, workflow schema, and failed cleanup leaving no published database; source databases remained unchanged. + +**Corrections:** 1 + +Configuration follow-up: the user requested "Please set claude sonnet medium effort for planner and for implementer too, same models and effort", then "Are you sure this model namei s correct? it needs to match exactly". Both stages now declare `kind: claude`, `model: claude-sonnet-5`, and `effort: medium`; the kickoff skill and guide match. This supersedes the initial provider assumption above and applies to new runs. + +Verified the exact MODEL / EFFORT with the installed Claude Code 2.1.263 and the official model configuration docs (`https://code.claude.com/docs/en/model-config`): `sonnet` is an alias; the full ID pins Sonnet 5. A live, tool-free, nonpersistent request from a temporary directory using `--safe-mode --model claude-sonnet-5 --effort medium` returned `OK`, with `claude-sonnet-5` in `modelUsage` (the harness also recorded an auxiliary Haiku call). This verified model access, not a complete live workflow handoff. diff --git a/.claude/memory/gotchas.md b/.claude/memory/gotchas.md index 3d52c5b..1487d28 100644 --- a/.claude/memory/gotchas.md +++ b/.claude/memory/gotchas.md @@ -209,6 +209,7 @@ and a line a test, type or GUARD HOOK rule now enforces is deleted. Each line en - **CONFIG.JSON** — A test that never pins the config path reads (and, through a key that saves, *writes*) the dev's real `config.json`: `save` `assert!`s `CONFIG_PATH_OVERRIDE` under `cfg(test)`, but a fresh `Config::load()` in the code under test just passes locally and fails elsewhere — audit for a missing `with_default_config` / `with_config_path` whenever a path newly touches config. ⟵ 2026-08-25-the-workspaces-column-remembers-itself-and-crosses-workspace; 2026-08-28-harnesses-can-be-switched-off-in-settings-and-leave-the-new ## Dev workflow and testing +- **E2E PTY** - STUB AGENTS still execute Codex hook installation in `CODEX_HOME`; give tests a temporary `CODEX_HOME` as well as isolated NEBULA data/runtime dirs to avoid changing the user's provider hooks. ⟵ 2026-09-06-ordered-agent-workflows-use-the-sqlite-store - **PROJECT TERMS** — Moving prose between Markdown files (a README split, a rewrap, a `TERMS.md` merge) drops whole paragraphs silently: diff every old line ≥25 chars, whitespace-normalized, against the *whole* new set before calling the move lossless — a 439→172-line README split lost four intact paragraphs (CLOUD MIRROR, AGENT PRESETS, PROJECT OPEN PRS, the Cursor catalogue) that way. TERMS CHECK's corpus (`terms_check.py::corpus_lines`) reads `README.md`, `CLAUDE.md`, `AGENTS.md`, the entries and the skills — prose moved anywhere else goes unscanned and its TERMS report `dead` (`docs/*.md` was added to it for this reason). No gate checks whether a DOCS PAGES or TERMS.md sentence is still *true* — TERMS CHECK validates pointers, aliases and dead/once TERMS, MEMORY CHECK validates caps — so behavior that changes under a line nobody re-reads (`q` gained a CONFIRM DIALOG; PROTOCOL VERSION went 33→34) stays green and wrong until someone audits it against the code. ⟵ 2026-08-30-readme-cut-to-a-landing-page-with-reference-moved-to-docs; 2026-08-29-claude-md-imports-agents-md-and-names-triggers-not-mechanics; 2026-08-30-a-ten-agent-docs-audit-against-the-code · re-hit ×3 2026-08-30 - **DEV INSTANCE** — Isolation is inherited, not propagated: `NEBULA_RUNTIME_DIR`/`NEBULA_DATA_DIR` flow make → TUI → daemon → agent PTY; the daemon adds only `NEBULA_AGENT_*`/`NEBULA_API_*`, never *which binary*; two checkouts sharing a runtime dir silently connect the second TUI to the first's daemon (the other checkout's binary) and `dev-prep`'s `dev-stop` SIGTERMs the other's daemon. ⟵ 2026-08-26-nebula-rename-broke-on-a-protocol-skew-the-error-message-mis; 2026-08-26-one-nebula-per-checkout-auto-port-for-browser-per-path-dev-s - **DEV INSTANCE** — `~/.nebula-dev/config.json` has `show_workspaces: false`; a fresh slot inherits it through `dev-seed`, so the WORKSPACES BAR starts hidden and looks broken — `Shift+W`. ⟵ 2026-08-26-one-nebula-per-checkout-auto-port-for-browser-per-path-dev-s diff --git a/.claude/skills/workflow-prototype/SKILL.md b/.claude/skills/workflow-prototype/SKILL.md new file mode 100644 index 0000000..4f80e54 --- /dev/null +++ b/.claude/skills/workflow-prototype/SKILL.md @@ -0,0 +1,40 @@ +--- +name: workflow-prototype +description: Start an ordered AGENT workflow from main using NEBULA WORKTREES and SESSIONS, with durable state in the SQLITE STORE. Use when the user asks to kick off a managed task, run planner then implementer, or execute the stages in .nebula/workflow.json. +--- + +# Start a workflow + +The DAEMON creates the WORKTREE, launches the configured AGENTS, stores their results, and +advances the stages. This skill only submits the task. Read `docs/workflows.md` for recovery +or configuration details. + +1. Read `.nebula/workflow.json`. Preserve the declared stage order and MODEL / EFFORT unless + the user requested a change. Both planner and implementer use `claude-sonnet-5` with medium effort. +2. Check `nebula workflow --help`. If the installed command is older than the prototype, + use this checkout's `target/debug/nebula` and its isolated development DAEMON. Never + install a binary or stop the live DAEMON to resolve a VERSION SKEW without authorization. +3. Run `nebula workflow status --json`. Success means this SESSION is already assigned to + a workflow: continue its current stage and report its result instead of starting another. + A "does not belong" response is normal for the kickoff SESSION; other errors need resolving. +4. Write the user's task, including its constraints, to a temporary UTF-8 file. From the ROOT + WORKTREE on `main`, run the checked executable with: + + ```sh + nebula workflow start --task-file /absolute/path/to/task.txt \ + --definition /absolute/path/to/.nebula/workflow.json + ``` + + Use the actual paths and shell-quote them. Delete only the temporary task file you created + after a successful response. If the request times out, inspect `nebula workflow list` + before considering another start: WORKTREE creation may have succeeded. +5. Report the run id, WORKTREE path, configured AGENT order, and current status. The caller + remains on `main`. The DAEMON continues independently after this SESSION ends its turn. + +Workers must send `workflow report` through the executable named in their STARTING PROMPT, +as their final tool call. A completed result requires a Markdown file; the DAEMON copies its +contents into the SQLITE STORE. Report `blocked` with a concrete reason when work cannot +finish. Never infer completion from FINISHED alone or manually edit the database. + +The prototype leaves all changes uncommitted. It does not authorize commits, pushes, merges, +deployments, permission bypasses, or other actions outside the user's task. diff --git a/.nebula/workflow.json b/.nebula/workflow.json new file mode 100644 index 0000000..10d70d7 --- /dev/null +++ b/.nebula/workflow.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "timeout_seconds": 1800, + "stages": [ + { + "id": "planner", + "kind": "claude", + "model": "claude-sonnet-5", + "effort": "medium", + "instructions": "Investigate the task and write an actionable plan with acceptance criteria, affected files, and verification steps. Leave product code unchanged." + }, + { + "id": "implementer", + "kind": "claude", + "model": "claude-sonnet-5", + "effort": "medium", + "instructions": "Read the planner result, implement the task, and run the relevant checks. Report the changed behavior, verification evidence, and any unresolved issues." + } + ] +} diff --git a/Makefile b/Makefile index 05c3d87..0dac30b 100644 --- a/Makefile +++ b/Makefile @@ -94,8 +94,8 @@ dev-prep: # A blank dev instance is useless for eyeballing a change — you'd re-add every # project by hand first. So the first `make dev` snapshots the real DB and -# settings, minus `agents` and `terminals`: those rows are the live sessions -# the real daemon owns, and the dev daemon must not resume them. `.backup` +# settings, minus live SESSIONS and workflow runs: the development DAEMON +# must not resume work already owned by the real DAEMON. `.backup` # reads the WAL, so the copy is consistent even with the real daemon running. # The real dir is where `directories::ProjectDirs::from("dev","nebula","nebula")` # puts it (nebula-core/src/paths.rs); keep the two in step. @@ -110,8 +110,16 @@ dev-seed: ## Copy real projects/workspaces/settings into the dev instance (only if ! command -v sqlite3 >/dev/null 2>&1; then \ echo "sqlite3 not on PATH — dev instance starts empty"; exit 0; fi; \ mkdir -p $(DEV_DATA); \ - sqlite3 "$$real/nebula.db" ".backup '$(DEV_DATA)/nebula.db'"; \ - sqlite3 $(DEV_DATA)/nebula.db "DELETE FROM agents; DELETE FROM terminals;"; \ + seed_db=$$(mktemp '$(DEV_DATA)/seed.XXXXXX') || exit 1; \ + trap 'rm -f "$$seed_db"' EXIT; \ + sqlite3 "$$real/nebula.db" ".backup '$$seed_db'" || exit 1; \ + sqlite3 "$$seed_db" "DELETE FROM agents; DELETE FROM terminals;" || exit 1; \ + workflow_table=$$(sqlite3 "$$seed_db" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='workflow_runs';") || exit 1; \ + if [ "$$workflow_table" = 1 ]; then \ + sqlite3 "$$seed_db" "BEGIN; DELETE FROM workflow_sessions; DELETE FROM workflow_runs; COMMIT;" || exit 1; \ + fi; \ + mv "$$seed_db" '$(DEV_DATA)/nebula.db' || exit 1; \ + trap - EXIT; \ for f in config.json reviewed.json; do \ if [ -f "$$real/$$f" ]; then cp "$$real/$$f" $(DEV_DATA)/; fi; \ done; \ diff --git a/TERMS.md b/TERMS.md index ee55f79..e1b0f71 100644 --- a/TERMS.md +++ b/TERMS.md @@ -61,10 +61,10 @@ Everything nests: WORKSPACE → PROJECT → WORKTREE → SESSION. The DAEMON own | **RUNTIME DIR** | Socket + PIDFILE directory: `$NEBULA_RUNTIME_DIR`, else `$XDG_RUNTIME_DIR/nebula`, else `/tmp/nebula-`. | — | `paths.rs::runtime_dir` | | **DATA DIR** | Where the SQLITE STORE, CONFIG.JSON, REVIEWED MARK and the SSH HOSTS file live: `$NEBULA_DATA_DIR` or the platform app-support dir (`~/.local/share/nebula`, `~/Library/Application Support/dev.nebula.nebula`). | "data dir" | `paths.rs::data_dir` · `NEBULA_DATA_DIR` | | **DAEMON LOG** | `daemon.log` (beside `tui.log`) in the state dir — `~/.local/state/nebula/`, or `/state` when `NEBULA_DATA_DIR` is set. `NEBULA_LOG=debug` for more. No `daemon.log` at all means the daemon never started. | "daemon.log", "the logs" | `paths.rs::daemon_log_path` · `NEBULA_LOG` | -| **SQLITE STORE** | `nebula.db` in the DATA DIR: workspaces, projects, worktrees, agents (kind + CLI session id + the `pr_url` an OPEN PRS launch carries), terminals, links, `pr_seen`, `ui_state`. Schema advanced by MIGRATIONS. | "the db", "sqlite" | `nebula-daemon/src/store.rs::Store` | -| **MIGRATION** | A numbered `PRAGMA user_version` step in the store (22 so far). Adding a column = a migration; adding a field to an entity usually also = a PROTOCOL VERSION bump. | "migration" | `store.rs::MIGRATIONS` | +| **SQLITE STORE** | `nebula.db` in the DATA DIR: workspaces, projects, worktrees, agents (kind + CLI session id + the `pr_url` an OPEN PRS launch carries), terminals, links, `pr_seen`, `ui_state`. Also holds ordered workflow snapshots, results, and SESSION associations. Schema advanced by MIGRATIONS. | "the db", "sqlite", "nebula sql" | `nebula-daemon/src/store.rs::Store` | +| **MIGRATION** | A numbered `PRAGMA user_version` step in the store (24 so far). Adding a column = a migration; adding a field to an entity usually also = a PROTOCOL VERSION bump. | "migration" | `store.rs::MIGRATIONS` | | **BUILDSTAMP** | A content hash of the running daemon binary written to `daemon.build` at start. Installers compare it to detect a stale daemon (STALE DAEMON NOTE); it cannot name the binary's path. | "buildstamp" | `lifecycle.rs::write_buildstamp` · `paths.rs::buildstamp_path` | -| **PROTOCOL VERSION** | `PROTOCOL_VERSION` (38) exchanged in the VERSION SKEW. Frames are positional msgpack, so any new field on a shared struct bumps it. Two branches that each bump it merge to the *same* number — diff it against `origin/main` before a release commit. | "protocol", "v26/v27/v28/v29/v30/v32" | `nebula-core/src/protocol.rs::PROTOCOL_VERSION` | +| **PROTOCOL VERSION** | `PROTOCOL_VERSION` (39) exchanged in the VERSION SKEW. Frames are positional msgpack, so any new field on a shared struct bumps it. Two branches that each bump it merge to the *same* number — diff it against `origin/main` before a release commit. | "protocol", "v26/v27/v28/v29/v30/v32" | `nebula-core/src/protocol.rs::PROTOCOL_VERSION` | | **VERSION SKEW** | A DAEMON and a client built from different PROTOCOL VERSIONS, caught by the handshake (`Hello{protocol_version}` → `HelloOk{daemon_pid}` or `Incompatible`, then `Subscribe` → `Snapshot`). The client's message names both binaries and says which side is older: `make install` when the client is, `nebula kill` when the daemon is. | "protocol mismatch", "daemon speaks protocol v26, this client v24", "the hook still seems to fail", "handshake" | `nebula-tui/src/ipc.rs::version_skew_message` | | **SERVER EVENT** | The event family the DAEMON pushes: `Snapshot`, `EntityUpserted`/`EntityRemoved` (deltas), `StatusChanged{unseen}`, `Scrollback`, `Output`, `SessionExited`, `KittyFlags`, `Metrics`. | "delta", "upsert" | `protocol.rs::ServerEvent` | | **IPC CODEC** | Length-prefixed MessagePack frames over the DAEMON SOCKET, 4 MiB max. | "msgpack", "rmp", "client request" | `nebula-core/src/codec.rs` | @@ -384,6 +384,7 @@ is pruned. Do not cross-reference a candidate in caps from a TERM row. | CANDIDATE | What it seems to be | Seen | Where | |---|---|---|---| +| **MANAGED WORKFLOW** | An ordered list of AGENTS configured in `.nebula/workflow.json`, kicked off from `main` by `/workflow-prototype`; the DAEMON owns its WORKTREE, SESSIONS, watcher and durable run state in the SQLITE STORE. | 2026-09-06 prompt ("create agent workflows", "state could be tracked inside nebula sql") | `nebula-daemon/src/workflow.rs::workflow_op` · `nebula-daemon/src/workflow/watcher.rs::tick_workflows` · `.claude/skills/workflow-prototype/SKILL.md` | | **LAND SKILL** | The `land` skill: the git and `gh` chores that put finished work on `main` — MAKE CI, a branch, one commit, a push, a PR, CLAUDE REVIEW's comments when asked to babysit, `gh pr merge --merge`, the checkout fast-forwarded back — standing in for PROMPT DADDY, NEBULA-MEMORY and PROJECT TERMS on that prompt. | 2026-09-05 AskUserQuestion pick ("New LAND skill for git chores") · 2026-09-05 MEMORY "A SKILL AUDIT HOOK On Stop, Housekeeping Skips For PROMPT DADDY And NEBULA-MEMORY, A Quote-Aware GUARD HOOK" | `.claude/skills/land/SKILL.md` | | **SKILL AUDIT HOOK** | The `Stop` hook that fires once per closed task — after a turn in which the NEBULA-MEMORY SKILL ran; `NEBULA_SKILL_AUDIT_COOLDOWN_MIN` spaces audits out — and keeps the turn going with a brief on the skills the session invoked (names, invocations, body sizes) so the agent proposes at most three cuts, merges, tightened rules or a new skill as one `AskUserQuestion` and applies what the user picks; unattended it writes `.claude/memory/skill-audit/` instead. `NEBULA_SKILL_AUDIT=off` silences it. | 2026-09-05 prompt ("a hook that runs at the very end which will self improve all of my skills") · 2026-09-05 MEMORY "A SKILL AUDIT HOOK On Stop, Housekeeping Skips For PROMPT DADDY And NEBULA-MEMORY, A Quote-Aware GUARD HOOK" | `.claude/hooks/skill_audit.py` · `.claude/settings.json` | | **RELEASE SNAPSHOT** | Two commits on a scratch branch — S, the SHARED CHECKOUT as it stood at the previous release cut (that release's scratchpad `local.patch` applied to the shared HEAD), and T, the SHARED CHECKOUT now — so `git cherry-pick --no-commit T` onto `origin/main` merges only the post-release delta into the RELEASE WORKTREE. | 2026-08-28 MEMORY "Released v0.15.0 By Cherry-Picking Only The Post-v0.14.0 Delta Onto origin/main" | branch `snap-shared` · `git cherry-pick --no-commit ` | @@ -515,7 +516,7 @@ The user's word → the TERM. A word under two TERMS is ambiguous: settle it (PR | "the socket", "pidfile", "buildstamp" | DAEMON SOCKET / BUILDSTAMP | | "the ui", "the app", "quit", "q", "closed out" | TUI | | "this app", "the app" (the whole system, not just the client) | NEBULA / TUI | -| "the db", "sqlite", "migration" | SQLITE STORE / MIGRATION | +| "the db", "sqlite", "nebula sql", "migration" | SQLITE STORE / MIGRATION | | "config", "settings file" | CONFIG.JSON | | "daemon.log", "the logs" | DAEMON LOG | | "nebula ssh", "ssh into the machine" | NEBULA SSH | diff --git a/crates/nebula-core/src/lib.rs b/crates/nebula-core/src/lib.rs index b4bd3d5..66f0b73 100644 --- a/crates/nebula-core/src/lib.rs +++ b/crates/nebula-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod ids; pub mod mem; pub mod paths; pub mod protocol; +pub mod workflow; pub use entities::*; pub use ids::*; diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index c02da23..6b4e0c1 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; /// Bump on any breaking change to these enums. The daemon refuses mismatched /// clients; the client then offers a kill-and-restart of the old daemon. -pub const PROTOCOL_VERSION: u32 = 38; +pub const PROTOCOL_VERSION: u32 = 39; /// Max IPC frame size (length prefix sanity bound). pub const MAX_FRAME_LEN: u32 = 4 * 1024 * 1024; @@ -26,6 +26,11 @@ pub enum SessionRef { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ClientRequest { + /// Prototype ordered workflows; persisted and advanced by the DAEMON. + Workflow { + req_id: u64, + op: crate::workflow::WorkflowOp, + }, Hello { protocol_version: u32, }, @@ -421,6 +426,10 @@ pub enum EnterOutcome { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ServerEvent { + Workflow { + req_id: u64, + reply: crate::workflow::WorkflowReply, + }, HelloOk { protocol_version: u32, daemon_pid: u32, diff --git a/crates/nebula-core/src/workflow.rs b/crates/nebula-core/src/workflow.rs new file mode 100644 index 0000000..e5218b0 --- /dev/null +++ b/crates/nebula-core/src/workflow.rs @@ -0,0 +1,131 @@ +//! Prototype workflow definitions and durable run state. The DAEMON owns transitions. + +use crate::{AgentId, AgentKind, ProjectId, Worktree}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowDefinition { + pub version: u32, + pub timeout_seconds: u64, + pub stages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StageDefinition { + pub id: String, + pub kind: AgentKind, + pub model: Option, + pub effort: Option, + pub instructions: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowStatus { + Creating, + Running, + Waiting, + Paused, + Blocked, + Completed, +} + +impl WorkflowStatus { + pub fn active(self) -> bool { + matches!(self, Self::Creating | Self::Running | Self::Waiting) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StageStatus { + Pending, + Launching, + Running, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StageOutcome { + Completed, + Blocked, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StageResult { + pub outcome: StageOutcome, + pub summary: String, + /// Markdown copied from the AGENT's result file; SQLite is authoritative. + pub artifact: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageRun { + pub status: StageStatus, + pub agent: Option, + pub started_at: i64, + pub result: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRun { + pub id: String, + pub project: ProjectId, + /// Retained as historical context even if the user deletes the WORKTREE. + pub worktree: Option, + pub branch: String, + pub base: String, + pub task: String, + /// MODEL / EFFORT defaults are resolved and frozen at kickoff. + pub definition: WorkflowDefinition, + pub stages: Vec, + pub current: usize, + pub status: WorkflowStatus, + pub message: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowSummary { + pub id: String, + pub branch: String, + pub status: WorkflowStatus, + pub current: usize, + pub total: usize, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WorkflowOp { + Start { + caller: AgentId, + task: String, + definition: WorkflowDefinition, + }, + Status { + id: Option, + caller: Option, + }, + List, + Report { + caller: AgentId, + stage: String, + result: StageResult, + }, + Pause { + id: String, + }, + Resume { + id: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WorkflowReply { + Run(Box), + List(Vec), +} diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index fdb5506..6e652fd 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -67,6 +67,16 @@ pub async fn current_branch(repo: &Path) -> Result { Ok(branch.to_string()) } +/// Pin the local main branch before creating a workflow's WORKTREE. +pub async fn main_commit(repo: &Path) -> Result { + Ok( + git(repo, &["rev-parse", "--verify", "refs/heads/main^{commit}"]) + .await? + .trim() + .to_string(), + ) +} + #[derive(Debug, Clone)] pub struct WorktreeEntry { pub path: PathBuf, diff --git a/crates/nebula-daemon/src/lib.rs b/crates/nebula-daemon/src/lib.rs index e7e339f..a1e6bd8 100644 --- a/crates/nebula-daemon/src/lib.rs +++ b/crates/nebula-daemon/src/lib.rs @@ -13,6 +13,7 @@ pub mod session_title; pub mod sibling; pub mod status; pub mod store; +pub mod workflow; use anyhow::{bail, Context, Result}; use nebula_core::{env, paths}; @@ -81,6 +82,7 @@ async fn serve() -> Result<()> { tracing::info!(port = hook_env.port, "hook receiver listening"); let daemon = registry::Daemon::new(store, hook_env); + daemon.start_workflow_watcher(); // Drain hook events into the status machines; a payload that reports a // cwd inside another worktree of the same project re-homes the agent row. diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index d5c3b7b..c8a18da 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -131,6 +131,8 @@ pub struct Daemon { /// Serializes worktree create/delete with the background auto-sync so /// a checkout is never adopted twice while its row is mid-insert. worktree_ops: tokio::sync::Mutex<()>, + /// The watcher and CLI reports serialize each workflow transition. + pub(crate) workflow_ops: tokio::sync::Mutex<()>, /// Warm agent CLIs awaiting adoption, at most one per (worktree, kind). prewarmed: Mutex>, /// Cached `command -v` results per CLI so a missing binary doesn't get @@ -195,6 +197,7 @@ impl Daemon { session_installs, shutdown: tokio_util::sync::CancellationToken::new(), worktree_ops: tokio::sync::Mutex::new(()), + workflow_ops: tokio::sync::Mutex::new(()), prewarmed: Mutex::new(HashMap::new()), cli_probes: Mutex::new(HashMap::new()), attach_counts: Mutex::new(HashMap::new()), diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 61cb89c..89cff00 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -137,6 +137,16 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { } }); } + ClientRequest::Workflow { req_id, op } => { + let reply = match daemon.workflow_op(op).await { + Ok(reply) => ServerEvent::Workflow { req_id, reply }, + Err(err) => ServerEvent::Error { + req_id: Some(req_id), + message: format!("{err:#}"), + }, + }; + let _ = out_tx.send(reply).await; + } ClientRequest::Attach { session: sref, from_seq, diff --git a/crates/nebula-daemon/src/store.rs b/crates/nebula-daemon/src/store.rs index 67e4748..0086bfa 100644 --- a/crates/nebula-daemon/src/store.rs +++ b/crates/nebula-daemon/src/store.rs @@ -12,6 +12,8 @@ use rusqlite::{params, Connection}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +mod workflows; + const MIGRATIONS: &[&str] = &[ // 1: initial schema " @@ -241,6 +243,21 @@ const MIGRATIONS: &[&str] = &[ " ALTER TABLE agents ADD COLUMN claude_title TEXT; ", + // 24: prototype workflows. Historical SESSION/WORKTREE ids intentionally + // survive entity deletion; the watcher blocks when their rows disappear. + " + CREATE TABLE workflow_runs ( + id TEXT PRIMARY KEY, + active INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + json TEXT NOT NULL + ); + CREATE INDEX workflow_runs_active ON workflow_runs(active); + CREATE TABLE workflow_sessions ( + agent_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE + ); + ", ]; pub struct Store { diff --git a/crates/nebula-daemon/src/store/workflows.rs b/crates/nebula-daemon/src/store/workflows.rs new file mode 100644 index 0000000..b9a4acc --- /dev/null +++ b/crates/nebula-daemon/src/store/workflows.rs @@ -0,0 +1,99 @@ +//! Workflow snapshots and SESSION associations commit together in the SQLITE STORE. + +use super::Store; +use anyhow::{Context, Result}; +use nebula_core::{ + workflow::{WorkflowRun, WorkflowSummary}, + AgentId, +}; +use rusqlite::{params, OptionalExtension}; + +impl Store { + pub fn save_workflow(&self, run: &WorkflowRun) -> Result<()> { + let json = serde_json::to_string(run)?; + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO workflow_runs (id, active, updated_at, json) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET active = excluded.active, + updated_at = excluded.updated_at, json = excluded.json", + params![run.id, run.status.active(), run.updated_at, json], + )?; + for agent in run.stages.iter().filter_map(|stage| stage.agent.as_ref()) { + tx.execute( + "INSERT INTO workflow_sessions (agent_id, run_id) VALUES (?1, ?2) + ON CONFLICT(agent_id) DO NOTHING", + params![agent.as_str(), run.id], + )?; + let owner: String = tx.query_row( + "SELECT run_id FROM workflow_sessions WHERE agent_id = ?1", + [agent.as_str()], + |r| r.get(0), + )?; + anyhow::ensure!( + owner == run.id, + "SESSION already belongs to another workflow" + ); + } + tx.commit()?; + Ok(()) + } + + pub fn workflow(&self, id: &str) -> Result { + let raw: String = self + .conn + .lock() + .unwrap() + .query_row("SELECT json FROM workflow_runs WHERE id = ?1", [id], |r| { + r.get(0) + }) + .optional()? + .context("workflow not found")?; + Ok(serde_json::from_str(&raw)?) + } + + pub fn workflow_for_agent(&self, agent: &AgentId) -> Result> { + let raw: Option = self + .conn + .lock() + .unwrap() + .query_row( + "SELECT r.json FROM workflow_runs r JOIN workflow_sessions s ON s.run_id = r.id + WHERE s.agent_id = ?1", + [agent.as_str()], + |r| r.get(0), + ) + .optional()?; + raw.map(|s| serde_json::from_str(&s).map_err(Into::into)) + .transpose() + } + + pub fn active_workflow_ids(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT id FROM workflow_runs WHERE active = 1 ORDER BY updated_at")?; + let rows = stmt + .query_map([], |r| r.get(0))? + .collect::>()?; + Ok(rows) + } + + pub fn workflow_summaries(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT json FROM workflow_runs ORDER BY updated_at DESC LIMIT 50")?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + rows.map(|row| { + let run: WorkflowRun = serde_json::from_str(&row?)?; + Ok(WorkflowSummary { + id: run.id, + branch: run.branch, + status: run.status, + current: run.current, + total: run.stages.len(), + message: run.message, + }) + }) + .collect() + } +} diff --git a/crates/nebula-daemon/src/workflow.rs b/crates/nebula-daemon/src/workflow.rs new file mode 100644 index 0000000..a1d8f33 --- /dev/null +++ b/crates/nebula-daemon/src/workflow.rs @@ -0,0 +1,209 @@ +//! Prototype sequential workflows. Only the DAEMON writes run state or launches stages. + +mod prompts; +#[cfg(test)] +mod tests; +mod watcher; + +use anyhow::{bail, ensure, Context, Result}; +use nebula_core::workflow::*; +use nebula_core::{AgentId, AgentStatus, EntityId, SessionRef}; +use std::sync::Arc; + +use crate::{git, registry::Daemon}; + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +impl Daemon { + pub async fn workflow_op(self: &Arc, op: WorkflowOp) -> Result { + let _guard = self.workflow_ops.lock().await; + let run = match op { + WorkflowOp::Start { + caller, + task, + definition, + } => self.start_workflow(&caller, task, definition).await?, + WorkflowOp::Status { id, caller } => match (id, caller) { + (Some(id), _) => self.store.workflow(&id)?, + (None, Some(caller)) => self + .store + .workflow_for_agent(&caller)? + .context("this SESSION does not belong to a workflow")?, + _ => bail!("name a workflow id, or run status inside a managed SESSION"), + }, + WorkflowOp::List => return Ok(WorkflowReply::List(self.store.workflow_summaries()?)), + WorkflowOp::Report { + caller, + stage, + result, + } => self.report_workflow(&caller, &stage, result)?, + WorkflowOp::Pause { id } => { + let mut run = self.store.workflow(&id)?; + ensure!( + run.status != WorkflowStatus::Completed, + "workflow already completed" + ); + run.status = WorkflowStatus::Paused; + run.message = "scheduling paused; the current SESSION is left running".into(); + self.persist_workflow(&mut run)?; + run + } + WorkflowOp::Resume { id } => { + let mut run = self.store.workflow(&id)?; + ensure!( + matches!(run.status, WorkflowStatus::Blocked | WorkflowStatus::Paused), + "only a blocked or paused workflow can resume" + ); + ensure!( + run.worktree.is_some(), + "WORKTREE creation was interrupted; inspect it before starting another run" + ); + let stage = &mut run.stages[run.current]; + if let Some(agent) = &stage.agent { + let row = self + .store + .get_agent(agent)? + .context("assigned SESSION was deleted")?; + ensure!(self.is_alive(&SessionRef::Agent(agent.clone())) || + (row.status == AgentStatus::Finished && stage.result.as_ref() + .is_some_and(|r| r.outcome == StageOutcome::Completed)), + "assigned SESSION stopped; reopen it in NEBULA and continue its stage before resuming"); + } + ensure!(!stage.result.as_ref().is_some_and(|r| r.outcome == StageOutcome::Blocked), + "the AGENT reported a blocker; resolve it in that SESSION and report completed first"); + stage.started_at = now_ms(); + run.status = WorkflowStatus::Running; + run.message = "watcher resumed".into(); + self.persist_workflow(&mut run)?; + run + } + }; + Ok(WorkflowReply::Run(Box::new(run))) + } + + async fn start_workflow( + self: &Arc, + caller: &AgentId, + task: String, + definition: WorkflowDefinition, + ) -> Result { + prompts::validate(&task, &definition)?; + ensure!( + self.store.workflow_for_agent(caller)?.is_none(), + "a workflow AGENT cannot start a nested workflow" + ); + let agent = self + .store + .get_agent(caller)? + .context("caller SESSION not found")?; + ensure!(!agent.archived, "caller SESSION is archived"); + let root = self + .store + .get_worktree(&agent.worktree_id)? + .context("caller WORKTREE not found")?; + ensure!( + root.is_main && git::current_branch(&root.path).await? == "main", + "start a workflow from the ROOT WORKTREE on main" + ); + let id = ulid::Ulid::generate().to_string(); + let mut run = WorkflowRun { + branch: format!("workflow-{}", id.to_lowercase()), + id, + project: root.project_id.clone(), + worktree: None, + base: git::main_commit(&root.path).await?, + task, + stages: definition + .stages + .iter() + .map(|_| StageRun { + status: StageStatus::Pending, + agent: None, + started_at: 0, + result: None, + }) + .collect(), + definition, + current: 0, + status: WorkflowStatus::Creating, + message: "creating WORKTREE".into(), + created_at: now_ms(), + updated_at: now_ms(), + }; + self.store.save_workflow(&run)?; + let created = self + .create_worktree(&run.project, &run.branch, Some(&run.base)) + .await; + match created { + Ok(EntityId::Worktree(id)) => { + run.worktree = self.store.get_worktree(&id)?; + run.status = WorkflowStatus::Running; + run.message = "WORKTREE created; first stage queued".into(); + } + Ok(_) => bail!("unexpected create WORKTREE result"), + Err(err) => { + run.status = WorkflowStatus::Blocked; + run.message = format!("WORKTREE creation failed: {err:#}"); + } + } + self.persist_workflow(&mut run)?; + Ok(run) + } + + fn persist_workflow(&self, run: &mut WorkflowRun) -> Result<()> { + run.updated_at = now_ms(); + self.store.save_workflow(run) + } + + fn report_workflow( + &self, + caller: &AgentId, + stage_id: &str, + result: StageResult, + ) -> Result { + prompts::validate_result(&result)?; + let mut run = self + .store + .workflow_for_agent(caller)? + .context("SESSION is not managed by a workflow")?; + ensure!(run.current < run.stages.len(), "workflow already completed"); + ensure!( + run.definition.stages[run.current].id == stage_id, + "report is for a different stage" + ); + let stage = &mut run.stages[run.current]; + ensure!( + stage.agent.as_ref() == Some(caller) && stage.status == StageStatus::Running, + "only the assigned AGENT may report the current stage" + ); + let agent = self + .store + .get_agent(caller)? + .context("SESSION was deleted")?; + ensure!( + !agent.archived + && run + .worktree + .as_ref() + .is_some_and(|w| w.id == agent.worktree_id), + "SESSION was archived or relocated" + ); + if let Some(previous) = &stage.result { + if previous == &result { + return Ok(run); + } + ensure!( + previous.outcome != StageOutcome::Completed, + "completed report cannot be overwritten" + ); + } + stage.result = Some(result); + self.persist_workflow(&mut run)?; + Ok(run) + } +} diff --git a/crates/nebula-daemon/src/workflow/prompts.rs b/crates/nebula-daemon/src/workflow/prompts.rs new file mode 100644 index 0000000..92f7410 --- /dev/null +++ b/crates/nebula-daemon/src/workflow/prompts.rs @@ -0,0 +1,106 @@ +use anyhow::{ensure, Result}; +use nebula_core::{workflow::*, AgentKind, MAX_CLOUD_PROMPT_BYTES}; +use std::collections::HashSet; + +fn checked(value: &str, label: &str, max: usize) -> Result<()> { + ensure!( + !value.trim().is_empty() && !value.contains('\0') && value.len() <= max, + "{label} must be nonempty, NUL-free text of at most {max} bytes" + ); + Ok(()) +} + +pub(super) fn validate(task: &str, definition: &WorkflowDefinition) -> Result<()> { + checked(task, "task", 8000)?; + ensure!(definition.version == 1, "workflow version must be 1"); + ensure!( + (10..=86400).contains(&definition.timeout_seconds), + "timeout_seconds must be 10..86400" + ); + ensure!( + (1..=10).contains(&definition.stages.len()), + "workflow needs 1..10 stages" + ); + let mut names = HashSet::new(); + for stage in &definition.stages { + checked(&stage.id, "stage id", 40)?; + ensure!( + stage.id.starts_with(|c: char| c.is_ascii_lowercase()) + && stage + .id + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_-".contains(&c)) + && names.insert(&stage.id), + "stage ids must be unique lowercase slugs" + ); + ensure!( + matches!(stage.kind, AgentKind::Claude | AgentKind::Codex), + "prototype supports claude and codex" + ); + checked(&stage.instructions, "stage instructions", 4000)?; + for value in [&stage.model, &stage.effort].into_iter().flatten() { + checked(value, "MODEL / EFFORT", 128)?; + ensure!( + !value.starts_with('-') && !value.chars().any(char::is_control), + "invalid MODEL / EFFORT" + ); + } + } + Ok(()) +} + +pub(super) fn validate_result(result: &StageResult) -> Result<()> { + checked(&result.summary, "summary", 4000)?; + if result.outcome == StageOutcome::Completed { + checked(&result.artifact, "completed stage artifact", 64 * 1024)?; + } else { + ensure!( + result.artifact.len() <= 64 * 1024 && !result.artifact.contains('\0'), + "invalid artifact" + ); + } + Ok(()) +} + +pub(super) fn session_name(run: &WorkflowRun) -> String { + format!( + "workflow {} {}", + run.id, run.definition.stages[run.current].id + ) +} + +pub(super) fn starting_prompt(run: &WorkflowRun) -> Result { + let stage = &run.definition.stages[run.current]; + let worktree = run.worktree.as_ref().expect("assigned before launch"); + // A development DAEMON may be newer than the installed NEBULA on PATH. + let executable = std::env::current_exe()?; + let command = format!( + "'{}' workflow", + executable.to_string_lossy().replace('\'', "'\\''") + ); + let prompt = format!( + "You are the {} AGENT in workflow {}.\n\ + This is an already-created managed WORKTREE at {} on branch {}. Stay here.\n\ + Runtime state and previous stage artifacts are in NEBULA's SQLITE STORE.\n\ + First run `{command} status {} --json` and read the task and previous stage results.\n\n\ + Task: {}\n\nYour stage: {}\n\n\ + Work autonomously within this task and the repository rules. The DAEMON owns stage ordering.\n\ + Do not start another workflow, launch other SESSIONS, or relocate this WORKTREE.\n\ + Do not commit, push, merge, publish, or deploy. Leave changes for the user to inspect.\n\ + After all stage work, checks, and repository bookkeeping, write a Markdown result to\n\ + a temporary file outside the checkout. Replace RESULT_FILE below with its quoted absolute path.\n\ + As your LAST tool call run:\n\ + {command} report --stage {} --outcome completed --file RESULT_FILE \ + --summary \"Stage completed\"\n\ + Then end your turn. The next stage waits for both this report and FINISHED status.\n\ + If blocked, run `{command} report --stage {} --outcome blocked --summary \"\"`\n\ + and end your turn. Never report completed for unfinished work.", + stage.id, run.id, worktree.path.display(), run.branch, run.id, run.task, + stage.instructions, stage.id, stage.id, + ); + ensure!( + prompt.len() <= MAX_CLOUD_PROMPT_BYTES, + "composed STARTING PROMPT exceeds 16 KiB" + ); + Ok(prompt) +} diff --git a/crates/nebula-daemon/src/workflow/tests.rs b/crates/nebula-daemon/src/workflow/tests.rs new file mode 100644 index 0000000..1cd52bc --- /dev/null +++ b/crates/nebula-daemon/src/workflow/tests.rs @@ -0,0 +1,119 @@ +use super::*; +use crate::{hooks::HookEnv, store::Store}; +use nebula_core::AgentKind; + +fn run(id: &str) -> WorkflowRun { + WorkflowRun { + id: id.into(), + project: "project".to_string().into(), + worktree: None, + branch: format!("workflow-{id}"), + base: "base".into(), + task: "Make a plan".into(), + definition: WorkflowDefinition { + version: 1, + timeout_seconds: 60, + stages: vec![StageDefinition { + id: "planner".into(), + kind: AgentKind::Claude, + model: None, + effort: None, + instructions: "Plan the task".into(), + }], + }, + stages: vec![StageRun { + status: StageStatus::Running, + agent: Some(AgentId("agent".into())), + started_at: now_ms(), + result: Some(StageResult { + outcome: StageOutcome::Completed, + summary: "Plan ready".into(), + artifact: "# The plan\n\nVerify the behavior.".into(), + }), + }], + current: 0, + status: WorkflowStatus::Running, + message: "waiting for FINISHED".into(), + created_at: now_ms(), + updated_at: now_ms(), + } +} + +#[test] +fn workflow_and_artifacts_survive_reopening_the_database() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nebula.db"); + let expected = run("run-1"); + { + let store = Store::open(&path).unwrap(); + store.save_workflow(&expected).unwrap(); + } + let store = Store::open(&path).unwrap(); + let restored = store + .workflow_for_agent(&AgentId("agent".into())) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_string(&restored).unwrap(), + serde_json::to_string(&expected).unwrap() + ); + assert_eq!(store.active_workflow_ids().unwrap(), ["run-1"]); + assert_eq!(store.workflow_summaries().unwrap()[0].total, 1); +} + +#[test] +fn conflicting_session_association_rolls_back_the_whole_snapshot() { + let store = Store::open_in_memory().unwrap(); + store.save_workflow(&run("first")).unwrap(); + assert!(store.save_workflow(&run("second")).is_err()); + assert!(store.workflow("second").is_err()); + assert_eq!( + store + .workflow_for_agent(&AgentId("agent".into())) + .unwrap() + .unwrap() + .id, + "first" + ); +} + +#[test] +fn invalid_workflow_inputs_and_empty_success_are_refused() { + let valid = run("first"); + assert!(prompts::validate(&valid.task, &valid.definition).is_ok()); + let mut invalid = valid.definition.clone(); + invalid.stages.push(invalid.stages[0].clone()); + assert!(prompts::validate(&valid.task, &invalid).is_err()); + invalid = valid.definition.clone(); + invalid.stages[0].id = "../planner".into(); + assert!(prompts::validate(&valid.task, &invalid).is_err()); + assert!(prompts::validate("\0", &valid.definition).is_err()); + assert!(prompts::validate_result(&StageResult { + outcome: StageOutcome::Completed, + summary: "done".into(), + artifact: " ".into() + }) + .is_err()); +} + +#[tokio::test] +async fn interrupted_worktree_creation_becomes_blocked_without_retrying() { + let store = Arc::new(Store::open_in_memory().unwrap()); + let mut interrupted = run("interrupted"); + interrupted.status = WorkflowStatus::Creating; + store.save_workflow(&interrupted).unwrap(); + let daemon = Daemon::new( + store, + HookEnv { + port: 0, + token: String::new(), + }, + ); + daemon.tick_workflows().await.unwrap(); + let blocked = daemon.store.workflow("interrupted").unwrap(); + assert_eq!(blocked.status, WorkflowStatus::Blocked); + assert!(blocked.message.contains("creation was interrupted")); + assert!(daemon.store.active_workflow_ids().unwrap().is_empty()); + daemon.tick_workflows().await.unwrap(); + assert!(daemon.store.load_tree().unwrap().1.is_empty()); +} diff --git a/crates/nebula-daemon/src/workflow/watcher.rs b/crates/nebula-daemon/src/workflow/watcher.rs new file mode 100644 index 0000000..db6aa6f --- /dev/null +++ b/crates/nebula-daemon/src/workflow/watcher.rs @@ -0,0 +1,185 @@ +//! DAEMON-owned scheduling and recovery of durable workflow checkpoints. + +use super::{now_ms, prompts}; +use crate::{ + git, + registry::{CreateAgentSpec, Daemon}, +}; +use anyhow::{bail, ensure, Context, Result}; +use nebula_core::{workflow::*, AgentStatus, EntityId, SessionRef}; +use std::sync::Arc; + +impl Daemon { + /// One watcher, owned by the DAEMON. A restart reads the same durable + /// checkpoints; it never assumes that a lost create reply meant failure. + pub fn start_workflow_watcher(self: &Arc) { + let daemon = self.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(2)); + loop { + tokio::select! { + _ = daemon.shutdown.cancelled() => break, + _ = tick.tick() => {} + } + if let Err(err) = daemon.tick_workflows().await { + tracing::warn!(error = %err, "workflow watcher failed"); + } + } + }); + } + + pub(crate) async fn tick_workflows(self: &Arc) -> Result<()> { + for id in self.store.active_workflow_ids()? { + let _guard = self.workflow_ops.lock().await; + let mut run = self.store.workflow(&id)?; + if !run.status.active() { + continue; + } + let before = serde_json::to_string(&run)?; + if let Err(err) = self.advance_workflow(&mut run).await { + run.status = WorkflowStatus::Blocked; + run.message = format!("{err:#}"); + } + if before != serde_json::to_string(&run)? { + self.persist_workflow(&mut run)?; + } + } + Ok(()) + } + + async fn advance_workflow(self: &Arc, run: &mut WorkflowRun) -> Result<()> { + let worktree = run + .worktree + .as_ref() + .context("WORKTREE creation was interrupted; inspect the recorded branch")?; + let live_worktree = self + .store + .get_worktree(&worktree.id)? + .context("managed WORKTREE was deleted")?; + ensure!( + live_worktree.path == worktree.path && worktree.path.is_dir(), + "managed WORKTREE moved or disappeared" + ); + if run.current == run.stages.len() { + run.status = WorkflowStatus::Completed; + run.message = "all stages completed".into(); + return Ok(()); + } + if run.stages[run.current].status == StageStatus::Pending { + ensure!( + git::current_branch(&worktree.path).await? == run.branch, + "managed WORKTREE changed branches" + ); + self.launch_workflow_stage(run).await?; + return Ok(()); + } + if run.stages[run.current].status == StageStatus::Launching { + // Only recovery reaches this: persist the intent before spawning, + // then find the single matching SESSION if the reply was lost. + let name = prompts::session_name(run); + let (_, _, agents, _) = self.store.load_tree()?; + let matches: Vec<_> = agents + .into_iter() + .filter(|a| a.worktree_id == worktree.id && a.name == name) + .collect(); + ensure!( + matches.len() == 1, + "stage launch is uncertain; inspect SESSIONS before retrying" + ); + run.stages[run.current].agent = Some(matches[0].id.clone()); + run.stages[run.current].status = StageStatus::Running; + } + let stage = &mut run.stages[run.current]; + let id = stage + .agent + .as_ref() + .context("stage has no assigned SESSION")?; + let agent = self + .store + .get_agent(id)? + .context("assigned SESSION was deleted")?; + ensure!( + !agent.archived && agent.worktree_id == worktree.id, + "assigned SESSION was archived or relocated" + ); + if let Some(result) = &stage.result { + if result.outcome == StageOutcome::Blocked { + run.status = WorkflowStatus::Blocked; + run.message = result.summary.clone(); + return Ok(()); + } + if agent.status == AgentStatus::Finished { + ensure!( + git::current_branch(&worktree.path).await? == run.branch, + "managed WORKTREE changed branches" + ); + stage.status = StageStatus::Completed; + run.current += 1; + run.status = if run.current == run.stages.len() { + WorkflowStatus::Completed + } else { + WorkflowStatus::Running + }; + run.message = format!("{} of {} stages completed", run.current, run.stages.len()); + return Ok(()); + } + } + ensure!(self.is_alive(&SessionRef::Agent(id.clone())) && + !matches!(agent.status, AgentStatus::Terminated | AgentStatus::Disconnected), + "assigned SESSION stopped before handoff; reopen it and continue, then resume the workflow"); + ensure!( + now_ms() - stage.started_at < (run.definition.timeout_seconds * 1000) as i64, + "stage timed out; inspect its SESSION, then resume the workflow" + ); + run.status = if agent.status == AgentStatus::NeedsFeedback { + WorkflowStatus::Waiting + } else { + WorkflowStatus::Running + }; + run.message = match (agent.status, stage.result.is_some()) { + (AgentStatus::Finished, false) => { + "SESSION is FINISHED; waiting for an explicit stage result".into() + } + (_, true) => "result recorded; waiting for SESSION to reach FINISHED".into(), + _ => format!( + "{}: {}", + run.definition.stages[run.current].id, + agent.status.as_str() + ), + }; + Ok(()) + } + + async fn launch_workflow_stage(self: &Arc, run: &mut WorkflowRun) -> Result<()> { + run.stages[run.current].status = StageStatus::Launching; + run.stages[run.current].started_at = now_ms(); + self.persist_workflow(run)?; + let definition = &run.definition.stages[run.current]; + let created = self + .create_agent(CreateAgentSpec { + worktree: run + .worktree + .as_ref() + .context("missing WORKTREE")? + .id + .clone(), + name: prompts::session_name(run), + kind: definition.kind, + model: definition.model.clone(), + effort: definition.effort.clone(), + auto_title: false, + cloud_prompt: None, + starting_prompt: Some(prompts::starting_prompt(run)?), + pr_url: None, + }) + .await?; + let EntityId::Agent(id) = created else { + bail!("unexpected create AGENT result"); + }; + run.stages[run.current].agent = Some(id); + run.stages[run.current].status = StageStatus::Running; + run.status = WorkflowStatus::Running; + run.message = format!("{} SESSION started", definition.id); + Ok(()) + } +} diff --git a/crates/nebula/Cargo.toml b/crates/nebula/Cargo.toml index e7238bd..6224bfb 100644 --- a/crates/nebula/Cargo.toml +++ b/crates/nebula/Cargo.toml @@ -13,6 +13,7 @@ nebula-core = { workspace = true } nebula-daemon = { workspace = true } nebula-tui = { workspace = true } anyhow = { workspace = true } +serde_json = { workspace = true } clap = { version = "4", features = ["derive", "wrap_help"] } tokio = { workspace = true } tracing = { workspace = true } @@ -21,6 +22,5 @@ tracing-subscriber = { workspace = true } [dev-dependencies] tempfile = "3" tokio = { workspace = true, features = ["test-util"] } -serde_json = { workspace = true } portable-pty = "0.9" vt100 = "0.16" diff --git a/crates/nebula/src/cli.rs b/crates/nebula/src/cli.rs index 039c505..3e5938d 100644 --- a/crates/nebula/src/cli.rs +++ b/crates/nebula/src/cli.rs @@ -77,6 +77,11 @@ fn parse_agent_kind(s: &str) -> Result { #[derive(Subcommand)] pub(crate) enum Command { + /// Run ordered AGENT workflows (prototype). + /// + /// A versioned definition chooses the stages. The DAEMON stores runtime + /// state in SQLite, creates the WORKTREE, and hands work between SESSIONS. + Workflow(crate::workflow_cli::WorkflowCli), /// Register a git checkout as a project. /// /// Adds a directory to the project list of the open workspace, named after diff --git a/crates/nebula/src/main.rs b/crates/nebula/src/main.rs index 65b8188..65baced 100644 --- a/crates/nebula/src/main.rs +++ b/crates/nebula/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod ssh; mod tunnel; mod upgrade; +mod workflow_cli; use anyhow::Result; use clap::Parser; @@ -12,6 +13,7 @@ use std::path::Path; fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Some(Command::Workflow(workflow)) => workflow.run(), Some(Command::Daemon { foreground }) => { init_daemon_logging(foreground)?; log_fatal( diff --git a/crates/nebula/src/workflow_cli.rs b/crates/nebula/src/workflow_cli.rs new file mode 100644 index 0000000..dafe564 --- /dev/null +++ b/crates/nebula/src/workflow_cli.rs @@ -0,0 +1,254 @@ +//! Prototype workflow CLI. The DAEMON owns all scheduling and durable state. + +use anyhow::{bail, ensure, Context, Result}; +use clap::{Args, Subcommand}; +use nebula_core::{ + codec::{read_frame, write_frame}, + workflow::*, + AgentId, ClientRequest, ServerEvent, +}; +use std::{ + io::Read, + path::{Path, PathBuf}, + time::Duration, +}; + +#[derive(Args)] +#[command( + after_help = "Examples:\n nebula workflow start \"fix the login redirect\"\n nebula workflow list\n nebula workflow status --json" +)] +pub(crate) struct WorkflowCli { + /// Print the full structured result, including stored stage artifacts. + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] + command: WorkflowCommand, +} + +#[derive(Subcommand)] +enum WorkflowCommand { + /// Start an ordered workflow from main. + /// + /// Run inside a NEBULA AGENT in the ROOT WORKTREE on main. Creates a new + /// WORKTREE and queues the first stage without moving the caller. + #[command(after_help = "Example:\n nebula workflow start \"fix the login redirect\"")] + Start { + /// Task to execute; use quotes for multiple words. + #[arg(required_unless_present = "task_file", conflicts_with = "task_file")] + task: Option, + /// Read the task from a UTF-8 file. + #[arg(long)] + task_file: Option, + /// Versioned workflow definition with stage order and MODEL / EFFORT. + #[arg(long, default_value = ".nebula/workflow.json")] + definition: PathBuf, + }, + /// Inspect a workflow and its stage results. + /// + /// Without an id, finds the run assigned to this NEBULA AGENT. Add --json + /// to read the task, frozen definition, and every stored stage artifact. + #[command( + after_help = "Examples:\n nebula workflow status \n nebula workflow status --json" + )] + Status { id: Option }, + /// List the 50 most recently updated runs. + /// + /// Shows summaries across this DAEMON; status --json reads one run in full. + #[command(after_help = "Example:\n nebula workflow list")] + List, + /// Record the current AGENT's stage result. + /// + /// The assigned AGENT runs this as its last tool call. A completed result + /// requires a Markdown artifact; the DAEMON also waits for FINISHED. + #[command( + override_usage = "nebula workflow report [OPTIONS]\n --stage --outcome \n --summary ", + after_help = "Example:\n nebula workflow report --stage planner \\\n --outcome completed --file plan.md \\\n --summary \"Plan ready\"" + )] + Report { + /// Stage id from the frozen definition. + #[arg(long)] + stage: String, + /// Whether this stage completed or needs intervention. + #[arg(long, value_parser = ["completed", "blocked"])] + outcome: String, + /// Short result or the exact reason work is blocked. + #[arg(long)] + summary: String, + /// Result file, copied into the SQLITE STORE (required for completed). + #[arg(long)] + file: Option, + }, + /// Pause stage scheduling. + /// + /// Leaves the active SESSION running. No subsequent stage starts until resume. + #[command(after_help = "Example:\n nebula workflow pause ")] + Pause { id: String }, + /// Resume a paused or blocked workflow. + /// + /// Renews the current stage's timeout. Resolve any reported blocker first; + /// reopen a stopped SESSION in NEBULA. Never creates a replacement SESSION. + #[command(after_help = "Example:\n nebula workflow resume ")] + Resume { id: String }, +} + +fn caller() -> Result { + nebula_core::env::non_empty(nebula_core::env::AGENT_ID) + .map(AgentId) + .context("run this command inside a NEBULA AGENT SESSION") +} + +fn read_file(path: &Path, limit: u64) -> Result { + let mut value = String::new(); + std::fs::File::open(path) + .with_context(|| format!("open {}", path.display()))? + .take(limit + 1) + .read_to_string(&mut value)?; + ensure!( + value.len() as u64 <= limit, + "{} exceeds {limit} bytes", + path.display() + ); + Ok(value) +} + +impl WorkflowCli { + pub(crate) fn run(self) -> Result<()> { + let op = match self.command { + WorkflowCommand::Start { + task, + task_file, + definition, + } => { + let caller = caller()?; + let task = match task_file { + Some(path) => read_file(&path, 8000)?, + None => task.context("task required")?, + }; + let mut definition: WorkflowDefinition = + serde_json::from_str(&read_file(&definition, 64 * 1024)?)?; + let config = nebula_tui::config::Config::load(); + for stage in &mut definition.stages { + ensure!( + config.kind_enabled(stage.kind), + "{} is disabled in NEBULA", + stage.kind.as_str() + ); + if stage.model.is_none() { + stage.model = config.default_model(stage.kind); + } + if stage.effort.is_none() { + stage.effort = config.default_effort(stage.kind); + } + } + WorkflowOp::Start { + caller, + task, + definition, + } + } + WorkflowCommand::Status { id } => WorkflowOp::Status { + id, + caller: caller().ok(), + }, + WorkflowCommand::List => WorkflowOp::List, + WorkflowCommand::Pause { id } => WorkflowOp::Pause { id }, + WorkflowCommand::Resume { id } => WorkflowOp::Resume { id }, + WorkflowCommand::Report { + stage, + outcome, + summary, + file, + } => { + let outcome = if outcome == "completed" { + StageOutcome::Completed + } else { + StageOutcome::Blocked + }; + ensure!( + outcome != StageOutcome::Completed || file.is_some(), + "completed requires --file" + ); + let artifact = file + .map(|p| read_file(&p, 64 * 1024)) + .transpose()? + .unwrap_or_default(); + WorkflowOp::Report { + caller: caller()?, + stage, + result: StageResult { + outcome, + summary, + artifact, + }, + } + } + }; + let runtime = tokio::runtime::Runtime::new()?; + let reply = runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(60), exchange(op)) + .await + .context( + "workflow request timed out; inspect `nebula workflow list` before retrying", + )? + })?; + if self.json { + println!("{}", serde_json::to_string_pretty(&reply)?); + } else { + match reply { + WorkflowReply::Run(run) => { + println!( + "Workflow: {}\nStatus: {:?}\n{}", + run.id, run.status, run.message + ); + if let Some(worktree) = &run.worktree { + println!("WORKTREE: {}", worktree.path.display()); + } + for (definition, stage) in run.definition.stages.iter().zip(&run.stages) { + println!( + " {}: {:?} ({}, model {}, effort {}){}", + definition.id, + stage.status, + definition.kind.as_str(), + definition.model.as_deref().unwrap_or("default"), + definition.effort.as_deref().unwrap_or("default"), + stage + .agent + .as_ref() + .map(|id| format!(" SESSION {id}")) + .unwrap_or_default() + ); + } + } + WorkflowReply::List(runs) => { + if runs.is_empty() { + println!("No workflows."); + } + for run in runs { + println!( + "{} {:?} {}/{} {}", + run.id, run.status, run.current, run.total, run.message + ); + } + } + } + } + Ok(()) + } +} + +async fn exchange(op: WorkflowOp) -> Result { + let mut connection = nebula_tui::ipc::connect_or_spawn().await?; + write_frame( + &mut connection.stream, + &ClientRequest::Workflow { req_id: 1, op }, + ) + .await?; + loop { + match read_frame::(&mut connection.stream).await? { + Some(ServerEvent::Workflow { req_id: 1, reply }) => return Ok(reply), + Some(ServerEvent::Error { message, .. }) => bail!("{message}"), + None => bail!("DAEMON closed before replying; inspect workflow list before retrying"), + _ => {} + } + } +} diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 3aea0e2..a54c38b 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -2,6 +2,9 @@ //! attach/detach with scrollback replay, git worktree ops, and persistence //! across a daemon restart. +#[path = "e2e_pty/workflow.rs"] +mod workflow; + use nebula_core::codec::{read_frame, write_frame}; use nebula_core::env; use nebula_core::{ diff --git a/crates/nebula/tests/e2e_pty/workflow.rs b/crates/nebula/tests/e2e_pty/workflow.rs new file mode 100644 index 0000000..76eaf10 --- /dev/null +++ b/crates/nebula/tests/e2e_pty/workflow.rs @@ -0,0 +1,433 @@ +//! CLI, real DAEMON, real WORKTREE and PTYs. STUB AGENTS supply explicit hooks/results. +use super::*; +use nebula_core::{workflow::*, AgentId}; + +fn workflow_cli(env: &TestEnv, caller: &AgentId, args: &[&str]) -> std::process::Output { + env.cli() + .args(args) + .env(env::AGENT_ID, caller.as_str()) + .output() + .unwrap() +} + +fn workflow_status(env: &TestEnv, id: &str) -> WorkflowRun { + let output = env + .cli() + .args(["workflow", "status", id, "--json"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let WorkflowReply::Run(run) = serde_json::from_slice(&output.stdout).unwrap() else { + panic!("expected a run"); + }; + *run +} + +async fn wait_for_run( + env: &TestEnv, + id: &str, + ready: impl Fn(&WorkflowRun) -> bool, +) -> WorkflowRun { + let deadline = tokio::time::Instant::now() + SPAWN_CHAIN_TIMEOUT; + loop { + let run = workflow_status(env, id); + if ready(&run) { + return run; + } + assert!( + tokio::time::Instant::now() < deadline, + "workflow did not advance: {}", + run.message + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn workflow_hook(env_dir: &Path, agent: &AgentId, kind: &str, event: &str) { + let vars = read_env_file(&env_dir.join(format!("{agent}.env"))).await; + let port = vars[env::API_URL] + .rsplit(':') + .next() + .unwrap() + .parse() + .unwrap(); + let (status, _) = hook_post( + port, + &format!("/api/hooks/{kind}?agentId={agent}&hookEvent={event}"), + &vars[env::API_TOKEN], + ) + .await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { + let env = TestEnv::new(); + let repo = env.make_repo(); + env.write_config( + r#"{"prewarm_agents":false,"prewarm_sessions":false,"session_idle_timeout":"off"}"#, + ); + let env_dir = env.tmp.path().join("agent-env"); + std::fs::create_dir_all(&env_dir).unwrap(); + let script = env.tmp.path().join("agent.sh"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nenv | grep '^NEBULA_' > '{d}'/$NEBULA_AGENT_ID.env\nexec sleep 600\n", + d = env_dir.display() + ), + ) + .unwrap(); + make_executable(&script); + let codex_dir = env.tmp.path().join("codex"); + let spawn = || { + env.spawn_daemon_with( + script.to_str().unwrap(), + &[("CODEX_HOME", codex_dir.to_str().unwrap())], + ) + }; + let mut daemon = spawn(); + let mut c = connect(&env.sock()).await; + handshake(&mut c).await; + let root = add_project_get_main_worktree(&mut c, &repo).await; + let caller = create_agent_get_id(&mut c, &root.id, "kickoff", 2).await; + let definition = WorkflowDefinition { + version: 1, + timeout_seconds: 60, + stages: vec![ + StageDefinition { + id: "planner".into(), + kind: AgentKind::Claude, + model: Some("test-planner-model".into()), + effort: Some("high".into()), + instructions: "Plan it".into(), + }, + StageDefinition { + id: "implementer".into(), + kind: AgentKind::Codex, + model: Some("test-implementer-model".into()), + effort: Some("medium".into()), + instructions: "Implement it".into(), + }, + ], + }; + let definition_file = env.tmp.path().join("workflow.json"); + std::fs::write( + &definition_file, + serde_json::to_string(&definition).unwrap(), + ) + .unwrap(); + let out = workflow_cli( + &env, + &caller, + &[ + "workflow", + "start", + "Change README", + "--definition", + definition_file.to_str().unwrap(), + "--json", + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let WorkflowReply::Run(run) = serde_json::from_slice(&out.stdout).unwrap() else { + panic!("expected run"); + }; + let id = run.id.clone(); + let first = wait_for_run(&env, &id, |r| r.stages[0].agent.is_some()).await; + let planner = first.stages[0].agent.clone().unwrap(); + assert_ne!(first.worktree.as_ref().unwrap().id, root.id); + assert!(first + .worktree + .as_ref() + .unwrap() + .path + .join("README.md") + .is_file()); + assert_eq!( + first.definition.stages[0].model.as_deref(), + Some("test-planner-model") + ); + + // A stopped turn without an explicit result cannot start the implementer. + workflow_hook(&env_dir, &planner, "claude", "UserPromptSubmit").await; + workflow_hook(&env_dir, &planner, "claude", "Stop").await; + wait_for_run(&env, &id, |r| r.message.contains("explicit stage result")).await; + assert!(workflow_status(&env, &id).stages[1].agent.is_none()); + let wrong = workflow_cli( + &env, + &caller, + &[ + "workflow", + "report", + "--stage", + "planner", + "--outcome", + "blocked", + "--summary", + "wrong caller", + ], + ); + assert!(!wrong.status.success()); + let wrong_stage = workflow_cli( + &env, + &planner, + &[ + "workflow", + "report", + "--stage", + "implementer", + "--outcome", + "blocked", + "--summary", + "wrong stage", + ], + ); + assert!(!wrong_stage.status.success()); + + let pause = env.cli().args(["workflow", "pause", &id]).output().unwrap(); + assert!(pause.status.success()); + let artifact = env.tmp.path().join("plan.md"); + std::fs::write(&artifact, "# Plan\nChange README and check its contents.\n").unwrap(); + let report = workflow_cli( + &env, + &planner, + &[ + "workflow", + "report", + "--stage", + "planner", + "--outcome", + "completed", + "--summary", + "Plan ready", + "--file", + artifact.to_str().unwrap(), + ], + ); + assert!( + report.status.success(), + "{}", + String::from_utf8_lossy(&report.stderr) + ); + assert_eq!(workflow_status(&env, &id).status, WorkflowStatus::Paused); + + // A clean DAEMON restart preserves the pause and the artifact in SQLite. + write_frame(&mut c, &ClientRequest::Shutdown).await.unwrap(); + wait_for_exit(&mut daemon); + daemon = spawn(); + c = connect(&env.sock()).await; + handshake(&mut c).await; + let restored = workflow_status(&env, &id); + assert_eq!(restored.status, WorkflowStatus::Paused); + assert!(restored.stages[0] + .result + .as_ref() + .unwrap() + .artifact + .contains("# Plan")); + let resumed = env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap(); + assert!( + resumed.status.success(), + "{}", + String::from_utf8_lossy(&resumed.stderr) + ); + let second = wait_for_run(&env, &id, |r| r.stages[1].agent.is_some()).await; + let implementer = second.stages[1].agent.clone().unwrap(); + assert_eq!(second.current, 1); + assert_eq!(second.stages[0].agent.as_ref(), Some(&planner)); + assert_eq!(second.definition.stages[1].kind, AgentKind::Codex); + workflow_hook(&env_dir, &implementer, "codex", "UserPromptSubmit").await; + + // An interrupted active stage blocks; recovery keeps the same SESSION id. + write_frame(&mut c, &ClientRequest::Shutdown).await.unwrap(); + wait_for_exit(&mut daemon); + daemon = spawn(); + c = connect(&env.sock()).await; + handshake(&mut c).await; + let blocked = wait_for_run(&env, &id, |r| r.status == WorkflowStatus::Blocked).await; + assert_eq!(blocked.stages[1].agent.as_ref(), Some(&implementer)); + let resume_dead = env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap(); + assert!(!resume_dead.status.success()); + write_frame( + &mut c, + &ClientRequest::Attach { + session: SessionRef::Agent(implementer.clone()), + from_seq: None, + cols: 80, + rows: 24, + }, + ) + .await + .unwrap(); + read_events_until(&mut c, EVENT_TIMEOUT, |events| { + events + .iter() + .any(|e| matches!(e, ServerEvent::Scrollback { .. })) + }) + .await; + // The stub rewrites its environment on respawn; wait for the new hook port. + let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT; + loop { + let vars = read_env_file(&env_dir.join(format!("{implementer}.env"))).await; + let url = &vars[env::API_URL]; + if tokio::net::TcpStream::connect(url.trim_start_matches("http://")) + .await + .is_ok() + { + break; + } + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(POLL_STEP).await; + } + workflow_hook(&env_dir, &implementer, "codex", "UserPromptSubmit").await; + let resumed = env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap(); + assert!( + resumed.status.success(), + "{}", + String::from_utf8_lossy(&resumed.stderr) + ); + let report_blocked = workflow_cli( + &env, + &implementer, + &[ + "workflow", + "report", + "--stage", + "implementer", + "--outcome", + "blocked", + "--summary", + "Need clarification", + ], + ); + assert!(report_blocked.status.success()); + wait_for_run(&env, &id, |r| r.status == WorkflowStatus::Blocked).await; + assert!(!env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap() + .status + .success()); + let complete = workflow_cli( + &env, + &implementer, + &[ + "workflow", + "report", + "--stage", + "implementer", + "--outcome", + "completed", + "--summary", + "Implemented and checked", + "--file", + artifact.to_str().unwrap(), + ], + ); + assert!(complete.status.success()); + let overwrite = workflow_cli( + &env, + &implementer, + &[ + "workflow", + "report", + "--stage", + "implementer", + "--outcome", + "blocked", + "--summary", + "overwrite completion", + ], + ); + assert!(!overwrite.status.success()); + assert!(env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap() + .status + .success()); + wait_for_run(&env, &id, |r| r.message.contains("waiting for SESSION")).await; + assert_eq!( + workflow_status(&env, &id).current, + 1, + "report alone cannot hand off a busy SESSION" + ); + let worktree = second.worktree.as_ref().unwrap(); + assert!(std::process::Command::new("git") + .args(["checkout", "-b", "unexpected-branch"]) + .current_dir(&worktree.path) + .output() + .unwrap() + .status + .success()); + workflow_hook(&env_dir, &implementer, "codex", "Stop").await; + let wrong_branch = wait_for_run(&env, &id, |r| r.status == WorkflowStatus::Blocked).await; + assert!(wrong_branch.message.contains("changed branches")); + assert!(std::process::Command::new("git") + .args(["checkout", &second.branch]) + .current_dir(&worktree.path) + .output() + .unwrap() + .status + .success()); + assert!(env + .cli() + .args(["workflow", "resume", &id]) + .output() + .unwrap() + .status + .success()); + let completed = wait_for_run(&env, &id, |r| r.status == WorkflowStatus::Completed).await; + assert_eq!(completed.current, 2); + assert!(completed + .stages + .iter() + .all(|s| s.status == StageStatus::Completed)); + + let snapshots = subscribe(&mut c).await; + let ServerEvent::Snapshot { agents, .. } = snapshots.last().unwrap() else { + panic!("snapshot"); + }; + assert_eq!( + agents.len(), + 3, + "one caller plus exactly two workflow SESSIONS" + ); + assert_eq!( + agents.iter().find(|a| a.id == caller).unwrap().worktree_id, + root.id + ); + assert_eq!( + agents.iter().find(|a| a.id == planner).unwrap().worktree_id, + agents + .iter() + .find(|a| a.id == implementer) + .unwrap() + .worktree_id + ); + write_frame(&mut c, &ClientRequest::Shutdown).await.unwrap(); + wait_for_exit(&mut daemon); +} diff --git a/crates/nebula/tests/help_cli.rs b/crates/nebula/tests/help_cli.rs index eb66b27..abf7bc2 100644 --- a/crates/nebula/tests/help_cli.rs +++ b/crates/nebula/tests/help_cli.rs @@ -13,6 +13,13 @@ use std::process::Command; /// Every command `nebula --help` lists, plus the `workspace` subcommands. /// Hidden ones (`_raw-attach`, `_stale-daemon-note`) are deliberately absent. const VISIBLE: &[&[&str]] = &[ + &["workflow"], + &["workflow", "start"], + &["workflow", "status"], + &["workflow", "list"], + &["workflow", "report"], + &["workflow", "pause"], + &["workflow", "resume"], &["add"], &["daemon"], &["kill"], @@ -120,8 +127,8 @@ fn the_root_help_lists_one_line_per_command() { } assert_eq!( commands.lines().count(), - 13, - "twelve commands plus `help`:\n{commands}" + 14, + "thirteen commands plus `help`:\n{commands}" ); } diff --git a/docs/commands.md b/docs/commands.md index 67f4ca0..0f48850 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -17,6 +17,7 @@ nebula kill shut the running daemon down (stops all sessions) nebula rename title the session this runs inside (agents run this) nebula worktree [name] move this session into a worktree (agents run this) nebula spawn <task> start another agent session beside it (agents run this) +nebula workflow <cmd> run ordered AGENT workflows (prototype) nebula open <file>… show files in this nebula's file tabs (agents run this) nebula workspace <cmd> manage workspaces — named groups of projects nebula browser serve this TUI in a web browser via ttyd @@ -64,6 +65,13 @@ nebula open <file>… # show the files in this nebula's FILE TABS — a mo # you ask to see a file, or to put their own work in front of you) ``` +## Ordered workflows (prototype) + +`nebula workflow start "task"` creates a separate WORKTREE from `main` and queues the +AGENTS declared in `.nebula/workflow.json`. The DAEMON stores run state and stage results +in the SQLITE STORE. `status`, `list`, `report`, `pause`, and `resume` inspect and manage +the run. See [Workflows](workflows.md) for configuration, all commands, and restart recovery. + ## Workspaces ```sh diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 0000000..e8f3ded --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,167 @@ +# Ordered AGENT workflows (prototype) + +Start a task from the ROOT WORKTREE on `main`. NEBULA creates one separate WORKTREE and +runs the configured AGENTS in order. Both planner and implementer use the exact model ID +`claude-sonnet-5` with medium effort. A reviewer is another entry in the same ordered list. + +Runtime state belongs to the **SQLITE STORE**. `.nebula/workflow.json` is the versioned +definition: provider, MODEL / EFFORT, instructions, order, and timeout. At kickoff the CLI +resolves NEBULA's configured defaults and the DAEMON freezes that definition into the run. +Editing the file afterwards affects new runs only. Remaining null defaults are delegated +to the provider CLI, so set explicit values when reproducibility matters. + +MAKE DEV's initial database seed removes workflow runs and their SESSION associations, +alongside live SESSIONS. It publishes the seed only after cleanup succeeds; development +instances must never execute copied production workflows. + +```mermaid +flowchart LR + S[Kickoff skill on main] --> D[DAEMON] + D --> W[New WORKTREE from pinned main commit] + W --> P[Planner SESSION] + P --> R1[Report completed + FINISHED] + R1 --> I[Implementer SESSION] + I --> R2[Report completed + FINISHED] + R2 --> C[Completed; changes ready to inspect] + D <--> DB[(SQLITE STORE)] +``` + +## Try it + +This requires the prototype binary and DAEMON, PROTOCOL VERSION 39. The installed release +does not gain these commands until rebuilt. Test without replacing the live DAEMON: + +1. Run the following from this checkout in a separate terminal. These dedicated paths avoid + stopping an existing MAKE DEV instance for this checkout. Provider authentication and + hooks still use your normal provider configuration. +2. Add this checkout as a PROJECT if absent, open an AGENT in its ROOT WORKTREE on `main`, + and invoke `/workflow-prototype <task>`. The skill is under `.claude/skills` and linked + into `.agents/skills` for both harnesses. +3. Inspect the new WORKTREE and SESSIONS in the TUI. Read `workflow status <run-id> --json` + for the task, frozen definition, stage SESSION ids, and stored results. + +```sh +make dev SEED=0 \ + DEV_RUNTIME=/tmp/nebula-workflow-prototype \ + DEV_DATA="$HOME/.nebula-dev/workflow-prototype" +``` + +Inside the development instance, use the absolute path to this checkout's +`target/debug/nebula` if `nebula` on PATH is the older installed release. The worker +STARTING PROMPTS already use the DAEMON executable's absolute, shell-quoted path. The +development runtime/data environment inherited by the SESSION selects the correct instance. +For example, from a kickoff SESSION whose working directory is this checkout: + +```sh +./target/debug/nebula workflow start "Document the README setup steps" \ + --definition .nebula/workflow.json +``` + +The new WORKTREE starts from the committed `main` revision. Uncommitted changes in the +ROOT WORKTREE, including this prototype's source, are not copied there. The running +DAEMON supplies the workflow instructions and stores the definition, so workers do not +need a copy of the kickoff skill or configuration to participate. + +## Configure AGENTS + +Edit [the definition](../.nebula/workflow.json). `kind` accepts `claude` or `codex` in this +prototype; `model` and `effort` are strings understood by the selected provider through +NEBULA's existing launch code. Null values use NEBULA defaults. Invalid or unavailable +provider/model combinations stop the run through the ordinary SESSION failure path. + +The list supports 1 to 10 unique stage ids and a timeout of 10 to 86,400 seconds per +stage, including time waiting for feedback. To add the third AGENT, append: + +```json +{ + "id": "reviewer", + "kind": "claude", + "model": null, + "effort": null, + "instructions": "Read the task, plan, implementation result, and diff from the recorded base commit. Review correctness and verification. Report completed with evidence when acceptable, or blocked with specific findings. Leave product code unchanged." +} +``` + +A reviewer rejection blocks the workflow. Automatic review/fix loops and parallel stages +are outside this prototype. All stages share the same WORKTREE and see previous edits; +each receives a fresh SESSION and reads previous results from the SQLITE STORE. + +## Commands + +All accept `--json`. [The complete existing CLI reference](commands.md) covers the rest +of NEBULA; every visible command and subcommand also has its own `--help` page. + +| Command | Behavior | +|---|---| +| `workflow start "task"` | Create a WORKTREE from `main`, freeze the definition, queue the first AGENT. Supports `--task-file` and `--definition`. Requires a kickoff SESSION in the ROOT WORKTREE. | +| `workflow status [id]` | Read one run; omitted id selects the caller's assigned workflow. JSON includes full artifacts. | +| `workflow list` | Show the 50 most recently updated runs across this DAEMON. | +| `workflow report --stage ID --outcome completed --file FILE --summary TEXT` | Store the current assigned AGENT's result and a copy of its Markdown file, maximum 64 KiB. | +| `workflow report --stage ID --outcome blocked --summary TEXT` | Record a blocker; an artifact is optional. | +| `workflow pause ID` | Stop scheduling; leave the current SESSION running. | +| `workflow resume ID` | Continue a paused/blocked run after resolving the cause; renew the stage timeout. | + +JSON replies are `{"Run": {...}}` or `{"List": [...]}`. These are prototype interfaces. +The commands operate through the same local IPC trust boundary as other NEBULA commands. +Caller SESSION ids identify assignments; they are not a security boundary against another +process running as the same local user. + +## State and recovery + +MIGRATION 24 adds `workflow_runs` and `workflow_sessions` to the existing SQLITE STORE. +The first holds a complete JSON snapshot plus indexed active/update fields. The second +maps each assigned SESSION to exactly one run. One SQLite transaction writes both, so +an association conflict rolls back the complete checkpoint. Artifacts are stored as text +inside the snapshot, not as paths that disappear with a WORKTREE. + +The DAEMON watcher runs every two seconds. A mutex serializes workflow commands and +watcher transitions. It records WORKTREE creation intent and stage launch intent before +external work, then records the resulting ids. Run states are `creating`, `running`, +`waiting`, `paused`, `blocked`, and `completed`; stage states are `pending`, `launching`, +`running`, and `completed`. + +| Situation | Result and recovery | +|---|---| +| SESSION becomes FINISHED without a report | Wait for an explicit result; timeout eventually blocks. | +| AGENT reports completed while still RUNNING | Store the report and wait for FINISHED before handoff. | +| AGENT needs permissions or feedback | Show `waiting`; resolve the prompt in that SESSION. Normal provider permission behavior is preserved. | +| AGENT reports blocked | Stop scheduling. Resolve it in the same SESSION, replace its blocked report with completed, finish the turn, and resume. | +| DAEMON restarts during active work | Preserve ids, definition, and artifacts. A stopped SESSION blocks; reopen the existing SESSION in the TUI, inspect its task with status, continue it, and resume. | +| Restart after a completed report and FINISHED | That durable completion can advance without recreating the prior SESSION. | +| Crash during stage launch | Adopt the unique SESSION matching the recorded run/stage name. Zero or multiple matches block for inspection; no automatic replacement is launched. | +| Crash during WORKTREE creation | Keep the recorded branch/base and block for inspection; do not blindly retry creation. | +| WORKTREE or SESSION deleted, archived, relocated, or unavailable | Block with the specific cause. Historical results survive ordinary entity deletion. | + +The watcher resumes scheduling after a DAEMON restart; it does not automatically resume +an interrupted provider conversation. This avoids duplicate implementation work when +completion is uncertain. A pause is a scheduling control, not a kill switch. Completed +reports are immutable; a duplicate report is accepted while that stage is still current. +No workflow automatically commits, merges, deploys, or deletes the resulting WORKTREE. + +## What NEBULA already provided + +The DAEMON already owned PTYs, the SQLITE STORE, WORKTREE creation, SESSION creation, +provider-specific MODEL / EFFORT arguments, STARTING PROMPTS, and status hooks. The TUI +is a client and can close while the DAEMON continues. + +`nebula worktree` relocates the caller's existing SESSION. `nebula spawn` starts a sibling +in that SESSION's current WORKTREE. Neither exposed an ordered durable workflow, so this +prototype adds workflow IPC/CLI commands and a small DAEMON scheduler around the existing +creation paths. It does not create another process manager or write SQLite from a skill. + +The source entry points are `nebula/src/cli.rs`, `nebula/src/workflow_cli.rs`, +`nebula-core/src/workflow.rs`, `nebula-daemon/src/workflow.rs`, +`nebula-daemon/src/workflow/watcher.rs`, and `nebula-daemon/src/store/workflows.rs`. + +## Verification + +The focused tests use a real isolated DAEMON, SQLite database, git WORKTREE, and PTYs with +STUB AGENTS. They exercise command submission, explicit-result gating, incorrect caller +rejection, pause/resume, durable results, DAEMON restart, and reuse of the original SESSION. +They do not establish that a real provider will follow every instruction without feedback. + +```sh +cargo test -p nebula-daemon workflow --lib +cargo test -p nebula --test help_cli +cargo test -p nebula --test e2e_pty workflow_cli_handoffs +``` From 5f62819957fcdbbaca2bb17f3e2b8faf3df77c8e Mon Sep 17 00:00:00 2001 From: "Lucas N. Munhoz" <ln.munhoz@gmail.com> Date: Sun, 6 Sep 2026 14:23:09 +0700 Subject: [PATCH 2/2] feat: add MANAGED WORKFLOW definitions and progress UI Support named TOML workflows and reusable AGENTS through /nebula-workflow. Show live progress in the TUI with readable WORKTREE and SESSION labels, a dedicated workflow panel, and a clickable running count in the FOOTER. Validated with 922 tests, formatting, Clippy, and the memory and glossary gates. --- .agents/skills/nebula-workflow | 1 + .agents/skills/workflow-prototype | 1 - .claude/MEMORY.md | 7 +- .claude/memory/archive.md | 5 + ...orkflows-and-reusable-agent-definitions.md | 12 + ...ed-agent-workflows-use-the-sqlite-store.md | 4 +- ...global-nebula-kept-development-instance.md | 10 + ...flow-progress-panel-and-readable-labels.md | 13 + ...-skill-renamed-and-cli-kickoff-boundary.md | 10 + .claude/skills/nebula-workflow/SKILL.md | 53 ++++ .claude/skills/workflow-prototype/SKILL.md | 40 --- .nebula/README.md | 47 ++++ .nebula/agents/implementer.toml | 9 + .nebula/agents/planner.toml | 8 + .nebula/agents/reviewer.toml | 10 + .nebula/workflow.json | 20 -- .nebula/workflows/default.toml | 13 + .nebula/workflows/review.toml | 9 + .nebula/workflows/reviewed.toml | 16 ++ Cargo.lock | 66 +++++ TERMS.md | 16 +- crates/nebula-core/src/protocol.rs | 6 +- crates/nebula-core/src/workflow.rs | 62 ++++- crates/nebula-daemon/src/git.rs | 95 ++++++- crates/nebula-daemon/src/registry.rs | 12 + crates/nebula-daemon/src/server.rs | 1 + crates/nebula-daemon/src/store/workflows.rs | 12 +- crates/nebula-daemon/src/workflow.rs | 15 +- crates/nebula-daemon/src/workflow/prompts.rs | 19 ++ crates/nebula-daemon/src/workflow/tests.rs | 55 ++++ crates/nebula-tui/src/app.rs | 96 ++----- crates/nebula-tui/src/app/panel_layout.rs | 102 ++++++++ crates/nebula-tui/src/config.rs | 18 ++ crates/nebula-tui/src/event_loop.rs | 74 +++++- .../nebula-tui/src/event_loop/focus_walk.rs | 2 + .../src/event_loop/tests/workflows.rs | 190 ++++++++++++++ crates/nebula-tui/src/event_loop/workflows.rs | 37 +++ crates/nebula-tui/src/keymap.rs | 10 + crates/nebula-tui/src/lib.rs | 1 + crates/nebula-tui/src/ui.rs | 57 ++++- crates/nebula-tui/src/ui/workflows.rs | 155 +++++++++++ crates/nebula-tui/src/workflows.rs | 145 +++++++++++ crates/nebula/Cargo.toml | 2 + crates/nebula/src/main.rs | 1 + crates/nebula/src/workflow_cli.rs | 148 +++++------ crates/nebula/src/workflow_cli/output.rs | 116 +++++++++ crates/nebula/src/workflow_config.rs | 240 ++++++++++++++++++ crates/nebula/src/workflow_config/format.rs | 121 +++++++++ crates/nebula/src/workflow_config/tests.rs | 195 ++++++++++++++ crates/nebula/tests/e2e_pty/workflow.rs | 94 ++++++- crates/nebula/tests/e2e_tui.rs | 3 + crates/nebula/tests/e2e_tui/workflows.rs | 117 +++++++++ crates/nebula/tests/help_cli.rs | 2 + crates/nebula/tests/workflow_config_cli.rs | 89 +++++++ docs/commands.md | 13 +- docs/workflows.md | 195 +++++++++++--- 56 files changed, 2558 insertions(+), 312 deletions(-) create mode 120000 .agents/skills/nebula-workflow delete mode 120000 .agents/skills/workflow-prototype create mode 100644 .claude/memory/archive.md create mode 100644 .claude/memory/entries/2026-09-06-named-workflows-and-reusable-agent-definitions.md create mode 100644 .claude/memory/entries/2026-09-06-removed-global-nebula-kept-development-instance.md create mode 100644 .claude/memory/entries/2026-09-06-workflow-progress-panel-and-readable-labels.md create mode 100644 .claude/memory/entries/2026-09-06-workflow-skill-renamed-and-cli-kickoff-boundary.md create mode 100644 .claude/skills/nebula-workflow/SKILL.md delete mode 100644 .claude/skills/workflow-prototype/SKILL.md create mode 100644 .nebula/README.md create mode 100644 .nebula/agents/implementer.toml create mode 100644 .nebula/agents/planner.toml create mode 100644 .nebula/agents/reviewer.toml delete mode 100644 .nebula/workflow.json create mode 100644 .nebula/workflows/default.toml create mode 100644 .nebula/workflows/review.toml create mode 100644 .nebula/workflows/reviewed.toml create mode 100644 crates/nebula-tui/src/app/panel_layout.rs create mode 100644 crates/nebula-tui/src/event_loop/tests/workflows.rs create mode 100644 crates/nebula-tui/src/event_loop/workflows.rs create mode 100644 crates/nebula-tui/src/ui/workflows.rs create mode 100644 crates/nebula-tui/src/workflows.rs create mode 100644 crates/nebula/src/workflow_cli/output.rs create mode 100644 crates/nebula/src/workflow_config.rs create mode 100644 crates/nebula/src/workflow_config/format.rs create mode 100644 crates/nebula/src/workflow_config/tests.rs create mode 100644 crates/nebula/tests/e2e_tui/workflows.rs create mode 100644 crates/nebula/tests/workflow_config_cli.rs diff --git a/.agents/skills/nebula-workflow b/.agents/skills/nebula-workflow new file mode 120000 index 0000000..9c996cd --- /dev/null +++ b/.agents/skills/nebula-workflow @@ -0,0 +1 @@ +../../.claude/skills/nebula-workflow \ No newline at end of file diff --git a/.agents/skills/workflow-prototype b/.agents/skills/workflow-prototype deleted file mode 120000 index de02c2e..0000000 --- a/.agents/skills/workflow-prototype +++ /dev/null @@ -1 +0,0 @@ -../../.claude/skills/workflow-prototype \ No newline at end of file diff --git a/.claude/MEMORY.md b/.claude/MEMORY.md index ee93f59..c13873e 100644 --- a/.claude/MEMORY.md +++ b/.claude/MEMORY.md @@ -16,6 +16,10 @@ Line shape: `- DATE · [Title](memory/entries/DATE-slug.md) · TERMS: A; B · fi > `.claude/MEMORY.md` was split into this index plus per-entry files on 2026-08-28. ## Index +- 2026-09-06 · [MANAGED WORKFLOW Progress Panel And Readable Labels](memory/entries/2026-09-06-workflow-progress-panel-and-readable-labels.md) · TERMS: MANAGED WORKFLOW; WORKTREES PANEL; SESSIONS PANEL; FOOTER; PANEL WALK; WORKSPACE · files: workflows.rs; panel_layout.rs; workflow.rs; protocol.rs · gotchas: 5 +- 2026-09-06 · [Removed Global NEBULA And Kept The DEV INSTANCE](memory/entries/2026-09-06-removed-global-nebula-kept-development-instance.md) · TERMS: NEBULA; MAKE DEV; DEV INSTANCE; DATA DIR; DAEMON; VERSION SKEW · files: install.sh; paths.rs; Makefile · gotchas: 2 +- 2026-09-06 · [MANAGED WORKFLOW Skill Rename And CLI Kickoff Boundary](memory/entries/2026-09-06-workflow-skill-renamed-and-cli-kickoff-boundary.md) · TERMS: MANAGED WORKFLOW; SESSION; AGENT; ROOT WORKTREE; PROJECT; DAEMON · files: SKILL.md; workflow_cli.rs; workflow.rs; workflows.md · gotchas: 2 +- 2026-09-06 · [Named MANAGED WORKFLOWS And Reusable AGENT Definitions](memory/entries/2026-09-06-named-workflows-and-reusable-agent-definitions.md) · TERMS: MANAGED WORKFLOW; MODEL / EFFORT; SQLITE STORE; AGENT; SESSION; PROTOCOL VERSION · files: workflow_config.rs; format.rs; workflow_cli.rs; SKILL.md · gotchas: 4 - 2026-09-06 · [Ordered AGENT Workflows Use The SQLITE STORE](memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md) · TERMS: SQLITE STORE; DAEMON; WORKTREE; SESSION; MODEL / EFFORT; STARTING PROMPT · files: workflow.rs; watcher.rs; workflows.rs; workflow_cli.rs · gotchas: 5 - 2026-09-05 · [Released v0.22.0 Level With `origin/main`: Nothing To Carry, 38 Commits To Explain](memory/entries/2026-09-05-released-v0-22-0-level-with-origin-nothing-to-carry.md) · TERMS: RELEASE SKILL; RELEASE NOTES; RELEASE WORKTREE; SHARED CHECKOUT; CARRY SET; PROTOCOL VERSION · files: Cargo.toml; Cargo.lock; commands.md; gotchas.md · gotchas: 3 - 2026-09-05 · [The `for … in $(…)` GUARD HOOK Block Was Right, But Its zsh Premise Is False](memory/entries/2026-09-05-for-in-guard-block-was-right-its-zsh-premise-is-false.md) · TERMS: GUARD HOOK; SESSION; RELEASE SKILL; RELEASE WORKTREE; WORKTREE · files: guard.py; gotchas.md · gotchas: 4 @@ -47,6 +51,7 @@ Line shape: `- DATE · [Title](memory/entries/DATE-slug.md) · TERMS: A; B · fi - 2026-08-30 · [A GitHub Action Archives Every Merged PR Into `.claude/memory/prs/` For Agent Context](memory/entries/2026-08-30-a-github-action-archives-every-merged-pr-for-context.md) · TERMS: PR ARCHIVE; RELEASE WORKFLOW; MEMORY LOG; MEMORY CHECK; PR ROW; RECALL HOOK · files: pr-archive.yml; pr_archive.py; AGENTS.md; check.py · gotchas: 6 - 2026-08-30 · [Walking Onto A Live SESSION ATTACHes Without The Debounce](memory/entries/2026-08-30-walking-onto-a-live-session-attaches-without-the-debounce.md) · TERMS: ATTACH; SESSIONS PANEL; TERMINAL PANE; SESSION; IDLE REAPER · files: event_loop.rs; ui.rs · gotchas: 4 - 2026-08-30 · [TERMINAL PANE WHEEL SCROLL Steps One Line A Notch](memory/entries/2026-08-30-terminal-wheel-scrolls-one-line-a-notch.md) · TERMS: TERMINAL PANE; WHEEL SCROLL; MOUSE MODE · files: event_loop.rs; TERMS.md · gotchas: 1 +- 2026-08-29 · [Resetting HOTKEYS By Hand-Editing CONFIG.JSON Needs A TUI Restart](memory/entries/2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md) · TERMS: CONFIG.JSON; KEYMAP; HOTKEYS TAB; DEV INSTANCE; DATA DIR; TUI · files: config.json; keymap.rs; event_loop.rs; Makefile · gotchas: 4 - 2026-08-29 · [Proving A SHARED CHECKOUT Reset Loses Nothing Before Running It](memory/entries/2026-08-29-proving-a-shared-checkout-reset-loses-nothing.md) · TERMS: SHARED CHECKOUT; RELEASE SKILL; MEMORY CHECK; STANDING GOTCHAS · files: gotchas.md; MEMORY.md; TERMS.md · gotchas: 3 - 2026-08-29 · [Released v0.21.0 — `cp` What Is Strictly Ahead, Merge What Moved Both Ways](memory/entries/2026-08-29-released-v0-21-0-cp-what-is-ahead-merge-what-moved-both-ways.md) · TERMS: RELEASE SKILL; CARRY SET; RELEASE WORKTREE; SHARED CHECKOUT; QUICK PROMPT FOCUS; MEMORY CHECK · files: gotchas.md; MEMORY.md; TERMS.md; config.rs · gotchas: 6 - 2026-08-29 · [A QUICK PROMPT Launch No Longer Steals The TERMINAL PANE](memory/entries/2026-08-29-a-quick-prompt-launch-no-longer-steals-the-terminal-pane.md) · TERMS: QUICK PROMPT; FOCUS; TERMINAL PANE; LOCKED PANE; SETTING; AGENTS TAB · files: event_loop.rs; app.rs; config.rs · gotchas: 5 @@ -193,5 +198,3 @@ Line shape: `- DATE · [Title](memory/entries/DATE-slug.md) · TERMS: A; B · fi - 2026-08-05 · [Project Dividers And Shift+J/K Reordering](memory/entries/2026-08-05-project-dividers-and-shift-j-k-reordering.md) · TERMS: MOVE UP / MOVE DOWN; PROJECT DIVIDERS; MOVE PROJECT; RECENCY ORDER; PROJECTS PANEL · files: · gotchas: 2 - 2026-08-05 · [Install Script And The Org Slug](memory/entries/2026-08-05-install-script-and-the-org-slug.md) · TERMS: INSTALL.SH; NEBULA UPGRADE; PROJECT · files: install.sh · gotchas: 1 - 2026-08-05 · [iTerm Swallowed Option+Delete](memory/entries/2026-08-05-iterm-swallowed-option-delete.md) · TERMS: SETTING; SESSION; KITTY PROTOCOL; LOCKED PANE; DELETE / DELETE ALL · files: · gotchas: 2 -- 2026-08-04 · [The Focus-Key Odyssey → Ctrl+Q](memory/entries/2026-08-04-the-focus-key-odyssey-ctrl-q.md) · TERMS: SESSION; FOCUS; HARDWIRED UNLOCK; ESCAPE HATCH; DRAG SELECT; KITTY PROTOCOL · files: · gotchas: 5 -- 2026-08-04 · [Bootstrap: Daemon/TUI Split](memory/entries/2026-08-04-bootstrap-daemon-tui-split.md) · TERMS: DAEMON SOCKET; IPC CODEC; HOOK RECEIVER; STATUS MACHINE; DAEMON; TUI · files: codec.rs; ui.rs; status.rs; kitty.rs · gotchas: 0 diff --git a/.claude/memory/archive.md b/.claude/memory/archive.md new file mode 100644 index 0000000..1b9b61b --- /dev/null +++ b/.claude/memory/archive.md @@ -0,0 +1,5 @@ +# Archived MEMORY LOG + +## Archived index +- 2026-08-04 · [Bootstrap: Daemon/TUI Split](memory/entries/2026-08-04-bootstrap-daemon-tui-split.md) · TERMS: DAEMON SOCKET; IPC CODEC; HOOK RECEIVER; STATUS MACHINE; DAEMON; TUI · files: codec.rs; ui.rs; status.rs; kitty.rs · gotchas: 0 +- 2026-08-04 · [The Focus-Key Odyssey → Ctrl+Q](memory/entries/2026-08-04-the-focus-key-odyssey-ctrl-q.md) · TERMS: SESSION; FOCUS; HARDWIRED UNLOCK; ESCAPE HATCH; DRAG SELECT; KITTY PROTOCOL · files: · gotchas: 5 diff --git a/.claude/memory/entries/2026-09-06-named-workflows-and-reusable-agent-definitions.md b/.claude/memory/entries/2026-09-06-named-workflows-and-reusable-agent-definitions.md new file mode 100644 index 0000000..b270841 --- /dev/null +++ b/.claude/memory/entries/2026-09-06-named-workflows-and-reusable-agent-definitions.md @@ -0,0 +1,12 @@ +# Named MANAGED WORKFLOWS And Reusable AGENT Definitions - 2026-09-06 + +**Asked:** "Recap on workflows, first test worked great, But I want to understand now how can I create multiple types of workflows ... we could have in the nebula folder an agents folder that it can pick up the agent from there like which agent to use the agent could be described in line or it's it's from one of the files ... it should be easy for us to kind of oversee the configuration" +→ refined: Extend the working workflow prototype with multiple named definitions in `.nebula/workflows/` and reusable AGENT definitions in `.nebula/agents/`. Support referenced and inline AGENTS, configurable MODEL / EFFORT, and stage-specific instructions. Use TOML unless the existing implementation gives a stronger reason for another format. Use filenames as stable selectors, with optional display names. Update the kickoff skill to discover, inspect, and select workflows. Preserve the existing SQLITE STORE scheduling and legacy JSON definitions, and make the resolved configuration easy to review before starting a run. + +**Did:** Added `workflow_config.rs` and `workflow_config/format.rs` for TOML discovery, typed parsing, reference resolution, and local validation. Added `workflow catalog`, `workflow inspect`, and `start --workflow`; extracted human/JSON rendering into `workflow_cli/output.rs`. Replaced the shipped `.nebula/workflow.json` with `workflows/default.toml`, `reviewed.toml`, and `review.toml` plus three reusable AGENT files, all pinned to `claude-sonnet-5` / `medium`. The kickoff skill now chooses by selector or description and previews the resolved definition. `docs/workflows.md` and `.nebula/README.md` explain composition and precedence. PROTOCOL VERSION 40 carries the optional definition id/name; existing SQLITE STORE snapshots load without a MIGRATION. Promoted MANAGED WORKFLOW after its second task. Clippy and formatting passed; all 915 workspace tests passed with `--test-threads=1` and an isolated `CODEX_HOME`. The initial parallel run hit three existing port bind/rebind races in `browser.rs` and `tunnel.rs` (`AddrInUse`); those tests passed sequentially without code changes. TERMS CHECK and RECALL EVAL passed, and this entry ranks 2 for its own prompt. On continuation, restored the missing index line for `2026-08-29-resetting-hotkeys-by-hand-editing-config-json-needs-a-tui-re.md`, the pre-existing MEMORY CHECK failure. MEMORY CHECK (199/200 index lines), RECALL EVAL, and TERMS CHECK then passed; the restored entry ranks 1 for its own prompt. A live read-only handshake confirmed the DAEMON still uses protocol 39 despite sharing the rebuilt protocol-40 executable path; restarting that DEV INSTANCE would terminate this SESSION and must be run from a separate terminal. + +**Gotchas:** +- The filename is the selector; `name` is a display label. Labels may repeat. With no selector, choose `default.toml`, then the sole TOML definition; multiple non-default files require a choice. Legacy JSON is the automatic fallback only when no TOML definitions exist. Regression tests cover selection and legacy compatibility. +- AGENT instructions are followed by stage instructions; stage MODEL / EFFORT override the AGENT, then omitted values use NEBULA defaults. The combined prompt text must fit the existing 4,000-byte stage bound. Unknown fields, missing references, unsupported kinds, and path/symlink escapes fail before DAEMON contact. +- File resolution happens before kickoff. The E2E test edits the referenced AGENT file and deletes the definition after start, then verifies that a later SESSION still uses the original frozen configuration across DAEMON restarts. The watcher never rereads those files. +- `catalog` and `inspect` need no running DAEMON or AGENT id. Starting a run needs a DAEMON matching PROTOCOL VERSION 40. The previously running prototype was version 39, so configuration can be inspected immediately but execution needs a deliberate restart. No live DAEMON was restarted by this task. diff --git a/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md b/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md index d86434c..4b7ea00 100644 --- a/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md +++ b/.claude/memory/entries/2026-09-06-ordered-agent-workflows-use-the-sqlite-store.md @@ -1,9 +1,11 @@ # Ordered AGENT Workflows Use The SQLITE STORE - 2026-09-06 +Configuration follow-up: [named workflows and reusable AGENTS](2026-09-06-named-workflows-and-reusable-agent-definitions.md) replace the single shipped JSON definition with TOML files. The original JSON format remains supported; the DAEMON scheduling described here still applies. + **Asked:** "Investigate how nebula works and all its commands. ... the idea is to build something on top of nebula to create agent workflows, so I can kick off a task that will be self executed in this order of agents we define" Follow-up: "Hey just one thing I noticed, I guess the state could be tracked inside nebula sql already instead of a .nebula folder, that could be more reliable" → refined: Investigate NEBULA's commands and build a runnable prototype for ordered AGENT workflows. Start a task from `main`, create a separate WORKTREE, and run a planner followed by an implementer in NEBULA SESSIONS, with a reviewer configurable later (assuming two stages initially). Store the workflow definition under `.nebula` and durable run state in the existing SQLITE STORE, including each stage's AGENT KIND and MODEL / EFFORT. A watcher advances only after an explicit stage result. Create a kickoff skill, demonstrate the handoff, and explain limitations. "Managed workflow" has no TERM yet; record it as a candidate. (Asked: two or three stages, provider mix; no answer received. Defaulted to Claude planner, Codex implementer, NEBULA-configured MODEL / EFFORT.) -**Did:** Investigated the CLI, WORKTREE/SESSION creation, STARTING PROMPTS, status hooks, and persistence. Added `workflow start/status/list/report/pause/resume`, MIGRATION 24, PROTOCOL VERSION 39, atomic workflow snapshots plus SESSION associations, and a DAEMON-owned watcher. `.nebula/workflow.json` holds only the versioned definition. Added `/workflow-prototype` under `.claude/skills` with an `.agents/skills` symlink, and `docs/workflows.md` with setup, command inventory, state transitions, and recovery. The engine is in `crates/nebula-daemon/src/workflow.rs` and `workflow/watcher.rs`; persistence is in `store/workflows.rs`. Workspace lint and 906 tests passed, including an isolated real DAEMON/WORKTREE/PTY handoff with STUB AGENTS. No real provider execution, live DAEMON restart, commit, or install. MEMORY CHECK already failed on the unrelated unindexed August 29 HOTKEYS entry before this entry was added. +**Did:** Investigated the CLI, WORKTREE/SESSION creation, STARTING PROMPTS, status hooks, and persistence. Added `workflow start/status/list/report/pause/resume`, MIGRATION 24, PROTOCOL VERSION 39, atomic workflow snapshots plus SESSION associations, and a DAEMON-owned watcher. `.nebula/workflow.json` holds only the versioned definition. Added `/workflow-prototype` under `.claude/skills` with an `.agents/skills` symlink (renamed to `/nebula-workflow` later on September 6), and `docs/workflows.md` with setup, command inventory, state transitions, and recovery. The engine is in `crates/nebula-daemon/src/workflow.rs` and `workflow/watcher.rs`; persistence is in `store/workflows.rs`. Workspace lint and 906 tests passed, including an isolated real DAEMON/WORKTREE/PTY handoff with STUB AGENTS. No real provider execution, live DAEMON restart, commit, or install. MEMORY CHECK already failed on the unrelated unindexed August 29 HOTKEYS entry before this entry was added. **Gotchas:** - FINISHED is a turn state, not task success. The watcher requires both an explicit completed report and FINISHED. The E2E test covers both missing-report and report-before-FINISHED ordering, wrong callers/stages, blocked-result recovery, and immutable completed results. diff --git a/.claude/memory/entries/2026-09-06-removed-global-nebula-kept-development-instance.md b/.claude/memory/entries/2026-09-06-removed-global-nebula-kept-development-instance.md new file mode 100644 index 0000000..c5ad98c --- /dev/null +++ b/.claude/memory/entries/2026-09-06-removed-global-nebula-kept-development-instance.md @@ -0,0 +1,10 @@ +# Removed Global NEBULA And Kept The DEV INSTANCE - 2026-09-06 + +**Asked:** "Please remove the glboal nebula instealled that I have to avoid confusion, lets keep only dev" +→ refined: Remove the globally installed NEBULA executable so this machine uses only the development build. Preserve the DEV INSTANCE, its SESSIONS and data, and the repository’s build outputs. Verify that a fresh shell no longer resolves the global executable. + +**Did:** Found the only installed copy at `~/.local/bin/nebula` (0.21.0), matching INSTALL.SH's default destination, with no Cargo registration or additional common-prefix installation. Its DAEMON, PID 99284, used `/tmp/nebula-501` and had no descendant processes. Stopped it through the installed binary with explicit global runtime/data paths, then removed that executable. The active DEV INSTANCE retained PID 44951 and `target/debug/nebula` remained version 0.22.0. Verified `command -v nebula` fails in a fresh interactive login shell, no process uses the removed installation, and both global and development databases still exist. Archived the oldest index line to keep MEMORY CHECK within its cap. No application code, shell configuration, DEV INSTANCE restart, or build changes. + +**Gotchas:** +- An executable's location does not select its DAEMON. This SESSION inherits development `NEBULA_RUNTIME_DIR` / `NEBULA_DATA_DIR`; invoking the global binary with those values would target the DEV INSTANCE. Global shutdown explicitly used `/tmp/nebula-501` and the app-support DATA DIR, and checked the development PID before and after. +- The user's machine now intentionally has no bare `nebula` command on PATH. Use MAKE DEV to launch the per-checkout instance and the checkout's `target/debug/nebula` for CLI calls from its SESSION. Running that development binary from an ordinary terminal without the development environment would use the default runtime/data location. MANAGED WORKFLOW STARTING PROMPTS already use the DAEMON executable's absolute path. diff --git a/.claude/memory/entries/2026-09-06-workflow-progress-panel-and-readable-labels.md b/.claude/memory/entries/2026-09-06-workflow-progress-panel-and-readable-labels.md new file mode 100644 index 0000000..bdffcf3 --- /dev/null +++ b/.claude/memory/entries/2026-09-06-workflow-progress-panel-and-readable-labels.md @@ -0,0 +1,13 @@ +# MANAGED WORKFLOW Progress Panel And Readable Labels - 2026-09-06 + +**Asked:** "Within Nebula we need a way to visualize all the current running workflows and in which step they are and how many steps are left ... in the footer of nebula we should show like how many workflows are running at that time ... I don't think we need to put this ID in the workflow in the work tree name ... in the workflows panel, we only display the workflows and then it could have a little bit more guidance UI to say like which step it is and then next step and then how many steps left" +→ refined: Add a dedicated workflows panel showing MANAGED WORKFLOW status, current stage, next stage, and steps remaining, with a keyboard toggle and a running count in the FOOTER. Scope it to the open WORKSPACE and keep paused or blocked runs visible. Selecting a workflow should take me to its WORKTREE and SESSIONS. Give workflow WORKTREES a distinct icon and readable task labels, and show concise stage labels for their SESSIONS. Keep stable internal IDs for recovery and make new workflow branch names readable. + +**Did:** Added the WORKFLOWS PANEL candidate, toggled by Shift+O or the clickable FOOTER count, with current/next stage and completed/remaining counts. It spans the open WORKSPACE's PROJECTS, retains all unfinished runs plus ten recent completed runs, and selects the run's WORKTREE and current SESSION. Added a saved Appearance SETTING, draggable width, KEYMAP action, mouse selection/scrolling, PANEL WALK participation, and snapshot/delta progress updates without FOCUS changes. The WORKTREES PANEL uses ◆ and task labels; SESSIONS PANEL, TERMINAL PANE and FOOTER hide generated workflow IDs while preserving explicit renames. New branches use task slugs with numbered collisions. All 922 workspace tests passed serially with an isolated CODEX_HOME, including real DAEMON/WORKTREE/PTY recovery and TUI kickoff/navigation/live pause-resume. Formatting, Clippy, MEMORY CHECK, RECALL EVAL and TERMS CHECK passed; this entry ranks first for its own prompt. Corrected a narrow TERMINAL PANE header collision exposed by the extra column. + +**Gotchas:** +- PROTOCOL VERSION is now 41: the initial snapshot includes compact WorkflowSummary records and persist_workflow broadcasts WorkflowUpdated only after the SQLITE STORE save succeeds. A DEV INSTANCE restart is required to load both sides. +- SESSION launch recovery matches the exact generated name inside its WORKTREE. Keep the ID-bearing stored name; agent_label substitutes the stage only when that generated name still matches, so explicit user renames remain visible. +- The old summary query capped at 50 and could omit an older active or paused run. Summaries now include every stored run but no instructions or artifacts; only the panel's completed history is capped. The regression test seeds 60 runs. +- Readable branches need collision checks against both Git refs and WORKTREE DIR paths. Strict add_new_worktree refuses an existing branch even if another caller creates it after the name check; ordinary add_worktree retains its existing-branch fallback. +- Persisted panel_widths stays a three-element array for old UI STATE blobs. Workflow width is separate, logical index 3 sits between indices 1 and 2, and splitter arithmetic follows visible order. The baseline 17 panel tests passed before extraction; new tests cover toggle, selection, scope, progress, resizing and restore. diff --git a/.claude/memory/entries/2026-09-06-workflow-skill-renamed-and-cli-kickoff-boundary.md b/.claude/memory/entries/2026-09-06-workflow-skill-renamed-and-cli-kickoff-boundary.md new file mode 100644 index 0000000..05509c0 --- /dev/null +++ b/.claude/memory/entries/2026-09-06-workflow-skill-renamed-and-cli-kickoff-boundary.md @@ -0,0 +1,10 @@ +# MANAGED WORKFLOW Skill Rename And CLI Kickoff Boundary - 2026-09-06 + +**Asked:** "By the way lets change the skill name to be proper now leike /nebula-workflow or something ... is ti possible to kickoff worflows only from nebula cli? or do we need to invoke from an agent?" +→ refined: Rename the MANAGED WORKFLOW kickoff skill to `/nebula-workflow`, update its shared skill link and current documentation, and explain whether `nebula workflow start` can run from an ordinary terminal or requires an AGENT SESSION. Preserve the current execution behavior. + +**Did:** Renamed the source directory and frontmatter to `.claude/skills/nebula-workflow`, replaced its `.agents/skills` symlink, and updated invocation examples and TERMS. `docs/workflows.md` now distinguishes direct CLI calls from skill invocation and lists which commands require an AGENT SESSION. Runtime directories and Rust behavior are unchanged. Manually verified the new name and link, then ran the built CLI with `NEBULA_AGENT_ID` removed and isolated data/runtime paths: start rejected the missing AGENT context before creating a DAEMON; inspect succeeded without one. No provider run, DAEMON restart, or Rust suite repeat was needed. + +**Gotchas:** +- The project skill's directory name determines its slash command, so renaming only YAML `name` is insufficient. Both the source directory and the shared `.agents/skills` link must move; the old skill path is removed. Historical references name the replacement. +- The skill is optional, but kickoff still resolves its PROJECT through the registered caller SESSION in `start_workflow`, not the CLI's working directory. `caller()` requires `NEBULA_AGENT_ID`; the DAEMON rejects archived or workflow-assigned callers and requires their ROOT WORKTREE on `main`. Local inspection succeeding outside NEBULA does not imply standalone kickoff is supported. diff --git a/.claude/skills/nebula-workflow/SKILL.md b/.claude/skills/nebula-workflow/SKILL.md new file mode 100644 index 0000000..6f40e4b --- /dev/null +++ b/.claude/skills/nebula-workflow/SKILL.md @@ -0,0 +1,53 @@ +--- +name: nebula-workflow +description: Choose and start a named workflow from .nebula/workflows using reusable or inline AGENTS. Use when the user names a workflow, asks to kick off a managed task, or requests an ordered planning, implementation, or review sequence in NEBULA. +--- + +# Start a workflow + +Invocation: `/nebula-workflow [workflow-selector] <task>`. + +The DAEMON owns WORKTREE creation, SESSIONS, scheduling, and durable state. This skill selects +a definition and submits the task through `nebula workflow start`. Kickoff currently requires +a NEBULA AGENT SESSION in the ROOT WORKTREE on `main`; the CLI resolves the PROJECT through +that SESSION. Read `docs/workflows.md` for configuration or recovery. + +1. Check `nebula workflow catalog --help`. Use this checkout's `target/debug/nebula` if the + installed executable lacks the command. Use that same executable throughout. Check + `workflow status --json`: if this SESSION is already assigned to a run, continue its + current stage instead of starting a nested workflow. "Does not belong" is normal for + the kickoff SESSION; resolve other errors before starting. A VERSION SKEW requires a + matching DAEMON; never install or stop the user's live DAEMON without authorization. +2. Run `workflow catalog --json`. Honor an explicit filename selector. Otherwise choose the + definition whose description and stage order fit the user's task. Prefer `default` for an + ordinary implementation task when it exists and fits. When two plausible choices would + change the outcome, ask one concise question. If the user supplied only a selector, ask + for the task. Report a selected definition's validation error instead of silently + choosing another workflow. +3. Run `workflow inspect <selector> --json`. Read the resolved stages, AGENT source paths, + MODEL / EFFORT, and composed instructions. Preserve them unless the user asked for an + edit. State the selected workflow and stage order in one line; no confirmation is + required for a task the user already authorized. The optional display `name` is a label; + the filename without `.toml` is the command selector. +4. Write the task and its constraints to a temporary UTF-8 file. From the ROOT WORKTREE on + `main`, run the checked executable with the chosen selector: + + ```sh + nebula workflow start --task-file /absolute/path/to/task.txt \ + --workflow <selector> + ``` + + Use the actual paths and shell-quote them. Delete only the temporary task file you created + after a successful response. If the request times out, inspect `nebula workflow list` + before considering another start: WORKTREE creation may have succeeded. If the catalog + exposes a legacy JSON entry, use `--definition <its path>` for inspect and start instead. +5. Report the run id, WORKTREE path, configured AGENT order, and current status. The caller + remains on `main`. The DAEMON continues independently after this SESSION ends its turn. + +Workers must send `workflow report` through the executable named in their STARTING PROMPT, +as their final tool call. A completed result requires a Markdown file; the DAEMON copies its +contents into the SQLITE STORE. Report `blocked` with a concrete reason when work cannot +finish. Never infer completion from FINISHED alone or manually edit the database. + +The prototype leaves all changes uncommitted. It does not authorize commits, pushes, merges, +deployments, permission bypasses, or other actions outside the user's task. diff --git a/.claude/skills/workflow-prototype/SKILL.md b/.claude/skills/workflow-prototype/SKILL.md deleted file mode 100644 index 4f80e54..0000000 --- a/.claude/skills/workflow-prototype/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: workflow-prototype -description: Start an ordered AGENT workflow from main using NEBULA WORKTREES and SESSIONS, with durable state in the SQLITE STORE. Use when the user asks to kick off a managed task, run planner then implementer, or execute the stages in .nebula/workflow.json. ---- - -# Start a workflow - -The DAEMON creates the WORKTREE, launches the configured AGENTS, stores their results, and -advances the stages. This skill only submits the task. Read `docs/workflows.md` for recovery -or configuration details. - -1. Read `.nebula/workflow.json`. Preserve the declared stage order and MODEL / EFFORT unless - the user requested a change. Both planner and implementer use `claude-sonnet-5` with medium effort. -2. Check `nebula workflow --help`. If the installed command is older than the prototype, - use this checkout's `target/debug/nebula` and its isolated development DAEMON. Never - install a binary or stop the live DAEMON to resolve a VERSION SKEW without authorization. -3. Run `nebula workflow status --json`. Success means this SESSION is already assigned to - a workflow: continue its current stage and report its result instead of starting another. - A "does not belong" response is normal for the kickoff SESSION; other errors need resolving. -4. Write the user's task, including its constraints, to a temporary UTF-8 file. From the ROOT - WORKTREE on `main`, run the checked executable with: - - ```sh - nebula workflow start --task-file /absolute/path/to/task.txt \ - --definition /absolute/path/to/.nebula/workflow.json - ``` - - Use the actual paths and shell-quote them. Delete only the temporary task file you created - after a successful response. If the request times out, inspect `nebula workflow list` - before considering another start: WORKTREE creation may have succeeded. -5. Report the run id, WORKTREE path, configured AGENT order, and current status. The caller - remains on `main`. The DAEMON continues independently after this SESSION ends its turn. - -Workers must send `workflow report` through the executable named in their STARTING PROMPT, -as their final tool call. A completed result requires a Markdown file; the DAEMON copies its -contents into the SQLITE STORE. Report `blocked` with a concrete reason when work cannot -finish. Never infer completion from FINISHED alone or manually edit the database. - -The prototype leaves all changes uncommitted. It does not authorize commits, pushes, merges, -deployments, permission bypasses, or other actions outside the user's task. diff --git a/.nebula/README.md b/.nebula/README.md new file mode 100644 index 0000000..4cc1965 --- /dev/null +++ b/.nebula/README.md @@ -0,0 +1,47 @@ +# Workflow configuration + +Edit a workflow to change the sequence. Edit an AGENT to change its provider, +exact model ID, effort, or reusable instructions. + +```text +.nebula/ + workflows/ + default.toml planner -> implementer + reviewed.toml planner -> implementer -> reviewer + review.toml reviewer + agents/ + planner.toml + implementer.toml + reviewer.toml +``` + +The filename is the selector: `reviewed.toml` means `--workflow reviewed`. +The optional `name` is a display label; `description` helps the kickoff skill choose. +`default.toml` is the default when no selector is supplied. + +From the repository root, inspect the configuration without starting SESSIONS: + +```sh +./target/debug/nebula workflow catalog +./target/debug/nebula workflow inspect reviewed +``` + +Start from an AGENT in the ROOT WORKTREE on `main`: + +```text +/nebula-workflow reviewed <your task> +``` + +The skill selects and submits through `nebula workflow start`; calling that CLI directly +also works inside a NEBULA AGENT SESSION. Kickoff from an ordinary terminal is not yet +supported. `catalog` and `inspect` work from either context. + +Stages can reference an AGENT file or define an inline table. Stage `model` and +`effort` override the AGENT's values; values absent from both use NEBULA defaults. Stage +`instructions` are appended to the AGENT's reusable instructions. Inspect shows +the final values and source paths. + +The DAEMON freezes the resolved configuration in the SQLITE STORE at kickoff. +File edits affect new runs. Review findings stop at a blocker; there is no automatic +review/fix loop. See [the full guide](../docs/workflows.md) for inline examples, +validation, legacy JSON, and recovery. diff --git a/.nebula/agents/implementer.toml b/.nebula/agents/implementer.toml new file mode 100644 index 0000000..b7ff720 --- /dev/null +++ b/.nebula/agents/implementer.toml @@ -0,0 +1,9 @@ +kind = "claude" +model = "claude-sonnet-5" +effort = "medium" + +instructions = """ +Read the plan and any previous stage results, implement the task, and run the +relevant checks. Report the changed behavior, verification evidence, and any +unresolved issues. +""" diff --git a/.nebula/agents/planner.toml b/.nebula/agents/planner.toml new file mode 100644 index 0000000..fbcba51 --- /dev/null +++ b/.nebula/agents/planner.toml @@ -0,0 +1,8 @@ +kind = "claude" +model = "claude-sonnet-5" +effort = "medium" + +instructions = """ +Investigate the task and write an actionable plan with acceptance criteria, +affected files, and verification steps. Leave product code unchanged. +""" diff --git a/.nebula/agents/reviewer.toml b/.nebula/agents/reviewer.toml new file mode 100644 index 0000000..3d1db9a --- /dev/null +++ b/.nebula/agents/reviewer.toml @@ -0,0 +1,10 @@ +kind = "claude" +model = "claude-sonnet-5" +effort = "medium" + +instructions = """ +Read the task and previous stage results. Review the relevant code and any +changes against the recorded base commit. Check correctness and verification +evidence. Report completed when acceptable, or blocked with specific findings. +Leave product code unchanged. +""" diff --git a/.nebula/workflow.json b/.nebula/workflow.json deleted file mode 100644 index 10d70d7..0000000 --- a/.nebula/workflow.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 1, - "timeout_seconds": 1800, - "stages": [ - { - "id": "planner", - "kind": "claude", - "model": "claude-sonnet-5", - "effort": "medium", - "instructions": "Investigate the task and write an actionable plan with acceptance criteria, affected files, and verification steps. Leave product code unchanged." - }, - { - "id": "implementer", - "kind": "claude", - "model": "claude-sonnet-5", - "effort": "medium", - "instructions": "Read the planner result, implement the task, and run the relevant checks. Report the changed behavior, verification evidence, and any unresolved issues." - } - ] -} diff --git a/.nebula/workflows/default.toml b/.nebula/workflows/default.toml new file mode 100644 index 0000000..89b1f53 --- /dev/null +++ b/.nebula/workflows/default.toml @@ -0,0 +1,13 @@ +# The filename is the selector. default.toml is used when none is supplied. +version = 1 +name = "Plan and implement" +description = "Implement a task after a dedicated planning stage." +timeout_seconds = 1800 + +[[stages]] +id = "planner" +agent = "planner" + +[[stages]] +id = "implementer" +agent = "implementer" diff --git a/.nebula/workflows/review.toml b/.nebula/workflows/review.toml new file mode 100644 index 0000000..14fc9fa --- /dev/null +++ b/.nebula/workflows/review.toml @@ -0,0 +1,9 @@ +version = 1 +name = "Review existing code" +description = "Investigate and review existing code or a reference supplied in the task, without implementing changes." +timeout_seconds = 1800 + +[[stages]] +id = "reviewer" +agent = "reviewer" +instructions = "There is no implementation stage in this workflow. Inspect the code or reference named in the task." diff --git a/.nebula/workflows/reviewed.toml b/.nebula/workflows/reviewed.toml new file mode 100644 index 0000000..0a784f4 --- /dev/null +++ b/.nebula/workflows/reviewed.toml @@ -0,0 +1,16 @@ +version = 1 +name = "Plan, implement, and review" +description = "Implement a task and have a separate AGENT review the result." +timeout_seconds = 1800 + +[[stages]] +id = "planner" +agent = "planner" + +[[stages]] +id = "implementer" +agent = "implementer" + +[[stages]] +id = "reviewer" +agent = "reviewer" diff --git a/Cargo.lock b/Cargo.lock index 3265c14..a3669e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -1136,9 +1146,11 @@ dependencies = [ "nebula-daemon", "nebula-tui", "portable-pty", + "serde", "serde_json", "tempfile", "tokio", + "toml", "tracing", "tracing-subscriber", "vt100", @@ -1887,6 +1899,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2310,6 +2331,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -2706,6 +2766,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winreg" version = "0.10.1" diff --git a/TERMS.md b/TERMS.md index e1b0f71..247fc99 100644 --- a/TERMS.md +++ b/TERMS.md @@ -64,7 +64,7 @@ Everything nests: WORKSPACE → PROJECT → WORKTREE → SESSION. The DAEMON own | **SQLITE STORE** | `nebula.db` in the DATA DIR: workspaces, projects, worktrees, agents (kind + CLI session id + the `pr_url` an OPEN PRS launch carries), terminals, links, `pr_seen`, `ui_state`. Also holds ordered workflow snapshots, results, and SESSION associations. Schema advanced by MIGRATIONS. | "the db", "sqlite", "nebula sql" | `nebula-daemon/src/store.rs::Store` | | **MIGRATION** | A numbered `PRAGMA user_version` step in the store (24 so far). Adding a column = a migration; adding a field to an entity usually also = a PROTOCOL VERSION bump. | "migration" | `store.rs::MIGRATIONS` | | **BUILDSTAMP** | A content hash of the running daemon binary written to `daemon.build` at start. Installers compare it to detect a stale daemon (STALE DAEMON NOTE); it cannot name the binary's path. | "buildstamp" | `lifecycle.rs::write_buildstamp` · `paths.rs::buildstamp_path` | -| **PROTOCOL VERSION** | `PROTOCOL_VERSION` (39) exchanged in the VERSION SKEW. Frames are positional msgpack, so any new field on a shared struct bumps it. Two branches that each bump it merge to the *same* number — diff it against `origin/main` before a release commit. | "protocol", "v26/v27/v28/v29/v30/v32" | `nebula-core/src/protocol.rs::PROTOCOL_VERSION` | +| **PROTOCOL VERSION** | `PROTOCOL_VERSION` (40) exchanged in the VERSION SKEW. Frames are positional msgpack, so any new field on a shared struct bumps it. Two branches that each bump it merge to the *same* number — diff it against `origin/main` before a release commit. | "protocol", "v26/v27/v28/v29/v30/v32" | `nebula-core/src/protocol.rs::PROTOCOL_VERSION` | | **VERSION SKEW** | A DAEMON and a client built from different PROTOCOL VERSIONS, caught by the handshake (`Hello{protocol_version}` → `HelloOk{daemon_pid}` or `Incompatible`, then `Subscribe` → `Snapshot`). The client's message names both binaries and says which side is older: `make install` when the client is, `nebula kill` when the daemon is. | "protocol mismatch", "daemon speaks protocol v26, this client v24", "the hook still seems to fail", "handshake" | `nebula-tui/src/ipc.rs::version_skew_message` | | **SERVER EVENT** | The event family the DAEMON pushes: `Snapshot`, `EntityUpserted`/`EntityRemoved` (deltas), `StatusChanged{unseen}`, `Scrollback`, `Output`, `SessionExited`, `KittyFlags`, `Metrics`. | "delta", "upsert" | `protocol.rs::ServerEvent` | | **IPC CODEC** | Length-prefixed MessagePack frames over the DAEMON SOCKET, 4 MiB max. | "msgpack", "rmp", "client request" | `nebula-core/src/codec.rs` | @@ -85,13 +85,13 @@ Everything nests: WORKSPACE → PROJECT → WORKTREE → SESSION. The DAEMON own | **WORKSPACE TAB** | One tab in the WORKSPACES BAR: the workspace name, its ROLLUP STATUS DOT and a DONE BADGE count. Selectable by SELECT WORKSPACE N. | "tab", "header workspace name" | `ui.rs::draw_workspaces_bar` · `1`–`9` | | **TAB UNDERLINE** | The `▀` under the open WORKSPACE TAB, in the tab's ROLLUP STATUS DOT color (the accent before 2026-08-28), flush with the tab's fill (a `━` left a half-cell gap). | "the bottom bar", "the underline", "green underline", "gap under the tab" | `ui.rs::draw_workspaces_bar` · `selection_mark` | | **PROJECTS PANEL** | Optional leftmost column, listing the OPEN WORKSPACE's PROJECTS. Its header reads `PROJECTS` while the WORKSPACES BAR is shown and the workspace's name (upper-cased) when it is hidden. | "projects list", "projects column", "projects", "projects worktrees and sessions lists" | `ui.rs::draw_projects` · `Focus::Projects` · `Config::hide_projects` · `Shift+P` | -| **WORKTREES PANEL** | Optional middle column: the selected PROJECT's WORKTREES in RECENCY ORDER plus the PROJECT OPEN PRS GROUP. | "worktrees column", "worktrees list", "worktrees row" | `ui.rs::draw_worktrees` · `Focus::Worktrees` · `Config::hide_worktrees` · `Shift+B` | +| **WORKTREES PANEL** | Optional middle column: the selected PROJECT's WORKTREES in RECENCY ORDER plus the PROJECT OPEN PRS GROUP. MANAGED WORKFLOWS carry ◆ and readable task labels. | "worktrees column", "worktrees list", "worktrees row" | `ui.rs::draw_worktrees` · `Focus::Worktrees` · `Config::hide_worktrees` · `Shift+B` | | **SESSIONS PANEL** | Always-visible third column: the selected WORKTREE's SESSIONS in SESSION GROUPS, plus the WORKTREE OPEN PRS GROUP. | "sessions list", "sessions column", "session list", "sessions", "session rows", "recent list", "focused on the session", "archive", "archived", "worktree session list" | `ui.rs::draw_sessions` · `Focus::Sessions` | | **RECENCY ORDER** | How every list column orders itself: PROJECTS, WORKTREES and live SESSIONS sit most-recently-interacted first, a RUNNING session counting as now, never-run rows keeping tree order at the bottom. There is no manual reorder (MOVE PROJECT is retired) and no PIN (retired); the AGO BADGE shows the stamp. | "recent to top", "order by last interaction", "goes to top of list", "always just move recent to top", "recent at the top", "time stamps", "most recently used" | `app.rs::Recency` · `last_interaction_ms` · `project_rows` · `visible_worktrees` · `visible_sessions` | | **TERMINAL PANE** | The right-hand pane showing the attached SESSION (title `TERMINAL`), or the PR PREVIEW (`PULL REQUEST`). Its chips: `INPUT` (LOCKED PANE), `scroll N`, `exited`. | "terminal panel", "the pane", "the terminal", "claude code session", "focused session terminal" | `ui.rs::draw_terminal` · `Focus::Terminal` | -| **PANEL** | A focusable screen region: the optional PROJECTS PANEL and WORKTREES PANEL, the always-visible SESSIONS PANEL, or the TERMINAL PANE. Sidebar widths are draggable and remembered while hidden; the WORKSPACES BAR is a separate top strip. | "panel", "column", "sidebar" | `app.rs::Focus` · `app.rs::App::panel_widths` | +| **PANEL** | A focusable screen region: the optional PROJECTS PANEL and WORKTREES PANEL, the always-visible SESSIONS PANEL, the optional workflow progress column, or the TERMINAL PANE. Sidebar widths are draggable and remembered while hidden; the WORKSPACES BAR is a separate top strip. | "panel", "column", "sidebar" | `app.rs::Focus` · `app.rs::App::panel_widths` | | **PR PREVIEW** | The TERMINAL PANE rendering a pull request's description, stats and conversation as wrapped text while a cursor rests on it — a PROJECT OPEN PRS GROUP row, or the PR ROW while the SESSIONS PANEL has FOCUS (focusing the pane brings the attached session back). Only the row you stop on is fetched, once per URL. | "pr preview", "read the PR in the pane", "show the contents of the PR directly in nebula", "hover over a PR", "pr description on the right" | `nebula-tui/src/pr_preview.rs` | -| **FOOTER** | The bottom bar: `✗ disconnected` (the `⏻ connected` span is built but never pushed — the state shows only when something is wrong), per-FOCUS key hints, restore hints for a hidden PROJECTS PANEL or WORKTREES PANEL, flash messages, the NAMEPLATE, hostname, and the agents / terms / WARM COUNT tallies. | "footer", "bottom bar", "status bar", "bottom left" | `ui.rs::draw_footer` · `app.rs::ConnState` | +| **FOOTER** | The bottom bar: `✗ disconnected` (the `⏻ connected` span is built but never pushed — the state shows only when something is wrong), per-FOCUS key hints, restore hints for a hidden PROJECTS PANEL or WORKTREES PANEL, flash messages, the NAMEPLATE, hostname, the agents / terms / WARM COUNT tallies, and a clickable count of active MANAGED WORKFLOWS in the open WORKSPACE. | "footer", "bottom bar", "status bar", "bottom left" | `ui.rs::draw_footer` · `app.rs::ConnState` | | **WORKSPACE NAMEPLATE** | The `◇ workspace` chip bottom-left of the FOOTER; clicking it opens the WORKSPACE SWITCHER. | "nameplate", "workspace chip" | `ui.rs::draw_footer_bar` · `app.rs::HitTarget::FooterWorkspace` | | **VERSION NAMEPLATE** | The `nebula vX.Y.Z` at the FOOTER's left edge (18 columns; yields only to a FLASH), followed by the UPDATE INDICATOR (` ⇡ vX.Y.Z`, warning color) while a newer release is published on GitHub. Same source as `nebula --version`. | "version number of nebula in the bottom bar", "my ui still says version" | `ui.rs::draw_footer` · `CARGO_PKG_VERSION` | | **UPDATE INDICATOR** | The ` ⇡ vX.Y.Z` after the VERSION NAMEPLATE while GitHub's latest release is newer than the running binary — a nudge toward NEBULA UPGRADE. Checked at start and hourly by one `curl` to the release page's redirect (no `gh` token), silent when it cannot ask; `NEBULA_UPDATE_CHECK_SECS` sets the interval, `0` turns it off. | "a new upgate is available", "update indicator" | `nebula-tui/src/update_check.rs` · `App::update_available` · `NEBULA_UPDATE_CHECK_SECS` | @@ -133,7 +133,7 @@ Everything nests: WORKSPACE → PROJECT → WORKTREE → SESSION. The DAEMON own | TERM | What it is | Also called | Where · key | |---|---|---|---| -| **FOCUS** | Which visible stop has the cursor: the WORKSPACES BAR, PROJECTS PANEL, WORKTREES PANEL, SESSIONS PANEL, or TERMINAL PANE. Hidden panels cannot own FOCUS. Moving the cursor onto a SESSION previews it (ATTACH) and reads it (MARK SEEN). | "focus", "the cursor" | `app.rs::Focus` · `App::focus_visible` | +| **FOCUS** | Which visible stop has the cursor: the WORKSPACES BAR, PROJECTS PANEL, WORKTREES PANEL, the optional workflow progress column, SESSIONS PANEL, or TERMINAL PANE. Hidden panels cannot own FOCUS. Moving the cursor onto a SESSION previews it (ATTACH) and reads it (MARK SEEN). | "focus", "the cursor" | `app.rs::Focus` · `App::focus_visible` | | **PANEL WALK** | Moving FOCUS with FOCUS NEXT / FOCUS PREV (Tab / Shift+Tab, `Ctrl+Shift+L` / `Ctrl+Shift+H`). It skips hidden panels; forward stops at the TERMINAL PANE and locks it (LOCKED PANE), while backward stops at the WORKSPACES BAR when shown or the first visible sidebar. Neither direction cycles or wraps. | "the walk", "the nav", "cycle the nav", "control shift h or l", "tab through the panels", "navigation keys to toggle through", "h and l should be for left and right" | `event_loop/focus_walk.rs::walk_focus_forward` / `walk_focus_back` · `App::next_visible_focus` / `previous_visible_focus` · `Tab` / `Shift+Tab` | | **WALK EDGE** | The place where a single move key is a no-op and a DOUBLE TAP jumps the boundary: the SESSIONS PANEL (or an unlocked TERMINAL PANE) going right, the first visible sidebar going left, a panel's first row going up (`k`/↑, while the WORKSPACES BAR is shown), and the WORKSPACES BAR going down (`j`/↓). Hidden panels are never an edge. | "locked layer", "the edge", "the end of the row", "blocked boundary", "at the first" | `event_loop.rs::Action::FocusLeft` / `FocusRight` / `MoveUp` / `MoveDown` · `event_loop/focus_walk.rs::at_top_row` | | **DOUBLE TAP** | Two presses of the same move key within `DOUBLE_TAP` (400 ms) at a WALK EDGE: `l`,`l` at Sessions enters and locks the pane like FOCUS NEXT; `h`,`h` at the first visible sidebar or `k`,`k` on a panel's first row steps up into the WORKSPACES BAR (only while shown); `j`,`j` in the bar drops back onto the panel focus came up from, cursor untouched (the first visible sidebar if it never came up, or if that panel has since been hidden). The first press stays put and flashes "`l` again: enter pane" / "`k` again: workspaces" / "`j` again: back to sessions"; any other key in between, or a slower second press, breaks the pair. The arrows share the actions, so ←/→/↑/↓ get it too. | "double tap", "double tap h or l", "jump over that blocked boundary", "second press", "double tap k", "double tab j" | `event_loop/focus_walk.rs::double_tapped` · `DOUBLE_TAP` · `app.rs::App::edge_tap` · `App::bar_return` | @@ -266,6 +266,7 @@ Everything nests: WORKSPACE → PROJECT → WORKTREE → SESSION. The DAEMON own | **NEBULA KILL** | Stop the DAEMON and every SESSION cleanly (SIGTERM fallback on VERSION SKEW). The cutover step after MAKE INSTALL when the *daemon* is the old side. | "kill the daemon", "kill-server" | `nebula kill` | | **NEBULA RENAME** | Title the current SESSION from inside it — what AUTO-TITLE runs. `--force` retitles a named one; a late attempt is "already titled". | "rename", "the rename hook" | `nebula rename <title> [--force]` · `nebula-tui/src/lib.rs::run_rename` | | **NEBULA WORKTREE** | Move the current SESSION into a WORKTREE of its PROJECT (WORKTREE RELOCATION); no name invents one, `--base <ref>` picks a new branch's start. | "nebula worktree", "do this in a worktree", "from a work tree" | `nebula worktree [name] [--base ref]` · `nebula-tui/src/lib.rs::run_worktree` | +| **MANAGED WORKFLOW** | An ordered AGENT sequence defined in `.nebula/workflows/<selector>.toml`, with optional display `name` and stages that reference `.nebula/agents/*.toml` or define AGENTS inline. `nebula workflow` and `/nebula-workflow` resolve MODEL / EFFORT and instructions; the DAEMON freezes the definition in the SQLITE STORE, creates a WORKTREE from `main`, and advances after an explicit completed report plus FINISHED. | "workflow", "agent workflow", "workflow types", "managed task", "workflows folder", "nebula-workflow" | `nebula/src/workflow_config.rs::load` · `nebula-daemon/src/workflow.rs::workflow_op` · `nebula-daemon/src/workflow/watcher.rs::tick_workflows` · `.claude/skills/nebula-workflow/SKILL.md` | | **NEBULA SPAWN** | `nebula spawn "<task>" [--kind claude\|codex\|cursor\|pi]`, run by an AGENT from inside its own SESSION when the user says "start a new nebula session that …": the DAEMON starts a second AGENT beside the caller — same WORKTREE, same AGENT KIND and MODEL / EFFORT unless `--kind` names another — on the task as its STARTING PROMPT, named `agent-N` so AUTO-TITLE applies, landing in the SESSIONS PANEL without moving FOCUS; the caller is untouched. Claude learns it from `CLAUDE_SPAWN_GUIDANCE`, appended after the WORKTREE GUIDANCE. | "start a new nebula session", "run a new session automatically", "spin up another session" | `nebula-daemon/src/sibling.rs::spawn_sibling_agent` · `protocol.rs::ClientRequest::SpawnSiblingAgent` · `ipc.rs::spawn_sibling_for_current_agent` · `nebula spawn` | | **NEBULA OPEN** | `nebula open <file>…`, run by an AGENT from inside its SESSION: the CLI resolves the paths in its own cwd, the DAEMON checks the caller and broadcasts `FilesOpened` (the WORKTREE path riding along as the editor's cwd), and every attached TUI raises the FILE TABS. Taught to Claude and pi by an appended system prompt and a `Bash(nebula open:*)` permission, like NEBULA SPAWN. | "open file inside this app", "show me the file" | `nebula open <file>…` · `crates/nebula-daemon/src/open_files.rs` · `nebula-tui/src/ipc.rs::open_files_for_current_agent` | | **NEBULA WORKSPACE** | `add` / `open` / `list` / `rename` / `delete` WORKSPACES from the shell; `open` sets what the next instance launches into. | — | `nebula workspace <sub>` | @@ -384,7 +385,9 @@ is pruned. Do not cross-reference a candidate in caps from a TERM row. | CANDIDATE | What it seems to be | Seen | Where | |---|---|---|---| -| **MANAGED WORKFLOW** | An ordered list of AGENTS configured in `.nebula/workflow.json`, kicked off from `main` by `/workflow-prototype`; the DAEMON owns its WORKTREE, SESSIONS, watcher and durable run state in the SQLITE STORE. | 2026-09-06 prompt ("create agent workflows", "state could be tracked inside nebula sql") | `nebula-daemon/src/workflow.rs::workflow_op` · `nebula-daemon/src/workflow/watcher.rs::tick_workflows` · `.claude/skills/workflow-prototype/SKILL.md` | +| **WORKFLOWS PANEL** | Optional workflow progress column between WORKTREES PANEL and SESSIONS PANEL, opened by Shift+O or the FOOTER count. Lists all unfinished MANAGED WORKFLOWS in the open WORKSPACE plus ten recent completions; selecting a row previews its WORKTREE/SESSION. | 2026-09-06 prompt ("workflows panel", "which step it is", "how many steps left") · 2026-09-06 MEMORY "MANAGED WORKFLOW Progress Panel And Readable Labels" | `nebula-tui/src/ui/workflows.rs::draw` · `nebula-tui/src/workflows.rs::Workflows` · `keymap.rs::Action::ToggleWorkflows` | +| **WORKFLOW AGENT** | A reusable role file in `.nebula/agents/` containing AGENT KIND, MODEL / EFFORT and instructions, or the same fields inline in a workflow stage; separate from the TUI AGENT PRESET. | 2026-09-06 prompt ("agents folder", "agent could be described in line") | `nebula/src/workflow_config/format.rs::AgentFile` | +| **WORKFLOW CATALOG** | `nebula workflow catalog`, a local list of named definitions, descriptions, stage order and validation errors; `workflow inspect` previews the resolved configuration without contacting the DAEMON. | 2026-09-06 prompt ("oversee the configuration") | `nebula/src/workflow_config.rs::catalog` · `nebula/src/workflow_cli/output.rs::inspect` | | **LAND SKILL** | The `land` skill: the git and `gh` chores that put finished work on `main` — MAKE CI, a branch, one commit, a push, a PR, CLAUDE REVIEW's comments when asked to babysit, `gh pr merge --merge`, the checkout fast-forwarded back — standing in for PROMPT DADDY, NEBULA-MEMORY and PROJECT TERMS on that prompt. | 2026-09-05 AskUserQuestion pick ("New LAND skill for git chores") · 2026-09-05 MEMORY "A SKILL AUDIT HOOK On Stop, Housekeeping Skips For PROMPT DADDY And NEBULA-MEMORY, A Quote-Aware GUARD HOOK" | `.claude/skills/land/SKILL.md` | | **SKILL AUDIT HOOK** | The `Stop` hook that fires once per closed task — after a turn in which the NEBULA-MEMORY SKILL ran; `NEBULA_SKILL_AUDIT_COOLDOWN_MIN` spaces audits out — and keeps the turn going with a brief on the skills the session invoked (names, invocations, body sizes) so the agent proposes at most three cuts, merges, tightened rules or a new skill as one `AskUserQuestion` and applies what the user picks; unattended it writes `.claude/memory/skill-audit/` instead. `NEBULA_SKILL_AUDIT=off` silences it. | 2026-09-05 prompt ("a hook that runs at the very end which will self improve all of my skills") · 2026-09-05 MEMORY "A SKILL AUDIT HOOK On Stop, Housekeeping Skips For PROMPT DADDY And NEBULA-MEMORY, A Quote-Aware GUARD HOOK" | `.claude/hooks/skill_audit.py` · `.claude/settings.json` | | **RELEASE SNAPSHOT** | Two commits on a scratch branch — S, the SHARED CHECKOUT as it stood at the previous release cut (that release's scratchpad `local.patch` applied to the shared HEAD), and T, the SHARED CHECKOUT now — so `git cherry-pick --no-commit T` onto `origin/main` merges only the post-release delta into the RELEASE WORKTREE. | 2026-08-28 MEMORY "Released v0.15.0 By Cherry-Picking Only The Post-v0.14.0 Delta Onto origin/main" | branch `snap-shared` · `git cherry-pick --no-commit <T>` | @@ -414,6 +417,7 @@ The user's word → the TERM. A word under two TERMS is ambiguous: settle it (PR | They say | They mean | |---|---| +| "workflow", "agent workflow", "workflow types", "managed task", "workflows folder", "nebula-workflow" | MANAGED WORKFLOW | | "docs", "the docs", "documentation", "readme" | DOCS PAGES — `README.md` and the five pages under `docs/` | | "pull all data related to the pr", "the pr including comments" | PR ARCHIVE — the merged-PR record under `.claude/memory/prs/` | | "a skill to describe pr descriptions", "pr skill", "table of contents", "table of contents links at top of pr" | PR DESCRIPTION SKILL | diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 6b4e0c1..914a35f 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; /// Bump on any breaking change to these enums. The daemon refuses mismatched /// clients; the client then offers a kill-and-restart of the old daemon. -pub const PROTOCOL_VERSION: u32 = 39; +pub const PROTOCOL_VERSION: u32 = 41; /// Max IPC frame size (length prefix sanity bound). pub const MAX_FRAME_LEN: u32 = 4 * 1024 * 1024; @@ -438,6 +438,7 @@ pub enum ServerEvent { daemon_protocol_version: u32, }, Snapshot { + workflows: Vec<crate::workflow::WorkflowSummary>, workspaces: Vec<Workspace>, /// The workspace to scope this client's project lists to: the /// last one opened anywhere, which is only ever a starting point — @@ -470,6 +471,9 @@ pub enum ServerEvent { }, // -- deltas (pushed to all subscribers) -- + WorkflowUpdated { + workflow: crate::workflow::WorkflowSummary, + }, EntityUpserted { entity: Entity, }, diff --git a/crates/nebula-core/src/workflow.rs b/crates/nebula-core/src/workflow.rs index e5218b0..6066677 100644 --- a/crates/nebula-core/src/workflow.rs +++ b/crates/nebula-core/src/workflow.rs @@ -1,12 +1,17 @@ //! Prototype workflow definitions and durable run state. The DAEMON owns transitions. -use crate::{AgentId, AgentKind, ProjectId, Worktree}; +use crate::{AgentId, AgentKind, ProjectId, Worktree, WorktreeId}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkflowDefinition { pub version: u32, + /// Filename selector and display name, frozen with the resolved stages. + #[serde(default)] + pub id: Option<String>, + #[serde(default)] + pub name: Option<String>, pub timeout_seconds: u64, pub stages: Vec<StageDefinition>, } @@ -92,11 +97,66 @@ pub struct WorkflowRun { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowSummary { pub id: String, + pub workflow: Option<String>, + pub name: Option<String>, pub branch: String, pub status: WorkflowStatus, pub current: usize, pub total: usize, pub message: String, + pub project: ProjectId, + pub worktree: Option<WorktreeId>, + pub title: String, + pub stages: Vec<StageSummary>, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageSummary { + pub id: String, + pub agent: Option<AgentId>, + pub status: StageStatus, +} + +impl WorkflowRun { + /// Progress for subscribers, without prompts or result artifacts. + pub fn summary(&self) -> WorkflowSummary { + WorkflowSummary { + id: self.id.clone(), + workflow: self.definition.id.clone(), + name: self.definition.name.clone(), + branch: self.branch.clone(), + status: self.status, + current: self.current, + total: self.stages.len(), + message: self.message.clone(), + project: self.project.clone(), + worktree: self.worktree.as_ref().map(|w| w.id.clone()), + title: display_title(&self.task), + stages: self + .definition + .stages + .iter() + .zip(&self.stages) + .map(|(def, run)| StageSummary { + id: def.id.clone(), + agent: run.agent.clone(), + status: run.status, + }) + .collect(), + created_at: self.created_at, + } + } +} + +pub fn display_title(task: &str) -> String { + task.split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .chars() + .filter(|c| !c.is_control()) + .take(80) + .collect() } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index 6e652fd..33e970f 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -185,9 +185,65 @@ pub fn worktree_dir(repo: &Path, branch: &str) -> PathBuf { .join(safe_branch) } +/// Readable MANAGED WORKFLOW names, with numbered repeats of the same task. +pub async fn workflow_branch(repo: &Path, task: &str) -> Result<String> { + let slug: String = task + .chars() + .take(80) + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let slug = slug + .split('-') + .filter(|s| !s.is_empty()) + .collect::<Vec<_>>() + .join("-"); + let slug = slug.chars().take(48).collect::<String>(); + let slug = slug.trim_end_matches('-'); + let base = format!("workflow-{}", if slug.is_empty() { "task" } else { slug }); + let branches = git( + repo, + &["for-each-ref", "--format=%(refname:short)", "refs/heads/"], + ) + .await?; + for number in 1..=10_000 { + let name = if number == 1 { + base.clone() + } else { + format!("{base}-{number}") + }; + let conflicts = branches.lines().any(|b| { + b == name || b.starts_with(&format!("{name}/")) || name.starts_with(&format!("{b}/")) + }); + if !conflicts && !worktree_dir(repo, &name).exists() { + return Ok(name); + } + } + bail!("could not choose an unused workflow branch name") +} + /// `git worktree add <path> -b <branch> [base]`. Falls back to checking out an /// existing branch when `-b` fails because it already exists. pub async fn add_worktree(repo: &Path, branch: &str, base: Option<&str>) -> Result<PathBuf> { + add_worktree_inner(repo, branch, base, true).await +} + +/// A MANAGED WORKFLOW must never adopt a branch created by a concurrent caller. +pub async fn add_new_worktree(repo: &Path, branch: &str, base: &str) -> Result<PathBuf> { + add_worktree_inner(repo, branch, Some(base), false).await +} + +async fn add_worktree_inner( + repo: &Path, + branch: &str, + base: Option<&str>, + reuse_existing: bool, +) -> Result<PathBuf> { let path = worktree_dir(repo, branch); if path.exists() { bail!("worktree path already exists: {}", path.display()); @@ -202,7 +258,7 @@ pub async fn add_worktree(repo: &Path, branch: &str, base: Option<&str>) -> Resu } match git(repo, &args).await { Ok(_) => Ok(path), - Err(e) if e.to_string().contains("already exists") => { + Err(e) if reuse_existing && e.to_string().contains("already exists") => { // Branch exists: check it out instead of creating. git(repo, &["worktree", "add", &path_str, branch]).await?; Ok(path) @@ -359,6 +415,43 @@ mod tests { assert!(!is_missing(&err), "{err:#}"); } + #[tokio::test] + async fn workflow_branches_are_readable_and_avoid_existing_refs_and_paths() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + assert_eq!( + workflow_branch(&repo, "Fix login!").await.unwrap(), + "workflow-fix-login" + ); + git(&repo, &["branch", "workflow-fix-login"]).await.unwrap(); + let base = main_commit(&repo).await.unwrap(); + assert!( + add_new_worktree(&repo, "workflow-fix-login", &base) + .await + .is_err(), + "a workflow cannot reuse an existing branch" + ); + std::fs::create_dir_all(worktree_dir(&repo, "workflow-fix-login-2")).unwrap(); + assert_eq!( + workflow_branch(&repo, "Fix login!").await.unwrap(), + "workflow-fix-login-3" + ); + assert_eq!( + workflow_branch(&repo, "修复").await.unwrap(), + "workflow-task" + ); + assert!(!workflow_branch( + &repo, + "../../ bad +name" + ) + .await + .unwrap() + .contains('/')); + } + #[tokio::test] async fn git_errors_are_not_reported_as_missing_git() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index c8a18da..0385496 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -520,6 +520,7 @@ impl Daemon { } } Ok(ServerEvent::Snapshot { + workflows: self.store.workflow_summaries()?, workspaces: self.store.load_workspaces()?, active_workspace: self.store.active_workspace_id()?, projects, @@ -812,6 +813,17 @@ impl Daemon { Ok(EntityId::Worktree(worktree.id)) } + pub(crate) async fn create_workflow_worktree( + &self, + repo: &std::path::Path, + run: &nebula_core::workflow::WorkflowRun, + ) -> Result<EntityId> { + let _ops = self.worktree_ops.lock().await; + let path = git::add_new_worktree(repo, &run.branch, &run.base).await?; + let worktree = self.register_worktree(&run.project, path, &run.branch)?; + Ok(EntityId::Worktree(worktree.id)) + } + /// The checkout every PR SESSION for pull request `number` runs in: the /// PROJECT's worktree already on its head branch `head` (the ROOT /// WORKTREE only when the branch is checked out there — git allows a diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 89cff00..0b750b6 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -97,6 +97,7 @@ async fn handle_client(daemon: Arc<Daemon>, stream: UnixStream) -> Result<()> { } ClientRequest::Subscribe => { let snapshot = daemon.snapshot().unwrap_or(ServerEvent::Snapshot { + workflows: vec![], workspaces: vec![], active_workspace: Default::default(), projects: vec![], diff --git a/crates/nebula-daemon/src/store/workflows.rs b/crates/nebula-daemon/src/store/workflows.rs index b9a4acc..297665b 100644 --- a/crates/nebula-daemon/src/store/workflows.rs +++ b/crates/nebula-daemon/src/store/workflows.rs @@ -80,19 +80,11 @@ impl Store { pub fn workflow_summaries(&self) -> Result<Vec<WorkflowSummary>> { let conn = self.conn.lock().unwrap(); - let mut stmt = - conn.prepare("SELECT json FROM workflow_runs ORDER BY updated_at DESC LIMIT 50")?; + let mut stmt = conn.prepare("SELECT json FROM workflow_runs ORDER BY updated_at DESC")?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; rows.map(|row| { let run: WorkflowRun = serde_json::from_str(&row?)?; - Ok(WorkflowSummary { - id: run.id, - branch: run.branch, - status: run.status, - current: run.current, - total: run.stages.len(), - message: run.message, - }) + Ok(run.summary()) }) .collect() } diff --git a/crates/nebula-daemon/src/workflow.rs b/crates/nebula-daemon/src/workflow.rs index a1d8f33..7d0e7c0 100644 --- a/crates/nebula-daemon/src/workflow.rs +++ b/crates/nebula-daemon/src/workflow.rs @@ -1,6 +1,7 @@ //! Prototype sequential workflows. Only the DAEMON writes run state or launches stages. mod prompts; +pub use prompts::validate_definition; #[cfg(test)] mod tests; mod watcher; @@ -112,7 +113,7 @@ impl Daemon { ); let id = ulid::Ulid::generate().to_string(); let mut run = WorkflowRun { - branch: format!("workflow-{}", id.to_lowercase()), + branch: git::workflow_branch(&root.path, &task).await?, id, project: root.project_id.clone(), worktree: None, @@ -135,10 +136,8 @@ impl Daemon { created_at: now_ms(), updated_at: now_ms(), }; - self.store.save_workflow(&run)?; - let created = self - .create_worktree(&run.project, &run.branch, Some(&run.base)) - .await; + self.persist_workflow(&mut run)?; + let created = self.create_workflow_worktree(&root.path, &run).await; match created { Ok(EntityId::Worktree(id)) => { run.worktree = self.store.get_worktree(&id)?; @@ -157,7 +156,11 @@ impl Daemon { fn persist_workflow(&self, run: &mut WorkflowRun) -> Result<()> { run.updated_at = now_ms(); - self.store.save_workflow(run) + self.store.save_workflow(run)?; + self.broadcast(nebula_core::ServerEvent::WorkflowUpdated { + workflow: run.summary(), + }); + Ok(()) } fn report_workflow( diff --git a/crates/nebula-daemon/src/workflow/prompts.rs b/crates/nebula-daemon/src/workflow/prompts.rs index 92f7410..81586df 100644 --- a/crates/nebula-daemon/src/workflow/prompts.rs +++ b/crates/nebula-daemon/src/workflow/prompts.rs @@ -12,7 +12,26 @@ fn checked(value: &str, label: &str, max: usize) -> Result<()> { pub(super) fn validate(task: &str, definition: &WorkflowDefinition) -> Result<()> { checked(task, "task", 8000)?; + validate_definition(definition) +} + +/// Shared by configuration previews and the DAEMON's IPC trust boundary. +pub fn validate_definition(definition: &WorkflowDefinition) -> Result<()> { ensure!(definition.version == 1, "workflow version must be 1"); + if let Some(id) = &definition.id { + checked(id, "workflow id", 64)?; + ensure!( + id.starts_with(|c: char| c.is_ascii_lowercase()) + && id + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_-".contains(&c)), + "workflow id must be a lowercase slug" + ); + } + if let Some(name) = &definition.name { + checked(name, "workflow name", 120)?; + ensure!(!name.chars().any(char::is_control), "invalid workflow name"); + } ensure!( (10..=86400).contains(&definition.timeout_seconds), "timeout_seconds must be 10..86400" diff --git a/crates/nebula-daemon/src/workflow/tests.rs b/crates/nebula-daemon/src/workflow/tests.rs index 1cd52bc..6430be9 100644 --- a/crates/nebula-daemon/src/workflow/tests.rs +++ b/crates/nebula-daemon/src/workflow/tests.rs @@ -12,6 +12,8 @@ fn run(id: &str) -> WorkflowRun { task: "Make a plan".into(), definition: WorkflowDefinition { version: 1, + id: None, + name: None, timeout_seconds: 60, stages: vec![StageDefinition { id: "planner".into(), @@ -61,6 +63,59 @@ fn workflow_and_artifacts_survive_reopening_the_database() { assert_eq!(store.workflow_summaries().unwrap()[0].total, 1); } +#[test] +fn workflow_summaries_keep_old_running_and_paused_runs() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("nebula.db")).unwrap(); + for i in 0..60 { + let mut entry = run(&format!("run-{i}")); + entry.stages[0].agent = None; + entry.status = if i == 0 { + WorkflowStatus::Running + } else if i == 1 { + WorkflowStatus::Paused + } else { + WorkflowStatus::Completed + }; + entry.updated_at = i; + store.save_workflow(&entry).unwrap(); + } + let rows = store.workflow_summaries().unwrap(); + assert_eq!(rows.len(), 60); + assert!(rows.iter().any(|r| r.id == "run-0" && r.status.active())); + assert!(rows + .iter() + .any(|r| r.id == "run-1" && r.status == WorkflowStatus::Paused)); + assert!(!serde_json::to_string(&rows).unwrap().contains("artifact")); +} + +#[test] +fn persisted_runs_from_before_named_workflows_still_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nebula.db"); + let expected = run("legacy"); + Store::open(&path) + .unwrap() + .save_workflow(&expected) + .unwrap(); + let mut old = serde_json::to_value(&expected).unwrap(); + old["definition"].as_object_mut().unwrap().remove("id"); + old["definition"].as_object_mut().unwrap().remove("name"); + rusqlite::Connection::open(&path) + .unwrap() + .execute( + "UPDATE workflow_runs SET json = ?1 WHERE id = 'legacy'", + [old.to_string()], + ) + .unwrap(); + let store = Store::open(&path).unwrap(); + let restored = store.workflow("legacy").unwrap(); + assert!(restored.definition.id.is_none()); + assert!(restored.definition.name.is_none()); + assert_eq!(restored.stages[0].result, expected.stages[0].result); + assert!(store.workflow_summaries().unwrap()[0].workflow.is_none()); +} + #[test] fn conflicting_session_association_rolls_back_the_whole_snapshot() { let store = Store::open_in_memory().unwrap(); diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 10f9c0b..ae6e89c 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -1,5 +1,7 @@ //! TUI state: the Elm-ish Model. +mod panel_layout; + use crate::git_diff::DiffFile; use crate::pull_request::{OpenPr, PrDetail, PullRequest}; use crate::text_input::TextInput; @@ -32,6 +34,7 @@ pub enum Focus { Workspaces, Projects, Worktrees, + Workflows, Sessions, Terminal, } @@ -44,6 +47,8 @@ pub enum HitTarget { /// The `◇ workspace` nameplate on the footer; a click opens the /// workspace switcher. FooterWorkspace, + FooterWorkflows, + Workflow(usize), /// Row index into `App::project_rows()`. Project(usize), Worktree(usize), @@ -1712,6 +1717,10 @@ pub struct UiState { /// Diff modal file-list width; absent in older blobs. #[serde(default)] pub diff_files_width: Option<u16>, + #[serde(default)] + pub workflow_width: Option<u16>, + #[serde(default)] + pub workflow: Option<String>, } /// A mouse selection over the terminal pane (drag or double-click word), in @@ -1832,6 +1841,7 @@ pub const OPEN_PRS_MIN_AGE: std::time::Duration = std::time::Duration::from_secs pub struct App { pub tree: Tree, + pub workflows: crate::workflows::Workflows, pub focus: Focus, /// Selected row in the Projects panel — indexes `project_rows()`, the /// open workspace's projects in display order. @@ -2107,6 +2117,7 @@ impl App { pub fn new() -> Self { Self { tree: Tree::default(), + workflows: crate::workflows::Workflows::default(), focus: Focus::Projects, sel_project: 0, sel_worktree: 0, @@ -2297,81 +2308,6 @@ impl App { } } - /// Visible sidebar indices, left to right. Sessions is always present. - pub fn visible_panel_indices(&self) -> Vec<usize> { - (0..3).filter(|idx| self.panel_visible(*idx)).collect() - } - - pub fn panel_visible(&self, idx: usize) -> bool { - match idx { - 0 => !self.hide_projects, - 1 => !self.hide_worktrees, - 2 => true, - _ => false, - } - } - - /// Every visible sidebar owns the draggable boundary on its right. - pub fn splitter_indices(&self) -> Vec<usize> { - self.visible_panel_indices() - } - - /// Screen x of splitter `idx` — the column where the panel to its right - /// starts, i.e. the right edge of panel `idx`. - pub fn splitter_x(&self, idx: usize) -> u16 { - self.visible_panel_indices() - .into_iter() - .filter(|visible| *visible <= idx) - .map(|visible| self.panel_widths[visible]) - .sum() - } - - /// Move splitter `idx` so its boundary lands at `boundary_x`, clamped so - /// the panel keeps `MIN_PANEL_W` and the terminal pane keeps `MIN_TERM_W`. - pub fn set_splitter(&mut self, idx: usize, boundary_x: i32, body_w: u16) { - let want = boundary_x.max(0) as u16; - if !self.panel_visible(idx) { - return; - } - let visible = self.visible_panel_indices(); - let left: u16 = visible - .iter() - .copied() - .filter(|visible| *visible < idx) - .map(|visible| self.panel_widths[visible]) - .sum(); - let fixed_right: u16 = visible - .iter() - .copied() - .filter(|visible| *visible > idx) - .map(|visible| self.panel_widths[visible]) - .sum(); - let max = body_w.saturating_sub(left + fixed_right + MIN_TERM_W); - if max < MIN_PANEL_W { - return; // terminal too small to honor the minimums - } - self.panel_widths[idx] = want.saturating_sub(left).clamp(MIN_PANEL_W, max); - } - - /// Re-fit panel widths to the current body width, shrinking the rightmost - /// panel first, each floored at `MIN_PANEL_W`. Keeps the terminal pane at - /// `MIN_TERM_W` whenever the screen allows it at all. The Workspaces bar - /// spans the full width above them, so it costs the panels nothing here. - pub fn normalize_panel_widths(&mut self, body_w: u16) { - let budget = body_w.saturating_sub(MIN_TERM_W); - let visible = self.visible_panel_indices(); - for i in visible.iter().rev().copied() { - let others: u16 = visible - .iter() - .copied() - .filter(|j| *j != i) - .map(|j| self.panel_widths[j]) - .sum(); - let max = budget.saturating_sub(others); - self.panel_widths[i] = self.panel_widths[i].clamp(MIN_PANEL_W, max.max(MIN_PANEL_W)); - } - } - pub fn alloc_req_id(&mut self, intent: PendingIntent) -> u64 { let id = self.next_req_id; self.next_req_id += 1; @@ -2808,6 +2744,8 @@ impl App { Focus::Projects } else if !self.hide_worktrees { Focus::Worktrees + } else if self.workflows.show { + Focus::Workflows } else { Focus::Sessions } @@ -2818,6 +2756,7 @@ impl App { Focus::Workspaces => self.show_workspaces, Focus::Projects => !self.hide_projects, Focus::Worktrees => !self.hide_worktrees, + Focus::Workflows => self.workflows.show, Focus::Sessions | Focus::Terminal => true, } } @@ -2827,8 +2766,9 @@ impl App { Focus::Workspaces => 0, Focus::Projects => 1, Focus::Worktrees => 2, - Focus::Sessions => 3, - Focus::Terminal => 4, + Focus::Workflows => 3, + Focus::Sessions => 4, + Focus::Terminal => 5, } } @@ -2838,6 +2778,7 @@ impl App { Focus::Workspaces, Focus::Projects, Focus::Worktrees, + Focus::Workflows, Focus::Sessions, Focus::Terminal, ] @@ -2851,6 +2792,7 @@ impl App { [ Focus::Terminal, Focus::Sessions, + Focus::Workflows, Focus::Worktrees, Focus::Projects, Focus::Workspaces, diff --git a/crates/nebula-tui/src/app/panel_layout.rs b/crates/nebula-tui/src/app/panel_layout.rs new file mode 100644 index 0000000..f804f06 --- /dev/null +++ b/crates/nebula-tui/src/app/panel_layout.rs @@ -0,0 +1,102 @@ +use super::{App, MIN_PANEL_W, MIN_TERM_W}; + +impl App { + /// Visible sidebar indices, left to right. Sessions is always present. + pub fn visible_panel_indices(&self) -> Vec<usize> { + [0, 1, 3, 2] + .into_iter() + .filter(|idx| self.panel_visible(*idx)) + .collect() + } + + pub fn panel_visible(&self, idx: usize) -> bool { + match idx { + 0 => !self.hide_projects, + 1 => !self.hide_worktrees, + 2 => true, + 3 => self.workflows.show, + _ => false, + } + } + + /// Every visible sidebar owns the draggable boundary on its right. + pub fn splitter_indices(&self) -> Vec<usize> { + self.visible_panel_indices() + } + + /// Screen x of splitter `idx` — the column where the panel to its right + /// starts, i.e. the right edge of panel `idx`. + pub fn splitter_x(&self, idx: usize) -> u16 { + self.panel_width(idx) + + self + .visible_panel_indices() + .into_iter() + .take_while(|visible| *visible != idx) + .map(|visible| self.panel_width(visible)) + .sum::<u16>() + } + + /// Move splitter `idx` so its boundary lands at `boundary_x`, clamped so + /// the panel keeps `MIN_PANEL_W` and the terminal pane keeps `MIN_TERM_W`. + pub fn set_splitter(&mut self, idx: usize, boundary_x: i32, body_w: u16) { + let want = boundary_x.max(0) as u16; + if !self.panel_visible(idx) { + return; + } + let visible = self.visible_panel_indices(); + let position = visible.iter().position(|i| *i == idx).unwrap(); + let left: u16 = visible[..position] + .iter() + .copied() + .map(|visible| self.panel_width(visible)) + .sum::<u16>(); + let fixed_right: u16 = visible[position + 1..] + .iter() + .copied() + .map(|visible| self.panel_width(visible)) + .sum::<u16>(); + let max = body_w.saturating_sub(left + fixed_right + MIN_TERM_W); + if max < MIN_PANEL_W { + return; // terminal too small to honor the minimums + } + self.set_panel_width(idx, want.saturating_sub(left).clamp(MIN_PANEL_W, max)); + } + + /// Re-fit panel widths to the current body width, shrinking the rightmost + /// panel first, each floored at `MIN_PANEL_W`. Keeps the terminal pane at + /// `MIN_TERM_W` whenever the screen allows it at all. The Workspaces bar + /// spans the full width above them, so it costs the panels nothing here. + pub fn normalize_panel_widths(&mut self, body_w: u16) { + let budget = body_w.saturating_sub(MIN_TERM_W); + let visible = self.visible_panel_indices(); + for i in visible.iter().rev().copied() { + let others: u16 = visible + .iter() + .copied() + .filter(|j| *j != i) + .map(|j| self.panel_width(j)) + .sum::<u16>(); + let max = budget.saturating_sub(others); + self.set_panel_width( + i, + self.panel_width(i).clamp(MIN_PANEL_W, max.max(MIN_PANEL_W)), + ); + } + } + + pub fn panel_width(&self, idx: usize) -> u16 { + if idx == 3 { + self.workflows.width + } else { + self.panel_widths[idx] + } + } + + fn set_panel_width(&mut self, idx: usize, width: u16) { + if idx == 3 { + self.workflows.width = width; + } else { + self.panel_widths[idx] = width; + } + } +} diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index 053b720..3484121 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -201,6 +201,7 @@ pub enum SettingKind { ShowWorkspaces, HideProjects, HideWorktrees, + ShowWorkflows, QuickPromptKind, QuickPromptFocus, ClaudeEnabled, @@ -306,6 +307,12 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ hint: "Show or hide the Worktrees panel (Shift+B toggles)", group: "", }, + SettingSpec { + kind: SettingKind::ShowWorkflows, + label: "Workflows panel", + hint: "Progress in this workspace (Shift+O toggles)", + group: "", + }, ]), }, // Grouped per harness: the two cross-kind quick prompt rows first, @@ -597,6 +604,8 @@ pub struct Config { /// Hide the Worktrees panel and give its width to the terminal pane. /// Independent from `hide_projects`; Sessions always remains visible. pub hide_worktrees: bool, + /// Show the WORKFLOWS PANEL alongside WORKTREES and SESSIONS. + pub show_workflows: bool, /// Default model/effort for new Claude / Codex / Cursor sessions. /// "default" means "don't pass the flag" (the CLI picks); any other /// value is passed through verbatim, so hand-edited configs can name @@ -665,6 +674,7 @@ impl Default for Config { show_workspaces: true, hide_projects: false, hide_worktrees: false, + show_workflows: false, claude_model: DEFAULT_CHOICE.into(), claude_models: Vec::new(), claude_effort: DEFAULT_CHOICE.into(), @@ -787,6 +797,10 @@ impl Config { "hide_worktrees".into(), serde_json::json!(self.hide_worktrees), ); + obj.insert( + "show_workflows".into(), + serde_json::json!(self.show_workflows), + ); obj.insert("claude_model".into(), serde_json::json!(self.claude_model)); obj.insert( "claude_models".into(), @@ -930,6 +944,7 @@ impl Config { SettingKind::ShowWorkspaces => on_off(self.show_workspaces).into(), SettingKind::HideProjects => shown_hidden(self.hide_projects).into(), SettingKind::HideWorktrees => shown_hidden(self.hide_worktrees).into(), + SettingKind::ShowWorkflows => shown_hidden(!self.show_workflows).into(), SettingKind::ClaudeModel => self.claude_model.clone(), SettingKind::ClaudeEffort => self.claude_effort.clone(), SettingKind::CodexModel => self.codex_model.clone(), @@ -996,6 +1011,9 @@ impl Config { SettingKind::HideProjects => { self.hide_projects = !self.hide_projects; } + SettingKind::ShowWorkflows => { + self.show_workflows = !self.show_workflows; + } SettingKind::HideWorktrees => { self.hide_worktrees = !self.hide_worktrees; } diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 550a4a8..1b30851 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -29,6 +29,7 @@ use std::time::Duration; mod focus_walk; mod host_terminal; +mod workflows; use focus_walk::{ at_top_row, bar_return_target, double_tapped, enter_terminal_pane, enter_workspaces_bar, leave_workspaces_bar, panel_name, walk_focus_back, walk_focus_forward, @@ -998,6 +999,8 @@ fn ui_state_json(app: &App) -> String { collapsed: app.collapsed, panel_widths: Some(app.panel_widths), diff_files_width: Some(app.diff_files_width), + workflow_width: Some(app.workflows.width), + workflow: app.workflows.selected.clone(), }; serde_json::to_string(&state).unwrap_or_else(|_| "{}".into()) } @@ -1053,6 +1056,10 @@ fn restore_ui_state(app: &mut App, json: &str) -> bool { return false; }; app.show_archived = state.show_archived; + app.workflows.selected = state.workflow; + if let Some(w) = state.workflow_width { + app.workflows.width = w.clamp(crate::app::MIN_PANEL_W, MAX_RESTORED_WIDTH); + } if let Some(w) = state.panel_widths { // normalize_panel_widths re-fits to the actual screen on the next // draw. @@ -1503,6 +1510,10 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec<ClientRequest>) { set_hide_projects(app, !app.hide_projects); save_panel_visibility(app); } + Action::ToggleWorkflows => { + workflows::set_visible(app, !app.workflows.show); + save_panel_visibility(app); + } Action::ToggleWorktrees => { set_hide_worktrees(app, !app.hide_worktrees); save_panel_visibility(app); @@ -1539,6 +1550,11 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec<ClientRequest>) { Some(url) => open_link(app, &url, out), None => app.focus = Focus::Sessions, }, + Focus::Workflows => { + if workflows::select(app, app.workflow_selection(), out) { + app.focus = Focus::Sessions; + } + } Focus::Sessions => attach_selected(app, out), // Lock input into an already-focused live pane. Focus::Terminal => enter_terminal_pane(app, out), @@ -1564,7 +1580,7 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec<ClientRequest>) { open_new_agent_picker(app, worktree); } } - Focus::Terminal => {} + Focus::Workflows | Focus::Terminal => {} }, Action::Rename => match app.focus { Focus::Sessions => match app.selected_session_row() { @@ -2250,6 +2266,11 @@ fn create_terminal(app: &mut App, worktree: WorktreeId, out: &mut Vec<ClientRequ /// project's main checkout (root) when the Projects panel has focus. fn worktree_in_context(app: &App) -> Option<WorktreeId> { match app.focus { + Focus::Workflows => app + .workflow_rows() + .get(app.workflow_selection()) + .and_then(|r| r.worktree.clone()) + .filter(|id| app.tree.worktrees.iter().any(|w| &w.id == id)), Focus::Projects => app.selected_project().and_then(|p| { app.tree .worktrees @@ -2308,7 +2329,7 @@ fn open_delete_confirm(app: &mut App) { let id = app.tree.active_workspace.clone(); open_remove_workspace_confirm(app, id, None); } - Focus::Terminal => {} + Focus::Workflows | Focus::Terminal => {} } } @@ -2483,7 +2504,7 @@ fn open_delete_all_confirm(app: &mut App) { area: ratatui::layout::Rect::default(), })); } - Focus::Workspaces | Focus::Projects | Focus::Terminal => {} + Focus::Workspaces | Focus::Projects | Focus::Workflows | Focus::Terminal => {} } } @@ -2996,7 +3017,7 @@ fn open_context_menu_for_selection(app: &mut App) { Some(SessionRow::Link(l)) => open_menu(app, menu_items_for_link(&l), at), None => {} }, - Focus::Terminal => {} + Focus::Workflows | Focus::Terminal => {} } } @@ -3878,6 +3899,7 @@ fn apply_config(app: &mut App, cfg: &crate::config::Config) { set_show_workspaces(app, cfg.show_workspaces); set_hide_projects(app, cfg.hide_projects); set_hide_worktrees(app, cfg.hide_worktrees); + workflows::set_visible(app, cfg.show_workflows); } /// `R` in the settings overlay, confirmed: rewrite config.json from the @@ -3958,6 +3980,7 @@ fn save_panel_visibility(app: &mut App) { let mut cfg = crate::config::Config::load(); cfg.hide_projects = app.hide_projects; cfg.hide_worktrees = app.hide_worktrees; + cfg.show_workflows = app.workflows.show; if let Err(err) = cfg.save() { app.flash = Some(format!("couldn't save settings: {err}")); } @@ -5031,6 +5054,7 @@ fn move_selection(app: &mut App, delta: i64, out: &mut Vec<ClientRequest>) { ), Focus::Projects => (app.project_rows().len(), app.sel_project), Focus::Worktrees => (app.worktree_row_count(), app.sel_worktree), + Focus::Workflows => (app.workflow_rows().len(), app.workflow_selection()), Focus::Sessions => (app.visible_session_rows().len(), app.sel_session), Focus::Terminal => return, }; @@ -5052,6 +5076,9 @@ fn move_selection(app: &mut App, delta: i64, out: &mut Vec<ClientRequest>) { } Focus::Projects => select_project_row(app, new, out), Focus::Worktrees => select_worktree_row(app, new, out), + Focus::Workflows => { + workflows::select(app, new, out); + } Focus::Sessions => { app.sel_session = new; preview_selected(app, out); @@ -6334,6 +6361,17 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>) enter_workspaces_bar(app); } Some(HitTarget::FooterWorkspace) => open_workspace_picker(app), + Some(HitTarget::FooterWorkflows) => { + workflows::set_visible(app, true); + app.collapsed = false; + app.term_locked = false; + app.focus = Focus::Workflows; + workflows::select(app, app.workflow_selection(), out); + save_panel_visibility(app); + } + Some(HitTarget::Workflow(i)) => { + workflows::select(app, i, out); + } Some(HitTarget::Project(i)) => { if app.sel_project != i { select_project_row(app, i, out); @@ -6474,7 +6512,18 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>) Some(HitTarget::Worktree(_) | HitTarget::PanelBg(Focus::Worktrees)) ); let in_term = matches!(over, Some(HitTarget::TerminalPane)) || app.collapsed; - if over_worktrees { + if matches!( + over, + Some(HitTarget::Workflow(_) | HitTarget::PanelBg(Focus::Workflows)) + ) && !app.collapsed + { + app.workflows.scroll = if up { + app.workflows.scroll.saturating_sub(SESSIONS_WHEEL_STEP) + } else { + app.workflows.scroll.saturating_add(SESSIONS_WHEEL_STEP) + }; + app.dirty = true; + } else if over_worktrees { app.worktrees_scroll = if up { app.worktrees_scroll.saturating_sub(SESSIONS_WHEEL_STEP) } else { @@ -6645,7 +6694,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>) ] }) .unwrap_or_default(), - Focus::Terminal => vec![], + Focus::Workflows | Focus::Terminal => vec![], }; open_menu(app, items, at); } @@ -6660,6 +6709,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>) fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec<ClientRequest>) { match event { ServerEvent::Snapshot { + workflows, workspaces, active_workspace, projects, @@ -6670,6 +6720,7 @@ fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec<ClientRe pr_seen, ui_state, } => { + app.workflows.runs = workflows; app.tree.workspaces = workspaces; app.tree.active_workspace = active_workspace; app.tree.projects = projects; @@ -6700,6 +6751,14 @@ fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec<ClientRe } app.dirty = true; } + ServerEvent::WorkflowUpdated { workflow } => { + if let Some(run) = app.workflows.runs.iter_mut().find(|r| r.id == workflow.id) { + *run = workflow; + } else { + app.workflows.runs.push(workflow); + } + app.dirty = true; + } ServerEvent::Scrollback { session, data, .. } => { if let Some(term) = &mut app.term { if term.sref == session { @@ -7185,6 +7244,7 @@ fn clamp_selections(app: &mut App) { #[cfg(test)] mod tests { + mod workflows; use super::*; use nebula_core::{AgentId, LinkId, ServerEvent, SessionRef}; use ratatui::backend::TestBackend; @@ -9832,6 +9892,7 @@ diff --git a/src/b.rs b/src/b.rs hse( &mut fresh, ServerEvent::Snapshot { + workflows: vec![], workspaces: tree.workspaces, active_workspace: tree.active_workspace, projects: tree.projects, @@ -9857,6 +9918,7 @@ diff --git a/src/b.rs b/src/b.rs seed_tree(&mut app); // p1 / w1 / a1 let tree = app.tree.clone(); let snapshot = |ui_state: Option<String>| ServerEvent::Snapshot { + workflows: vec![], workspaces: tree.workspaces.clone(), active_workspace: tree.active_workspace.clone(), projects: tree.projects.clone(), diff --git a/crates/nebula-tui/src/event_loop/focus_walk.rs b/crates/nebula-tui/src/event_loop/focus_walk.rs index c8bb958..2bb5a76 100644 --- a/crates/nebula-tui/src/event_loop/focus_walk.rs +++ b/crates/nebula-tui/src/event_loop/focus_walk.rs @@ -120,6 +120,7 @@ pub(super) fn at_top_row(app: &App) -> bool { match app.focus { Focus::Projects => app.sel_project == 0, Focus::Worktrees => app.sel_worktree == 0, + Focus::Workflows => app.workflow_selection() == 0, Focus::Sessions => app.sel_session == 0, Focus::Workspaces | Focus::Terminal => false, } @@ -132,6 +133,7 @@ pub(super) fn panel_name(focus: Focus) -> &'static str { Focus::Workspaces => "workspaces", Focus::Projects => "projects", Focus::Worktrees => "worktrees", + Focus::Workflows => "workflows", Focus::Sessions => "sessions", Focus::Terminal => "terminal", } diff --git a/crates/nebula-tui/src/event_loop/tests/workflows.rs b/crates/nebula-tui/src/event_loop/tests/workflows.rs new file mode 100644 index 0000000..2ae9db3 --- /dev/null +++ b/crates/nebula-tui/src/event_loop/tests/workflows.rs @@ -0,0 +1,190 @@ +use super::*; +use nebula_core::workflow::{StageStatus, StageSummary, WorkflowStatus, WorkflowSummary}; + +fn workflow(id: &str, status: WorkflowStatus) -> WorkflowSummary { + WorkflowSummary { + id: id.into(), + workflow: Some("default".into()), + name: Some("Plan and implement".into()), + branch: format!("workflow-{id}"), + status, + current: 0, + total: 2, + message: "Awaiting report".into(), + project: ProjectId("p1".into()), + worktree: Some(WorktreeId("w1".into())), + title: "Fix login redirect".into(), + created_at: 1, + stages: vec![ + StageSummary { + id: "planner".into(), + agent: Some(AgentId("a1".into())), + status: StageStatus::Running, + }, + StageSummary { + id: "implementer".into(), + agent: None, + status: StageStatus::Pending, + }, + ], + } +} + +fn update(app: &mut App, run: WorkflowSummary) { + hse(app, ServerEvent::WorkflowUpdated { workflow: run }); +} + +#[test] +fn workflow_progress_streams_without_moving_the_cursor() { + let mut app = App::new(); + seed_tree(&mut app); + app.workflows.show = true; + app.workflows.width = 38; + app.tree.agents[0].name = "workflow old-id planner".into(); + update(&mut app, workflow("old-id", WorkflowStatus::Running)); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap(); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let text = buffer_text(&terminal); + for needle in [ + "WORKFLOWS", + "Fix login redirect", + "Step 1/2 · planner", + "Next: implementer", + "0/2 done · 2 left", + "◆ 1 running", + ] { + assert!(text.contains(needle), "missing {needle}:\n{text}"); + } + assert!(!text.contains("old-id"), "{text}"); + assert_eq!(app.focus, Focus::Projects); + app.tree.agents[0].name = "My custom title".into(); + assert_eq!(app.agent_label(&app.tree.agents[0]), "My custom title"); + app.workflows.selected = Some("old-id".into()); + let mut run = workflow("old-id", WorkflowStatus::Completed); + run.current = 2; + for s in &mut run.stages { + s.status = StageStatus::Completed; + } + update(&mut app, run); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let text = buffer_text(&terminal); + for needle in ["All steps complete", "2/2 done · 0 left", "◆ 0 running"] { + assert!(text.contains(needle), "{text}"); + } + assert!(!text.contains("3/2"), "{text}"); + assert_eq!(app.focus, Focus::Projects); + assert_eq!(app.workflows.runs.len(), 1); +} + +#[test] +fn workflow_scope_retains_every_unfinished_run_before_recent_history() { + let mut app = App::new(); + seed_tree(&mut app); + for i in 0..60 { + let mut run = workflow(&format!("completed-{i}"), WorkflowStatus::Completed); + run.created_at = i; + update(&mut app, run); + } + for status in [ + WorkflowStatus::Creating, + WorkflowStatus::Running, + WorkflowStatus::Waiting, + WorkflowStatus::Paused, + WorkflowStatus::Blocked, + ] { + update(&mut app, workflow(&format!("{status:?}"), status)); + } + let mut foreign = workflow("other-project", WorkflowStatus::Running); + foreign.project = ProjectId("p2".into()); + update(&mut app, foreign); + let rows = app.workflow_rows(); + assert_eq!(rows.len(), 15); + assert_eq!(rows.iter().filter(|r| r.status.active()).count(), 3); + assert_eq!(rows[0].status, WorkflowStatus::Blocked); + assert_eq!(rows[5].id, "completed-59"); + app.tree.active_workspace = WorkspaceId("other".into()); + assert!(app.workflow_rows().is_empty()); +} + +#[test] +fn workflow_panel_toggle_walk_click_resize_and_restore() { + with_default_config(|| { + let mut app = App::new(); + seed_tree(&mut app); + update(&mut app, workflow("run", WorkflowStatus::Running)); + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('O'), KeyModifiers::SHIFT, &mut out); + assert!(app.workflows.show); + assert!(crate::config::Config::load().show_workflows); + assert_eq!(app.focus, Focus::Projects); + assert_eq!(app.next_visible_focus(Focus::Worktrees), Focus::Workflows); + assert_eq!( + app.previous_visible_focus(Focus::Sessions), + Focus::Workflows + ); + assert_eq!(app.visible_panel_indices(), [0, 1, 3, 2]); + app.set_splitter(3, 82, 160); + assert_eq!(app.workflows.width, 40); + let saved = ui_state_json(&app); + let mut restored = App::new(); + restore_ui_state(&mut restored, &saved); + assert_eq!(restored.workflows.width, 40); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap(); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let (rect, _) = app + .hits + .iter() + .find(|(_, hit)| *hit == HitTarget::Workflow(0)) + .unwrap() + .clone(); + handle_mouse( + &mut app, + mev( + MouseEventKind::Down(MouseButton::Left), + rect.x + 1, + rect.y + 1, + ), + &mut out, + ); + assert_eq!(app.focus, Focus::Workflows); + assert_eq!(app.selected_session().unwrap().id.as_str(), "a1"); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert_eq!(app.focus, Focus::Sessions); + app.focus = Focus::Workflows; + press(&mut app, KeyCode::Char('O'), KeyModifiers::SHIFT, &mut out); + assert_eq!(app.focus, Focus::Sessions); + assert!(!app.workflows.show); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let (rect, _) = app + .hits + .iter() + .find(|(_, hit)| *hit == HitTarget::FooterWorkflows) + .unwrap() + .clone(); + handle_mouse( + &mut app, + mev(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y), + &mut out, + ); + assert!(app.workflows.show); + assert_eq!(app.focus, Focus::Workflows); + for width in [120, 80, 40, 10] { + let mut terminal = Terminal::new(TestBackend::new(width, 18)).unwrap(); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + } + }); +} + +#[test] +fn unavailable_workflow_does_not_select_an_unrelated_worktree() { + let mut app = App::new(); + seed_tree(&mut app); + let mut run = workflow("gone", WorkflowStatus::Blocked); + run.worktree = Some(WorktreeId("deleted".into())); + update(&mut app, run); + let mut out = Vec::new(); + super::super::workflows::select(&mut app, 0, &mut out); + assert_eq!(app.focus, Focus::Workflows); + assert!(app.flash.as_ref().unwrap().contains("not available")); + assert!(out.is_empty()); +} diff --git a/crates/nebula-tui/src/event_loop/workflows.rs b/crates/nebula-tui/src/event_loop/workflows.rs new file mode 100644 index 0000000..b38bb81 --- /dev/null +++ b/crates/nebula-tui/src/event_loop/workflows.rs @@ -0,0 +1,37 @@ +use super::*; + +pub(super) fn set_visible(app: &mut App, show: bool) { + app.workflows.show = show; + if !show && app.focus == Focus::Workflows { + app.focus = Focus::Sessions; + } +} + +pub(super) fn select(app: &mut App, index: usize, out: &mut Vec<ClientRequest>) -> bool { + let Some(run) = app.workflow_rows().get(index).copied().cloned() else { + return false; + }; + app.workflows.selected = Some(run.id); + app.focus = Focus::Workflows; + let Some(worktree) = run + .worktree + .filter(|id| app.tree.worktrees.iter().any(|w| &w.id == id)) + else { + app.flash = Some("WORKTREE is not available for this workflow".into()); + return false; + }; + let stage = run.stages.get(run.current).or_else(|| run.stages.last()); + let agent = stage.and_then(|s| s.agent.as_ref()).filter(|id| { + app.tree + .agents + .iter() + .any(|a| &a.id == *id && a.worktree_id == worktree && !a.archived) + }); + let target = agent.map_or_else( + || PaletteTarget::Worktree(worktree.clone()), + |id| PaletteTarget::Session(id.clone()), + ); + jump_to_target_inner(app, target, Landing::FocusOnly, out); + app.focus = Focus::Workflows; + true +} diff --git a/crates/nebula-tui/src/keymap.rs b/crates/nebula-tui/src/keymap.rs index ae443a3..5df514b 100644 --- a/crates/nebula-tui/src/keymap.rs +++ b/crates/nebula-tui/src/keymap.rs @@ -89,6 +89,7 @@ pub enum Action { ToggleWorkspaces, ToggleProjects, ToggleWorktrees, + ToggleWorkflows, /// Open the Nth workspace tab (1-based) straight from the top bar. SelectWorkspace(u8), Hosts, @@ -426,6 +427,15 @@ pub const ACTIONS: &[ActionSpec] = &[ scope: Scope::Global, defaults: &["shift+b"], }, + ActionSpec { + action: Action::ToggleWorkflows, + id: "toggle_workflows", + label: "Workflows panel", + hint: "Show or hide workflow progress in this workspace", + group: "GENERAL", + scope: Scope::Global, + defaults: &["shift+o"], + }, workspace_slot!(1, "select_workspace_1", "Open workspace 1", "cmd+1", "1"), workspace_slot!(2, "select_workspace_2", "Open workspace 2", "cmd+2", "2"), workspace_slot!(3, "select_workspace_3", "Open workspace 3", "cmd+3", "3"), diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index df021dd..9d6d5da 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -34,6 +34,7 @@ pub mod tree_browser; pub mod ui; pub mod update_check; pub mod vim_term; +pub mod workflows; use anyhow::Result; diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index ad54dc3..82152dd 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -1,6 +1,8 @@ //! View layer: draws the visible panels + terminal pane + footer, and //! records hit regions for mouse interaction. +mod workflows; + use crate::app::{App, ConnState, Focus, HitTarget, Overlay, PaletteTarget, SessionRow}; use crate::git_diff::{classify_diff_line, DiffLineKind}; use crate::keymap::Action; @@ -125,10 +127,10 @@ pub fn draw(f: &mut Frame, app: &mut App) { let visible_panels = app.visible_panel_indices(); let constraints = visible_panels .iter() - .map(|idx| Constraint::Length(app.panel_widths[*idx])) + .map(|idx| Constraint::Length(app.panel_width(*idx))) .chain(std::iter::once(Constraint::Min(crate::app::MIN_TERM_W))); let areas = panels_a.layout_vec(&Layout::horizontal(constraints)); - let mut panel_areas: [Option<Rect>; 3] = [None; 3]; + let mut panel_areas: [Option<Rect>; 4] = [None; 4]; for (idx, area) in visible_panels.iter().copied().zip(areas.iter().copied()) { panel_areas[idx] = Some(area); } @@ -159,6 +161,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { if let Some(area) = panel_areas[1] { draw_worktrees(f, app, area); } + if let Some(area) = panel_areas[3] { + workflows::draw(f, app, area); + } draw_sessions( f, app, @@ -175,6 +180,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { Focus::Workspaces => Some(shrink_b(workspaces_a)), Focus::Projects => panel_areas[0].map(shrink_r), Focus::Worktrees => panel_areas[1].map(shrink_r), + Focus::Workflows => panel_areas[3].map(shrink_r), Focus::Sessions => panel_areas[2].map(shrink_r), Focus::Terminal => Some(term_a), }; @@ -2759,7 +2765,7 @@ type ProjectRowData = (String, Option<String>, Option<AgentStatus>, usize, i64); /// The same for the Worktrees panel: branch, is-root, rollup, /// unwatched-finish count, last-turn stamp. -type WorktreeRowData = (String, bool, Option<AgentStatus>, usize, i64); +type WorktreeRowData = (String, bool, Option<AgentStatus>, usize, i64, bool); /// Columns between the `WORKSPACES` label and the first tab. const TAB_GAP: u16 = 2; @@ -2921,11 +2927,12 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { .iter() .map(|w| { ( - w.branch.clone(), + app.worktree_label(w).to_string(), w.is_main, app.worktree_rollup(&w.id), app.worktree_unseen(&w.id), app.worktree_recency(&w.id).stamped, + app.workflow_for_worktree(&w.id).is_some(), ) }) .collect(); @@ -3039,7 +3046,7 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { } } WorktreeEntry::Row(i) if *i < worktrees.len() => { - let (branch, is_main, roll, unseen, stamped) = &worktrees[*i]; + let (branch, is_main, roll, unseen, stamped, workflow) = &worktrees[*i]; let (badges, badge_len) = row_badges(*unseen, th); let ramp = sweep_ramp(*roll, th, app.animations); // 3, not 2: the dot's two cells plus the pill marker @@ -3069,7 +3076,14 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { None }; let max = free - root.map_or(0, |r| r.chars().count()); - let mut spans = vec![status_dot(*roll, *unseen > 0, th)]; + let mut spans = vec![if *workflow { + Span::styled( + format!("{} ", crate::workflows::ICON), + Style::default().fg(status_color(*roll, *unseen > 0, th)), + ) + } else { + status_dot(*roll, *unseen > 0, th) + }]; spans.extend(status_name_spans( truncate(branch, max), Style::default(), @@ -3379,7 +3393,7 @@ fn draw_session_row( }; let mut spans = vec![dot]; spans.extend(status_name_spans( - truncate(&a.name, name_max), + truncate(app.agent_label(a), name_max), name_style, ramp, app.sweep_phase(), @@ -3575,7 +3589,17 @@ fn titled_frame( if let Some(r) = row_rect(area, 1) { let mut spans = vec![Span::styled(format!(" {title}"), header_style)]; spans.extend(left); - f.render_widget(Paragraph::new(Line::from(spans)), r); + let right = right.filter(|tag| 2 + title.len() + 2 + tag.width() <= r.width as usize); + let left_width = r + .width + .saturating_sub(right.as_ref().map_or(0, |tag| tag.width() as u16 + 2)); + f.render_widget( + Paragraph::new(Line::from(spans)), + Rect { + width: left_width, + ..r + }, + ); if let Some(tag) = right { f.render_widget( Paragraph::new(Line::from(vec![tag, Span::raw(" ")])) @@ -3774,7 +3798,7 @@ fn attached_session_name(app: &App) -> Option<String> { .agents .iter() .find(|a| &a.id == id) - .map(|a| a.name.clone()), + .map(|a| app.agent_label(a).to_string()), SessionRef::Terminal(id) => app .tree .terminals @@ -3808,14 +3832,19 @@ fn breadcrumb(app: &App) -> Vec<Span<'static>> { spans.push(seg(&project.name, app.focus == Focus::Projects)); if let Some(worktree) = app.selected_worktree() { spans.push(sep()); - spans.push(seg(&worktree.branch, app.focus == Focus::Worktrees)); + spans.push(seg( + app.worktree_label(worktree), + matches!(app.focus, Focus::Worktrees | Focus::Workflows), + )); if let Some(session) = app.selected_session_row() { spans.push(sep()); // A link's crumb is its display label, not the raw URL — the // crumb has 20 cells and "https://" would eat eight of them. - let name = match session.as_link() { - Some(link) => link.label(), - None => session.name().to_string(), + let name = match &session { + SessionRow::Agent(a) => app.agent_label(a).to_string(), + _ => session + .as_link() + .map_or_else(|| session.name().to_string(), |l| l.label()), }; spans.push(seg( &name, @@ -3840,6 +3869,7 @@ fn editor_name(cmd: &str) -> &str { /// The bar is drawn under the splash and the collapsed view too, so the /// registration lives here rather than in `draw`'s panel branch. fn draw_footer(f: &mut Frame, app: &mut App, area: Rect) { + let area = workflows::footer(f, app, area); if let Some(rect) = draw_footer_bar(f, app, area) { app.hits.push((rect, HitTarget::FooterWorkspace)); } @@ -3998,6 +4028,7 @@ fn draw_footer_bar(f: &mut Frame, app: &App, area: Rect) -> Option<Rect> { k(Action::ToggleWorkspaces), k(Action::Help) ), + Focus::Workflows => format!("{}: sessions ↑/↓: workflow {}: hide", k(Action::Activate), k(Action::ToggleWorkflows)), Focus::Projects => format!( "{}/{}: add {}: rename {}: remove {}: search {}: menu {}: help", k(Action::New), diff --git a/crates/nebula-tui/src/ui/workflows.rs b/crates/nebula-tui/src/ui/workflows.rs new file mode 100644 index 0000000..70b3a22 --- /dev/null +++ b/crates/nebula-tui/src/ui/workflows.rs @@ -0,0 +1,155 @@ +use super::*; +use crate::workflows::{current_step, next_step, progress, status_label, ICON}; +use nebula_core::workflow::WorkflowStatus; + +const ROW_H: usize = 8; + +fn color(status: WorkflowStatus, th: Theme) -> Color { + match status { + WorkflowStatus::Blocked => th.err, + WorkflowStatus::Waiting | WorkflowStatus::Paused => th.warn, + WorkflowStatus::Completed => th.ok, + WorkflowStatus::Creating | WorkflowStatus::Running => th.accent, + } +} + +pub(super) fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let th = app.theme; + let focused = app.focus == Focus::Workflows; + let rows: Vec<_> = app.workflow_rows().into_iter().cloned().collect(); + let inner = draw_column( + f, + area, + "WORKFLOWS", + Some(rows.len()).filter(|n| *n > 0), + focused, + th, + ); + let selected = app.workflow_selection(); + let anchor = rows.get(selected).map(|r| (r.id.clone(), selected)); + if let Some((id, _)) = &anchor { + app.workflows.selected = Some(id.clone()); + } + if app.workflows.anchor != anchor { + app.workflows.anchor = anchor; + let top = selected * ROW_H; + if top < app.workflows.scroll { + app.workflows.scroll = top; + } else if top + ROW_H > app.workflows.scroll + inner.height as usize { + app.workflows.scroll = (top + ROW_H).saturating_sub(inner.height as usize); + } + } + app.workflows.scroll = app + .workflows + .scroll + .min((rows.len() * ROW_H).saturating_sub(inner.height as usize)); + if rows.is_empty() { + f.render_widget( + Paragraph::new(vec![ + Line::from("No workflows yet"), + Line::from(""), + Line::from("Start from a main SESSION:"), + Line::from("/nebula-workflow <task>"), + ]) + .style(Style::default().fg(th.muted)), + inner, + ); + } + for (i, run) in rows.iter().enumerate() { + let y = (i * ROW_H) as isize - app.workflows.scroll as isize; + let rail = color(run.status, th); + let project = app + .tree + .projects + .iter() + .find(|p| p.id == run.project) + .map_or("", |p| p.name.as_str()); + let definition = run + .name + .as_deref() + .or(run.workflow.as_deref()) + .unwrap_or("workflow"); + let lines = [ + format!("{ICON} {}", run.title), + format!("{project} · {definition}"), + status_label(run.status).to_string(), + current_step(run), + next_step(run), + progress(run), + if matches!( + run.status, + WorkflowStatus::Blocked | WorkflowStatus::Paused | WorkflowStatus::Waiting + ) { + run.message.split_whitespace().collect::<Vec<_>>().join(" ") + } else { + String::new() + }, + ]; + for (offset, text) in lines.into_iter().enumerate() { + let Some(rect) = row_rect_at(inner, y + offset as isize) else { + continue; + }; + let style = Style::default().fg(if offset == 0 { + th.text + } else if offset == 2 || offset == 6 { + rail + } else { + th.muted + }); + let text = truncate(&text, inner.width.saturating_sub(1) as usize); + if offset == 0 { + render_row( + f, + rect, + vec![Span::styled(text, style.add_modifier(Modifier::BOLD))], + i == selected, + focused, + th, + ); + } else { + f.render_widget( + Paragraph::new(Span::styled(format!(" {text}"), style)), + rect, + ); + } + } + if let Some(hit) = rows_rect_at(inner, y, ROW_H as u16) { + app.hits.push((hit, HitTarget::Workflow(i))); + } + } + app.hits.push((inner, HitTarget::PanelBg(Focus::Workflows))); +} + +/// Reserve a clickable progress count even before the metrics poll replies. +pub(super) fn footer(f: &mut Frame, app: &mut App, area: Rect) -> Rect { + let rows = app.workflow_rows(); + if rows.is_empty() && !app.workflows.show { + return area; + } + let active = rows.iter().filter(|r| r.status.active()).count(); + let attention = rows + .iter() + .any(|r| matches!(r.status, WorkflowStatus::Blocked | WorkflowStatus::Waiting)); + let key = key_hint(app, Action::ToggleWorkflows); + let text = format!(" {ICON} {active} running · {key} "); + let width = (Span::raw(&text).width() as u16).min(area.width); + let badge = Rect::new( + area.right().saturating_sub(width), + area.bottom().saturating_sub(1), + width, + area.height.min(1), + ); + f.render_widget( + Paragraph::new(text).style(Style::default().fg(if attention { + app.theme.warn + } else { + app.theme.accent + })), + badge, + ); + app.hits.push((badge, HitTarget::FooterWorkflows)); + Rect { + width: area.width.saturating_sub(width), + ..area + } +} diff --git a/crates/nebula-tui/src/workflows.rs b/crates/nebula-tui/src/workflows.rs new file mode 100644 index 0000000..6f82588 --- /dev/null +++ b/crates/nebula-tui/src/workflows.rs @@ -0,0 +1,145 @@ +//! MANAGED WORKFLOW progress and display labels for the TUI. + +use crate::app::App; +use nebula_core::{ + workflow::{StageStatus, WorkflowStatus, WorkflowSummary}, + Agent, Worktree, WorktreeId, +}; + +pub const ICON: &str = "◆"; + +pub struct Workflows { + pub runs: Vec<WorkflowSummary>, + pub show: bool, + pub selected: Option<String>, + pub width: u16, + pub scroll: usize, + pub anchor: Option<(String, usize)>, +} + +impl Default for Workflows { + fn default() -> Self { + Self { + runs: Vec::new(), + show: false, + selected: None, + width: 34, + scroll: 0, + anchor: None, + } + } +} + +impl App { + pub fn workflow_rows(&self) -> Vec<&WorkflowSummary> { + let mut rows: Vec<_> = self + .workflows + .runs + .iter() + .filter(|run| { + self.tree + .projects + .iter() + .any(|p| p.id == run.project && self.tree.in_active_workspace(p)) + }) + .collect(); + rows.sort_by(|a, b| { + (a.status == WorkflowStatus::Completed) + .cmp(&(b.status == WorkflowStatus::Completed)) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| a.id.cmp(&b.id)) + }); + let unfinished = rows + .iter() + .take_while(|r| r.status != WorkflowStatus::Completed) + .count(); + rows.truncate(unfinished + 10); + rows + } + + pub fn workflow_selection(&self) -> usize { + self.workflow_rows() + .iter() + .position(|r| Some(&r.id) == self.workflows.selected.as_ref()) + .unwrap_or(0) + } + + pub fn workflow_for_worktree(&self, id: &WorktreeId) -> Option<&WorkflowSummary> { + self.workflows + .runs + .iter() + .find(|run| run.worktree.as_ref() == Some(id)) + } + + pub fn worktree_label<'a>(&'a self, worktree: &'a Worktree) -> &'a str { + self.workflow_for_worktree(&worktree.id) + .map_or(&worktree.branch, |run| &run.title) + } + + pub fn agent_label<'a>(&'a self, agent: &'a Agent) -> &'a str { + if let Some(run) = self.workflow_for_worktree(&agent.worktree_id) { + if let Some(stage) = run + .stages + .iter() + .find(|s| s.agent.as_ref() == Some(&agent.id)) + { + if agent.name == format!("workflow {} {}", run.id, stage.id) { + return &stage.id; + } + } + } + &agent.name + } +} + +pub fn status_label(status: WorkflowStatus) -> &'static str { + match status { + WorkflowStatus::Creating => "Creating", + WorkflowStatus::Running => "Running", + WorkflowStatus::Waiting => "Waiting", + WorkflowStatus::Paused => "Paused", + WorkflowStatus::Blocked => "Blocked", + WorkflowStatus::Completed => "Completed", + } +} + +pub fn progress(run: &WorkflowSummary) -> String { + let done = run + .stages + .iter() + .filter(|s| s.status == StageStatus::Completed) + .count(); + format!( + "{done}/{} done · {} left", + run.total, + run.total.saturating_sub(done) + ) +} + +pub fn current_step(run: &WorkflowSummary) -> String { + if run.status == WorkflowStatus::Completed { + return "All steps complete".into(); + } + run.stages.get(run.current).map_or_else( + || "Preparing".into(), + |s| { + format!( + "{} {}/{} · {}", + if s.status == StageStatus::Pending { + "Queued" + } else { + "Step" + }, + run.current + 1, + run.total, + s.id + ) + }, + ) +} + +pub fn next_step(run: &WorkflowSummary) -> String { + run.stages + .get(run.current.saturating_add(1)) + .map_or_else(|| "Next: none".into(), |s| format!("Next: {}", s.id)) +} diff --git a/crates/nebula/Cargo.toml b/crates/nebula/Cargo.toml index 6224bfb..6004fb0 100644 --- a/crates/nebula/Cargo.toml +++ b/crates/nebula/Cargo.toml @@ -13,7 +13,9 @@ nebula-core = { workspace = true } nebula-daemon = { workspace = true } nebula-tui = { workspace = true } anyhow = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +toml = { version = "1.1", default-features = false, features = ["std", "serde", "parse"] } clap = { version = "4", features = ["derive", "wrap_help"] } tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/nebula/src/main.rs b/crates/nebula/src/main.rs index 65baced..3abd055 100644 --- a/crates/nebula/src/main.rs +++ b/crates/nebula/src/main.rs @@ -4,6 +4,7 @@ mod ssh; mod tunnel; mod upgrade; mod workflow_cli; +mod workflow_config; use anyhow::Result; use clap::Parser; diff --git a/crates/nebula/src/workflow_cli.rs b/crates/nebula/src/workflow_cli.rs index dafe564..ceffadd 100644 --- a/crates/nebula/src/workflow_cli.rs +++ b/crates/nebula/src/workflow_cli.rs @@ -1,5 +1,8 @@ //! Prototype workflow CLI. The DAEMON owns all scheduling and durable state. +mod output; + +use crate::workflow_config::{self, read_text}; use anyhow::{bail, ensure, Context, Result}; use clap::{Args, Subcommand}; use nebula_core::{ @@ -7,11 +10,7 @@ use nebula_core::{ workflow::*, AgentId, ClientRequest, ServerEvent, }; -use std::{ - io::Read, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{path::PathBuf, time::Duration}; #[derive(Args)] #[command( @@ -27,6 +26,26 @@ pub(crate) struct WorkflowCli { #[derive(Subcommand)] enum WorkflowCommand { + /// List this checkout's workflow definitions. + /// + /// Reads .nebula/workflows locally, including invalid definitions and + /// their errors. Does not start a DAEMON or an AGENT. + #[command(after_help = "Example:\n nebula workflow catalog --json")] + Catalog, + /// Preview a workflow's resolved AGENTS and instructions. + /// + /// Resolves AGENT references and MODEL / EFFORT defaults locally. Without + /// a selector, uses default.toml, the sole definition, or legacy JSON. + #[command( + after_help = "Examples:\n nebula workflow inspect default\n nebula workflow inspect --definition task.toml" + )] + Inspect { + /// Filename selector from .nebula/workflows (without .toml). + workflow: Option<String>, + /// Read an explicit TOML or legacy JSON workflow. + #[arg(long, conflicts_with = "workflow")] + definition: Option<PathBuf>, + }, /// Start an ordered workflow from main. /// /// Run inside a NEBULA AGENT in the ROOT WORKTREE on main. Creates a new @@ -39,9 +58,12 @@ enum WorkflowCommand { /// Read the task from a UTF-8 file. #[arg(long)] task_file: Option<PathBuf>, - /// Versioned workflow definition with stage order and MODEL / EFFORT. - #[arg(long, default_value = ".nebula/workflow.json")] - definition: PathBuf, + /// Filename selector from .nebula/workflows (without .toml). + #[arg(long, conflicts_with = "definition")] + workflow: Option<String>, + /// Read an explicit TOML or legacy JSON workflow. + #[arg(long)] + definition: Option<PathBuf>, }, /// Inspect a workflow and its stage results. /// @@ -51,7 +73,7 @@ enum WorkflowCommand { after_help = "Examples:\n nebula workflow status <run-id>\n nebula workflow status --json" )] Status { id: Option<String> }, - /// List the 50 most recently updated runs. + /// List workflow runs, most recently updated first. /// /// Shows summaries across this DAEMON; status --json reads one run in full. #[command(after_help = "Example:\n nebula workflow list")] @@ -97,49 +119,50 @@ fn caller() -> Result<AgentId> { .context("run this command inside a NEBULA AGENT SESSION") } -fn read_file(path: &Path, limit: u64) -> Result<String> { - let mut value = String::new(); - std::fs::File::open(path) - .with_context(|| format!("open {}", path.display()))? - .take(limit + 1) - .read_to_string(&mut value)?; - ensure!( - value.len() as u64 <= limit, - "{} exceeds {limit} bytes", - path.display() - ); - Ok(value) -} - impl WorkflowCli { pub(crate) fn run(self) -> Result<()> { let op = match self.command { + WorkflowCommand::Catalog => { + return output::catalog( + workflow_config::catalog( + &std::env::current_dir()?, + &nebula_tui::config::Config::load(), + )?, + self.json, + ); + } + WorkflowCommand::Inspect { + workflow, + definition, + } => { + return output::inspect( + workflow_config::load( + &std::env::current_dir()?, + workflow.as_deref(), + definition.as_deref(), + &nebula_tui::config::Config::load(), + )?, + self.json, + ); + } WorkflowCommand::Start { task, task_file, + workflow, definition, } => { let caller = caller()?; let task = match task_file { - Some(path) => read_file(&path, 8000)?, + Some(path) => read_text(&path, 8000)?, None => task.context("task required")?, }; - let mut definition: WorkflowDefinition = - serde_json::from_str(&read_file(&definition, 64 * 1024)?)?; - let config = nebula_tui::config::Config::load(); - for stage in &mut definition.stages { - ensure!( - config.kind_enabled(stage.kind), - "{} is disabled in NEBULA", - stage.kind.as_str() - ); - if stage.model.is_none() { - stage.model = config.default_model(stage.kind); - } - if stage.effort.is_none() { - stage.effort = config.default_effort(stage.kind); - } - } + let definition = workflow_config::load( + &std::env::current_dir()?, + workflow.as_deref(), + definition.as_deref(), + &nebula_tui::config::Config::load(), + )? + .definition; WorkflowOp::Start { caller, task, @@ -169,7 +192,7 @@ impl WorkflowCli { "completed requires --file" ); let artifact = file - .map(|p| read_file(&p, 64 * 1024)) + .map(|p| read_text(&p, 64 * 1024)) .transpose()? .unwrap_or_default(); WorkflowOp::Report { @@ -191,48 +214,7 @@ impl WorkflowCli { "workflow request timed out; inspect `nebula workflow list` before retrying", )? })?; - if self.json { - println!("{}", serde_json::to_string_pretty(&reply)?); - } else { - match reply { - WorkflowReply::Run(run) => { - println!( - "Workflow: {}\nStatus: {:?}\n{}", - run.id, run.status, run.message - ); - if let Some(worktree) = &run.worktree { - println!("WORKTREE: {}", worktree.path.display()); - } - for (definition, stage) in run.definition.stages.iter().zip(&run.stages) { - println!( - " {}: {:?} ({}, model {}, effort {}){}", - definition.id, - stage.status, - definition.kind.as_str(), - definition.model.as_deref().unwrap_or("default"), - definition.effort.as_deref().unwrap_or("default"), - stage - .agent - .as_ref() - .map(|id| format!(" SESSION {id}")) - .unwrap_or_default() - ); - } - } - WorkflowReply::List(runs) => { - if runs.is_empty() { - println!("No workflows."); - } - for run in runs { - println!( - "{} {:?} {}/{} {}", - run.id, run.status, run.current, run.total, run.message - ); - } - } - } - } - Ok(()) + output::reply(reply, self.json) } } diff --git a/crates/nebula/src/workflow_cli/output.rs b/crates/nebula/src/workflow_cli/output.rs new file mode 100644 index 0000000..9ef7614 --- /dev/null +++ b/crates/nebula/src/workflow_cli/output.rs @@ -0,0 +1,116 @@ +use crate::workflow_config::{CatalogEntry, ResolvedWorkflow}; +use anyhow::Result; +use nebula_core::workflow::WorkflowReply; + +pub(super) fn catalog(entries: Vec<CatalogEntry>, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else if entries.is_empty() { + println!("No definitions. Add .nebula/workflows/default.toml."); + } else { + for entry in entries { + if let Some(error) = entry.error { + println!("{}: INVALID\n {error}", entry.id); + } else { + println!( + "{}: {}\n {}\n {}", + entry.id, + entry.name.as_deref().unwrap_or("Legacy JSON"), + entry.description.as_deref().unwrap_or_default(), + entry.stages.join(" -> ") + ); + } + } + } + Ok(()) +} + +pub(super) fn inspect(resolved: ResolvedWorkflow, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&resolved)?); + return Ok(()); + } + let definition = &resolved.definition; + println!( + "Definition: {} ({})\nFile: {}\n{}\nTimeout: {} seconds per stage", + definition.id.as_deref().unwrap_or("legacy"), + definition.name.as_deref().unwrap_or("Legacy JSON"), + resolved.path.display(), + resolved.description, + definition.timeout_seconds + ); + for (index, stage) in definition.stages.iter().enumerate() { + println!( + "\n{}. {}: {} / {} / {}", + index + 1, + stage.id, + stage.kind.as_str(), + stage.model.as_deref().unwrap_or("provider default"), + stage.effort.as_deref().unwrap_or("provider default") + ); + match &resolved.agent_sources[index] { + Some(path) => println!(" AGENT: {}", path.display()), + None => println!(" AGENT: inline"), + } + for line in stage.instructions.lines() { + println!(" {line}"); + } + } + Ok(()) +} + +pub(super) fn reply(reply: WorkflowReply, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&reply)?); + return Ok(()); + } + match reply { + WorkflowReply::Run(run) => { + println!( + "Workflow: {}\nStatus: {:?}\n{}", + run.id, run.status, run.message + ); + if let Some(id) = &run.definition.id { + println!( + "Definition: {id} ({})", + run.definition.name.as_deref().unwrap_or(id) + ); + } + if let Some(worktree) = &run.worktree { + println!("WORKTREE: {}", worktree.path.display()); + } + for (definition, stage) in run.definition.stages.iter().zip(&run.stages) { + println!( + " {}: {:?} ({}, model {}, effort {}){}", + definition.id, + stage.status, + definition.kind.as_str(), + definition.model.as_deref().unwrap_or("default"), + definition.effort.as_deref().unwrap_or("default"), + stage + .agent + .as_ref() + .map(|id| format!(" SESSION {id}")) + .unwrap_or_default() + ); + } + } + WorkflowReply::List(runs) => { + if runs.is_empty() { + println!("No workflows."); + } + for run in runs { + println!( + "{} {} {:?} {}/{} {}", + run.id, + run.workflow.as_deref().unwrap_or("legacy"), + run.status, + run.current, + run.total, + run.message + ); + } + } + } + Ok(()) +} diff --git a/crates/nebula/src/workflow_config.rs b/crates/nebula/src/workflow_config.rs new file mode 100644 index 0000000..eab7c56 --- /dev/null +++ b/crates/nebula/src/workflow_config.rs @@ -0,0 +1,240 @@ +//! File discovery and resolution. The DAEMON receives a complete, frozen definition. + +mod format; +#[cfg(test)] +mod tests; + +use anyhow::{bail, ensure, Context, Result}; +use nebula_core::workflow::WorkflowDefinition; +use nebula_tui::config::Config; +use serde::Serialize; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Serialize)] +pub(crate) struct ResolvedWorkflow { + pub path: PathBuf, + pub description: String, + /// One source per stage: a canonical AGENT file path, or None for inline. + pub agent_sources: Vec<Option<PathBuf>>, + pub definition: WorkflowDefinition, +} + +#[derive(Serialize)] +pub(crate) struct CatalogEntry { + pub id: String, + pub path: PathBuf, + pub name: Option<String>, + pub description: Option<String>, + pub stages: Vec<String>, + pub error: Option<String>, +} + +pub(crate) fn read_text(path: &Path, limit: u64) -> Result<String> { + let mut value = String::new(); + fs::File::open(path) + .with_context(|| format!("open {}", path.display()))? + .take(limit + 1) + .read_to_string(&mut value) + .with_context(|| format!("read UTF-8 from {}", path.display()))?; + ensure!( + value.len() as u64 <= limit, + "{} exceeds {limit} bytes", + path.display() + ); + Ok(value) +} + +fn config_dir(cwd: &Path) -> Result<PathBuf> { + for dir in cwd.ancestors() { + let nebula = dir.join(".nebula"); + if nebula.is_dir() { + return Ok(nebula); + } + if dir.join(".git").exists() { + break; + } + } + bail!("no .nebula directory in this checkout; use --definition for an explicit file") +} + +fn slug(id: &str) -> Result<()> { + ensure!( + id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_lowercase()) + && id + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_-".contains(&c)), + "{id:?} must be a lowercase filename slug (letters, digits, '_' or '-')" + ); + Ok(()) +} + +fn named_file(dir: &Path, id: &str) -> Result<PathBuf> { + slug(id)?; + let path = dir.join(format!("{id}.toml")); + let canonical = path + .canonicalize() + .with_context(|| format!("find {}", path.display()))?; + let root = dir.canonicalize()?; + ensure!( + canonical.starts_with(root), + "{} points outside its configuration directory", + path.display() + ); + ensure!( + canonical.is_file(), + "{} must be a regular file", + path.display() + ); + // Keep the selected filename as the identity even for an internal symlink. + Ok(path) +} + +fn workflow_ids(root: &Path) -> Result<Vec<String>> { + let dir = root.join("workflows"); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut ids = Vec::new(); + for entry in fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "toml") { + ids.push( + path.file_stem() + .and_then(|s| s.to_str()) + .context("workflow filename is not UTF-8")? + .to_string(), + ); + } + } + ids.sort(); + Ok(ids) +} + +pub(crate) fn load( + cwd: &Path, + workflow: Option<&str>, + definition: Option<&Path>, + config: &Config, +) -> Result<ResolvedWorkflow> { + ensure!( + workflow.is_none() || definition.is_none(), + "choose --workflow or --definition" + ); + let path = if let Some(path) = definition { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } + } else { + let root = config_dir(cwd)?; + if let Some(id) = workflow { + named_file(&root.join("workflows"), id)? + } else { + let ids = workflow_ids(&root)?; + if ids.iter().any(|id| id == "default") { + named_file(&root.join("workflows"), "default")? + } else if ids.len() == 1 { + named_file(&root.join("workflows"), &ids[0])? + } else if ids.is_empty() && root.join("workflow.json").is_file() { + root.join("workflow.json") + } else if ids.is_empty() { + bail!("no workflow definitions; add .nebula/workflows/default.toml"); + } else { + bail!( + "choose --workflow from: {} (or name the default file default.toml)", + ids.join(", ") + ); + } + } + }; + load_path(&path, config) +} + +fn load_path(path: &Path, config: &Config) -> Result<ResolvedWorkflow> { + let text = read_text(path, 64 * 1024)?; + let mut resolved = match path.extension().and_then(|ext| ext.to_str()) { + Some("toml") => format::resolve(path, &text)?, + Some("json") => { + let definition: WorkflowDefinition = serde_json::from_str(&text) + .with_context(|| format!("parse legacy workflow {}", path.display()))?; + ResolvedWorkflow { + path: path.to_path_buf(), + description: "Legacy JSON workflow".into(), + agent_sources: vec![None; definition.stages.len()], + definition, + } + } + _ => bail!( + "{} must be a .toml or legacy .json definition", + path.display() + ), + }; + for stage in &mut resolved.definition.stages { + ensure!( + config.kind_enabled(stage.kind), + "{}: {} is disabled in NEBULA", + path.display(), + stage.kind.as_str() + ); + if stage.model.is_none() { + stage.model = config.default_model(stage.kind); + } + if stage.effort.is_none() { + stage.effort = config.default_effort(stage.kind); + } + } + nebula_daemon::workflow::validate_definition(&resolved.definition) + .with_context(|| format!("validate {}", path.display()))?; + Ok(resolved) +} + +pub(crate) fn catalog(cwd: &Path, config: &Config) -> Result<Vec<CatalogEntry>> { + let root = config_dir(cwd)?; + let ids = workflow_ids(&root)?; + let legacy = ids.is_empty() && root.join("workflow.json").is_file(); + let ids = if legacy { vec!["legacy".into()] } else { ids }; + Ok(ids + .into_iter() + .map(|id| { + let path = if legacy { + root.join("workflow.json") + } else { + root.join("workflows").join(format!("{id}.toml")) + }; + let loaded = if legacy { + load_path(&path, config) + } else { + named_file(&root.join("workflows"), &id).and_then(|p| load_path(&p, config)) + }; + match loaded { + Ok(resolved) => CatalogEntry { + id, + path, + name: resolved.definition.name, + description: Some(resolved.description), + stages: resolved + .definition + .stages + .into_iter() + .map(|s| s.id) + .collect(), + error: None, + }, + Err(err) => CatalogEntry { + id, + path, + name: None, + description: None, + stages: Vec::new(), + error: Some(format!("{err:#}")), + }, + } + }) + .collect()) +} diff --git a/crates/nebula/src/workflow_config/format.rs b/crates/nebula/src/workflow_config/format.rs new file mode 100644 index 0000000..7f84cdb --- /dev/null +++ b/crates/nebula/src/workflow_config/format.rs @@ -0,0 +1,121 @@ +//! TOML supports an AGENT filename or inline table; references resolve once at kickoff. + +use super::{named_file, read_text, slug, ResolvedWorkflow}; +use anyhow::{ensure, Context, Result}; +use nebula_core::{ + workflow::{StageDefinition, WorkflowDefinition}, + AgentKind, +}; +use serde::Deserialize; +use std::path::Path; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkflowFile { + version: u32, + name: Option<String>, + #[serde(default)] + description: String, + timeout_seconds: u64, + stages: Vec<StageFile>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StageFile { + id: String, + agent: AgentSource, + model: Option<String>, + effort: Option<String>, + #[serde(default)] + instructions: String, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum AgentSource { + Reference(String), + Inline(AgentFile), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentFile { + kind: AgentKind, + model: Option<String>, + effort: Option<String>, + #[serde(default)] + instructions: String, +} + +pub(super) fn resolve(path: &Path, text: &str) -> Result<ResolvedWorkflow> { + let file: WorkflowFile = + toml::from_str(text).with_context(|| format!("parse {}", path.display()))?; + let id = path + .file_stem() + .and_then(|s| s.to_str()) + .context("workflow filename is not UTF-8")?; + slug(id)?; + ensure!( + file.description.len() <= 2000 + && !file + .description + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')), + "workflow description must be plain text of at most 2000 bytes" + ); + ensure!( + (1..=10).contains(&file.stages.len()), + "workflow needs 1..10 stages" + ); + let mut stages = Vec::new(); + let mut sources = Vec::new(); + for stage in file.stages { + let (agent, source) = match stage.agent { + AgentSource::Inline(agent) => (agent, None), + AgentSource::Reference(reference) => { + let root = path.ancestors().find(|p| p.file_name().is_some_and(|n| n == ".nebula")) + .context("referenced AGENTS require the workflow file to live under .nebula; use an inline agent for standalone files")?; + let agent_path = + named_file(&root.join("agents"), &reference).with_context(|| { + format!("stage {} references AGENT {reference:?}", stage.id) + })?; + let text = read_text(&agent_path, 16 * 1024)?; + let agent: AgentFile = toml::from_str(&text).with_context(|| { + format!( + "parse AGENT {} for stage {}", + agent_path.display(), + stage.id + ) + })?; + (agent, Some(agent_path.canonicalize()?)) + } + }; + // AGENT instructions describe the reusable role; stage text adds task context. + let instructions = [agent.instructions.trim(), stage.instructions.trim()] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::<Vec<_>>() + .join("\n\n"); + stages.push(StageDefinition { + id: stage.id, + kind: agent.kind, + model: stage.model.or(agent.model), + effort: stage.effort.or(agent.effort), + instructions, + }); + sources.push(source); + } + Ok(ResolvedWorkflow { + path: path.canonicalize()?, + description: file.description, + agent_sources: sources, + definition: WorkflowDefinition { + version: file.version, + id: Some(id.to_string()), + name: Some(file.name.unwrap_or_else(|| id.to_string())), + timeout_seconds: file.timeout_seconds, + stages, + }, + }) +} diff --git a/crates/nebula/src/workflow_config/tests.rs b/crates/nebula/src/workflow_config/tests.rs new file mode 100644 index 0000000..b810e9d --- /dev/null +++ b/crates/nebula/src/workflow_config/tests.rs @@ -0,0 +1,195 @@ +use super::*; + +fn write(root: &Path, path: &str, text: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); +} + +const AGENT: &str = "kind = 'claude'\nmodel = 'claude-sonnet-5'\neffort = 'medium'\ninstructions = 'Investigate the task.'\n"; +const WORKFLOW: &str = "version = 1\nname = 'Display label'\ndescription = 'Use for implementation'\ntimeout_seconds = 60\n[[stages]]\nid = 'plan'\nagent = 'planner'\ninstructions = 'Check edge cases.'\n"; + +#[test] +fn references_inline_agents_and_overrides_resolve_into_one_definition() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".nebula/agents/planner.toml", AGENT); + let text = format!("{WORKFLOW}effort = 'high'\n[[stages]]\nid = 'implement'\nagent = {{ kind = 'codex', model = 'test-model', instructions = 'Implement the plan.' }}\n"); + write(dir.path(), ".nebula/workflows/build.toml", &text); + let config = Config { + codex_effort: "medium".into(), + ..Config::default() + }; + let resolved = load(dir.path(), Some("build"), None, &config).unwrap(); + assert_eq!(resolved.definition.id.as_deref(), Some("build")); + assert_eq!(resolved.definition.name.as_deref(), Some("Display label")); + let plan = &resolved.definition.stages[0]; + assert_eq!(plan.model.as_deref(), Some("claude-sonnet-5")); + assert_eq!(plan.effort.as_deref(), Some("high")); + assert_eq!( + plan.instructions, + "Investigate the task.\n\nCheck edge cases." + ); + assert!(resolved.agent_sources[0] + .as_ref() + .unwrap() + .ends_with("planner.toml")); + assert!(resolved.agent_sources[1].is_none()); + assert_eq!( + resolved.definition.stages[1].effort.as_deref(), + Some("medium") + ); + // Changing the source does not change an already resolved snapshot. + write( + dir.path(), + ".nebula/agents/planner.toml", + &AGENT.replace("claude-sonnet-5", "different-model"), + ); + assert_eq!(plan.model.as_deref(), Some("claude-sonnet-5")); + assert_eq!( + load(dir.path(), Some("build"), None, &config) + .unwrap() + .definition + .stages[0] + .model + .as_deref(), + Some("different-model") + ); +} + +#[test] +fn selection_is_explicit_default_then_sole_and_never_a_display_name() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".nebula/agents/planner.toml", AGENT); + write(dir.path(), ".nebula/workflows/one.toml", WORKFLOW); + let config = Config::default(); + assert_eq!( + load(dir.path(), None, None, &config) + .unwrap() + .definition + .id + .as_deref(), + Some("one") + ); + write(dir.path(), ".nebula/workflows/two.toml", WORKFLOW); + assert!(load(dir.path(), None, None, &config) + .unwrap_err() + .to_string() + .contains("choose --workflow")); + assert!(load(dir.path(), Some("Display label"), None, &config).is_err()); + // Duplicate display labels are allowed because filenames are the stable selectors. + assert_eq!(catalog(dir.path(), &config).unwrap().len(), 2); + write( + dir.path(), + ".nebula/workflows/default.toml", + &WORKFLOW.replace("name = 'Display label'\n", ""), + ); + let default = load(dir.path(), None, None, &config).unwrap(); + assert_eq!(default.definition.id.as_deref(), Some("default")); + assert_eq!(default.definition.name.as_deref(), Some("default")); + assert_eq!( + load(dir.path(), Some("two"), None, &config) + .unwrap() + .definition + .id + .as_deref(), + Some("two") + ); +} + +#[test] +fn references_are_relative_to_the_definition_and_lookup_stops_at_checkout_root() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".nebula/agents/planner.toml", AGENT); + write(dir.path(), ".nebula/workflows/build.toml", WORKFLOW); + let nested = dir.path().join("project/src"); + fs::create_dir_all(&nested).unwrap(); + assert!(load(&nested, Some("build"), None, &Config::default()).is_ok()); + write(dir.path(), "project/.git", "gitdir: elsewhere"); + assert!(load(&nested, Some("build"), None, &Config::default()).is_err()); + let explicit = dir.path().join(".nebula/workflows/build.toml"); + assert!(load(&nested, None, Some(&explicit), &Config::default()).is_ok()); +} + +#[test] +fn bad_references_unknown_fields_duplicates_and_oversized_instructions_fail() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".nebula/agents/planner.toml", AGENT); + let cases = [ + WORKFLOW.replace("agent = 'planner'", "agent = 'missing'"), + WORKFLOW.replace("agent = 'planner'", "agent = '../planner'"), + WORKFLOW.replace("name =", "unexpected ="), + WORKFLOW.replace("instructions =", "instruction ="), + WORKFLOW.replace("version = 1", "version = 2"), + WORKFLOW.replace("timeout_seconds = 60", "timeout_seconds = 0"), + WORKFLOW.replace("agent = 'planner'", "agent = { kind = 'cursor' }"), + WORKFLOW.replace( + "description = 'Use for implementation'", + "description = \"bad\\u001Bcontrol\"", + ), + format!("{WORKFLOW}[[stages]]\nid = 'plan'\nagent = 'planner'\n"), + WORKFLOW.replace("Check edge cases.", &"x".repeat(4000)), + ]; + for invalid in cases { + write(dir.path(), ".nebula/workflows/bad.toml", &invalid); + assert!( + load(dir.path(), Some("bad"), None, &Config::default()).is_err(), + "accepted {invalid}" + ); + } + write(dir.path(), ".nebula/workflows/good.toml", WORKFLOW); + let entries = catalog(dir.path(), &Config::default()).unwrap(); + assert!(entries[0].error.is_some()); + assert!(entries[1].error.is_none()); + write( + dir.path(), + ".nebula/agents/planner.toml", + &AGENT.replace("model =", "models ="), + ); + assert!(load(dir.path(), Some("good"), None, &Config::default()).is_err()); + assert!(load(dir.path(), Some("../good"), None, &Config::default()).is_err()); +} + +#[test] +fn legacy_json_loads_without_metadata_and_does_not_shadow_named_workflows() { + let dir = tempfile::tempdir().unwrap(); + let json = r#"{"version":1,"timeout_seconds":60,"stages":[{"id":"plan","kind":"claude","model":"claude-sonnet-5","effort":"medium","instructions":"Plan it"}]}"#; + write(dir.path(), ".nebula/workflow.json", json); + let config = Config::default(); + let legacy = load(dir.path(), None, None, &config).unwrap(); + assert!(legacy.definition.id.is_none()); + assert!(legacy.definition.name.is_none()); + assert_eq!(catalog(dir.path(), &config).unwrap()[0].id, "legacy"); + write(dir.path(), ".nebula/agents/planner.toml", AGENT); + write(dir.path(), ".nebula/workflows/one.toml", WORKFLOW); + write(dir.path(), ".nebula/workflows/two.toml", WORKFLOW); + assert!(load(dir.path(), None, None, &config).is_err()); + assert!(load( + dir.path(), + None, + Some(Path::new(".nebula/workflow.json")), + &config + ) + .is_ok()); +} + +#[cfg(unix)] +#[test] +fn named_workflows_and_agents_cannot_follow_symlinks_outside_their_directories() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "outside.toml", AGENT); + write(dir.path(), ".nebula/workflows/build.toml", WORKFLOW); + fs::create_dir_all(dir.path().join(".nebula/agents")).unwrap(); + symlink( + dir.path().join("outside.toml"), + dir.path().join(".nebula/agents/planner.toml"), + ) + .unwrap(); + assert!(load(dir.path(), Some("build"), None, &Config::default()).is_err()); + symlink( + dir.path().join("outside.toml"), + dir.path().join(".nebula/workflows/escape.toml"), + ) + .unwrap(); + assert!(load(dir.path(), Some("escape"), None, &Config::default()).is_err()); +} diff --git a/crates/nebula/tests/e2e_pty/workflow.rs b/crates/nebula/tests/e2e_pty/workflow.rs index 76eaf10..c940cde 100644 --- a/crates/nebula/tests/e2e_pty/workflow.rs +++ b/crates/nebula/tests/e2e_pty/workflow.rs @@ -97,6 +97,8 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { let caller = create_agent_get_id(&mut c, &root.id, "kickoff", 2).await; let definition = WorkflowDefinition { version: 1, + id: None, + name: None, timeout_seconds: 60, stages: vec![ StageDefinition { @@ -121,18 +123,41 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { serde_json::to_string(&definition).unwrap(), ) .unwrap(); - let out = workflow_cli( - &env, - &caller, - &[ + let configs = repo.join(".nebula"); + std::fs::create_dir_all(configs.join("workflows")).unwrap(); + std::fs::create_dir_all(configs.join("agents")).unwrap(); + let agent_file = configs.join("agents/implementer.toml"); + std::fs::write(&agent_file, "kind = 'codex'\nmodel = 'test-implementer-model'\neffort = 'medium'\ninstructions = 'Implement it'\n").unwrap(); + std::fs::write( + configs.join("workflows/build.toml"), + r#" +version = 1 +name = "Build with reviewable checkpoints" +description = "Named workflow E2E" +timeout_seconds = 60 +[[stages]] +id = "planner" +agent = { kind = "claude", model = "test-planner-model", effort = "high", instructions = "Plan it" } +[[stages]] +id = "implementer" +agent = "implementer" +"#, + ) + .unwrap(); + let out = env + .cli() + .current_dir(&repo) + .env(env::AGENT_ID, caller.as_str()) + .args([ "workflow", "start", "Change README", - "--definition", - definition_file.to_str().unwrap(), + "--workflow", + "build", "--json", - ], - ); + ]) + .output() + .unwrap(); assert!( out.status.success(), "{}", @@ -142,6 +167,19 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { panic!("expected run"); }; let id = run.id.clone(); + assert_eq!(run.branch, "workflow-change-readme"); + assert_eq!(run.definition.id.as_deref(), Some("build")); + assert_eq!( + run.definition.name.as_deref(), + Some("Build with reviewable checkpoints") + ); + // Sources can change or disappear before the second stage: the DAEMON owns the snapshot. + std::fs::write( + &agent_file, + "kind = 'claude'\nmodel = 'changed-model'\neffort = 'low'\ninstructions = 'Changed'\n", + ) + .unwrap(); + std::fs::remove_file(configs.join("workflows/build.toml")).unwrap(); let first = wait_for_run(&env, &id, |r| r.stages[0].agent.is_some()).await; let planner = first.stages[0].agent.clone().unwrap(); assert_ne!(first.worktree.as_ref().unwrap().id, root.id); @@ -249,6 +287,11 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { assert_eq!(second.current, 1); assert_eq!(second.stages[0].agent.as_ref(), Some(&planner)); assert_eq!(second.definition.stages[1].kind, AgentKind::Codex); + assert_eq!( + second.definition.stages[1].model.as_deref(), + Some("test-implementer-model") + ); + assert_eq!(second.definition.id.as_deref(), Some("build")); workflow_hook(&env_dir, &implementer, "codex", "UserPromptSubmit").await; // An interrupted active stage blocks; recovery keeps the same SESSION id. @@ -408,9 +451,21 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { .all(|s| s.status == StageStatus::Completed)); let snapshots = subscribe(&mut c).await; - let ServerEvent::Snapshot { agents, .. } = snapshots.last().unwrap() else { + let ServerEvent::Snapshot { + agents, workflows, .. + } = snapshots.last().unwrap() + else { panic!("snapshot"); }; + let summary = workflows.iter().find(|r| r.id == id).unwrap(); + assert_eq!(summary.status, WorkflowStatus::Completed); + assert_eq!(summary.current, 2); + assert_eq!(summary.title, "Change README"); + assert_eq!( + summary.worktree, + completed.worktree.as_ref().map(|w| w.id.clone()) + ); + assert_eq!(summary.stages[1].agent.as_ref(), Some(&implementer)); assert_eq!( agents.len(), 3, @@ -428,6 +483,27 @@ async fn workflow_cli_handoffs_are_durable_and_require_an_explicit_result() { .unwrap() .worktree_id ); + let old = workflow_cli( + &env, + &caller, + &[ + "workflow", + "start", + "Legacy task", + "--definition", + definition_file.to_str().unwrap(), + "--json", + ], + ); + assert!( + old.status.success(), + "{}", + String::from_utf8_lossy(&old.stderr) + ); + let WorkflowReply::Run(legacy) = serde_json::from_slice(&old.stdout).unwrap() else { + panic!("expected legacy run") + }; + assert!(legacy.definition.id.is_none()); write_frame(&mut c, &ClientRequest::Shutdown).await.unwrap(); wait_for_exit(&mut daemon); } diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index b6752b6..56df5e1 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -15,6 +15,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +#[path = "e2e_tui/workflows.rs"] +mod workflows; + const COLS: u16 = 120; const ROWS: u16 = 36; const WAIT: Duration = Duration::from_secs(20); diff --git a/crates/nebula/tests/e2e_tui/workflows.rs b/crates/nebula/tests/e2e_tui/workflows.rs new file mode 100644 index 0000000..a082d28 --- /dev/null +++ b/crates/nebula/tests/e2e_tui/workflows.rs @@ -0,0 +1,117 @@ +use super::*; +use nebula_core::workflow::{WorkflowReply, WorkflowRun}; + +fn cli(tui: &TuiHarness, args: &[&str], caller: &str) -> std::process::Output { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_nebula")) + .args(args) + .env(nebula_core::env::RUNTIME_DIR, &tui.runtime_dir) + .env(nebula_core::env::DATA_DIR, &tui.data_dir) + .env(nebula_core::env::AGENT_ID, caller) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + +#[test] +fn workflows_are_visible_live_and_open_their_sessions() { + use std::os::unix::fs::PermissionsExt; + let scratch = tempfile::tempdir().unwrap(); + let stub = scratch.path().join("agent"); + std::fs::write(&stub, "#!/bin/sh\nexec sleep 600\n").unwrap(); + std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut tui = TuiHarness::spawn_with_env(&[ + (nebula_core::env::AGENT_CMD, stub.display().to_string()), + ( + "CODEX_HOME", + scratch.path().join("codex").display().to_string(), + ), + ]); + let repo = tui.make_repo("demo"); + add_project(&mut tui, &repo, "demo"); + tui.send(ENTER); + tui.wait_for_text(FOOTER_WORKTREES); + tui.send(ENTER); + tui.wait_for_text(FOOTER_SESSIONS); + tui.send(b"n"); + tui.wait_for_text("New session"); + tui.send(ENTER); + tui.wait_for_text("New agent"); + tui.type_str("kickoff"); + tui.send(ENTER); + tui.wait_for_gone("New agent"); + tui.wait_for_text(FOOTER_TERMINAL_LOCKED); + tui.send(CTRL_Q); + tui.wait_for_text(FOOTER_SESSIONS); + let store = nebula_daemon::store::Store::open(&tui.data_dir.join("nebula.db")).unwrap(); + let caller = store + .load_tree() + .unwrap() + .2 + .into_iter() + .find(|a| a.name == "kickoff") + .unwrap() + .id; + let definition = scratch.path().join("workflow.json"); + std::fs::write( + &definition, + r#"{"version":1,"timeout_seconds":600,"stages":[ + {"id":"planner","kind":"claude","model":null,"effort":null,"instructions":"Plan"}, + {"id":"implementer","kind":"claude","model":null,"effort":null,"instructions":"Implement"} + ]}"#, + ) + .unwrap(); + let output = cli( + &tui, + &[ + "workflow", + "start", + "Fix login", + "--definition", + definition.to_str().unwrap(), + "--json", + ], + caller.as_str(), + ); + let WorkflowReply::Run(run) = serde_json::from_slice(&output.stdout).unwrap() else { + panic!("run"); + }; + let run: WorkflowRun = *run; + assert_eq!(run.branch, "workflow-fix-login"); + tui.wait_for_text("◆ 1 running"); + tui.wait_for_text("◆ Fix login"); + tui.send(b"O"); + tui.wait_for_text("WORKFLOWS"); + tui.wait_for_text("Step 1/2 · planner"); + tui.wait_for_text("Next: implementer"); + tui.wait_for_text("0/2 done · 2 left"); + // Enter the dedicated panel from SESSIONS, then open the run. + tui.send(SHIFT_TAB); + tui.send(ENTER); + tui.wait_for_text("planner"); + cli(&tui, &["workflow", "pause", &run.id], caller.as_str()); + tui.wait_for_text("Paused"); + tui.wait_for_text("◆ 0 running"); + cli(&tui, &["workflow", "resume", &run.id], caller.as_str()); + tui.wait_for_text("Running"); + tui.wait_for_text("◆ 1 running"); + let text = tui.screen_text(); + assert!(!text.contains(&run.id), "{text}"); + if let Ok(path) = std::env::var("NEBULA_WORKFLOW_SCREENSHOT") { + std::fs::write(&path, &text).unwrap(); + let parser = tui.parser.lock().unwrap(); + let screen = parser.screen(); + let cells: Vec<_> = (0..ROWS).flat_map(|y| (0..COLS).map(move |x| { + let c = screen.cell(y, x).unwrap(); + serde_json::json!({"x":x, "y":y, "text":c.contents(), "fg":format!("{:?}",c.fgcolor()), "bg":format!("{:?}",c.bgcolor()), "bold":c.bold()}) + })).collect(); + std::fs::write(format!("{path}.json"), serde_json::to_vec(&cells).unwrap()).unwrap(); + } + tui.send(b"O"); + tui.wait_for_gone("WORKFLOWS"); + tui.wait_for_text("◆ 1 running"); +} diff --git a/crates/nebula/tests/help_cli.rs b/crates/nebula/tests/help_cli.rs index abf7bc2..4c9729f 100644 --- a/crates/nebula/tests/help_cli.rs +++ b/crates/nebula/tests/help_cli.rs @@ -14,6 +14,8 @@ use std::process::Command; /// Hidden ones (`_raw-attach`, `_stale-daemon-note`) are deliberately absent. const VISIBLE: &[&[&str]] = &[ &["workflow"], + &["workflow", "catalog"], + &["workflow", "inspect"], &["workflow", "start"], &["workflow", "status"], &["workflow", "list"], diff --git a/crates/nebula/tests/workflow_config_cli.rs b/crates/nebula/tests/workflow_config_cli.rs new file mode 100644 index 0000000..1932c78 --- /dev/null +++ b/crates/nebula/tests/workflow_config_cli.rs @@ -0,0 +1,89 @@ +//! Configuration inspection is local and must not need a running DAEMON or an AGENT id. +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; + +fn command(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_nebula")) + .args(args) + .current_dir(root) + .env_remove("NEBULA_AGENT_ID") + .env("NEBULA_DATA_DIR", root.join("data")) + .env("NEBULA_RUNTIME_DIR", root.join("runtime")) + .output() + .unwrap() +} + +fn fixture(root: &Path) { + fs::create_dir_all(root.join(".nebula/workflows")).unwrap(); + fs::create_dir_all(root.join(".nebula/agents")).unwrap(); + fs::write(root.join(".nebula/agents/reviewer.toml"), "kind = 'claude'\nmodel = 'claude-sonnet-5'\neffort = 'medium'\ninstructions = 'Review existing code.'\n").unwrap(); + fs::write(root.join(".nebula/workflows/review.toml"), "version = 1\nname = 'Read-only review'\ndescription = 'Review existing code without changing it'\ntimeout_seconds = 60\n[[stages]]\nid = 'review'\nagent = 'reviewer'\neffort = 'high'\ninstructions = 'Focus on the user request.'\n").unwrap(); +} + +#[test] +fn catalog_and_inspect_show_resolved_values_without_starting_a_daemon() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + let out = command(dir.path(), &["workflow", "catalog", "--json"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let entries: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(entries[0]["id"], "review"); + assert_eq!(entries[0]["name"], "Read-only review"); + assert!(entries[0]["error"].is_null()); + let out = command(dir.path(), &["workflow", "inspect", "review", "--json"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let resolved: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(resolved["definition"]["id"], "review"); + let stage = &resolved["definition"]["stages"][0]; + assert_eq!(stage["model"], "claude-sonnet-5"); + assert_eq!(stage["effort"], "high"); + assert_eq!( + stage["instructions"], + "Review existing code.\n\nFocus on the user request." + ); + assert!(!dir.path().join("runtime").exists()); + assert!(!dir.path().join("data/nebula.db").exists()); + let out = command(dir.path(), &["workflow", "inspect", "review"]); + let text = String::from_utf8(out.stdout).unwrap(); + assert!( + text.contains("AGENT:") + && text.contains("reviewer.toml") + && text.contains("claude-sonnet-5") + ); +} + +#[test] +fn malformed_agents_are_visible_in_catalog_and_rejected_before_launch() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + fs::remove_file(dir.path().join(".nebula/agents/reviewer.toml")).unwrap(); + let out = command(dir.path(), &["workflow", "catalog", "--json"]); + assert!(out.status.success()); + let entries: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(entries[0]["error"].as_str().unwrap().contains("reviewer")); + assert!(!command(dir.path(), &["workflow", "inspect", "review"]) + .status + .success()); + let out = Command::new(env!("CARGO_BIN_EXE_nebula")) + .args(["workflow", "start", "Review it", "--workflow", "review"]) + .current_dir(dir.path()) + .env("NEBULA_AGENT_ID", "no-real-agent") + .env("NEBULA_DATA_DIR", dir.path().join("data")) + .env("NEBULA_RUNTIME_DIR", dir.path().join("runtime")) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("reviewer")); + assert!(!dir.path().join("runtime").exists()); +} diff --git a/docs/commands.md b/docs/commands.md index 0f48850..67b00cf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -68,9 +68,16 @@ nebula open <file>… # show the files in this nebula's FILE TABS — a mo ## Ordered workflows (prototype) `nebula workflow start "task"` creates a separate WORKTREE from `main` and queues the -AGENTS declared in `.nebula/workflow.json`. The DAEMON stores run state and stage results -in the SQLITE STORE. `status`, `list`, `report`, `pause`, and `resume` inspect and manage -the run. See [Workflows](workflows.md) for configuration, all commands, and restart recovery. +AGENTS declared in `.nebula/workflows/default.toml`. Choose another definition with +`--workflow <filename-without-extension>`. `catalog` lists definitions; `inspect <selector>` +previews resolved AGENTS from `.nebula/agents/` or inline tables without starting SESSIONS. +The DAEMON stores run state and stage results in the SQLITE STORE. `status`, `list`, +`report`, `pause`, and `resume` inspect and manage runs. See [Workflows](workflows.md) for +configuration, all commands, legacy JSON support, and restart recovery. + +In the TUI, **Shift+O** toggles the WORKFLOWS PANEL. Its rows show current/next steps +and steps left for runs in the open WORKSPACE. Click the FOOTER workflow count to +open it; select a run and press Enter to reach its SESSIONS. ## Workspaces diff --git a/docs/workflows.md b/docs/workflows.md index e8f3ded..4acdbb4 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -1,14 +1,22 @@ -# Ordered AGENT workflows (prototype) +# Ordered AGENT workflows Start a task from the ROOT WORKTREE on `main`. NEBULA creates one separate WORKTREE and -runs the configured AGENTS in order. Both planner and implementer use the exact model ID -`claude-sonnet-5` with medium effort. A reviewer is another entry in the same ordered list. +runs the configured AGENTS in order. [The configuration overview](../.nebula/README.md) +lists the workflows and AGENTS shipped with this checkout. -Runtime state belongs to the **SQLITE STORE**. `.nebula/workflow.json` is the versioned -definition: provider, MODEL / EFFORT, instructions, order, and timeout. At kickoff the CLI -resolves NEBULA's configured defaults and the DAEMON freezes that definition into the run. -Editing the file afterwards affects new runs only. Remaining null defaults are delegated -to the provider CLI, so set explicit values when reproducibility matters. +There are three separate concerns: + +| Location | Responsibility | +|---|---| +| `.nebula/workflows/*.toml` | Name, purpose, ordered stages, AGENT selection, and timeout. | +| `.nebula/agents/*.toml` | Reusable provider, MODEL / EFFORT, and role instructions. A stage can also define these inline. | +| SQLITE STORE | Frozen resolved definition, assigned SESSIONS, current stage, and results for each run. | + +The initial prototype already accepted different stage lists through `--definition`. +The catalog adds discovery and composition; it feeds the same sequential DAEMON scheduler. +At kickoff, all references and NEBULA defaults resolve into a complete definition. The +DAEMON stores that snapshot, including its filename selector and display name. Edits to +either kind of file affect new runs only, even if a later stage has not started yet. MAKE DEV's initial database seed removes workflow runs and their SESSION associations, alongside live SESSIONS. It publishes the seed only after cleanup succeeds; development @@ -16,7 +24,8 @@ instances must never execute copied production workflows. ```mermaid flowchart LR - S[Kickoff skill on main] --> D[DAEMON] + F[Workflow TOML + AGENT TOML] --> S[Kickoff skill: select and inspect] + S --> D[DAEMON: freeze definition] D --> W[New WORKTREE from pinned main commit] W --> P[Planner SESSION] P --> R1[Report completed + FINISHED] @@ -28,14 +37,15 @@ flowchart LR ## Try it -This requires the prototype binary and DAEMON, PROTOCOL VERSION 39. The installed release -does not gain these commands until rebuilt. Test without replacing the live DAEMON: +Execution requires a matching binary and DAEMON, PROTOCOL VERSION 40. Older runs still +load from the SQLITE STORE without a schema migration. `catalog` and `inspect` only read +local files and work without a DAEMON. To try execution in a separate development instance: 1. Run the following from this checkout in a separate terminal. These dedicated paths avoid stopping an existing MAKE DEV instance for this checkout. Provider authentication and hooks still use your normal provider configuration. 2. Add this checkout as a PROJECT if absent, open an AGENT in its ROOT WORKTREE on `main`, - and invoke `/workflow-prototype <task>`. The skill is under `.claude/skills` and linked + and invoke `/nebula-workflow reviewed <task>`. The skill is under `.claude/skills` and linked into `.agents/skills` for both harnesses. 3. Inspect the new WORKTREE and SESSIONS in the TUI. Read `workflow status <run-id> --json` for the task, frozen definition, stage SESSION ids, and stored results. @@ -54,7 +64,7 @@ For example, from a kickoff SESSION whose working directory is this checkout: ```sh ./target/debug/nebula workflow start "Document the README setup steps" \ - --definition .nebula/workflow.json + --workflow default ``` The new WORKTREE starts from the committed `main` revision. Uncommitted changes in the @@ -62,25 +72,113 @@ ROOT WORKTREE, including this prototype's source, are not copied there. The runn DAEMON supplies the workflow instructions and stores the definition, so workers do not need a copy of the kickoff skill or configuration to participate. -## Configure AGENTS +## Watch progress in the TUI + +Press **Shift+O** to toggle the WORKFLOWS PANEL, between the WORKTREES PANEL and +SESSIONS PANEL. It is hidden by default; its visibility is saved in the Appearance +SETTING and its width is draggable. + +Each row shows the task, PROJECT, workflow definition, status, current step, next +step, and completed/remaining counts. Steps left includes the current unfinished +step. All unfinished runs in the open WORKSPACE appear first, including paused +and blocked runs, followed by the ten newest completed runs. + +The FOOTER shows `◆ N running · ⇧O` while this WORKSPACE has workflows or the +panel is open. Creating, running, and waiting runs count as active; paused, +blocked, and completed runs do not. Click the count to open the panel. +Use ↑/↓ to select a run and preview its WORKTREE/SESSION; Enter moves to the +SESSIONS PANEL. Background progress updates do not move FOCUS or switch your SESSION. + +MANAGED WORKFLOW worktrees carry a ◆ icon and a task label. New branch names use +the task, such as `workflow-fix-login`, with `-2`, `-3` for repeats. Existing +branches stay as they are. Generated SESSION labels show only their stage, such +as `planner`; explicit user renames are preserved. Internal run IDs and recovery +names remain stable in the SQLITE STORE. + +The DAEMON streams compact summaries with the initial snapshot and each persisted +transition. This requires PROTOCOL VERSION 41: restart the DEV INSTANCE to load it. +No separate polling process or state files are needed. + +## Create a workflow + +TOML is the authoring format: explicit sections, comments, and multiline instructions +keep these small configuration files easy to review. It also uses the same syntax as +the repository's Cargo manifests. Legacy JSON remains readable; YAML is not accepted. +The loader uses [`toml::from_str`](https://docs.rs/toml/latest/toml/fn.from_str.html) +with typed, strict deserialization. + +Create `.nebula/workflows/feature.toml`: + +```toml +version = 1 +name = "Feature implementation" +description = "Plan and implement a feature with attention to compatibility." +timeout_seconds = 1800 + +[[stages]] +id = "plan" +agent = "planner" # .nebula/agents/planner.toml +instructions = "Include compatibility risks in the plan." + +[[stages]] +id = "implement" +agent = { kind = "claude", model = "claude-sonnet-5", effort = "medium" } +instructions = """ +Implement the plan and run the relevant checks. +Report verification evidence and unresolved issues. +""" +``` -Edit [the definition](../.nebula/workflow.json). `kind` accepts `claude` or `codex` in this -prototype; `model` and `effort` are strings understood by the selected provider through -NEBULA's existing launch code. Null values use NEBULA defaults. Invalid or unavailable -provider/model combinations stop the run through the ordinary SESSION failure path. +The `feature` filename is the stable selector. `name` is an optional display label and +defaults to the filename; changing it does not change the selector. Different files may +share a label. `description` is optional but helps the skill choose between workflows. +The skill honors an explicit selector, otherwise matches descriptions to the task and +asks when plausible choices would change the outcome. + +No-selector behavior is deterministic: use `default.toml`, otherwise the sole TOML +definition. With multiple files and no `default.toml`, selection is required. The CLI +never guesses from alphabetical order. Discovery starts in the current directory and +walks up to the checkout root; it does not inherit another repository's `.nebula` folder. + +## Define reusable AGENTS + +Create `.nebula/agents/planner.toml`, or edit an existing role: + +```toml +kind = "claude" +model = "claude-sonnet-5" +effort = "medium" +instructions = """ +Investigate the task and write an actionable plan with acceptance criteria, +affected files, and verification steps. Leave product code unchanged. +""" +``` -The list supports 1 to 10 unique stage ids and a timeout of 10 to 86,400 seconds per -stage, including time waiting for feedback. To add the third AGENT, append: +The AGENT filename is the reference (`agent = "planner"`). AGENT files do not inherit +other AGENTS. A stage's `agent` is either that reference or an inline table containing +the same fields. These repository files are separate from the TUI's AGENT PRESETS. -```json -{ - "id": "reviewer", - "kind": "claude", - "model": null, - "effort": null, - "instructions": "Read the task, plan, implementation result, and diff from the recorded base commit. Review correctness and verification. Report completed with evidence when acceptable, or blocked with specific findings. Leave product code unchanged." -} -``` +| Field | Resolution | +|---|---| +| `kind` | Required in the referenced or inline AGENT. Supports `claude` and `codex`. | +| `model`, `effort` | Stage override, then AGENT value, then NEBULA default. If still absent, the provider CLI chooses. | +| `instructions` | AGENT instructions followed by stage instructions, separated by a blank line. At least one must supply text. | + +For example, put `effort = "high"` alongside a stage's `agent = "planner"` to override +that stage alone. MODEL / EFFORT strings reach the provider unchanged; exact IDs such as +`claude-sonnet-5` pin the selected version. Use `inspect` to see the final configuration. + +Workflow and AGENT selectors are lowercase filename slugs of up to 64 characters. +References stay within the AGENT directory; `../`, absolute references, and symlinks +escaping that directory are rejected. Explicit `--definition` paths may be elsewhere, +but standalone files must use inline AGENTS unless located under a `.nebula` directory. + +Validation rejects unknown fields, missing AGENTS, duplicate stage ids, unsupported kinds, +empty composed instructions, and bad bounds before contacting the DAEMON. Workflows have +1 to 10 stages, unique stage ids up to 40 characters, and a timeout of 10 to 86,400 seconds +per stage, including feedback time. Composed instructions are limited to 4,000 bytes; +workflow files to 64 KiB and AGENT files to 16 KiB. Provider access and model availability +are checked by the actual provider launch, not by the local preview. A reviewer rejection blocks the workflow. Automatic review/fix loops and parallel stages are outside this prototype. All stages share the same WORKTREE and see previous edits; @@ -88,24 +186,52 @@ each receives a fresh SESSION and reads previous results from the SQLITE STORE. ## Commands +The skill is optional: inside a NEBULA AGENT SESSION, call the CLI directly: + +```sh +./target/debug/nebula workflow start "Your task" --workflow reviewed +``` + +Kickoff currently requires `NEBULA_AGENT_ID`. The DAEMON looks up that registered SESSION +to find its PROJECT and WORKTREE, rejects archived or workflow-assigned callers, and +requires the ROOT WORKTREE on `main`. A normal terminal without that SESSION context +cannot start a run yet; there is no standalone PROJECT or repository selector. + +`catalog` and `inspect` work in any terminal without a DAEMON. `list`, `status <id>`, +`pause <id>`, and `resume <id>` need the matching DAEMON but no AGENT context. `report` +requires the assigned stage's AGENT SESSION. The initiating AGENT can finish its turn +after kickoff; the DAEMON owns subsequent scheduling. + All accept `--json`. [The complete existing CLI reference](commands.md) covers the rest of NEBULA; every visible command and subcommand also has its own `--help` page. | Command | Behavior | |---|---| -| `workflow start "task"` | Create a WORKTREE from `main`, freeze the definition, queue the first AGENT. Supports `--task-file` and `--definition`. Requires a kickoff SESSION in the ROOT WORKTREE. | +| `workflow catalog` | List this checkout's definitions, descriptions, stage order, and validation errors. Local read only. | +| `workflow inspect [selector]` | Preview resolved AGENTS, source files, exact MODEL / EFFORT, and composed instructions. Supports `--definition`. Local read only. | +| `workflow start "task" --workflow selector` | Create a WORKTREE from `main`, freeze the definition, queue the first AGENT. Supports `--task-file` for task input and `--definition` for an explicit configuration path. Requires a kickoff SESSION in the ROOT WORKTREE. | | `workflow status [id]` | Read one run; omitted id selects the caller's assigned workflow. JSON includes full artifacts. | -| `workflow list` | Show the 50 most recently updated runs across this DAEMON. | +| `workflow list` | Show runs across this DAEMON, most recently updated first. | | `workflow report --stage ID --outcome completed --file FILE --summary TEXT` | Store the current assigned AGENT's result and a copy of its Markdown file, maximum 64 KiB. | | `workflow report --stage ID --outcome blocked --summary TEXT` | Record a blocker; an artifact is optional. | | `workflow pause ID` | Stop scheduling; leave the current SESSION running. | | `workflow resume ID` | Continue a paused/blocked run after resolving the cause; renew the stage timeout. | -JSON replies are `{"Run": {...}}` or `{"List": [...]}`. These are prototype interfaces. +`catalog --json` returns an array; `inspect --json` returns the source path, description, +AGENT source paths, and resolved definition. Run command replies are `{"Run": {...}}` or +`{"List": [...]}`. These are prototype interfaces. The commands operate through the same local IPC trust boundary as other NEBULA commands. Caller SESSION ids identify assignments; they are not a security boundary against another process running as the same local user. +## Legacy JSON + +Existing flat stage definitions still load through `--definition path/to/workflow.json`. +If no TOML definitions exist, the old `.nebula/workflow.json` is also the default and +appears as `legacy` in the catalog. Use its explicit path rather than `--workflow legacy`. +When TOML files exist they take precedence. This checkout's former JSON file was replaced +by `workflows/default.toml` plus reusable AGENTS, preserving its two-stage sequence. + ## State and recovery MIGRATION 24 adds `workflow_runs` and `workflow_sessions` to the existing SQLITE STORE. @@ -150,6 +276,7 @@ prototype adds workflow IPC/CLI commands and a small DAEMON scheduler around the creation paths. It does not create another process manager or write SQLite from a skill. The source entry points are `nebula/src/cli.rs`, `nebula/src/workflow_cli.rs`, +`nebula/src/workflow_config.rs`, `nebula/src/workflow_config/format.rs`, `nebula-core/src/workflow.rs`, `nebula-daemon/src/workflow.rs`, `nebula-daemon/src/workflow/watcher.rs`, and `nebula-daemon/src/store/workflows.rs`. @@ -158,10 +285,14 @@ The source entry points are `nebula/src/cli.rs`, `nebula/src/workflow_cli.rs`, The focused tests use a real isolated DAEMON, SQLite database, git WORKTREE, and PTYs with STUB AGENTS. They exercise command submission, explicit-result gating, incorrect caller rejection, pause/resume, durable results, DAEMON restart, and reuse of the original SESSION. +Named workflow coverage changes/deletes the source files after kickoff and verifies later +stages still use the frozen configuration. Local CLI checks cover catalog/inspect without +a DAEMON, and persistence tests load snapshots created before workflow names existed. They do not establish that a real provider will follow every instruction without feedback. ```sh cargo test -p nebula-daemon workflow --lib +cargo test -p nebula workflow_config cargo test -p nebula --test help_cli cargo test -p nebula --test e2e_pty workflow_cli_handoffs ```