chore(workload): close the workload-first arc — apps i18n + codemap + tests
Build / build (push) Successful in 10m36s

Closes the workload-first refactor by landing the Priority 3 polish
items and the Priority 4 test gap. Net: ~2,400 lines added,
~350 lines modified across 13 files.

Priority 3 — polish
- apps.* i18n namespace: 276 new keys across apps.list.* (27),
  apps.new.* (91, sibling of existing apps.new.triggers.*), and
  apps.detail.* (158, sibling of existing apps.detail.bindings.*).
  EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new,
  /apps/[id] now render entirely from i18n.
- New codemap docs/CODEMAPS/workload-plugin.md (238 lines):
  Source × Trigger contract, dispatch seam, webhook fan-out path,
  recipes for adding a new Source or Trigger kind. Plus
  docs/CODEMAPS/INDEX.md gateway.

Priority 4 — tests
- internal/api/workloads_test.go (new, ~30 subtests): /api/workloads
  CRUD + deploy + delete + env + volumes + chain + promote-from +
  triggers list/inline-bind + auth gating + standalone /api/triggers
  CRUD (create / dup-409 / kind filter / delete). Uses real
  POST handlers via httptest.NewServer + a fake plugin source
  registered under "testfakesource".
- internal/deployer/dispatch_test.go (new, 11 tests):
  DispatchPlugin / DispatchTeardown / DispatchReconcile happy +
  unknown-kind + propagated-error each; PluginDeps wiring; a real
  2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider.
- internal/workload/plugin/source/compose/compose_test.go (new,
  ~26 subtests): composeProjectName sanitization,
  writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy
  + bad inputs, Kind / SchemaSample.

Coverage delta on the workload-plugin path:
- internal/api: 1.1% → 16.0%
- internal/deployer: 0% → 54.1%
- internal/workload/plugin/source/compose: 0% → 38.5%
- Trigger plugins already at 87-95% from the trigger-split work.

Production fix surfaced by the tests
- store.CreateWorkload now self-references RefID = ID when caller
  leaves RefID empty (the typical plugin-native path). The api
  layer's broken backfill loop (called UpdateWorkload, which
  deliberately omits ref_id) is gone. Multiple sibling plugin
  workloads can now coexist under the UNIQUE(kind, ref_id) constraint.

Review fixes addressed before commit
- CRITICAL: deadlock-detect test gained a real 2s time.After (was
  selecting on context.Background().Done() which never fires).
- HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf
  that would silently pass after a production fix).
- HIGH: standalone /api/triggers CRUD coverage added (was bypassed
  by the workload-side bind flow).
- HIGH: seedWorkload bypass deleted; tests now go through the
  real POST /api/workloads handler.
- MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores);
  dead `old := os.Getenv(...)` capture removed.
- MEDIUM: list-workloads test now asserts ID membership, not just
  count.

Doc
- WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3
  polish, and Priority 4 tests marked DONE. The workload-first arc
  is closed.
