Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .agents/skills/backend/python-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
uv sync # create/refresh .venv with the package (editable) + dev group
uv run python -c "..." # run anything inside the env
uv run pytest # tests
uv add <pkg> # runtime dependency -> [project].dependencies
uv add --dev <pkg> # dev dependency -> [dependency-groups].dev

# Adding a dependency goes through the cool-down wrapper — see below.
bash scripts/cooldown.sh uv add <pkg> # runtime -> [project].dependencies
bash scripts/cooldown.sh uv add --dev <pkg> # dev -> [dependency-groups].dev
```

Never edit `uv.lock` by hand; never `pip install` into `.venv`.

**The three-day cool-down.** This repository does not take a package version the ecosystem has not
had three days to look at. uv has no rolling setting for it — `--exclude-newer` accepts absolute
dates only — so `scripts/cooldown.sh` computes the cutoff at the moment of the call and exports
`UV_EXCLUDE_NEWER`. Use it for anything that **resolves**: `uv add`, `uv lock`, `uv pip install`.

Do **not** put it in front of `uv sync`. A cutoff on a plain sync makes uv discard the lockfile and
re-resolve (`Ignoring existing lockfile due to addition of timestamp cutoff`), which is the opposite
of what a sync is for; CI uses `uv sync --locked` so it cannot happen there. The cool-down governs
what gets *into* uv.lock, and the lockfile governs everything after. Full rules and the escape
hatches are in CONTRIBUTING.md.

## Checks that must stay green

| Check | Command |
Expand Down
12 changes: 10 additions & 2 deletions .agents/skills/frontend/nodejs-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
## Environment

- **Node.js 24**. With nvm on the host: `nvm use 24`.
- **pnpm** only (never npm, never yarn). Pinned via `packageManager` in the root
`package.json` — enable it with `corepack enable`.
- **pnpm** only (never npm, never yarn, never `npx`). Pinned via `packageManager` in
the root `package.json` — enable it with `corepack enable`. To run a binary the
workspace already has, `pnpm exec <bin>`; `npx` would *fetch and run* one it does
not, which is a resolution no lockfile names and no cool-down covers.
- **The three-day cool-down applies to every `pnpm add` and `pnpm update`**, through
`minimumReleaseAge` in `pnpm-workspace.yaml`. Nothing to type — but a package whose
newest release is younger than that is refused outright
(`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) rather than silently downgraded, because a
bare `pnpm add <pkg>` asks for `latest`. Wait, name an older version, or add a
reviewed entry to `minimumReleaseAgeExclude`. See CONTRIBUTING.md.
- Single **pnpm workspace** rooted at the repo root; members are `frontend/*`
(`@visionset/annotator`, `@visionset/ui-core`, `@visionset/app`).
- Run commands **from the repo root**.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/process/refactor-protocol/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ All work in the worktree; never the primary checkout. Conventional commits in lo
The script sets `CI=1` for the Playwright steps itself, so that is no longer yours to remember. **`--fast` is never enough before a push.** The real-server cycle run is mandatory for anything touching state, gating, or progress: it was three separate times the *only* suite to catch a regression — a stale job declaration, a label flip standing in for feedback, and a progress counter running backwards. — 2026-08 run, T3/T5/T6; #314
- **When the machine is saturated, the fallback is declared — never silent.** A green `bash scripts/check.sh` is still what a merge requires. When another session has the box, and you can *show* it — load average, the competing processes, `ps aux | grep` output — the sanctioned substitute is: every static gate (`ruff check .`, `ruff format --check .`, `mypy`, `lint-imports`, the `node --test` script gates), the full frontend build and test suite, and every pytest module the change touches, with **full green CI on clean runners as the arbiter**. That is not a lowering of the bar: a timing-sensitive suite at load average 60 tells you nothing it would not also tell you at load average 6000. **Say so in the PR body before the merge, naming which suites did not run and why.** A merge that lets a reader infer a green local gate that never happened is a protocol violation, not a shortcut — and the fallback is only available for a machine you can evidence, not for one you are impatient with. — #339
- **Where the harness kills long-running commands, run the gate in stages rather than fighting the ceiling.** The observed limit is ~10 minutes, the kill takes the whole process group, and every way out of it fails: `run_in_background`, a watcher, and `nohup … & disown` all die at the same point (and `setsid` does not exist on macOS, so that spelling dies instantly and silently). The stages that fit: pytest split by test directory — **derived from `ls tests/` at run time, never a remembered list**, since #344's staged runs missed `tests/jobs`, new since #339 — then `ruff` / `mypy` / `lint-imports`, then frontend, then browser. **Record every stage's exit code verbatim in the PR body** — a staged gate whose stages are undocumented is indistinguishable from a partial one, the same false-calm failure as #336. And never pipe a runner through `tail` to dodge the ceiling: the repo forbids it, and it swallows the summary line along with the exit code. — #344
- **`CI=1` on any Playwright run you invoke by hand.** `playwright.config.ts` sets `reuseExistingServer: !CI`, so a stale vite server on this worktree's derived e2e port answers instead of your build and produces failures that read as code bugs. `check.sh` does this for you; `npx playwright test` typed directly does not. — 2026-08 run, T3
- **`CI=1` on any Playwright run you invoke by hand.** `playwright.config.ts` sets `reuseExistingServer: !CI`, so a stale vite server on this worktree's derived e2e port answers instead of your build and produces failures that read as code bugs. `check.sh` does this for you; `pnpm exec playwright test` typed directly does not. — 2026-08 run, T3
- **The browser stages take a port per worktree, so two of them may run at once.** Since #346 the number is derived from the worktree's absolute path by `frontend/app/e2e-ports.ts`; the main checkout and CI keep the old 5273 / 8123 / 5373, and every run prints the three it resolved before it starts. Override one with `VISIONSET_E2E_PORT`, `VISIONSET_CYCLE_PORT` or `VISIONSET_BENCH_PORT`. What survives from when they *were* single-occupancy: **a stage that fails far faster than its normal runtime is a setup collision, not a test failure** — read the printed port, find the occupant (`lsof -nP -iTCP:<port> -sTCP:LISTEN`) and read its cmdline for the path that owns it, before debugging a single test. The occupant is now almost always a server *this* worktree left behind, since the port is private to it. **Never kill a process belonging to another session**; wait, or set the override. — #344, #346
- **To rerun the cycle suite N times, use `--repeat-each=N`** — it costs one build rather than N, because the suite's names are run-scoped since #314. Before that a fixed project name made repeat 2 die on `POST /projects → 409`, and repetition meant N whole invocations at ~90 s of rebuild each.
- **`git add` new files before trusting any local check run.** Several gates read `git ls-files` — the index, not the working tree — so an untracked new file is invisible to them and passes locally while failing in CI. — 2026-08 run, T4
Expand Down
61 changes: 60 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
# Automated dependency updates.
#
# Three ecosystems, all at the repository root:
# Five ecosystems:
# uv — the single `visionset` distribution (pyproject.toml + uv.lock)
# npm — the pnpm workspace (pnpm-lock.yaml at the root, packages in frontend/*)
# github-actions — .github/workflows/
# docker — the two dev-stack images (docker/*.Dockerfile)
# docker-compose — the services docker/compose.yaml pulls rather than builds
#
# `cooldown` is a supply-chain margin: a version must have been published more
# than three days ago before Dependabot will propose it, so a package that is
# compromised and yanked shortly after release never reaches a PR here. It
# applies to *version* updates only — Dependabot security updates stay
# immediate by design, which is what we want.
#
# **Three days is one number with several spellings, and they must agree.** The
# same rule governs the versions a person or a CI step picks, where Dependabot is
# not involved at all: `minimumReleaseAge` in pnpm-workspace.yaml covers every
# `pnpm add`, and scripts/cooldown.sh covers uv, which has no equivalent setting.
# `tests/scripts/cooldown.test.mjs` holds all three to each other, so moving one
# and forgetting the others is a red test rather than a quiet inconsistency.
#
# `ignore` is for a version this repository cannot take *yet* — a major whose
# ecosystem has not caught up. Every entry must name the blocker and the
# condition that retires it, because an ignore with no expiry is a pin nobody
Expand Down Expand Up @@ -88,3 +97,53 @@ updates:
commit-message:
prefix: "chore(deps)"
labels: ["dependencies", "ci"]

