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
12 changes: 9 additions & 3 deletions docs/content/batches.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,15 +506,21 @@ otherwise loop, which is the shape `SchemaChangeWouldOrphan` already argues for.

```bash
visionset batch list --project road-signs
visionset batch approve "$BATCH" --jobs-of 100
visionset batch approve "$BATCH" --jobs-of 100 [--start]
visionset batch start "$BATCH"
visionset batch pre-label "$BATCH" CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE ...]
visionset batch complete "$BATCH"
visionset batch complete "$BATCH" [--promote]
visionset batch promote "$BATCH"
```

Each is one service call, and the listing carries the progress counts because a batch's name and
state do not say whether anybody has started on it.
state do not say whether anybody has started on it. `--start` and `--promote` are the next
command made in the same run - `approve` then `start`, `complete` then `promote` - and
`ingest --start` is `approve` (one job) then `start` on the batch it filled. No transition is
added for them: `start` needs only `approved` and `promote` only `completed`, so the second call
is legal whenever the first succeeded, and each commits on its own. A refused second step
leaves the first step's state in place and the output names it. The MCP tools carry the same
pair as `start` and `promote` parameters, reported back as `started` and `promoted`.

**`--jobs-of N` is `BySize`; with no flag the batch becomes one job.** There is no flag for
`BySegments`, and that is a decision rather than an omission: its own contract is that the caller
Expand Down
28 changes: 21 additions & 7 deletions docs/content/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ visionset schema draft set FILE --project P [--kind K] [--note TEXT] [--revision
visionset schema draft clear --project P [--kind K]
visionset schema draft publish --project P [--kind K] [--revision N] [--allow-destructive]

visionset ingest PATH --project P [--fps N] [--range S:E]... [--batch-name NAME]
visionset ingest PATH --project P [--fps N] [--range S:E]... [--batch-name NAME] [--start]
visionset batch list --project P
visionset batch approve BATCH_ID [--jobs-of N]
visionset batch approve BATCH_ID [--jobs-of N] [--start]
visionset batch pre-label BATCH_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE]...
visionset project pre-label PROJECT CONNECTION [--batch BATCH_ID]... [--minimum-confidence FLOAT] [--geometry SHAPE]...
visionset batch start|complete|promote BATCH_ID
visionset batch start|promote BATCH_ID
visionset batch complete BATCH_ID [--promote]

visionset job list --batch BATCH_ID
visionset job next JOB_ID [-n COUNT]
Expand Down Expand Up @@ -69,11 +70,9 @@ visionset project create road-signs
visionset schema apply schema.json --project road-signs
BATCH=$(visionset ingest ./incoming --project road-signs)

visionset batch approve "$BATCH" --jobs-of 100
visionset batch start "$BATCH"
visionset batch approve "$BATCH" --jobs-of 100 --start
# …annotate, in the app or through `visionset job mark`…
visionset batch complete "$BATCH"
visionset batch promote "$BATCH"
visionset batch complete "$BATCH" --promote

visionset release publish --tag v1.0 --project road-signs --split 0.7,0.15,0.15
visionset release verify v1.0 --project road-signs && \
Expand Down Expand Up @@ -367,6 +366,21 @@ and touching ranges merged. The run is **synchronous**, and there is no
`promote`. Each maps to the `BatchService` method of the same name, except `promote`, which is
`DatasetService.promote` - it takes a *batch* id and derives the dataset, which is why it lives here.

**Two of the walk's steps carry the next one as a flag.** `approve --start` approves and then
starts; `complete --promote` completes and then promotes; `ingest --start` ingests, approves as one
job, and starts. Each is the two commands it names, made one after the other, and nothing new
in the kernel: `start` requires only `approved`, and `promote` only `completed`, so the second
step is legal exactly when the first succeeded. The first step commits before the second is
attempted, and a refusal in the second prints the first step's line, then the refusal as the
single-step command would print it, then a line naming which step refused and the state the
batch is actually in - `The approve step refused; batch … is draft.` - so nothing has to be
guessed from the exit code. The commonest refusal is `ingest --start` into a project with no
schema, which leaves the draft the ingest made. Plain output prints both outcomes; `--json`
prints the batch document the walk ended on, except `complete --promote --json`, which prints
`{"batch": …, "promoted": …}` - the closed batch beside the page of assets that entered the
dataset. Stdout stays the batch id alone in every composed form, so `BATCH=$(visionset ingest
./incoming --project P --start)` hands the next command an open batch.

