> ## Documentation Index
> Fetch the complete documentation index at: https://docs.morphllm.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reflex status flow

# Reflex status flow (read this before touching status code)

Reflex follows **OpenAI fine-tuning conventions**, so a job's *internal* status is not the same as the *OpenAI-compatible* value the API returns — and there are **two rows** per workspace. Those two facts cause \~all of the status confusion. Keep both in your head.

## Two rows per workspace

| Row          | What it is                                                                                                                       | Its `status` is…                        |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| **Chat row** | the user-facing workspace (`reflex_chats`, has a `title`, `source='dashboard'`)                                                  | the **dashboard** status the UI renders |
| **Job row**  | the spawned fine-tuning job — *also* a `reflex_chats` row (`title=NULL`, `source='api'`), referenced by the chat's `latestJobId` | the **backend/internal** status         |

The UI only ever renders the **chat row**. So a `status` like `prepared` shows up only on a **job row** — the user never sees it. (Gotcha: `reflex_training_queue.chat_id` stores the **job** uuid, not the chat id.)

## Three status vocabularies

* **Dashboard** (chat row, what the UI keys off): `chatting · generating · queued · training · ready · error`
* **Internal / backend** (job row): `queued · preparing · generating · labeling · prepared · training · ready · error · stopped`
* **OpenAI-compatible** (what `GET /v1/fine_tuning/jobs/{id}` returns): `validating_files · queued · running · succeeded · failed · cancelled`

## The intended lifecycle

```
chatting → generating → pending-approval → queued → training → ready
```

| # | Step                 | Dashboard         | Internal (job)                    | OpenAI-compat      | What the user sees                            |
| - | -------------------- | ----------------- | --------------------------------- | ------------------ | --------------------------------------------- |
| 1 | chatting             | `chatting`        | —                                 | —                  | the conversation / building the dataset       |
| 2 | generating           | `generating`      | preparing / generating / labeling | `validating_files` | "Generating… / Relabeling…" progress          |
| 3 | **pending approval** | **`chatting`** ⚠️ | **`prepared`**                    | `queued`           | the **review grid** — approve / edit the rows |
| 4 | queued               | `queued`          | `queued`                          | `queued`           | "queued to train"                             |
| 5 | training             | `training`        | `training`                        | `running`          | training progress                             |
| 6 | ready                | `ready`           | `ready`                           | `succeeded`        | model card / playground                       |

`error` / `stopped`(→`cancelled`) are terminal off-paths from any active step.

## The two lossy collapses (this is the trap)

`tab/reflex` `api/wire.py::map_reflex_status` maps internal → OpenAI-compatible, and it's lossy:

* `preparing / generating / labeling` → **`validating_files`**  (can't tell which)
* **`prepared` → `queued`**  (so step 3 and step 4 *both* read `queued`)
* `training → running`, `ready → succeeded`, `error → failed`, `stopped → cancelled`

Because OpenAI `queued` means **either** "prepared / pending approval" (step 3) **or** "queued to train" (step 4), the dashboard can't trust the raw OpenAI value. The seam that disambiguates is `src/lib/reflex/job-status.ts::resolveJobChatStatus`:

> OpenAI `queued` + review-gated (`auto_train:false`, not approved) + a draft (or `getJobDataset` rows) ⇒ step 3 (prepared) ⇒ dashboard **`chatting`** (review grid). A queue row counts as step 4 only when it is actually **`training`**, or when it is `queued`/`pending` on an **approved / auto\_train** chat. A merely-`queued` row on an *unapproved* chat is NOT the training line — the backend pre-creates the queue slot at job creation for supplied-`training_data` jobs (labeled uploads) and it idles `queued` until Approve & Train; reading it as "past review" froze upload grids at a phantom "queued" (chat 3f39a4f5). The #390 approve-race (train enqueued, wrapper's approved write lost) is repaired forward by the `/train` route's 409 handler instead.

**Every status writer must go through `resolveJobChatStatus`, not `jobStatusToChatStatus` directly** — bypassing it caused the #390 and #418 regressions.

## Known smell: "pending approval" is not a first-class status

Step 3 isn't its own dashboard status — it's **inferred** as `chatting` + an unapproved prepared dataset. So `chatting` is overloaded: it means both "just talking" *and* "review-ready." This works today (don't rush to change it), but it's why the review step is easy to mistake for "still going." If we ever make it first-class, the seam is already there: `check_status` returns an explicit `phase: 'awaiting_review'`.

## label\_data (relabel) notes

* Teacher-labeling runs through the **OpenAI Batch API** (`completion_window: "24h"`, `api/openai_batch.py`) — minutes to hours, **no synchronous path**. That's why fresh relabels are slow to test.
* A relabel reaches step 3 correctly (chat `chatting`, job `prepared`). The review grid for relabel is wired via `LabelDataCard` (it was previously only wired for `generate_data` / `map_upload`), and `check_status` reports a clear phase instead of leaking the raw OpenAI `queued` + phantom epochs.
