feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+767
View File
@@ -0,0 +1,767 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { TriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// Three kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
let kind = $state<KnownKind | string>('registry');
let name = $state('');
let webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
let useAdvancedJson = $state(false);
let submitting = $state(false);
let error = $state('');
// Per-kind structured fields. They mirror the Go config shapes
// documented in the parent task description — see TriggerInput
// in $lib/api. Keeping them as separate $state slots lets the
// kind switch persist values across kind flips (operator typo
// recovery) without juggling a discriminated union.
let regImage = $state('');
let regTagPattern = $state('*');
let gitRepo = $state('');
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
// Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to
// edit. We only auto-prime when the field is blank to avoid
// nuking deliberate edits on re-toggle.
let jsonText = $state('');
let jsonLoading = $state(false);
const jsonValid = $derived.by(() => {
if (!useAdvancedJson) return true;
if (!jsonText.trim()) return true; // blank treated as empty object server-side
try {
const parsed = JSON.parse(jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function buildConfig(): unknown {
if (useAdvancedJson) {
if (!jsonText.trim()) return {};
return JSON.parse(jsonText);
}
switch (kind) {
case 'registry':
return {
image: regImage.trim(),
tag_pattern: regTagPattern.trim() || '*'
};
case 'git':
return gitMode === 'push'
? { repo: gitRepo.trim(), mode: 'push', branch: gitBranch.trim() || 'main' }
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual':
return {};
default:
// Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be
// on by this point.
return {};
}
}
function canSubmit(): boolean {
if (submitting) return false;
if (!name.trim()) return false;
if (useAdvancedJson) return jsonValid;
switch (kind) {
case 'registry':
return !!regImage.trim();
case 'git':
return !!gitRepo.trim();
case 'manual':
return true;
default:
return false; // unknown kinds force advanced JSON
}
}
async function loadSampleIntoJson(): Promise<void> {
jsonLoading = true;
try {
const schema = await api.getHookKindSchema(kind);
jsonText = JSON.stringify(schema.sample ?? {}, null, 2);
} catch {
// Best-effort prime — operator can paste their own.
jsonText = '{\n \n}';
} finally {
jsonLoading = false;
}
}
function toggleAdvanced(): void {
useAdvancedJson = !useAdvancedJson;
if (useAdvancedJson && !jsonText.trim()) {
// Seed with current structured values (or schema sample
// as fallback) so the operator can refine instead of
// retyping.
try {
jsonText = JSON.stringify(buildConfig(), null, 2);
} catch {
void loadSampleIntoJson();
}
}
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
<title>{$t('redeployTriggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('redeployTriggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/triggers"
backLabel={$t('redeployTriggers.toolbar.backToList')}
eyebrowSuffix={$t('redeployTriggers.toolbar.newButton').toUpperCase()}
title={$t('redeployTriggers.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" novalidate aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<!-- Step 01 · Kind picker. Renders as a grid of square cards
so the kind is the first visual commitment of the wizard. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('redeployTriggers.form.kindLabel')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</legend>
<p class="hint">{$t('redeployTriggers.form.kindHint')}</p>
<div class="kind-grid" role="radiogroup" aria-label={$t('redeployTriggers.form.kindLabel')}>
{#each ALL_PICKABLE as k}
<button
type="button"
role="radio"
aria-checked={kind === k}
class="kind-card"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="kind-card-tag mono">{$t(`redeployTriggers.kindShort.${k}`)}</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
<!-- Step 02 · Name. -->
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('redeployTriggers.form.name')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<!-- Step 03 · Config — kind-aware switch. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if useAdvancedJson}
<p class="hint">{$t('redeployTriggers.form.advancedHint')}</p>
<label class="sub" for="trig-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="trig-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={jsonText}
rows="8"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
aria-describedby={!jsonValid ? 'trig-json-err' : 'trig-json-hint'}
></textarea>
<span id="trig-json-hint" class="hint">
{$t('redeployTriggers.form.configJsonHint')}
{#if jsonLoading} <em>· loading sample…</em>{/if}
</span>
{#if !jsonValid}
<span id="trig-json-err" class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{/if}
</label>
{:else if kind === 'registry'}
<label class="sub" for="trig-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="trig-image"
type="text"
class="input mono"
bind:value={regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="trig-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-tag"
type="text"
class="input mono"
bind:value={regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if kind === 'git'}
<label class="sub" for="trig-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="trig-repo"
type="text"
class="input mono"
bind:value={gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div class="mode-row" role="radiogroup" aria-label={$t('redeployTriggers.form.mode')}>
<button
type="button"
role="radio"
aria-checked={gitMode === 'push'}
class="mode-chip"
class:active={gitMode === 'push'}
onclick={() => (gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={gitMode === 'tag'}
class="mode-chip"
class:active={gitMode === 'tag'}
onclick={() => (gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if gitMode === 'push'}
<label class="sub" for="trig-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="trig-branch"
type="text"
class="input mono"
bind:value={gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="trig-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-gtag"
type="text"
class="input mono"
bind:value={gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
<!-- Step 04 · Webhook ingress. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
aria-busy={submitting}
>
{submitting
? $t('redeployTriggers.form.submitting')
: $t('redeployTriggers.form.submit')}
</button>
</div>
</form>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 760px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group { gap: 0.75rem; }
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0;
}
.field-label.as-legend { float: none; width: 100%; }
.num {
display: inline-flex;
width: 26px; height: 26px;
justify-content: center; align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem; font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--text-primary);
}
.lbl.small { font-size: 0.95rem; }
.req, .opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req { color: var(--color-danger); }
.opt { color: var(--text-tertiary); }
/* Advanced JSON pill-toggle lives in the same legend row as
the section number. Visually it's a quiet outlined button
that fills in when active. */
.adv-toggle {
margin-left: auto;
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
/* ── Inputs ─────────────────────────────────────── */
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono { font-family: var(--forge-mono); font-size: 0.85rem; }
.input.code {
resize: vertical;
min-height: 140px;
line-height: 1.5;
}
.input.bad { border-color: var(--color-danger); }
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger { color: var(--color-danger); }
.hint em {
font-style: italic;
color: var(--forge-accent);
}
/* ── Note banner (manual/unknown) ─────────────────── */
.note {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.18rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── Kind picker grid ─────────────────────────────
Each card has a monospace tag and a soft name. The
active card lights up the tag in brand colour and
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
@media (max-width: 600px) {
.kind-grid { grid-template-columns: 1fr; }
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 0.9rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease,
box-shadow 150ms ease;
position: relative;
overflow: hidden;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Mode chips (git push vs tag) ─────────────── */
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.32rem 0.85rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover { color: var(--text-primary); }
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.1rem;
padding-left: 1rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
</style>