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
72 changes: 57 additions & 15 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,10 @@ The page the reference design shows (#56), with measurements verified in v1's so

`Save and next` is `go(1)`: the same save-first advance the navigator has always used,
so there is one save pipeline and one place principle 10 is enforced. It reads **`Next`
when no save will happen** — an untouched frame, or one that cannot be written to at
all — because the button never promises a save it will not perform.
when no save will happen** — an untouched frame — because the button never promises a
save it will not perform, and once the *job* is closed it is **not rendered at all**
(#439): there is no save-first advance to offer on any frame of it, and `›` is what
moves there. See *The read-only mode* below.

**On the last frame `Finish job` takes the filled slot**, in place: `Save and next` is
not rendered there, and Finish job is not rendered anywhere else (#416). There is
Expand Down Expand Up @@ -777,18 +779,36 @@ The page the reference design shows (#56), with measurements verified in v1's so
### The read-only mode

The workspace opens as a **viewer** whenever the wire withholds `annotate` on the
frame — a completed batch, or a settled frame inside an open one. The mode is the
frame's own declaration (`allowed_actions`), never this page's arithmetic; it was
made a mode at all by audit F2, which found "open it and let the saves fail"
shipping as the behaviour.

What a viewer is (decision of 2026-08-07, #426):
frame — a completed batch, a **finished job**, or a settled frame inside an open
one. The mode is the frame's own declaration (`allowed_actions`), never this
page's arithmetic; it was made a mode at all by audit F2, which found "open it
and let the saves fail" shipping as the behaviour.

**It is also a transition, not only an entry state** (decision of 2026-08-08,
#439). Pressing `Finish job` turns the workspace into a viewer **in place** —
same window, no navigation away, no reload — across every frame of the job and
not only the last. That works for one reason and it is worth stating: the
mutation invalidates the frames' declarations, the wire's answer moves because
the *kernel's* did (`asset_actions` reads the job's state), and the page
re-derives. Nothing here mirrors a rule. A job completing does not complete its
batch, so before #439 the wire's answer did not move and the workspace stayed an
editor over work it had just been told was over.

The press says so out loud — a toast, the add-a-class chain's idiom — because
everything else it does is a subtraction, and a screen with less on it is not an
explanation.

What a viewer is (decisions of 2026-08-07, #426, and 2026-08-08, #439):

- **One explanation surface.** The banner under the top bar says `Viewing only.`
with the cause, and — when the wire declares `create_correction` — the route
onward: `Correct this batch`. It renders on every frame of a closed batch,
including skipped ones (#423), where the skipped notice would otherwise promise
an Un-skip the wire withholds.
onward: `Correct this batch`. It renders on every frame of a closed batch or a
finished job, including skipped ones (#423, #439), where the skipped notice
would otherwise promise an Un-skip the wire withholds. Three causes, three
sentences, ranked: a **closed batch** carries the correction route; a
**finished job** names itself and offers nothing, because `JOB_TRANSITIONS` has
no way back and the batch is still open; a **settled frame** in an open batch
points at the control on this very toolbar.
- **No classes region.** The side panel is the objects region alone, at full
height — the region, its filter, its quick-create and its hotkey badges are
absent, not disabled, and `C` and the digits do nothing. This supersedes #420's
Expand All @@ -804,10 +824,32 @@ What a viewer is (decision of 2026-08-07, #426):
right-click menu uses — and the objects panel's row highlights and scrolls
into view. That reflection is both modes' behaviour, not the viewer's alone.
DOM focus stays with the canvas, which reads its chords off its own root.
- **Reads stay live.** Zoom, pan, fullscreen, the frame gallery, `‹` `›`,
visibility toggles, the object filter, and copy (`⌘C`) — the road a box takes
into a correction batch — all work; paste and every other write is refused at
the engine (`readOnly` on the canvas, `READ_ONLY_KINDS` for the keyboard).
- **Once the job is closed, the frame's own verbs go with it — and the job's
does not.** `Skip` / `Un-skip` and the flow verb (`Save and next` / `Next`)
stop rendering on every frame of a completed batch or a finished job: the tool
strip's rule applied to the bar, since all three only ever move *this frame*
and there is no move behind any of them there. The `browse | resolve` divider
goes with them.

**The gate is the job, never the frame**, and both halves of that are load
bearing. A merely *settled* frame inside a working job keeps the pair, greyed —
because the cluster is measured to one width (above) and a slot that emptied
and refilled as somebody walked a mixed job would move the arrows under their
cursor, and because `Un-skip` is the one way back out of a skipped frame, which
is itself read-only. A closed job withholds every move on every frame alike, so
the cluster is uniformly narrower and nothing jitters.

**`Finish job` stays** on the last frame: `complete` is the *job's* declaration
rather than the frame's, and a job whose last frame happens to be `accepted`
would otherwise have no way to be finished at all. Once it is finished it reads
`Finished`, which is this page's standing statement that the work is over.
- **Navigation stays whole.** `‹` `›`, the `n/m` counter and the frame gallery
all work, and no save-first guard engages — there is nothing to save. Only
editing dies; moving between pictures does not.
- **Reads stay live.** Zoom, pan, fullscreen, visibility toggles, the object
filter, and copy (`⌘C`) — the road a box takes into a correction batch — all
work; paste and every other write is refused at the engine (`readOnly` on the
canvas, `READ_ONLY_KINDS` for the keyboard).

### The canvas surround

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ argument for branching on `code`.
| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) |
| **405** | `METHOD_NOT_ALLOWED` |
| **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge |
| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_NOT_IN_ANNOTATION` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` |
| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_NOT_IN_ANNOTATION` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` |
| **422** | `VALIDATION_ERROR` · `INVALID_NAME` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` |
| **503** | `WORKSPACE_BUSY` |
| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `INTERNAL_ERROR` |
Expand Down
27 changes: 25 additions & 2 deletions docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,31 @@ The remedy is the transition table. `skipped → unannotated` is the take-it-bac
reversed while the job is open makes the asset writable again. `accepted` has no exit at all, by
design, which is why correcting accepted work means a new batch rather than a progress move.

Two gates, two questions: `BatchNotInAnnotation` says nobody opened this batch, and its remedy is
to start it. `AssetNotWritable` says this asset inside an open batch is done being labeled.
### …and so does a job that has already finished

```python
OPEN_JOB_STATES # {pending, in_progress}
```

The third gate, and the one that arrived last (#439). `JobService.complete` does **not** complete
the batch — `BatchService` derives that separately when asked — so the ordinary state of a
finished job is *inside a batch that is still open*. The batch gate therefore had nothing to say
about it, and until this existed a completed job went on accepting labels and progress moves: the
word "finished" describing nothing, and work landing where nobody would look for it.

`AnnotationService.add`, `update` and `delete` consult it, and so does `JobService.mark`; all four
answer `JobFinished` (409 `JOB_FINISHED`). Reads are untouched — `AnnotationService.for_asset`
passes no gate but membership, because a viewer over finished work has to be able to show it.

The remedy is not a move. `JOB_TRANSITIONS` gives `completed` no way back, by the same
forward-only argument a completed batch is immutable by, so correcting finished work means a
correction batch.

Three gates, three questions: `BatchNotInAnnotation` says nobody opened this batch, and its
remedy is to start it. `JobFinished` says this job inside an open batch is over, and it has no
remedy — a batch is partitioned into jobs that finish at different times, and the first to finish
freezes its own frames while its neighbours carry on. `AssetNotWritable` says this asset inside an
open job is done being labeled.

### Marking a state it is already in is a no-op

Expand Down
28 changes: 28 additions & 0 deletions frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,34 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await expect(page.getByTestId("job-progress")).toHaveText("3 / 3 annotated");
await page.getByTestId("finish-job").click();
await expect(page.getByTestId("finish-job")).toHaveText("Finished");

/*
* #439, against the real kernel and in place — no reload, no navigation.
*
* The batch is still `in_annotation` here (the next step is what completes
* it), which is exactly the case the batch gate cannot cover: `JobService`
* does not cascade upward. What moves is `asset_actions` reading the job's
* state, so the declarations this mutation invalidates come back empty and
* the page re-derives its mode from them. A stub can be made to say that;
* only this walk proves the kernel does.
*
* Worth stating about the frame we are standing on: it was already a viewer
* a moment ago, because `accepted` is not writable — and `Finish job` was
* still on the bar, which is why it could be pressed at all. That is the
* rule kept deliberately when the frame's own verbs left the read-only mode:
* `complete` is the job's declaration, not the frame's.
*/
await expect(page.getByTestId("readonly-banner")).toContainText(/this job is finished/i);
await expect(page.getByTestId("tool-palette")).toHaveCount(0);
await expect(page.getByTestId("class-region")).toHaveCount(0);
await expect(page.getByTestId("save-and-next")).toHaveCount(0);

// Every frame, not only the one it was pressed on — and navigation is what
// proves it, which is also half the decision: only editing dies.
await page.getByTestId("prev-asset").click();
await expect(page.getByTestId("asset-position")).toHaveText("2/3");
await expect(page.getByTestId("readonly-banner")).toContainText(/this job is finished/i);
await expect(page.getByTestId("tool-palette")).toHaveCount(0);
});

await test.step("complete the batch", async () => {
Expand Down
15 changes: 12 additions & 3 deletions frontend/app/e2e/_wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,21 @@ export function jobActions(
);
}

/** An asset's actions. `progress` is null exactly while the batch is a draft. */
/**
* An asset's actions. `progress` is null exactly while the batch is a draft.
*
* `jobState` is the third dimension (#439) and defaults to `in_progress`, the
* state a job is in while somebody is working it — which is what every caller
* here means. A `completed` job declares nothing on any of its frames, in an
* open batch as much as in a closed one: completing a job does not complete its
* batch, so the batch dimension cannot cover this.
*/
export function assetActions(
progress: string | null,
options: { batchState?: string } = {},
options: { batchState?: string; jobState?: string } = {},
): string[] {
const { batchState = "in_annotation" } = options;
const { batchState = "in_annotation", jobState = "in_progress" } = options;
if (batchState !== "in_annotation" || progress === null) return [];
if (jobState === "completed") return [];
return [...(ASSET_ACTIONS[progress as Progress] ?? [])];
}
Loading
Loading