`pre-label BATCH_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry
SHAPE]...` blocks and calls `visionset.inference.pre_label` inline because a terminal has no
dispatcher, once per open job of the batch: a run is over one job's assets, so the batch command
Expand Down
4 changes: 2 additions & 2 deletions docs/content/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ error envelope, and the three gate words.
| `backfill_thumbnails` | `project` | Render the previews that are missing for a project's assets. |
| `list_batches` | `project` | List a project's batches with where each one's assets have got to. |
| `get_batch` | `batch_id` | Read one batch: its state, its schema pin, its progress and its jobs. |
| `approve_batch` | `batch_id`, `jobs_of`? | Freeze a batch, pin the project's active schema, and cut it into jobs. |
| `approve_batch` | `batch_id`, `jobs_of`?, `start`? | Freeze a batch, pin the project's active schema, and cut it into jobs. |
| `start_batch` | `batch_id` | Open an approved batch for annotation. |
| `get_pre_label_plan` | `batch_id`, `connection`, `geometries`? | Which classes a pre-labeling run of that connection over this batch would ask about, which it would leave out, and what shapes it would write. |
| `pre_label_batch` | `batch_id`, `connection`, `minimum_confidence`?, `replace_model_labels`?, `geometries`? | Ask a model to label every untouched asset in a batch, one run per open job. |
Expand All @@ -51,7 +51,7 @@ error envelope, and the three gate words.
| `delete_annotations` | `job_id`, `annotation_ids` | Remove annotations from a job. All succeed together or none are removed. |
| `set_asset_progress` | `job_id`, `asset_id`, `progress` | Record where one asset of a job has got to, without writing annotations. |
| `complete_job` | `job_id` | Close a job, once every one of its assets has been settled. |
| `complete_batch` | `batch_id` | Close a batch, once every one of its jobs is complete. |
| `complete_batch` | `batch_id`, `promote`? | Close a batch, once every one of its jobs is complete. |
| `promote_batch` | `batch_id` | Move a completed batch's finished assets into the project's dataset. |
| `create_correction_batch` | `batch_id`, `name`, `asset_ids`? | Start a draft batch that corrects a completed one. |
| `dataset_stats` | `project` | Count what is in a project's dataset, class by class. |
Expand Down
17 changes: 15 additions & 2 deletions docs/content/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ page groups them by what they are for.
| `create_batch` | Start a draft over a chosen set of a project's assets. |
| `add_batch_assets` | Put assets into a draft. |
| `remove_batch_assets` | Take assets out of a draft. Deletes nothing. |
| `approve_batch` | Freeze it, pin the schema, cut it into jobs. |
| `approve_batch` | Freeze it, pin the schema, cut it into jobs. `start: true` opens it for annotation in the same call, and `started` in the answer says whether that happened. |
| `start_batch` | Open it for annotation. |
| `get_pre_label_plan` | Which classes a run of a connection would ask about, which it would leave out, and what shapes it writes. |
| `pre_label_batch` | Ask a model to label every untouched asset of a batch, one run per open job; one outcome per job. Blocks until it is done. |
| `pre_label_project` | The same, over every open batch of a project or the named ones; one outcome per open job. Blocks until done. |
| `repin_batch` | Move its schema pin onto the current active version. |
| `list_batch_assets` | What is in it, paged, with each asset's job and progress. `job_id` narrows it to one job's frames. |
| `complete_batch` | Close it, once every job is complete. |
| `complete_batch` | Close it, once every job is complete. `promote: true` moves the finished assets into the dataset in the same call, and `promoted` in the answer counts them. |
| `promote_batch` | Move the finished assets into the dataset. |
| `create_correction_batch` | Start a draft that corrects a completed one. |

Expand All @@ -134,6 +134,19 @@ the other one: picking a subset by hand, which is what the browser's gallery doe
agent doing the same work needs. All three are `draft` only, because past approval the batch
is already cut into jobs — see [batches.md](batches.md).

`approve_batch` and `complete_batch` each take the next step of the walk as a parameter, on the
`job_started` principle below: a move an agent asked for is made and reported, never made behind
it. `start: true` on `approve_batch` is `start_batch` in the same call, and `promote: true` on
`complete_batch` is `promote_batch` in the same call; `started` and `promoted` in the answers say
what each did, so an agent reading only the answer knows which state the batch is in and how
many assets entered the dataset - `promoted` is zero when nothing was asked to move and zero
again when the trunk already held every asset, since promotion is a union. The first step
commits before the second is attempted: a refusal is the second step's own, and it leaves the
batch where the first step put it - `approved`, or `completed` - which `get_batch` confirms. The
one refusal the composed forms meet in practice is approval's own, a project with no schema,
which leaves the batch a draft. `start_batch` and `promote_batch` are unchanged for a caller that
wants the steps apart.

