01Services02Process03Projects04About05FAQ06Blog07Hire Me

25+ products shipped · $3.8M+ raised by clients

Back to Blog
AI EngineeringPart 1 of 6October 2, 20268 min read

One row tells the whole story

Subhankar Denria

Subhankar Denria

Software Architect · Product Engineer

~8 min

What this part does

Decide where the truth about a running job lives. Get this right and every later question in the series has one reliable place to be answered.

The decision
One database row, or something cleverer
Costs to run
Nothing — it's a row you already needed
What you get
Progress that survives restarts, deploys and everything in between

A real moment: a deploy at minute two

Deploys don't wait for users. This is the situation the whole part is built around, and the reason the answer is a database row rather than anything cleverer.

A deploy lands at minute two

The moment

An admin presses the button and watches the research begin. Two minutes in, a routine deploy goes out and every process on the server restarts.

A typical first build

  • Progress lived in the process that was doing the work, so it goes with the restart
  • The page stops at step three and stays there
  • The admin refreshes, sees nothing, and starts again from zero

This build

  • The worker finishes the run it is on before it restarts
  • The page keeps reading the same row and carries on from step three
  • The admin never learns a deploy happened

How: all state is one database row, read by a two-second poll. Nothing that restarts is holding anything.

The problem with a job you can't see

The work doesn't happen in the request. The user presses a button, the request returns immediately, and the real work starts somewhere else entirely — in a worker process, on a different core, possibly on a different machine.

So the page now has a genuine problem. It has to show a user what's happening inside a process it cannot see, cannot ask, and has no connection to.

There are glamorous answers to this. A websocket channel. An in-memory progress registry. An event bus. Every one of them has the same flaw: the state lives in a process, and processes restart. A job that is running perfectly well should still be visible after a deploy.

The unglamorous answer is one row in one table, and it holds up better than any of them.

Four columns run the whole interface

Every run is one row in idea_runs. The browser, the queue worker, the usage limits and the support commands all read and write that row and nothing else.

Four states, and a clean exit

queued●

waiting for a worker

▼

researching

the long, paid stage

▼

ideating

one more model call

▼

completed

ideas on the page

▼
If anything interrupts itThe run is closed straight away, with a clear message

failed

Shown to the user, and it doesn’t count against their allowance

A run only ever moves forward, and every run ends in one of two clear states.

A status alone isn't enough to draw a useful page, though. A four-minute stage deserves more than one word. Four columns do the real work:

status

Written by
The job
What it's for
Where in the lifecycle the run is

current_step + steps

Written by
markStep()
What it's for
Which of the eight stages are done, and a one-line result for each

current_activity

Written by
reportActivity()
What it's for
What is happening right now inside a step — "Reading example.org"

step_started_at

Written by
markStep()
What it's for
Lets the page say "45s on this step", so slow doesn't read as frozen

Two small details in those writers matter more than they look.

The step counter can't go backwards. markStep() uses max(current_step, number). Stages can report slightly out of order, and a bar that only ever moves forward is one a user can trust.

The activity column is written constantly. reportActivity() fires on every single tool call the model makes — dozens of times in a run. So it writes exactly one column and nothing else: one small UPDATE, no other columns touched. Keeping that write tiny is what lets the page narrate every search and every page read.

How the page watches: it just asks, repeatedly

The progress page calls a JSON endpoint every two seconds. The first call goes out after 1.2 seconds, so the first tick lands almost immediately and the page feels alive from the first second.

One request returns everything the page needs:

json
{
  "status": "researching",
  "percentage": 38,
  "steps": [ { "number": 3, "state": "active", "detail": null } ],
  "activity": "Searching: [organisation] events 2026",
  "waiting": false,
  "seconds_on_step": 41,
  "verified": [ ],
  "sources": [ ],
  "finished": false,
  "redirect": null
}
One endpoint, every two seconds

Requests

  • 1.2s—
  • 3.2s—
  • 5.2s—
  • 7.2s—

…and on, until the run finishes.

What the page draws from it

step1 of 8

current_activity

—

Sources and verified findings arrive in the same payload, so the panels fill in as the run goes.

Each tick is one indexed read by primary key. On a three-minute job, nobody can tell two seconds late from instant.

Note what's in that payload: verified and sources. The findings from the research stage are sent as they're confirmed, so the "Verified information" and "Sources" panels fill in while the user watches. They are not staring at a spinner for three minutes waiting for everything at once. They're watching something get built.

"Why not websockets?" — the honest answer

This is the part people argue about, so here is the actual reasoning rather than a principle.

Three ways to show progress

Polling

A request every 2 seconds

Costs
Nothing new to run

Against
Up to 2s stale — invisible here

Server-sent events

One long-lived response

Costs
No new daemon

Against
Holds a web worker open for the whole run

WebSockets

A real-time channel

Costs
A new server to run and monitor

Against
One more service to operate, for one feature

The answer flips with many concurrent viewers, sub-second updates, or a real-time feature already in the product.

The app is a conventional request-and-response web app with a database-backed queue. Adding a websocket server for one feature means a new process to run, a new thing to monitor, and a new thing to explain to whoever is on call. Server-sent events are lighter and would work — but they hold a web worker open for the whole run, and that worker should be free to serve everyone else.

Against that: a poll every two seconds, hitting an indexed primary key, costs almost nothing. And on a three-minute job, nobody can tell the difference between instant and two seconds late.

The calculation flips completely if you have many concurrent viewers, sub-second updates, or a real-time feature already in the product. Many frameworks now ship a first-party websocket server, so the cost of that choice is lower than it used to be. It just wasn't lower than zero, which is what polling cost here.

The row is addressable, so guard it

There is a catch to making one row the centre of everything. Routes bind to it by ID:

text
GET /ideas/{run}

A route that looks a record up by its ID will happily resolve any run, including one belonging to a different organisation. A run's research can contain an organisation's plans and its performance history. A guessable ID must never hand that over.

So every action checks ownership explicitly, before it does anything else:

pseudocode
function authoriseRun(run, organisation):
    if run.organisation_id != organisation.id:
        respond 403 Forbidden

That covers the progress page, the status endpoint, and the approve, dismiss and refine actions. Two tests attempt cross-organisation access and expect a 403, so the guarantee is proven on every build rather than assumed.

What this bought

By the end of this decision, every later problem in the series has somewhere to be solved:

  • An interrupted run is a row with an old timestamp, so it's found and closed automatically.
  • Usage limits are a count over rows, not a counter that can drift.
  • Support can unblock a customer by updating a row, with no deploy.
  • Every run leaves a complete record behind, which is why the cost reporting in Part 4 is a SQL query rather than a trip to a provider's dashboard.

All of that comes free once the truth is a row.

Let's connect

Choose your preferred way

Available for new projects