Worktree commands have one orchestration, shared by the CLI and the dashboard #477

Open
opened 2026-08-28 19:51:48 +00:00 by mph · 1 comment
Owner

Problem

Worktree commands have two orchestrators, and only one of each pair is
maintained. The CLI and the dashboard each re-derive the sequence of a
command — the order of steps, the checks, and what runs alongside them — from
the shared pieces in core. The pieces are shared; the orchestration is not.

The two mutating dashboard endpoints solve this in opposite, ad-hoc ways:

  • Create (/api/worktrees/create, CaskServer.scala:1224) re-wires the
    steps in-process. WorktreeCreationService.create calls IssueId.parse,
    builds a WorktreePath, then invokes createWorktree,
    createTerminalWorkspace and registerWorktree as injected lambdas.
  • Remove (/api/worktrees/:issueId/remove, CaskServer.scala:1361)
    spawns the CLI. WorktreeRemovalService builds
    Seq("rm", key, "--path", …, "--no-prompt") and hands it to IwCli.run
    (WorktreeRemovalService.scala:64-70, CaskServer.scala:1373), then reads
    an exit code.

Neither reuses Start.run or Rm.run, because both are CLI entry points:
argv in, prose out through env.console, CommandResult back
(core/commands/Start.scala:24). A web handler needs typed inputs and typed
failures, so the create path invented its own WorktreeCreationError ADT and
the remove path settled for exit codes.

What has already drifted

WorktreeCreationService is behind iw start in three ways:

  • No effort worktrees. WorktreeCreationService.scala:73-76 hardcodes
    WorktreeKey.Issue; Start.scala:122 builds WorktreeKey.Effort(slug).
    An effort worktree cannot be created from the browser at all.
  • No hooks. Start.scala:194,248,271 runs env.hooks.runSessionHooks.
    The dashboard path has no equivalent, so a worktree created from the browser
    silently skips every project session hook.
  • A self-declared stopgap path. :82 is
    fullPath = s"../${worktreePath.directoryName}", under the comment
    "For now, we construct a relative path string that will be resolved by the
    caller."

Each divergence appeared because a change landed in Start.scala and nothing
forced the copy to follow.

Two justifications for the subprocess that do not hold

Checked while analysing IW-476, so they are not re-litigated later:

  • Per-project iw versions. Not in use. All 58 .iw/config.conf files
    across the local project tree read version = latest, and
    ~/.local/share/iw/versions holds exactly one directory (latest), so every
    project resolves the same binary. iw-bootstrap is an installer/launcher —
    read a version, download to a shared cache if absent, exec iw-run — not a
    per-project component boundary.
  • A globally mutable working directory in iw rm. Overstated in
    IwCli.scala's scaladoc. Rm.scala:222-233 uses os.dynamicPwd.withValue,
    a scoped thread-local restored even when a hook throws, so concurrent server
    requests could not corrupt one another.

What does survive is crash isolation for hook code: a project hook that
hangs or calls System.exit would take the server with it. IwCli contains
that with a 2-minute timeout. That is a narrow concern about hooks, not a
reason for a whole command to live behind a process boundary.

Outcome

  • One orchestration per command, called in-process by both the CLI and the
    dashboard. A change to what iw start does reaches the browser with no
    second edit.
  • The dashboard can create effort worktrees, and worktrees created from the
    browser run the project's session hooks.
  • iw start and the dashboard produce identical worktrees, including the
    herdr sidebar label (the divergence IW-476 accepted rather than papered over).

Design sketch

Extract each command's orchestration into a function with typed input and a
typed result — the functional core the project already claims — and give it two
shells:

  • Start.run / Rm.run render the typed result to a console and a
    CommandResult.
  • The dashboard renders the same typed result to an HTTP response.

Then WorktreeCreationService and WorktreeRemovalService collapse into thin
adapters, and WorktreeCreationError becomes the shared failure type rather
than a dashboard-only invention.

Hook execution is the one step that may keep an isolation boundary. Decide
during the work whether it stays in-process (with a timeout) or is the only
thing that is still spawned.

Scope

  • core/commands/Start.scala, core/commands/Rm.scala
  • dashboard/jvm/src/application/WorktreeCreationService.scala (+ its 17 tests)
  • dashboard/jvm/src/application/WorktreeRemovalService.scala
  • dashboard/jvm/src/infrastructure/IwCli.scala — kept, reduced, or removed
  • dashboard/jvm/src/CaskServer.scala:1224,1361

MainProjectService is read-only derivation and is out of scope. The two
mutating endpoints above are the whole dashboard command surface today; the
same rule should govern any command the dashboard drives later.

Notes

  • Raised while resolving IW-476's clarifications. IW-476 deliberately does
    not thread its new workspace label through WorktreeCreationService,
    because that orchestrator is dissolved here. Until this lands, a worktree
    created from the browser carries the long <project>-<key> herdr label while
    one created by iw start carries the short key — two conventions in the same
    sidebar. That is the accepted cost recorded in IW-476's analysis.
  • CaskServer.scala:1279 will pass sessionName twice after IW-476, which is
    a greppable marker of the unconverted path.