### Jobs and annotations

| | |
Expand Down
15 changes: 11 additions & 4 deletions docs/content/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ which somebody is going to label.

```bash
BATCH=<the id ingest printed>
visionset batch approve "$BATCH" --jobs-of 100 --start
```

That is two moves in one command - `approve`, then `start` - and the same batch can be walked in
two commands when you want to look at the jobs before anybody opens them:

```bash
visionset batch approve "$BATCH" --jobs-of 100
visionset batch start "$BATCH"
```
Expand Down Expand Up @@ -150,12 +157,12 @@ the batch, then promote:

```bash
visionset job complete <job-id>
visionset batch complete "$BATCH"
visionset batch promote "$BATCH"
visionset batch complete "$BATCH" --promote
```

`complete` on the batch refuses while any job is still open: "derived" here means *recomputed*,
not automatic. **Promotion** is what moves assets into the trunk - a union against what is already
`--promote` is `batch promote` made right after `batch complete`; the two commands, one after
the other, do the same. `complete` on the batch refuses while any job is still open: "derived"
here means *recomputed*, not automatic. **Promotion** is what moves assets into the trunk - a union against what is already
there, so promoting twice adds nothing and re-promoting after a curator removed something puts it
back.

Expand Down
28 changes: 28 additions & 0 deletions docs/content/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,34 @@ it means typing tuples of UUIDs. Its `kind` is always sent explicitly - a
discriminated union's tag emitted by default reads as optional in the schema while
pydantic needs it in the dict to pick a variant.

#### Two steps as one

Two pairs of lifecycle steps are offered as one control, on the screens where a person
takes them together. **Approve and start** takes a draft through `approved` to
`in_annotation`, cut into one job - the common batch; a batch that needs splitting still
goes through *Approve*, whose dialog is where a partition is chosen. It sits on a draft's
row in the batch table, secondary like every row action, and on the ingest run card once
a run settles, where it is the card's one filled control and *Open batch* steps down
beside it. **Complete and promote** closes the batch and moves its work into the trunk;
it sits in the gallery header beside *Complete*, withheld the same way while any frame is
still to do, and says what the press moved in the same sentence *Promote* uses.

Neither is a new transition. Each control sends the two requests a person could send one
at a time, in order, and stops where the kernel stops it: a refusal on the second step
leaves the batch where the first step put it, and the control says so - the first step's
outcome as a line (*Approved against v3*, *Completed, finishing 1 job*), the second
step's refusal beneath it in the shared vocabulary, never in its place. The line stays
once the batch's own declaration has moved on, because it is the only thing on the
screen that explains the state the batch is now in.

*Approve and start* is offered only while the project has an active schema. Approval pins
one, and `allowed_actions` cannot say whether there is one to pin: that is a fact about
the project rather than a state of the batch, so the surface reads the schema itself.
Without one, a draft's row keeps its existing controls and the run card says in words
what approving needs, with the way to the schema section beside it, and keeps *Open
batch* filled. Only a schema that does not exist counts as none - any other failure of
that read says nothing about the project, and the card offers what it always did.

#### Deleting a batch, behind `⋯` and at two anchors

The one control on either of these screens that ends a batch rather than moving it
Expand Down
18 changes: 18 additions & 0 deletions frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,16 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
// Walking back through the project to find the batch instead would be a suite
// finding it by another road, which cannot notice that the screen offers none.
await expect(page.getByTestId("run-outcome")).toContainText("cycle-batch");
// The project declared a schema before this ingest, so the card's filled
// control is the composed step — approve as one job and open for work —
// and *Open batch* steps down beside it. The walk keeps to the two-step
// road below, which is the one that exercises the partition dialog.
await expect(page.getByTestId("approve-start-cycle-batch")).toHaveAttribute(
"data-variant",
"primary",
);
await expect(page.getByTestId("open-batch")).toHaveAttribute("data-variant", "secondary");
await expect(page.getByTestId("approve-needs-schema")).toHaveCount(0);
await page.getByTestId("open-batch").click();
await expect(page).toHaveURL(/\/projects\/[0-9a-f-]+\/batches\/[0-9a-f-]+$/);
});
Expand Down Expand Up @@ -471,6 +481,11 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await openProject(page, PROJECT, "batches");
await expect(page.getByTestId("batches-table")).toBeVisible();
await expect(page.getByTestId("batch-cycle-batch")).toContainText("pending approval");
// A draft row offers the composed step too, secondary like every row action.
await expect(page.getByTestId("approve-start-cycle-batch")).toHaveAttribute(
"data-variant",
"secondary",
);