This commit is contained in:
2026-05-16 06:42:43 +03:00
parent 739b67856a
commit e3c7b13d58
13 changed files with 2736 additions and 352 deletions
+94 -117
View File
@@ -325,7 +325,7 @@
existingTriggers = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
} finally {
loading = false;
}
@@ -533,20 +533,20 @@
</script>
<svelte:head>
<title>New App · Tinyforge</title>
<title>{$t('apps.new.pageTitle')}</title>
</svelte:head>
<div class="forge">
{#snippet newLede()}
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
{/snippet}
<ForgeHero
backHref="/apps"
backLabel="Back to apps"
eyebrowSuffix="NEW APP"
title="Forge a new app"
backLabel={$t('apps.new.backLabel')}
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
title={$t('apps.new.title')}
size="lg"
lede_html={newLede}
/>
@@ -554,7 +554,7 @@
{#if loading}
<div class="loading-line">
<span class="spinner" aria-hidden="true"></span>
<span>Loading available plugin kinds…</span>
<span>{$t('apps.new.loadingKinds')}</span>
</div>
{:else}
<form onsubmit={submit} class="form" novalidate>
@@ -564,36 +564,36 @@
<span class="reg reg-br" aria-hidden="true"></span>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
{/if}
<div class="field">
<label for="app-name" class="field-label">
<span class="num">01</span>
<span class="lbl">Name</span>
<span class="req">REQUIRED</span>
<span class="lbl">{$t('apps.new.fieldName')}</span>
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
</label>
<input
id="app-name"
type="text"
bind:value={name}
required
placeholder="my-app"
placeholder={$t('apps.new.fieldNamePlaceholder')}
class="input"
autocomplete="off"
spellcheck="false"
/>
<p class="hint">Lowercase, no spaces. Becomes part of container names and subdomains.</p>
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num">02</span>
<span class="lbl">Source plugin</span>
<span class="opt">REQUIRED</span>
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
</div>
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<span class="sub-label">{$t('apps.new.fieldSourceLabel')}</span>
<select
id="app-source"
class="input"
@@ -605,37 +605,34 @@
{/each}
</select>
</label>
<p class="hint">
Populated from the running daemon — only plugins compiled in show up. Triggers
(registry / git / manual) are configured below as standalone records.
</p>
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">Source config</span>
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
<span class="req">
{useComposeForm
? 'YAML'
? $t('apps.new.fieldConfigYaml')
: useImageForm || useStaticForm
? 'FORM'
: 'JSON'}
? $t('apps.new.fieldConfigForm')
: $t('apps.new.fieldConfigJson')}
</span>
</div>
{#if useComposeForm}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">compose.yaml · compose</span>
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
<span class="spacer"></span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<textarea
@@ -644,26 +641,26 @@
rows="12"
spellcheck="false"
class="code-area"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"'}
aria-label="Compose YAML"
placeholder={$t('apps.new.composePlaceholder')}
aria-label={$t('apps.new.composeAriaLabel')}
></textarea>
<div class="editor-foot">
<span class="foot-status">
<span class="foot-dot" aria-hidden="true"></span>
YAML
{$t('apps.new.fieldConfigYaml')}
</span>
<span class="sep">·</span>
<span>{composeYaml.split('\n').length} lines</span>
<span>{composeYaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
</div>
</div>
<label class="sub" for="app-compose-project">
<span class="sub-label">Compose project name (optional)</span>
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
<input
id="app-compose-project"
type="text"
class="input"
bind:value={composeProjectName}
placeholder="(defaults to sanitized workload name)"
placeholder={$t('apps.new.composeProjectPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -675,36 +672,33 @@
to be set at create time. -->
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">image source · runtime knobs</span>
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<label class="sub" for="app-image-ref">
<span class="sub-label">Image (registry path)</span>
<span class="sub-label">{$t('apps.new.imageRefLabel')}</span>
<input
id="app-image-ref"
type="text"
class="input mono"
bind:value={imageRef}
placeholder="registry.example.com/owner/app"
placeholder={$t('apps.new.imageRefPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<p class="hint">
Fully-qualified reference; the tag is set per-deploy via the trigger or
the Default tag field below.
</p>
<p class="hint">{$t('apps.new.imageRefHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-port">
<span class="sub-label">Port</span>
<span class="sub-label">{$t('apps.new.imagePort')}</span>
<input
id="app-image-port"
type="number"
@@ -715,7 +709,7 @@
/>
</label>
<label class="sub" for="app-image-healthcheck">
<span class="sub-label">Healthcheck path</span>
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
<input
id="app-image-healthcheck"
type="text"
@@ -727,7 +721,7 @@
/>
</label>
<label class="sub" for="app-image-default-tag">
<span class="sub-label">Default tag</span>
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
<input
id="app-image-default-tag"
type="text"
@@ -740,14 +734,14 @@
</label>
</div>
<label class="sub" for="app-image-registry">
<span class="sub-label">Registry (for private pulls)</span>
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
{#if registries.length > 0}
<select
id="app-image-registry"
class="input"
bind:value={imageRegistryName}
>
<option value="">(public — no auth)</option>
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
{#each registries as r}
<option value={r.name}>{r.name} {r.url}</option>
{/each}
@@ -758,18 +752,15 @@
type="text"
class="input"
bind:value={imageRegistryName}
placeholder="(public no auth)"
placeholder={$t('apps.new.imageRegistryPublic')}
autocomplete="off"
/>
{/if}
<p class="hint">
Match the name from the Registries settings page. Leave empty for
public images.
</p>
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-cpu">
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
<input
id="app-image-cpu"
type="number"
@@ -780,7 +771,7 @@
/>
</label>
<label class="sub" for="app-image-memory">
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
<input
id="app-image-memory"
type="number"
@@ -790,7 +781,7 @@
/>
</label>
<label class="sub" for="app-image-max">
<span class="sub-label">Max instances</span>
<span class="sub-label">{$t('apps.new.imageMax')}</span>
<input
id="app-image-max"
type="number"
@@ -798,13 +789,10 @@
class="input"
bind:value={imageMaxInstances}
/>
<p class="hint">1 = strict blue-green.</p>
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<p class="hint image-form-foot">
Env vars and volume mounts live in their own panels on the workload
detail page after creation.
</p>
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
{:else if useStaticForm}
<!-- Static source form. Provider + repo + mode in
@@ -812,19 +800,19 @@
password input. -->
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">static source · pages from a repo</span>
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<div class="row">
<label class="sub" for="app-static-provider">
<span class="sub-label">Provider</span>
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
<select
id="app-static-provider"
class="input"
@@ -836,13 +824,13 @@
</select>
</label>
<label class="sub" for="app-static-base-url">
<span class="sub-label">Base URL</span>
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
<input
id="app-static-base-url"
type="url"
class="input mono"
bind:value={staticBaseURL}
placeholder="https://git.example.com"
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -850,26 +838,26 @@
</div>
<div class="row">
<label class="sub" for="app-static-owner">
<span class="sub-label">Repo owner</span>
<span class="sub-label">{$t('apps.new.staticRepoOwner')}</span>
<input
id="app-static-owner"
type="text"
class="input mono"
bind:value={staticRepoOwner}
placeholder="owner"
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
</label>
<label class="sub" for="app-static-name">
<span class="sub-label">Repo name</span>
<span class="sub-label">{$t('apps.new.staticRepoName')}</span>
<input
id="app-static-name"
type="text"
class="input mono"
bind:value={staticRepoName}
placeholder="pages"
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
autocomplete="off"
spellcheck="false"
required
@@ -878,46 +866,44 @@
</div>
<div class="row">
<label class="sub" for="app-static-branch">
<span class="sub-label">Branch</span>
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
<input
id="app-static-branch"
type="text"
class="input mono"
bind:value={staticBranch}
placeholder="main"
placeholder={$t('apps.new.staticBranchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-static-folder">
<span class="sub-label">Folder path (optional)</span>
<span class="sub-label">{$t('apps.new.staticFolder')}</span>
<input
id="app-static-folder"
type="text"
class="input mono"
bind:value={staticFolderPath}
placeholder="(repo root)"
placeholder={$t('apps.new.staticFolderPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<label class="sub" for="app-static-token">
<span class="sub-label">Access token (private repos)</span>
<span class="sub-label">{$t('apps.new.staticToken')}</span>
<input
id="app-static-token"
type="password"
class="input"
bind:value={staticAccessToken}
placeholder="(leave blank for public repos)"
placeholder={$t('apps.new.staticTokenPlaceholder')}
autocomplete="new-password"
/>
<p class="hint">
Encrypted at rest. Required only when the repo is private.
</p>
<p class="hint">{$t('apps.new.staticTokenHint')}</p>
</label>
<fieldset class="static-mode">
<legend class="sub-label">Mode</legend>
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
<label class="radio">
<input
type="radio"
@@ -926,8 +912,7 @@
bind:group={staticMode}
/>
<span>
<strong>static</strong> — serve files via nginx; zero runtime
overhead.
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
</span>
</label>
<label class="radio">
@@ -938,40 +923,35 @@
bind:group={staticMode}
/>
<span>
<strong>deno</strong> — Deno runtime container with optional
dynamic routing.
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
</span>
</label>
</fieldset>
<label class="toggle-row">
<ToggleSwitch
bind:checked={staticRenderMarkdown}
label="Render markdown"
label={$t('apps.new.staticRenderMarkdown')}
/>
<span>
<strong>Render markdown</strong> — auto-render <code>.md</code>
files as HTML pages.
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
</span>
</label>
<p class="hint image-form-foot">
The webhook secret for git push triggers lives on the workload's
Webhook panel after creation.
</p>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
{:else}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">source_config.json · {sourceKind}</span>
<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
<span class="spacer"></span>
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'}
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch back to the form"
title={$t('apps.new.switchToFormTitle')}
>
Back to form
{$t('apps.new.backToForm')}
</button>
{/if}
<button
@@ -979,7 +959,7 @@
class="editor-chip"
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
>
Reset sample
{$t('apps.new.resetSample')}
</button>
</div>
<textarea
@@ -988,15 +968,15 @@
rows="12"
spellcheck="false"
class="code-area"
aria-label="Source plugin configuration (JSON)"
aria-label={$t('apps.new.sourceConfigJsonAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!sourceValid}>
<span class="foot-dot" aria-hidden="true"></span>
{sourceValid ? 'JSON OK' : 'JSON INVALID'}
{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
</span>
<span class="sep">·</span>
<span>{sourceLines} lines</span>
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
<span class="sep">·</span>
<span>{sourceBytes} B</span>
</div>
@@ -1008,7 +988,7 @@
<legend class="field-label as-legend">
<span class="num">04</span>
<span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="opt">OPTIONAL</span>
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
@@ -1029,7 +1009,7 @@
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">NEW</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
@@ -1041,7 +1021,7 @@
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">PICK</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
@@ -1053,7 +1033,7 @@
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">SKIP</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
@@ -1073,7 +1053,7 @@
<div class="trig-sub">
{#if existingTriggers.length === 0}
<div class="note muted-note">
<span class="note-tag"></span>
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
<p>{$t('apps.new.triggers.pickEmpty')}</p>
</div>
{:else}
@@ -1100,7 +1080,7 @@
{:else}
<div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">SKIP</span>
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
@@ -1110,34 +1090,34 @@
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">05</span>
<span class="lbl">Public face</span>
<span class="opt">OPTIONAL</span>
<span class="lbl">{$t('apps.new.faceLabel')}</span>
<span class="opt">{$t('apps.new.faceOptional')}</span>
</legend>
<div class="row three">
<label class="sub" for="app-public-subdomain">
<span class="sub-label">Subdomain</span>
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
<input
id="app-public-subdomain"
type="text"
class="input"
bind:value={publicSubdomain}
placeholder="myapp"
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-domain">
<span class="sub-label">Domain</span>
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
<input
id="app-public-domain"
type="text"
class="input"
bind:value={publicDomain}
placeholder="(inherit from settings)"
placeholder={$t('apps.new.faceDomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-port">
<span class="sub-label">Target port</span>
<span class="sub-label">{$t('apps.new.facePort')}</span>
<input
id="app-public-port"
type="number"
@@ -1148,20 +1128,17 @@
/>
</label>
</div>
<p class="hint">
Leave blank to skip provisioning a proxy route. Filling any field creates a single
face row attached to this workload.
</p>
<p class="hint">{$t('apps.new.faceHint')}</p>
</fieldset>
<div class="actions">
<a href="/apps" class="forge-btn-ghost">Cancel</a>
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
<button
class="btn-primary"
type="submit"
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
>
<span>{submitting ? 'Forging' : 'Forge app'}</span>
<span>{submitting ? $t('apps.new.submitting') : $t('apps.new.submit')}</span>
<span class="arrow" aria-hidden="true"></span>
</button>
</div>