## Problem Worktree commands have two orchestrators, and only one of each pair is maintained. The CLI and the dashboard each re-derive the *sequence* of a command — the order of steps, the checks, and what runs alongside them — from the shared pieces in `core`. The pieces are shared; the orchestration is not. The two mutating dashboard endpoints solve this in opposite, ad-hoc ways: - **Create** (`/api/worktrees/create`, `CaskServer.scala:1224`) re-wires the steps in-process. `WorktreeCreationService.create` calls `IssueId.parse`, builds a `WorktreePath`, then invokes `createWorktree`, `createTerminalWorkspace` and `registerWorktree` as injected lambdas. - **Remove** (`/api/worktrees/:issueId/remove`, `CaskServer.scala:1361`) spawns the CLI. `WorktreeRemovalService` builds `Seq("rm", key, "--path", …, "--no-prompt")` and hands it to `IwCli.run` (`WorktreeRemovalService.scala:64-70`, `CaskServer.scala:1373`), then reads an exit code. Neither reuses `Start.run` or `Rm.run`, because both are CLI entry points: argv in, prose out through `env.console`, `CommandResult` back (`core/commands/Start.scala:24`). A web handler needs typed inputs and typed failures, so the create path invented its own `WorktreeCreationError` ADT and the remove path settled for exit codes. ## What has already drifted `WorktreeCreationService` is behind `iw start` in three ways: - **No effort worktrees.** `WorktreeCreationService.scala:73-76` hardcodes `WorktreeKey.Issue`; `Start.scala:122` builds `WorktreeKey.Effort(slug)`. An effort worktree cannot be created from the browser at all. - **No hooks.** `Start.scala:194,248,271` runs `env.hooks.runSessionHooks`. The dashboard path has no equivalent, so a worktree created from the browser silently skips every project session hook. - **A self-declared stopgap path.** `:82` is `fullPath = s"../${worktreePath.directoryName}"`, under the comment "For now, we construct a relative path string that will be resolved by the caller." Each divergence appeared because a change landed in `Start.scala` and nothing forced the copy to follow. ## Two justifications for the subprocess that do not hold Checked while analysing IW-476, so they are not re-litigated later: - **Per-project iw versions.** Not in use. All 58 `.iw/config.conf` files across the local project tree read `version = latest`, and `~/.local/share/iw/versions` holds exactly one directory (`latest`), so every project resolves the same binary. `iw-bootstrap` is an installer/launcher — read a version, download to a shared cache if absent, `exec iw-run` — not a per-project component boundary. - **A globally mutable working directory in `iw rm`.** Overstated in `IwCli.scala`'s scaladoc. `Rm.scala:222-233` uses `os.dynamicPwd.withValue`, a scoped thread-local restored even when a hook throws, so concurrent server requests could not corrupt one another. What does survive is **crash isolation for hook code**: a project hook that hangs or calls `System.exit` would take the server with it. `IwCli` contains that with a 2-minute timeout. That is a narrow concern about hooks, not a reason for a whole command to live behind a process boundary. ## Outcome - One orchestration per command, called in-process by both the CLI and the dashboard. A change to what `iw start` does reaches the browser with no second edit. - The dashboard can create effort worktrees, and worktrees created from the browser run the project's session hooks. - `iw start` and the dashboard produce identical worktrees, including the herdr sidebar label (the divergence IW-476 accepted rather than papered over). ## Design sketch Extract each command's orchestration into a function with typed input and a typed result — the functional core the project already claims — and give it two shells: - `Start.run` / `Rm.run` render the typed result to a console and a `CommandResult`. - The dashboard renders the same typed result to an HTTP response. Then `WorktreeCreationService` and `WorktreeRemovalService` collapse into thin adapters, and `WorktreeCreationError` becomes the shared failure type rather than a dashboard-only invention. Hook execution is the one step that may keep an isolation boundary. Decide during the work whether it stays in-process (with a timeout) or is the only thing that is still spawned. ## Scope - `core/commands/Start.scala`, `core/commands/Rm.scala` - `dashboard/jvm/src/application/WorktreeCreationService.scala` (+ its 17 tests) - `dashboard/jvm/src/application/WorktreeRemovalService.scala` - `dashboard/jvm/src/infrastructure/IwCli.scala` — kept, reduced, or removed - `dashboard/jvm/src/CaskServer.scala:1224,1361` `MainProjectService` is read-only derivation and is out of scope. The two mutating endpoints above are the whole dashboard command surface today; the same rule should govern any command the dashboard drives later. ## Notes - Raised while resolving IW-476's clarifications. IW-476 deliberately does **not** thread its new workspace label through `WorktreeCreationService`, because that orchestrator is dissolved here. Until this lands, a worktree created from the browser carries the long `<project>-<key>` herdr label while one created by `iw start` carries the short key — two conventions in the same sidebar. That is the accepted cost recorded in IW-476's analysis. - `CaskServer.scala:1279` will pass `sessionName` twice after IW-476, which is a greppable marker of the unconverted path.
Member

Intake: needs-human
Workflow: waterfall
Reason: G4 the analysis proposes a change to a document a human owns: a paragraph in CLAUDE.md "Dashboard layers" (analysis.md:378, Documentation Requirements)
Analysis: project-management/issues/IW-477/analysis.md on branch IW-477

Intake: needs-human Workflow: waterfall Reason: G4 the analysis proposes a change to a document a human owns: a paragraph in `CLAUDE.md` "Dashboard layers" (analysis.md:378, Documentation Requirements) Analysis: project-management/issues/IW-477/analysis.md on branch IW-477
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/iw-cli#477
No description provided.