# The base images of the two dev-stack builds — `node:24-bookworm-slim` and
# `ghcr.io/astral-sh/uv:python3.12-trixie-slim`. Until this entry existed they
# were the one class of dependency here with no automation and no cool-down at
# all: a tag is re-pointed by its publisher, so `node:24-bookworm-slim` means
# different bytes on different days and nothing in the repository recorded
# which. Dependabot cannot fix the re-pointing, but it can propose the version
# bumps, and the same three days apply.
#
# Both Dockerfiles pin a major-and-flavour tag rather than a digest, which is
# the deliberate trade docker/api.Dockerfile's header argues for (the ffmpeg
# version that comes with the distribution is the thing being chosen). A digest
# pin would be stricter and would also make every security patch a manual bump.
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 3
groups:
images-minor-patch:
applies-to: version-updates
patterns: ["*"]
update-types: ["minor", "patch"]
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
labels: ["dependencies", "ci"]

# The services compose *pulls* rather than builds: nginx, postgres, minio. A
# separate ecosystem from the one above because Dependabot reads Dockerfiles and
# compose files with different parsers, and an entry for one does not see the
# other.
- package-ecosystem: "docker-compose"
directory: "/docker"
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 3
groups:
services-minor-patch:
applies-to: version-updates
patterns: ["*"]
update-types: ["minor", "patch"]
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
labels: ["dependencies", "ci"]
26 changes: 20 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ jobs:
with:
enable-cache: true

