Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a793e70cc | |||
| 201eae68bb | |||
| 8fcef5a10b | |||
| 67edda77eb | |||
| 87b5e1c3db | |||
| 61443f4814 | |||
| bd829ba1c3 | |||
| 78f6e1be8e | |||
| 9f13769fb4 | |||
| 54299f119e | |||
| e31d261fd7 |
@@ -83,17 +83,18 @@ Do NOT jump to code. Understand the requirement first.
|
||||
- `ccam stage implementing`
|
||||
|
||||
### 2 — Pre-push CI gates + dev preflight (on the feature branch)
|
||||
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
|
||||
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
|
||||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||
|
||||
### 3 — E2E on the feature branch
|
||||
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
|
||||
|
||||
- `ccam stage e2e-feature --status running`
|
||||
- `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
|
||||
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
|
||||
- `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||
- `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||
- On failure: fix on the feature branch, commit, re-run from Stage 2.
|
||||
- **Iterating on a failing spec:** use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||
- **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||
- On success: `ccam stage e2e-feature-passed --status running`
|
||||
|
||||
### 4 — Code review *(no open PR yet — use local diff)*
|
||||
@@ -119,12 +120,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
|
||||
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
|
||||
- Parse its final line:
|
||||
- `VERDICT: GO` → proceed to Stage 8.
|
||||
- `VERDICT: NO-GO — <fixes>` → fix on the feature branch, re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||
- `VERDICT: NO-GO — <fixes>` → `ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
|
||||
|
||||
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
|
||||
- `ccam stage publishing --status running`
|
||||
- Re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||
- `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
|
||||
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
|
||||
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
|
||||
@@ -151,12 +152,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
|
||||
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
|
||||
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
|
||||
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
|
||||
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, resolve it as real work:
|
||||
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
|
||||
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
|
||||
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
|
||||
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||||
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
|
||||
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
|
||||
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
|
||||
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
|
||||
|
||||
@@ -1,28 +1,99 @@
|
||||
---
|
||||
name: ship-feature
|
||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates.
|
||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
|
||||
---
|
||||
|
||||
# Ship Feature
|
||||
|
||||
Use this workflow for medium or large implementation tasks.
|
||||
Use this workflow for medium or large implementation tasks. It does not restate
|
||||
how to plan, test, or review — the Superpowers skills own that. What lives here
|
||||
is the phase order, this repository's own rules, and the stage declaration at
|
||||
each boundary.
|
||||
|
||||
## Steps
|
||||
- Explore impacted modules first.
|
||||
- Write a short implementation plan before editing.
|
||||
- Implement smallest coherent diff that satisfies requirements.
|
||||
- Run relevant verification commands.
|
||||
- Update docs when commands, paths, architecture, or behavior changed.
|
||||
For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
|
||||
the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
|
||||
|
||||
## Required quality checks
|
||||
- Keep API and websocket contracts stable unless intentionally changed.
|
||||
- Keep destructive operations behind explicit guardrails.
|
||||
- Avoid broad refactors in feature tickets unless requested.
|
||||
## Declaring the stage
|
||||
|
||||
## Finish checklist
|
||||
- Tests/build/typecheck completed or explicitly reported as not run.
|
||||
- Changed file set is scoped and intentional.
|
||||
- User-facing docs updated if behavior changed.
|
||||
Each phase below starts with `ccam stage <node>`, which is what puts the phase
|
||||
on the lane's pipeline map. The nodes are the `default` template's:
|
||||
`intake → plan → implement → tests → review → gate → ship → done`.
|
||||
|
||||
`ccam stage` needs a lane owning the current directory. If it reports no lane,
|
||||
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
|
||||
with the workflow and skip the stage calls; they are reporting, not control
|
||||
flow. Never skip a phase because its stage call failed.
|
||||
|
||||
Declaring beats detection. The dashboard also *infers* these stages from the
|
||||
Superpowers skill invocations below, but an inference never renders `done` and
|
||||
never overrides a declaration — and detection cannot move a lane BACKWARD past
|
||||
a declared stage, so a rework loop is invisible unless you re-declare the phase
|
||||
you dropped back to.
|
||||
|
||||
## Phases
|
||||
|
||||
### intake — understand before touching anything
|
||||
- `ccam stage intake --status running`
|
||||
- Restate the requirement and its success criteria.
|
||||
- Explore the impacted modules: `Explore` agent for breadth, then read the key
|
||||
files yourself. Identify which layers are hit (server / client / mcp / docs /
|
||||
scripts) — `repo-onboarding` if the area is unfamiliar.
|
||||
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
|
||||
|
||||
### plan — a written plan, challenged before it is code
|
||||
- `ccam stage plan`
|
||||
- **superpowers:writing-plans**: approach, files to change, the test strategy
|
||||
(which behavior each test pins), and how each success criterion is met.
|
||||
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
|
||||
first. Root cause, not symptom — and grep every caller of the function you
|
||||
are about to change, not just the path the report names.
|
||||
|
||||
### implement — smallest coherent diff
|
||||
- `ccam stage implementing`
|
||||
- **superpowers:test-driven-development**: failing test → minimal code → green.
|
||||
- Smallest diff that satisfies the requirement. Every changed line traces to it.
|
||||
- **file-headers** applies to every source file you create or edit.
|
||||
|
||||
### tests — this repo's verification, not a claim
|
||||
- `ccam stage tests --status running`
|
||||
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
|
||||
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
|
||||
- A UI snapshot diff is reviewed, never blindly regenerated
|
||||
(`cd client && npx vitest run -u` only after you have read the diff).
|
||||
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
|
||||
`--result fail` with what failed. A step you could not run is reported as not
|
||||
run, never as passed.
|
||||
|
||||
### review — a real review pass, not a re-read
|
||||
- `ccam stage review`
|
||||
- Run the **code-review** skill on the working diff, or
|
||||
**superpowers:requesting-code-review** when handing it to an agent.
|
||||
- Apply what is worth applying with **superpowers:receiving-code-review**
|
||||
judgment: verify each point, neither blind agreement nor blind rejection.
|
||||
- Changed code in response? Re-run the `tests` phase.
|
||||
|
||||
### gate — evidence before the completion claim
|
||||
- **superpowers:verification-before-completion**. Commands actually run, output
|
||||
actually read. This is the phase that stops "should work" from shipping.
|
||||
- `ccam stage gate --evidence "<what was verified>"`
|
||||
|
||||
### ship — docs, then the user's call
|
||||
- `ccam stage ship`
|
||||
- **update-project-docs** — mandatory for any change to behavior, config,
|
||||
interfaces, events, schema, CLI commands, or features. Not optional, not
|
||||
deferred, not "if asked".
|
||||
- Commit / push / PR **only when the user asks in this turn**. Finishing an
|
||||
implementation is not authorization to commit.
|
||||
- `ccam stage done --status passed --evidence "<what shipped>"`
|
||||
|
||||
## This repository's own rules
|
||||
|
||||
Not restated here. `CLAUDE.md` is loaded in every session and already binds
|
||||
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
|
||||
migration-safe schema changes, the destructive-lane and git-argv guards, and
|
||||
the lane boundaries (the console never writes a stage; the runtime never writes
|
||||
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
|
||||
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
|
||||
|
||||
## References
|
||||
- Checklist template: `references/feature-checklist.md`
|
||||
|
||||
@@ -18,6 +18,10 @@ desktop/assets/icon.iconset/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Local lane profile - only present when this repo itself is adopted as a
|
||||
# lane (`ccam lanes add --cwd`); machine-specific runtime config, not source.
|
||||
/.ccam/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
+62
@@ -192,6 +192,7 @@ async function api(method, pathname, body, options = {}) {
|
||||
}
|
||||
const get = (p, b, options) => api("GET", p, undefined, options);
|
||||
const post = (p, b, options) => api("POST", p, b, options);
|
||||
const patch = (p, b, options) => api("PATCH", p, b, options);
|
||||
|
||||
/**
|
||||
* Print the standard "server is not running" indicator and exit 1. Every
|
||||
@@ -2262,6 +2263,43 @@ async function cmdLanesIntegration(args) {
|
||||
process.exitCode = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `ccam lanes add --pipeline` could only set
|
||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||
* was stuck on `default` with no way back to a 16-node template.
|
||||
*/
|
||||
async function cmdLanesPipeline(args) {
|
||||
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
|
||||
if (!resolved) return;
|
||||
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
);
|
||||
// Switching templates re-resolves the SAME declared stage string against a
|
||||
// different node list, so a stage that meant something in the old pipeline
|
||||
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
|
||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||
console.error(
|
||||
c.yellow(
|
||||
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
|
||||
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdFeatureShow(args) {
|
||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!slug) {
|
||||
@@ -2315,6 +2353,22 @@ async function cmdStage(args) {
|
||||
result: flag("result"),
|
||||
});
|
||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||
|
||||
// A stage name matching no node (nor alias) still stores — setStage takes the
|
||||
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
|
||||
// `current` and progress reads 0. Warn, never fail: a typo must not break a
|
||||
// declaration the pipeline can still record, but it must not pass silently.
|
||||
// Skipped for `--result fail`, which paints the node `failed` rather than
|
||||
// `current` and would otherwise look identical to an unknown stage.
|
||||
const nodes = lane.pipeline_nodes || [];
|
||||
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
|
||||
console.error(
|
||||
c.yellow(
|
||||
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
|
||||
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||
@@ -2425,6 +2479,11 @@ const COMMAND_GROUPS = [
|
||||
"[<path>]",
|
||||
"Validate a profile (path defaults to cwd, not a lane id)",
|
||||
],
|
||||
[
|
||||
"lanes pipeline",
|
||||
"[<template-id>] [<id>]",
|
||||
"Show, or switch, which pipeline template a lane renders against",
|
||||
],
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
@@ -3300,6 +3359,9 @@ async function runCommand(argv) {
|
||||
if (rest[0] === "integration") {
|
||||
return cmdLanesIntegration(rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "pipeline") {
|
||||
return cmdLanesPipeline(rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "gc") {
|
||||
return cmdLanesGc(rest.slice(1));
|
||||
}
|
||||
|
||||
@@ -1,24 +1,76 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
|
||||
* adopt), pick which of its branches to fork from, name the feature, and the
|
||||
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
||||
* — the dashboard invents the lane's own directory and branch name, the same
|
||||
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
|
||||
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
|
||||
* subscription in the Workspace page flips it to idle when the worktree is
|
||||
* actually ready, so this component does not poll.
|
||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||
* segmented toggle: "Repo" adopts an existing directory as-is via
|
||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
* manually-typed branch name. Either mode's path field can be filled by
|
||||
* typing, by the CwdAutocomplete suggestions, or by browsing
|
||||
* (`FolderBrowseModal`) — a native folder picker cannot hand a web page an
|
||||
* absolute filesystem path, so browsing is server-backed instead. The
|
||||
* worktree lane returned is `status: "provisioning"`: the route answers
|
||||
* before the actual `git worktree add` runs, so this component polls
|
||||
* `GET /api/lanes/:id` until that finishes before running the auto-setup
|
||||
* calls against a cwd that must actually exist on disk first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
* lane record, or `null` on timeout. */
|
||||
async function waitForProvisioned(
|
||||
laneId: number,
|
||||
{ intervalMs = 500, timeoutMs = 30000 } = {}
|
||||
): Promise<Lane | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const { lane } = await api.lanes.get(laneId);
|
||||
if (lane.status !== "provisioning") return lane;
|
||||
if (Date.now() >= deadline) return null;
|
||||
await new Promise((r) => window.setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||
function Seg({
|
||||
active,
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-pressed={active}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-accent text-white shadow-sm"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddLaneModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -27,20 +79,25 @@ export function AddLaneModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the newly provisioned (still-provisioning) lane. */
|
||||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||
onAdded: (lane: Lane) => void;
|
||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<{
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
@@ -48,40 +105,48 @@ export function AddLaneModal({
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setMode("worktree");
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setSetupResult(null);
|
||||
};
|
||||
|
||||
// The setup summary (below) is shown for a few seconds before the modal
|
||||
// auto-closes, so the user actually sees whether profile/agents/mcp
|
||||
// succeeded instead of the modal vanishing the instant the lane exists.
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const finishAndClose = useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
// The template a lane is created with is the ONLY chance to get it right
|
||||
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||
// Fetched on open (templates are file-backed and can change between opens).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
api.lanes
|
||||
.pipelines()
|
||||
.then((r) => {
|
||||
if (!cancelled) setPipelines(r.pipelines);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: the select just falls back to the single `default` option
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Look up the repo's branches once the path settles - debounced so every
|
||||
// keystroke while typing a path doesn't fire a request against a path that
|
||||
// isn't finished yet.
|
||||
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
|
||||
// never forks a branch.
|
||||
const lookedUpFor = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (mode !== "worktree") return;
|
||||
const path = sourceRepo.trim();
|
||||
if (!path) {
|
||||
setBranches(null);
|
||||
@@ -107,20 +172,54 @@ export function AddLaneModal({
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sourceRepo, t]);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
const name = title.trim();
|
||||
if (!repo || !branches || !name) return;
|
||||
if (!repo) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (mode === "repo") {
|
||||
try {
|
||||
const result = await api.lanes.ensure({
|
||||
cwd: repo,
|
||||
title: name || undefined,
|
||||
pipeline,
|
||||
});
|
||||
onAdded(result.lane);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branches || !name || !branch.trim()) return;
|
||||
try {
|
||||
const result = await api.lanes.worktree({
|
||||
sourceRepo: repo,
|
||||
title: name,
|
||||
base: base || undefined,
|
||||
branch: branch.trim(),
|
||||
pipeline,
|
||||
});
|
||||
onAdded(result.lane);
|
||||
|
||||
// POST /worktree returns as soon as the DB row exists (202) — the
|
||||
// actual `git worktree add` runs afterward, in the background, on the
|
||||
// server. Firing setup against the lane's cwd before that finishes
|
||||
// means agents/mcp write into a directory that doesn't exist yet, so
|
||||
// wait for provisioning to leave the "provisioning" status first.
|
||||
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||||
if (!provisionedLane || provisionedLane.status === "failed") {
|
||||
setBusy(false);
|
||||
setError(t("addLaneProvisionFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
@@ -147,9 +246,10 @@ export function AddLaneModal({
|
||||
});
|
||||
}
|
||||
|
||||
// Leave the modal open so the setup summary below stays on screen; the
|
||||
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||
// than racing a timer that can close before they've looked at it.
|
||||
setBusy(false);
|
||||
onAdded(result.lane);
|
||||
closeTimerRef.current = window.setTimeout(finishAndClose, 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
@@ -163,8 +263,21 @@ export function AddLaneModal({
|
||||
// into after the very first character. useCallback keeps the identity stable
|
||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||
const handleCancel = useCallback(() => {
|
||||
finishAndClose();
|
||||
}, [finishAndClose]);
|
||||
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
|
||||
// While the folder browser is open on top of it, that dismissal should
|
||||
// close only the browser, not both modals at once.
|
||||
if (browseOpen) {
|
||||
setBrowseOpen(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
|
||||
const disabled =
|
||||
!!setupResult ||
|
||||
!sourceRepo.trim() ||
|
||||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
@@ -174,22 +287,52 @@ export function AddLaneModal({
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!!setupResult || !sourceRepo.trim() || !title.trim() || !branches}
|
||||
disabled={disabled}
|
||||
onConfirm={submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={mode === "repo"}
|
||||
label={t("mode.repo")}
|
||||
title={t("mode.repoHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "worktree"}
|
||||
label={t("mode.worktree")}
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{t("addLaneRepoLabel")}
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
onChange={setSourceRepo}
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
onChange={setSourceRepo}
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrowseOpen(true)}
|
||||
title={t("browse.title")}
|
||||
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t("browse.button")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -205,7 +348,28 @@ export function AddLaneModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||
{t("addLanePipelineLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="add-lane-pipeline"
|
||||
value={pipeline}
|
||||
onChange={(e) => setPipeline(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||
(p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
@@ -228,10 +392,26 @@ export function AddLaneModal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{branchesError && !branches && (
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
@@ -270,6 +450,13 @@ export function AddLaneModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FolderBrowseModal
|
||||
open={browseOpen}
|
||||
initialPath={sourceRepo.trim() || undefined}
|
||||
onSelect={setSourceRepo}
|
||||
onClose={() => setBrowseOpen(false)}
|
||||
/>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
||||
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||
* up-one-level button and a click-to-descend list, since this tool is
|
||||
* local-first and the server already trusts arbitrary local paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
open,
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async (path?: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await api.lanes.browse(path));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load(initialPath);
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => load(entry.path)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,9 +180,17 @@ function since(sec: number | null): string {
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
childWorktrees,
|
||||
onSelectLane,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
||||
* when this lane is itself a source repo (typically an adopted one) that
|
||||
* other lanes were provisioned as worktrees from. */
|
||||
childWorktrees?: Lane[];
|
||||
/** Jumps the Workspace page's selection to another lane's card. */
|
||||
onSelectLane?: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
||||
@@ -452,6 +460,31 @@ export default function LaneCard({
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="lane-child-worktrees"
|
||||
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<div className="text-fg-muted">
|
||||
{t("worktrees.heading", { count: childWorktrees.length })}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{childWorktrees.map((w) => (
|
||||
<li key={w.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-400 hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file AddLaneModal.test.tsx
|
||||
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
|
||||
* repo instead of an existing folder: picking or typing a repo path triggers a
|
||||
* branch lookup, the base-branch picker only appears once that lookup resolves,
|
||||
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
|
||||
* an unresolvable path degrades to a quiet hint instead of blocking the form,
|
||||
* and a server error surfaces instead of closing the modal.
|
||||
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
|
||||
* pick a source repo, fork a branch, type a new branch name, submit through
|
||||
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
|
||||
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
|
||||
* unresolvable-path degrade, server-error handling, the auto-setup summary,
|
||||
* the provisioning-wait race, and the folder-browse modal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
branches: vi.fn(),
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -30,12 +34,17 @@ vi.mock("../../../lib/api", () => ({
|
||||
}));
|
||||
|
||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||
// base object below still lists all of them, so the cast asserts what the
|
||||
// literal already proves.
|
||||
return {
|
||||
id: 9,
|
||||
title: "",
|
||||
cwd: "/lanes/repo__feature",
|
||||
branch: "feat/feature",
|
||||
kind: "managed",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
@@ -59,7 +68,7 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||
slot: null,
|
||||
ports: {},
|
||||
...over,
|
||||
};
|
||||
} as Lane;
|
||||
}
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
@@ -81,19 +90,51 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
||||
await user.click(el);
|
||||
}
|
||||
|
||||
/** Fills the default "Worktree" mode's form up through a resolved branch
|
||||
* list, title, and new-branch name - everything Add lane needs to enable. */
|
||||
async function fillWorktreeForm(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
|
||||
) {
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, repo);
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), title);
|
||||
await user.type(screen.getByLabelText("New branch name"), branch);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.profileInit).mockResolvedValue({
|
||||
scaffolded: false,
|
||||
reason: "no detectable Node.js project",
|
||||
});
|
||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
// provisioning-in-progress race override this per-test.
|
||||
vi.mocked(api.lanes.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
|
||||
vi.mocked(api.lanes.profileInit)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
});
|
||||
|
||||
describe("AddLaneModal", () => {
|
||||
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
@@ -133,7 +174,7 @@ describe("AddLaneModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, not ensure", async () => {
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
@@ -141,11 +182,7 @@ describe("AddLaneModal", () => {
|
||||
renderModal({ onClose, onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
||||
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -153,13 +190,16 @@ describe("AddLaneModal", () => {
|
||||
sourceRepo: "/Users/tester/projects/repo",
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||
);
|
||||
// The setup summary stays on screen for a few seconds before auto-closing.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 });
|
||||
// The modal stays open showing the setup summary until dismissed - it
|
||||
// does not close itself just because the lane was added.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
@@ -169,11 +209,7 @@ describe("AddLaneModal", () => {
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
||||
await fillWorktreeForm(user, { title: "New feature" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
||||
@@ -194,11 +230,7 @@ describe("AddLaneModal", () => {
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
await fillWorktreeForm(user);
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
|
||||
@@ -208,7 +240,7 @@ describe("AddLaneModal", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("still calls onAdded and closes even when every setup call fails", async () => {
|
||||
it("still calls onAdded even when every setup call fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
@@ -221,18 +253,14 @@ describe("AddLaneModal", () => {
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "demo2");
|
||||
await fillWorktreeForm(user, { title: "demo2" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the setup summary and lets the user dismiss it early instead of waiting out the auto-close timer", async () => {
|
||||
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
@@ -241,11 +269,7 @@ describe("AddLaneModal", () => {
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "demo3");
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||
@@ -256,4 +280,162 @@ describe("AddLaneModal", () => {
|
||||
await user.click(dismissButton);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||
});
|
||||
// First poll still provisioning, second poll reports the background
|
||||
// `git worktree add` failed.
|
||||
vi.mocked(api.lanes.get)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo4" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
|
||||
expect(api.lanes.profileInit).not.toHaveBeenCalled();
|
||||
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
|
||||
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
it("hides the branch fields and enables confirm on a path alone", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/Users/tester/projects/repo");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
|
||||
expect(screen.queryByLabelText("New branch name")).toBeNull();
|
||||
expect(api.lanes.branches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits through ensure, not worktree, and closes immediately", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/Users/tester/projects/repo");
|
||||
await user.type(screen.getByLabelText("Title"), "main repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
|
||||
// Repo mode never runs the profile/agents/mcp setup summary - it should
|
||||
// close right away like the old adopt flow did.
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — folder browse", () => {
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
parent: "/Users",
|
||||
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
parent: null,
|
||||
entries: [],
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
await screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
|
||||
).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ import { DestructiveLaneModal } from "../DestructiveLaneModal";
|
||||
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||
// base object below still lists all of them, so the cast asserts what the
|
||||
// literal already proves.
|
||||
return {
|
||||
id: 1,
|
||||
title: "demo",
|
||||
@@ -50,7 +54,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
slot: null,
|
||||
ports: {},
|
||||
...overrides,
|
||||
};
|
||||
} as Lane;
|
||||
}
|
||||
|
||||
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard from "../LaneCard";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -48,12 +48,17 @@ vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||
// base object below still lists all of them, so the cast asserts what the
|
||||
// literal already proves.
|
||||
return {
|
||||
id: 1,
|
||||
title: "demo",
|
||||
cwd: "/work/demo",
|
||||
branch: "lane/demo",
|
||||
kind: "adopted",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
@@ -77,7 +82,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
slot: null,
|
||||
ports: {},
|
||||
...overrides,
|
||||
};
|
||||
} as Lane;
|
||||
}
|
||||
|
||||
describe("LaneCard status badge", () => {
|
||||
@@ -90,6 +95,31 @@ describe("LaneCard status badge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("LaneCard child worktrees", () => {
|
||||
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||
const onSelectLane = vi.fn();
|
||||
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ id: 1 })}
|
||||
onAction={vi.fn()}
|
||||
childWorktrees={[worktree]}
|
||||
onSelectLane={onSelectLane}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
||||
expect(onSelectLane).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
it("renders nothing when there are no child worktrees", () => {
|
||||
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
||||
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||
@@ -370,6 +400,9 @@ describe("LaneCard — named locks", () => {
|
||||
|
||||
describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
// touches here.
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
@@ -377,7 +410,7 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.ccam/profile",
|
||||
ports: {},
|
||||
});
|
||||
} as unknown as LaneRuntime);
|
||||
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||
Promise.resolve({ enabled: name === "tracker" })
|
||||
);
|
||||
|
||||
@@ -19,15 +19,33 @@
|
||||
"add": "Add lane",
|
||||
"addLane": "Create a lane from a working directory",
|
||||
"addLaneBaseLabel": "Branch to fork from",
|
||||
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
|
||||
"addLaneBranchLabel": "New branch name",
|
||||
"addLaneBranchPlaceholder": "feat/my-feature",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
|
||||
"addLanePipelineLabel": "Pipeline template",
|
||||
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
|
||||
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
|
||||
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
|
||||
"addLaneRepoLabel": "Source repository",
|
||||
"addLaneRepoLabelAdopt": "Directory",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupTitle": "Setup",
|
||||
"addLaneTitleLabel": "Title",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"browse.button": "Browse",
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.title": "Browse for a folder",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"cardId": "Lane {{id}}",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"action.clear": "dọn trạng thái",
|
||||
"action.forget": "xóa làn",
|
||||
"action.purge": "xóa lịch sử",
|
||||
"action.remove": "xóa làn + worktree",
|
||||
"action.reset": "đặt lại worktree",
|
||||
"action.start": "bắt đầu",
|
||||
"action.stop": "dừng",
|
||||
"action.clear": "Dọn trạng thái",
|
||||
"action.forget": "Xóa làn",
|
||||
"action.purge": "Xóa lịch sử",
|
||||
"action.remove": "Xóa làn + worktree",
|
||||
"action.reset": "Đặt lại worktree",
|
||||
"action.start": "Bắt đầu",
|
||||
"action.stop": "Dừng",
|
||||
"actionError": "Thao tác làn đường thất bại: {{message}}",
|
||||
"actionErrorUnknown": "Lỗi không xác định",
|
||||
"actions.agentsInstall": "Cài agent",
|
||||
@@ -19,15 +19,33 @@
|
||||
"add": "Thêm lane",
|
||||
"addLane": "Tạo lane từ một thư mục làm việc",
|
||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
||||
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
|
||||
"addLaneBranchLabel": "Tên nhánh mới",
|
||||
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
|
||||
"addLanePipelineLabel": "Mẫu pipeline",
|
||||
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
|
||||
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
|
||||
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
|
||||
"addLaneRepoLabel": "Repo nguồn",
|
||||
"addLaneRepoLabelAdopt": "Thư mục",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupTitle": "Thiết lập",
|
||||
"addLaneTitleLabel": "Tiêu đề",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"browse.button": "Duyệt",
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"cardId": "Làn đường {{id}}",
|
||||
|
||||
+32
-2
@@ -394,6 +394,7 @@ import type {
|
||||
LaneGitFacts,
|
||||
LaneRuntime,
|
||||
ModelPricing,
|
||||
NamedLock,
|
||||
Session,
|
||||
SessionDrillIn,
|
||||
SessionStats,
|
||||
@@ -1900,11 +1901,20 @@ export const api = {
|
||||
* @param body The cwd and optional title.
|
||||
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
|
||||
*/
|
||||
ensure: (body: { cwd: string; title?: string }) =>
|
||||
ensure: (body: { cwd: string; title?: string; pipeline?: string }) =>
|
||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
* @returns `{ pipelines }` — each with its `id`, display `name` and `nodes`.
|
||||
*/
|
||||
pipelines: () =>
|
||||
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
||||
"/lanes/pipelines"
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||
* @param repo Absolute path to an existing git repository.
|
||||
@@ -1922,11 +1932,31 @@ export const api = {
|
||||
* @param body The source repo, a slug/title, and the branch to fork from.
|
||||
* @returns `{ lane }` — the lane row created immediately, before provisioning finishes.
|
||||
*/
|
||||
worktree: (body: { sourceRepo: string; slug?: string; title?: string; base?: string }) =>
|
||||
worktree: (body: {
|
||||
sourceRepo: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
pipeline?: string;
|
||||
}) =>
|
||||
request<{ lane: Lane }>("/lanes/worktree", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/browse — list a directory's immediate subdirectories, for
|
||||
* the Add Lane modal's folder browser. Browsers cannot expose absolute
|
||||
* filesystem paths from a native picker, so this drives a server-backed one.
|
||||
* @param path Absolute path to list; defaults server-side to the home dir.
|
||||
* @returns `{ path, parent, entries }` — `parent` is null at the root.
|
||||
*/
|
||||
browse: (path?: string) =>
|
||||
request<{
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: { name: string; path: string; isGitRepo: boolean }[];
|
||||
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
|
||||
/**
|
||||
* GET /api/lanes/:id — fetch one lane.
|
||||
* @param id The lane id.
|
||||
|
||||
@@ -2319,6 +2319,9 @@ export interface Lane {
|
||||
cwd: string;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||
source_repo: string | null;
|
||||
pipeline: string;
|
||||
session_id: string | null;
|
||||
run_id: string | null;
|
||||
|
||||
@@ -1079,6 +1079,10 @@ export function Workspace() {
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||
childWorktrees={lanes.filter(
|
||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -24,6 +24,7 @@ type LaneFixture = {
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
active_feature_id?: number | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
@@ -583,7 +584,8 @@ describe("Workspace — proof gallery", () => {
|
||||
],
|
||||
});
|
||||
// Set active_feature_id and ensure features list is populated
|
||||
lanesToReturn[0].active_feature_id = 1;
|
||||
// (indexed access is `| undefined` under noUncheckedIndexedAccess)
|
||||
lanesToReturn[0]!.active_feature_id = 1;
|
||||
vi.mocked(api.lanes.features.list).mockResolvedValue({
|
||||
features: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -135,9 +135,14 @@ POST /api/lanes/ensure
|
||||
```
|
||||
|
||||
```json
|
||||
{ "cwd": "/absolute/path/to/work", "title": "App package" }
|
||||
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
|
||||
```
|
||||
|
||||
`pipeline` is optional and, like `title`, applies **only when a lane is
|
||||
actually created** — an existing lane is returned untouched. Omitted, the lane
|
||||
gets `default`. `POST /api/lanes/worktree` and `POST /api/lanes` take the same
|
||||
optional field. An id no template defines returns `400 EBADPIPELINE`.
|
||||
|
||||
Idempotent lookup by working directory — the Workspace page opens on a `cwd`,
|
||||
not on a lane id. Returns the lane whose own `cwd` is that path, or the longest
|
||||
path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`.
|
||||
@@ -159,6 +164,16 @@ PATCH /api/lanes/:id
|
||||
Partially updates a lane. This route is same-origin guarded because its patch
|
||||
can set `run_id`.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`ccam lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
to the default template when READING, so an unvalidated write would be
|
||||
accepted and then silently render the wrong map forever. Same 400 treatment as
|
||||
`EBADKIND`. Switching a template re-resolves the lane's existing declared
|
||||
`stage` string against the new node list — a stage the old template knew may
|
||||
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
|
||||
until the next `ccam stage`.
|
||||
|
||||
#### Provision a managed worktree
|
||||
|
||||
```http
|
||||
|
||||
+2
-1
@@ -227,9 +227,10 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
||||
| `ccam lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
|
||||
| `ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
|
||||
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
|
||||
| `ccam lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
||||
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary |
|
||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
|
||||
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
||||
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
||||
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
||||
|
||||
+154
-4
@@ -32,7 +32,18 @@ ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-f
|
||||
|
||||
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
|
||||
|
||||
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results for a few seconds (or until dismissed) before closing. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||||
Adopting your main repo (`ccam lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
|
||||
|
||||
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||||
|
||||
### The "+ Add lane" modal
|
||||
|
||||
The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
|
||||
- **Repo** — adopts the given directory as-is (maps to `ccam lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `ccam lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
## Destructive lane actions
|
||||
|
||||
@@ -409,6 +420,17 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
|
||||
|
||||
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
|
||||
|
||||
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `ccam lanes add --pipeline <id>` is the same choice from the terminal. Creation is the point that matters — a lane born on `default` renders 8 nodes for a 16-node workflow.
|
||||
|
||||
For a lane that already exists (including every lane created before the picker shipped):
|
||||
|
||||
```bash
|
||||
ccam lanes pipeline # which template this lane uses, and what else exists
|
||||
ccam lanes pipeline ship-feature # switch it
|
||||
```
|
||||
|
||||
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the command warns when that happens, and the next `ccam stage` fixes it.
|
||||
|
||||
The dashboard renders every node in the pipeline in one of five **states**:
|
||||
|
||||
| State | Color | Meaning |
|
||||
@@ -460,6 +482,20 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
|
||||
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
|
||||
- `done` / `complete` / `completed` / `merged`
|
||||
|
||||
Aliases are **per template** — the `ship-feature` pipeline has its own set
|
||||
(see "Pipeline template: ship-feature" below). A name matching no node and no
|
||||
alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing:
|
||||
no node renders `current` and progress reads `0`. The CLI prints a warning to
|
||||
stderr and still exits `0` in that case — a typo must not break a
|
||||
declaration the lane can record, but it must not pass silently either:
|
||||
|
||||
```
|
||||
! "revieww" matches no node in pipeline "default" — recorded, but the pipeline map won't show it. Nodes: intake, plan, implement, …
|
||||
```
|
||||
|
||||
The check is skipped for `--result fail`, which paints the node `failed`
|
||||
rather than `current`.
|
||||
|
||||
- `--lane <id>` (optional): the numeric lane ID. If omitted, the command resolves the lane by `cwd`.
|
||||
|
||||
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
|
||||
@@ -615,7 +651,11 @@ proof would let a lane's pipeline map lie about what actually happened.
|
||||
The node the agent *declared* itself on is never flagged as detected, so it
|
||||
keeps its blue `current` ring — including when the declaration came in through
|
||||
an alias (`ccam stage coding` resolves to the `implement` node, and `stages` is
|
||||
keyed by the raw declared word, not the node id).
|
||||
keyed by the raw declared word, not the node id). Past nodes get the same
|
||||
protection: `stageRecords` (`server/lib/pipelines.js`) resolves every recorded
|
||||
key back onto its node, so a stage declared by alias keeps its record — and its
|
||||
`--evidence` — instead of reading as an inference or losing its `done`. Where a
|
||||
node has both a canonical record and an alias record, the canonical one wins.
|
||||
|
||||
The same rule governs `ccam lanes`: the inferred stage is printed only when it
|
||||
leads the declared one (see "Viewing lanes" below), and `LaneCard.tsx`'s
|
||||
@@ -672,6 +712,23 @@ to the flattened input. Either way the result is capped at 120 characters
|
||||
lane's tooltip sometimes shows a file path or a skill name rather than a shell
|
||||
command: it's whichever of the fields above the matching rule's `tool` carried.
|
||||
|
||||
### Superpowers skill invocations
|
||||
|
||||
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship`
|
||||
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
|
||||
Superpowers workflow skill names (`brainstorming`/`writing-plans`,
|
||||
`executing-plans`/`subagent-driven-development`, `code-review`/
|
||||
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of
|
||||
these skills is a much stronger signal than a matched Bash command, but it is
|
||||
still detection, not declaration — it renders dashed amber and never `done`,
|
||||
same as every other detected stage.
|
||||
|
||||
Detection only ever attributes to a lane whose `cwd` matches the hook's
|
||||
session `cwd` (see "Which lane a signal is credited to" above). A session
|
||||
working directly in a source repo that was never itself adopted as a lane —
|
||||
run `ccam lanes add --cwd $(pwd)` from that repo to fix that — gets no
|
||||
detection at all, because no lane owns that `cwd`.
|
||||
|
||||
### Detection expires
|
||||
|
||||
Forward-only would otherwise park a lane at the highest stage it ever touched:
|
||||
@@ -744,8 +801,43 @@ accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
|
||||
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
|
||||
`docker push`, and `npm run push-docs`.
|
||||
|
||||
`intake`, `gate`, and `done` deliberately have no rules. `intake` is where a
|
||||
lane starts — there is no tool event that means "just claimed," so there is
|
||||
The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
|
||||
its own rules, aimed at the commands and Superpowers skills its driving skill
|
||||
actually runs:
|
||||
|
||||
| Node | Detect rules |
|
||||
|---|---|
|
||||
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `ccam … feature activate` |
|
||||
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md` |
|
||||
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
|
||||
| `gates` | `Bash` matching `ccam … hook ci-gate` or `ccam … sync-base --check` |
|
||||
| `e2e-feature` | `Bash` matching `ccam … up --qc` or `ccam … hook e2e` |
|
||||
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review` |
|
||||
| `qc` | `Agent` matching `qc-local`; `Bash` matching `ccam … lanes proof-link` |
|
||||
| `gate` | `Agent` matching `senior-gate-reviewer`; `Skill` matching `verification-before-completion` |
|
||||
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
|
||||
| `pr-open` | `Bash` matching `gh … pr create` |
|
||||
| `watching-pr` | `Bash` matching `gh … pr view` |
|
||||
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
|
||||
|
||||
Two rules are deliberately absent from this template. There is **no `git diff`
|
||||
rule on `review`**, for the reason the `default` template learned the hard way
|
||||
below; the `code-review` skill invocation is the honest signal. And `merged` /
|
||||
`done` carry no rule at all, because inference must never reach a terminal
|
||||
state — a test pins that.
|
||||
|
||||
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
|
||||
skill declares every one of these stages with `ccam stage` itself. What
|
||||
detection adds is the stage an agent forgot after a context compaction, the
|
||||
heartbeat that keeps a working lane from reading STALLED, and coverage for a
|
||||
lane running Superpowers skills without the driving skill at all. It cannot
|
||||
substitute for the skill's own declarations: because `recordDetection` is
|
||||
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
|
||||
that drops back to `gates` is invisible to detection — only the skill's
|
||||
`ccam stage gates` moves the lane back down.
|
||||
|
||||
`intake`, `gate`, and `done` in `default.json` deliberately have no rules.
|
||||
`intake` is where a lane starts — there is no tool event that means "just claimed," so there is
|
||||
nothing to detect. `gate` and `done` are explicitly out of scope for
|
||||
inference (see the task's "Out of scope" list): a gate's pass/fail is a human
|
||||
or skill decision, and `done` is the one state detection must never reach on
|
||||
@@ -1123,6 +1215,64 @@ The skill uses the `ship-feature` pipeline template, which defines the following
|
||||
|
||||
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
|
||||
|
||||
### More stage names than nodes
|
||||
|
||||
Each node carries aliases, and they do two different jobs. Some absorb a
|
||||
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
|
||||
the skill declares to say *why* the lane is sitting on a node, without adding a
|
||||
node to the map.
|
||||
|
||||
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
|
||||
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
|
||||
read at a glance across many lanes at once, so it stays coarse; the stage name
|
||||
is read one lane at a time, so it can be specific.
|
||||
|
||||
| Node | Near-miss aliases | Sub-states the skill declares |
|
||||
|---|---|---|
|
||||
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
|
||||
| `plan` | `planning`, `brainstorm`, `design` | — |
|
||||
| `implementing` | `implement`, `coding`, `build` | — |
|
||||
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
|
||||
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
|
||||
| `e2e-feature-passed` | `e2e-passed` | — |
|
||||
| `review` | `reviewing`, `code-review`, `self-review` | — |
|
||||
| `qc-plan` | — | — |
|
||||
| `qc` | — | — |
|
||||
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
|
||||
| `publishing` | `push` | — |
|
||||
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
|
||||
| `reported` | — | — |
|
||||
| `watching-pr` | — | `pr-comment-fix` |
|
||||
| `merged` | — | — |
|
||||
| `done` | `complete`, `completed` | — |
|
||||
|
||||
50 names over 16 nodes. Every one of them has a **source**: the skill declares
|
||||
it, or `default.json` uses it (an agent moving between pipelines will type
|
||||
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
|
||||
no source is not "flexibility" — it is a synonym someone imagined, and the
|
||||
`ccam stage` warning already catches a name that resolves to nothing, which is
|
||||
better feedback than silently absorbing every plausible spelling. Twelve
|
||||
sourceless aliases were written and then cut for exactly this reason.
|
||||
|
||||
Two constraints on this:
|
||||
|
||||
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
|
||||
FIRST match, so a duplicate would silently resolve a declaration onto the
|
||||
wrong node. A test asserts the whole template is collision-free.
|
||||
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
|
||||
declared string, and `stageRecords` resolves each key onto its node — so
|
||||
declaring `migration-collision` and then `gates` leaves ONE record for that
|
||||
node (the canonical `gates` one wins). A sub-state that needs to keep its own
|
||||
`--evidence` separately has to be a real node, not an alias.
|
||||
|
||||
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
|
||||
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
|
||||
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
|
||||
names into the surviving phases so old feature cards still render. CCAM never
|
||||
shipped them, so there is nothing to fold.
|
||||
|
||||
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
|
||||
|
||||
## Orchestration: what CCAM does NOT do
|
||||
|
||||
**CCAM does not chain, queue, retry, or evaluate gates.**
|
||||
|
||||
Generated
+1
-4
@@ -8,7 +8,7 @@
|
||||
"name": "agent-dashboard",
|
||||
"version": "1.4.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
@@ -33,9 +33,6 @@
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hoangsonww"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.0.0"
|
||||
}
|
||||
|
||||
@@ -488,6 +488,68 @@ describe("managed worktree provisioning", () => {
|
||||
const missing = await request("GET", `/api/lanes/${lane.id}`);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
|
||||
it("uses a caller-supplied branch name instead of deriving one from the slug", async () => {
|
||||
const created = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo: SRC,
|
||||
title: "Custom Branch",
|
||||
base: "main",
|
||||
branch: "custom/my-branch",
|
||||
});
|
||||
|
||||
assert.equal(created.status, 202);
|
||||
assert.equal(created.body.lane.branch, "custom/my-branch");
|
||||
const lane = await waitForProvisioning(created.body.lane.id);
|
||||
assert.equal(lane.status, "idle");
|
||||
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch");
|
||||
});
|
||||
|
||||
it("rejects an invalid caller-supplied branch name before creating anything", async () => {
|
||||
const response = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo: SRC,
|
||||
title: "Bad Branch",
|
||||
base: "main",
|
||||
branch: "not a valid branch..name",
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(response.body.error.code, "EBADBRANCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/browse", () => {
|
||||
it("lists a directory's immediate subdirectories, marking git repos", async () => {
|
||||
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.path, ROOT);
|
||||
const names = response.body.entries.map((e) => e.name);
|
||||
assert.ok(names.includes("src-repo"));
|
||||
const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo");
|
||||
assert.equal(srcRepoEntry.isGitRepo, true);
|
||||
});
|
||||
|
||||
it("reports the parent directory, or null at the filesystem root", async () => {
|
||||
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
assert.equal(response.body.parent, path.dirname(ROOT));
|
||||
|
||||
const rootResponse = await request("GET", "/api/lanes/browse?path=/");
|
||||
assert.equal(rootResponse.body.parent, null);
|
||||
});
|
||||
|
||||
it("rejects a path that does not exist or is not a directory", async () => {
|
||||
const missing = await request(
|
||||
"GET",
|
||||
`/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}`
|
||||
);
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.error.code, "ENOTFOUND");
|
||||
|
||||
const filePath = path.join(ROOT, "a-file.txt");
|
||||
fs.writeFileSync(filePath, "hi\n");
|
||||
const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`);
|
||||
assert.equal(notADir.status, 400);
|
||||
assert.equal(notADir.body.error.code, "ENOTADIR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
@@ -29,7 +29,30 @@ const profileLib = require("../lib/lane-profile");
|
||||
const runtime = require("../lib/lane-runtime");
|
||||
const { isListening } = require("../lib/ports");
|
||||
|
||||
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
/**
|
||||
* Lanes whose boot hook actually spawned a detached process. `harness_spawn`
|
||||
* children outlive the hook by design, so a test that throws before its own
|
||||
* `downLane` — or never calls one — leaves a real server holding a real port
|
||||
* AFTER the suite exits. The port pool is only ten wide per name, so ten such
|
||||
* runs exhaust it and every later boot fails with EPORTBUSY: a leak in these
|
||||
* tests reads as a failure in whichever test runs next.
|
||||
*/
|
||||
const bootedLanes = new Set();
|
||||
|
||||
// One teardown, in this order on purpose: downLane reads the pid files under
|
||||
// LANES_ROOT, which lives inside SUITE_ROOT — removing the tree first would
|
||||
// leave nothing to kill the children with.
|
||||
after(async () => {
|
||||
for (const id of bootedLanes) {
|
||||
try {
|
||||
const lane = lanesLib.getLane(id);
|
||||
if (lane) await runtime.downLane(lane);
|
||||
} catch {
|
||||
/* already stopped, or torn down by the test itself */
|
||||
}
|
||||
}
|
||||
fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
let laneSeq = 0;
|
||||
/** A lane row backed by a real directory, so profile lookup and mkdir work. */
|
||||
@@ -274,6 +297,7 @@ describe("lifecycle", () => {
|
||||
const lane = makeLane();
|
||||
serverProfile(lane, 19100);
|
||||
|
||||
bootedLanes.add(lane.id);
|
||||
const facts = await runtime.upLane(lanesLib.getLane(lane.id));
|
||||
assert.equal(facts.available, true);
|
||||
assert.equal(facts.up, true);
|
||||
@@ -301,6 +325,7 @@ describe("lifecycle", () => {
|
||||
assert.equal(afterDown.up, false);
|
||||
assert.deepEqual(afterDown.services, []);
|
||||
|
||||
bootedLanes.delete(lane.id);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
@@ -377,12 +402,22 @@ describe("upLane qc option", () => {
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
bootedLanes.add(lane.id);
|
||||
await runtime.upLane(lanesLib.getLane(lane.id), { qc: true });
|
||||
const booted = lanesLib.getLane(lane.id);
|
||||
const markerPath = pathMod.join(booted.cwd, "qc-marker.txt");
|
||||
const marker = fsMod.readFileSync(markerPath, "utf8");
|
||||
assert.match(marker, /QC=from-qc/);
|
||||
|
||||
// Stop the stack BEFORE releasing the slot: downLane locates the pid file
|
||||
// through the lane's slot directory, so a released slot orphans a running
|
||||
// child with no way left to reach it. Asserting the port went quiet is what
|
||||
// makes a broken teardown fail HERE instead of leaking a live server into
|
||||
// the next run's port pool.
|
||||
const bootedPort = booted.ports.web;
|
||||
await runtime.downLane(lanesLib.getLane(lane.id));
|
||||
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
||||
bootedLanes.delete(lane.id);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
@@ -408,12 +443,17 @@ describe("upLane qc option", () => {
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
bootedLanes.add(lane.id);
|
||||
await runtime.upLane(lanesLib.getLane(lane.id));
|
||||
const booted = lanesLib.getLane(lane.id);
|
||||
const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt");
|
||||
const marker = fsMod.readFileSync(markerPath, "utf8");
|
||||
assert.match(marker, /QC=not-set/);
|
||||
|
||||
const bootedPort = booted.ports.web;
|
||||
await runtime.downLane(lanesLib.getLane(lane.id));
|
||||
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
||||
bootedLanes.delete(lane.id);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,6 +186,31 @@ describe("ccam stage", () => {
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
||||
});
|
||||
|
||||
it("warns, but still records, a stage name matching no pipeline node", async () => {
|
||||
// setStage stores the string verbatim, so a typo'd stage is accepted and
|
||||
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
|
||||
// a lane ends up looking unstarted for an entire pipeline run.
|
||||
const r = await cli(["stage", "revieww"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /revieww/);
|
||||
assert.match(r.stderr, /matches no node/i);
|
||||
assert.match(r.stderr, /revieww/);
|
||||
});
|
||||
|
||||
it("does not warn for a stage declared by alias", async () => {
|
||||
const r = await cli(["stage", "planning"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
|
||||
it("does not warn for a valid stage reported as failed", async () => {
|
||||
// --result fail paints the node `failed`, not `current`; the warning must
|
||||
// not read that as an unknown stage.
|
||||
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ccam lanes add", () => {
|
||||
@@ -248,6 +273,35 @@ describe("ccam lanes add", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
||||
// `--pipeline` was creation-only, so every lane added from the dashboard
|
||||
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
||||
const show = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.equal(show.status, 0, show.stderr);
|
||||
assert.match(show.stdout, /default/);
|
||||
assert.match(show.stdout, /available:.*ship-feature/);
|
||||
|
||||
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
assert.equal(set.status, 0, set.stderr);
|
||||
assert.match(set.stdout, /ship-feature/);
|
||||
assert.match(set.stdout, /16 nodes/);
|
||||
|
||||
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
assert.equal(back.status, 0, back.stderr);
|
||||
assert.match(back.stdout, /8 nodes/);
|
||||
});
|
||||
|
||||
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
||||
// getPipeline() returns the default template for an unknown id, so without
|
||||
// a write-side check a typo would store, render `default`, and look fine.
|
||||
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
||||
|
||||
const after = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.match(after.stdout, /default/);
|
||||
});
|
||||
|
||||
it("provisions a managed worktree lane and reports it ready", async () => {
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||
|
||||
@@ -77,6 +77,44 @@ describe("pipelines", () => {
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
});
|
||||
|
||||
it("keeps the evidence of a stage declared under an alias", () => {
|
||||
// `lane.stages` is keyed by the raw declared string, so `ccam stage
|
||||
// planning --evidence x` files the record under "planning" while the node
|
||||
// is "plan". Looking it up by node id alone loses the evidence and paints
|
||||
// a `done` node amber — silently, and only for agents that use an alias.
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("prefers a node-id record over an alias record for the same node", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: {
|
||||
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
|
||||
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
|
||||
},
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("ignores a recorded stage matching no node instead of shifting the others", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
assert.equal(byId.review, "current");
|
||||
});
|
||||
|
||||
it("computes progress from node position, 0 for an unknown stage", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
|
||||
|
||||
@@ -482,4 +482,138 @@ test.describe("ship-feature pipeline template", () => {
|
||||
const ids = pipeline.nodes.map((n) => n.id);
|
||||
assert.deepEqual(ids, [...new Set(ids)]);
|
||||
});
|
||||
|
||||
test.it("no alias collides with another node's id or with a second node's alias", () => {
|
||||
// phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
|
||||
// silently resolves a declaration onto the wrong node — the failure mode
|
||||
// aliases are supposed to prevent.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const seen = new Map();
|
||||
for (const n of pipeline.nodes) {
|
||||
for (const name of [n.id, ...n.aliases]) {
|
||||
const key = name.toLowerCase();
|
||||
assert.equal(
|
||||
seen.has(key),
|
||||
false,
|
||||
`"${name}" claimed by both ${seen.get(key)} and ${n.id}`
|
||||
);
|
||||
seen.set(key, n.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Same discipline as default.json's end-to-end rule sweep: a rule pinned only
|
||||
// against a fixture can ship inert, and these fire on a live lane's hooks.
|
||||
test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const cases = [
|
||||
["intake", "Skill", { skill: "superpowers:brainstorming" }],
|
||||
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
|
||||
["plan", "Skill", { skill: "superpowers:writing-plans" }],
|
||||
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
|
||||
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
|
||||
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
|
||||
["implementing", "Edit", { file_path: "server/lib/app.js" }],
|
||||
["implementing", "Write", { file_path: "server/lib/app.js" }],
|
||||
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
|
||||
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
|
||||
["review", "Skill", { skill: "code-review" }],
|
||||
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
|
||||
["qc", "Agent", { subagent_type: "qc-local" }],
|
||||
["qc", "Bash", { command: "ccam lanes proof-link" }],
|
||||
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
|
||||
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
|
||||
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
|
||||
["publishing", "Bash", { command: "git push -u origin feat/foo" }],
|
||||
["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
|
||||
["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
|
||||
];
|
||||
for (const [nodeId, tool_name, tool_input] of cases) {
|
||||
const got = detect(pipeline, { tool_name, tool_input });
|
||||
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("every sub-state the skill declares resolves onto its node", () => {
|
||||
// These names exist so a skill can say WHY a lane sits on a node without
|
||||
// the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
|
||||
// names over 13 nodes). An unresolvable one is worse than no alias: it
|
||||
// records, renders nowhere, and only warns.
|
||||
const { reload, phaseIdx } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const subStates = {
|
||||
bootstrapping: "intake",
|
||||
"migration-collision": "gates",
|
||||
"sync-conflict": "gates",
|
||||
booting: "e2e-feature",
|
||||
"e2e-scoped": "e2e-feature",
|
||||
live: "e2e-feature",
|
||||
"gate-blocked": "gate",
|
||||
"push-conflict": "pr-open",
|
||||
"push-revalidate": "pr-open",
|
||||
"pr-comment-fix": "watching-pr",
|
||||
};
|
||||
for (const [declared, nodeId] of Object.entries(subStates)) {
|
||||
assert.equal(
|
||||
phaseIdx(pipeline, declared),
|
||||
pipeline.nodes.findIndex((n) => n.id === nodeId),
|
||||
`${declared} must resolve to ${nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not stamp implementing for an edit to the lane spec", () => {
|
||||
// Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
|
||||
// Without the exclusion that edit reads as code work, and because
|
||||
// `implementing` sits early in the pipeline the mis-read is only invisible
|
||||
// by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const tool_name of ["Edit", "Write"]) {
|
||||
const r = detect(pipeline, {
|
||||
tool_name,
|
||||
tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
|
||||
});
|
||||
assert.notEqual(r && r.nodeId, "implementing", tool_name);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("never infers a terminal stage — merged and done are declaration-only", () => {
|
||||
// CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
|
||||
// for these nodes would be the one way to break that from the data side.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
|
||||
const node = pipeline.nodes.find((n) => n.id === id);
|
||||
assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const command of [
|
||||
'git commit -m "do not push this yet"',
|
||||
"ccam lanes logs 3 e2e",
|
||||
"ccam stage gates --status running",
|
||||
"gh pr diff 42",
|
||||
]) {
|
||||
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
|
||||
assert.equal(
|
||||
["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
|
||||
false,
|
||||
`${command} → ${r && r.nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"build"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "executing-plans|subagent-driven-development"
|
||||
},
|
||||
{
|
||||
"tool": "Edit"
|
||||
},
|
||||
@@ -118,6 +122,10 @@
|
||||
"push"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "finishing-a-development-branch"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b"
|
||||
|
||||
@@ -2,21 +2,257 @@
|
||||
"id": "ship-feature",
|
||||
"name": "Ship feature (lane pipeline)",
|
||||
"nodes": [
|
||||
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": [] },
|
||||
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": [] },
|
||||
{ "id": "implementing", "label": "implement (TDD)", "icon": "🛠", "gate": false, "aliases": [] },
|
||||
{ "id": "gates", "label": "CI gates + preflight", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "e2e-feature", "label": "e2e on feature branch", "icon": "🧪", "gate": false, "aliases": [] },
|
||||
{ "id": "e2e-feature-passed", "label": "e2e passed", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "review", "label": "code review", "icon": "👀", "gate": true, "aliases": [] },
|
||||
{ "id": "qc-plan", "label": "QC plan", "icon": "📋", "gate": false, "aliases": [] },
|
||||
{ "id": "qc", "label": "browser QC", "icon": "🔍", "gate": true, "aliases": [] },
|
||||
{ "id": "gate", "label": "senior GO/NO-GO gate", "icon": "🚦", "gate": true, "aliases": [] },
|
||||
{ "id": "publishing", "label": "publish PR", "icon": "🔀", "gate": false, "aliases": [] },
|
||||
{ "id": "pr-open", "label": "PR open", "icon": "🔀", "gate": false, "aliases": ["ship"] },
|
||||
{ "id": "reported", "label": "reported", "icon": "📣", "gate": false, "aliases": [] },
|
||||
{ "id": "watching-pr", "label": "watching PR", "icon": "👁", "gate": false, "aliases": [] },
|
||||
{ "id": "merged", "label": "merged — post-verify", "icon": "🔗", "gate": false, "aliases": [] },
|
||||
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed"] }
|
||||
{
|
||||
"id": "intake",
|
||||
"label": "intake",
|
||||
"icon": "📝",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"assigned",
|
||||
"claimed",
|
||||
"start",
|
||||
"bootstrapping"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "brainstorming"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\bfeature activate\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"label": "plan",
|
||||
"icon": "🧭",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"planning",
|
||||
"brainstorm",
|
||||
"design"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "writing-plans"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "docs/superpowers/specs/lane-.*\\.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "implementing",
|
||||
"label": "implement (TDD)",
|
||||
"icon": "🛠",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"implement",
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "test-driven-development|executing-plans|subagent-driven-development|systematic-debugging"
|
||||
},
|
||||
{
|
||||
"tool": "Edit",
|
||||
"match": "^(?!.*docs/superpowers/specs/)"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "^(?!.*(?:^|/)docs/)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gates",
|
||||
"label": "CI gates + preflight",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"pre-push-gate",
|
||||
"tests",
|
||||
"migration-collision",
|
||||
"sync-conflict"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature",
|
||||
"label": "e2e on feature branch",
|
||||
"icon": "🧪",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"e2e",
|
||||
"e2e-scoped",
|
||||
"booting",
|
||||
"live"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature-passed",
|
||||
"label": "e2e passed",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"e2e-passed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "code review",
|
||||
"icon": "👀",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"reviewing",
|
||||
"code-review",
|
||||
"self-review"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "code-review|requesting-code-review|receiving-code-review"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qc-plan",
|
||||
"label": "QC plan",
|
||||
"icon": "📋",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "qc",
|
||||
"label": "browser QC",
|
||||
"icon": "🔍",
|
||||
"gate": true,
|
||||
"aliases": [],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "qc-local"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\blanes proof-link\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
"label": "senior GO/NO-GO gate",
|
||||
"icon": "🚦",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"sr-gate",
|
||||
"verify",
|
||||
"verification",
|
||||
"gate-blocked"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "senior-gate-reviewer"
|
||||
},
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "verification-before-completion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "publishing",
|
||||
"label": "publish PR",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"push"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "finishing-a-development-branch"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pr-open",
|
||||
"label": "PR open",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"ship",
|
||||
"pr",
|
||||
"push-conflict",
|
||||
"push-revalidate"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr create\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reported",
|
||||
"label": "reported",
|
||||
"icon": "📣",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "watching-pr",
|
||||
"label": "watching PR",
|
||||
"icon": "👁",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"pr-comment-fix"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr view\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "merged",
|
||||
"label": "merged — post-verify",
|
||||
"icon": "🔗",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"label": "done",
|
||||
"icon": "✅",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"complete",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+28
-3
@@ -10,7 +10,14 @@
|
||||
*/
|
||||
|
||||
const { db } = require("../db");
|
||||
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines");
|
||||
const {
|
||||
listPipelines,
|
||||
getPipeline,
|
||||
phaseIdx,
|
||||
stageRecords,
|
||||
nodeStates,
|
||||
progressPct,
|
||||
} = require("./pipelines");
|
||||
|
||||
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
|
||||
/**
|
||||
@@ -88,6 +95,18 @@ function validateKind(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `getPipeline` falls back to the default template for an unknown id — correct
|
||||
* when READING (a lane must always render something), wrong when WRITING: a
|
||||
* typo'd id would be accepted, stored, and then silently draw the default map
|
||||
* forever. Reject it at the write, where the caller can still be told.
|
||||
*/
|
||||
function validatePipeline(id) {
|
||||
if (!listPipelines().some((p) => p.id === id)) {
|
||||
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(row) {
|
||||
if (!row) return null;
|
||||
let stages = {};
|
||||
@@ -125,6 +144,7 @@ function createLane({
|
||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||
}
|
||||
validateKind(kind);
|
||||
validatePipeline(pipeline);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -156,6 +176,9 @@ function updateLane(id, patch = {}) {
|
||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
||||
validateKind(patch.kind);
|
||||
}
|
||||
if ("pipeline" in patch && patch.pipeline !== null && patch.pipeline !== undefined) {
|
||||
validatePipeline(patch.pipeline);
|
||||
}
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
@@ -441,14 +464,16 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
|
||||
* its own id: declaring by ALIAS (`ccam stage coding` → the `implement` node)
|
||||
* keys `stages` by the raw declared string, so the node the agent says it is on
|
||||
* would otherwise render as an inference instead of the blue `current` ring.
|
||||
* Past nodes go through `stageRecords`, which resolves those alias keys — a
|
||||
* node the agent DECLARED must never be painted as merely detected.
|
||||
*/
|
||||
function withDetected(states, pipeline, lane) {
|
||||
const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
|
||||
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
|
||||
const stages = lane.stages || {};
|
||||
const records = stageRecords(pipeline, lane.stages);
|
||||
return states.map((n, i) => ({
|
||||
...n,
|
||||
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current",
|
||||
detected: i <= detectedIdx && !records.has(i) && n.state !== "current",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -77,6 +77,27 @@ function phaseIdx(pipeline, stage) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every recorded stage by the node it resolves to, so a stage declared
|
||||
* under an ALIAS keeps its record — and therefore its evidence. Keying the
|
||||
* records by node id alone (what `lane.stages` is keyed by, verbatim from the
|
||||
* declaration) silently drops `--evidence` the moment an agent says `e2e`
|
||||
* instead of `e2e-feature`, which is exactly what aliases exist to allow.
|
||||
* A record stored under the node's own id always wins over an alias record for
|
||||
* the same node; a key matching no node is skipped.
|
||||
*/
|
||||
function stageRecords(pipeline, stages) {
|
||||
const byIdx = new Map();
|
||||
for (const [key, rec] of Object.entries(stages || {})) {
|
||||
const i = phaseIdx(pipeline, key);
|
||||
if (i === -1) continue;
|
||||
const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase();
|
||||
if (byIdx.has(i) && !isCanonical) continue;
|
||||
byIdx.set(i, rec);
|
||||
}
|
||||
return byIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render state per node:
|
||||
* failed — the stage recorded result "fail"
|
||||
@@ -86,10 +107,10 @@ function phaseIdx(pipeline, stage) {
|
||||
* pending — not reached
|
||||
*/
|
||||
function nodeStates(pipeline, lane) {
|
||||
const stages = lane.stages || {};
|
||||
const records = stageRecords(pipeline, lane.stages);
|
||||
const cur = phaseIdx(pipeline, lane.stage);
|
||||
return pipeline.nodes.map((n, i) => {
|
||||
const rec = stages[n.id];
|
||||
const rec = records.get(i);
|
||||
let state;
|
||||
if (rec && rec.result === "fail") state = "failed";
|
||||
else if (i === cur) state = "current";
|
||||
@@ -111,6 +132,7 @@ module.exports = {
|
||||
listPipelines,
|
||||
getPipeline,
|
||||
phaseIdx,
|
||||
stageRecords,
|
||||
nodeStates,
|
||||
progressPct,
|
||||
reload,
|
||||
|
||||
+17
-5
@@ -78,6 +78,21 @@ async function isGitRepo(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `name` is a legal git branch name, per git's own rules rather than
|
||||
* a hand-rolled regex. `cwd` need not be `sourceRepo` specifically — the
|
||||
* check is not repo-dependent — but `git()` requires a directory to run in.
|
||||
*/
|
||||
async function isValidBranchName(cwd, name) {
|
||||
if (typeof name !== "string" || !name) return false;
|
||||
try {
|
||||
await git(cwd, ["check-ref-format", "--branch", name]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List a repo's local branches plus its current HEAD branch, so a caller can
|
||||
* offer a real picker instead of asking someone to remember a branch name.
|
||||
@@ -85,11 +100,7 @@ async function isGitRepo(dir) {
|
||||
* can actually check a new worktree out onto without a fetch first.
|
||||
*/
|
||||
async function listBranches(sourceRepo) {
|
||||
const result = await git(sourceRepo, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
]);
|
||||
const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
|
||||
const branches = result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -643,6 +654,7 @@ module.exports = {
|
||||
LANES_ROOT,
|
||||
git,
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
resolveBase,
|
||||
slugify,
|
||||
|
||||
+67
-8
@@ -10,6 +10,7 @@
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
@@ -25,6 +26,7 @@ const {
|
||||
addWorktree,
|
||||
gitFacts,
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
removeWorktree,
|
||||
resetWorktree,
|
||||
@@ -159,11 +161,15 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
const owner = lanesLib.resolveLaneByCwd(body.cwd);
|
||||
if (owner) return res.json({ lane: payload(owner), created: false });
|
||||
try {
|
||||
const lane = lanesLib.createLane({ cwd: body.cwd, title: body.title || "" });
|
||||
const lane = lanesLib.createLane({
|
||||
cwd: body.cwd,
|
||||
title: body.title || "",
|
||||
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
||||
});
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({ lane: payload(lane), created: true });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD") {
|
||||
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
|
||||
@@ -193,6 +199,47 @@ router.post("/gc", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Directory listing for the Add Lane modal's folder browser — browsers cannot
|
||||
* expose absolute filesystem paths from a native picker, so path selection is
|
||||
* done by browsing server-side instead. Read-only; registered ahead of
|
||||
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
||||
*/
|
||||
router.get("/browse", (req, res) => {
|
||||
const raw =
|
||||
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
|
||||
const resolved = path.resolve(raw);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
|
||||
}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs
|
||||
.readdirSync(resolved, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
||||
.map((e) => {
|
||||
const full = path.join(resolved, e.name);
|
||||
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
// An unreadable entry mid-listing is skipped, not a request failure.
|
||||
}
|
||||
|
||||
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
|
||||
res.json({ path: resolved, parent, entries });
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
@@ -205,7 +252,7 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD") {
|
||||
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
@@ -225,9 +272,10 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` is invalid input, not a server fault — every sibling route
|
||||
// answers 400 here, so this one must too instead of throwing into Express.
|
||||
if (err.code === "EBADKIND") {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
@@ -503,8 +551,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
|
||||
const branch = `${branchPrefix}${slug}`;
|
||||
let branch;
|
||||
if (body.branch !== undefined) {
|
||||
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
|
||||
});
|
||||
}
|
||||
branch = body.branch;
|
||||
} else {
|
||||
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
|
||||
branch = `${branchPrefix}${slug}`;
|
||||
}
|
||||
let lane;
|
||||
try {
|
||||
lane = lanesLib.createLane({
|
||||
@@ -515,6 +573,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
source_repo: resolvedSourceRepo,
|
||||
base_branch: body.base || null,
|
||||
slug,
|
||||
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
||||
});
|
||||
lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user