vinta-ai-workflows 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/package.json +1 -1
- package/schemas/vinta-ai-workflows-config.v1.schema.json +7 -1
- package/skills/vinta-bootstrap-ai-tools/SKILL.md +4 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/SKILL.md +84 -12
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/gen-compose-worktree-override.sh +211 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,89 @@ All notable changes to `vinta-ai-workflows` are documented in this file.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
6
6
|
the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] — 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`prepare-worktree` — data-corruption bug: `COMPOSE_PROJECT_NAME` alone does
|
|
13
|
+
NOT isolate a per-worktree docker-compose stack, so worktrees could run a
|
|
14
|
+
second database server against the main checkout's data volume.** Step 4
|
|
15
|
+
assumed `COMPOSE_PROJECT_NAME` fully isolates a compose stack. It does not for
|
|
16
|
+
two very common patterns: (1) volumes declared `external: true` and/or pinned
|
|
17
|
+
to a fixed top-level `name:` — the project name namespaces containers and
|
|
18
|
+
networks but NOT these volumes, so every worktree's `db` service mounted the
|
|
19
|
+
**same physical volume**; and (2) fixed host port bindings
|
|
20
|
+
(`ports: - "5432:5432"`) — published to the same host port across compose
|
|
21
|
+
projects. On a Django + docker-compose project (2026-07-17) this ran two
|
|
22
|
+
Postgres postmasters against one PGDATA for a whole test run; when the
|
|
23
|
+
worktree stack was `docker compose down`'d, its Postgres removed the shared
|
|
24
|
+
`postmaster.pid` and the **main checkout's database self-terminated**
|
|
25
|
+
(recovered on restart — luck, not safety). The fix also corrects a deeper
|
|
26
|
+
conceptual error: Step 3a's "share the DB (don't fork)" is now explicit that
|
|
27
|
+
*share* means share a **connection to a single already-running server** — for
|
|
28
|
+
a compose-delivered DB the data volumes are **always forked, independent of
|
|
29
|
+
`schema_change` / `test_db_strategy`**, because the worktree runs its own
|
|
30
|
+
server and two servers on one volume is corruption.
|
|
31
|
+
|
|
32
|
+
**User impact / migration:** existing worktrees provisioned before this fix
|
|
33
|
+
are **unsafe to boot their DB compose stack while the main stack is up** —
|
|
34
|
+
they share main's data volume. For an already-provisioned worktree, regenerate
|
|
35
|
+
the override without re-running the whole skill:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ai-tools/skills/prepare-worktree/scripts/gen-compose-worktree-override.sh \
|
|
39
|
+
--main <main-checkout-root> --worktree <worktree-path> --name <name> \
|
|
40
|
+
--out <summary_dir>/<name>.docker-compose.override.yml
|
|
41
|
+
echo "COMPOSE_FILE=<base-compose-file>:<override-path>" >> <worktree>/.env
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then confirm the worktree's resolved DB volume name differs from main's before
|
|
45
|
+
booting its stack. **Consumers**: re-sync (`vinta-sync-ai-tools`) to pick up
|
|
46
|
+
the new script, the reworked Step 4/Step 3a, and the `shared_volumes` config.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`prepare-worktree` bundled script
|
|
51
|
+
`scripts/gen-compose-worktree-override.sh`.** Detects the compose isolation
|
|
52
|
+
leaks above entirely from `docker compose config --format json` (no project /
|
|
53
|
+
service / volume name hardcoded) and emits a **generated, out-of-tree**
|
|
54
|
+
compose override that re-pins every `external:`/fixed-`name:` volume to a
|
|
55
|
+
non-external, worktree-namespaced volume (`<project>_wt_<name>_<volkey>`) and
|
|
56
|
+
strips fixed host-port publishing (via compose's `!override []` — a plain
|
|
57
|
+
`ports: []` merges/appends in Compose v2+; verified on Docker Compose v5.1.1).
|
|
58
|
+
The override is written under `<summary_dir>/` and wired in via
|
|
59
|
+
`COMPOSE_FILE=<base>:<override>` appended to the worktree's **copied** `.env`,
|
|
60
|
+
so **no tracked compose file is ever mutated**. Volumes safe to keep shared
|
|
61
|
+
(a read-only dep/venv cache) are exempted via `--share-volume`.
|
|
62
|
+
- **`skills.prepare-worktree.shared_volumes` config field** (array of compose
|
|
63
|
+
volume keys, default `[]`). Lists volumes safe to keep shared across worktrees
|
|
64
|
+
— read-only dep/venv caches only, never a data/DB volume. Feeds
|
|
65
|
+
`gen-compose-worktree-override.sh --share-volume`. Added to the config schema
|
|
66
|
+
and the bootstrap template's `prepare-worktree` block.
|
|
67
|
+
- **`prepare-worktree` Step 4a "Neutralize compose isolation leaks"**, a
|
|
68
|
+
Verification invariant that boots the worktree's DB service and asserts its
|
|
69
|
+
resolved data volume name differs from main's (failing provisioning loudly
|
|
70
|
+
otherwise), a matching Pitfall + Rules entry, and a `state.compose` summary
|
|
71
|
+
schema extension (per-volume fork decisions, override path, stripped ports)
|
|
72
|
+
so teardown is mechanical (`docker volume rm` the forked names; `rm` the
|
|
73
|
+
override — never `down -v`, which would nuke shared volumes).
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **`prepare-worktree` foundation skill — rebalanced Step 3b (test database)
|
|
78
|
+
to be stack-agnostic.** The section previously led with a detailed
|
|
79
|
+
`pytest-django` recipe (with `vitest`/`jest` and Rails as one-liners),
|
|
80
|
+
which read as Django-centric. It now opens with the stack-agnostic
|
|
81
|
+
principle — give the worktree its own test DB name, inject it through
|
|
82
|
+
whatever channel the runner already reads (env var or per-worktree config
|
|
83
|
+
override), and never edit tracked test config in place — then lists the
|
|
84
|
+
runners as equal-weight illustrations grouped by *how they take config*:
|
|
85
|
+
env-var runners (`vitest`/`jest`, `go test`, `cargo test`), config-file
|
|
86
|
+
runners (`pytest-django`), convention-based runners (Rails), and
|
|
87
|
+
compose-based test DBs. No behavior change; the pytest-django mechanics
|
|
88
|
+
(`conftest_worktree.py`, don't touch the tracked `conftest.py`) are
|
|
89
|
+
preserved. **Consumers**: re-sync to pick up the reworded skill body.
|
|
90
|
+
|
|
8
91
|
## [0.2.0] — 2026-07-15
|
|
9
92
|
|
|
10
93
|
### Added
|
package/package.json
CHANGED
|
@@ -420,10 +420,16 @@
|
|
|
420
420
|
"default": "fork-on-schema-change"
|
|
421
421
|
},
|
|
422
422
|
"summary_dir": {
|
|
423
|
-
"description": "Where per-worktree summary YAMLs land. Default `.vinta-ai-workflows/worktrees` — covered by the umbrella gitignore added in 0.1.3. The skill writes `<summary_dir>/<name>.yaml` per provisioned worktree; teardown reads it.",
|
|
423
|
+
"description": "Where per-worktree summary YAMLs land. Default `.vinta-ai-workflows/worktrees` — covered by the umbrella gitignore added in 0.1.3. The skill writes `<summary_dir>/<name>.yaml` per provisioned worktree; teardown reads it. The generated compose override (see `shared_volumes`) also lands here as `<summary_dir>/<name>.docker-compose.override.yml`.",
|
|
424
424
|
"type": "string",
|
|
425
425
|
"minLength": 1,
|
|
426
426
|
"default": ".vinta-ai-workflows/worktrees"
|
|
427
|
+
},
|
|
428
|
+
"shared_volumes": {
|
|
429
|
+
"description": "Compose volume KEYS (as they appear under top-level `volumes:` in the project's compose config) that are safe to keep SHARED across worktrees — a read-only dep/venv/asset cache with no write churn. The skill's compose-isolation step (Step 4a) forks EVERY `external:`/fixed-`name:` volume into a worktree-namespaced copy by default (COMPOSE_PROJECT_NAME does not isolate those, and two servers on one DATA volume corrupts the store); volumes listed here are exempted and stay shared. NEVER list a database/data volume here. Passed through to `scripts/gen-compose-worktree-override.sh --share-volume`. Empty/omitted = fork every leaky volume (safest).",
|
|
430
|
+
"type": "array",
|
|
431
|
+
"items": { "type": "string", "minLength": 1 },
|
|
432
|
+
"default": []
|
|
427
433
|
}
|
|
428
434
|
}
|
|
429
435
|
},
|
|
@@ -312,6 +312,10 @@ skills:
|
|
|
312
312
|
compose_network: per-worktree
|
|
313
313
|
test_db_strategy: <prepare-worktree follow-up — fork-on-schema-change | always-fork | share>
|
|
314
314
|
summary_dir: .vinta-ai-workflows/worktrees
|
|
315
|
+
# Compose volume keys safe to keep shared across worktrees (read-only dep/venv
|
|
316
|
+
# caches only — NEVER a data/DB volume). Empty = fork every external/named
|
|
317
|
+
# volume (safest). See prepare-worktree Step 4a.
|
|
318
|
+
shared_volumes: []
|
|
315
319
|
|
|
316
320
|
# Only emit this block when foundation_skills.add-one-off-script = enabled.
|
|
317
321
|
add-one-off-script:
|
|
@@ -109,6 +109,8 @@ Default: **copy** for pnpm + npm + cargo + go; **reinstall** for poetry + venv +
|
|
|
109
109
|
|
|
110
110
|
`env_change = true` → `cp <main>/.env <worktree>/.env`. Then mutate per the **Database fork** step (DB URL) + the **Docker / compose isolation** step (compose project name).
|
|
111
111
|
|
|
112
|
+
**Compose forces a copy.** If the project uses compose, the [4a](#4a--neutralize-compose-isolation-leaks) sub-step appends `COMPOSE_FILE=…` to the worktree's `.env` — a mutation. So a compose project ALWAYS needs a copied `.env` (or a copied `.envrc`) even when `env_change = false`; a symlinked `.env` would leak the `COMPOSE_FILE` line into main. Treat "project uses compose" as an independent reason to copy `.env`.
|
|
113
|
+
|
|
112
114
|
`.env.example` is **always** symlinked — it's committed; the worktree should keep tracking it.
|
|
113
115
|
|
|
114
116
|
### 2c — Skip rules
|
|
@@ -129,6 +131,11 @@ Whatever the app reads + writes during local dev. Detect the connection string s
|
|
|
129
131
|
2. Project config files: `config/database.yml` (Rails), `database.ini`, `prisma/schema.prisma`'s `datasource`, `knexfile.js`, Django `settings.py`'s `DATABASES`.
|
|
130
132
|
3. Docker compose service env: `services.db.environment.POSTGRES_DB`.
|
|
131
133
|
|
|
134
|
+
**First, determine HOW the DB is delivered — this changes what "share" even means.** Two deliveries:
|
|
135
|
+
|
|
136
|
+
- **External server** — the DB runs on a server the worktree only *connects* to (a system Postgres, an RDS instance, a compose service the worktree does NOT boot its own copy of). Here "share" = point the worktree's connection string at the one already-running server. Fork = clone into a new DB *on that same server*.
|
|
137
|
+
- **Per-worktree compose stack** — the DB is delivered *through the worktree's own `docker compose up`* (a `db` service the worktree boots). Here the worktree runs its **own server process**. "Share" the way the connection-string sense means is impossible without corruption: booting a second server against the same data volume is two postmasters on one PGDATA. **For compose-delivered DBs the data volumes are ALWAYS forked, independent of `schema_change`** — see the [Neutralize compose isolation leaks](#4a--neutralize-compose-isolation-leaks) sub-step, which does the volume fork mechanically. `schema_change = false` may still let you skip *cloning main's data* into the forked volume (boot empty + migrate), but it never authorizes a shared data volume.
|
|
138
|
+
|
|
132
139
|
For each detected DB, ask the user (`AskUserQuestion`) once:
|
|
133
140
|
|
|
134
141
|
- **Fork the DB** — recommended when `schema_change = true` or when the plan's phases run destructive migrations. Strategy depends on the engine:
|
|
@@ -137,7 +144,7 @@ For each detected DB, ask the user (`AskUserQuestion`) once:
|
|
|
137
144
|
- **SQLite (file-based)** — `cp <main>/db.sqlite3 <worktree>/db.sqlite3`. Symlink would defeat the point.
|
|
138
145
|
- **Mongo / Redis / Elasticsearch** — engine-specific clone, OR per-worktree DB name / key prefix (`REDIS_URL=redis://localhost:6379/<index>` with a free index; `MONGODB_URI=mongodb://localhost:27017/<db>_wt_<name>`).
|
|
139
146
|
|
|
140
|
-
- **Share the DB (don't fork)** —
|
|
147
|
+
- **Share the DB (don't fork)** — means share a *connection to a single already-running server*, nothing more. Only safe when the DB is an **external server** (NOT compose-delivered — see the delivery check above) AND `schema_change = false` AND there's no risk of conflicting writes. Symlink the SQLite file or point the worktree's env at the same URL. Warn explicitly: "DB shared with main checkout — destructive ops here will be visible in main." **Never read this bullet as permission to point a second per-worktree compose server at a shared data volume** — that is not sharing a connection, it is running two servers on one data directory, and it corrupts the store regardless of `schema_change`.
|
|
141
148
|
|
|
142
149
|
- **Stub the DB** — point the worktree at a fresh empty DB (`createdb <main_db>_wt_<name>` + run all migrations + seed if a seed exists). Best when the feature needs schema parity but no production data.
|
|
143
150
|
|
|
@@ -147,10 +154,12 @@ Record the chosen strategy + the forked DB name in the worktree summary (written
|
|
|
147
154
|
|
|
148
155
|
A different beast — tests on the same engine but a different DB name (`<main_db>_test`, `test_<repo>`). Parallel worktrees running tests against the same `<main_db>_test` will overwrite each other's fixtures and produce flaky failures.
|
|
149
156
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
- **
|
|
153
|
-
- **
|
|
157
|
+
**The principle (stack-agnostic):** give the worktree its own test DB name (`<main_db>_test_wt_<name>`), inject it through whatever channel the test runner already reads (an env var or a per-worktree config override), and **never edit tracked test config in place** — a worktree-local override or an env var is safe; a change to a committed config file is one `git add -A` away from leaking into main. How each runner exposes that channel:
|
|
158
|
+
|
|
159
|
+
- **Env-var runners (`vitest` / `jest`, `go test`, `cargo test`, most CI-shaped suites)** — set `TEST_DATABASE_URL` (or the suite's equivalent) per worktree and confirm the test setup reads it. Simplest case; no file edits at all.
|
|
160
|
+
- **Config-file runners (`pytest-django` / `pytest`)** — set `--reuse-db` per worktree via a per-worktree `DJANGO_SETTINGS_MODULE`, OR override `DATABASES['default']['NAME']` to `<main_db>_test_wt_<name>` guarded on a `WORKTREE_NAME` env var. Put the override in a `conftest_worktree.py` in the worktree — don't touch the tracked `conftest.py`.
|
|
161
|
+
- **Convention-based runners (Rails)** — point the `test` env's `DATABASE_URL` at `<main_db>_test_wt_<name>`.
|
|
162
|
+
- **Docker-compose-based test DB (any stack)** — see the **Docker / compose isolation** step below; a per-worktree compose project name fixes it.
|
|
154
163
|
|
|
155
164
|
If `test_infra_change = true` → fork the test DB unconditionally. If `false` and the user says "share is fine" → still flag the race risk; offer a one-line fix to switch later.
|
|
156
165
|
|
|
@@ -166,10 +175,15 @@ If the project has a `docker-compose.yml` / `compose.yaml` / `docker-compose.ove
|
|
|
166
175
|
|
|
167
176
|
1. Set `COMPOSE_PROJECT_NAME=<repo>_<worktree-name>` in the worktree's `.env` (or in a `.envrc` if the project uses direnv). Then `docker compose up` inside the worktree spins a fresh container set.
|
|
168
177
|
|
|
169
|
-
|
|
178
|
+
**`COMPOSE_PROJECT_NAME` is necessary but NOT sufficient.** It namespaces containers and networks, and auto-named volumes — but it does **not** isolate two very common compose patterns: volumes declared `external: true` and/or pinned to a fixed top-level `name:`, and services with fixed host port bindings (`ports: - "5432:5432"`). A shared *database* volume is the dangerous one: two worktree stacks (or a worktree stack + main's stack) mount the same physical volume, so two server processes run against one data directory → corruption. The next sub-step closes both leaks mechanically.
|
|
179
|
+
|
|
180
|
+
2. **Neutralize compose isolation leaks** — see [4a](#4a--neutralize-compose-isolation-leaks) below. Run this whenever the project has compose, before ever booting the worktree's stack.
|
|
181
|
+
|
|
182
|
+
3. **Shared network** — if the worktree's app needs to reach services running in the *main* checkout's compose stack (a queue, a cache, a search index that's expensive to spin twice), put them on an external network:
|
|
170
183
|
|
|
171
184
|
```yaml
|
|
172
|
-
#
|
|
185
|
+
# Add to the SAME out-of-tree override the 4a sub-step generates — NOT a
|
|
186
|
+
# tracked docker-compose.override.yml at the worktree root (see 4a for why).
|
|
173
187
|
networks:
|
|
174
188
|
shared:
|
|
175
189
|
external: true
|
|
@@ -182,9 +196,44 @@ If the project has a `docker-compose.yml` / `compose.yaml` / `docker-compose.ove
|
|
|
182
196
|
|
|
183
197
|
Or use `network_mode: host` if the project uses that pattern. Decide once per project and record in `.vinta-ai-workflows.yaml` → `run_options.prepare-worktree.compose_network: per-worktree | shared-external | host`.
|
|
184
198
|
|
|
185
|
-
|
|
199
|
+
4. **Linters / formatters / test runners that run inside docker** (a Dockerfile-based `pnpm test`, a `lint` target that mounts source into a sidecar container) — these MUST run with the worktree's `COMPOSE_PROJECT_NAME`. Otherwise concurrent runs hit the same container and one wins. The standard fix: ensure the project's lint/test scripts read `COMPOSE_PROJECT_NAME` from env instead of hard-coding it.
|
|
200
|
+
|
|
201
|
+
5. **Per-worktree volumes** — for a volume the project leaves un-named, compose auto-namespaces it by project name and it's already isolated. But projects routinely pin data volumes with `external: true` or a fixed `name:` (for good reasons of their own — you must NOT edit their committed compose to "fix" that). Those are the leaks the [4a](#4a--neutralize-compose-isolation-leaks) sub-step re-pins in a generated override. Never touch the tracked compose file to change a volume's isolation; the override is the only lever.
|
|
202
|
+
|
|
203
|
+
### 4a — Neutralize compose isolation leaks
|
|
204
|
+
|
|
205
|
+
Runs whenever the project has any compose file, before the worktree's stack is ever booted. Fully project-agnostic — every decision is driven by `docker compose config --format json`, no project / service / volume name is hardcoded.
|
|
206
|
+
|
|
207
|
+
**1. Generate the override.** Run the bundled [scripts/gen-compose-worktree-override.sh](scripts/gen-compose-worktree-override.sh):
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
override=$(scripts/gen-compose-worktree-override.sh \
|
|
211
|
+
--main <main-checkout-root> \
|
|
212
|
+
--worktree <worktree-path> \
|
|
213
|
+
--name <worktree-name> \
|
|
214
|
+
--out <summary_dir>/<name>.docker-compose.override.yml \
|
|
215
|
+
--share-volume <volkey>...) # 0+ volumes safe to keep shared (see below)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The script renders the resolved config from the main checkout and, for the worktree:
|
|
219
|
+
- re-pins every volume that is `external: true` OR carries a fixed top-level `name:` to a **non-external, worktree-namespaced** volume (`<project>_wt_<name>_<volkey>`), so the worktree's server writes to its OWN physical volume;
|
|
220
|
+
- strips host port publishing from every service that publishes a fixed port (via compose's `!override []` — a plain `ports: []` **merges/appends** in Compose v2+ and would NOT strip it; verified on Docker Compose v5.1.1);
|
|
221
|
+
- leaves alone any volume already namespaced by project name (auto-named), and any volume the caller passes via `--share-volume` (a read-only dep/venv cache with no dep churn is safe to keep shared — the config's `shared_volumes` list feeds this arg).
|
|
222
|
+
|
|
223
|
+
The script exits non-zero with a clear message if `docker compose` is unavailable or the config doesn't parse; surface that and stop (don't boot a stack you couldn't prove is isolated).
|
|
224
|
+
|
|
225
|
+
**2. Write it OUT OF TREE and wire it in via the copied `.env`.** The override goes to `<summary_dir>/<name>.docker-compose.override.yml` (under the already-ignored `.vinta-ai-workflows/` umbrella) — **NOT** a `docker-compose.override.yml` at the worktree root. Rationale: a root `docker-compose.override.yml` is auto-loaded by compose, but it is frequently a **tracked** file (it was in the project that hit the 2026-07-17 incident); writing the generated override there pollutes the worktree diff and risks `git add -A` leaking it into main. Instead, wire it in by appending to the worktree's **copied** `.env`:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
# The generator prints exactly this line (base:override) on stderr — use it verbatim.
|
|
229
|
+
echo "COMPOSE_FILE=<base-compose-file>:<ABSOLUTE-override-path>" >> <worktree>/.env
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`COMPOSE_FILE` makes compose load base + override for every `docker compose` call in the worktree without touching any tracked file. The **override path must be absolute** — it lives under `<summary_dir>` (outside the worktree), so a bare basename wouldn't resolve from the worktree's compose project dir; the generator prints the absolute path for you (and on stdout, for `$(...)` capture). The base file stays relative — the worktree carries its own tracked copy. **This requires `.env` to be COPIED, not symlinked** — appending to a symlinked `.env` leaks the line into main. If the **Env files** step decided `env_change = false` (symlink), flip `.env` to a copy here (compose isolation is itself an env change) and note it in the summary. Use the exact base compose filename the generator reports (`compose.yaml` vs `docker-compose.yml`).
|
|
233
|
+
|
|
234
|
+
**3. Record the decision.** Write the per-volume fork decisions and the override path into the summary YAML's `state.compose` (see the **Write the summary file** step). The override's own `volumes:` block is the teardown manifest — each pinned `name:` is a `docker volume rm` target.
|
|
186
235
|
|
|
187
|
-
|
|
236
|
+
**Relation to `test_db_strategy`.** The `test_db_strategy: fork-on-schema-change` config governs the **test-DB axis only** (Step 3b — which test DB *name* the runner targets). It says nothing about compose data volumes and must **never** be read as permission to share the compose stack's volumes. Volume forking here is unconditional for compose-delivered DBs, independent of `schema_change` and independent of `test_db_strategy`.
|
|
188
237
|
|
|
189
238
|
## Step 5 — Other shared infra to fork
|
|
190
239
|
|
|
@@ -303,6 +352,14 @@ state:
|
|
|
303
352
|
compose:
|
|
304
353
|
project_name: <repo>_<worktree-name>
|
|
305
354
|
network_strategy: per-worktree | shared-external | host
|
|
355
|
+
override_path: <summary_dir>/<name>.docker-compose.override.yml # null when no compose
|
|
356
|
+
base_compose_file: docker-compose.yml # the base file COMPOSE_FILE prepends
|
|
357
|
+
forked_volumes: # [] when nothing leaked; teardown removes each name
|
|
358
|
+
- volume_key: pgdata # the compose key
|
|
359
|
+
reason: external | fixed-name # why it leaked past COMPOSE_PROJECT_NAME
|
|
360
|
+
forked_name: <repo>_wt_<name>_pgdata # the worktree-namespaced physical volume
|
|
361
|
+
shared_volumes: [virtualenv] # leaky volumes deliberately kept shared (--share-volume)
|
|
362
|
+
ports_stripped_from: [db, cache] # services whose fixed host ports the override removed
|
|
306
363
|
other:
|
|
307
364
|
redis_db_index: <int>
|
|
308
365
|
s3_prefix: wt-<name>/
|
|
@@ -346,7 +403,12 @@ guard is active; the orchestrator's post-run stray-write check is the backstop.
|
|
|
346
403
|
When the plan is merged / abandoned:
|
|
347
404
|
git worktree remove <path>
|
|
348
405
|
<drop-db command> # if fork strategy was `fork`
|
|
349
|
-
|
|
406
|
+
# Compose (if forked). Do NOT use `down -v` — it can also nuke SHARED volumes
|
|
407
|
+
# (a fixed-name dep/venv cache). Bring the stack down, then remove only the
|
|
408
|
+
# worktree-namespaced volumes the override created:
|
|
409
|
+
docker compose -p <project_name> down
|
|
410
|
+
docker volume rm <each state.compose.forked_volumes[].forked_name>
|
|
411
|
+
rm <override_path> # the generated out-of-tree compose override
|
|
350
412
|
|
|
351
413
|
The summary file at `.vinta-ai-workflows/worktrees/<name>.yaml` records every
|
|
352
414
|
decision for mechanical teardown.
|
|
@@ -371,7 +433,10 @@ A sibling skill / explicit command — not auto-run by this skill. Steps:
|
|
|
371
433
|
1. Confirm no uncommitted changes in the worktree (`git -C <path> status`).
|
|
372
434
|
2. `git worktree remove <path>` (or `--force` after explicit confirmation if the user is fine losing work).
|
|
373
435
|
3. Drop the forked DB (`dropdb <forked_name>`, `DROP DATABASE`, `rm <sqlite-file>`, …) — read the strategy from the summary YAML.
|
|
374
|
-
4.
|
|
436
|
+
4. Compose teardown (read `state.compose` from the summary YAML):
|
|
437
|
+
- `docker compose -p <project_name> down` — **not `down -v`**; a bare `down -v` also removes shared volumes (a fixed-name dep/venv cache in `state.compose.shared_volumes`), which belong to main.
|
|
438
|
+
- `docker volume rm <forked_name>` for each entry in `state.compose.forked_volumes` — these are the worktree-only volumes the generated override created; removing them is safe and leaves main's / shared volumes untouched.
|
|
439
|
+
- `rm <state.compose.override_path>` — delete the generated out-of-tree override.
|
|
375
440
|
5. Remove `.vinta-ai-workflows/worktrees/<name>.yaml`.
|
|
376
441
|
|
|
377
442
|
Every step gated on user confirmation when the worktree has un-pushed branches.
|
|
@@ -380,6 +445,7 @@ Every step gated on user confirmation when the worktree has un-pushed branches.
|
|
|
380
445
|
|
|
381
446
|
- **Symlink for reads, copy for writes, fork for state.** This is the only mental model that scales. Default to fork when unsure — disk is cheap, corrupted main-checkout DBs are not.
|
|
382
447
|
- **Never share a writable DB across worktrees by default.** The race conditions are subtle and the failure mode is silent data corruption.
|
|
448
|
+
- **`COMPOSE_PROJECT_NAME` does not isolate `external:`/fixed-`name:` volumes or fixed host ports — always run the [4a](#4a--neutralize-compose-isolation-leaks) generator.** For a compose-delivered DB the data volumes are forked *unconditionally* (independent of `schema_change` and `test_db_strategy`): the worktree runs its own server, and two servers on one volume is corruption. "Share the DB" only ever means share a connection to a single already-running server, never a second server on shared storage. The fix is a generated, out-of-tree override + a `COMPOSE_FILE` line in the copied `.env` — never an edit to any tracked compose file. Verify the volume-differs invariant before declaring the worktree runnable.
|
|
383
449
|
- **Every fork decision lands in `.vinta-ai-workflows/worktrees/<name>.yaml`.** Teardown reads it; humans grep it; agents resuming a stalled plan read it. No decision lives only in conversation memory.
|
|
384
450
|
- **Worktree root governed by runtime conventions.** claude-code uses `.claude/worktrees/`; other harnesses use sibling dirs. Don't fight the harness — match it.
|
|
385
451
|
- **Don't mutate the main checkout from this skill.** Every write goes to the worktree or to `.vinta-ai-workflows/`. Forking a Postgres DB is the one exception (the new DB lives in the same server) — document it loudly in the summary.
|
|
@@ -395,6 +461,9 @@ Every step gated on user confirmation when the worktree has un-pushed branches.
|
|
|
395
461
|
- **Symlinking `node_modules` for a `pnpm add` phase.** The new package writes back through the symlink into the main checkout's store. Detect dep churn in the **Plan inspection** step and copy/reinstall instead.
|
|
396
462
|
- **Forking the dev DB but forgetting the test DB.** Tests still hit the shared test DB and stomp on parallel worktrees' fixtures. Both axes need their own decision in the **Database fork** step.
|
|
397
463
|
- **Forgetting `COMPOSE_PROJECT_NAME`.** Containers from worktree-A overwrite worktree-B's containers; volumes get nuked. Set it in `.env` so every `docker compose` call inherits.
|
|
464
|
+
- **Trusting `COMPOSE_PROJECT_NAME` to isolate `external:`/fixed-`name:` volumes and fixed host ports — it does NOT.** These defeat the project-name namespace: every worktree's compose stack mounts the SAME external/named volume and binds the SAME host port. For a DB volume that means two server processes on one data directory — the 2026-07-17 corruption incident (a worktree's `docker compose down` deleted the shared `postmaster.pid` and the main checkout's Postgres self-terminated). The fix is the [4a](#4a--neutralize-compose-isolation-leaks) generated out-of-tree override, never an edit to the tracked compose file. Verify the invariant (Verification step 6), don't assume it.
|
|
465
|
+
- **Confusing "share the DB" (a connection to one running server) with "share the data volume" (a second server on shared storage).** The first can be safe when `schema_change = false`; the second is always corruption. `schema_change = false` and `test_db_strategy` never authorize a shared compose data volume — see [3a](#3a--dev--app-database).
|
|
466
|
+
- **Writing the compose override to the worktree-root `docker-compose.override.yml`.** It's auto-loaded (convenient) but often a *tracked* file — the generated override then shows up in the diff and one `git add -A` leaks it into main. Write it under `<summary_dir>/` and wire it in via `COMPOSE_FILE` in the COPIED `.env` ([4a](#4a--neutralize-compose-isolation-leaks)).
|
|
398
467
|
- **Sharing a Redis DB without per-worktree prefix.** Tests writing `user:123` collide across worktrees. Pick an index OR a key prefix.
|
|
399
468
|
- **Copying `node_modules` for yarn PnP / absolute-path setups.** The copy carries baked-in paths from the main checkout. Reinstall instead — pnpm's relative-symlink store is the safe-to-copy exception.
|
|
400
469
|
- **Forgetting to `--allow` the `.vinta-ai-workflows` dir under sandbox.** It's shared/symlinked but the run *writes* it (summary YAMLs, prs-context). If it's not an `--allow` exception, those writes hit the read-only main subtree and fail mid-run. Pass it alongside the worktree path.
|
|
@@ -413,4 +482,7 @@ After the **Write the summary file** step writes the summary:
|
|
|
413
482
|
3. `git -C <worktree-path> status` is clean (no accidental file additions from the prep step).
|
|
414
483
|
4. The summary YAML parses (`python3 -c "import yaml; yaml.safe_load(open('.vinta-ai-workflows/worktrees/<name>.yaml'))"`).
|
|
415
484
|
5. `WORKTREE.md` exists at the worktree root with accurate fork / share annotations.
|
|
416
|
-
6.
|
|
485
|
+
6. **Compose volume-isolation invariant (when the DB is compose-delivered).** This is the checked form of the footgun that caused the 2026-07-17 incident — do not skip it when the project boots its DB via compose. From the worktree:
|
|
486
|
+
- Resolve the worktree's DB data volume: `docker compose config --format json | <read the db service's data volume `source`, then that volume's `.name`>`. Assert it **differs** from the main checkout's resolved DB volume name (run the same read in `<main-checkout-root>`). If they're equal, the override didn't take — **fail provisioning loudly**; do not proceed.
|
|
487
|
+
- Boot only the DB service in the worktree (`docker compose up -d <db-service>`), then confirm the **main checkout's** DB container is still healthy/running afterward (`docker inspect`/`pg_isready` against main's DB). Bring the worktree DB back down. A main-DB disturbance here means isolation failed — fail provisioning.
|
|
488
|
+
7. Optional smoke test: run a single new-test command in the worktree (e.g. `pytest -x tests/health.py`) — confirms env vars resolved, DB reachable, deps importable.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gen-compose-worktree-override.sh — generate an out-of-tree docker-compose
|
|
3
|
+
# override that neutralizes the isolation leaks COMPOSE_PROJECT_NAME does NOT
|
|
4
|
+
# cover, so a per-worktree compose stack cannot collide with the main checkout's.
|
|
5
|
+
#
|
|
6
|
+
# Why this exists: COMPOSE_PROJECT_NAME namespaces containers and networks, but
|
|
7
|
+
# NOT two very common compose patterns —
|
|
8
|
+
#
|
|
9
|
+
# 1. Volumes declared `external: true` and/or pinned to a fixed top-level
|
|
10
|
+
# `name:`. These are addressed by name, so EVERY worktree's compose stack
|
|
11
|
+
# mounts the SAME physical volume. For a database volume that means two
|
|
12
|
+
# postmasters on one PGDATA → data corruption (the 2026-07-17 incident).
|
|
13
|
+
# 2. Fixed host port bindings (`ports: - "5432:5432"`). These publish to a
|
|
14
|
+
# fixed host port that collides across compose projects regardless of name.
|
|
15
|
+
#
|
|
16
|
+
# The fix is a GENERATED, out-of-tree override (never a mutation of the project's
|
|
17
|
+
# committed compose files) that, for the worktree:
|
|
18
|
+
# - re-pins every leaky volume to a NON-external, worktree-namespaced volume
|
|
19
|
+
# (`<project>_wt_<name>_<volkey>`), EXCEPT volumes the caller marks shareable
|
|
20
|
+
# (a read-only dep/venv cache with no dep churn is safe to keep shared);
|
|
21
|
+
# - strips host port publishing from every service that has it, using compose's
|
|
22
|
+
# `!override []` tag (a plain `ports: []` MERGES/appends in Compose v2+ and
|
|
23
|
+
# would NOT strip the port — verified on Docker Compose v5.1.1).
|
|
24
|
+
#
|
|
25
|
+
# Detection is 100% generic — no project / service / volume name is hardcoded.
|
|
26
|
+
# Everything comes from `docker compose config --format json` run in the main
|
|
27
|
+
# checkout. A volume leaks when `.external == true` OR its resolved `.name`
|
|
28
|
+
# differs from the auto-namespaced form `<project>_<volkey>` (i.e. it carries a
|
|
29
|
+
# fixed top-level `name:`). Setting `external: false` in the override is
|
|
30
|
+
# mandatory — without it the base `external: true` merges through and the volume
|
|
31
|
+
# stays externally-pinned (verified).
|
|
32
|
+
#
|
|
33
|
+
# Usage:
|
|
34
|
+
# gen-compose-worktree-override.sh \
|
|
35
|
+
# --main <main-checkout-root> \
|
|
36
|
+
# --worktree <worktree-path> \
|
|
37
|
+
# --name <worktree-name> \
|
|
38
|
+
# --out <override-output-path> \
|
|
39
|
+
# [--share-volume <volkey>]... # volumes safe to keep shared (repeatable)
|
|
40
|
+
#
|
|
41
|
+
# Output:
|
|
42
|
+
# stdout — the override path (only, on success) so it is safe to capture with
|
|
43
|
+
# $(...). stderr carries a human-readable decision report + the
|
|
44
|
+
# suggested `COMPOSE_FILE=<base>:<override>` wiring line.
|
|
45
|
+
# The generated override's `volumes:` block lists the forked volumes by their
|
|
46
|
+
# pinned name — that IS the teardown manifest (`docker volume rm <name>`).
|
|
47
|
+
#
|
|
48
|
+
# Exit: 0 on success; 2 on argument / setup errors (no `docker compose`, config
|
|
49
|
+
# does not parse, no base compose file found).
|
|
50
|
+
|
|
51
|
+
set -u
|
|
52
|
+
|
|
53
|
+
die() { echo "gen-compose-worktree-override: $*" >&2; exit 2; }
|
|
54
|
+
|
|
55
|
+
MAIN=""; WORKTREE=""; NAME=""; OUT=""
|
|
56
|
+
SHARE=()
|
|
57
|
+
while [ $# -gt 0 ]; do
|
|
58
|
+
case "$1" in
|
|
59
|
+
--main) shift; [ $# -gt 0 ] || die "--main needs a path"; MAIN="$1"; shift ;;
|
|
60
|
+
--worktree) shift; [ $# -gt 0 ] || die "--worktree needs a path"; WORKTREE="$1"; shift ;;
|
|
61
|
+
--name) shift; [ $# -gt 0 ] || die "--name needs a value"; NAME="$1"; shift ;;
|
|
62
|
+
--out) shift; [ $# -gt 0 ] || die "--out needs a path"; OUT="$1"; shift ;;
|
|
63
|
+
--share-volume) shift; [ $# -gt 0 ] || die "--share-volume needs a volume key"; SHARE+=("$1"); shift ;;
|
|
64
|
+
*) die "unknown arg: $1" ;;
|
|
65
|
+
esac
|
|
66
|
+
done
|
|
67
|
+
|
|
68
|
+
[ -n "$MAIN" ] || die "--main <main-checkout-root> required"
|
|
69
|
+
[ -n "$WORKTREE" ] || die "--worktree <path> required"
|
|
70
|
+
[ -n "$NAME" ] || die "--name <worktree-name> required"
|
|
71
|
+
[ -n "$OUT" ] || die "--out <override-path> required"
|
|
72
|
+
|
|
73
|
+
command -v docker >/dev/null 2>&1 || die "docker not found on PATH — cannot generate compose override"
|
|
74
|
+
docker compose version >/dev/null 2>&1 || die "'docker compose' unavailable — is the Compose v2 plugin installed?"
|
|
75
|
+
|
|
76
|
+
# Canonicalize the main checkout (must exist).
|
|
77
|
+
[ -d "$MAIN" ] || die "main checkout path is not a directory: $MAIN"
|
|
78
|
+
MAIN=$(cd "$MAIN" && pwd -P)
|
|
79
|
+
|
|
80
|
+
# Locate the base compose file compose will auto-load (standard search order).
|
|
81
|
+
BASE=""
|
|
82
|
+
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
|
83
|
+
if [ -f "$MAIN/$f" ]; then BASE="$f"; break; fi
|
|
84
|
+
done
|
|
85
|
+
[ -n "$BASE" ] || die "no compose file (compose.yaml / docker-compose.yml) found in $MAIN"
|
|
86
|
+
|
|
87
|
+
# Render the resolved config from the MAIN checkout. Do not force
|
|
88
|
+
# COMPOSE_PROJECT_NAME: let it default exactly as the project normally resolves
|
|
89
|
+
# it, so the auto-namespace comparison below reflects reality.
|
|
90
|
+
CONFIG_JSON=$(cd "$MAIN" && docker compose config --format json 2>/dev/null) \
|
|
91
|
+
|| die "'docker compose config' failed in $MAIN — the compose config does not parse"
|
|
92
|
+
|
|
93
|
+
mkdir -p "$(dirname "$OUT")" || die "cannot create output dir for $OUT"
|
|
94
|
+
|
|
95
|
+
CONFIG_JSON="$CONFIG_JSON" OUT="$OUT" NAME="$NAME" BASE="$BASE" \
|
|
96
|
+
python3 - "${SHARE[@]:-}" <<'PY' || die "failed to generate override"
|
|
97
|
+
import json, os, sys
|
|
98
|
+
|
|
99
|
+
cfg = json.loads(os.environ["CONFIG_JSON"])
|
|
100
|
+
out = os.environ["OUT"]
|
|
101
|
+
wt_name = os.environ["NAME"]
|
|
102
|
+
base = os.environ["BASE"]
|
|
103
|
+
share = {s for s in sys.argv[1:] if s}
|
|
104
|
+
|
|
105
|
+
# The override lives OUT of the worktree (under <summary_dir>), so the COMPOSE_FILE
|
|
106
|
+
# entry must reference it by ABSOLUTE path — a basename would only resolve if the
|
|
107
|
+
# file sat in the compose project dir. The base file is worktree-local (the
|
|
108
|
+
# worktree carries its own tracked copy), so it stays relative.
|
|
109
|
+
out_abs = os.path.abspath(out)
|
|
110
|
+
|
|
111
|
+
project = cfg.get("name") or "compose"
|
|
112
|
+
volumes = cfg.get("volumes") or {}
|
|
113
|
+
services = cfg.get("services") or {}
|
|
114
|
+
|
|
115
|
+
# ---- Detect leaky volumes -------------------------------------------------
|
|
116
|
+
# A volume leaks past COMPOSE_PROJECT_NAME when it is external, or carries a
|
|
117
|
+
# fixed top-level name (resolved name != the auto-namespaced <project>_<key>).
|
|
118
|
+
forked = [] # (volkey, reason, pinned_name)
|
|
119
|
+
shared_leaky = [] # (volkey, reason) — leaky but caller marked shareable
|
|
120
|
+
for key, vol in volumes.items():
|
|
121
|
+
vol = vol or {}
|
|
122
|
+
external = bool(vol.get("external"))
|
|
123
|
+
resolved = vol.get("name", "")
|
|
124
|
+
auto = f"{project}_{key}"
|
|
125
|
+
if external:
|
|
126
|
+
reason = "external:true"
|
|
127
|
+
elif resolved and resolved != auto:
|
|
128
|
+
reason = f"fixed name '{resolved}'"
|
|
129
|
+
else:
|
|
130
|
+
continue # already namespaced by project name — safe
|
|
131
|
+
if key in share:
|
|
132
|
+
shared_leaky.append((key, reason))
|
|
133
|
+
continue
|
|
134
|
+
pinned = f"{project}_wt_{wt_name}_{key}"
|
|
135
|
+
forked.append((key, reason, pinned))
|
|
136
|
+
|
|
137
|
+
# ---- Detect services publishing fixed host ports --------------------------
|
|
138
|
+
stripped = [] # service names whose host port publishing we drop
|
|
139
|
+
for svc, spec in services.items():
|
|
140
|
+
spec = spec or {}
|
|
141
|
+
ports = spec.get("ports") or []
|
|
142
|
+
if any((p or {}).get("published") for p in ports):
|
|
143
|
+
stripped.append(svc)
|
|
144
|
+
|
|
145
|
+
# ---- Emit the override YAML (hand-rolled; no yaml dependency) --------------
|
|
146
|
+
def y(s):
|
|
147
|
+
# Quote scalars that YAML could misread; safe for our name/key charset.
|
|
148
|
+
return json.dumps(s)
|
|
149
|
+
|
|
150
|
+
lines = []
|
|
151
|
+
lines.append("# GENERATED by gen-compose-worktree-override.sh — do not edit by hand.")
|
|
152
|
+
lines.append("# Out-of-tree compose override that neutralizes the isolation leaks")
|
|
153
|
+
lines.append("# COMPOSE_PROJECT_NAME does not cover (external / fixed-name volumes,")
|
|
154
|
+
lines.append("# fixed host port bindings). Wire in without touching tracked files via:")
|
|
155
|
+
lines.append(f"# COMPOSE_FILE={base}:{out_abs} (in the worktree's .env)")
|
|
156
|
+
lines.append(f"# Source project: {project} Worktree: {wt_name}")
|
|
157
|
+
if shared_leaky:
|
|
158
|
+
for key, reason in shared_leaky:
|
|
159
|
+
lines.append(f"# KEPT SHARED (caller --share-volume): {key} ({reason})")
|
|
160
|
+
lines.append("")
|
|
161
|
+
|
|
162
|
+
if stripped:
|
|
163
|
+
lines.append("services:")
|
|
164
|
+
for svc in sorted(stripped):
|
|
165
|
+
lines.append(f" {y(svc)}:")
|
|
166
|
+
# `!override []` REPLACES the base list (plain [] would append/merge).
|
|
167
|
+
lines.append(" ports: !override []")
|
|
168
|
+
lines.append("")
|
|
169
|
+
|
|
170
|
+
if forked:
|
|
171
|
+
lines.append("volumes:")
|
|
172
|
+
for key, _reason, pinned in forked:
|
|
173
|
+
lines.append(f" {y(key)}:")
|
|
174
|
+
lines.append(f" name: {y(pinned)}")
|
|
175
|
+
lines.append(" external: false")
|
|
176
|
+
lines.append("")
|
|
177
|
+
|
|
178
|
+
if not stripped and not forked:
|
|
179
|
+
lines.append("# No isolation leaks detected: every volume is already namespaced by")
|
|
180
|
+
lines.append("# COMPOSE_PROJECT_NAME and no service publishes a fixed host port.")
|
|
181
|
+
lines.append("# This override is a valid no-op; wiring it in is harmless.")
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
with open(out, "w") as f:
|
|
185
|
+
f.write("\n".join(lines).rstrip("\n") + "\n")
|
|
186
|
+
|
|
187
|
+
# ---- Report to stderr (human + agent readable) ----------------------------
|
|
188
|
+
def err(msg):
|
|
189
|
+
print(msg, file=sys.stderr)
|
|
190
|
+
|
|
191
|
+
err(f"gen-compose-worktree-override: wrote {out}")
|
|
192
|
+
err(f" base compose file: {base} project: {project}")
|
|
193
|
+
if forked:
|
|
194
|
+
err(" forked volumes (were shared → now worktree-namespaced, non-external):")
|
|
195
|
+
for key, reason, pinned in forked:
|
|
196
|
+
err(f" - {key} ({reason}) -> {pinned}")
|
|
197
|
+
else:
|
|
198
|
+
err(" forked volumes: none")
|
|
199
|
+
if shared_leaky:
|
|
200
|
+
err(" KEPT SHARED (caller --share-volume) — verify these are read-only caches:")
|
|
201
|
+
for key, reason in shared_leaky:
|
|
202
|
+
err(f" - {key} ({reason})")
|
|
203
|
+
if stripped:
|
|
204
|
+
err(f" stripped host-port publishing from: {', '.join(sorted(stripped))}")
|
|
205
|
+
else:
|
|
206
|
+
err(" stripped host-port publishing from: none")
|
|
207
|
+
err(f" wire in: COMPOSE_FILE={base}:{out_abs} (append to the worktree's copied .env)")
|
|
208
|
+
|
|
209
|
+
# stdout = override path ONLY (safe for $(...) capture).
|
|
210
|
+
print(out_abs)
|
|
211
|
+
PY
|