Skip to content

feat(conductor): workflow-template library — bundled catalog, slot mapping, guided instantiation (#429)#479

Merged
Weegy merged 12 commits into
mainfrom
feat/issue-429-workflow-templates
Jul 10, 2026
Merged

feat(conductor): workflow-template library — bundled catalog, slot mapping, guided instantiation (#429)#479
Weegy merged 12 commits into
mainfrom
feat/issue-429-workflow-templates

Conversation

@Weegy

@Weegy Weegy commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

A curated, file-based workflow template catalog for Conductor with a declared-slot parameterization layer and a guided instantiation path: pick template → map slots → validated publish → ordinary workflow.

conductor-core: template contract (middleware/packages/conductor-core)

  • New TemplateManifest type: a complete WorkflowGraph whose external references (agent slugs, action ids, role keys, event ids, delivery channels) are slot:<kind>:<key> placeholders, plus catalog metadata (id, name, description, useCase, defaultSlug) and a slots declaration enumerating every placeholder with a human-readable label.
  • Pure, field-targeted substituteSlots() — substitution is deterministic and covered by unit tests; no shared client code needed.

Middleware: bundled catalog + routes

  • Four bundled templates in middleware/src/conductor/templates/: expense approval with escalation, notify-and-escalate, weekly scheduled report, onboarding checklist. File-based, loaded at wire time — zero migrations.
  • Catalog loader with CI-gated validation: every bundled manifest is checked against conductorGraphSchema and the structural validator in tests, so the catalog cannot drift from the engine.
  • Three operator routes on the auth-gated conductor router:
    • GET /templates — full manifests incl. graph + slot declarations (machine-readable, settles the omadia Facilitator — agent that moderates group chats to a defined outcome (+ Conductor JIT/ephemeral workflows & Core group primitives) #330 seam),
    • POST /templates/:id/resolve — server-side substitution + validation with live KnownRefs, returns the resolved graph without persisting (ephemeral instantiation),
    • POST /templates/:id/instantiate — same resolution path, then publishes through the existing ConductorWorkflowStore.createOrPublish() with the atomic cron-schedule reconcile; enable defaults to false. The result is an ordinary versioned workflow — copy-not-reference, fully editable, no link back to the template.
  • Fail-clear error envelopes: template_slot_mapping_incomplete (per-slot detail), slug_exists (409), invalid_graph (validator error list).

web-ui: gallery + guided instantiation on /conductor

  • Template gallery: card grid above the workflows list — name, use-case tag, description, a pluralized "You will map: …" slot summary, and a text/edge "Runs on a schedule" badge for cron templates. Empty catalog renders nothing.
  • Slot-mapping form: ONE upfront form for the whole template (never per-node walking) — prefilled slug/name, one picker per declared slot grouped by kind, fed by the live role/agent/action/event catalogs; channels via the shared ChannelSelect. Server error envelopes map back to inline field errors. Lume-conform: state colors as text/edge only, Button busy pattern (no spinners), all strings i18n'd (en + de).
  • Two paths from the form: Create workflow (instantiate) and Open in designer (resolve → the graph hydrates ConductorCanvas via the existing chat→canvas loadGraphRequest mechanism, extended to carry the instance slug/name — publish then goes through the canvas's normal save flow).

Review fix: designer handoff honors the enable=OFF default

The "Open in designer" handoff previously dropped the form's default-off enable toggle: the canvas save path hardcoded enable: true, so a cron template left disabled was created enabled and started its schedule without the form's schedule notice applying. The handoff and CanvasGraphRequest now carry the form's enable choice and the canvas publishes with it; chat drafts and the edit-existing path keep the historical enabled-on-save behavior (the store only applies enable on first create). Regression tests: new ConductorCanvas.test.tsx (stubbed @xyflow/react) reproducing the exact scenario, plus extended form handoff tests.

Design decisions

  • Slots-with-explicit-mapping over raw JSON import: the graph validator already rejects unknown agent/action/role/event refs, so an n8n-style import could never publish. The mapping form is the UX; the validator is the safety net.
  • Server-side substitution (resolve endpoint) instead of sharing @omadia/conductor-core with web-ui — one code path serves both the UI and omadia Facilitator — agent that moderates group chats to a defined outcome (+ Conductor JIT/ephemeral workflows & Core group primitives) #330's ephemeral instantiation.
  • Curated in-repo shelf, no marketplace: a CI test is the review board.
  • User-facing name is "workflow templates" — the internal codename appears nowhere in copy, API paths, or i18n strings.

Testing

  • middleware: typecheck + full test suite green (conductor-core template unit tests, catalog CI gate, route tests).
  • web-ui: tsc --noEmit clean, 37 test files / 230 tests green (incl. new gallery, form, and canvas-handoff tests), i18n parity check OK.

Post-review hardening (review round 3, cross-vendor findings)

The cross-vendor (codex) reviewer rejected the branch a first reviewer had approved, with two findings — both fixed and re-approved by both reviewers:

  • Atomic create-new instantiation (8f25c15): the getBySlug() pre-check + ON CONFLICT DO UPDATE combination was a TOCTOU — two concurrent instantiate calls with the same fresh slug both returned 201, the loser silently publishing a second version over the winner. createOrPublish now takes expectNew, flipping the conflict clause to INSERT ... ON CONFLICT (slug) DO NOTHING; zero returned rows rolls the transaction back and throws WorkflowSlugExistsError, which the route maps to the 409 conductor.slug_exists envelope. The pre-check is gone; manual POST / and the canvas save path provably keep upsert semantics (regression tests pin both conflict-clause modes).
  • Manifest-borne localization (a0a3df3): template name, description, use-case tag, slot labels and help texts are now LocalizedText (string or { en, de? }, en required and validated by checkTemplateManifest). All four bundled templates ship full German translations, gated by the catalog CI test. The web-ui resolves the active locale (useLocale) with en fallback; GET /templates keeps returning unresolved full manifests so the omadia Facilitator — agent that moderates group chats to a defined outcome (+ Conductor JIT/ephemeral workflows & Core group primitives) #330 machine contract stays stable. Localization lives in the manifest — not in en.json/de.json — so it travels with templates once v2 (Conductor workflow templates v2: save-as-template, distribution, DB storage, discovery, chat awareness, telemetry, text slots, versioning #478) distributes them outside the repo.

Closes #429

Weegy added 10 commits July 10, 2026 07:36
…itution (#429)

TemplateManifest/TemplateSlots/TemplateSlotMapping types plus pure,
I/O-free helpers: extractSlotRefs (structural walk of the five ref
fields), missingSlotMappings, applyTemplateSlots (deep-clone,
field-targeted slot:<kind>:<key> substitution, {{ctx.*}} interpolation
untouched) and checkTemplateManifest (metadata, structural validate,
bidirectional slot coverage, duplicate keys, malformed refs).
Foundation for the file-based template catalog and /templates routes.
…ild plumbing (#429)

Four curated TemplateManifest JSONs (expense-approval, notify-and-escalate,
weekly-report, onboarding-checklist) in middleware/src/conductor/templates/,
a dirname-relative loadTemplateCatalog() that skips invalid assets with a
log line instead of failing boot, a CI gate test proving every bundled
template is valid and instantiable end-to-end (synthetic mapping +
validate() with KnownRefs + cron validity), and build plumbing so both
plain npm run build (copy-build-assets.mjs) and the Docker image carry
the JSON assets. File-based catalog, no DB migration.
#429)

Expose the bundled template catalog on the operator conductor API and add
the two instantiation paths, both validating with live KnownRefs (agent
slugs, action ids, role keys, event catalog) — deliberately stricter than
POST / so a template instance is runnable, not merely well-formed.
Instantiate publishes through the ordinary createOrPublish path with the
atomic cron-schedule reconcile and 409s on slug collision instead of
upserting. Resolve is the ephemeral seam for #330 and the UI's open-in-
designer flow. Routes registered before the /:slug catch-all; stub-dep
route tests cover every status code, the createOrPublish call shape, and
a route-order regression. API documented in the middleware handoff doc.
Card grid above the workflows list rendering the bundled template
catalog from GET /templates: name, useCase tag, description, pluralized
slot-mapping summary and a text/edge schedule badge for cron templates.
API client mirrors the TemplateManifest wire shape locally and ships
the resolve/instantiate fetchers for the follow-up mapping-form unit.
i18n en+de; Vitest component tests.
Completes the guided instantiation flow the f1 gallery opened: one upfront
mapping form per template instead of per-node walking, because declared
slots make a single form sufficient and the server's completeness check
already names every gap. Create publishes via POST /templates/:id/
instantiate; Open in designer resolves ephemerally and reuses the existing
chat-to-canvas hydration so the canvas needs no changes. The enable toggle
defaults to off with a persistent schedule notice for cron templates —
schedule transparency is a must-have, never hover-hidden. Channel slots
prefill 'teams' so the mapping state never diverges from what the shared
ChannelSelect displays.
Review fixups:
- The Dockerfile COPY for the template JSON assets was redundant (the builder
  stage's npm run build already mirrors them into dist via
  copy-build-assets.mjs, and the runtime stage copies dist wholesale) and the
  review gate forbids touching deployment config — reverted to base.
- "Open in designer" now hands the form's instance slug/name to the canvas
  alongside the resolved graph (CanvasGraphRequest gains optional slug/name),
  so Save is armed on a fresh page and never republishes the template graph
  over a previously loaded workflow's slug. Chat-pane draft pushes omit the
  fields and leave the canvas identity untouched.
…r save path (#429)

The 'Open in designer' handoff passed only graph/slug/name, and
ConductorCanvas.handleSave hardcoded publishConductorWorkflow enable:
true — so a cron template left on the default-off toggle was created
enabled on Save and its schedule started without the form's schedule
notice applying. onOpenInDesigner and CanvasGraphRequest now carry the
form's enable flag; the canvas publishes with it and resets to the
historical enabled-on-save behaviour for chat drafts and loaded
workflows (the store only applies enable on first create anyway).
Regression tests: new ConductorCanvas.test.tsx (stubbed @xyflow/react)
plus extended form handoff tests.
…an for bundled templates (#429)

Template name, description, useCase, and slot labels/help texts rendered raw
English in the German UI. Templates are data (v2 distributes them outside the
repo), so localization travels in the manifest: these fields now accept a plain
string or an { en, de?, ... } record with en required as the universal fallback.

- conductor-core: LocalizedText/LocalizedTextMap types, resolveLocalizedText,
  checkTemplateManifest validates the localized shape (new
  template_invalid_localized_text code); missing-slot envelopes keep flat
  English labels (clients localize by kind+key from the manifest they hold)
- bundled manifests: proper German translations for all operator-facing texts;
  the catalog CI gate now asserts bundled en/de parity
- routes: GET /templates keeps returning full, unresolved manifests (#330);
  the instantiate route resolves its manifest-borne name/description fallbacks
  to the en base before persisting
- web-ui: gallery + slot-mapping form resolve the active locale via
  next-intl useLocale() with en fallback (resolveConductorText); component
  tests cover German rendering under de and the en fallback
…ate-only publish (#429)

Two concurrent POST /templates/:id/instantiate with the same not-yet-existing
slug both passed the getBySlug pre-check; the loser fell into createOrPublish's
ON CONFLICT DO UPDATE upsert and silently published a second version over the
just-created workflow, answering 201 — violating the route's create-new
contract.

- ConductorWorkflowStore.createOrPublish gains expectNew: the conflict clause
  flips to INSERT ... ON CONFLICT (slug) DO NOTHING; zero returned rows aborts
  the publish transaction with the new WorkflowSlugExistsError
- the instantiate route drops the racy pre-check entirely and maps the error to
  the existing 409 conductor.slug_exists envelope — of two racing instantiates
  exactly one INSERT wins
- POST / and the canvas save path keep their idempotent upsert untouched
  (regression-tested: no expectNew leaks into the ordinary publish)
- store-level tests (fake pool, SQL-shape scripted) prove the create-only
  conflict semantics incl. rollback; route tests prove the 409 mapping
* feat(conductor-core): template manifest v2 — version, text slots, inference, strict gate (#478)

Additive extension of the #429 template contract, pure functions only:
TemplateManifest.version (absent = 1, read via templateManifestVersion),
declared text slots substituted as slot:text:<key> tokens in the two
designated prose fields (step.prompt, human.message; disjoint from
{{...}} run-context interpolation, structural walk, never string-replace
over JSON), TemplateSlotMapping.text, kind:'text' missing-slot entries,
checkTemplateManifest text-slot coverage codes plus a { strict } mode
that rejects concrete refs in distributed (plugin/hub) manifests, and
inferTemplateManifest reversing the extractSlotRefs walk for the
save-as-template path (slugified de-duplicated keys, pre-existing
placeholders preserved, round-trip tested). Text machinery split into
textSlots.ts and v2 tests into templateV2.test.ts to keep every file
under the 500-line budget; the v1 test file stays byte-identical as the
contract pin for #330 consumers.

* feat(conductor): DB template store, composite catalog, CRUD + versioning routes (#478)

User-authored workflow templates move into the conductor DB: migration
0006_templates.sql (conductor chain) adds conductor_templates,
conductor_template_versions (immutable JSONB manifests),
conductor_template_instantiations (append-only anonymous telemetry) and
template provenance columns on conductor_workflows. A composite catalog
merges bundled files, DB user templates and a plugin registration seam
behind one viewer-scoped interface whose visibility rule makes 'pending'
templates reachable by every operator — every operator on the single-tier
operator API is a potential reviewer, so the review gate cannot collapse
into author-only self-approval. New CRUD + versioning routes (split into
templateRoutes.ts for file size) keep the GET /templates wire shape
additive over v1 so the #330 consumer contract holds; resolve/instantiate
accept an explicit version and instantiate stamps provenance in the same
transaction as the publish. Conductor migrations are now also mirrored
into dist by copy-build-assets.mjs, which previously relied on the
Dockerfile COPY alone.

* feat(conductor): save-as-template inference, review gate, plugin-borne templates, update hint (#478)

Unit B3 of the templates-v2 plan:

- POST /:slug/save-as-template returns an inferTemplateManifest DRAFT
  (one declared slot per distinct concrete ref) from the workflow's
  active published version; nothing persisted, UI publishes via
  POST/PUT /templates. Default id derives from the slug with a
  -template suffix on collision.
- Review state machine private -> pending -> shared: submit
  (author-only, 409 template_status_conflict otherwise), approve/reject
  (ANY operator via the viewer-scoped catalog get - reachable because
  pending is visible install-wide; reviewed_by recorded; self-approval
  permitted and auditable).
- Workflow list/detail additively report template?: { id, version,
  latestVersion, updateAvailable } from the template_id/
  template_version provenance columns (viewer-scoped, no existence
  leak); workflowStore now selects those columns.
- Plugin-borne templates (trust boundary in docs/security-architecture
  section 4): permissions.templates declares package-relative JSON
  manifests; fail-closed install gate in src/plugins/pluginTemplates.ts
  (path confinement after symlink unwrapping, plugin:<id>:<name>
  namespacing, checkTemplateManifest strict mode, isValidCron); any
  violation fails the install with install.template_invalid. Accepted
  manifests register as read-only source 'plugin' catalog entries,
  unregister on uninstall, and re-register at boot (fail-open sweep).
  Templates are data, never code - no runtime template API.

Tests: test/pluginTemplates.test.ts (new) + extended
test/conductorTemplateRoutes.test.ts (48 route cases incl. non-author
approve, inference round-trip, update hint, plugin source read-only).

* feat(conductor): builder-chat template awareness (#478)

The conversational builder can now see the workflow-template catalog and
suggest templates in chat: its system prompt carries a viewer-scoped
catalog digest (capped at 30 entries) and the reply protocol accepts a
templateProposals block that POST /builder/turn returns additively (the
key is absent without proposals, keeping the v1 wire shape byte-identical).

Proposals are vetted server-side inside the agent seam so the LLM cannot
surface templates the viewer must not see: unknown/invisible ids dropped
against the composite catalog, max 3 deduped proposals, version taken from
the catalog rather than the model, and prefill guesses kept only for
declared slot keys whose ref values resolve against the live KnownRefs
sets (shared with the resolve/instantiate routes via a hoisted
templateKnownRefs so the two gates cannot drift). Chat proposes and
prefills only - instantiation stays on the deliberate form flow.

* feat(web-ui): save-as-template dialog with owner-aware version publish (#478)

Authoring UX in the Conductor admin UI over the backend's slot-inference
draft (POST /:slug/save-as-template): edit metadata and inferred ref-slot
labels (en/de LocalizedText, en required), declare text slots with the
paste-able slot:text:<key> token, and publish. The primary action is
owner-aware: fresh id posts a new template, an id owned by the viewer
switches to 'Publish as v{latestVersion+1}' via PUT, a foreign/bundled id
shows an inline error, and a 409 race on POST re-checks ownership and flips
the dialog into the PUT state instead of dead-ending. This makes publisher
maintenance (v>1) reachable from the UI, not just the raw API.

* feat(web-ui): gallery v2 facets, pending-review queue, search, manage actions (#478)

TemplateGallery renders the composite catalog with provenance facets
(All / Bundled / My templates / Shared / Plugins / Pending review),
client-side text search over the locale-resolved name/description/useCase,
and secondary use-case chips. The Pending review facet is the reviewer
queue: every pending user template is listed for EVERY operator (the
install-wide pending-visibility rule makes the review gate reachable by
non-author reviewers), with the submitter shown and Approve/Reject actions
directly on the card, plus a waiting-count badge on the facet label.

Cards gain a provenance/status badge (text + edge color only, per Lume),
a v{n} tag, an instantiation count, and author manage actions on own user
templates: Submit for review (private only) and Delete behind an inline
confirm. Mutations refetch the catalog via the page (onCatalogChanged ->
reload); errors surface inline as text with the server's message. API
client gains deleteConductorTemplate, submitConductorTemplate,
approveConductorTemplate, rejectConductorTemplate.

Vitest covers facet filtering (viewer-owned only under My templates), the
non-author pending queue with a working Approve (and the card surfacing
under Shared after refresh), locale-fallback search, author-only manage
actions, the delete confirm, and absence of review actions on non-pending
cards.

* feat(web-ui): instantiate form v2 — text slots, graph preview, version pin, update hint (#478)

Text-slot inputs (required-fill unless a default exists; kind:'text' server
errors mapped inline; mapping carries the additive text record), a collapsed-
by-default read-only graph preview rendered from the manifest (TemplatePreview
on @xyflow/react — the designer's actual stack, not the plan's Cytoscape),
version display + explicit-version resolve/instantiate, an initialMapping
prefill seam for the chat proposals (F4), and the opt-in update path: workflows
carrying template.updateAvailable render 'Template updated (v{n} -> v{m})' with
a re-instantiate action opening the form pinned to the latest version
(TemplateUpdateHint; copy-not-reference — the old workflow stays untouched).
API client: mapping.text + version on resolve/instantiate,
fetchConductorTemplateVersions, template hint on the workflow wire type.

* feat(web-ui): chat template proposal cards in the builder pane (#478)

Render the builder turn's templateProposals (B4) as up to 3 compact cards
under the assistant reply: locale-resolved template name, v{n} tag, one-line
reason, and a slot-coverage line counted against declared slots only. The
single 'Use template' action hands off to the instantiate form through the
page's existing state plumbing (prefill analog of the chat->canvas
setChatGraphRequest hand-off): the form opens pinned to the proposed version
with the proposal's prefill as initialMapping, re-keyed per hand-off. Chat
never auto-instantiates. Unknown template ids degrade to plain text; turns
without proposals render exactly as before (API client type is additive).
i18n en+de; 5 vitest cases (cards, hand-off payload, no-proposal regression,
>3 cap, degrade).

* refactor(web-ui): step-kind Lume tokens + 500-line splits (#478)

Review fixups for the templates-v2 branch:

- Lume gate: the step-kind palette (agent/action/human + badge text) was
  hardcoded hex twice (ConductorCanvas, TemplatePreview). It now lives as
  --step-kind-* tokens in theme.css, consumed by both components through
  the shared stepKindColors.ts map — no hex in component code, no
  keep-in-sync comment.
- 500-line rule: SaveAsTemplateDialog (527) extracted its slot editor
  sections into SaveAsTemplateSlotEditors.tsx (388/197);
  conductor/page.tsx (604) extracted the Roles and emit-event sections
  into ConductorRolesSection.tsx and ConductorEmitSection.tsx (464).
  Behavior-preserving: role/emit errors and refetches still route through
  the page, and the double-fire guard is shared via a guard callback
  (prop-object mutation trips react-hooks/immutability).

* fix(conductor): harden manifest gate, place text-slot tokens (#478)

Codex review round 2 on the templates-v2 branch found three blockers:

- POST /conductors/templates with {} (or any manifest whose slots field /
  kind lists / entries have the wrong shape) crashed checkTemplateManifest
  and surfaced as a 500. The gate now shape-checks slots before walking and
  reports malformed input as ordinary validation errors, so the route
  answers 400 conductor.template_invalid. The localized-text helpers moved
  to localizedText.ts to keep template.ts within the 500-line budget; the
  @omadia/conductor-core export surface is unchanged.
- Save-as-template text slots were undeliverable: the dialog declared them
  but never put a slot:text:<key> token into the graph, so the backend
  rejected every such manifest as template_text_slot_unused. The dialog now
  edits the graph's designated step texts (step.prompt, human.message) with
  per-field token insert buttons and blocks publish until every declared
  slot's token is placed; the edited texts travel in the manifest graph.
- Committed trailing whitespace in the conductor template test files broke
  git diff --check hygiene; stripped.

* fix(web-ui): gate save-as-template ownership check while auth resolves (#478)

An owned template id no longer dead-ends on 'id taken' when the dialog
opens before getAuthMe settles. The page now tracks viewerPending, and
the dialog derives a fourth publishMode 'pending' from live props: a
user-sourced collision holds a gated 'Checking ownership' state (Lume
busy-dots, submit disabled) and flips to 'Publish as v{n+1}' or the
id-taken error once the viewer is known. Bundled/plugin collisions stay
terminal, and the 409-race re-check is now pending-aware too.
@Weegy Weegy enabled auto-merge (squash) July 10, 2026 16:15
@Weegy Weegy merged commit 5f210eb into main Jul 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Conductor: define reusable use-case workflows ("Paperclip Process")

1 participant