Types
The canonical shapes a worker's environment is described by, the status it reports back, and the worker status vocabulary with its transition graph.
Why it exists
A script generator needs to know what a feature is, and it cannot import a Drizzle table — let alone
two, one on Postgres and one on SQLite. So the dependency would run the wrong way. Instead, the
package owns the types, and each product's ORM schema declares its JSON columns with $type<…>()
pointing at them:
import type { ProjectFeature, Repository, SetupStatus } from "@spunto/build/types"
export const projects = pgTable("projects", {
features: jsonb("features").$type<ProjectFeature[]>(),
repositories: jsonb("repositories").$type<Repository[]>().notNull(),
})
export const workers = pgTable("workers", {
setupStatus: jsonb("setup_status").$type<SetupStatus>(),
})That makes them versioned contract. SetupStatus in particular is what a shell script writes
into a file inside a container and a control plane reads back out minutes later.
Changing SetupStatus (or the worker status vocabulary) is a migration, not an edit: workers
running today write the old shape, and rows stored today hold it.
These are the strict, canonical shapes a control plane writes. The design system deliberately models the same data as lax, optional shapes so a UI degrades instead of throwing; TypeScript's structural typing joins the two without either package importing the other.
Project shapes
ProjectFeature
A devcontainer feature as a project selects it.
Prop
Type
Repository
A repository cloned into a worker's workspace.
Prop
Type
Setup status
SetupStatus
What a worker reports about its own setup. Written to WORKER_STATUS_FILE
(./naming) by the generated setup script, read back
by the control plane. The JSON layout is in
Formats → Worker status.
type RepoSetupState = "pending" | "cloning" | "done" | "error"
/** `null` = the project has no such command. */
type LifecycleState = "pending" | "running" | "done" | "error" | nullProp
Type
{
"phase": "cloning",
"repos": [{ "name": "acme/app", "state": "cloning" }],
"postCreate": "pending",
"postStart": null,
"timings": { "initializing": 1767225600000, "credentials": 1767225601200, "cloning": 1767225603400 }
}timings is optional and absent on older workers and hard crashes, and any key may be missing.
Derive a segment's duration as "next present milestone minus this one", never by assuming
the next key exists.
RepoSetupState and LifecycleState are not exported by name; use SetupStatus["repos"][number]["state"]
and SetupStatus["postCreate"] if you need them.
BuildStep, BuildStepKind, BuildStepState
Re-exported from ./steps, so an ORM schema can type a
build's persisted step plan from this entry point too.
Worker status vocabulary
The list of statuses a worker row can carry, what a status carries with it, and which transitions are allowed — a versioned contract between control planes, not an application detail.
WORKER_STATUSES / WorkerStatus
const WORKER_STATUSES = [
"provisioning", "building", "pulling", "starting", "ready",
"stopping", "stopped", "deleting", "exited", "unknown", "error",
] as const
type WorkerStatus = (typeof WORKER_STATUSES)[number]No product is required to emit them all. Spunto Lite has no pulling, stopping or
deleting — its Docker socket is local, so those network round-trips never get a name. The list
is the union, not an exhaustiveness contract.
Three different "something is wrong" statuses, told apart on purpose:
stopped— someone asked for it;exited— the container died on its own;error— a platform operation failed.
WorkerStatusReason
Why a worker is in its status:
| Reason | Meaning |
|---|---|
exited | The container ended on its own with a non-zero code. |
oom-killed | Same, killed by the kernel for lack of memory (OOMKilled, or code 137). |
completed | Same, code 0: the main process returned. Neither a requested stop nor a failure. |
removed-externally | The container is no longer in the node's inventory while it was expected there. |
setup-failed | Setup inside the container failed (clone, postCreate…). |
build-failed | The project image could not be built. |
spawn-orphaned | The spawn wait died with the control-plane process. |
node-lost | The node disappeared during an in-flight operation, before a container existed. |
node-disconnected | The node disappeared while the container existed: nothing is known, nothing is claimed. |
WorkerStatusMeta
What a status carries with it. One object, so a new case is not a migration.
Prop
Type
previousStatus is not decorative. Switching to unknown would otherwise destroy the last
known status, and what the UI offers depends on it: "unknown, was ready" and "unknown, was
stopped" open different buttons.
WORKER_STATUS_TRANSITIONS
const WORKER_STATUS_TRANSITIONS: Record<WorkerStatus, readonly WorkerStatus[]>The allowed transitions, as a graph, not an ordered list — the lifecycle is not linear.
building is a wait nested inside provisioning, so building → provisioning is legitimate.
| From | Allowed next |
|---|---|
provisioning | building, pulling, starting, error, deleting |
building | provisioning, pulling, starting, error, deleting |
pulling | starting, error, deleting |
starting | ready, stopping, stopped, exited, unknown, error, deleting |
ready | starting, stopping, stopped, exited, unknown, error, deleting |
stopping | stopped, error, deleting |
stopped | starting, unknown, error, deleting |
exited | starting, stopped, unknown, error, deleting |
unknown | ready, starting, stopped, exited, error, deleting |
deleting | error |
error | provisioning, starting, ready, stopped, exited, unknown, deleting |
Two decisions are encoded here:
- A pre-container worker never goes
unknown.provisioning,buildingandpullinglead toerrorwhen the node drops: the wait lived in the control plane and nobody will resume it.unknownmeans "the container probably exists, we can't see it" — there is nothing to not see before it exists. deletingleads nowhere except failure: afterwards the row no longer exists, and "deleted" is not a status.
canTransition(from, to)
function canTransition(from: WorkerStatus, to: WorkerStatus): booleantrue if from → to is in the graph.
async function setStatus(worker: Worker, to: WorkerStatus, meta: WorkerStatusMeta = {}) {
if (!canTransition(worker.status, to)) {
logger.warn(`refusing ${worker.status} → ${to} for ${worker.id}`)
return
}
const previousStatus = to === "unknown" ? worker.status : undefined
await db
.update(workers)
.set({ status: to, statusMeta: { ...meta, previousStatus, at: new Date().toISOString() } })
.where(eq(workers.id, worker.id))
}isSettingUp(status)
function isSettingUp(status: WorkerStatus): booleanThe worker is not usable yet and the platform is working on it: provisioning, building,
pulling or starting. This is the predicate that decides between a progress bar and a button.
unknown is excluded — announcing a setup in flight for a machine nobody can see would be exactly
the lie that status exists to avoid.
isTerminal(status)
function isTerminal(status: WorkerStatus): booleanNothing moves on its own any more; an action is needed to leave: stopped, exited or error.
unknown is not terminal (the node reconnecting will settle it), and neither is deleting (it
ends with the row disappearing, not with a status).