Introduces the data layer for the Workload refactor (see docs/plans/workload-refactor.md): three new tables and store methods, no behavior changes elsewhere yet. - workloads: unifying primitive over Project/Stack/StaticSite, paired via UNIQUE(kind, ref_id). Notification + webhook config hosted here so it lives in one place across kinds. - containers: normalized index of every Tinyforge-managed container with first-class subdomain/proxy_route_id/npm_proxy_id columns (heavily queried by ListProxyRoutes / stale detection). - apps: optional grouping of workloads; schema only, no UI in v1. Foundation only — deployer surgery, reconciler, and consumer switchover land in the next commit.
14 KiB
Workload Refactor — Compressed Plan
Status: Draft, pre-implementation Owner: alexei.dolgolyov Date: 2026-05-07
Goal
Unify Project, Stack, and StaticSite under a single Workload primitive, and introduce a normalized containers index so every Tinyforge-managed container has one canonical row. This unblocks a global Containers view today and lets future workload kinds (cron jobs, one-shot tasks, databases-as-resource, functions) plug in without another tab/store/deployer branch.
Why this is the compressed plan
The original 8-PR plan was designed for a live system with dual-writes and soak periods. Tinyforge has no production users yet, so all defenses against live runtime state collapse: no external label consumers, no third-party CI hitting webhook URLs, no orphaned containers to recover. Everything ships in 3 PRs against a clean slate. Solo-dev reversibility is preserved by branching, not by dual-write gymnastics.
Target architecture
Workloadis the unifying primitive withkind ∈ {project, stack, site, …}. Each existing Project/Stack/StaticSite becomes a Workload row.containersis a normalized index: every Tinyforge-managed container has one row withworkload_id,workload_kind,role, Docker container ID, host, state, last_seen.- Optional
appstable (thin nullableapp_idon Workload) added empty; UI gated behind a feature flag, defer indefinitely until pull. - Stable Docker labels:
tinyforge.workload.id,tinyforge.workload.kind,tinyforge.role,tinyforge.managed. Legacytinyforge.project/tinyforge.stage/tinyforge.instance-idare removed in the same wave. - Global
/containersUI route; per-workload container panel becomes a shared<WorkloadContainers>component reused by project, stack, and site detail pages.
Schema
Appended to internal/store/store.go::runMigrations() as additive CREATE TABLE statements (idempotent via CREATE TABLE IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS workloads (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- 'project' | 'stack' | 'site'
ref_id TEXT NOT NULL, -- FK into projects/stacks/static_sites by kind
name TEXT NOT NULL,
app_id TEXT, -- nullable FK into apps.id
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
webhook_secret TEXT NOT NULL DEFAULT '',
webhook_signing_secret TEXT NOT NULL DEFAULT '',
webhook_require_signature INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(kind, ref_id)
);
CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id);
CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind);
CREATE TABLE IF NOT EXISTS containers (
id TEXT PRIMARY KEY,
workload_id TEXT NOT NULL,
workload_kind TEXT NOT NULL, -- denormalized for filtered queries
role TEXT NOT NULL, -- stage name (project), service name (stack), '' (site)
container_id TEXT NOT NULL DEFAULT '', -- Docker ID, '' between create+start
image_ref TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT 'local',
state TEXT NOT NULL DEFAULT '', -- running | stopped | failed | removing | missing
port INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL DEFAULT '',
extra_json TEXT NOT NULL DEFAULT '{}', -- {subdomain, npm_proxy_id, proxy_route_id, ...}
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_containers_workload ON containers(workload_id);
CREATE INDEX IF NOT EXISTS idx_containers_state ON containers(state);
CREATE INDEX IF NOT EXISTS idx_containers_container_id ON containers(container_id);
CREATE TABLE IF NOT EXISTS apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
extra_json carries kind-specific fields (subdomain, npm_proxy_id, proxy_route_id) so the spine stays narrow. SQLite JSON1 is required for queries against extra_json; verify the driver in go.mod supports it before committing — fall back to dedicated columns if not.
PR 1 — Spine: schema, Workload package, reconciler
Single PR, lands the data layer end-to-end. No dual-writes; project/stack/site CRUD writes directly to workloads.
New files
internal/store/workloads.go—CreateWorkload,GetWorkloadByID,GetWorkloadByRef(kind, refID),ListWorkloads,UpdateWorkload,DeleteWorkload.internal/store/containers.go—UpsertContainer,GetContainerByDockerID,ListContainersByWorkload,ListContainers(filter),MarkContainerMissing, newListProxyRoutes(mirrors the join shape frominternal/store/instances.go::ListProxyRoutes, readingextra_jsonviajson_extract).internal/store/apps.go— minimal CRUD; not wired anywhere yet.internal/workload/workload.go—Workloadinterface (ID,Kind,Name,Deploy,Stop,Start,Delete,Containers).internal/workload/adapters/project_adapter.go— wrapsinternal/deployer.internal/workload/adapters/stack_adapter.go— wrapsinternal/stack/manager.go.internal/workload/adapters/site_adapter.go— wrapsinternal/staticsite/manager.go.internal/reconciler/reconciler.go— single writer tocontainers. Readsdocker ps --filter label=tinyforge.managed, groups by(workload.id, role), upserts rows, marks absent rowsstate='missing'. Boot-time one-shot run + 30s tick.internal/reconciler/reconciler_test.go— table-driven tests with a fake Docker client.
Modified files
internal/store/store.go::runMigrations— append the threeCREATE TABLEstatements (after line ~165 where the existing migrations end).internal/store/models.go— addWorkload,Container,Appstructs.internal/store/projects.go—CreateProject,UpdateProject,DeleteProjectwrap the write ins.db.Begin()and also write the matchingworkloadsrow. Webhook/notification secret setters updateworkloads.webhook_secret/webhook_signing_secret/notification_secretdirectly.internal/store/stacks.go— same Workload write onCreateStack/UpdateStack/DeleteStack.internal/store/static_sites.go— same.internal/docker/client.go— add label constantsLabelWorkloadID,LabelWorkloadKind,LabelRole,LabelManaged. Remove the oldLabelProject,LabelStage,LabelInstanceIDwrites from the deployer.internal/deployer/deployer.go(label injection ~line 388) — emit only the new labels.internal/deployer/bluegreen.go(~line 97) — same.internal/stack/manager.go— afterdocker compose up, stamp new labels on each compose-managed container viadocker container update --label-add. Compose's owncom.docker.compose.servicebecomesrole.internal/staticsite/manager.go— stamp new labels at container start.internal/store/instances.go— delete this file. The deployer no longer creates instance rows; reconciler owns container state.internal/api/instances.go— delete or alias to/api/containersfiltered by workload. Solo dev → delete is cleaner.internal/api/proxies.go— switch theListProxyRoutesimport tocontainers.ListProxyRoutes.internal/api/docker.go::buildActiveImagesSet(~line 251) — replace theListAllInstanceswalk with a singlecontainers.image_refquery.internal/api/stale.go,internal/stale/scanner.go— read fromcontainersinstead ofinstances.internal/webhook/matcher.go— queryworkloads.webhook_secretdirectly.cmd/server/main.go— start the reconciler goroutine afterstore.New. Drop any startup code that touchedinstances.
Tests
- Extend
internal/store/store_test.gowithTestCreateProjectAlsoCreatesWorkload,TestDeleteProjectCascadesWorkload,TestUpsertContainerIdempotent,TestListProxyRoutesShape. - New
internal/reconciler/reconciler_test.gowith adockerClientinterface and a fake — assert that a slice oftypes.Containerproduces the expectedcontainersupserts. - Run the existing test suite under
-race.
Deliverable
System builds, deploys a project end-to-end, deploys a stack end-to-end, deploys a static site end-to-end. containers table reflects reality after each deploy and after a 30s reconciler tick. The legacy instances table is gone.
PR 2 — API + frontend
New files
internal/api/workloads.go—GET /api/workloads,GET /api/workloads/{id},GET /api/workloads/{id}/containers,PATCH /api/workloads/{id}(setsapp_idand notification/webhook config).internal/api/containers.go—GET /api/containers?workload_id=&kind=&state=&app_id=,GET /api/containers/{id}.internal/api/apps.go—GET /api/apps,POST /api/apps,PATCH /api/apps/{id},DELETE /api/apps/{id}(gated by settings flagfeatures.apps_grouping=true).web/src/routes/containers/+page.svelte— global filterable table. Reuses table patterns fromweb/src/routes/proxies/+page.svelteandweb/src/routes/containers/stale/+page.svelte(the existingstale/route stays untouched).web/src/lib/components/WorkloadContainers.svelte— shared container panel. TakesworkloadIdprop, hits/api/workloads/{id}/containers. Handles 1..N container rows.
Modified files
internal/api/router.go— register the new endpoints. Remove/api/instancesregistration.web/src/routes/projects/[id]/+page.svelte— replace the inline instance list with<WorkloadContainers workloadId={...}/>.web/src/routes/stacks/[id]/+page.svelte— same.web/src/routes/sites/[id]/+page.svelte— same.- Top nav component (find under
web/src/lib/components/) — insert a "Containers" tab between "Projects" and "Stacks". Existing tabs stay. web/src/lib/api.ts(or wherever API client functions live) — addlistWorkloads,getWorkload,listContainers,getContainer,listApps. Remove instance-shaped helpers.web/src/lib/types.ts— addWorkload,Container,Apptypes. RemoveInstanceonce unreferenced.
Deliverable
User-visible: a Containers tab in the top nav showing every running container with kind/state/workload filters, links into the owning project/stack/site detail page, and a per-workload container panel that looks identical on all three detail pages.
PR 3 — Polish + optional Apps UI
Defer indefinitely if no pull. Lands as a single PR when wanted.
Scope
- Apps UI:
web/src/routes/apps/+page.svelte,[id]/+page.svelte. Workload detail pages get an "App" dropdown to assignapp_id. Gated byfeatures.apps_grouping=truein settings. - Drop any leftover dead code referencing
Instancetypes. - Documentation: update
CLAUDE.mdandREADME.mdto describe the Workload model. - Optional: consolidate
internal/deployerandinternal/stack/managerinto a single orchestrator. Out of scope for this refactor — adapters wrap the existing kind-specific code and that's fine. Revisit only if the duplication starts hurting.
What's explicitly deferred
- Deployer + stack-manager consolidation.
- Apps UI (schema added in PR 1, UI in PR 3 behind flag).
- Multi-host containers (
containers.hostexists but is always'local'). - Workload-kind plugin model — the adapter registry has three hardcoded entries.
- Webhook secret handling for old per-project URLs that may already be in CI configs (no users yet → don't care).
Risks (compressed)
- SQLite JSON1 availability. Verify the driver in
go.modsupportsjson_extractbefore committing toextra_json. If not, hoistsubdomain,npm_proxy_id,proxy_route_idto dedicated columns oncontainers. ListProxyRoutesshape regression. The new query reads fromcontainers+workloadsinstead ofinstances+projects+stages. Worth a golden-output test before flippinginternal/api/proxies.goover.- Stack containers and label stamping.
docker container update --label-addis required to label compose-managed containers post-up. If the local Docker engine version doesn't support it, fall back to relying oncom.docker.compose.project+com.docker.compose.servicefor reconciler joins. - Boot-time backfill from
docker ps. First run needs to populatecontainersfrom currently-running containers using the legacytinyforge.instance-idandcom.docker.compose.projectlabels (since pre-refactor containers don't have the new labels). Solo-dev workaround:docker compose downtest workloads, run the new binary against an empty Docker host, redeploy.
Concrete file paths
Modified:
internal/store/store.go(migrations at line ~75–165)internal/store/projects.go,stacks.go,static_sites.go,models.go,store_test.gointernal/docker/client.gointernal/deployer/deployer.go(~line 388),internal/deployer/bluegreen.go(~line 97)internal/stack/manager.go,internal/staticsite/manager.gointernal/api/router.go,proxies.go,docker.go(buildActiveImagesSetat line 251),stale.gointernal/stale/scanner.go,internal/webhook/matcher.gocmd/server/main.goweb/src/routes/projects/[id]/+page.svelte,stacks/[id]/+page.svelte,sites/[id]/+page.svelteweb/src/lib/api.ts,web/src/lib/types.ts- Top nav component in
web/src/lib/components/
Created:
internal/store/workloads.go,containers.go,apps.gointernal/workload/workload.go,adapters/project_adapter.go,adapters/stack_adapter.go,adapters/site_adapter.gointernal/reconciler/reconciler.go,reconciler_test.gointernal/api/workloads.go,containers.go,apps.goweb/src/routes/containers/+page.svelteweb/src/lib/components/WorkloadContainers.svelte
Deleted:
internal/store/instances.gointernal/api/instances.go