Files
tiny-forge/docs/WORKLOAD_REFACTOR_TODO.md
T
alexei.dolgolyov 739b67856a
Build / build (push) Successful in 10m39s
feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
2026-05-16 06:00:21 +03:00

518 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Workload-First Refactor — Remaining Work
Handoff for resuming the refactor. The plugin architecture (Source × Trigger),
`/api/workloads` surface, `/apps` UI, env/volume/webhook/logs/chain panels,
multi-face proxy routes, blue-green image deploys, schema-driven wizard, and
test coverage on triggers / image helpers / webhook parser / store upserts are
**already landed and live**. What follows is what's still pending, in priority
order.
> ## Current focus (read this first)
>
> **Hard legacy cutover — DONE** (2026-05-16). All three Priority 1 items
> are now shipped. The legacy `/api/{projects,stages,stacks,sites,
> deploys,instances}/*` HTTP surface, every backing table (`projects`,
> `stages`, `stage_env`, `volumes`, `deploys`, `deploy_logs`,
> `poll_states`, `stacks`, `stack_revisions`, `stack_deploys`,
> `static_sites`, `static_site_secrets`), the project-deploy pipeline
> (`bluegreen.go`, `promote.go`, `rollback.go`, `subdomain.go` + most of
> `deployer.go`), the legacy webhook routes (`/api/webhook/{secret}`,
> `/api/webhook/sites/{secret}`, `/api/webhook/workloads/{secret}`), and
> the legacy frontend (`/projects`, `/stacks`, `/sites`, `/deploy`) are
> gone. The `internal/staticsite/{provider,gitea_content,
> github_provider,gitlab_provider,markdown,deno}` and
> `internal/stack/{compose,parse,validate}` files survive only as
> helpers imported by the static + compose plugins.
>
> **Next focus** is **Priority 3 polish** — the `apps.*` i18n namespace
> still has ~60 hardcoded English strings on `/apps` and `/apps/new`,
> and `docs/CODEMAPS/` lacks an entry for `internal/workload/plugin/`.
> After that, **Priority 4 tests** — `/api/workloads/*` integration tests
> and dispatcher coverage.
## Status at a glance
| Item | Priority | Status |
| ---- | -------- | ------ |
| Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) |
| Static source inline port | 1 | **DONE** (2026-05-16) |
| Hard legacy cutover | 1 | **DONE** (2026-05-16) |
| Generalized volume scopes | 2 | DONE |
| Kind-aware editors (compose / image / static) | 2 | DONE |
| Vendor-specific webhook parsing | 2 | DONE |
| Chain-panel CSS | 3 | DONE |
| Log Rules panel on `/apps/[id]` | adjacent | DONE — uses `getEffectiveLogScanRules` + per-workload override action |
| i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending |
| Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** |
| API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** |
Cross-references to the adjacent Observability work (Event Triggers + Log
Scanner backend + drop-counter stats panel) live in
[docs/LOGSCAN_AND_TRIGGERS_TODO.md](LOGSCAN_AND_TRIGGERS_TODO.md).
## Priority 1 — Architecture unlock
### ~~Triggers as first-class reusable entities~~ — DONE (2026-05-16)
Trigger config used to live embedded in the workload row
(`workload.trigger_kind` + `workload.trigger_config`). One workload owned
exactly one trigger; one trigger served exactly one workload. The split
makes a Trigger its own record so one inbound webhook / registry watcher /
schedule / git-push filter fans out to many workloads.
**Schema + store**`triggers` + `workload_trigger_bindings` tables with
`ON DELETE CASCADE`. `binding_config` JSON merges on top of `trigger.config`
(top-level merge, binding wins). Boot-time backfill lifts every existing
embedded trigger into a standalone trigger row + binding inside a
per-workload transaction so a partial failure rolls back cleanly. Trigger
names are id-suffixed unconditionally to dodge the (name, kind) collision
race. `store.ErrUnique` sentinel translates SQLite UNIQUE violations at
the store boundary; API handlers use `errors.Is` instead of substring
match. `MergeJSONConfig` always returns a freshly allocated slice (no
aliasing under fan-out).
**Webhook fan-out** — new `POST /api/webhook/triggers/{secret}` resolves
to one Trigger and fans out to every enabled binding via a bounded worker
pool (`maxTriggerFanOutConcurrency = 4`). Per-binding errors are isolated
(one broken workload doesn't block siblings). Outcome accounting splits
deployed / skipped / no-match / errored cleanly. Legacy
`POST /api/webhook/workloads/{secret}` route dropped (clean break per the
workload-first memory; the boot backfill kept secrets resolvable at the
new path).
**API**`/api/triggers` CRUD, `/api/triggers/{id}/webhook`,
`/api/triggers/{id}/bindings` (list + bind), `/api/bindings/{id}` for
update and delete, and `/api/workloads/{id}/triggers` (list + bind,
accepts either `trigger_id` or inline `{kind, name, config, ...}`).
Inline-create path
runs trigger insert + binding insert inside one transaction
(`CreateTriggerWithBindingTx`) so a binding failure can't leak an orphan
trigger. `validateBindingConfig` enforces 8 KiB cap and runs the trigger
plugin's `Validate()` against the merged shape on every bind/update.
List endpoints use `LEFT JOIN ... GROUP BY` (`ListTriggersWithBindingCount`,
`ListBindingsForTriggerWithNames`, `ListBindingsForWorkloadWithNames`) —
no per-row N+1.
**Plugin contract unchanged**`Trigger.Match` still takes `(Workload,
InboundEvent)`. The fan-out path uses `plugin.WithEffectiveTrigger` to
stuff the merged config into a copied workload before the call, so the
existing `registry`, `git`, `manual` plugins work unchanged.
**Reconciler** — gate dropped from `(SourceKind != "" && TriggerKind != "")`
to `SourceKind != ""`. A workload with a Source but no triggers still
gets `Source.Reconcile` called every tick (manual-only deploys are
common during early setup).
**Frontend** — new pages under `web/src/routes/triggers/`:
- `+page.svelte` — list with kind chips, binding count, webhook status,
empty state.
- `new/+page.svelte` — wizard with kind picker (cards), name, kind-aware
config form (registry / git / manual + JSON fallback), webhook toggles.
- `[id]/+page.svelte` — editable per-kind form, webhook URL panel
(origin-prefixed, copy + ConfirmDialog-gated rotate), bindings list
with per-row enabled `<ToggleSwitch>` + ConfirmDialog-gated unbind,
danger-zone delete.
**Workload UI** — embedded trigger fields removed.
- `apps/new/+page.svelte` — wizard now has Trigger step with NEW / PICK /
SKIP modes; bind happens after `createPluginWorkload` succeeds.
- `apps/[id]/+page.svelte` — Bindings panel above Containers, "Add trigger"
modal with Inline / Pick-existing tabs, **per-binding override editor**
(inline disclosure with read-only base config, editable JSON override,
merged preview, 8 KiB byte cap, save / reset-to-inherit). Per-row
"OVERRIDES n FIELDS" badge surfaces deviation from the trigger.
**Shared component**`web/src/lib/components/TriggerKindForm.svelte`
hosts the kind picker + name + per-kind config + JSON fallback + webhook
toggles. Reused on both `/triggers/new` and the workload Add-trigger modal.
**i18n** — full EN + RU coverage under `redeployTriggers.*` (standalone
pages), `apps.detail.bindings.*` (workload bindings panel including
`override.*`), `apps.new.triggers.*` (wizard mode picker), `nav.triggers`.
The existing `/event-triggers` nav label was disambiguated to "Event
Triggers" to coexist with the new `/triggers` entry.
**Compliance** — three pre-existing raw `<input type="checkbox">`
instances in `apps/new` + `apps/[id]` (render-markdown, env-encrypted)
replaced with `<ToggleSwitch>` to honor the project rule.
**Touch points (final):**
- `internal/store/triggers.go`, `workload_trigger_bindings.go`, `models.go`,
`store.go` (schema + backfill + `translateSQLError`).
- `internal/workload/plugin/binding.go` (`MergeJSONConfig`,
`WithEffectiveTrigger`).
- `internal/webhook/trigger_handler.go` + `handler.go` (route mount,
legacy route removed).
- `internal/reconciler/reconciler.go` (trigger gate dropped).
- `internal/api/triggers.go` + `router.go` (REST surface).
- `web/src/routes/triggers/`, `web/src/routes/apps/{new,[id]}`,
`web/src/lib/components/TriggerKindForm.svelte`, `web/src/lib/api.ts`,
`web/src/lib/i18n/{en,ru}.json`, `web/src/routes/+layout.svelte`.
**Reviews shipped through go-reviewer + security-reviewer +
typescript-reviewer subagents** — 0 CRITICAL; 5 HIGH and 4 MEDIUM
findings addressed inline before merge.
### ~~Static source inline port~~ — DONE (2026-05-16)
The phantom-row adapter (`cmd/server/static_backend.go`) is deleted; the
static plugin now operates directly on `plugin.Workload`, the `containers`
table, and `workload_env`. The deploy pipeline body lives inline in
`internal/workload/plugin/source/static/{deploy,teardown,reconcile,
state,env,build,naming,static}.go`.
**State migration:** the legacy `static_sites` columns
(`last_commit_sha`, `last_sync_at`, `last_error`, `status`,
`container_id`, `proxy_route_id`) are now persisted on the container
row keyed `<workloadID>:site` — deterministic ID, single row per
workload. First-class fields (`container_id`, `proxy_route_id`,
`subdomain`, `state`, `port`, `image_ref`) move into their dedicated
columns on the `containers` table; the rest live in
`containers.extra_json` via a typed `runtimeState` struct that
preserves unknown keys on round-trip (so future writers can extend
`extra_json` without forcing this struct to grow). `workload_env`
replaces `static_site_secrets` for plugin-native workloads.
**Reused helpers:** `internal/staticsite/{provider,gitea_content,
github_provider,gitlab_provider,markdown,deno}` stay alive (and
exported) as helpers — providers are still imported via
`staticsite.NewGitProvider`. The `staticsite.Manager` itself stays
alive only to service the legacy `/api/sites/*` HTTP routes; once
those drop in the cutover the package can be deleted entirely.
**Hardening landed alongside the port** (from `go-reviewer` +
`security-reviewer` subagent passes — 1 CRITICAL, 5 HIGH, 3 MEDIUM
addressed before merge):
- **Path-traversal defense:** providers (`gitea_content.go`,
`github_provider.go`, `gitlab_provider.go`) reject any tree entry
whose resolved local path escapes `destDir`; the static plugin's
`verifyDownloadInsideRoot` walks the build dir post-download as a
second line of defense; `copyDir` uses `filepath.WalkDir` + `Lstat`
to refuse symlinks and non-regular files.
- **Error sanitization:** a `sanitizeError` helper redacts the
decrypted access token, collapses to one line, and clamps to 240
bytes before any error string lands in `runtimeState.LastError`
(persisted in `extra_json`) or fans out to the notification
webhook.
- **Resource naming with workload-ID short suffix:** container,
image, and storage volume names all carry `idShort(w)` so two
workloads sharing a name can't clobber each other's resources
(workload `name` is not UNIQUE in the schema).
- **Per-workload mutex on `saveState`:** serializes the read-modify-
write of `containers.extra_json` so two parallel deploys for the
same workload can't race to clobber each other's
`container_id` / `proxy_route_id`.
- **`saveState` failure on the success path is fatal:** rolls back
the just-created container + proxy route and writes a "failed"
state, so we don't leak a running container with no row pointing
at it.
- **`primaryDomain` reads `settings.Domain`** to complete a bare
subdomain face into a full FQDN (matches legacy Manager behavior).
- **`time.Sleep` honors `ctx.Done()`** during the post-start health
window.
- **`json.Marshal` for event metadata + `strings.HasPrefix` for
failed-status detection** — replaces the prior fmt.Sprintf JSON
template + brittle slice expression.
**Touch points (final):**
- `internal/workload/plugin/source/static/{static,deploy,teardown,
reconcile,state,env,build,naming}.go` — the inline plugin.
- `internal/staticsite/{gitea_content,github_provider,
gitlab_provider}.go` — added the path-traversal guards.
- `cmd/server/main.go` — `wireStaticBackend(...)` call removed; the
existing blank import on `_ "internal/workload/plugin/source/
static"` now drives `init()` registration.
- `cmd/server/static_backend.go` — deleted.
**Behavioral notes for operators:**
- Plugin-native static workloads no longer write to the `static_sites`
table at all — anything querying that table for plugin-native
workloads (operator dashboards, ad-hoc SQL) sees stale or absent
values. The legacy `/api/sites/*` routes still serve original rows
unchanged.
- Container labels `tinyforge.static-site` / `tinyforge.static-site-name`
are no longer set on plugin-native deploys; the canonical
`tinyforge.workload.id` / `.kind` labels (added by
`docker.ContainerConfig`) cover ownership.
- Container, image, and volume names all gained an 8-char ID suffix
(e.g. `dw-site-mysite-a1b2c3d4`). Existing legacy-deployed sites
keep their old `dw-site-mysite` shape until they're redeployed
through the plugin path.
### ~~Hard legacy cutover~~ — DONE (2026-05-16)
The clean-break delete that closed the workload-first arc. Net diff:
~30 files deleted, ~20 modified, ~12k LOC removed.
**Backend deletions:**
- API handlers: `internal/api/{projects,stages,stage_env,stacks,
static_sites,deploys,instances,volume_browser}.go`.
- Store CRUD + tests: `internal/store/{projects,stages,stage_env,
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
workload_sync}.go` + their `_test.go`.
- Deployer pipeline: `internal/deployer/{bluegreen,promote,rollback,
subdomain,resolver_test}.go`; `deployer.go` trimmed to just the
dispatch surface.
- `internal/staticsite/{manager,healthcheck}.go` and
`internal/stack/manager.go` (the rest of those packages are still
imported by the static + compose plugins as helpers).
- Webhook routes: `handleWebhook` (project) + `handleSiteWebhook`
(site) handlers gone; `/api/webhook/triggers/{secret}` is the only
inbound surface left. The workload-side webhook URL handlers
(`getWorkloadWebhook` + `regenerateWorkloadWebhook`) were removed
in the cutover-followup pass when a security review caught them
minting URLs that 404'd.
- `internal/registry/poller.go` (legacy registry poller).
- `internal/volume/ResolvePath` (legacy resolver; the workload
resolver `ResolveWorkloadPath` stays).
- `cmd/server/main.go`: dropped `staticsite.Manager`,
`stack.Manager`, `staticsite.HealthChecker`, registry poller,
`SetSiteSyncTriggerer`, `SetStaticSiteManager`, `SetStackManager`.
**Schema migrations:** `internal/store/store.go` ends with
idempotent `DROP TABLE IF EXISTS` for every legacy table
(`projects`, `stages`, `stage_env`, `volumes`, `deploys`,
`deploy_logs`, `poll_states`, `stacks`, `stack_revisions`,
`stack_deploys`, `static_sites`, `static_site_secrets`). FK order is
children-then-parents.
**Frontend deletions:** `web/src/routes/{projects,stacks,sites,
deploy}/` (entire trees); legacy components
(`ProjectCard.svelte`, `InstanceCard.svelte`,
`StaleContainerCard.svelte`); `api.ts` legacy functions + types
(`Project`, `Stage`, `Stack`, `StaticSite`, `Deploy`, `Instance`,
plus their helpers); i18n namespaces (`projects.*`, `projectDetail.*`,
`envEditor.*`, `volumeEditor.*`, `volumeBrowser.*`, `quickDeploy.*`,
`sites.*`, `stacks.*`, `instance.*`, `confirm.*`); nav entries.
Dashboard rewritten to read `listWorkloads()` + `listContainers()`
only.
**Helper extractions** (to keep deletions atomic):
`internal/store/helpers.go` (`BoolToInt`, `rowScanner`,
`GenerateWebhookSecret`); `internal/api/secrets.go` (api shim that
forwards to the store helper so the api + store paths share one
secret-generation impl, no panic-vs-UUID-fallback divergence).
**Reviews shipped through go-reviewer + security-reviewer +
typescript-reviewer subagents** — 0 CRITICAL across all three; 1
HIGH (dead-end workload webhook surface) + ~12 MEDIUMs all
addressed inline before commit.
**Behavioral notes for operators upgrading from a pre-cutover
build:**
- Existing rows in `projects` / `stages` / `stacks` / `static_sites`
/ `static_site_secrets` / `deploys` / `deploy_logs` / `volumes`
/ `poll_states` / `stage_env` / `stack_revisions` / `stack_deploys`
are dropped on first boot.
- The legacy webhook URLs at `/api/webhook/{secret}` and
`/api/webhook/sites/{secret}` return 404 — operators with old CI
configs must repoint to `/api/webhook/triggers/{secret}` (the boot
backfill from the trigger-split refactor lifted any embedded
workload secret onto a Trigger row, so the secret value itself
carries over).
- Frontend routes `/projects`, `/stacks`, `/sites`, `/deploy` are
gone. Nav links replaced with `/apps` (+ `/triggers` from the
prior arc).
## Priority 2 — Behavior gaps
### ~~Generalized volume scopes~~ — DONE
Landed: `internal/volume.ResolveWorkloadPath` (workload-keyed; sits next to the
legacy `ResolvePath` so legacy code paths keep working) plus the wired-through
`computeMounts` in `internal/workload/plugin/source/image/image.go`. All
`VolumeScope` values are now honored at deploy time:
- `absolute` — host bind, validated against `settings.AllowedVolumePaths`.
- `ephemeral` — tmpfs.
- `instance` — per-tag dir under `<base>/<workload>-<idShort>/instance-<tag>/<source>`.
- `stage`, `project` — both collapse to `<base>/<workload>-<idShort>/<source>`.
- `project_named` — Docker named volume prefixed `tf-<idShort>-<name>`.
- `named` — Docker named volume by raw name.
Test coverage: `internal/volume/resolver_test.go` (table-driven, portable
Linux/Windows). The legacy `ResolvePath` stays in place for legacy deployer +
volume-browser callers and dies with the hard cutover.
### ~~Kind-aware editors on `/apps/new` and `/apps/[id]` edit~~ — DONE
All three Source plugins now have hand-rolled forms on both pages, with
an "Advanced JSON" toggle preserved as the power-user escape hatch.
Submit logic marshals form fields back into the same JSON shape the
backend already expects — no API or store changes required.
**Principle:** the plugin contract makes new Source / Trigger kinds cheap
on the backend, but the UI is not cheap by default — every kind needs a
paired hand-rolled form to be daily-driver usable. The shared JSON
editor is the fallback for power users and brand-new plugins, not the
end state. New Source / Trigger merge requests should treat "ship the
kind-aware form" as part of done, not a follow-up.
**Landed:**
- `compose`: YAML textarea + project_name input on both `/apps/new`
and `/apps/[id]`.
- `image`: form fields for image / port / healthcheck / default_tag /
registry_name / cpu_limit / memory_limit / max_instances on both
pages. Registry name is a select populated from `/api/registries`
(with text-input fallback when the list is empty). env + volumes
stay in their detail-page panels and round-trip through the form
via `imageFormBody` so manual edits aren't clobbered.
- `static`: provider select (gitea / github / gitlab), base URL,
repo_owner / repo_name (both required), branch (default "main"),
folder_path, access_token (password input, for private repos),
mode radio (static / deno), render_markdown checkbox. The
storage_enabled / storage_limit_mb fields aren't surfaced as
form controls yet, but they round-trip through `staticFormBody`
so values set via the raw JSON editor survive form edits.
**Still pending forms:** none — all three Source plugins now have
hand-rolled forms on both `/apps/new` and `/apps/[id]`.
The raw JSON editor stays available behind the "Advanced JSON" toggle
(shipped with compose) so the plugin's full sample is still reachable
for power users and for any new plugin kind without a hand-rolled form.
Effort: per-kind form roughly half a turn each; can land incrementally.
Touches `web/src/routes/apps/new/+page.svelte` and the edit block in
`web/src/routes/apps/[id]/+page.svelte`. The Svelte side keeps
serializing into the same `source_config` JSON shape the backend
already expects — no API or store change required.
### ~~Vendor-specific webhook parsing for `/api/webhook/workloads/{secret}`~~ — DONE
Landed: `internal/webhook/vendor_parsers.go` plus rewrites in
`internal/webhook/handler.go` `buildInboundEvent`. The dispatch order is now:
1. Empty body → manual event.
2. Vendor-specific parsers, short-circuit on a recognized `X-*-Event`
header — Gitea package, GitHub `package` / `registry_package`, GitHub
push, Gitea push, GitLab `Push Hook` / `Tag Push Hook`.
3. Generic simple-body fallback: top-level `image` or top-level `ref` —
what the legacy CI integrations already send.
Vendor parsers can populate fields the generic parser cannot: image
digest, `GitEvent.Vendor`, registry host. When a vendor parser claims a
request (header matches) it is authoritative — a malformed Gitea
package payload surfaces as an error rather than silently falling
through to the generic parser. Test coverage:
`internal/webhook/vendor_parsers_test.go` covers each vendor branch +
the routed-via-`buildInboundEvent` integration cases.
Open follow-ups deferred to future turns:
- GitLab Container Registry events use a custom envelope outside the
webhook event surface — handle if a user reports needing it.
- Docker Hub webhook (push event) uses `{"push_data": {"tag": ...}, "repository": {...}}` — add when there's a user request.
## Priority 3 — Polish
### ~~Chain-panel CSS~~ — DONE
Landed: rules for `.chain-row`, `.chain-card` (with hover/transform on
anchors), `.chain-self` (brand-tinted highlight), `.chain-name`,
`.chain-label` (70px fixed-width mono column), `.chain-children-list`
(flex-wrap), plus a sub-600px stack to keep the panel usable on narrow
screens. Appended at the end of the `<style>` block in
`web/src/routes/apps/[id]/+page.svelte`.
### Docs / codemap entries
Nothing under `docs/CODEMAPS/` for `internal/workload/plugin/`. Should cover:
- The Source × Trigger contract + registry pattern (`init()` + blank-import in
`cmd/server/main.go`).
- How a new Source kind is added (write `init()` registration, blank-import,
add to wizard via `SchemaSample`).
- The dispatcher seam: `deployer.DispatchPlugin` / `DispatchTeardown` /
`DispatchReconcile` and how the reconciler / webhook ingress / API
handlers all flow through it.
`README.md` should mention `/apps` as the new user surface and that
`/projects` / `/sites` / `/stacks` carry `Deprecation: true` headers.
### i18n: page-level strings — PARTIAL
Already i18n'd:
- `nav.apps`, `nav.eventTriggers`, `nav.logScanRules` — top nav labels.
- Log Rules panel on `/apps/[id]` reuses `logscan.panel.*` keys
(shipped with the Observability work).
- All `/event-triggers/*` and `/log-scan-rules/*` page strings — keys
live under `triggers.*` and `logscan.*` namespaces in
`web/src/lib/i18n/{en,ru}.json`.
Still hardcoded English:
- `/apps/+page.svelte` — list page (hero, lede, stats, empty state,
table headers, status pills).
- `/apps/new/+page.svelte` — wizard labels, form copy, kind-aware
form rows (compose / image / static all hardcoded English today).
- `/apps/[id]/+page.svelte` — detail page sections (chain, env,
volumes, webhook, manual deploy, danger zone) — the Log Rules
panel embedded inside it is the only i18n'd section.
Roughly 80100 keys across the three `/apps/*` pages once extracted.
Namespace: `apps.*` (with sub-namespaces `apps.list.*`, `apps.new.*`,
`apps.detail.*`, `apps.form.*`).
## Priority 4 — Tests we still don't have
Solid pure-function coverage landed in the prior turn. Still missing:
- **API-handler integration tests** for `/api/workloads/*` (CRUD, deploy,
env, volumes, webhook, chain, promote-from). Pattern: in-memory store +
fake deployer + fake docker / proxy / dns providers, exercise via
`httptest`.
- **Deployer dispatcher**: `DispatchPlugin` / `DispatchTeardown` /
`DispatchReconcile` with a fake Source registered.
- **Compose source**: `composeProjectName` sanitizer, `writeYAMLIfChanged`
short-circuit. (Both pure; just need fixtures.)
- **Static source Backend adapter** in `cmd/server/static_backend.go`.
## Open architectural questions
### Stages chain vs explicit Stage entity
`parent_workload_id` is now the canonical mechanism for stage chains
(dev → staging → prod). Decision deferred: do we need a separate `Stage`
entity at all, or is the chain sufficient? Currently feels like the chain
covers the use case — `promote-from` works, the UI shows the relationship.
Probably can leave the legacy `stages` table dropped entirely once cutover
proceeds.
### `Container.extra_json` evolution
Currently only the image source uses it (per-face proxy route IDs). If
other sources gain similar needs (compose service health metadata, static
build SHAs), the schema there should stay versionless and additive — every
reader must tolerate unknown keys. Document this in the source plugin
guide alongside the codemap entry.
## File pointers for the next session
- Plugin contracts: `internal/workload/plugin/{plugin,source,trigger,types,registry}.go`
- Source implementations: `internal/workload/plugin/source/{image,compose,static}/`
- Trigger implementations: `internal/workload/plugin/trigger/{registry,git,manual}/`
- Dispatcher: `internal/deployer/dispatch.go`
- Webhook ingress (plugin path): `internal/webhook/handler.go` `handlePluginWorkloadWebhook`
- Reconciler hook: `internal/reconciler/reconciler.go` `reconcilePluginWorkloads`
- Static backend adapter (to be deleted post-port): `cmd/server/static_backend.go`
- Frontend pages: `web/src/routes/apps/+page.svelte`, `web/src/routes/apps/new/+page.svelte`, `web/src/routes/apps/[id]/+page.svelte`
- Tests: `internal/workload/plugin/trigger/*/!(_test).go`, `internal/workload/plugin/source/image/image_helpers_test.go`, `internal/webhook/inbound_event_test.go`, `internal/store/workload_env_test.go`
## Memory pointer
Memory at
`C:/Users/Alexei/.claude/projects/c--Users-Alexei-Documents-docker-watcher/memory/`
already covers the Workload-first decision and the no-migration constraint.
Refresh as the cutover lands.