# `--locked`, not a bare `uv sync`, and it does two jobs. It refuses to
# re-resolve, which is what keeps the dependency cool-down honest — a
# resolution that happens here would be one nobody applied the rule to (see
# scripts/cooldown.sh). And it fails when uv.lock has drifted from
# pyproject.toml, so an uncommitted lock is a named red step rather than a
# silent re-lock that makes CI test a set no reviewer chose.
- name: Sync environment
run: uv sync
run: uv sync --locked

- name: Ruff (lint + format)
run: |
Expand Down Expand Up @@ -97,7 +103,7 @@ jobs:
enable-cache: true

- name: Sync environment
run: uv sync
run: uv sync --locked

# `uv run` puts the virtualenv's bin/ on PATH, so `visionset` is the
# installation under test — which the HTTP leg starts a server with and the
Expand Down Expand Up @@ -238,7 +244,7 @@ jobs:
enable-cache: true

- name: Sync environment
run: uv sync
run: uv sync --locked

- uses: pnpm/action-setup@v6 # reads packageManager from package.json

Expand Down Expand Up @@ -393,10 +399,18 @@ jobs:
# particular not the repository, which is what makes this a test of the
# artifact. `--no-cache` so a previously built wheel of the same version
# cannot be served from uv's cache in place of this one.
#
# The one place in this workflow that genuinely *resolves* a third-party
# package from an index — `ultralytics` is named with no version and comes
# from PyPI, unlike everything else here, which comes from uv.lock. So it
# is the one place the cool-down wrapper is needed rather than decorative.
# The local wheel is a path, and a path has no upload date, so the cutoff
# cannot touch the artifact under test.
- name: Install the wheel into a clean environment
run: |
uv venv /tmp/flow-venv
uv pip install --no-cache --python /tmp/flow-venv dist/*.whl ultralytics
bash scripts/cooldown.sh \
uv pip install --no-cache --python /tmp/flow-venv dist/*.whl ultralytics

# `cd /tmp` so an accidental relative import cannot reach the checkout: the
# script is named by absolute path and runs with the repository *not* on
Expand Down Expand Up @@ -443,7 +457,7 @@ jobs:
enable-cache: true

- name: Sync environment with the format groups
run: uv sync --group yolo --group coco
run: uv sync --locked --group yolo --group coco

- name: The reference readers load the exports
run: uv run pytest tests/formats/test_yolo_smoke.py tests/formats/test_coco_smoke.py -q
Expand Down Expand Up @@ -509,7 +523,7 @@ jobs:
enable-cache: true

- name: Sync environment
run: uv sync
run: uv sync --locked

- name: Regenerate openapi.json and fail on drift
run: |
Expand Down
34 changes: 34 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ Dev only; the release artifact is always the pip package, and these images are n
it on the host stays faster, because a bind mount has to poll for file changes rather than being
told about them.

## Adding a dependency: the three-day cool-down

**This repository does not install a package version the ecosystem has not had three days to look
at.** A compromised release is most dangerous in the hours between publication and yanking, and
patience is the cheapest defence there is. The rule is the same everywhere; only the spelling
differs, because pnpm has a setting for it and uv does not.

| Adding to | Type | The rule reaches you through |
| --- | --- | --- |
| the pnpm workspace | `pnpm add <pkg>` | `minimumReleaseAge` in `pnpm-workspace.yaml` — automatic, nothing to type |
| the Python distribution | `bash scripts/cooldown.sh uv add <pkg>` | the wrapper; a bare `uv add` waits for nothing |
| a build backend | — | `scripts/build_dist.sh` already wraps `uv build` |
| GitHub Actions, Docker images | Dependabot | `cooldown.default-days` in `.github/dependabot.yml` |

Three days is **one number with four spellings**, and `tests/scripts/cooldown.test.mjs` holds them
to each other — `scripts/cooldown.sh` is the source, and moving it without moving the rest is a red
test rather than a quiet inconsistency.

**It is a resolution-time rule, and it is inert on every install path.** `pnpm install
--frozen-lockfile` and `uv sync --locked` install exactly what the lockfile names, cool-down or no
— that is the point, not a gap. The lockfile is the reviewed artifact; the cool-down polices what
gets into it. This is also why CI uses `--locked` rather than a bare `uv sync`: a plain `uv sync`
under a cutoff *discards the lockfile and re-resolves*, which would mean CI silently testing a set
nobody chose.

**When it fires.** `pnpm add <pkg>` with no version asks for `latest`, so a too-new release is
refused outright (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) rather than silently downgraded. Wait, or
name an older version. To take a young version deliberately, add it to `minimumReleaseAgeExclude`
in the same commit — an exception in the diff is one somebody can review. On the Python side, run
the bare command or set `VISIONSET_COOLDOWN_DAYS=0` for a single invocation.

**Security fixes are never delayed.** Dependabot's security updates bypass its own cool-down by
design, and nothing here re-imposes one on them.

## Checks that must stay green

**Run them with `bash scripts/check.sh`** (or `pnpm check` — the same script). It is the
Expand Down
9 changes: 8 additions & 1 deletion docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,14 @@ services:

minio:
profiles: ["minio"]
image: minio/minio:latest
# A named release rather than `:latest`, and it is the one image here that
# needed changing. `latest` is the single thing a cool-down structurally
# cannot cover: there is no version to be three days old, the tag is
# re-pointed by its publisher, and `docker compose pull` on the morning of a
# compromise fetches it with nothing in between. Every other service in this
# file already names a version. Dependabot's `docker-compose` entry now
# proposes the bumps, under the same three days as everything else.
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: visionset
Expand Down
7 changes: 6 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ page will not show you a year from now, attached to the commit they came from.
publishing a package that looks official:

```bash
npm org create visionset # or: npm login && npm access …
pnpm login --registry https://registry.npmjs.org # then create the org in the npm web UI
```

pnpm is the only Node package manager this repository uses, and `pnpm login` writes the same
credential `pnpm publish` would later read. Creating the *organisation* is a registry
administration action with no client-side equivalent in any package manager — it is done on the
npmjs.com website, not from a terminal.

Creating the organisation reserves the scope. **No placeholder publishes** — an empty package on
npm is a thing users find, file issues against, and depend on by accident, and un-publishing it
later is a worse problem than the one it was meant to prevent.
Expand Down
26 changes: 26 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,28 @@
packages:
- "frontend/*"

# The dependency cool-down, Node's half. See scripts/cooldown.sh for the rule and
# the argument; this is the same three days, in the units pnpm wants (minutes).
#
# Declarative rather than a wrapper, because pnpm has the setting uv does not:
# it applies to every `pnpm add` and `pnpm update` on a laptop, in
# docker/app.Dockerfile — which copies this file, so the image inherits it — and
# in CI, with nothing to remember and no flag to forget.
#
# It is a *resolution*-time rule and is inert under `--frozen-lockfile`, which is
# what every automated install here uses. That is deliberate: the lockfile is the
# reviewed artifact, and re-litigating it at install time would only mean CI
# stopped testing the pinned set.
#
# What it feels like when it fires: `pnpm add <pkg>` with no range asks for
# `latest`, so if the newest release is younger than this, pnpm refuses outright
# with ERR_PNPM_NO_MATURE_MATCHING_VERSION rather than quietly picking an older
# one. That is the intended answer — wait, or name an older version explicitly.
# To take a young version deliberately, add it to `minimumReleaseAgeExclude`
# below in the same commit, so the exception is reviewable.
minimumReleaseAge: 4320

# Packages allowed to skip the wait. Empty, and a new entry should say why in the
# commit that adds it — an exclusion with no stated reason is a cool-down quietly
# switched off for one package.
minimumReleaseAgeExclude: []
Loading
Loading