@webpressvn/lane
v0.3.2
Published
Parallel development lanes: one git worktree, one port block, one forked database and one pm2 process group per task.
Readme
lane
Parallel development lanes. One lane = one git worktree + one port block + one forked database + one pm2 process per service.
Built for running several tasks (or several AI coding agents) against one monorepo at the same time, without them colliding on ports, processes, or schema.
pnpm lane new crm-fix # worktree, config, ports, forked database, dependencies
pnpm lane up crm-fix # start every service under pm2
pnpm lane ls # what exists, what is serving
pnpm lane down crm-fix # stop, and free the ports for real
pnpm lane rm crm-fix # stop + drop the database + remove the worktreelane 0 main worktree api 3000 web 4000 hieupv_webpress_app
lane 1 .worktrees/<name> api 3100 web 4100 hieupv_webpress_app_l1
lane 2 .worktrees/<name> api 3200 web 4200 hieupv_webpress_app_l2Why each piece exists
The worktree isolates the code. The port block lets two servers run at once.
The forked database lets a lane run its own migrations, which is the reason lanes cannot simply
share one schema. pm2 owns the processes so ls can tell you what is actually running
and down can stop it.
The awkward part is that a new worktree is broken on arrival: the files that carry the
database credentials and the API origin are gitignored, so git worktree add does not bring
them. lane new copies and patches them, which is most of what it does.
git worktree add also leaves every submodule as an empty directory, so a repository
that keeps a library in one does not compile in a fresh lane. lane new runs
git submodule update --init --recursive in the new worktree whenever a .gitmodules
exists, and warns instead of failing the lane when that command does not succeed.
Removal is the mirror image: git worktree remove refuses outright once .gitmodules
exists ("working trees containing submodules cannot be moved or removed"), populated or
not, so lane rm passes --force for those repositories. It costs no safety, because
the gate that protects real work is lane rm's own dirty-path check, which looks only at
that worktree and still refuses without --force.
Why the API command carries --skip-nx-cache
libs/server/config/src/environments/environment.ts is gitignored, so Nx leaves it out of the build
hash. Without the flag a lane that patched its own environment.ts (fork database, lane ports) can be
served from a cached dist/apps/server/main.js built for the main lane: the lane API then reads and
writes the MAIN database and registers its RBAC presets there. Keep the flag on api.command (and on
any custom command that builds the server) until the build target lists environment.ts as an input.
Commands
| Command | What it does |
|---|---|
| lane new <name> | Worktree → copy gitignored config → allocate ports → fork database → patch config → install |
| lane up [name] | Start every service under pm2. Idempotent, so it also means restart |
| lane down [name] | Stop the services, then sweep the lane's ports |
| lane ls | Every lane: branch, database, ports, process state |
| lane logs [name] | Stream pm2 logs (--service, --lines) |
| lane rm <name> | Full cleanup: processes, ports, database, worktree, lane index |
| lane describe | MCP-shaped tool manifest for agents |
[name] defaults to main (the main worktree, lane 0), so lane up with no argument
manages your primary checkout like any other lane.
Useful flags: --no-fork-db (share the source database), --schema-only (fork structure
without rows, much faster), --keep-db on rm, --dry-run on up/down,
--branch / --base on new.
For agents
pnpm -s lane describe --json prints an MCP-shaped manifest: every command with a
description, JSON Schema for its inputs, the exact shell template to call, and what it
returns. There is no MCP server: an agent reads the manifest and shells out.
- Always pass
--json, and always run pnpm with-s. Human output goes to stderr and one JSON object goes to stdout; without-s, pnpm prints its own banner onto stdout and the JSON will not parse. - Success is exit
0with{"ok":true,...}; failure is exit1with{"ok":false,"error":...}. lane logsis the exception: it streams raw pm2 output and never terminates on its own.
pnpm -s lane ls --json # find a free lane / see what is serving
pnpm -s lane new my-task --json # returns ports, database, path
pnpm -s lane up my-task --json # returns the health url to poll
pnpm -s lane up my-task --dry-run --json # print the pm2 commands, change nothinglane up returns before the servers are ready (the task runner is still compiling). Poll
the returned health url until it responds.
Configuration
lane.config.json at the repository root overrides any subset of the shipped defaults
(lane.config.default.json). Ports, services, the files to copy and the patches to apply
are all data, so adding a third served process or a new file to patch is a config edit
rather than a code change.
{
"ports": { "stride": 100 }, // lane N: portBase + stride * N
"services": [
{ "name": "api", "portBase": 3000, "command": "nx serve server",
"env": { "PORT": "{port}" }, "healthPath": "/api/health" }
],
"pm2": { "prefix": "wp", "runner": "pnpm" },
"database": { "fork": true, "nameTemplate": "{source}_l{index}", "dropOnRemove": true },
"files": [
{ "path": "apps/frontend/.env", "required": true, "format": "dotenv",
"patches": [{ "key": "NEXT_PUBLIC_API_BASE_URL", "value": "http://localhost:{port:api}/api" }] }
]
}Templates understand {index}, {name}, {database}, {source}, {port} (the current
service's) and {port:<service>}. Patch formats are ts-object (key: 'value' in a
TypeScript literal) and dotenv (KEY=value).
State lives in .worktrees/lanes.json, the lane database, holding each lane's index,
ports, branch, database and pm2 names. Indexes come from that file rather than from
position in git worktree list, because removing a worktree would otherwise renumber the
survivors while their processes are still bound to the old ports.
Implementation notes
The database fork uses mysql2, not mysqldump. Source and target live on the same
server, so CREATE TABLE … LIKE + INSERT … SELECT copies server-side with no dump
crossing the wire. It also sidesteps MySQL client 9.x, which dropped
mysql_native_password and cannot authenticate against servers that still require it.
Routines and triggers are not copied; lane new warns when the source has any.
The copy runs in two phases, never interleaved. planCopy (src/core/copy-plan.ts, pure
and unit-tested) emits every CREATE TABLE … LIKE as phase one and every INSERT … SELECT as
phase two; forkDatabase awaits a phase completely before starting the next, and only the
batches inside a phase run in parallel. The earlier single flat list sliced into 25-statement
batches let a worker run a table's INSERT while another worker still held its CREATE, which
failed every data fork with Table <db>_l1.attendance_departmentSchedule doesn't exist.
Tables with a generated column get an explicit column list. INSERT … SELECT * refuses a
value for a STORED/VIRTUAL GENERATED column (23 CRM tables carry one, e.g.
crm_account.codeActiveKey), so the plan reads information_schema.COLUMNS and leaves those
columns out of both lists; the server recomputes them on insert.
Expect the fork to take a minute or two. Per-table DDL on a remote server dominates:
a 413-table schema takes ~40s spread across COPY_CONCURRENCY connections, against ~2m40s
on a single one. --schema-only skips the rows when a lane only needs somewhere to run
migrations.
down sweeps ports, it does not just stop processes. pm2 stops what it spawned, but
the package-manager → task-runner → node chain beneath it can survive and keep holding the
port. The sweep is scoped to the lane's own ports, so it can never touch another lane.
new rolls back. Anything that can fail without side effects is checked before the
worktree exists; anything created after that is undone if a later step throws.
Running E2E on a lane
The Playwright suite is lane-aware end to end, so a lane runs it against its own servers and its own database while lane 0 keeps serving:
pnpm -s lane new e2e-finance --json # worktree + ports + forked database
pnpm -s lane up e2e-finance --json # poll the returned health url until it answers
cd .worktrees/e2e-finance
pnpm exec playwright test --config apps/frontend/playwright.config.ts tests/e2e/finance
pnpm exec playwright test --config apps/frontend/playwright.config.ts # the whole suite
cd - && pnpm lane rm e2e-financeWhat makes that work, and what to check when it does not:
- Ports.
apps/frontend/tests/e2e/helpers/lane.tsreads the worktree's.lane.json(written bylane new) and derivesbaseURLand the API origin from the lane index; a plain checkout has no.lane.jsonand stays on 3000/4000. Never hardcode a port in a spec. - Login.
tests/e2e/global-setup.tssigns every role in through the id-service API from Node (tests/e2e/helpers/session.ts) and hands the browser the three cookies the login form would set. The form itself cannot log in from a lane: the shared id-service allows the originhttp://localhost:4000only, so its preflight for:4100fails with "Failed to fetch". The id-service URL comes fromapps/frontend/.env(NEXT_PUBLIC_AWS_API_URL, copied bylane new) orE2E_ID_SERVICE_URLin.env.e2e; on a lane the setup refuses to start without one (on lane 0 it falls back to driving the login form, the pre-lane behaviour). - Credentials are read from
apps/frontend/.env.e2e(gitignored, copied bylane new; the template is.env.e2e.example). Nothing is hardcoded in a spec or helper. e2eOrganizationIdmust be set inenvironment.ts(gitignored, solane newcopies the one on your machine and a new lane already has it; a fresh MACHINE has to fill it in). Set it to the same value asE2E_ORG_IDin.env.e2e. Global setup callsPOST /application/debug/finance/wipeonce per run to clear that organization's finance data, because every finance flow works in its own fresh fiscal year and never gives it back: after four or five runs the range up to the 2037 ceiling is used up and EVERY finance flow fails at once, with a message about fiscal years that has nothing to do with what it was testing. Unset means the endpoint refuses (403) and the wipe is skipped with a warning, which is survivable for one run. Leave it empty on any machine holding real books: it is the second lock behind theproductionflag.- Gitignored keys.
apps/server/src/assets/nats/certs/client.key(andca.key) are ignored by git, so a fresh worktree has none;apps/server/src/main.tsreadsclient.keyat boot and the lane's API dies without it. Both are listed as optional entries in the repository'slane.config.jsonfiles(and in the shippedlane.config.default.json), solane newcopies them when the main worktree has them. Arrays inlane.config.jsonreplace the defaults wholesale: keep the two entries when you edit that list. - Data. Each finance flow works in its own fresh fiscal year, so a lane that already holds data or a period closed by the previous run never blocks the next one; run RPC-heavy flows one lane at a time until the NATS bus is per-lane (below).
Resetting a lane database in place
lane rm then lane new gives the lane a new index, new ports and a new database name. When other sessions are
configured against a lane, reset its database in place instead:
docs/ai/testing/lane-db-reset.md (stop, drop and re-fork under the same
name with dropDatabase and forkDatabase from src/core/database.ts, delete the module's test data, then migrate,
start and seed).
Known constraint: the NATS bus
A lane gets its own database but still shares the message bus, so another lane's server can
answer its RPCs against a different database. Set nats.enabled in lane.config.json (and
install nats-server) to give each lane its own broker; until then, run RPC-heavy flows one
lane at a time. lane new warns about this whenever it forks a database. The CRM posting E2E
flows (crm-order-revenue-posting, crm-billing-revenue, finance-posting-retry) are such flows:
see docs/ai/design/finance/finance.md «E2E fiscal years».
Requirements
- Node >= 20, git, pm2 (
npm i -g pm2) - A MySQL-compatible server reachable with an account that can
CREATE/DROP DATABASE