await page.getByTestId("approve-cycle-batch").click();
await page.getByTestId("approve-submit").click();
Expand All @@ -495,6 +510,9 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
// choose from — the job sits flat under the header, and the batch bar is
// the page's one bar.
await expect(page.getByTestId("start-annotating")).toHaveCount(0);
// The composed closing move sits beside Complete and is withheld the same
// way while every frame is still to do.
await expect(page.getByTestId("complete-promote-cycle-batch")).toBeDisabled();
await expect(page.getByTestId("job-panels")).toHaveCount(0);
await expect(page.getByTestId(/^job-header-/)).toHaveCount(0);
const workspace = page.getByTestId("job-workspace");
Expand Down
67 changes: 66 additions & 1 deletion frontend/app/e2e/gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
// What this run has removed, so the listing and the counts move with the
// DELETE the same way the server's would.
const removed = new Set<string>();
// What a promotion moved into the trunk, read back by the batch the way the
// server derives `promoted_asset_count` per read.
let promoted = 0;

await page.route("**/api/**", async (route) => {
const request = route.request();
Expand Down Expand Up @@ -371,12 +374,42 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
asset_count: counts.total - removed.size,
progress: counts,
allowed_actions: batchActions(current),
promoted_asset_count: 0,
promoted_asset_count: promoted,
parent_batch_id: null,
pre_label_run: null,
} satisfies Wire["BatchOut"],
});
}
if (request.method() === "POST" && path === `/batches/${BATCH}/promote`) {
// Only finished work is promoted, and the kernel says so before this
// fixture would — a page that promoted first would meet the real refusal.
if (current !== "completed") {
return route.fulfill({
status: 409,
json: { code: "BATCH_NOT_COMPLETE", message: `batch 'drive-01' is '${current}'` },
});
}
promoted = counts.annotated;
return route.fulfill({
json: {
items: Array.from({ length: promoted }, (_, at) => ({
id: `asset-${at}`,
project_id: PROJECT,
modality: "image",
content_hash: `${at}`.padStart(8, "0") + "deadbeef",
width: 1280,
height: 720,
format: "jpeg",
source_id: null,
frame_index: at,
frame_timestamp: null,
thumbnail_hash: null,
ingested_at: "2026-08-01T09:00:00Z",
})),
total: promoted,
} satisfies Wire["AssetPage"],
});
}
if (request.method() === "DELETE" && path === `/batches/${BATCH}/assets`) {
// The kernel's own gate, kept rather than stubbed away: membership is
// editable in `draft` and nowhere else, so a page that offers this on an
Expand Down Expand Up @@ -732,6 +765,38 @@ test("the chosen density survives a reload", async ({ page }) => {
await expect(page.getByTestId("density")).toHaveValue("0");
});

// --- two steps as one --------------------------------------------------------

test("Complete and promote finishes the job, closes the batch, promotes, and says all three", async ({
page,
}) => {
const sent: Request[] = [];
await openGallery(page, sent, { settled: true });

const composed = page.getByTestId("complete-promote-drive-01");
await expect(composed).toHaveAttribute("data-variant", "secondary");
await composed.click();

// In order, and the job first: the batch refuses while its job is open.
const line = page.getByTestId("completed-drive-01");
await expect(line).toHaveText(
"Completed, finishing 1 job. Promoted 3 assets to the dataset. 45 skipped frames stayed out.",
);
const posts = sent
.filter((request) => request.method() === "POST")
.map((request) => new URL(request.url()).pathname.replace(/^\/api/, ""));
expect(posts).toEqual([
`/jobs/${JOB}/complete`,
`/batches/${BATCH}/complete`,
`/batches/${BATCH}/promote`,
]);
// The batch is `completed` now and declares no `complete`, so the button is
// gone — the line that says what it did is not.
await expect(composed).toHaveCount(0);
await expect(page.getByTestId("batch-state")).toHaveText("completed");
await expect(page.getByTestId("complete-promote-open-dataset-drive-01")).toBeVisible();
});

// --- one job, and several ----------------------------------------------------

test("a one-job batch draws its job flat: no accordion, one bar, the job's controls under the header", async ({
Expand Down
Loading
Loading