From f2d4e1dd110c5827d1f075b997349805ab2a250d Mon Sep 17 00:00:00 2001 From: Christian Wendler Date: Tue, 16 Jun 2026 15:13:20 +0200 Subject: [PATCH 01/19] =?UTF-8?q?docs(conductor):=20add=20Spec=20005=20?= =?UTF-8?q?=E2=80=94=20deterministic=20workflow=20engine,=20designer=20&?= =?UTF-8?q?=20human-in-the-loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conception for Omadia Conductor: a process layer where the runtime (not the LLM) owns step progression and hand-offs, promoting the existing per-tool postcondition / tool-obligation / deterministic-action atoms to process scope. - spec.md: 9 user stories (engine, durable run lifecycle/resume, triggers, event triggers + connector "Conductor Surface", human steps with durable awaits/reminders/ deadline, principals & role resolver seam, visual+conversational Designer, dry-run, audit), 32 FRs, 10 SCs. - data-model.md: workflow/version/draft, run/run-step, conductor_awaits (+responses), roles/role-assignments (the "baton"), manifest emits: extension, LISTEN/NOTIFY resume, run/await state machines. Status: Draft. In-repo modular (@omadia/conductor-core + kernel wiring + Designer under web-ui/app/admin/conductor/). No connectors built here — only the contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/005-omadia-conductor/data-model.md | 405 ++++++++++++++ specs/005-omadia-conductor/spec.md | 683 +++++++++++++++++++++++ 2 files changed, 1088 insertions(+) create mode 100644 specs/005-omadia-conductor/data-model.md create mode 100644 specs/005-omadia-conductor/spec.md diff --git a/specs/005-omadia-conductor/data-model.md b/specs/005-omadia-conductor/data-model.md new file mode 100644 index 00000000..aa62068b --- /dev/null +++ b/specs/005-omadia-conductor/data-model.md @@ -0,0 +1,405 @@ +# Data Model: Omadia Conductor + +Phase 1 output. Entities, persistent schema, declarative (manifest) schema, and +in-memory runtime structures. DDL is illustrative — final column types/constraints +follow the repo's migration conventions (`middleware/migrations/`). Enums are stored +as `TEXT` + `CHECK` (not Postgres `ENUM`) so the value set can extend without +`ALTER TYPE`, consistent with `specs/001-multi-orchestrator-runtime/data-model.md`. + +## Entity Overview + +| Entity | Kind | Lifetime | +|---|---|---| +| Workflow | persistent (DB row) | until operator deletes | +| Workflow Version | persistent (DB row, immutable) | retained for audit; runs bind to it | +| Workflow Draft | persistent (DB row, mutable) | editable working copy until published | +| Run | persistent (DB row) | until retention policy prunes | +| Run Step | persistent (DB row) | with its run (durable step record / trace) | +| Await (`conductor_awaits`) | persistent (DB row) | from human step entry to resolve/timeout | +| Await Response | persistent (DB row) | with its await (per-holder, for `quorum: all`) | +| Role | persistent (DB row) | until operator deletes | +| Role Assignment | persistent (DB row) | the baton; until moved/expired | +| Event Catalog Entry | runtime registry (derived from manifests) | per installed connector | +| Conductor Surface | declarative (`manifest.yaml` `emits:`/`provides:`) | versioned with the connector | +| Conductor Engine state | runtime (in-memory, pure) | per step evaluation; no I/O | + +## Persistent Schema (Postgres / Neon) + +### `conductor_workflows` + +The workflow header. The graph itself lives in immutable versions and a mutable draft. + +```sql +CREATE TABLE conductor_workflows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, -- stable id, e.g. "release-signoff" + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'disabled'-- 'enabled' | 'disabled' + CHECK (status IN ('enabled','disabled')), + active_version_id UUID, -- FK set after first publish + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +- `status = 'disabled'` keeps the row but suppresses all triggers (FR-009). +- `active_version_id` is the version new runs bind to; it changes only on publish. + +### `conductor_workflow_versions` + +An immutable snapshot of the full graph. Runs reference exactly one version (FR-027). + +```sql +CREATE TABLE conductor_workflow_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL REFERENCES conductor_workflows(id) ON DELETE CASCADE, + version INT NOT NULL, -- monotonic per workflow + graph JSONB NOT NULL, -- steps + transitions + triggers (see below) + published_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workflow_id, version) +); +``` + +`graph` shape (validated by `@omadia/conductor-core` before publish): + +```jsonc +{ + "entryStepId": "s1", + "steps": [ + { "id": "s1", "kind": "agent", "agentId": "...", "postcondition": {...}, + "fallbackTransitionId": "t_fail", "position": { "x": 40, "y": 40 } }, + { "id": "s2", "kind": "human", "human": { /* see human-step config */ }, + "fallbackTransitionId": "t_deadline" }, + { "id": "s3", "kind": "action", "actionId": "github.create_release" } + ], + "transitions": [ + { "id": "t1", "source": "s1", "target": "s2", "guard": {...} }, + { "id": "t_fail", "source": "s1", "target": "s_end_fail" }, + { "id": "t_deadline","source": "s2","target": "s_autoreject" } // in-graph deadline fallback + ], + "triggers": [ + { "id": "tr1", "kind": "event", "eventId": "github.pull_request.merged", + "filter": { "base": "main" } }, + { "id": "tr2", "kind": "manual" } + ] +} +``` + +Human-step config (embedded in a `kind: "human"` step): + +```jsonc +{ + "principal": { "kind": "role", "ref": "approver.release" }, // or { "kind":"user","ref":"" } + "channel": "teams", + "message": "Release {{ctx.tag}} ready — approve?", + "reminderInterval": "PT6H", // ISO-8601 duration; null = no reminders + "deadline": "PT24H", // relative to step entry; null = no deadline + "quorum": "any", // 'any' | 'all' (default 'any') + "responseSchema": {...} // shape of the expected decision/input +} +``` + +### `conductor_workflow_drafts` + +The mutable working copy the Designer edits; publishing snapshots it into a version. + +```sql +CREATE TABLE conductor_workflow_drafts ( + workflow_id UUID PRIMARY KEY REFERENCES conductor_workflows(id) ON DELETE CASCADE, + graph JSONB NOT NULL DEFAULT '{}', -- same shape as versions.graph + base_version INT, -- version this draft was forked from + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### `conductor_runs` + +A live or completed execution, bound to one immutable version. + +```sql +CREATE TABLE conductor_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_version_id UUID NOT NULL REFERENCES conductor_workflow_versions(id), + status TEXT NOT NULL DEFAULT 'running' -- 'running'|'waiting'|'completed'|'failed' + CHECK (status IN ('running','waiting','completed','failed')), + current_step_id TEXT, -- node id within the version graph + context JSONB NOT NULL DEFAULT '{}', -- accumulated run context + trigger_kind TEXT NOT NULL, -- 'manual'|'cron'|'channel'|'agent'|'webhook'|'workflow'|'event' + trigger_source JSONB, -- e.g. { eventId, sourcePluginId } for event triggers + is_dry_run BOOLEAN NOT NULL DEFAULT false, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ +); +CREATE INDEX conductor_runs_waiting_idx ON conductor_runs(status) WHERE status = 'waiting'; +``` + +- `context` is persisted before each step transition (FR-004) so a restart rehydrates + an accurate run. `is_dry_run` runs never create real awaits or fire connector actions + (FR-029). + +### `conductor_run_steps` + +Durable per-step record — both the resume checkpoint (FR-004) and the audit trace +(FR-030). The human-facing view integrates with omadia's existing per-run trace viewer. + +```sql +CREATE TABLE conductor_run_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, -- node id in the version graph + seq INT NOT NULL, -- order within the run + actor JSONB, -- { kind:'agent', agentId } | { kind:'human', resolvedUserId } | { kind:'action', actionId } + postcondition_outcome TEXT, -- 'met' | 'unmet' | 'n/a' + transition_taken TEXT, -- transition id (incl. fallback) + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + UNIQUE (run_id, seq) +); +``` + +### `conductor_awaits` + +The durable pending human action — the one genuinely net-new substrate (today +`ask_user_choice` is in-memory and dies on restart). Drives reminders, deadline, and +resume. + +```sql +CREATE TABLE conductor_awaits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + principal_kind TEXT NOT NULL CHECK (principal_kind IN ('user','role')), + principal_ref TEXT NOT NULL, -- user uuid OR role key + channel_type TEXT NOT NULL, -- 'teams'|'telegram'|... + message TEXT NOT NULL, + quorum TEXT NOT NULL DEFAULT 'any' CHECK (quorum IN ('any','all')), + reminder_interval_ms BIGINT, -- null = no reminders + deadline_at TIMESTAMPTZ, -- null = no deadline + fallback_transition_id TEXT, -- in-graph fallback (required if deadline set) + status TEXT NOT NULL DEFAULT 'waiting' + CHECK (status IN ('waiting','resolved','timed_out','cancelled')), + last_reminder_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX conductor_awaits_due_idx ON conductor_awaits(status, deadline_at, last_reminder_at) + WHERE status = 'waiting'; +``` + +- `principal_ref` holds a **role key**, not a frozen user id, when `principal_kind = + 'role'` — access and reminders re-resolve the current holder (FR-022, FR-023). +- A row with `deadline_at` set MUST carry `fallback_transition_id` (FR-017); enforced in + validation, not the DB. +- The scheduler polls `conductor_awaits_due_idx`: send a reminder when + `now ≥ last_reminder_at + reminder_interval_ms`; fire the fallback when + `now ≥ deadline_at` (reusing the `scheduleWorker` tick). + +### `conductor_await_responses` + +Per-holder responses, needed for `quorum: all` and for audit. + +```sql +CREATE TABLE conductor_await_responses ( + await_id UUID NOT NULL REFERENCES conductor_awaits(id) ON DELETE CASCADE, + responder_id UUID NOT NULL REFERENCES users(id), + response JSONB NOT NULL, -- the decision/input, shaped by responseSchema + responded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (await_id, responder_id) +); +``` + +- `quorum = 'any'`: the first qualifying row resolves the await. +- `quorum = 'all'`: resolved only when every *current* holder (re-resolved at check + time) has a row — a departed holder's obligation is dropped, a new holder's is added + (FR-019). + +### `conductor_roles` + +A named seat addressable by a human step. + +```sql +CREATE TABLE conductor_roles ( + key TEXT PRIMARY KEY, -- e.g. "approver.release" + label TEXT NOT NULL, + description TEXT, + scope TEXT, -- optional namespacing/tenant + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### `conductor_role_assignments` + +The baton. Read by the **default** `RoleResolver`. An external resolver may ignore this +table entirely and answer from its own source — Conductor only ever calls the resolver. + +```sql +CREATE TABLE conductor_role_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key TEXT NOT NULL REFERENCES conductor_roles(key) ON DELETE CASCADE, + holder_id UUID NOT NULL REFERENCES users(id), + provenance TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'resolver:' + delegate_id UUID REFERENCES users(id), -- optional stand-in + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_to TIMESTAMPTZ, -- null = open-ended + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX conductor_role_assignments_role_idx ON conductor_role_assignments(role_key); +``` + +- Multiple live rows for one `role_key` = a multi-holder role (interacts with `quorum`). +- Moving the baton = closing one assignment (`valid_to = now()`) and opening another; + this fires `role.assignment.changed` (below). + +### Change-notification triggers (run resume + baton moves) + +```sql +-- Wake a waiting run when its human responds (US2 resume hook, FR-004). +CREATE OR REPLACE FUNCTION notify_await_resolved() RETURNS trigger AS $$ +BEGIN + IF NEW.status IN ('resolved','timed_out') AND OLD.status = 'waiting' THEN + PERFORM pg_notify('conductor_await_resolved', NEW.run_id::text); + END IF; + RETURN NULL; +END; $$ LANGUAGE plpgsql; +-- AFTER UPDATE ON conductor_awaits + +-- Emit baton moves for audit + external subscription (FR-025). +CREATE OR REPLACE FUNCTION notify_role_changed() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('conductor_role_changed', + COALESCE(NEW.role_key, OLD.role_key)); + RETURN NULL; +END; $$ LANGUAGE plpgsql; +-- AFTER INSERT/UPDATE/DELETE ON conductor_role_assignments +``` + +The kernel runs `LISTEN conductor_await_resolved` and resumes the named run; a periodic +reconcile (scan `conductor_awaits_due_idx` and `status='waiting'` runs) is the fallback +for a dropped `LISTEN` connection, mirroring the multi-orchestrator reconcile (spec 001 +D3). + +## Declarative Schema — Manifest Extension (the "Conductor Surface") + +This feature adds an `emits:` block and an `events` permission to the *existing* plugin +`manifest.yaml` (loaded by `manifestLoader`, validated by `manifestLinter`). It is a +sibling of the existing `provides:` block — no parallel manifest format (FR-010). + +```yaml +# in a connector plugin's manifest.yaml +emits: + - id: github.pull_request.merged # stable, namespaced event id + label: "Pull request merged" + payload_schema: # JSON Schema; the Designer reads this + type: object + required: [repo, number, base, mergeSha] + properties: + repo: { type: string } + number: { type: integer } + base: { type: string } + mergeSha: { type: string } + schema_version: 1 + +permissions: + events: + emit: [github.pull_request.merged, github.release.created] # deny-by-default +``` + +- `provides:` (existing) already enumerates the **actions** a workflow can call back + into the connector. Together `emits:` + `provides:` are the connector's **Conductor + Surface** the Designer renders (FR-014). +- Absence of `emits:` is meaningful: the Designer shows the connector exposes no + Conductor triggers (FR-014). + +## Runtime Structures + +### `@omadia/conductor-core` (pure engine — no I/O) + +- `validate(graph): ValidationResult` — reachability, cycles, deadline-without-fallback, + unknown references (FR-003). +- `nextStep(graph, currentStepId, stepResult, ctx): Decision` — deterministic + advancement: postcondition verdict → matching guarded transition → else fallback → + else `Stuck` error (FR-001, FR-002, FR-006). +- No persistence, scheduling, notification, or LLM calls — those are kernel wiring + (FR-032). Unit-testable with fixtures exactly like `@omadia/canvas-core`. + +### `EventCatalogRegistry` (kernel, via `serviceRegistry`) + +Autodiscovered from installed manifests' `emits:` blocks ("declare → resolve → derive", +the canvas-output / deterministic-action pattern). Hot — install adds, uninstall removes +(FR-011). Read by: the Conductor's event subscription router (to start runs) and the +Designer (to offer triggers + payload fields). + +### `RoleResolver` registry (kernel, via `serviceRegistry`) + +`resolve(roleKey, ctx) → { holders: Principal[]; unavailable?: Principal[]; delegate?: Principal }`. +A default resolver reads `conductor_role_assignments`; an integration registers its own +(FR-021). Called late — at dispatch and on each reminder (FR-022). + +### `ctx.events.emit(id, payload)` (kernel, gated) + +Present only when the manifest declares `permissions.events.emit` (deny-by-default). +Validates `payload` against the catalog's declared schema; rejects + logs a +non-conforming emit; otherwise stamps provenance and routes to subscribed workflows +(FR-012). + +## State Machines + +### Run + +```text + ┌───────────── (step needs human/timer/event) ─────────────┐ + ▼ │ + (start) running ──(step completes, more steps)──▶ running │ + │ │ │ + │ └──▶ waiting ───────┘ (awaited signal arrives) + ├──(entry/end step, no more steps)──▶ completed + └──(step error / stuck-no-fallback)─▶ failed +``` + +### Await + +```text + (human step entered) waiting + ├── qualifying response (quorum satisfied) ──▶ resolved → resume run + ├── deadline passes, no qualifying response ──▶ timed_out → fire fallback transition + └── run cancelled/superseded ───────────────▶ cancelled +``` + +`waiting → {resolved, timed_out}` is atomic (FR-018): the first of {qualifying response, +deadline} wins; the transition emits `conductor_await_resolved`. + +## Relationships + +```text +conductor_workflows 1───n conductor_workflow_versions (a workflow has many versions) +conductor_workflows 1───1 conductor_workflow_drafts (one editable draft) +conductor_workflow_versions 1───n conductor_runs (a version backs many runs) +conductor_runs 1───n conductor_run_steps (a run records its step path) +conductor_runs 1───n conductor_awaits (a run may open several human steps) +conductor_awaits 1───n conductor_await_responses (per-holder responses; quorum) +conductor_roles 1───n conductor_role_assignments (a role has current holder(s) = the baton) +conductor_awaits n───1 conductor_roles (role-addressed await; resolved live) +users 1───n conductor_role_assignments (a user may hold several roles) +EventCatalogRegistry ──derives──> connector manifest `emits:` (runtime, per install) +``` + +## Validation Rules + +- `conductor_workflows.slug`: unique, immutable, URL-safe. +- A published version's `graph`: must pass `@omadia/conductor-core` `validate()` — + reachable steps, no unguarded cycle, every deadline-bearing human step has a + `fallbackTransitionId`, every referenced `agentId`/`actionId`/`role`/`eventId` resolves + (FR-003). Validation runs in the Designer and again on publish/activate. +- `conductor_runs` bind to an immutable `workflow_version_id`; a workflow edit creates a + new version and never mutates an in-flight run's version (FR-027). +- `conductor_awaits` with `deadline_at` set must carry `fallback_transition_id`. +- An emit is validated against the **installed** connector's declared `payload_schema` + for that `schema_version`; a non-conforming emit starts no run (FR-012). +- A role-addressed await authorizes read/answer against the role's *current* holders at + access time; a user who no longer holds the role cannot read or answer it (FR-023). +- A role that resolves to zero available holders makes the human step's postcondition + unmet → fallback transition fires (FR-024). +- `quorum = 'all'` evaluates the required-holder set at the completion check, re-resolved + via the `RoleResolver` (not frozen at await creation) (FR-019). diff --git a/specs/005-omadia-conductor/spec.md b/specs/005-omadia-conductor/spec.md new file mode 100644 index 00000000..cac2424b --- /dev/null +++ b/specs/005-omadia-conductor/spec.md @@ -0,0 +1,683 @@ +# Feature Specification: Omadia Conductor — Deterministic Workflow Engine, Designer & Human-in-the-Loop + +**Feature Branch**: `005-omadia-conductor` +**Created**: 2026-06-16 +**Status**: Draft +**Input**: An operator wants to build real, auditable processes that combine +**agentic steps** (an Agent does work) and **human steps** (a person decides, +approves, or supplies input) and that start automatically on real-world events — +e.g. a release pipeline that runs on every merge / RC-build and then asks a human +for release sign-off; a customer-handover preparation; a step that fires when a +calendar appointment approaches; or an applicant flow that starts when a +candidate is set to "invite" in an external ATS. The headline requirement is a +**deterministic harness**: the runtime — not the LLM — owns step progression and +hand-offs, so a process cannot silently stall the way prompt-only multi-agent +frameworks do (an agent that "forgets" to delegate). The operator must be able to +design these workflows visually and conversationally (a sibling of the Agent +Builder), save and later update them, and — after connecting an external system +via a connector plugin — immediately see whether and how that system can interact +with the Conductor. + +## Overview + +Today omadia runs Agents as single-agent orchestrator loops (`@omadia/orchestrator`, +`buildOrchestratorForAgent`). Multi-agent coordination is **LLM-decided**: an Agent +may call a domain sub-agent as a tool, or a plugin may call `ctx.subAgent.ask(...)`, +but **nothing in the runtime owns the order of steps or enforces a hand-off**. The +canvas Agent Graph (`@omadia/plugin-api` `agentGraph.ts`) is structural wiring, not +an executed sequence. The platform already ships the *atoms* of a deterministic +harness — tool **postconditions** + the verifier (`dynamicAgentRuntime.ts`, +`@omadia/verifier`), the OB-31 tool-obligation / repeat-failure loop guards +(`localSubAgent.ts`), and the `deterministic_action` fast-path +(`deterministicActionRegistry.ts`) — but only **per tool / per turn**, never across +a multi-step process or a hand-off. + +This feature introduces **Conductor**: a process layer that promotes those atoms to +**process scope**. A *Workflow* is a declarative graph of **steps** (an agent turn, +a deterministic action, or a human step) connected by **guarded transitions**. The +**Conductor** runtime owns advancement: after each step it evaluates the step's +**exit postcondition** and, when it is unmet, it does not hope the LLM self-corrects +— it acts deterministically (re-inject / force a tool obligation / route to a +declared fallback transition). A hand-off is a transition the Conductor fires, not a +prompt line an Agent can drop. + +A **human step** is the same pattern with a person as the actor: its postcondition is +"the addressed principal responded by the deadline"; if unmet, the deterministic +action is "send a reminder"; on deadline it fires the fallback transition. The +addressed principal is either a **specific user** or a **role** (a baton that is +late-bound at dispatch to whoever currently holds it). + +Workflows start on **triggers**. Every trigger funnels into a single entry point +(`startRun(workflowId, payload)`); from there the Conductor owns the run. One trigger +class is first-class and designed in from day one: **events emitted by connector +plugins**, declared in the connector's manifest (a self-describing "Conductor +Surface") so the Designer can surface them automatically. + +Architecture placement (see Assumptions): Conductor ships **in this repo, modular** — +a pure `@omadia/conductor-core` engine package (sibling of `@omadia/canvas-core`), +kernel wiring in `middleware/src/` via the existing `serviceRegistry`, and a Designer +under `web-ui/app/admin/conductor/` that mirrors the Agent Builder. No separate repo. + +Out of scope (handled elsewhere, deferred, or owned by the live instance): + +- **Connector plugins themselves** (GitHub/CI, ATS/HR, calendar, ERP, …). Conductor + defines the *contract* a connector implements; building connectors is separate + plugin work. Conductor never hard-codes knowledge of any specific connector. +- **The HR/ERP role-movement policy** — *when and why* a baton moves automatically + (sickness, vacation, org change). Conductor exposes the resolver seam and the + assignment store/APIs/events; the live instance + its integration own the policy. +- **N-of-M quorum** beyond the two-value `any | all` switch — a later extension. +- **Sub-workflow invocation as the deadline fallback** — per the 2026-06-16 + clarification the deadline fallback is an **in-graph transition only**. Workflow→ + workflow *triggering* is in scope; calling a separate workflow *as a deadline + handler* is not. +- **Distributed multi-process scheduling** — the existing single-process scheduler + model (`scheduleWorker`) is reused; horizontal scale-out of the timer loop is a + later concern, consistent with the platform today. +- **Knowledge-Graph / per-record ACL redesign** — Conductor consumes existing scoping + and adds only the await/role access rule defined here. + +## Clarifications + +### Session 2026-06-16 + +- Q: Conductor as a separate repo or in this monorepo? → A: **In-repo, modular** — + `@omadia/conductor-core` (pure engine) + kernel wiring via `serviceRegistry` + + Designer under `web-ui/app/admin/conductor/`. A separate repo would force + publishing internal `@omadia/*` packages and a cross-repo version matrix for + something that ships as one Docker image. Only an HR/ERP role resolver belongs in + a separate, swappable plugin. +- Q: When a human step's deadline passes, branch within the same workflow or call a + separate sub-workflow? → A: **In-graph branch only** (a guarded transition such as + "auto-reject" or "escalate"). Keeps the engine lean. +- Q: When a role has several holders, who must respond? → A: **Per-step switch** + `quorum: any | all` (default `any`). `any` = first responder decides; `all` = + every current holder must respond. +- Q: How important is the event/condition trigger? → A: **First-class, day one** (not + Phase 2). The *contract* — a connector's manifest `emits:` block, the event + catalog, `ctx.events.emit`, the subscription/filter model — ships now. Only the + connectors themselves are out of scope. +- Q: How is a role resolved to a person? → A: **Late binding** — resolved at step + dispatch and re-resolved on every reminder, via a pluggable `RoleResolver` seam + (registered like `LlmProvider`/channels). A default resolver reads omadia's own + manual assignment table; an integration may register a resolver that consults + external availability. Access to a pending await is granted to whoever holds the + role **at access time**, never frozen to a user id. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Deterministic Process Engine (`conductor-core`) (Priority: P1) + +A platform developer defines a Workflow as a graph of steps and guarded transitions +in a pure, I/O-free engine. The engine, given a current step result, decides the next +step deterministically: it evaluates the completed step's exit postcondition and +selects the matching transition; if no postcondition is satisfied and no transition +matches, it selects the step's declared fallback transition rather than ending +ambiguously. + +**Why this priority**: This is the harness — the headline capability. Every other +story builds on a runtime that owns advancement. It is pure and unit-testable in +isolation, exactly like `@omadia/canvas-core`. + +**Independent Test**: Construct a three-step workflow with a postcondition on step 1 +and two outgoing guarded transitions; feed synthetic step results; confirm the engine +selects the correct next step for a satisfied postcondition, the fallback transition +for an unmet one, and rejects a graph with an unreachable step or a cycle without a +progress guard at validation time. + +**Acceptance Scenarios**: + +1. **Given** a workflow graph, **When** a step completes with a result that satisfies + exactly one outgoing transition guard, **Then** the engine advances to that + transition's target step. +2. **Given** a step whose exit postcondition is unmet, **When** the engine evaluates + it, **Then** it does not advance on a "happy path" transition; it selects the + step's declared fallback (or raises a precise "stuck, no fallback" error if none). +3. **Given** a graph with an unreachable step or an unguarded cycle, **When** it is + validated, **Then** validation fails naming the offending node(s). +4. **Given** the same workflow and the same sequence of step results, **When** the + engine runs twice, **Then** it produces the identical step path (determinism). + +--- + +### User Story 2 - Durable Run Lifecycle & Resume (Priority: P1) + +A workflow run is persisted from start to finish. A run that is waiting (on a human, +a timer, or an external event) survives a process restart and resumes exactly where +it left off when the awaited signal arrives. + +**Why this priority**: Without durability the engine is a demo. Real processes wait +hours or days; a restart must not lose a run or double-fire a step. + +**Independent Test**: Start a run, advance it to a waiting step, restart the +middleware process, deliver the awaited signal, and confirm the run resumes at the +correct step with its accumulated context intact and no step re-executed. + +**Acceptance Scenarios**: + +1. **Given** a started run, **When** it advances, **Then** each completed step and the + run's accumulated context are persisted before the next step begins. +2. **Given** a run in a `waiting` state, **When** the process restarts, **Then** the + run is rehydrated and remains `waiting` — no step is re-executed and no timer is + lost. +3. **Given** a waiting run, **When** its awaited signal (human response, timer tick, + event) arrives, **Then** the run resumes at the waiting step and advances. +4. **Given** a step that throws or times out, **When** the engine handles it, **Then** + the run transitions to a `failed`/fallback state per the graph — never a silent + hang with no recorded state. + +--- + +### User Story 3 - Triggers Start a Run (Priority: P1) + +An operator (or another system) starts a workflow run. All entry paths — an inbound +channel message, a cron schedule, a manual UI/API start, an Agent calling a +`start_workflow` tool, an external webhook, or another workflow — funnel into one +`startRun(workflowId, payload)` entry point, and the trigger payload becomes the run's +initial context. + +**Why this priority**: A workflow that cannot be started is inert. The unified funnel +keeps the engine independent of how a run begins and makes new trigger types cheap. + +**Independent Test**: Define a workflow with a manual trigger and a cron trigger; +start it both ways; confirm both produce a run whose initial context equals the +supplied payload and whose first step is the workflow's entry step. + +**Acceptance Scenarios**: + +1. **Given** a workflow with a manual trigger, **When** an operator starts it with a + payload, **Then** a run is created with that payload as initial context. +2. **Given** a workflow with a cron trigger, **When** the schedule matches, **Then** a + run starts automatically, reusing the existing `scheduleWorker` mechanism. +3. **Given** a workflow bound to an inbound channel, **When** a matching message + arrives, **Then** a run starts with the message as initial context. +4. **Given** a disabled workflow, **When** any trigger fires, **Then** no run starts + and the suppressed trigger is logged — never silently dropped. + +--- + +### User Story 4 - Event Triggers & the Connector "Conductor Surface" (Priority: P1) + +A connector plugin declares, in its `manifest.yaml`, the **events it can emit** (each +with a stable id, label, and a payload JSON Schema) and the **actions it provides**. +On install, the kernel autodiscovers these into an event catalog. When the connector's +external system fires, the connector calls `ctx.events.emit(id, payload)`; the kernel +validates the payload against the declared schema and routes it. A workflow's event +trigger names an event id and an optional filter; a matching emit starts a run with +the validated payload as context. The Designer reads the catalog so the operator +immediately sees which Conductor interactions a freshly connected system supports. + +**Why this priority**: This is the trigger class the operator's real use cases depend +on (merge / RC-build, ATS "invite", calendar). Elevated to day-one in the 2026-06-16 +clarification. It reuses the existing manifest self-description (`provides:`) and the +"declare → resolve → derive" autodiscovery pattern (canvas-output / +deterministic-action), so it is idiomatic, not a foreign body. + +**Independent Test**: Install a fixture connector whose manifest declares an `emits:` +event with a payload schema; confirm the catalog lists it; emit a valid payload and a +schema-violating payload; confirm the valid one starts a subscribed workflow run with +the payload as context and the invalid one is rejected at the seam and logged; confirm +uninstalling the connector removes the event from the catalog. + +**Acceptance Scenarios**: + +1. **Given** a connector manifest with an `emits:` block, **When** it is installed, + **Then** each declared event (id, label, payload schema) appears in the event + catalog and is offered by the Designer as a selectable trigger. +2. **Given** a workflow subscribed to event `X` with filter `F`, **When** the + connector emits `X` with a payload matching `F`, **Then** a run starts with the + payload as initial context; a non-matching payload starts no run. +3. **Given** an emit whose payload violates the declared schema, **When** it reaches + `ctx.events.emit`, **Then** it is rejected with a precise error and logged — no run + starts on malformed data. +4. **Given** a connector that declares no `emits:`, **When** it is installed, **Then** + the Designer clearly shows it exposes no Conductor triggers (absence is as explicit + as presence) while still listing any actions it `provides:`. +5. **Given** a connector that is uninstalled, **When** the catalog is read, **Then** + its events are gone and workflows that subscribed to them surface a clear + "trigger source missing" diagnostic rather than silently never firing. + +--- + +### User Story 5 - Human Step with Durable Awaits, Reminders & Deadline (Priority: P1) + +A workflow step addresses a human for a decision, approval, or input. The step +notifies the addressed principal on a configured channel and creates a **durable +pending await**. If the human does not respond within the reminder interval, omadia +re-sends a reminder; if an optional deadline passes with no response, the Conductor +fires the step's in-graph fallback transition. When the human responds, the run +resumes. For a role with multiple holders the step's `quorum` decides whether one +response (`any`) or all current holders (`all`) are required. + +**Why this priority**: Human-in-the-loop is the explicit product requirement that +distinguishes Conductor from a pure agent pipeline. The durable await is the one +genuinely net-new substrate (today `ask_user_choice` is in-memory and dies on +restart). + +**Independent Test**: Build a workflow with a human approval step (target principal, +channel, 6h reminder, 24h deadline, fallback = "auto-reject"); start a run; confirm +the principal is notified and an await row persists; advance the clock past the +reminder with no response and confirm a reminder is sent; advance past the deadline +and confirm the fallback transition fires; in a second run, respond before the +deadline and confirm the run resumes on the approval branch. Verify both `quorum` +modes for a multi-holder role. + +**Acceptance Scenarios**: + +1. **Given** a human step, **When** the run reaches it, **Then** the addressed + principal is notified on the configured channel and a durable await is created in + the `waiting` state. +2. **Given** a pending await with a reminder interval, **When** the interval elapses + with no response, **Then** a reminder is sent (re-resolving a role to its *current* + holder), bounded so reminders stop once the await is resolved. +3. **Given** a pending await with a deadline, **When** the deadline passes with no + qualifying response, **Then** the Conductor fires the step's declared in-graph + fallback transition and the await is closed as `timed_out`. +4. **Given** a human response that arrives, **When** it is recorded, **Then** the run + resumes; a late response arriving after the deadline/resolution is rejected and + logged, never double-advancing the run. +5. **Given** a role-addressed step with `quorum: all`, **When** responses arrive, + **Then** the step completes only after every current holder has responded; with + `quorum: any` the first qualifying response completes it. + +--- + +### User Story 6 - Principals & the Role Resolver Seam (the "baton") (Priority: P1) + +A workflow step addresses a **principal**: either `user:` (a specific person, who +may be any omadia user of the instance, not only the workflow's creator) or +`role:` (a named seat). A role is resolved to its current holder(s) **at dispatch +time and re-resolved on every reminder**, through a pluggable `RoleResolver`. omadia +ships a default resolver backed by a manual assignment store (the baton can be moved +by an API/Designer action); an integration may register a resolver that reports the +current holder and unavailability from an external source. Access to a pending await +and its payload is granted to whoever holds the role **at access time** — when the +baton moves, access moves with it. + +**Why this priority**: Addressing a fixed person is brittle (people change roles, go +on leave). The role indirection is required for the operator's real processes and must +be in the data model from the start, not retrofitted. + +**Independent Test**: Define `role:approver`; assign it to user A; start a run that +addresses `role:approver`; confirm A is notified and can see/answer the await; move the +baton to user B; confirm the next reminder targets B, that B can now see/answer the +await, and that A no longer can; with no holder assigned, confirm the step takes the +fallback transition. + +**Acceptance Scenarios**: + +1. **Given** a step addressing `user:`, **When** the run reaches it, **Then** that + specific omadia user is the addressed principal regardless of who started the run. +2. **Given** a step addressing `role:`, **When** the step dispatches, **Then** the + holder is resolved live via the `RoleResolver`; the registered resolver (default: + manual store) determines the result and Conductor hard-codes no role semantics. +3. **Given** a pending await for a role, **When** the baton moves to a new holder, + **Then** the new holder gains access to the await and its payload and the previous + holder loses it, resolved at access time — not frozen to a user id. +4. **Given** a role with no current holder (or all holders reported unavailable with no + delegate), **When** the step dispatches or a reminder is due, **Then** it is treated + as an unmet postcondition and the fallback transition fires — reusing the same + harness, no special-casing. +5. **Given** a baton move, **When** it occurs, **Then** a `role.assignment.changed` + event and an `await.reassigned` event are emitted for audit and for any external + subscriber. + +--- + +### User Story 7 - Conductor Designer: Visual & Conversational Co-Design (Priority: P2) + +An operator opens the Conductor Designer, designs a workflow in conversation with a +builder agent and on a visual flow diagram (the same UX as the Agent Builder, applied +to the collaboration *between* Agents), and saves it. Saved workflows can be reopened +and updated; saves are versioned so a later edit does not silently mutate the +definition a running release depends on. + +**Why this priority**: The capability is usable via API/config once US1–US6 land; the +Designer is the ergonomics layer that makes it a product. It is high value but depends +on the engine and the catalog existing first — the same sequencing the Agent Builder's +Operator UI followed. + +**Independent Test**: Use the Designer to build a workflow with a trigger, an agentic +step, and a human step with a role and a deadline fallback; save it; confirm it +validates and persists; reopen it, change the reminder interval, save again, and +confirm a new version is recorded while a run started on the prior version is +unaffected. + +**Acceptance Scenarios**: + +1. **Given** the Designer canvas, **When** the operator adds steps, transitions, and a + trigger and wires them, **Then** the visual graph and the persisted workflow + definition stay in sync via the same optimistic-mutation-with-rollback pattern the + Agent Builder uses. +2. **Given** the builder agent, **When** the operator describes a process in chat, + **Then** the agent mutates the workflow definition incrementally (create step, wire + transition, set postcondition, add human step) and the canvas reflects each change. +3. **Given** installed connectors, **When** the operator picks a trigger, **Then** the + Designer offers the event catalog's events with their payload fields available for + filters/branches (field autocomplete from the declared schema). +4. **Given** a saved workflow, **When** the operator edits and re-saves it, **Then** a + new version is recorded and runs already in flight continue on the version they + started with. +5. **Given** an invalid workflow (unreachable step, missing fallback on a deadline, + unknown role), **When** the operator tries to save/activate it, **Then** validation + blocks it and names the failing check. + +--- + +### User Story 8 - Workflow Dry-Run / Preview (Priority: P2) + +Before activating a workflow, the operator runs it in a preview mode that simulates the +multi-agent path and lets the operator stand in for human steps, without notifying real +users or performing irreversible connector actions. + +**Why this priority**: Mirrors the Agent Builder's preview value — confidence before +go-live — but for a process. Multi-agent preview is net-new (the single-agent +`previewRuntime` does not cover it), so it is its own story, not a reuse. + +**Independent Test**: Dry-run a workflow with one agentic and one human step; confirm +the agentic step executes against preview-scoped tools, the human step prompts the +operator inline (no real channel notification, no durable await against a real user), +and the simulated path matches the engine's deterministic decisions. + +**Acceptance Scenarios**: + +1. **Given** dry-run mode, **When** a run executes, **Then** human steps are answered + inline by the operator and no real notification, reminder, or durable await against + a real user is created. +2. **Given** dry-run mode, **When** a step would call a connector action flagged + irreversible, **Then** it is simulated/stubbed rather than executed. +3. **Given** a dry-run, **When** it completes, **Then** the operator sees the full step + path, each step's postcondition outcome, and where fallbacks would have fired. + +--- + +### User Story 9 - Run Audit & Observability (Priority: P3) + +Every run produces an auditable trace: which trigger started it, each step with its +actor (Agent or resolved human principal), each postcondition outcome, each transition +taken (including fallbacks), every reminder sent, and every baton resolution. This +plugs into omadia's existing per-run trace / call-stack viewer. + +**Why this priority**: Auditability is a core omadia promise and a selling point over +prompt-only frameworks, but the run is functional without the viewer; this is the +observability layer on top. + +**Independent Test**: Run a workflow that takes a fallback transition and sends a +reminder; open the run trace; confirm the trigger, every step, the postcondition +verdicts, the reminder, the resolved human principal, and the fallback transition are +all present and ordered. + +**Acceptance Scenarios**: + +1. **Given** a completed run, **When** its trace is opened, **Then** it shows the + trigger, the ordered step path, each actor, each postcondition outcome, and each + transition (including fallbacks). +2. **Given** a human step, **When** its trace entry is inspected, **Then** it records + the addressed principal, the *resolved* holder at dispatch, any reminders, and the + final response or timeout. +3. **Given** an event-triggered run, **When** its trace is inspected, **Then** the + originating event id, source connector, and (redaction-respecting) payload are + recorded. + +--- + +### Edge Cases + +- **Process restart mid-wait**: the run stays `waiting`; the timer for reminders/ + deadline is re-derived from persisted timestamps on boot, not from an in-memory + timer (US2). +- **Deadline fires while a response is in flight**: resolution is atomic — the first of + {qualifying response, deadline} wins; the loser is rejected and logged, the run never + double-advances (US5). +- **Reminder after resolution**: reminders are bounded by the await state; once + `resolved`/`timed_out`, no further reminder is sent (US5). +- **Baton moves mid-wait**: the next reminder re-resolves and targets the new holder; + access to the await follows the current holder at access time (US6). +- **Role with no holder / all unavailable**: treated as an unmet postcondition → the + fallback transition fires; no silent hang (US6). +- **`quorum: all` and a holder leaves the role mid-wait**: the required set is the + holders current *at completion check* time; a departed holder's outstanding + obligation is dropped, a newly added holder's is added — re-resolved, not frozen + (US5/US6). +- **Connector uninstalled while a run is subscribed/waiting on its events**: the + workflow surfaces a "trigger source missing" diagnostic; in-flight runs already + started are unaffected (US4). +- **Event payload schema changes between connector versions**: the catalog records the + schema version; an emit is validated against the installed version; a subscribed + workflow referencing a now-absent field surfaces a validation diagnostic in the + Designer (US4). +- **Cyclic graph / unreachable step / deadline step with no fallback**: rejected at + workflow validation time, in the Designer and on activation (US1/US7). +- **Workflow edited while runs are in flight**: in-flight runs continue on their + started version; only new runs use the new version (US7). +- **Two triggers fire for the same workflow near-simultaneously**: each produces an + independent run; runs do not share mutable state. +- **Human step targets a user who has no binding on the configured channel**: the await + is created but flagged "principal unreachable on channel"; per configuration this + either escalates via the fallback or surfaces an operator diagnostic — never a silent + no-op. +- **Agentic step stalls (LLM ends without satisfying the postcondition)**: the + Conductor applies the existing tool-obligation/repeat-failure guards at step scope + and, if still unmet, fires the fallback — the harness on track (US1). + +## Requirements *(mandatory)* + +### Functional Requirements + +**Engine & runs** + +- **FR-001**: The system MUST provide a pure, I/O-free engine package + (`@omadia/conductor-core`) that models a Workflow as steps + guarded transitions and, + given a completed step's result, deterministically selects the next step or the + step's declared fallback. +- **FR-002**: The engine MUST evaluate a completed step's **exit postcondition** and + MUST NOT advance on a happy-path transition when the postcondition is unmet; it MUST + instead select the step's fallback transition, or raise a precise error if none is + declared. +- **FR-003**: Workflow validation MUST reject unreachable steps, unguarded cycles, a + deadline-bearing human step without a fallback transition, and references to unknown + roles, events, agents, or actions — naming the offending node. +- **FR-004**: A workflow **run** MUST be persisted such that each completed step and the + run's accumulated context are durable before the next step begins, and a `waiting` + run MUST survive a process restart and resume without re-executing or skipping a step. +- **FR-005**: A step that throws or exceeds its time budget MUST drive the run to a + recorded `failed`/fallback state per the graph — never an unrecorded hang. +- **FR-006**: The engine MUST be deterministic: identical workflow + identical sequence + of step results MUST yield the identical step path. + +**Triggers** + +- **FR-007**: All trigger types MUST funnel into a single `startRun(workflowId, + payload)` entry point, and the trigger payload MUST become the run's initial context. +- **FR-008**: The system MUST support, as start triggers, at minimum: manual + (UI/API), cron (reusing `scheduleWorker`/`agent_schedules`), inbound channel message, + an Agent-invoked `start_workflow` tool, an external webhook, and workflow→workflow. +- **FR-009**: A trigger that fires for a disabled or non-existent workflow MUST start no + run and MUST be logged — never silently dropped. + +**Event triggers / Conductor Surface** + +- **FR-010**: The plugin `manifest.yaml` MUST be extendable with an `emits:` block in + which a connector declares events it can emit — each with a stable `id`, a human + label, and a payload JSON Schema. This is a sibling of the existing `provides:` block; + no parallel manifest format is introduced. +- **FR-011**: On install/activation the kernel MUST autodiscover declared `emits:` + entries into an event catalog (the "declare → resolve → derive" pattern, provided via + `serviceRegistry`), and MUST remove them on uninstall/hot-unload. +- **FR-012**: The kernel MUST expose `ctx.events.emit(id, payload)`, gated by a manifest + permission (`permissions.events.emit`, deny-by-default), and MUST validate the payload + against the declared schema, rejecting and logging a non-conforming emit so no run + starts on malformed data. +- **FR-013**: A workflow event trigger MUST be able to name an event `id` plus an + optional filter over payload fields; a matching emit MUST start a run with the + validated payload as initial context; a non-matching emit MUST start no run. +- **FR-014**: The system MUST expose the catalog such that, after a connector is + installed, an operator can see which events (triggers) and which actions a connector + makes available to the Conductor — and the absence of `emits:` MUST be presented as + clearly as its presence. + +**Human steps & awaits** + +- **FR-015**: A human step MUST create a **durable** pending await (surviving process + restart) carrying its addressed principal, channel, message, reminder interval, + optional deadline, fallback transition reference, `quorum`, and status. +- **FR-016**: The system MUST notify the addressed principal on the configured channel + using the existing proactive-send mechanism, and MUST send reminders at the configured + interval until the await is resolved or timed out. +- **FR-017**: When a deadline passes with no qualifying response, the system MUST fire + the human step's **in-graph fallback transition** (not a separate sub-workflow) and + close the await as `timed_out`. +- **FR-018**: Await resolution MUST be atomic between a qualifying response and the + deadline; a response arriving after resolution/timeout MUST be rejected and logged, + never double-advancing the run. +- **FR-019**: A human step MUST support `quorum: any | all` (default `any`); `all` MUST + complete only when every *current* holder of the addressed role has responded, with + the required set re-resolved (not frozen) at the completion check. + +**Principals & roles** + +- **FR-020**: A human step MUST address a **principal** that is either `user:` (any + omadia user of the instance, not only the run's initiator) or `role:`. +- **FR-021**: A `role:` MUST be resolved to its current holder(s) via a pluggable + `RoleResolver` registered through `serviceRegistry` (the same seam pattern as + `LlmProvider`/channels); Conductor MUST hard-code no role semantics. A default + resolver MUST be provided, backed by a manual assignment store with APIs to move the + baton. +- **FR-022**: Role resolution MUST be **late-bound**: performed at step dispatch and + re-performed on each reminder, so a baton that moves before or during a wait routes to + the current holder. +- **FR-023**: Access to a pending await and its payload MUST be authorized against the + role's holder **at access time**; when the baton moves, the new holder gains access + and the previous holder loses it. +- **FR-024**: A role with no current holder (or all holders reported unavailable with no + delegate) MUST be treated as an unmet postcondition and fire the fallback transition. +- **FR-025**: Baton moves and await reassignments MUST emit `role.assignment.changed` + and `await.reassigned` events for audit and external subscription. + +**Designer** + +- **FR-026**: The system MUST provide a Conductor Designer under + `web-ui/app/admin/conductor/` that lets an operator build a workflow visually (a flow + diagram reusing the Agent Builder's React-Flow canvas, optimistic-mutation, and REST + patterns) and conversationally (a builder agent that incrementally mutates the + workflow definition). +- **FR-027**: The Designer MUST persist workflows with **versioning**; editing and + re-saving a workflow MUST create a new version and MUST NOT alter the definition used + by runs already in flight. +- **FR-028**: The Designer MUST source trigger options from the live event catalog and + MUST offer payload fields (from the declared schema) for filters and branch + conditions; it MUST block save/activation of an invalid workflow, naming the failing + check. + +**Preview & audit** + +- **FR-029**: The system MUST provide a dry-run/preview mode in which human steps are + answered inline by the operator (no real notification, reminder, or durable await + against a real user) and connector actions flagged irreversible are simulated. +- **FR-030**: Every run MUST emit a structured, auditable trace — trigger, ordered step + path, each actor (Agent or resolved human holder), each postcondition outcome, each + transition (including fallbacks), reminders, and baton resolutions — integrating with + omadia's existing per-run trace viewer and respecting existing redaction. + +**Architecture & reuse** + +- **FR-031**: Conductor MUST reuse the existing platform primitives rather than + duplicate them: orchestrator/sub-agent loop and its postcondition/obligation guards, + `scheduleWorker` for time-driven signals, the channel registry + proactive sender for + notifications, the user store for principals, and the verifier — extended, not + replaced. +- **FR-032**: The engine (`@omadia/conductor-core`) MUST be pure and I/O-free; all + persistence, scheduling, notification, and LLM I/O MUST live in kernel wiring outside + the engine package, so the engine is unit-testable in isolation. + +### Key Entities + +- **Workflow**: a named, versioned process definition — a graph of steps + guarded + transitions + one or more triggers. Identified by a slug; immutable per version. +- **Workflow Version**: an immutable snapshot of a workflow's graph; runs bind to the + version they start on. +- **Step**: a node of kind `agent` (an Agent turn), `action` (a deterministic action), + or `human` (a human step). Carries an exit postcondition and a fallback transition + reference. +- **Transition**: a guarded directed edge from one step to another; the guard is + evaluated against the source step's result/context. A step's fallback is a designated + transition. +- **Trigger**: a run starter bound to a workflow — kind `manual | cron | channel | + agent | webhook | workflow | event`. An `event` trigger names a catalog event id + an + optional payload filter. +- **Run**: a live or completed execution of a Workflow Version — state, current step, + accumulated context, audit trace. States include `running | waiting | completed | + failed`. +- **Await (`conductor_awaits`)**: a durable pending human action for a run's human step + — addressed principal, channel, message, reminder interval, optional deadline, + fallback reference, `quorum`, status (`waiting | resolved | timed_out | cancelled`), + recorded response. +- **Principal**: the addressee of a human step — `user:` or `role:`. +- **Role**: a named seat (`key`, label, scope) addressable by a human step. +- **Role Assignment**: the binding of a role to current holder principal(s) — the baton; + provenance (`manual | resolver:`), validity window, optional delegate. +- **Role Resolver**: a registered provider that resolves a role key to current + holder(s) and availability; default is the manual-assignment-backed resolver. +- **Event Catalog Entry**: a declared connector event — id, source plugin, label, + payload JSON Schema (versioned) — autodiscovered from a connector's `emits:` block. +- **Conductor Surface**: a connector's declared interaction set with the Conductor — + its `emits:` events (triggers) plus its `provides:` actions — surfaced in the Designer. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An operator can build, save, and run a workflow that combines at least one + agentic step and at least one human step, end to end, without writing code. +- **SC-002**: A workflow run that is waiting on a human step survives a middleware + process restart and resumes correctly when the human responds — verified by an + automated restart test (no step re-executed, none skipped). +- **SC-003**: A human step with a reminder interval and a deadline sends the reminder at + the interval and fires the in-graph fallback at the deadline, in 100% of no-response + cases — verified by a clock-driven automated test for both `quorum: any` and `all`. +- **SC-004**: After installing a fixture connector that declares `emits:`, the declared + events appear in the catalog and are selectable as triggers in the Designer with their + payload fields available — with zero manual wiring. +- **SC-005**: An emit whose payload violates the declared schema starts no run and is + logged with a precise error — verified by an automated test. +- **SC-006**: Moving a role's baton from holder A to holder B causes the next reminder + to target B and transfers await access from A to B, resolved at access time — + verified by an automated test; A can no longer read the await, B can. +- **SC-007**: A role with no current holder causes the human step to take its fallback + transition rather than hang — verified by an automated test. +- **SC-008**: Editing and re-saving a workflow creates a new version while a run started + on the prior version completes unchanged — verified by an automated test. +- **SC-009**: The deterministic engine produces an identical step path for identical + inputs across repeated runs — verified by a property/fixture test in + `@omadia/conductor-core` with no I/O. +- **SC-010**: A completed run's trace contains the trigger, every step with its actor, + every postcondition outcome, every transition (including fallbacks), reminders, and + baton resolutions. + +## Assumptions + +- Conductor ships **in this repo, modular**: `@omadia/conductor-core` (pure engine) + + kernel wiring in `middleware/src/` via the existing `serviceRegistry` + a Designer + under `web-ui/app/admin/conductor/`. No separate repository; only an HR/ERP role + resolver is expected to live in a separate, swappable connector plugin. +- The existing primitives are reused as-is and extended, not replaced: the orchestrator + / sub-agent loop and its postcondition, tool-obligation, and repeat-failure guards; + the `scheduleWorker` cron scheduler (minute granularity, DB-durable, single-process); + the channel registry + proactive sender; the user store; the verifier; the Agent + Builder's canvas, builder-agent, and REST patterns. +- Connector plugins (GitHub/CI, ATS/HR, calendar, ERP, …) are separate plugin work. + Conductor defines and depends only on the *contract* (`emits:` / `provides:` / the + event catalog / `ctx.events.emit`), never on a specific connector. +- The HR/ERP role-movement *policy* (when/why a baton moves) is owned by the live + instance and its integration; Conductor provides the resolver seam, the manual + assignment store + APIs, and the events — and exposes state/data access scoped to the + current holder so any integration can drive movement. +- A human principal is reachable proactively on a channel only if a channel binding / + conversation reference for that user exists; provisioning those bindings is an + operational concern reusing existing channel mechanisms. +- The existing Postgres (Neon) instance is available for workflow, run, await, role, and + catalog storage and supports `LISTEN/NOTIFY` for run resume on human response. +- The reminder/deadline timing granularity inherits the scheduler's minute-level + resolution, which is sufficient for human-response cadences (hours/days). +- `deterministic_action`, postconditions, and the verifier already exist at tool/turn + scope; this feature promotes their use to process scope and does not redefine them. From 4518e8d0b6171bba88168c72c446d08f7681f569 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 07:50:53 +0200 Subject: [PATCH 02/19] docs(conductor): add Spec 005 integration & implementation plan Grounded against the live codebase: phase-by-phase build sequence (US1-US9), reuse map, net-new substrate, 16 resolved integration landmines (A-P), test strategy mapped to SC-001..010, and the 4 owner design decisions (conductor_channel_bindings, conductor_schedules, FOR UPDATE SKIP LOCKED claim, serializable predicate AST). --- specs/005-omadia-conductor/plan.md | 510 +++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 specs/005-omadia-conductor/plan.md diff --git a/specs/005-omadia-conductor/plan.md b/specs/005-omadia-conductor/plan.md new file mode 100644 index 00000000..1293ad12 --- /dev/null +++ b/specs/005-omadia-conductor/plan.md @@ -0,0 +1,510 @@ +# Implementation & Integration Plan: Omadia Conductor + +**Feature Branch**: `005-omadia-conductor` +**Inputs**: `spec.md`, `data-model.md` (this directory) +**Created**: 2026-06-17 +**Status**: Draft — grounded against the live codebase (worktree off `main`) + +> This plan was produced by reading both spec artifacts and then grounding every +> primitive the spec leans on against the **real** middleware/web-ui code. Each +> phase below names the exact files to reuse, the seam to hook, the net-new code, +> and the integration risk. The "Landmines" section (§7) is the deep-search output: +> every place the spec's assumptions diverge from what actually exists today. + +--- + +## 1. Executive Summary + +Conductor is **mostly an assembly job on top of mature primitives, plus three genuinely +net-new substrates**. The headline engine (`@omadia/conductor-core`) is a pure package +that fits the established `@omadia/canvas-core` mold exactly. The event-catalog +autodiscovery is a near-verbatim clone of the shipped `canvasOutputRegistry` / +`deterministicActionRegistry` pattern. The Designer reuses the Agent Builder's React-Flow +canvas, optimistic-mutation hook, and REST conventions. + +The three things that **do not exist today** and carry the real risk: + +1. **A durable await** (`conductor_awaits`) — `ask_user_choice` is not just in-memory, it + does not survive a single turn boundary. This is greenfield. +2. **`ctx.events.emit` + the event bus** — no event surface exists on `PluginContext` + today; `notifications.send` is channel fan-out, not an event bus. +3. **A multi-agent preview + a multi-agent run executor** — `previewRuntime` and the + orchestrator are strictly single-agent; the run executor that drives a *graph* of + agent/human/action steps is net-new (the engine decides the path; the executor + performs the I/O for each step). + +Two cross-cutting constraints shape everything: **(a)** all persistence is gated on the +Neon `graphPool`, which is `undefined` on the in-memory backend — Conductor must +degrade-skip exactly like routines/schedules do; **(b)** there is **no central migration +runner** — each subsystem ships its own migrator. + +**Recommended sequencing** (matches the spec's P1→P2→P3 and the Agent-Builder precedent): +engine-core → durable run lifecycle → triggers (incl. event surface) → human steps & +awaits → roles → Designer → preview → audit. + +--- + +## 2. Architecture Placement & Package Topology + +Per the 2026-06-16 clarification (in-repo, modular). Confirmed feasible against the +existing package layout (`middleware/packages/*`, kernel `middleware/src/*`, `web-ui/app/*`). + +| Layer | Location | Mirror of | Notes | +|---|---|---|---| +| Pure engine | `middleware/packages/conductor-core/` | `middleware/packages/canvas-core/` | ajv-only runtime dep; schemas as `*.schema.json` + generated validators; fixture-driven vitest. `@omadia/plugin-api` as **devDep only** to stay I/O-free. | +| Kernel wiring | `middleware/src/conductor/` (new dir) | `middleware/src/scheduler/`, `middleware/src/plugins/routines/` | Stores (`*Store.ts` over `graphPool`), the run executor, the await worker, the event router, the role-resolver registry, the migrator. | +| Manifest extension | `middleware/src/api/admin-v1.ts` + `middleware/src/plugins/manifestLoader.ts` | existing `provides:` / `PluginPermissionsSummary` | Add `emits:` parsing and an `events_emit` permission. | +| Plugin contract | `middleware/packages/plugin-api/src/` | `pluginContext.ts` accessors | Add `EventsAccessor` + `readonly events?` on `PluginContext`; add `emits`/`events` types. | +| Designer (web-ui) | `web-ui/app/admin/conductor/` | `web-ui/app/admin/builder/` | React-Flow canvas + `useConductorGraph` (clone of `useAgentGraph`) + `conductorBuilder.ts` REST client. | +| Designer chat agent | `middleware/src/conductor/builder/` | `middleware/src/plugins/builder/builderAgent.ts` | New conductor-spec patch toolset + system prompt. | +| Operator API | `middleware/src/routes/conductor*.ts` mounted at `/api/v1/operator/conductors/*` behind `requireAuth` | `routes/operatorAgents.ts` | All writes through `/bot-api` → cookie-JWT. | + +**Boot wiring** lands in `middleware/src/index.ts` next to `ScheduleWorker`/`initRoutines`, +all `if (graphPool) { … }`-gated (see §4 Phase 2, §7-F). + +--- + +## 3. Reuse Map — spec primitive → real artifact → status + +Legend: ✅ reuse as-is · 🔶 reuse + extend · 🆕 net-new (no precedent) · ⚠️ mismatch to resolve + +| Spec calls for | Real artifact (grounded) | Status | +|---|---|---| +| `@omadia/conductor-core` pure engine | `middleware/packages/canvas-core/` (ajv-only, fixture-tested) | ✅ template | +| `buildOrchestratorForAgent` for agent steps | `middleware/packages/harness-orchestrator/src/buildOrchestrator.ts` L155 | ✅ (note: `buildOrchestrator` is test-only) | +| verifier / postconditions | `harness-verifier/src/verifierPipeline.ts`; kernel `verifierService.ts` | 🔶 verify exists; postcond = Zod-output only; binding is kernel-side | +| OB-31 obligation / repeat-failure guards | `harness-orchestrator/src/localSubAgent.ts`; `loopGuard.ts` | 🔶 in-memory, per-`ask()`, no process scope | +| deterministic-action fast-path | `deterministicActionRegistry.ts`; `omadia-ui-orchestrator/src/plugin.ts` L442-480 | ⚠️ canvas/UI-action-shaped, not a general step skip | +| `serviceRegistry` seam | `middleware/src/platform/serviceRegistry.ts` (`provide`/`get`/`replace`) | ✅ | +| declare→resolve→derive autodiscovery (event catalog) | `canvasOutputRegistry.ts` + `dynamicAgentRuntime.ts` activate/deactivate L524-572 | ✅ exact template — but resolve hook is dynamic-agents-only (§7-K) | +| manifest `provides:` / `permissions:` | `admin-v1.ts` `Plugin` L222+, `PluginPermissionsSummary` L69; `manifestLoader.ts` `adaptManifestV1` | 🔶 no standalone `manifestLinter`; catalog startup-cached | +| `ctx.events.emit` | — none — closest is `notifications.send` (fan-out) | 🆕 | +| `scheduleWorker` / `agent_schedules` for cron + await polling | `middleware/src/scheduler/scheduleWorker.ts`; `migrations/0003` | 🔶 DB-durable rows, but in-memory dedup, UTC-only, no due-poll/claim | +| proactive sender / channel notify | `plugins/routines/proactiveSender.ts`; `channels/channelRegistry.ts` | ⚠️ Teams only; registry in-memory; no user→conversationRef store | +| durable await (replaces `ask_user_choice`) | `harness-orchestrator/src/tools/askUserChoiceTool.ts` (per-turn instance field) | 🆕 | +| inbound channel → run trigger | `channels/coreApi.ts` `handleTurnStream`; `orchestratorDispatcher.ts` | ✅ hook point; keyed on agent-binding not user | +| webhook trigger | channel-transport-specific (`/api/messages`, Telegram) | ⚠️ no generic ingress route | +| `users` table / `user:` principal | `middleware/src/auth/userStore.ts`; `auth/migrations/0001_users.sql` | 🔶 auth-only; no channel-binding join | +| Agent Builder canvas (React-Flow) | `web-ui/app/admin/builder/BuilderCanvas.tsx` (`@xyflow/react`) | 🔶 hard-coded to single-agent `AgentGraph` topology | +| optimistic-mutation + REST | `web-ui/app/admin/builder/useAgentGraph.ts`; `_lib/agentBuilder.ts`, `_lib/api.ts` | ✅ copy `mutate` shape + dual-path client | +| conversational builder agent + `patch_spec` | `middleware/src/plugins/builder/builderAgent.ts`; `tools/patchSpec.ts` | 🔶 mutates AgentSpec; needs conductor spec/tools/prompt | +| `previewRuntime` (multi-agent preview) | `plugins/builder/previewRuntime.ts` (one ZIP→one agent) | 🆕 multi-agent preview | +| run persistence / resume | spec 001 config tables + `routine_runs` (audit) + `ReloadBus` D3 | 🆕 durable in-flight run/resume; reuse `routine_runs` column shape + notify/reconcile | +| migration conventions | `middleware/migrations/` + per-subsystem migrators (`runAuthMigrations` etc.) | ✅ TEXT+CHECK; ship a `runConductorMigrations` | +| DB pool | `serviceRegistry.get('graphPool')` (owned by KG-Neon plugin) | ✅ gate all persistence on it | +| `pg_notify` + LISTEN | `migrations/0001-0002` `notify_*`; `harness-orchestrator/src/registry/reloadBus.ts` | 🔶 real, but `enableListen=false` default (pool budget) | +| operator auth | `auth/requireAuth.js`; session-scoped handlers | ✅ mount conductor routes behind it | + +--- + +## 4. Build Sequence + +Each phase is independently testable and ordered so every later phase builds on a landed, +verified substrate (the spec's own sequencing rationale). Phase ↔ User Story ↔ Priority +mapping is noted. + +### Phase 0 — Foundations (enabling, no user story) + +- **`middleware/packages/conductor-core/`** scaffold mirroring `canvas-core`: `package.json` + (`main: dist/src/index.js`, ajv runtime dep, plugin-api devDep), `tsconfig`, `src/index.ts`, + `schema/`, `fixtures/`, `test/`, `tools/genValidator.ts`. +- **`runConductorMigrations` + `_conductor_migrations`** tracking table, following the + `runAuthMigrations`/`runRoutineMigrations` template verbatim (`CREATE TABLE IF NOT EXISTS`, + read applied set, sorted `.sql` apply in `BEGIN/COMMIT`). `MIGRATIONS_DIR` resolved relative + to the migrator module. Wired into `index.ts` boot under `if (graphPool)`. +- **`0001_conductor.sql`**: all `conductor_*` tables from `data-model.md`, TEXT+CHECK enums, + `TIMESTAMPTZ DEFAULT now()`, partial indexes (`conductor_runs_waiting_idx`, + `conductor_awaits_due_idx`), and the two `notify_*` trigger functions + (`notify_await_resolved`, `notify_role_changed`). + +### Phase 1 — Deterministic Engine `conductor-core` (US1, P1) ✅ low risk + +- **Build**: `validate(graph)` (reachability, unguarded-cycle, deadline-without-fallback, + unknown-reference checks) and `nextStep(graph, currentStepId, stepResult, ctx): Decision` + (postcondition verdict → matching guarded transition → fallback → `Stuck` error). +- **Reuse**: pattern from `canvas-core` validators; ajv for the `graph` JSON-schema. +- **Net-new**: the graph schema itself, the guard-evaluation language, the postcondition + representation (see §7-D — must be a real predicate language, not Zod-output reuse). +- **Test (SC-009)**: property/fixture tests, zero I/O — identical inputs → identical path; + reject-corpus for invalid graphs naming the offending node. +- **Risk**: low. This is the cleanest reuse. The only design decision is the + guard/postcondition expression language (recommend a small, serializable predicate AST + over `ctx`/`stepResult`, JSON-schema-validated — NOT JS eval). + +### Phase 2 — Durable Run Lifecycle & Resume (US2, P1) 🆕 high value + +- **Build**: `ConductorRunStore` + `ConductorRunStepStore` (`pg`, over `graphPool`); a + **run executor** that loads a run, asks `conductor-core.nextStep`, performs the step's I/O + (agent turn / action / human dispatch), persists step + context **before** advancing + (FR-004), and parks the run in `waiting` for human/timer/event signals. +- **Reuse**: `routine_runs` column shape for the audit fields; `ReloadBus` notify/reconcile + pattern (`reloadBus.ts`) for resume; `serviceRegistry.get('graphPool')`. +- **Resume**: `LISTEN conductor_await_resolved` → resume named run; **60s reconcile** + (scan `status='waiting'` + due awaits) as the authoritative fallback. See §7-E: rely on + reconcile first; treat LISTEN as an optimization, because `enableListen=false` by default. +- **Test (SC-002)**: start → advance to waiting → restart process → deliver signal → resume + at correct step, no step re-executed/skipped. Step that throws/times out → recorded + `failed`/fallback, never an unrecorded hang (FR-005). +- **Risk**: medium-high. Net-new state machine; the at-most-once step execution under + restart + concurrent reconcile is the crux (§7-G idempotency). + +### Phase 3 — Triggers & the Event Surface (US3 + US4, P1) 🆕 + ✅ + +- **Single funnel** `startRun(workflowId, payload)` (FR-007). Trigger kinds: + - `manual` (UI/API) — new operator route. ✅ + - `cron` — reuse `ScheduleWorker`; map a workflow cron trigger to an `agent_schedules`-style + row OR a parallel `conductor_schedules` table polled by the same worker tick. 🔶 (§7-A) + - `channel` — hook `coreApi.handleTurnStream` / `TurnDispatcher.streamTurn`. ✅ + - `agent` — a `start_workflow` native tool (FR-008). 🆕 small + - `webhook` — **new generic ingress route** (no precedent; §7-I). 🆕 + - `workflow` — internal call into `startRun`. ✅ + - `event` — the Conductor Surface (below). 🆕 +- **Event Surface (US4)**: + - `emits:` manifest block + `permissions.events.emit` parsing in `adaptManifestV1` + (`admin-v1.ts` + `manifestLoader.ts`). 🔶 + - **`EventCatalogRegistry`** = copy `CanvasOutputRegistry`; `eventCatalogToolIds`-equivalent + extractor; register/unregister in `dynamicAgentRuntime.activate/deactivate` (L524-572). + ⚠️ **Verify built-in/static plugins also resolve** their `emits:` — the canvas-output hook + is wired only for the dynamic runtime (§7-K). + - **`ctx.events.emit(id, payload)`** — new `EventsAccessor` on `PluginContext` + (`plugin-api/src/pluginContext.ts`), provisioned in `createPluginContext` + (`middleware/src/platform/pluginContext.ts`), gated on the new permission, validates + payload against the catalog schema, rejects+logs non-conforming, routes to subscribed + workflows. 🆕 (§7-B) +- **Disabled/missing workflow** (FR-009): suppressed trigger logged, never dropped. +- **Test (SC-004/005)**: fixture connector with `emits:` → catalog lists it → valid emit + starts a subscribed run, schema-violating emit starts none and is logged → uninstall removes + it and subscribers surface "trigger source missing". +- **Risk**: medium. The event accessor is net-new contract surface; the static-plugin + resolve coverage is the sneaky gap. + +### Phase 4 — Human Steps & Durable Awaits (US5, P1) 🆕 highest net-new + +- **Build**: `ConductorAwaitStore` + `ConductorAwaitResponseStore`; an **await worker** that + polls `conductor_awaits_due_idx` on the `ScheduleWorker` tick: send reminder when + `now ≥ last_reminder_at + reminder_interval_ms`; fire fallback transition when + `now ≥ deadline_at`, closing the await `timed_out` (FR-015..FR-019). +- **Reuse**: `proactiveSender` for notification (FR-016); `ScheduleWorker` tick for timing. +- **Net-new**: the durable await itself (greenfield vs `ask_user_choice`); atomic + `waiting → {resolved,timed_out}` resolution (FR-018, §7-G); the response-ingestion path + (how a human's channel reply / UI click resolves a specific await — correlation id). +- **Critical dependency**: notification needs a **user→channel conversationRef** mapping that + does not exist today (§7-C). This is a blocking sub-task, not a detail. +- **Test (SC-003)**: clock-driven reminder + deadline-fallback for both `quorum: any` and + `all`; late response after resolution rejected and logged (no double-advance). +- **Risk**: high. Two net-new substrates (await + conversationRef store) + atomic resolution. + +### Phase 5 — Principals & Role Resolver (US6, P1) 🆕 + ✅ seam + +- **Build**: `conductor_roles` + `conductor_role_assignments` stores; **`RoleResolver` + registry** via `serviceRegistry.provide('roleResolver', …)` (same seam as canvasOutputRegistry); + a **default resolver** reading `conductor_role_assignments`; baton-move API + (close one assignment, open another) firing `role.assignment.changed` / `await.reassigned` + (FR-021..FR-025). +- **Late binding** (FR-022): resolve at dispatch + on each reminder. **Access at access time** + (FR-023): await read/answer authorized against the role's *current* holders — not frozen. +- **No-holder** (FR-024): unmet postcondition → fallback (reuses the harness, no special-case). +- **Test (SC-006/007)**: baton A→B transfers reminder target + await access; no-holder → fallback. +- **Risk**: medium. The resolver seam is clean; the access-at-access-time authorization on the + await read/answer routes is the subtle part (must re-resolve on every read, §7-C). + +### Phase 6 — Conductor Designer (US7, P2) 🔶 fuse two builders + +- **Build**: `web-ui/app/admin/conductor/` mirroring `app/admin/builder/`: a React-Flow canvas + (`@xyflow/react`), `useConductorGraph` (clone `useAgentGraph.mutate` optimistic-rollback), + `conductorBuilder.ts` REST client (dual-path cookie-forward, clone `_lib/agentBuilder.ts`), + node/edge/inspector components for step/transition/trigger; a **conductor builder agent** + (clone `builderAgent` + a new patch toolset that mutates the conductor draft graph + a new + system prompt). Versioned save (draft → version snapshot) per FR-027. +- **Reuse**: optimistic-mutation + REST ✅; canvas shell 🔶; builder-agent architecture 🔶. +- **Net-new**: conductor graph topology in the canvas (single-agent `AgentGraph`/`graphToFlow` + does not fit — §7-L); the conductor draft spec schema + patch/lint tools. +- **Designer sources triggers from the live event catalog** (FR-028) with payload-field + autocomplete from the declared schema. +- **Test (SC-001/008)**: build agentic+human workflow no-code; edit+resave → new version while + in-flight run on prior version unaffected; invalid graph blocks save naming the check. +- **Risk**: medium. Mostly extension, but "visual + conversational" fuses two today-separate + subsystems (admin/builder canvas vs store/builder chat). + +### Phase 7 — Dry-Run / Preview (US8, P2) 🆕 hardest-missing + +- **Build**: a **multi-agent preview executor** — runs the engine path with preview-scoped + tools, operator answers human steps inline (no real notification / durable await), connector + actions flagged irreversible are stubbed (FR-029). +- **Net-new**: `previewRuntime` is strictly one-ZIP→one-agent with no routing/hand-off; this + needs either orchestrating multiple preview handles behind the engine, or a purpose-built + preview executor that shares the Phase-2 run executor with an injected "preview I/O adapter". +- **Recommendation**: build the Phase-2 run executor with a pluggable **StepEffects interface** + (notify / await / call-action / run-agent-turn) so preview is just an alternate StepEffects + impl — avoids a parallel executor. +- **Risk**: medium-high. Genuinely net-new; de-risked if Phase 2's executor is built with the + StepEffects seam from the start (do this — it is cheap up front, expensive to retrofit). + +### Phase 8 — Run Audit & Observability (US9, P3) ✅ on existing trace + +- **Build**: surface `conductor_run_steps` (already written each step in Phase 2) through + omadia's existing per-run trace / call-stack viewer; record trigger, ordered steps, actor, + postcondition outcome, transitions (incl. fallback), reminders, baton resolutions, event + origin (redaction-respecting) — FR-030. +- **Reuse**: the existing viewer stack (`RunTrace`/`RunTraceCollector` → + `routine_runs.run_trace JSONB` → `GET /:id/runs/:runId` → `web-ui/app/routines/_components/RunTraceViewer.tsx`) + + its redaction. **Caveat (VERIFIED)**: `RunTraceViewer` is **shape-aware** (typed to + `{iterations, orchestratorToolCalls, agentInvocations}`), not a generic JSON tree, and `RunTrace` + is orchestrator-tool-call-shaped — it does not fit the `conductor_run_steps` ordered-step model + 1:1. **Decision**: add a Conductor-specific branch/variant of `RunTraceViewer` driven by + `conductor_run_steps` (trigger · ordered steps · actor · postcondition outcome · transition · + reminders · baton resolutions) rather than forcing steps into the tool-call schema. Surfaced via + new `GET /api/v1/operator/conductors/:slug/runs(/:runId)` routes mirroring the routines routes. +- **Test (SC-010)**: completed run trace contains all required elements ordered. +- **Risk**: low-medium. The data is already persisted by Phase 2/4/5; the only real work is the + Conductor-shaped viewer branch (the generic viewer cannot be reused verbatim). + +--- + +## 5. Net-New Substrate (no precedent — budget accordingly) + +These five are the real engineering, ranked by risk: + +1. **Durable await + atomic resolution** (Phase 4) — greenfield; `ask_user_choice` gives nothing. +2. **Multi-agent run executor + resume** (Phase 2) — the engine decides; the executor performs + I/O and survives restart. Build with the **StepEffects seam** so preview (Phase 7) reuses it. +3. **`ctx.events.emit` + event router** (Phase 3) — new contract surface on `PluginContext`. +4. **User→channel conversationRef store** (Phase 4 dependency) — required to notify a `user:` + principal proactively; today the handle lives only on `routines` rows (§7-C). +5. **Conductor graph topology in the canvas + conductor builder toolset** (Phase 6) — the + single-agent `AgentGraph` does not model a multi-step process. + +--- + +## 6. Cross-Cutting Engineering Decisions + +- **Engine purity (FR-032)**: `conductor-core` does zero I/O. Guards/postconditions are a + **serializable predicate AST**, evaluated by the engine over `{ctx, stepResult}`. No `eval`, + no LLM call, no DB. This is what makes SC-009 (determinism) and isolated unit-tests possible. +- **StepEffects seam**: the run executor takes a `StepEffects` interface + (`runAgentTurn`, `runAction`, `dispatchHuman`, `notify`, `emit`). Production wires real + implementations; preview (US8) and tests wire fakes. Decided up front (§4 Phase 7 rationale). +- **graphPool gating**: every store/worker is `if (graphPool)`-guarded; on the in-memory + backend Conductor is inert (no runs, catalog read-only) — matches routines/schedules. +- **Versioning (FR-027)**: runs bind `workflow_version_id` (immutable); drafts are mutable; + publish snapshots draft→version. The engine validates a version before publish. +- **Multi-replica**: out of scope per spec (single-process scheduler reused), but the + in-memory dedup in `ScheduleWorker` means **do not run two replicas of the await/cron worker** + without a DB claim. Document the single-worker constraint loudly (§7-A). + +--- + +## 7. Landmines, Risks & Open Questions (deep-search output) + +Every divergence the grounding found between the spec's assumptions and the live code. + +**A. Two schedulers — the biggest conflation.** `ScheduleWorker` + `agent_schedules` +(`middleware/src/scheduler/scheduleWorker.ts`, `migrations/0003`) is DB-durable (rows survive +restart) but its per-minute dedup + in-flight set are **in-memory** → not multi-replica safe, +and it is **UTC-only** (`cron.ts`). The *other* scheduler — `JobScheduler` +(`middleware/src/plugins/jobScheduler.ts`, "does not persist anything across process restarts") ++ `RoutineRunner` — is **not** durable. **Decision (RESOLVED #2/#3)**: build on `ScheduleWorker` +(durable rows); add a sibling `conductor_schedules` table + a **due-row claim via +`FOR UPDATE SKIP LOCKED`** for both cron and awaits; do not reuse `JobScheduler`. Reminder/ +deadline timing inherits minute granularity (acceptable per spec Assumptions). The DB claim +**supersedes** the in-memory dedup, making the worker multi-replica-safe from day one. + +**B. `ctx.events.emit` does not exist.** No `events`/`bus` accessor on `PluginContext` today +(`bus` is a reserved-but-unwired `ServiceName`). **Decision (RESOLVED)**: (1) add `EventsAccessor` ++ `readonly events?` to `plugin-api/src/pluginContext.ts`; (2) add an `events_emit` field to +`PluginPermissionsSummary` (`admin-v1.ts`) + loader parse, gating it `subAgents`/`llm`-style +(empty permission → accessor `undefined`); (3) provision the accessor in `createPluginContext` +(`middleware/src/platform/pluginContext.ts`) wired to a new **`middleware/src/conductor/eventRouter.ts`**. +The router validates `payload` against the `EventCatalogRegistry` schema for the installed +`schema_version`, rejects+logs non-conforming emits, stamps provenance, and calls `startRun` for +every subscribed workflow whose filter matches. The router is the single consumer the +`ctx.events.emit` impl delegates to — keeping the plugin-api surface thin. + +**C. No user→channel conversationRef mapping.** `users` (`auth/userStore.ts`) is auth-only; +the proactive conversationRef lives only on `routines.conversation_ref`, and the proactive +sender registry is **in-memory, Teams-only** (Telegram declared-not-implemented). Resolving a +`user:` or a role-resolved holder to "which channel + ref to notify" has **no join today**. +**Decision (RESOLVED #1)**: net-new durable `conductor_channel_bindings (user_id, channel_type, +conversation_ref JSONB)` store, PK `(user_id, channel_type)`, decoupled from `routines`. Resolved +at dispatch; a binding miss creates the await flagged `unreachable` and fires the workflow's +configurable fallback (default behavior). Provisioning the binding rows reuses existing channel +mechanisms (operational concern per spec Assumptions). MVP ships Teams; Telegram sender is +declared-not-implemented and tracked separately. + +**D. Postconditions are Zod-output-conformance only.** Today a postcondition = an optional +`output?: z.ZodType` on a bridged tool, checked per tool-call in `bridgeTool` +(`dynamicAgentRuntime.ts` L743-796 → `[POSTCONDITION_FAILED]` → verifier `tool_postcondition` +claim). There is **no general predicate/assertion language**. Conductor's *step exit +postcondition* is a richer concept (assert over run context, not just one tool's output shape). +**Decision**: define the postcondition AST in `conductor-core` (§6); the per-tool Zod check +remains a *separate*, lower layer used inside agent steps. + +**E. LISTEN/NOTIFY is disabled by default.** `pg_notify` machinery is real +(`migrations/0001-0002`, `reloadBus.ts`) but `ReloadBus.enableListen=false` by default because +LISTEN pins one connection and the KG pool is `max:5` (deadlock risk on boot). **Decision**: +make the **60s reconcile poll the authoritative resume path**; treat LISTEN as an optional +latency optimization to be enabled only after the connection-budget is addressed (dedicated +`DATABASE_URL` connection or raised pool max). Do not design the await-resume happy path to +*require* live NOTIFY. + +**F. graphPool only exists with Neon.** `serviceRegistry.get('graphPool')` is `undefined` +on the in-memory KG backend (`DATABASE_URL` unset). All Conductor persistence/workers must +degrade-skip. **Risk if ignored**: boot crash on dev/in-memory setups. + +**G. At-most-once step execution + atomic await resolution.** The hardest correctness problem. +A `waiting` run resumed by both a NOTIFY and the reconcile poll, or a deadline firing while a +response is in flight, must not double-advance. **Decision**: resolve `conductor_awaits` +`waiting → {resolved,timed_out}` with a single conditional `UPDATE ... WHERE status='waiting' +RETURNING` (the row update is the lock; the `notify_await_resolved` trigger only fires on the +真 transition `OLD.status='waiting'`). Step execution claims the run via an optimistic +`current_step_id` + `status` CAS before performing I/O. + +**H. `VerifierService` is kernel-side, not in `@omadia/verifier`.** The package exposes +`VerifierPipeline.verify`, but the binding that actually drives postcondition→retry +(`verifierService.ts`, consumes ~7 kernel-internal symbols) is deliberately kernel-side. +Conductor agent-steps that want the retry behavior must depend on the **kernel** binding, not +just the package. + +**I. No generic webhook ingress.** Webhooks today are channel-transport-specific +(Teams `/api/messages`, Telegram). **Decision (RESOLVED)**: add a new generic route +`POST /api/v1/conductor/webhooks/:workflowSlug` (mounted in `index.ts`, **outside** `requireAuth` +since callers are external), authenticated by a per-trigger shared secret / HMAC header (reusing +the channel SDK's `verify_signature` convention). The validated body becomes the run's initial +context via `startRun`. Net-new, small. (Distinct from the `event` trigger, which is internal +`ctx.events.emit`; the webhook trigger is for systems that cannot host a connector plugin.) + +**J. `ctx.subAgent.ask` is stateless and uncycled.** One `ask()` = one full sub-agent run, +fresh messages array, returns only a final string, no cross-call session, **no indirect-cycle +detection** (A→B→A) beyond `maxIterations`. A multi-step process **must not** thread state +through `ctx.subAgent.ask`; the **run context** (persisted `conductor_runs.context`) is the +state carrier between steps, and the executor (not the sub-agent seam) owns ordering. Conductor +must add its own per-run cycle/budget accounting if agent steps can re-enter. + +**K. Event-catalog resolve hook is dynamic-agents-only. (VERIFIED — confirmed fork.)** There are +**two parallel activation runtimes**, both driven from `index.ts`: `DynamicAgentRuntime` +(`dynamicAgentRuntime.ts`, dynamic/uploaded agents — **has** the `canvasOutputRegistry.register` +resolve hook at ~L520-545) and `ToolPluginRuntime` (`middleware/src/plugins/toolPluginRuntime.ts` +`activate()` L208-300, built-in/static tool/extension/integration packages — **NO** manifest- +capability resolve step; built-ins register tools directly into `nativeToolRegistry` from their own +`activate(ctx)`). A built-in connector declaring `emits:` is resolved by **nothing** today. +**Decision (RESOLVED)**: Conductor's `EventCatalogRegistry` resolve call must be added on **both** +paths — clone the dynamic-runtime block into `ToolPluginRuntime.activate()` (~L293, after +`this.active.set(...)`) and the symmetric `unregister` into its deactivate. Same applies to the +new `irreversible` resolve (§7-P). This is the single most overlooked wiring task. + +**P. `irreversible` action flag is net-new. (VERIFIED.)** US8/FR-029 needs preview to stub +"connector actions flagged irreversible", but **no `irreversible`/`destructive`/side-effecting +capability flag exists** — the manifest capability schema (`admin-v1.ts` L278-287) has only +`provides`/`requires` strings plus exactly two per-capability booleans (`canvas_output`, +`deterministic_action`). **Decision (RESOLVED)**: add an `irreversible: true` per-capability boolean +following the `canvas_output` precedent — a new `irreversibleActionToolIds(manifest)` helper (clone +of `canvasOutputToolIds`, `canvasOutputRegistry.ts` L59) + an `IrreversibleActionRegistry`, resolved +on **both** activation paths (§7-K). The preview StepEffects (§6) consults it to stub the action. + +**L. The single-agent canvas does not model a process.** `web-ui/app/admin/builder` + +`graphMapping.graphToFlow` are hard-coded to one `agent` node + its sub-agents/skills/tools +(`AgentGraph`). A conductor graph (peer steps, guarded transitions, triggers) needs a new +node-kind union, edge-semantics table, and persistence routes — a parallel canvas +implementation, not a config of the existing one. + +**M. `deterministic_action` fast-path is canvas-UI-shaped.** The LLM-free dispatch +(`omadia-ui-orchestrator/src/plugin.ts` L442-480) requires a structured *canvas action* whose +`type` names an allow-set tool + a canvas-output sentinel. It is **not** a general "skip the LLM +for this step." A conductor `action` step that calls a connector action will invoke the bridged +tool handler directly (via `dynamicAgentRuntime.invokeAgentTool`, L644-652) — the right seam — +but should not be confused with the canvas fast-path. + +**N. `buildOrchestrator` is test-only.** Use `buildOrchestratorForAgent` +(`buildOrchestrator.ts` L155); it owns a large `OrchestratorDeps` surface and the post-activate +`attachOrchestrator` handshake. Agent steps reuse the registry's already-built bundles rather +than constructing orchestrators ad hoc. + +**O. Operator access is session-only (no RBAC role).** `requireAuth` = authenticated admin +session; there is no `role==='operator'` check and no Next-layer guard. Conductor routes must be +explicitly mounted behind `requireAuth` under `/api/v1/operator/conductors/*`; per-row ownership +(if any) is handler-enforced via `req.session`. + +### Resolved decisions (owner sign-off 2026-06-17) + +1. **conversationRef provisioning (§7-C)** → **new durable `conductor_channel_bindings` table** + `(user_id, channel_type, conversation_ref JSONB)`, PK `(user_id, channel_type)`. Resolved at + dispatch; a miss creates the await flagged `unreachable` and fires the workflow's configurable + fallback transition (default). Decoupled from `routines`. (Phase 4 net-new sub-task.) +2. **Cron triggers** → **sibling `conductor_schedules` table** `(id, workflow_id FK, cron, + timezone, status, last_run_at)`, polled by the same `ScheduleWorker.tick()`. No FK coupling to + `agents`. (Phase 3.) +3. **Multi-replica posture** → **DB claim from day one**: due-row selection uses + `FOR UPDATE SKIP LOCKED` + a `claimed_by`/`claimed_at` column on `conductor_awaits` (and the + cron poll). Removes the in-memory-dedup footgun; horizontal scale-out becomes free. (Phases 2/4.) +4. **Guard/postcondition language** → **serializable predicate AST** over `{ctx, stepResult}` + (`eq|and|or|not|exists|gt|lt|in|matches`), JSON-schema-validated, no `eval`. Keeps the engine + pure (SC-009) and makes Designer field-autocomplete trivial from the payload schema. (Phase 1.) + +--- + +## 8. Test Strategy (mapped to Success Criteria) + +| SC | Test | Where | +|---|---|---| +| SC-009 | Determinism property/fixture test, no I/O | `conductor-core` vitest (Phase 1) | +| SC-001 | Build+save+run agentic+human workflow no-code | e2e (Phase 6) | +| SC-002 | Restart mid-wait → resume, no re-exec/skip | integration restart test (Phase 2) | +| SC-003 | Clock-driven reminder + deadline fallback, both quorum modes | await worker test (Phase 4) | +| SC-004 | Fixture connector `emits:` → catalog → selectable trigger | event-catalog test (Phase 3) | +| SC-005 | Schema-violating emit → no run + logged | event-router test (Phase 3) | +| SC-006 | Baton A→B → reminder target + await access transfer | role-resolver test (Phase 5) | +| SC-007 | No-holder role → fallback, no hang | role-resolver test (Phase 5) | +| SC-008 | Edit+resave → new version, in-flight run unchanged | versioning test (Phase 6) | +| SC-010 | Completed run trace completeness | audit test (Phase 8) | + +Engine tests are pure/fixture-driven (mirror `canvas-core/test`). Kernel tests use the +`StepEffects` fakes + a test `graphPool` (or skip-on-no-pool, matching routines tests). Clock is +injected (`now?` dep already present on `ScheduleWorker`) for deterministic reminder/deadline +tests. + +--- + +## 9. Migration & Rollout + +- **DB**: `0001_conductor.sql` via `runConductorMigrations` (per-subsystem migrator, gated on + `graphPool`). Forward-only, idempotent DDL. +- **Data-model deltas beyond `data-model.md`** (introduced by the resolved decisions; `data-model.md` + is iret77's spec artifact and is left untouched — these land in the migration + a follow-up + data-model update on the PR branch): + - **`conductor_channel_bindings`** `(user_id UUID, channel_type TEXT, conversation_ref JSONB, + PRIMARY KEY (user_id, channel_type))` — RESOLVED #1. + - **`conductor_schedules`** `(id, workflow_id FK, cron TEXT, timezone TEXT, status, last_run_at)` — + RESOLVED #2. + - **`claimed_by UUID`, `claimed_at TIMESTAMPTZ`** columns on `conductor_awaits` (and + `conductor_schedules`) for the `FOR UPDATE SKIP LOCKED` claim — RESOLVED #3. + - **`unreachable` await flag** — a status/flag on `conductor_awaits` for the "principal + unreachable on channel" edge case (RESOLVED #1). + - **Manifest**: per-capability **`irreversible: true`** boolean (§7-P), alongside the + `emits:` block + `permissions.events.emit` already in `data-model.md`. +- **Manifest**: `emits:` + `events_emit` permission are **additive** — existing manifests without + them are unaffected (absence of `emits:` is meaningful, surfaced in the Designer per FR-014). +- **Feature gating**: Conductor inert without `graphPool`; Designer routes 503 when the conductor + service is absent (mirror `operatorAgents` 503-on-missing-registry). +- **Backward compatibility**: no change to existing orchestrator/agent-builder behavior; Conductor + is an additive process layer. `ask_user_choice` is untouched (Conductor's await is a separate + substrate, not a replacement migration). + +--- + +## 10. Phase → Story → Risk Summary + +| Phase | Story (Priority) | Risk | Net-new? | +|---|---|---|---| +| 0 Foundations | — | low | scaffold | +| 1 Engine core | US1 (P1) | low | engine + AST | +| 2 Run lifecycle | US2 (P1) | **high** | executor + resume | +| 3 Triggers + events | US3+US4 (P1) | medium | event surface | +| 4 Human awaits | US5 (P1) | **high** | await + conversationRef | +| 5 Roles | US6 (P1) | medium | resolver seam | +| 6 Designer | US7 (P2) | medium | conductor canvas/toolset | +| 7 Preview | US8 (P2) | medium-high | multi-agent preview | +| 8 Audit | US9 (P3) | low | viewer layer | + +**MVP cut** (delivers SC-001..SC-003, SC-009 — the headline): Phases 0–5 via API/config, before +the Designer. This matches the spec's "usable via API once US1–US6 land; Designer is the +ergonomics layer" framing. From 8a866cdca09197833fee2cff3777a7158a811093 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 08:04:30 +0200 Subject: [PATCH 03/19] feat(conductor): implement @omadia/conductor-core pure engine (US1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0+1 of the Spec 005 integration plan: the deterministic, I/O-free workflow engine — sibling of @omadia/canvas-core. - Predicate AST (serializable guards + exit postconditions; no eval) with a total, deterministic evaluator over {ctx, stepResult}. - validate(graph, knownRefs?): shape gate (ajv 2020-12) + reachability, unguarded-cycle detection, deadline-without-fallback, duplicate ids, fallback-origin, and optional agent/action/role/event reference checks — each error names the offending node (FR-003). - nextStep(): postcondition verdict -> matching guarded transition -> declared fallback -> complete/stuck; identical inputs yield identical decisions (FR-001/002/006, SC-009). - Published JSON schema (schema/) kept in parity with the runtime schema by test. - 46 vitest cases (predicate, engine US1 acceptance, validation, fixtures); builds clean (tsc) and typechecks. Wired into the workspace build order. ajv-only runtime dep; engine has no DB/network/LLM I/O (FR-032). --- middleware/package.json | 6 +- middleware/packages/conductor-core/.gitignore | 2 + .../invalid-deadline-no-fallback.json | 20 ++ .../fixtures/invalid-unguarded-cycle.json | 12 + .../fixtures/invalid-unreachable.json | 12 + .../fixtures/valid-release-signoff.json | 40 +++ .../packages/conductor-core/package.json | 35 +++ .../schema/conductor-graph.schema.json | 93 +++++++ .../packages/conductor-core/src/engine.ts | 70 +++++ .../packages/conductor-core/src/index.ts | 8 + .../packages/conductor-core/src/predicate.ts | 101 +++++++ .../packages/conductor-core/src/schema.ts | 117 ++++++++ .../packages/conductor-core/src/types.ts | 249 ++++++++++++++++++ .../packages/conductor-core/src/validate.ts | 201 ++++++++++++++ .../conductor-core/test/engine.test.ts | 88 +++++++ .../conductor-core/test/fixtures.test.ts | 78 ++++++ .../conductor-core/test/predicate.test.ts | 64 +++++ .../conductor-core/test/validate.test.ts | 118 +++++++++ .../conductor-core/tsconfig.build.json | 14 + .../packages/conductor-core/tsconfig.json | 16 ++ 20 files changed, 1341 insertions(+), 3 deletions(-) create mode 100644 middleware/packages/conductor-core/.gitignore create mode 100644 middleware/packages/conductor-core/fixtures/invalid-deadline-no-fallback.json create mode 100644 middleware/packages/conductor-core/fixtures/invalid-unguarded-cycle.json create mode 100644 middleware/packages/conductor-core/fixtures/invalid-unreachable.json create mode 100644 middleware/packages/conductor-core/fixtures/valid-release-signoff.json create mode 100644 middleware/packages/conductor-core/package.json create mode 100644 middleware/packages/conductor-core/schema/conductor-graph.schema.json create mode 100644 middleware/packages/conductor-core/src/engine.ts create mode 100644 middleware/packages/conductor-core/src/index.ts create mode 100644 middleware/packages/conductor-core/src/predicate.ts create mode 100644 middleware/packages/conductor-core/src/schema.ts create mode 100644 middleware/packages/conductor-core/src/types.ts create mode 100644 middleware/packages/conductor-core/src/validate.ts create mode 100644 middleware/packages/conductor-core/test/engine.test.ts create mode 100644 middleware/packages/conductor-core/test/fixtures.test.ts create mode 100644 middleware/packages/conductor-core/test/predicate.test.ts create mode 100644 middleware/packages/conductor-core/test/validate.test.ts create mode 100644 middleware/packages/conductor-core/tsconfig.build.json create mode 100644 middleware/packages/conductor-core/tsconfig.json diff --git a/middleware/package.json b/middleware/package.json index d96e970d..0dc63f35 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -9,14 +9,14 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", + "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", "start": "node dist/index.js", - "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", + "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", "dev:clean": "node scripts/dev-clean.mjs && npm run dev", "ensure-native-abi": "node scripts/ensure-native-abi.mjs", "lint": "eslint src/ packages/plugin-api/src/ packages/llm-provider/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/", "lint:fix": "eslint src/ packages/plugin-api/src/ packages/llm-provider/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/ --fix", - "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit", + "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/conductor-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "smoke:entity-refs": "tsx scripts/smoke-entity-refs.ts", diff --git a/middleware/packages/conductor-core/.gitignore b/middleware/packages/conductor-core/.gitignore new file mode 100644 index 00000000..15813be9 --- /dev/null +++ b/middleware/packages/conductor-core/.gitignore @@ -0,0 +1,2 @@ +package-lock.json +node_modules/ diff --git a/middleware/packages/conductor-core/fixtures/invalid-deadline-no-fallback.json b/middleware/packages/conductor-core/fixtures/invalid-deadline-no-fallback.json new file mode 100644 index 00000000..dd25eead --- /dev/null +++ b/middleware/packages/conductor-core/fixtures/invalid-deadline-no-fallback.json @@ -0,0 +1,20 @@ +{ + "entryStepId": "s1", + "steps": [ + { + "id": "s1", + "kind": "human", + "human": { + "principal": { "kind": "user", "ref": "11111111-1111-1111-1111-111111111111" }, + "channel": "teams", + "message": "Approve?", + "deadline": "PT24H" + } + }, + { "id": "s2", "kind": "action", "actionId": "act.done" } + ], + "transitions": [ + { "id": "t1", "source": "s1", "target": "s2", "guard": { "op": "eq", "path": "stepResult.approved", "value": true } } + ], + "triggers": [{ "id": "tr1", "kind": "manual" }] +} diff --git a/middleware/packages/conductor-core/fixtures/invalid-unguarded-cycle.json b/middleware/packages/conductor-core/fixtures/invalid-unguarded-cycle.json new file mode 100644 index 00000000..9559df44 --- /dev/null +++ b/middleware/packages/conductor-core/fixtures/invalid-unguarded-cycle.json @@ -0,0 +1,12 @@ +{ + "entryStepId": "s1", + "steps": [ + { "id": "s1", "kind": "agent", "agentId": "a1" }, + { "id": "s2", "kind": "agent", "agentId": "a2" } + ], + "transitions": [ + { "id": "tA", "source": "s1", "target": "s2" }, + { "id": "tB", "source": "s2", "target": "s1" } + ], + "triggers": [{ "id": "tr1", "kind": "manual" }] +} diff --git a/middleware/packages/conductor-core/fixtures/invalid-unreachable.json b/middleware/packages/conductor-core/fixtures/invalid-unreachable.json new file mode 100644 index 00000000..bfaf3be2 --- /dev/null +++ b/middleware/packages/conductor-core/fixtures/invalid-unreachable.json @@ -0,0 +1,12 @@ +{ + "entryStepId": "s1", + "steps": [ + { "id": "s1", "kind": "agent", "agentId": "a1" }, + { "id": "s2", "kind": "action", "actionId": "act.done" }, + { "id": "s_orphan", "kind": "action", "actionId": "act.orphan" } + ], + "transitions": [ + { "id": "t1", "source": "s1", "target": "s2" } + ], + "triggers": [{ "id": "tr1", "kind": "manual" }] +} diff --git a/middleware/packages/conductor-core/fixtures/valid-release-signoff.json b/middleware/packages/conductor-core/fixtures/valid-release-signoff.json new file mode 100644 index 00000000..75d898c2 --- /dev/null +++ b/middleware/packages/conductor-core/fixtures/valid-release-signoff.json @@ -0,0 +1,40 @@ +{ + "entryStepId": "s1", + "steps": [ + { + "id": "s1", + "kind": "agent", + "agentId": "release-notes", + "postcondition": { "op": "exists", "path": "stepResult.notes" }, + "fallbackTransitionId": "t_fail", + "position": { "x": 40, "y": 40 } + }, + { + "id": "s2", + "kind": "human", + "human": { + "principal": { "kind": "role", "ref": "approver.release" }, + "channel": "teams", + "message": "Release {{ctx.tag}} ready — approve?", + "reminderInterval": "PT6H", + "deadline": "PT24H", + "quorum": "any" + }, + "fallbackTransitionId": "t_deadline", + "position": { "x": 240, "y": 40 } + }, + { "id": "s3", "kind": "action", "actionId": "github.create_release", "position": { "x": 440, "y": 40 } }, + { "id": "s_end_fail", "kind": "action", "actionId": "notify.failure", "position": { "x": 240, "y": 200 } }, + { "id": "s_autoreject", "kind": "action", "actionId": "release.cancel", "position": { "x": 440, "y": 200 } } + ], + "transitions": [ + { "id": "t1", "source": "s1", "target": "s2", "guard": { "op": "exists", "path": "stepResult.notes" } }, + { "id": "t_fail", "source": "s1", "target": "s_end_fail" }, + { "id": "t_approve", "source": "s2", "target": "s3", "guard": { "op": "eq", "path": "stepResult.approved", "value": true } }, + { "id": "t_deadline", "source": "s2", "target": "s_autoreject" } + ], + "triggers": [ + { "id": "tr1", "kind": "event", "eventId": "github.pull_request.merged", "filter": { "op": "eq", "path": "ctx.base", "value": "main" } }, + { "id": "tr2", "kind": "manual" } + ] +} diff --git a/middleware/packages/conductor-core/package.json b/middleware/packages/conductor-core/package.json new file mode 100644 index 00000000..75db712b --- /dev/null +++ b/middleware/packages/conductor-core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@omadia/conductor-core", + "version": "0.1.0", + "private": false, + "description": "Pure, I/O-free Omadia Conductor engine: workflow graph validation and deterministic step advancement (predicate-AST guards + exit postconditions). Sibling of @omadia/canvas-core.", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./schema/*": "./schema/*", + "./fixtures/*": "./fixtures/*" + }, + "files": [ + "dist", + "schema", + "fixtures" + ], + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "ajv": "^8.20.0" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^5.9.0", + "vitest": "^4.1.8" + } +} diff --git a/middleware/packages/conductor-core/schema/conductor-graph.schema.json b/middleware/packages/conductor-core/schema/conductor-graph.schema.json new file mode 100644 index 00000000..d6e005df --- /dev/null +++ b/middleware/packages/conductor-core/schema/conductor-graph.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/schema/conductor-graph.schema.json", + "title": "Conductor Workflow Graph", + "type": "object", + "required": ["entryStepId", "steps", "transitions"], + "additionalProperties": false, + "properties": { + "entryStepId": { "type": "string", "minLength": 1 }, + "steps": { "type": "array", "items": { "$ref": "#/$defs/step" } }, + "transitions": { "type": "array", "items": { "$ref": "#/$defs/transition" } }, + "triggers": { "type": "array", "items": { "$ref": "#/$defs/trigger" } } + }, + "$defs": { + "step": { + "type": "object", + "required": ["id", "kind"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["agent", "action", "human"] }, + "agentId": { "type": "string" }, + "actionId": { "type": "string" }, + "human": { "$ref": "#/$defs/human" }, + "postcondition": { "$ref": "#/$defs/predicate" }, + "fallbackTransitionId": { "type": "string" }, + "position": { + "type": "object", + "properties": { "x": { "type": "number" }, "y": { "type": "number" } } + } + } + }, + "human": { + "type": "object", + "required": ["principal", "channel", "message"], + "additionalProperties": false, + "properties": { + "principal": { + "type": "object", + "required": ["kind", "ref"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["user", "role"] }, + "ref": { "type": "string", "minLength": 1 } + } + }, + "channel": { "type": "string", "minLength": 1 }, + "message": { "type": "string" }, + "reminderInterval": { "type": ["string", "null"] }, + "deadline": { "type": ["string", "null"] }, + "quorum": { "enum": ["any", "all"] }, + "responseSchema": { "type": "object" } + } + }, + "transition": { + "type": "object", + "required": ["id", "source", "target"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "guard": { "$ref": "#/$defs/predicate" } + } + }, + "trigger": { + "type": "object", + "required": ["id", "kind"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["manual", "cron", "channel", "agent", "webhook", "workflow", "event"] }, + "eventId": { "type": "string" }, + "filter": { "$ref": "#/$defs/predicate" }, + "cron": { "type": "string" } + } + }, + "predicate": { + "type": "object", + "required": ["op"], + "additionalProperties": false, + "properties": { + "op": { + "enum": ["eq", "ne", "gt", "lt", "gte", "lte", "exists", "in", "matches", "and", "or", "not", "always", "never"] + }, + "path": { "type": "string" }, + "value": true, + "args": { "type": "array", "items": { "$ref": "#/$defs/predicate" } }, + "arg": { "$ref": "#/$defs/predicate" } + } + } + } +} diff --git a/middleware/packages/conductor-core/src/engine.ts b/middleware/packages/conductor-core/src/engine.ts new file mode 100644 index 00000000..6ad16c6a --- /dev/null +++ b/middleware/packages/conductor-core/src/engine.ts @@ -0,0 +1,70 @@ +// Deterministic step advancement (FR-001, FR-002, FR-006). Pure; no I/O. + +import type { Decision, JsonObject, JsonValue, PostconditionOutcome, WorkflowGraph } from './types.js'; +import { evaluatePredicate } from './predicate.js'; + +/** + * Given a completed step's result and the run context, deterministically decide the next move: + * 1. Evaluate the step's exit postcondition. + * - If unmet → fire the step's declared fallback transition (or `stuck` if none). + * 2. If met (or absent) → evaluate the guards of the outgoing happy-path transitions + * (every outgoing transition except the fallback). + * - Exactly one matches → advance via it. + * - More than one match → `stuck` (ambiguous_guards) — a deterministic, surfaced error. + * - None match → fire the fallback, else `complete` if terminal, else `stuck`. + * + * Identical (graph, currentStepId, stepResult, ctx) always yields an identical Decision. + */ +export function nextStep( + graph: WorkflowGraph, + currentStepId: string, + stepResult: JsonValue, + ctx: JsonObject, +): Decision { + const step = graph.steps.find((s) => s.id === currentStepId); + if (!step) { + return { kind: 'stuck', code: 'unknown_step', message: `no step with id '${currentStepId}'`, nodeIds: [currentStepId], postcondition: 'n/a' }; + } + + const scope = { ctx, stepResult }; + const hasPost = step.postcondition !== undefined; + const postMet = hasPost ? evaluatePredicate(step.postcondition!, scope) : true; + const postOutcome: PostconditionOutcome = hasPost ? (postMet ? 'met' : 'unmet') : 'n/a'; + + const outgoing = graph.transitions.filter((t) => t.source === currentStepId); + const fallbackId = step.fallbackTransitionId; + const fallback = fallbackId !== undefined ? graph.transitions.find((t) => t.id === fallbackId) : undefined; + + if (fallbackId !== undefined && !fallback) { + return { kind: 'stuck', code: 'fallback_transition_missing', message: `step '${currentStepId}' fallbackTransitionId '${fallbackId}' not found`, nodeIds: [currentStepId, fallbackId], postcondition: postOutcome }; + } + + // 1. Unmet postcondition → fallback (never a happy-path transition). + if (hasPost && !postMet) { + if (fallback) { + return { kind: 'advance', transitionId: fallback.id, targetStepId: fallback.target, reason: 'postcondition_unmet_fallback', postcondition: 'unmet' }; + } + return { kind: 'stuck', code: 'postcondition_unmet_no_fallback', message: `step '${currentStepId}' postcondition unmet and no fallback transition declared`, nodeIds: [currentStepId], postcondition: 'unmet' }; + } + + // 2. Postcondition met (or absent) → evaluate happy-path guards. + const happy = outgoing.filter((t) => t.id !== fallbackId); + const matched = happy.filter((t) => (t.guard === undefined ? true : evaluatePredicate(t.guard, scope))); + + if (matched.length === 1) { + const t = matched[0]!; + return { kind: 'advance', transitionId: t.id, targetStepId: t.target, reason: 'guard_matched', postcondition: postOutcome }; + } + if (matched.length > 1) { + return { kind: 'stuck', code: 'ambiguous_guards', message: `step '${currentStepId}' has multiple matching transitions: ${matched.map((t) => t.id).join(', ')}`, nodeIds: matched.map((t) => t.id), postcondition: postOutcome }; + } + + // 3. No happy-path matched. + if (fallback) { + return { kind: 'advance', transitionId: fallback.id, targetStepId: fallback.target, reason: 'no_transition_matched_fallback', postcondition: postOutcome }; + } + if (outgoing.length === 0) { + return { kind: 'complete', postcondition: postOutcome }; + } + return { kind: 'stuck', code: 'no_transition_no_fallback', message: `step '${currentStepId}' has outgoing transitions but none matched and no fallback declared`, nodeIds: [currentStepId, ...outgoing.map((t) => t.id)], postcondition: postOutcome }; +} diff --git a/middleware/packages/conductor-core/src/index.ts b/middleware/packages/conductor-core/src/index.ts new file mode 100644 index 00000000..9c134671 --- /dev/null +++ b/middleware/packages/conductor-core/src/index.ts @@ -0,0 +1,8 @@ +// @omadia/conductor-core — pure, I/O-free Conductor engine (US1). +// Sibling of @omadia/canvas-core: deterministic workflow-graph validation + advancement. + +export * from './types.js'; +export { evaluatePredicate, resolvePath } from './predicate.js'; +export { conductorGraphSchema, validateGraphShape, type ShapeResult } from './schema.js'; +export { validate } from './validate.js'; +export { nextStep } from './engine.js'; diff --git a/middleware/packages/conductor-core/src/predicate.ts b/middleware/packages/conductor-core/src/predicate.ts new file mode 100644 index 00000000..a550a50e --- /dev/null +++ b/middleware/packages/conductor-core/src/predicate.ts @@ -0,0 +1,101 @@ +// Pure, deterministic evaluator for the Predicate AST. No I/O, no eval. + +import type { EvalScope, JsonValue, Predicate } from './types.js'; + +/** Resolve a dot-path (e.g. "ctx.base", "stepResult.items.0.id") against the scope. + * Numeric segments index into arrays. Any missing segment yields `undefined`. */ +export function resolvePath(scope: EvalScope, path: string): JsonValue | undefined { + // The scope object {ctx, stepResult} is itself the path root. + let current: JsonValue | undefined = scope as unknown as JsonValue; + if (path.length === 0) return current; + for (const seg of path.split('.')) { + if (current === undefined || current === null) return undefined; + if (Array.isArray(current)) { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.length) return undefined; + current = current[idx]; + } else if (typeof current === 'object') { + current = (current as Record)[seg]; + } else { + return undefined; + } + } + return current; +} + +/** Stable, key-sorted serialization for deterministic deep-equality. */ +function canonical(v: JsonValue): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'; + if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; + const obj = v as Record; + const keys = Object.keys(obj).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(obj[k]!)).join(',') + '}'; +} + +function deepEqual(a: JsonValue | undefined, b: JsonValue): boolean { + if (a === undefined) return false; + return canonical(a) === canonical(b); +} + +function compareOrder(op: 'gt' | 'lt' | 'gte' | 'lte', left: JsonValue | undefined, right: JsonValue): boolean { + if (typeof left === 'number' && typeof right === 'number') { + switch (op) { + case 'gt': return left > right; + case 'lt': return left < right; + case 'gte': return left >= right; + case 'lte': return left <= right; + } + } + if (typeof left === 'string' && typeof right === 'string') { + switch (op) { + case 'gt': return left > right; + case 'lt': return left < right; + case 'gte': return left >= right; + case 'lte': return left <= right; + } + } + return false; +} + +/** Evaluate a predicate against a scope. Total and deterministic: any type mismatch or + * missing path resolves to `false` (never throws). */ +export function evaluatePredicate(pred: Predicate, scope: EvalScope): boolean { + switch (pred.op) { + case 'always': + return true; + case 'never': + return false; + case 'and': + return pred.args.every((p) => evaluatePredicate(p, scope)); + case 'or': + return pred.args.some((p) => evaluatePredicate(p, scope)); + case 'not': + return !evaluatePredicate(pred.arg, scope); + case 'exists': + return resolvePath(scope, pred.path) !== undefined; + case 'eq': + return deepEqual(resolvePath(scope, pred.path), pred.value); + case 'ne': + return !deepEqual(resolvePath(scope, pred.path), pred.value); + case 'gt': + case 'lt': + case 'gte': + case 'lte': + return compareOrder(pred.op, resolvePath(scope, pred.path), pred.value); + case 'in': { + const left = resolvePath(scope, pred.path); + return pred.value.some((v) => deepEqual(left, v)); + } + case 'matches': { + const left = resolvePath(scope, pred.path); + if (typeof left !== 'string') return false; + let re: RegExp; + try { + re = new RegExp(pred.value); + } catch { + return false; + } + return re.test(left); + } + } +} diff --git a/middleware/packages/conductor-core/src/schema.ts b/middleware/packages/conductor-core/src/schema.ts new file mode 100644 index 00000000..37946919 --- /dev/null +++ b/middleware/packages/conductor-core/src/schema.ts @@ -0,0 +1,117 @@ +// Structural (ajv) validation of the workflow graph shape. ajv is the sole runtime +// dependency. `conductorGraphSchema` is the single source of truth; the published +// schema/conductor-graph.schema.json is asserted structurally equal by a test. + +import { Ajv2020 } from 'ajv/dist/2020.js'; + +/** JSON Schema (draft 2020-12) for the workflow graph persisted as + * `conductor_workflow_versions.graph`. */ +export const conductorGraphSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://omadia.ai/schema/conductor-graph.schema.json', + title: 'Conductor Workflow Graph', + type: 'object', + required: ['entryStepId', 'steps', 'transitions'], + additionalProperties: false, + properties: { + entryStepId: { type: 'string', minLength: 1 }, + steps: { type: 'array', items: { $ref: '#/$defs/step' } }, + transitions: { type: 'array', items: { $ref: '#/$defs/transition' } }, + triggers: { type: 'array', items: { $ref: '#/$defs/trigger' } }, + }, + $defs: { + step: { + type: 'object', + required: ['id', 'kind'], + additionalProperties: false, + properties: { + id: { type: 'string', minLength: 1 }, + kind: { enum: ['agent', 'action', 'human'] }, + agentId: { type: 'string' }, + actionId: { type: 'string' }, + human: { $ref: '#/$defs/human' }, + postcondition: { $ref: '#/$defs/predicate' }, + fallbackTransitionId: { type: 'string' }, + position: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' } }, + }, + }, + }, + human: { + type: 'object', + required: ['principal', 'channel', 'message'], + additionalProperties: false, + properties: { + principal: { + type: 'object', + required: ['kind', 'ref'], + additionalProperties: false, + properties: { + kind: { enum: ['user', 'role'] }, + ref: { type: 'string', minLength: 1 }, + }, + }, + channel: { type: 'string', minLength: 1 }, + message: { type: 'string' }, + reminderInterval: { type: ['string', 'null'] }, + deadline: { type: ['string', 'null'] }, + quorum: { enum: ['any', 'all'] }, + responseSchema: { type: 'object' }, + }, + }, + transition: { + type: 'object', + required: ['id', 'source', 'target'], + additionalProperties: false, + properties: { + id: { type: 'string', minLength: 1 }, + source: { type: 'string', minLength: 1 }, + target: { type: 'string', minLength: 1 }, + guard: { $ref: '#/$defs/predicate' }, + }, + }, + trigger: { + type: 'object', + required: ['id', 'kind'], + additionalProperties: false, + properties: { + id: { type: 'string', minLength: 1 }, + kind: { enum: ['manual', 'cron', 'channel', 'agent', 'webhook', 'workflow', 'event'] }, + eventId: { type: 'string' }, + filter: { $ref: '#/$defs/predicate' }, + cron: { type: 'string' }, + }, + }, + predicate: { + type: 'object', + required: ['op'], + additionalProperties: false, + properties: { + op: { + enum: ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'exists', 'in', 'matches', 'and', 'or', 'not', 'always', 'never'], + }, + path: { type: 'string' }, + value: true, + args: { type: 'array', items: { $ref: '#/$defs/predicate' } }, + arg: { $ref: '#/$defs/predicate' }, + }, + }, + }, +} as const; + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const validateFn = ajv.compile(conductorGraphSchema as unknown as object); + +export interface ShapeResult { + ok: boolean; + errors: string[]; +} + +/** Validate the structural shape of an unknown value against the graph schema. */ +export function validateGraphShape(graph: unknown): ShapeResult { + const ok = validateFn(graph) as boolean; + if (ok) return { ok: true, errors: [] }; + const errs = (validateFn.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? 'invalid'}`); + return { ok: false, errors: errs }; +} diff --git a/middleware/packages/conductor-core/src/types.ts b/middleware/packages/conductor-core/src/types.ts new file mode 100644 index 00000000..32fe09da --- /dev/null +++ b/middleware/packages/conductor-core/src/types.ts @@ -0,0 +1,249 @@ +// Pure type definitions for the Conductor engine. No I/O, no runtime dependencies. +// Mirrors the graph shape in specs/005-omadia-conductor/data-model.md. + +/** A JSON-serializable value. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +// --------------------------------------------------------------------------- +// Predicate AST — the serializable guard / exit-postcondition language. +// Evaluated against an EvalScope; never executed as code (no eval). +// --------------------------------------------------------------------------- + +/** Compare a dot-path value against a literal. Ordering ops apply to number/number + * and string/string only; any other pairing is `false`. */ +export interface ComparePredicate { + op: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte'; + path: string; + value: JsonValue; +} + +/** True iff the dot-path resolves to a defined value. */ +export interface ExistsPredicate { + op: 'exists'; + path: string; +} + +/** True iff the dot-path value deep-equals one of the listed values. */ +export interface InPredicate { + op: 'in'; + path: string; + value: JsonValue[]; +} + +/** True iff the dot-path resolves to a string matching the (RegExp) pattern. */ +export interface MatchesPredicate { + op: 'matches'; + path: string; + value: string; +} + +export interface AndPredicate { + op: 'and'; + args: Predicate[]; +} + +export interface OrPredicate { + op: 'or'; + args: Predicate[]; +} + +export interface NotPredicate { + op: 'not'; + arg: Predicate; +} + +/** Constant predicates. `always` ≡ true, `never` ≡ false. */ +export type ConstPredicate = { op: 'always' } | { op: 'never' }; + +export type Predicate = + | ComparePredicate + | ExistsPredicate + | InPredicate + | MatchesPredicate + | AndPredicate + | OrPredicate + | NotPredicate + | ConstPredicate; + +/** The scope a predicate is evaluated against: the run's accumulated context and the + * just-completed step's result. Paths are rooted here — e.g. "ctx.base", + * "stepResult.approved", "stepResult.items.0.id". */ +export interface EvalScope { + ctx: JsonObject; + stepResult: JsonValue; +} + +// --------------------------------------------------------------------------- +// Workflow graph +// --------------------------------------------------------------------------- + +export type StepKind = 'agent' | 'action' | 'human'; + +export type PrincipalKind = 'user' | 'role'; + +export interface Principal { + kind: PrincipalKind; + /** user uuid (kind='user') or role key (kind='role'). */ + ref: string; +} + +export type Quorum = 'any' | 'all'; + +export interface HumanStepConfig { + principal: Principal; + channel: string; + message: string; + /** ISO-8601 duration; null/absent = no reminders. */ + reminderInterval?: string | null; + /** ISO-8601 duration relative to step entry; null/absent = no deadline. */ + deadline?: string | null; + /** default 'any'. */ + quorum?: Quorum; + responseSchema?: JsonObject; +} + +export interface CanvasPosition { + x: number; + y: number; +} + +export interface Step { + id: string; + kind: StepKind; + /** required when kind='agent'. */ + agentId?: string; + /** required when kind='action'. */ + actionId?: string; + /** required when kind='human'. */ + human?: HumanStepConfig; + /** the step's exit postcondition; absent ≡ always met. */ + postcondition?: Predicate; + /** id of the transition fired when the postcondition is unmet, or when no happy-path + * guard matches. Required for a deadline-bearing human step (validated). */ + fallbackTransitionId?: string; + position?: CanvasPosition; +} + +export interface Transition { + id: string; + source: string; + target: string; + /** guard evaluated against the source step's result/context; absent ≡ always true. */ + guard?: Predicate; +} + +export type TriggerKind = + | 'manual' + | 'cron' + | 'channel' + | 'agent' + | 'webhook' + | 'workflow' + | 'event'; + +export interface Trigger { + id: string; + kind: TriggerKind; + /** for kind='event': the catalog event id. */ + eventId?: string; + /** for kind='event': an optional payload filter (predicate over the event payload). */ + filter?: Predicate; + /** for kind='cron': a cron expression. */ + cron?: string; +} + +export interface WorkflowGraph { + entryStepId: string; + steps: Step[]; + transitions: Transition[]; + triggers?: Trigger[]; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export type ValidationCode = + | 'shape' + | 'unknown_entry_step' + | 'duplicate_step_id' + | 'duplicate_transition_id' + | 'transition_unknown_source' + | 'transition_unknown_target' + | 'fallback_unknown_transition' + | 'fallback_wrong_source' + | 'unreachable_step' + | 'unguarded_cycle' + | 'deadline_without_fallback' + | 'agent_step_missing_agent' + | 'action_step_missing_action' + | 'human_step_missing_config' + | 'unknown_agent_ref' + | 'unknown_action_ref' + | 'unknown_role_ref' + | 'unknown_event_ref'; + +export interface ValidationError { + code: ValidationCode; + message: string; + /** the offending node id(s) — steps, transitions, or triggers. */ + nodeIds: string[]; +} + +export interface ValidationResult { + ok: boolean; + errors: ValidationError[]; +} + +/** Optional known-reference sets supplied by the kernel so the pure engine can verify that + * referenced agents/actions/roles/events resolve against the live catalog. An absent set is + * not checked (structural presence only), keeping the engine usable standalone. */ +export interface KnownRefs { + agentIds?: readonly string[]; + actionIds?: readonly string[]; + roleKeys?: readonly string[]; + eventIds?: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Engine decision +// --------------------------------------------------------------------------- + +export type PostconditionOutcome = 'met' | 'unmet' | 'n/a'; + +export type AdvanceReason = + | 'guard_matched' + | 'postcondition_unmet_fallback' + | 'no_transition_matched_fallback'; + +export type StuckCode = + | 'unknown_step' + | 'postcondition_unmet_no_fallback' + | 'no_transition_no_fallback' + | 'ambiguous_guards' + | 'fallback_transition_missing'; + +export type Decision = + | { + kind: 'advance'; + transitionId: string; + targetStepId: string; + reason: AdvanceReason; + postcondition: PostconditionOutcome; + } + | { kind: 'complete'; postcondition: PostconditionOutcome } + | { + kind: 'stuck'; + code: StuckCode; + message: string; + nodeIds: string[]; + postcondition: PostconditionOutcome; + }; diff --git a/middleware/packages/conductor-core/src/validate.ts b/middleware/packages/conductor-core/src/validate.ts new file mode 100644 index 00000000..c80792a1 --- /dev/null +++ b/middleware/packages/conductor-core/src/validate.ts @@ -0,0 +1,201 @@ +// Semantic workflow-graph validation (FR-003). Pure; uses ajv only for the shape gate. + +import type { + KnownRefs, + Step, + Transition, + ValidationError, + ValidationResult, + WorkflowGraph, +} from './types.js'; +import { validateGraphShape } from './schema.js'; + +function unique(xs: string[]): string[] { + return [...new Set(xs)]; +} + +/** Steps reachable from `entry` by following transitions whose endpoints both exist. */ +function computeReachable(entry: string, transitions: Transition[], stepIds: Set): Set { + const adjacency = new Map(); + for (const t of transitions) { + if (!stepIds.has(t.source) || !stepIds.has(t.target)) continue; + (adjacency.get(t.source) ?? adjacency.set(t.source, []).get(t.source)!).push(t.target); + } + const seen = new Set(); + const queue = [entry]; + while (queue.length) { + const node = queue.shift()!; + if (seen.has(node)) continue; + seen.add(node); + for (const next of adjacency.get(node) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return seen; +} + +/** Find a cycle reachable through transitions that carry NO guard (a cycle with no progress + * guard). Returns the step ids on the cycle, or null. */ +function findUnguardedCycle(transitions: Transition[], stepIds: Set): string[] | null { + const adjacency = new Map(); + for (const t of transitions) { + if (t.guard !== undefined) continue; // only unguarded edges + if (!stepIds.has(t.source) || !stepIds.has(t.target)) continue; + (adjacency.get(t.source) ?? adjacency.set(t.source, []).get(t.source)!).push(t.target); + } + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + const stack: string[] = []; + + function dfs(node: string): string[] | null { + color.set(node, GRAY); + stack.push(node); + for (const next of adjacency.get(node) ?? []) { + const c = color.get(next) ?? WHITE; + if (c === GRAY) { + // back-edge → cycle from `next` down to `node` + const start = stack.indexOf(next); + return stack.slice(start).concat(next); + } + if (c === WHITE) { + const found = dfs(next); + if (found) return found; + } + } + color.set(node, BLACK); + stack.pop(); + return null; + } + + for (const node of adjacency.keys()) { + if ((color.get(node) ?? WHITE) === WHITE) { + const found = dfs(node); + if (found) return found; + } + } + return null; +} + +/** + * Validate a workflow graph: structural shape, unique ids, resolvable transition endpoints + * and fallbacks, reachability, unguarded cycles, deadline-without-fallback, per-kind config, + * and (when `knownRefs` is supplied) that referenced agents/actions/roles/events resolve. + */ +export function validate(graph: WorkflowGraph, knownRefs?: KnownRefs): ValidationResult { + // 0. shape gate — if the raw shape is wrong, deeper checks would be noise. + const shape = validateGraphShape(graph); + if (!shape.ok) { + return { + ok: false, + errors: [{ code: 'shape', message: `graph shape invalid: ${shape.errors.join('; ')}`, nodeIds: [] }], + }; + } + + const errors: ValidationError[] = []; + const steps = graph.steps; + const transitions = graph.transitions; + + // 1. unique ids + const stepById = new Map(); + const dupSteps: string[] = []; + for (const s of steps) { + if (stepById.has(s.id)) dupSteps.push(s.id); + else stepById.set(s.id, s); + } + if (dupSteps.length) { + errors.push({ code: 'duplicate_step_id', message: `duplicate step id(s): ${unique(dupSteps).join(', ')}`, nodeIds: unique(dupSteps) }); + } + const txById = new Map(); + const dupTx: string[] = []; + for (const t of transitions) { + if (txById.has(t.id)) dupTx.push(t.id); + else txById.set(t.id, t); + } + if (dupTx.length) { + errors.push({ code: 'duplicate_transition_id', message: `duplicate transition id(s): ${unique(dupTx).join(', ')}`, nodeIds: unique(dupTx) }); + } + + const stepIds = new Set(stepById.keys()); + + // 2. entry step exists + if (!stepIds.has(graph.entryStepId)) { + errors.push({ code: 'unknown_entry_step', message: `entryStepId '${graph.entryStepId}' is not a declared step`, nodeIds: [graph.entryStepId] }); + } + + // 3. transition endpoints resolve + for (const t of transitions) { + if (!stepIds.has(t.source)) { + errors.push({ code: 'transition_unknown_source', message: `transition '${t.id}' source '${t.source}' is not a step`, nodeIds: [t.id] }); + } + if (!stepIds.has(t.target)) { + errors.push({ code: 'transition_unknown_target', message: `transition '${t.id}' target '${t.target}' is not a step`, nodeIds: [t.id] }); + } + } + + // 4. per-step: kind config, fallback resolution, deadline-without-fallback, known refs + for (const s of steps) { + if (s.kind === 'agent' && !s.agentId) { + errors.push({ code: 'agent_step_missing_agent', message: `agent step '${s.id}' has no agentId`, nodeIds: [s.id] }); + } + if (s.kind === 'action' && !s.actionId) { + errors.push({ code: 'action_step_missing_action', message: `action step '${s.id}' has no actionId`, nodeIds: [s.id] }); + } + if (s.kind === 'human' && !s.human) { + errors.push({ code: 'human_step_missing_config', message: `human step '${s.id}' has no human config`, nodeIds: [s.id] }); + } + + if (s.fallbackTransitionId !== undefined) { + const fb = txById.get(s.fallbackTransitionId); + if (!fb) { + errors.push({ code: 'fallback_unknown_transition', message: `step '${s.id}' fallbackTransitionId '${s.fallbackTransitionId}' is not a transition`, nodeIds: [s.id] }); + } else if (fb.source !== s.id) { + errors.push({ code: 'fallback_wrong_source', message: `step '${s.id}' fallback transition '${fb.id}' does not originate from this step`, nodeIds: [s.id, fb.id] }); + } + } + + if (s.kind === 'human' && s.human && s.human.deadline != null && s.fallbackTransitionId === undefined) { + errors.push({ code: 'deadline_without_fallback', message: `human step '${s.id}' has a deadline but no fallbackTransitionId`, nodeIds: [s.id] }); + } + + if (knownRefs?.agentIds && s.kind === 'agent' && s.agentId && !knownRefs.agentIds.includes(s.agentId)) { + errors.push({ code: 'unknown_agent_ref', message: `step '${s.id}' references unknown agent '${s.agentId}'`, nodeIds: [s.id] }); + } + if (knownRefs?.actionIds && s.kind === 'action' && s.actionId && !knownRefs.actionIds.includes(s.actionId)) { + errors.push({ code: 'unknown_action_ref', message: `step '${s.id}' references unknown action '${s.actionId}'`, nodeIds: [s.id] }); + } + if (knownRefs?.roleKeys && s.kind === 'human' && s.human?.principal.kind === 'role' && !knownRefs.roleKeys.includes(s.human.principal.ref)) { + errors.push({ code: 'unknown_role_ref', message: `step '${s.id}' references unknown role '${s.human.principal.ref}'`, nodeIds: [s.id] }); + } + } + + // 5. triggers: event triggers need an eventId (and a known one if refs supplied) + for (const tr of graph.triggers ?? []) { + if (tr.kind === 'event') { + if (!tr.eventId) { + errors.push({ code: 'unknown_event_ref', message: `event trigger '${tr.id}' has no eventId`, nodeIds: [tr.id] }); + } else if (knownRefs?.eventIds && !knownRefs.eventIds.includes(tr.eventId)) { + errors.push({ code: 'unknown_event_ref', message: `event trigger '${tr.id}' references unknown event '${tr.eventId}'`, nodeIds: [tr.id] }); + } + } + } + + // 6. reachability (only meaningful when entry resolves) + if (stepIds.has(graph.entryStepId)) { + const reachable = computeReachable(graph.entryStepId, transitions, stepIds); + for (const s of steps) { + if (!reachable.has(s.id)) { + errors.push({ code: 'unreachable_step', message: `step '${s.id}' is unreachable from entry step '${graph.entryStepId}'`, nodeIds: [s.id] }); + } + } + } + + // 7. unguarded cycle + const cycle = findUnguardedCycle(transitions, stepIds); + if (cycle) { + errors.push({ code: 'unguarded_cycle', message: `unguarded cycle: ${cycle.join(' -> ')}`, nodeIds: unique(cycle) }); + } + + return { ok: errors.length === 0, errors }; +} diff --git a/middleware/packages/conductor-core/test/engine.test.ts b/middleware/packages/conductor-core/test/engine.test.ts new file mode 100644 index 00000000..50f322f4 --- /dev/null +++ b/middleware/packages/conductor-core/test/engine.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { nextStep } from '../src/engine.js'; +import type { JsonObject, WorkflowGraph } from '../src/types.js'; + +// Three-step workflow: s1 (postcondition + two outgoing) -> s2 | fallback s_fail; s2 terminal. +const graph: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { + id: 's1', + kind: 'agent', + agentId: 'a1', + postcondition: { op: 'exists', path: 'stepResult.notes' }, + fallbackTransitionId: 't_fail', + }, + { id: 's2', kind: 'action', actionId: 'act.done' }, + { id: 's_fail', kind: 'action', actionId: 'act.fail' }, + ], + transitions: [ + { id: 't_ok', source: 's1', target: 's2', guard: { op: 'eq', path: 'stepResult.ok', value: true } }, + { id: 't_fail', source: 's1', target: 's_fail' }, + ], +}; + +const noCtx: JsonObject = {}; + +describe('nextStep — US1 acceptance', () => { + it('1. satisfied postcondition + exactly one matching guard advances to its target', () => { + const d = nextStep(graph, 's1', { notes: 'x', ok: true }, noCtx); + expect(d).toEqual({ kind: 'advance', transitionId: 't_ok', targetStepId: 's2', reason: 'guard_matched', postcondition: 'met' }); + }); + + it('2a. unmet postcondition does NOT take happy path; takes declared fallback', () => { + const d = nextStep(graph, 's1', { ok: true }, noCtx); // no notes → postcondition unmet + expect(d).toEqual({ kind: 'advance', transitionId: 't_fail', targetStepId: 's_fail', reason: 'postcondition_unmet_fallback', postcondition: 'unmet' }); + }); + + it('2b. unmet postcondition with no fallback is a precise stuck', () => { + const g2: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'a1', postcondition: { op: 'exists', path: 'stepResult.notes' } }], + transitions: [], + }; + const d = nextStep(g2, 's1', {}, noCtx); + expect(d.kind).toBe('stuck'); + if (d.kind === 'stuck') expect(d.code).toBe('postcondition_unmet_no_fallback'); + }); + + it('met postcondition but no happy guard matched → fallback fires', () => { + const d = nextStep(graph, 's1', { notes: 'x', ok: false }, noCtx); + expect(d).toEqual({ kind: 'advance', transitionId: 't_fail', targetStepId: 's_fail', reason: 'no_transition_matched_fallback', postcondition: 'met' }); + }); + + it('terminal step (no outgoing) completes the run', () => { + const d = nextStep(graph, 's2', { anything: 1 }, noCtx); + expect(d).toEqual({ kind: 'complete', postcondition: 'n/a' }); + }); + + it('ambiguous guards → deterministic stuck naming the transitions', () => { + const g3: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'a1' }, { id: 'a', kind: 'action', actionId: 'x' }, { id: 'b', kind: 'action', actionId: 'y' }], + transitions: [ + { id: 't_a', source: 's1', target: 'a', guard: { op: 'always' } }, + { id: 't_b', source: 's1', target: 'b', guard: { op: 'always' } }, + ], + }; + const d = nextStep(g3, 's1', {}, noCtx); + expect(d.kind).toBe('stuck'); + if (d.kind === 'stuck') { + expect(d.code).toBe('ambiguous_guards'); + expect(d.nodeIds).toEqual(['t_a', 't_b']); + } + }); + + it('unknown step id → stuck', () => { + const d = nextStep(graph, 'nope', {}, noCtx); + expect(d.kind).toBe('stuck'); + if (d.kind === 'stuck') expect(d.code).toBe('unknown_step'); + }); + + it('4. determinism — identical inputs yield identical decisions', () => { + const result = { notes: 'x', ok: true }; + const a = nextStep(graph, 's1', result, noCtx); + const b = nextStep(graph, 's1', result, noCtx); + expect(a).toEqual(b); + }); +}); diff --git a/middleware/packages/conductor-core/test/fixtures.test.ts b/middleware/packages/conductor-core/test/fixtures.test.ts new file mode 100644 index 00000000..fc654514 --- /dev/null +++ b/middleware/packages/conductor-core/test/fixtures.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { validate } from '../src/validate.js'; +import { nextStep } from '../src/engine.js'; +import { conductorGraphSchema } from '../src/schema.js'; +import type { Decision, JsonObject, JsonValue, WorkflowGraph } from '../src/types.js'; + +function loadJson(relative: string): unknown { + return JSON.parse(readFileSync(new URL(relative, import.meta.url), 'utf8')); +} + +describe('fixtures — validation', () => { + it('valid-release-signoff passes validation', () => { + const g = loadJson('../fixtures/valid-release-signoff.json') as WorkflowGraph; + expect(validate(g)).toEqual({ ok: true, errors: [] }); + }); + + const invalids: Array<[string, string]> = [ + ['../fixtures/invalid-unreachable.json', 'unreachable_step'], + ['../fixtures/invalid-unguarded-cycle.json', 'unguarded_cycle'], + ['../fixtures/invalid-deadline-no-fallback.json', 'deadline_without_fallback'], + ]; + for (const [file, expectedCode] of invalids) { + it(`${file} fails with ${expectedCode}`, () => { + const g = loadJson(file) as WorkflowGraph; + const r = validate(g); + expect(r.ok).toBe(false); + expect(r.errors.map((e) => e.code)).toContain(expectedCode); + }); + } +}); + +describe('fixtures — deterministic walk through valid-release-signoff', () => { + const g = loadJson('../fixtures/valid-release-signoff.json') as WorkflowGraph; + + /** Drive the engine through a graph from `entryStepId`, feeding a per-step result. */ + function walk(stepResults: Record, ctx: JsonObject): Decision[] { + const path: Decision[] = []; + let stepId: string | undefined = g.entryStepId; + const guard = new Set(); + while (stepId) { + if (guard.has(stepId)) throw new Error(`loop at ${stepId}`); + guard.add(stepId); + const d = nextStep(g, stepId, stepResults[stepId] ?? {}, ctx); + path.push(d); + stepId = d.kind === 'advance' ? d.targetStepId : undefined; + } + return path; + } + + it('approval path: s1 -t1-> s2 -t_approve-> s3 -> complete', () => { + const path = walk({ s1: { notes: 'cut' }, s2: { approved: true } }, { base: 'main', tag: 'v1' }); + expect(path.map((d) => (d.kind === 'advance' ? d.transitionId : d.kind))).toEqual(['t1', 't_approve', 'complete']); + }); + + it('deadline path: unmet approval falls through to s_autoreject', () => { + const path = walk({ s1: { notes: 'cut' }, s2: { approved: false } }, { base: 'main' }); + expect(path.map((d) => (d.kind === 'advance' ? d.transitionId : d.kind))).toEqual(['t1', 't_deadline', 'complete']); + }); + + it('agent-failure path: s1 postcondition unmet → t_fail', () => { + const path = walk({ s1: {}, s_end_fail: {} }, { base: 'main' }); + expect(path[0]).toMatchObject({ kind: 'advance', transitionId: 't_fail', reason: 'postcondition_unmet_fallback' }); + }); + + it('is deterministic across repeated walks', () => { + const a = walk({ s1: { notes: 'cut' }, s2: { approved: true } }, { base: 'main' }); + const b = walk({ s1: { notes: 'cut' }, s2: { approved: true } }, { base: 'main' }); + expect(a).toEqual(b); + }); +}); + +describe('schema parity', () => { + it('published schema/conductor-graph.schema.json equals the exported conductorGraphSchema', () => { + const published = loadJson('../schema/conductor-graph.schema.json'); + expect(published).toEqual(conductorGraphSchema); + }); +}); diff --git a/middleware/packages/conductor-core/test/predicate.test.ts b/middleware/packages/conductor-core/test/predicate.test.ts new file mode 100644 index 00000000..3b7b7d2c --- /dev/null +++ b/middleware/packages/conductor-core/test/predicate.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { evaluatePredicate, resolvePath } from '../src/predicate.js'; +import type { EvalScope, Predicate } from '../src/types.js'; + +const scope: EvalScope = { + ctx: { base: 'main', amount: 1500, tags: ['rc', 'release'], nested: { ok: true } }, + stepResult: { approved: true, score: 42, name: 'Acme' }, +}; + +describe('resolvePath', () => { + it('resolves ctx and stepResult dot-paths', () => { + expect(resolvePath(scope, 'ctx.base')).toBe('main'); + expect(resolvePath(scope, 'stepResult.approved')).toBe(true); + expect(resolvePath(scope, 'ctx.nested.ok')).toBe(true); + }); + it('indexes into arrays', () => { + expect(resolvePath(scope, 'ctx.tags.0')).toBe('rc'); + expect(resolvePath(scope, 'ctx.tags.5')).toBeUndefined(); + }); + it('returns undefined for missing segments', () => { + expect(resolvePath(scope, 'ctx.nope.deep')).toBeUndefined(); + expect(resolvePath(scope, 'stepResult.score.x')).toBeUndefined(); + }); +}); + +describe('evaluatePredicate', () => { + const cases: Array<[string, Predicate, boolean]> = [ + ['always', { op: 'always' }, true], + ['never', { op: 'never' }, false], + ['eq true', { op: 'eq', path: 'stepResult.approved', value: true }, true], + ['eq mismatch', { op: 'eq', path: 'ctx.base', value: 'dev' }, false], + ['ne', { op: 'ne', path: 'ctx.base', value: 'dev' }, true], + ['exists', { op: 'exists', path: 'stepResult.name' }, true], + ['exists missing', { op: 'exists', path: 'stepResult.missing' }, false], + ['gt number', { op: 'gt', path: 'ctx.amount', value: 1000 }, true], + ['lte number false', { op: 'lte', path: 'ctx.amount', value: 1000 }, false], + ['gt type-mismatch is false', { op: 'gt', path: 'ctx.base', value: 1000 }, false], + ['in', { op: 'in', path: 'ctx.base', value: ['main', 'master'] }, true], + ['in miss', { op: 'in', path: 'ctx.base', value: ['dev'] }, false], + ['matches', { op: 'matches', path: 'stepResult.name', value: '^Ac' }, true], + ['matches non-string false', { op: 'matches', path: 'stepResult.score', value: '4' }, false], + ['matches bad-regex false', { op: 'matches', path: 'stepResult.name', value: '(' }, false], + ]; + for (const [name, pred, expected] of cases) { + it(name, () => expect(evaluatePredicate(pred, scope)).toBe(expected)); + } + + it('composes and/or/not', () => { + const p: Predicate = { + op: 'and', + args: [ + { op: 'eq', path: 'ctx.base', value: 'main' }, + { op: 'or', args: [{ op: 'eq', path: 'stepResult.approved', value: false }, { op: 'gt', path: 'stepResult.score', value: 10 }] }, + { op: 'not', arg: { op: 'exists', path: 'stepResult.missing' } }, + ], + }; + expect(evaluatePredicate(p, scope)).toBe(true); + }); + + it('is deterministic across repeated evaluation', () => { + const p: Predicate = { op: 'gt', path: 'ctx.amount', value: 1000 }; + expect(evaluatePredicate(p, scope)).toBe(evaluatePredicate(p, scope)); + }); +}); diff --git a/middleware/packages/conductor-core/test/validate.test.ts b/middleware/packages/conductor-core/test/validate.test.ts new file mode 100644 index 00000000..31ca2f79 --- /dev/null +++ b/middleware/packages/conductor-core/test/validate.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { validate } from '../src/validate.js'; +import type { ValidationCode, WorkflowGraph } from '../src/types.js'; + +function codes(graph: WorkflowGraph, knownRefs?: Parameters[1]): ValidationCode[] { + return validate(graph, knownRefs).errors.map((e) => e.code); +} + +describe('validate', () => { + it('accepts a well-formed graph', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'agent', agentId: 'a1', fallbackTransitionId: 't_fail' }, + { id: 's2', kind: 'action', actionId: 'act.done' }, + { id: 's_fail', kind: 'action', actionId: 'act.fail' }, + ], + transitions: [ + { id: 't_ok', source: 's1', target: 's2', guard: { op: 'eq', path: 'stepResult.ok', value: true } }, + { id: 't_fail', source: 's1', target: 's_fail' }, + ], + }; + expect(validate(g)).toEqual({ ok: true, errors: [] }); + }); + + it('rejects a bad shape with a single shape error', () => { + const r = validate({ steps: [], transitions: [] } as unknown as WorkflowGraph); + expect(r.ok).toBe(false); + expect(r.errors.map((e) => e.code)).toEqual(['shape']); + }); + + it('names an unreachable step', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'agent', agentId: 'a1' }, + { id: 's2', kind: 'action', actionId: 'x' }, + { id: 'orphan', kind: 'action', actionId: 'y' }, + ], + transitions: [{ id: 't1', source: 's1', target: 's2' }], + }; + const r = validate(g); + expect(r.ok).toBe(false); + const err = r.errors.find((e) => e.code === 'unreachable_step'); + expect(err?.nodeIds).toEqual(['orphan']); + }); + + it('detects an unguarded cycle', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'a1' }, { id: 's2', kind: 'agent', agentId: 'a2' }], + transitions: [ + { id: 'tA', source: 's1', target: 's2' }, + { id: 'tB', source: 's2', target: 's1' }, + ], + }; + expect(codes(g)).toContain('unguarded_cycle'); + }); + + it('allows a guarded cycle (guard can break out)', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'a1' }, { id: 's2', kind: 'agent', agentId: 'a2' }], + transitions: [ + { id: 'tA', source: 's1', target: 's2' }, + { id: 'tB', source: 's2', target: 's1', guard: { op: 'eq', path: 'stepResult.retry', value: true } }, + ], + }; + expect(codes(g)).not.toContain('unguarded_cycle'); + }); + + it('rejects a deadline-bearing human step without a fallback', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'human', human: { principal: { kind: 'role', ref: 'r' }, channel: 'teams', message: 'm', deadline: 'PT1H' } }, + { id: 's2', kind: 'action', actionId: 'x' }, + ], + transitions: [{ id: 't1', source: 's1', target: 's2', guard: { op: 'always' } }], + }; + expect(codes(g)).toContain('deadline_without_fallback'); + }); + + it('flags a fallback that does not originate from its step', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'agent', agentId: 'a1', fallbackTransitionId: 't2' }, + { id: 's2', kind: 'action', actionId: 'x' }, + ], + transitions: [ + { id: 't1', source: 's1', target: 's2' }, + { id: 't2', source: 's2', target: 's1' }, + ], + }; + expect(codes(g)).toContain('fallback_wrong_source'); + }); + + it('checks known references when supplied', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'ghost' }], + transitions: [], + }; + expect(codes(g, { agentIds: ['real'] })).toContain('unknown_agent_ref'); + expect(codes(g, { agentIds: ['ghost'] })).not.toContain('unknown_agent_ref'); + }); + + it('rejects an event trigger with an unknown event id', () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'a1' }], + transitions: [], + triggers: [{ id: 'tr1', kind: 'event', eventId: 'x.y' }], + }; + expect(codes(g, { eventIds: ['a.b'] })).toContain('unknown_event_ref'); + }); +}); diff --git a/middleware/packages/conductor-core/tsconfig.build.json b/middleware/packages/conductor-core/tsconfig.build.json new file mode 100644 index 00000000..1bfea9dc --- /dev/null +++ b/middleware/packages/conductor-core/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["test", "dist", "node_modules"] +} diff --git a/middleware/packages/conductor-core/tsconfig.json b/middleware/packages/conductor-core/tsconfig.json new file mode 100644 index 00000000..5db577e7 --- /dev/null +++ b/middleware/packages/conductor-core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["src", "test"] +} From e0a81ba2cf7c398063b8f97deda6b9ab83d0b668 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 08:15:16 +0200 Subject: [PATCH 04/19] feat(conductor): wire conductor-core into the kernel (Phase 2 vertical slice) Deterministic engine now drives real, persisted runs end to end: - migrations/0001_conductor.sql: full conductor_* schema (workflows/versions/ drafts/runs/run_steps/awaits/await_responses/roles/role_assignments) + the resolved-decision tables (conductor_channel_bindings, conductor_schedules) + claim columns + pg_notify triggers. TEXT+CHECK enums, idempotent. - runConductorMigrations (per-subsystem migrator, mirrors runAuthMigrations). - ConductorWorkflowStore (create + versioned publish) and ConductorRunStore (run + durable per-step record, recordStepAndAdvance persists before advance). - StepEffects seam (so US8 preview reuses this executor) + StubStepEffects. - ConductorRunExecutor.startRun: validate-active-version -> create run -> engine nextStep loop -> persist each step + accumulated context; human steps park as waiting (durable awaits land later), agent/action run to completion. - Operator API /api/v1/operator/conductors (publish/list/status/start-run/trace), graph validated by conductor-core before persist; mounted behind requireAuth. - wireConductor() called from boot inside the graphPool block (inert in-memory). tsc --noEmit clean across the middleware; conductor-core builds. --- middleware/src/conductor/index.ts | 56 ++++++ .../conductor/migrations/0001_conductor.sql | 185 ++++++++++++++++++ middleware/src/conductor/migrator.ts | 54 +++++ middleware/src/conductor/routes.ts | 146 ++++++++++++++ middleware/src/conductor/runExecutor.ts | 153 +++++++++++++++ middleware/src/conductor/runStore.ts | 185 ++++++++++++++++++ middleware/src/conductor/stepEffects.ts | 40 ++++ middleware/src/conductor/workflowStore.ts | 161 +++++++++++++++ middleware/src/index.ts | 6 + 9 files changed, 986 insertions(+) create mode 100644 middleware/src/conductor/index.ts create mode 100644 middleware/src/conductor/migrations/0001_conductor.sql create mode 100644 middleware/src/conductor/migrator.ts create mode 100644 middleware/src/conductor/routes.ts create mode 100644 middleware/src/conductor/runExecutor.ts create mode 100644 middleware/src/conductor/runStore.ts create mode 100644 middleware/src/conductor/stepEffects.ts create mode 100644 middleware/src/conductor/workflowStore.ts diff --git a/middleware/src/conductor/index.ts b/middleware/src/conductor/index.ts new file mode 100644 index 00000000..250a0c76 --- /dev/null +++ b/middleware/src/conductor/index.ts @@ -0,0 +1,56 @@ +import type { Express, RequestHandler } from 'express'; +import type { Pool } from 'pg'; + +import { runConductorMigrations } from './migrator.js'; +import { ConductorWorkflowStore } from './workflowStore.js'; +import { ConductorRunStore } from './runStore.js'; +import { ConductorRunExecutor } from './runExecutor.js'; +import { StubStepEffects } from './stepEffects.js'; +import { createConductorRouter } from './routes.js'; + +export { runConductorMigrations } from './migrator.js'; +export { ConductorWorkflowStore } from './workflowStore.js'; +export { ConductorRunStore } from './runStore.js'; +export { ConductorRunExecutor } from './runExecutor.js'; +export { StubStepEffects } from './stepEffects.js'; +export type { StepEffects, StepExecution } from './stepEffects.js'; +export { createConductorRouter } from './routes.js'; + +export interface ConductorWiring { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + executor: ConductorRunExecutor; +} + +/** + * Wire the Conductor subsystem into the kernel: run its migrations, construct its stores + + * run executor (stub step effects for now), and mount the operator API behind requireAuth. + * Called from the kernel boot inside the `graphPool` block — Conductor is inert on the + * in-memory backend (no pool), exactly like routines / agent_schedules. + */ +export async function wireConductor(deps: { + pool: Pool; + app: Express; + requireAuth: RequestHandler; + log?: (msg: string) => void; +}): Promise { + const log = deps.log ?? (() => undefined); + await runConductorMigrations(deps.pool, log); + + const workflowStore = new ConductorWorkflowStore(deps.pool); + const runStore = new ConductorRunStore(deps.pool); + const executor = new ConductorRunExecutor({ + workflowStore, + runStore, + effects: new StubStepEffects(), + log, + }); + + deps.app.use( + '/api/v1/operator/conductors', + deps.requireAuth, + createConductorRouter({ workflowStore, runStore, executor }), + ); + + return { workflowStore, runStore, executor }; +} diff --git a/middleware/src/conductor/migrations/0001_conductor.sql b/middleware/src/conductor/migrations/0001_conductor.sql new file mode 100644 index 00000000..5cc135ea --- /dev/null +++ b/middleware/src/conductor/migrations/0001_conductor.sql @@ -0,0 +1,185 @@ +-- Omadia Conductor — initial schema (Spec 005). +-- Enums are TEXT + CHECK (extend without ALTER TYPE), per data-model.md / spec 001. +-- Forward-only, idempotent: CREATE ... IF NOT EXISTS, CREATE OR REPLACE FUNCTION, +-- DROP TRIGGER IF EXISTS before CREATE TRIGGER. + +-- --------------------------------------------------------------------------- +-- Workflow header + immutable versions + mutable draft +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_workflows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'disabled' + CHECK (status IN ('enabled', 'disabled')), + active_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS conductor_workflow_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL REFERENCES conductor_workflows(id) ON DELETE CASCADE, + version INT NOT NULL, + graph JSONB NOT NULL, + published_by UUID, + published_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workflow_id, version) +); + +CREATE TABLE IF NOT EXISTS conductor_workflow_drafts ( + workflow_id UUID PRIMARY KEY REFERENCES conductor_workflows(id) ON DELETE CASCADE, + graph JSONB NOT NULL DEFAULT '{}', + base_version INT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- Runs + per-step durable record (resume checkpoint + audit trace) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_version_id UUID NOT NULL REFERENCES conductor_workflow_versions(id), + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running', 'waiting', 'completed', 'failed')), + current_step_id TEXT, + context JSONB NOT NULL DEFAULT '{}', + trigger_kind TEXT NOT NULL, + trigger_source JSONB, + is_dry_run BOOLEAN NOT NULL DEFAULT false, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS conductor_runs_waiting_idx + ON conductor_runs(status) WHERE status = 'waiting'; + +CREATE TABLE IF NOT EXISTS conductor_run_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + seq INT NOT NULL, + actor JSONB, + postcondition_outcome TEXT, + transition_taken TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + UNIQUE (run_id, seq) +); + +-- --------------------------------------------------------------------------- +-- Durable awaits (+ DB-claim columns and unreachable flag — resolved decisions) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_awaits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + principal_kind TEXT NOT NULL CHECK (principal_kind IN ('user', 'role')), + principal_ref TEXT NOT NULL, + channel_type TEXT NOT NULL, + message TEXT NOT NULL, + quorum TEXT NOT NULL DEFAULT 'any' CHECK (quorum IN ('any', 'all')), + reminder_interval_ms BIGINT, + deadline_at TIMESTAMPTZ, + fallback_transition_id TEXT, + status TEXT NOT NULL DEFAULT 'waiting' + CHECK (status IN ('waiting', 'resolved', 'timed_out', 'cancelled')), + unreachable BOOLEAN NOT NULL DEFAULT false, + last_reminder_at TIMESTAMPTZ, + claimed_by UUID, + claimed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS conductor_awaits_due_idx + ON conductor_awaits(status, deadline_at, last_reminder_at) WHERE status = 'waiting'; + +CREATE TABLE IF NOT EXISTS conductor_await_responses ( + await_id UUID NOT NULL REFERENCES conductor_awaits(id) ON DELETE CASCADE, + responder_id UUID NOT NULL, + response JSONB NOT NULL, + responded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (await_id, responder_id) +); + +-- --------------------------------------------------------------------------- +-- Roles + assignments (the baton). Read by the default RoleResolver. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_roles ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT, + scope TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS conductor_role_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key TEXT NOT NULL REFERENCES conductor_roles(key) ON DELETE CASCADE, + holder_id UUID NOT NULL, + provenance TEXT NOT NULL DEFAULT 'manual', + delegate_id UUID, + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_to TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS conductor_role_assignments_role_idx + ON conductor_role_assignments(role_key); + +-- --------------------------------------------------------------------------- +-- User -> channel conversation-reference mapping (resolved decision #1): +-- how to proactively reach a user: / role-resolved holder. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_channel_bindings ( + user_id UUID NOT NULL, + channel_type TEXT NOT NULL, + conversation_ref JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_type) +); + +-- --------------------------------------------------------------------------- +-- Cron schedules (resolved decision #2): sibling of agent_schedules, polled +-- by the same ScheduleWorker tick. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS conductor_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL REFERENCES conductor_workflows(id) ON DELETE CASCADE, + cron TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + status TEXT NOT NULL DEFAULT 'enabled' CHECK (status IN ('enabled', 'disabled')), + claimed_by UUID, + claimed_at TIMESTAMPTZ, + last_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS conductor_schedules_role_idx + ON conductor_schedules(workflow_id); + +-- --------------------------------------------------------------------------- +-- Change-notification triggers (run resume + baton moves) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION conductor_notify_await_resolved() RETURNS trigger AS $$ +BEGIN + IF NEW.status IN ('resolved', 'timed_out') AND OLD.status = 'waiting' THEN + PERFORM pg_notify('conductor_await_resolved', NEW.run_id::text); + END IF; + RETURN NULL; +END; $$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS conductor_await_resolved_trg ON conductor_awaits; +CREATE TRIGGER conductor_await_resolved_trg + AFTER UPDATE ON conductor_awaits + FOR EACH ROW EXECUTE FUNCTION conductor_notify_await_resolved(); + +CREATE OR REPLACE FUNCTION conductor_notify_role_changed() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('conductor_role_changed', COALESCE(NEW.role_key, OLD.role_key)); + RETURN NULL; +END; $$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS conductor_role_changed_trg ON conductor_role_assignments; +CREATE TRIGGER conductor_role_changed_trg + AFTER INSERT OR UPDATE OR DELETE ON conductor_role_assignments + FOR EACH ROW EXECUTE FUNCTION conductor_notify_role_changed(); diff --git a/middleware/src/conductor/migrator.ts b/middleware/src/conductor/migrator.ts new file mode 100644 index 00000000..7199b36f --- /dev/null +++ b/middleware/src/conductor/migrator.ts @@ -0,0 +1,54 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Pool } from 'pg'; + +const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations'); + +/** + * Apply pending Conductor SQL migrations against the shared Postgres pool. + * Tracking lives in `_conductor_migrations`, independent of the other + * subsystem migrators. Mirrors `runAuthMigrations` line for line so the + * migrators stay diff-comparable. + * + * Idempotent: each file runs in its own transaction, recorded only on commit. + */ +export async function runConductorMigrations( + pool: Pool, + log: (msg: string) => void = () => undefined, +): Promise { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS _conductor_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + const applied = new Set( + (await client.query<{ id: string }>('SELECT id FROM _conductor_migrations')).rows.map( + (r) => r.id, + ), + ); + + const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith('.sql')).sort(); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8'); + log(`[conductor] applying migration ${file}`); + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query('INSERT INTO _conductor_migrations (id) VALUES ($1)', [file]); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + } finally { + client.release(); + } +} diff --git a/middleware/src/conductor/routes.ts b/middleware/src/conductor/routes.ts new file mode 100644 index 00000000..0ffd84cf --- /dev/null +++ b/middleware/src/conductor/routes.ts @@ -0,0 +1,146 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; + +import { validate } from '@omadia/conductor-core'; +import type { JsonObject, WorkflowGraph } from '@omadia/conductor-core'; + +import type { ConductorWorkflowStore } from './workflowStore.js'; +import type { ConductorRunStore } from './runStore.js'; +import { + ConductorRunExecutor, + WorkflowDisabledError, + WorkflowNotFoundError, + WorkflowNotPublishedError, +} from './runExecutor.js'; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function asObject(v: unknown): JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonObject) : {}; +} + +export interface ConductorRouterDeps { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + executor: ConductorRunExecutor; +} + +/** + * Operator-facing Conductor API, mounted behind requireAuth at + * /api/v1/operator/conductors. Lets an operator publish a workflow (graph + * validated by @omadia/conductor-core before persist), start manual runs, and + * read the durable run trace. + */ +export function createConductorRouter(deps: ConductorRouterDeps): Router { + const router = Router(); + + // List workflows. + router.get('/', async (_req: Request, res: Response): Promise => { + try { + res.json({ workflows: await deps.workflowStore.list() }); + } catch (err) { + res.status(500).json({ code: 'conductor.list_failed', message: errMsg(err) }); + } + }); + + // Create or publish a workflow version. Validates the graph first. + router.post('/', async (req: Request, res: Response): Promise => { + const body = asObject(req.body); + const slug = typeof body.slug === 'string' ? body.slug : ''; + const name = typeof body.name === 'string' ? body.name : ''; + if (!slug || !name) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'slug and name are required' }); + return; + } + const graph = body.graph as WorkflowGraph; + const result = validate(graph); + if (!result.ok) { + res.status(400).json({ code: 'conductor.invalid_graph', errors: result.errors }); + return; + } + try { + const out = await deps.workflowStore.createOrPublish({ + slug, + name, + description: typeof body.description === 'string' ? body.description : null, + graph, + enable: body.enable === true, + }); + res.status(201).json({ + workflow: out.workflow, + version: { id: out.version.id, version: out.version.version }, + }); + } catch (err) { + res.status(500).json({ code: 'conductor.publish_failed', message: errMsg(err) }); + } + }); + + // Enable / disable a workflow. + router.post('/:slug/status', async (req: Request, res: Response): Promise => { + const status = asObject(req.body).status; + if (status !== 'enabled' && status !== 'disabled') { + res.status(400).json({ code: 'conductor.invalid_input', message: "status must be 'enabled' or 'disabled'" }); + return; + } + try { + await deps.workflowStore.setStatus(req.params.slug ?? '', status); + res.status(204).end(); + } catch (err) { + res.status(500).json({ code: 'conductor.status_failed', message: errMsg(err) }); + } + }); + + // Start a manual run; returns the (synchronously driven) run plus its step trace. + router.post('/:slug/runs', async (req: Request, res: Response): Promise => { + const slug = req.params.slug ?? ''; + const payload = asObject(asObject(req.body).payload); + try { + const run = await deps.executor.startRun({ slug, payload, triggerKind: 'manual' }); + const steps = await deps.runStore.stepsForRun(run.id); + res.status(201).json({ run, steps }); + } catch (err) { + if (err instanceof WorkflowNotFoundError) { + res.status(404).json({ code: 'conductor.not_found', message: err.message }); + } else if (err instanceof WorkflowDisabledError) { + res.status(409).json({ code: 'conductor.disabled', message: err.message }); + } else if (err instanceof WorkflowNotPublishedError) { + res.status(409).json({ code: 'conductor.not_published', message: err.message }); + } else { + res.status(500).json({ code: 'conductor.run_failed', message: errMsg(err) }); + } + } + }); + + // List runs for a workflow's active version. + router.get('/:slug/runs', async (req: Request, res: Response): Promise => { + try { + const wf = await deps.workflowStore.getBySlug(req.params.slug ?? ''); + if (!wf || !wf.activeVersionId) { + res.status(404).json({ code: 'conductor.not_found', message: 'workflow or active version missing' }); + return; + } + res.json({ runs: await deps.runStore.listForVersion(wf.activeVersionId) }); + } catch (err) { + res.status(500).json({ code: 'conductor.list_runs_failed', message: errMsg(err) }); + } + }); + + // Single run with its ordered step trace (audit / US9 surface). + router.get('/:slug/runs/:runId', async (req: Request, res: Response): Promise => { + try { + const run = await deps.runStore.get(req.params.runId ?? ''); + if (!run) { + res.status(404).json({ code: 'conductor.not_found', message: 'run not found' }); + return; + } + const steps = await deps.runStore.stepsForRun(run.id); + res.json({ run, steps }); + } catch (err) { + res.status(500).json({ code: 'conductor.get_run_failed', message: errMsg(err) }); + } + }); + + return router; +} diff --git a/middleware/src/conductor/runExecutor.ts b/middleware/src/conductor/runExecutor.ts new file mode 100644 index 00000000..96002146 --- /dev/null +++ b/middleware/src/conductor/runExecutor.ts @@ -0,0 +1,153 @@ +import { nextStep } from '@omadia/conductor-core'; +import type { JsonObject, JsonValue, WorkflowGraph } from '@omadia/conductor-core'; + +import type { ConductorWorkflowStore } from './workflowStore.js'; +import type { ConductorRun, ConductorRunStore, TriggerKind } from './runStore.js'; +import type { StepEffects } from './stepEffects.js'; + +export class WorkflowNotFoundError extends Error {} +export class WorkflowDisabledError extends Error {} +export class WorkflowNotPublishedError extends Error {} + +const MAX_STEPS = 1000; + +function asObject(v: JsonValue | undefined): JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {}; +} + +/** + * Owns run advancement: the engine (`@omadia/conductor-core`) decides the path; this executor + * performs the per-step I/O (via StepEffects) and persists each step + the run's accumulated + * context before advancing (FR-004). Human steps park the run as `waiting` (the durable-await + * substrate lands in a later phase); agent/action steps run to completion deterministically. + */ +export class ConductorRunExecutor { + private readonly workflowStore: ConductorWorkflowStore; + private readonly runStore: ConductorRunStore; + private readonly effects: StepEffects; + private readonly log: (msg: string) => void; + + constructor(deps: { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + effects: StepEffects; + log?: (msg: string) => void; + }) { + this.workflowStore = deps.workflowStore; + this.runStore = deps.runStore; + this.effects = deps.effects; + this.log = deps.log ?? (() => undefined); + } + + async startRun(input: { + slug: string; + payload: JsonObject; + triggerKind?: TriggerKind; + triggerSource?: JsonValue | null; + isDryRun?: boolean; + }): Promise { + const wf = await this.workflowStore.getBySlug(input.slug); + if (!wf) throw new WorkflowNotFoundError(`workflow '${input.slug}' not found`); + if (wf.status === 'disabled') { + // FR-009: a trigger for a disabled workflow starts no run and is logged. + this.log(`[conductor] suppressed trigger for disabled workflow '${input.slug}'`); + throw new WorkflowDisabledError(`workflow '${input.slug}' is disabled`); + } + if (!wf.activeVersionId) throw new WorkflowNotPublishedError(`workflow '${input.slug}' has no active version`); + + const version = await this.workflowStore.getVersion(wf.activeVersionId); + if (!version) throw new WorkflowNotPublishedError(`active version of '${input.slug}' missing`); + + const run = await this.runStore.create({ + workflowVersionId: version.id, + entryStepId: version.graph.entryStepId, + context: input.payload, + triggerKind: input.triggerKind ?? 'manual', + triggerSource: input.triggerSource ?? null, + isDryRun: input.isDryRun ?? false, + }); + + return this.drive(run, version.graph); + } + + private async drive(run: ConductorRun, graph: WorkflowGraph): Promise { + let context: JsonObject = { ...run.context }; + let currentStepId: string | null = run.currentStepId; + let seq = 0; + + while (currentStepId && seq < MAX_STEPS) { + const stepId: string = currentStepId; + const step = graph.steps.find((s) => s.id === stepId); + if (!step) { + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, actor: null, + postconditionOutcome: 'n/a', transitionTaken: null, + nextStepId: null, context, status: 'failed', + }); + break; + } + + // Human steps: park the run (durable-await substrate is a later phase). + if (step.kind === 'human') { + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, + actor: { kind: 'human', principal: step.human?.principal ?? null }, + postconditionOutcome: 'n/a', transitionTaken: null, + nextStepId: stepId, context, status: 'waiting', + }); + this.log(`[conductor] run ${run.id} parked at human step '${stepId}' (awaits not yet wired)`); + break; + } + + let exec; + try { + exec = step.kind === 'agent' + ? await this.effects.runAgentStep(step, context) + : await this.effects.runActionStep(step, context); + } catch (err) { + this.log(`[conductor] run ${run.id} step '${stepId}' threw: ${err instanceof Error ? err.message : String(err)}`); + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, + actor: { kind: step.kind, ref: step.agentId ?? step.actionId ?? null }, + postconditionOutcome: 'n/a', transitionTaken: null, + nextStepId: null, context, status: 'failed', + }); + break; + } + + const decision = nextStep(graph, stepId, exec.result, context); + + // Accumulate this step's result under context.steps[stepId] for later guards. + const prevSteps = asObject(context.steps); + context = { ...context, steps: { ...prevSteps, [stepId]: exec.result } }; + + if (decision.kind === 'advance') { + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, actor: exec.actor, + postconditionOutcome: decision.postcondition, transitionTaken: decision.transitionId, + nextStepId: decision.targetStepId, context, status: 'running', + }); + currentStepId = decision.targetStepId; + seq += 1; + } else if (decision.kind === 'complete') { + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, actor: exec.actor, + postconditionOutcome: decision.postcondition, transitionTaken: null, + nextStepId: null, context, status: 'completed', + }); + currentStepId = null; + } else { + this.log(`[conductor] run ${run.id} stuck at '${stepId}': ${decision.message}`); + await this.runStore.recordStepAndAdvance({ + runId: run.id, seq, stepId, actor: exec.actor, + postconditionOutcome: decision.postcondition, transitionTaken: null, + nextStepId: stepId, context, status: 'failed', + }); + currentStepId = null; + } + } + + const finalRun = await this.runStore.get(run.id); + return finalRun ?? run; + } +} diff --git a/middleware/src/conductor/runStore.ts b/middleware/src/conductor/runStore.ts new file mode 100644 index 00000000..e01842d9 --- /dev/null +++ b/middleware/src/conductor/runStore.ts @@ -0,0 +1,185 @@ +import type { Pool } from 'pg'; +import type { JsonObject, JsonValue } from '@omadia/conductor-core'; + +export type RunStatus = 'running' | 'waiting' | 'completed' | 'failed'; +export type TriggerKind = 'manual' | 'cron' | 'channel' | 'agent' | 'webhook' | 'workflow' | 'event'; + +export interface ConductorRun { + id: string; + workflowVersionId: string; + status: RunStatus; + currentStepId: string | null; + context: JsonObject; + triggerKind: TriggerKind; + triggerSource: JsonValue | null; + isDryRun: boolean; + startedAt: Date; + endedAt: Date | null; +} + +export interface ConductorRunStep { + id: string; + runId: string; + stepId: string; + seq: number; + actor: JsonValue | null; + postconditionOutcome: string | null; + transitionTaken: string | null; + startedAt: Date; + endedAt: Date | null; +} + +interface RunRow { + id: string; + workflow_version_id: string; + status: RunStatus; + current_step_id: string | null; + context: JsonObject; + trigger_kind: TriggerKind; + trigger_source: JsonValue | null; + is_dry_run: boolean; + started_at: Date; + ended_at: Date | null; +} + +interface StepRow { + id: string; + run_id: string; + step_id: string; + seq: number; + actor: JsonValue | null; + postcondition_outcome: string | null; + transition_taken: string | null; + started_at: Date; + ended_at: Date | null; +} + +function toRun(r: RunRow): ConductorRun { + return { + id: r.id, + workflowVersionId: r.workflow_version_id, + status: r.status, + currentStepId: r.current_step_id, + context: r.context, + triggerKind: r.trigger_kind, + triggerSource: r.trigger_source, + isDryRun: r.is_dry_run, + startedAt: r.started_at, + endedAt: r.ended_at, + }; +} + +function toStep(r: StepRow): ConductorRunStep { + return { + id: r.id, + runId: r.run_id, + stepId: r.step_id, + seq: r.seq, + actor: r.actor, + postconditionOutcome: r.postcondition_outcome, + transitionTaken: r.transition_taken, + startedAt: r.started_at, + endedAt: r.ended_at, + }; +} + +const RUN_COLS = `id, workflow_version_id, status, current_step_id, context, trigger_kind, trigger_source, is_dry_run, started_at, ended_at`; +const STEP_COLS = `id, run_id, step_id, seq, actor, postcondition_outcome, transition_taken, started_at, ended_at`; + +/** Persistence for runs + their durable per-step record (resume checkpoint + audit trace). */ +export class ConductorRunStore { + constructor(private readonly pool: Pool) {} + + async create(input: { + workflowVersionId: string; + entryStepId: string; + context: JsonObject; + triggerKind: TriggerKind; + triggerSource?: JsonValue | null; + isDryRun?: boolean; + }): Promise { + const r = await this.pool.query( + `INSERT INTO conductor_runs + (workflow_version_id, status, current_step_id, context, trigger_kind, trigger_source, is_dry_run) + VALUES ($1, 'running', $2, $3::jsonb, $4, $5::jsonb, $6) + RETURNING ${RUN_COLS}`, + [ + input.workflowVersionId, + input.entryStepId, + JSON.stringify(input.context), + input.triggerKind, + input.triggerSource === undefined ? null : JSON.stringify(input.triggerSource), + input.isDryRun ?? false, + ], + ); + return toRun(r.rows[0]!); + } + + async get(runId: string): Promise { + const r = await this.pool.query(`SELECT ${RUN_COLS} FROM conductor_runs WHERE id = $1`, [runId]); + return r.rows[0] ? toRun(r.rows[0]) : null; + } + + async listForVersion(workflowVersionId: string, limit = 50): Promise { + const safe = Math.min(Math.max(1, Math.trunc(limit)), 200); + const r = await this.pool.query( + `SELECT ${RUN_COLS} FROM conductor_runs WHERE workflow_version_id = $1 ORDER BY started_at DESC LIMIT $2`, + [workflowVersionId, safe], + ); + return r.rows.map(toRun); + } + + /** Persist a completed step (the resume checkpoint + audit record) and the run's + * advanced state in one transaction (FR-004 — durable before the next step begins). */ + async recordStepAndAdvance(input: { + runId: string; + seq: number; + stepId: string; + actor: JsonValue | null; + postconditionOutcome: string | null; + transitionTaken: string | null; + nextStepId: string | null; + context: JsonObject; + status: RunStatus; + }): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `INSERT INTO conductor_run_steps + (run_id, step_id, seq, actor, postcondition_outcome, transition_taken, ended_at) + VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`, + [ + input.runId, + input.stepId, + input.seq, + input.actor === null ? null : JSON.stringify(input.actor), + input.postconditionOutcome, + input.transitionTaken, + ], + ); + const ended = input.status === 'completed' || input.status === 'failed'; + await client.query( + `UPDATE conductor_runs + SET current_step_id = $2, context = $3::jsonb, status = $4, + ended_at = CASE WHEN $5 THEN now() ELSE ended_at END + WHERE id = $1`, + [input.runId, input.nextStepId, JSON.stringify(input.context), input.status, ended], + ); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + async stepsForRun(runId: string): Promise { + const r = await this.pool.query( + `SELECT ${STEP_COLS} FROM conductor_run_steps WHERE run_id = $1 ORDER BY seq ASC`, + [runId], + ); + return r.rows.map(toStep); + } +} diff --git a/middleware/src/conductor/stepEffects.ts b/middleware/src/conductor/stepEffects.ts new file mode 100644 index 00000000..4b941488 --- /dev/null +++ b/middleware/src/conductor/stepEffects.ts @@ -0,0 +1,40 @@ +import type { JsonObject, JsonValue, Step } from '@omadia/conductor-core'; + +export interface StepExecution { + /** the step's result, fed to the engine as `stepResult` for guard/postcondition evaluation. */ + result: JsonValue; + /** audit actor record persisted on the run step. */ + actor: JsonValue; +} + +/** + * The I/O side of step execution, injected into the run executor. Production wires real + * orchestrator turns / connector actions; preview (US8) and tests wire fakes. This is the + * seam that lets the deterministic engine stay pure while the executor performs side effects. + */ +export interface StepEffects { + runAgentStep(step: Step, context: JsonObject): Promise; + runActionStep(step: Step, context: JsonObject): Promise; +} + +/** + * First-slice default: deterministic, dependency-free execution that records the step and + * returns a synthetic result. Proves the wiring (API → engine → persistence → audit) end to + * end in the live kernel without an LLM or an installed connector. Real agent-turn and + * connector-action execution replace these two methods in a later phase. + */ +export class StubStepEffects implements StepEffects { + async runAgentStep(step: Step, _context: JsonObject): Promise { + return { + result: { stub: true, kind: 'agent', agentId: step.agentId ?? null }, + actor: { kind: 'agent', agentId: step.agentId ?? null }, + }; + } + + async runActionStep(step: Step, _context: JsonObject): Promise { + return { + result: { stub: true, kind: 'action', actionId: step.actionId ?? null }, + actor: { kind: 'action', actionId: step.actionId ?? null }, + }; + } +} diff --git a/middleware/src/conductor/workflowStore.ts b/middleware/src/conductor/workflowStore.ts new file mode 100644 index 00000000..e6d393e1 --- /dev/null +++ b/middleware/src/conductor/workflowStore.ts @@ -0,0 +1,161 @@ +import type { Pool } from 'pg'; +import type { WorkflowGraph } from '@omadia/conductor-core'; + +export interface ConductorWorkflow { + id: string; + slug: string; + name: string; + description: string | null; + status: 'enabled' | 'disabled'; + activeVersionId: string | null; +} + +export interface ConductorVersion { + id: string; + workflowId: string; + version: number; + graph: WorkflowGraph; +} + +interface WorkflowRow { + id: string; + slug: string; + name: string; + description: string | null; + status: 'enabled' | 'disabled'; + active_version_id: string | null; +} + +interface VersionRow { + id: string; + workflow_id: string; + version: number; + graph: WorkflowGraph; +} + +function toWorkflow(r: WorkflowRow): ConductorWorkflow { + return { + id: r.id, + slug: r.slug, + name: r.name, + description: r.description, + status: r.status, + activeVersionId: r.active_version_id, + }; +} + +/** + * Persistence for workflow headers + immutable versions. A publish snapshots the + * supplied graph into a new monotonic version and points `active_version_id` at it + * (FR-027 — runs already in flight keep their version). + */ +export class ConductorWorkflowStore { + constructor(private readonly pool: Pool) {} + + async getBySlug(slug: string): Promise { + const r = await this.pool.query( + 'SELECT id, slug, name, description, status, active_version_id FROM conductor_workflows WHERE slug = $1', + [slug], + ); + return r.rows[0] ? toWorkflow(r.rows[0]) : null; + } + + async list(): Promise { + const r = await this.pool.query( + 'SELECT id, slug, name, description, status, active_version_id FROM conductor_workflows ORDER BY created_at DESC', + ); + return r.rows.map(toWorkflow); + } + + async getVersion(versionId: string): Promise { + const r = await this.pool.query( + 'SELECT id, workflow_id, version, graph FROM conductor_workflow_versions WHERE id = $1', + [versionId], + ); + const row = r.rows[0]; + return row ? { id: row.id, workflowId: row.workflow_id, version: row.version, graph: row.graph } : null; + } + + /** + * Create a workflow (if the slug is new) and publish `graph` as the next version, + * setting it active. If the slug already exists, publishes a new version on it. + * Returns the workflow plus the newly published version. + */ + async createOrPublish(input: { + slug: string; + name: string; + description?: string | null; + graph: WorkflowGraph; + publishedBy?: string | null; + enable?: boolean; + }): Promise<{ workflow: ConductorWorkflow; version: ConductorVersion }> { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const existing = await client.query( + 'SELECT id, slug, name, description, status, active_version_id FROM conductor_workflows WHERE slug = $1 FOR UPDATE', + [input.slug], + ); + + let workflowId: string; + if (existing.rows[0]) { + workflowId = existing.rows[0].id; + await client.query( + `UPDATE conductor_workflows + SET name = $2, description = $3, updated_at = now() + WHERE id = $1`, + [workflowId, input.name, input.description ?? null], + ); + } else { + const created = await client.query<{ id: string }>( + `INSERT INTO conductor_workflows (slug, name, description, status) + VALUES ($1, $2, $3, $4) RETURNING id`, + [input.slug, input.name, input.description ?? null, input.enable ? 'enabled' : 'disabled'], + ); + workflowId = created.rows[0]!.id; + } + + const next = await client.query<{ next: number }>( + `SELECT COALESCE(MAX(version), 0) + 1 AS next + FROM conductor_workflow_versions WHERE workflow_id = $1`, + [workflowId], + ); + const versionNumber = next.rows[0]!.next; + + const versionRow = await client.query( + `INSERT INTO conductor_workflow_versions (workflow_id, version, graph, published_by) + VALUES ($1, $2, $3::jsonb, $4) + RETURNING id, workflow_id, version, graph`, + [workflowId, versionNumber, JSON.stringify(input.graph), input.publishedBy ?? null], + ); + const version = versionRow.rows[0]!; + + const wfRow = await client.query( + `UPDATE conductor_workflows + SET active_version_id = $2, updated_at = now() + WHERE id = $1 + RETURNING id, slug, name, description, status, active_version_id`, + [workflowId, version.id], + ); + + await client.query('COMMIT'); + return { + workflow: toWorkflow(wfRow.rows[0]!), + version: { id: version.id, workflowId: version.workflow_id, version: version.version, graph: version.graph }, + }; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + async setStatus(slug: string, status: 'enabled' | 'disabled'): Promise { + await this.pool.query( + 'UPDATE conductor_workflows SET status = $2, updated_at = now() WHERE slug = $1', + [slug, status], + ); + } +} diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 8c86d02b..9957023f 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -17,6 +17,7 @@ import { createMemoryPurgeRouter } from './routes/memoryPurge.js'; import { createMemoryBackendRouter } from './routes/memoryBackend.js'; import { createChatRouter } from './routes/chat.js'; import { createOperatorAgentsRouter } from './routes/operatorAgents.js'; +import { wireConductor } from './conductor/index.js'; import { createOperatorChannelsRouter } from './routes/operatorChannels.js'; import { createAgentBuilderRouter } from './routes/agentBuilder.js'; import { ScheduleWorker } from './scheduler/scheduleWorker.js'; @@ -2026,6 +2027,11 @@ async function main(): Promise { await runAuthMigrations(graphPool, (m) => console.log(m)); await runProfileStorageMigrations(graphPool, (m) => console.log(m)); await runProfileSnapshotMigrations(graphPool, (m) => console.log(m)); + + // Conductor (Spec 005) — deterministic workflow engine. Migrations + stores + + // run executor + operator API, all behind the graphPool (inert in-memory). + await wireConductor({ pool: graphPool, app, requireAuth, log: (m) => console.log(m) }); + console.log('[middleware] conductor wired at /api/v1/operator/conductors/* (auth-gated)'); const userStore = new UserStore(graphPool); const bootstrapResult = await runAuthBootstrap({ From 89571dc0d80582f209950caf6767f016b3c95e6b Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 08:24:10 +0200 Subject: [PATCH 05/19] build(conductor): add @omadia/conductor-core to package-lock (fixes npm ci) The Docker image build runs `npm ci`, which requires the lockfile to include the new conductor-core workspace package. Regenerated with node 22.22.3; minimal diff (one workspace entry, no other dep churn). --- middleware/package-lock.json | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 77e63aa6..b6738eae 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -1948,6 +1948,10 @@ "resolved": "packages/harness-channel-sdk", "link": true }, + "node_modules/@omadia/conductor-core": { + "resolved": "packages/conductor-core", + "link": true + }, "node_modules/@omadia/diagrams": { "resolved": "packages/harness-diagrams", "link": true @@ -8464,6 +8468,54 @@ "node": ">=14.17" } }, + "packages/conductor-core": { + "name": "@omadia/conductor-core", + "version": "0.1.0", + "dependencies": { + "ajv": "^8.20.0" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^5.9.0", + "vitest": "^4.1.8" + } + }, + "packages/conductor-core/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/conductor-core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "packages/conductor-core/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/harness-channel-sdk": { "name": "@omadia/channel-sdk", "version": "0.1.0", From c6702db2b275d5684b6569f33bb6272606706d14 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 08:29:32 +0200 Subject: [PATCH 06/19] fix(conductor): typecheck under express 5 + JsonValue (container build) - routes: coerce req.params (string | string[]) via paramStr(); cast req.body graph through unknown before WorkflowGraph (TS2352). - runExecutor: human-step actor uses primitive principalKind/principalRef fields instead of the Principal interface (not assignable to JsonValue). Full workspace build (node 22.22.3) clean; surfaces only under the stricter lockfile-pinned deps the Docker image uses. --- middleware/src/conductor/routes.ts | 16 +++++++++++----- middleware/src/conductor/runExecutor.ts | 6 +++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/middleware/src/conductor/routes.ts b/middleware/src/conductor/routes.ts index 0ffd84cf..63c0d397 100644 --- a/middleware/src/conductor/routes.ts +++ b/middleware/src/conductor/routes.ts @@ -21,6 +21,12 @@ function asObject(v: unknown): JsonObject { return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonObject) : {}; } +function paramStr(v: string | string[] | undefined): string { + if (typeof v === 'string') return v; + if (Array.isArray(v)) return v[0] ?? ''; + return ''; +} + export interface ConductorRouterDeps { workflowStore: ConductorWorkflowStore; runStore: ConductorRunStore; @@ -54,7 +60,7 @@ export function createConductorRouter(deps: ConductorRouterDeps): Router { res.status(400).json({ code: 'conductor.invalid_input', message: 'slug and name are required' }); return; } - const graph = body.graph as WorkflowGraph; + const graph = body.graph as unknown as WorkflowGraph; const result = validate(graph); if (!result.ok) { res.status(400).json({ code: 'conductor.invalid_graph', errors: result.errors }); @@ -85,7 +91,7 @@ export function createConductorRouter(deps: ConductorRouterDeps): Router { return; } try { - await deps.workflowStore.setStatus(req.params.slug ?? '', status); + await deps.workflowStore.setStatus(paramStr(req.params.slug), status); res.status(204).end(); } catch (err) { res.status(500).json({ code: 'conductor.status_failed', message: errMsg(err) }); @@ -94,7 +100,7 @@ export function createConductorRouter(deps: ConductorRouterDeps): Router { // Start a manual run; returns the (synchronously driven) run plus its step trace. router.post('/:slug/runs', async (req: Request, res: Response): Promise => { - const slug = req.params.slug ?? ''; + const slug = paramStr(req.params.slug); const payload = asObject(asObject(req.body).payload); try { const run = await deps.executor.startRun({ slug, payload, triggerKind: 'manual' }); @@ -116,7 +122,7 @@ export function createConductorRouter(deps: ConductorRouterDeps): Router { // List runs for a workflow's active version. router.get('/:slug/runs', async (req: Request, res: Response): Promise => { try { - const wf = await deps.workflowStore.getBySlug(req.params.slug ?? ''); + const wf = await deps.workflowStore.getBySlug(paramStr(req.params.slug)); if (!wf || !wf.activeVersionId) { res.status(404).json({ code: 'conductor.not_found', message: 'workflow or active version missing' }); return; @@ -130,7 +136,7 @@ export function createConductorRouter(deps: ConductorRouterDeps): Router { // Single run with its ordered step trace (audit / US9 surface). router.get('/:slug/runs/:runId', async (req: Request, res: Response): Promise => { try { - const run = await deps.runStore.get(req.params.runId ?? ''); + const run = await deps.runStore.get(paramStr(req.params.runId)); if (!run) { res.status(404).json({ code: 'conductor.not_found', message: 'run not found' }); return; diff --git a/middleware/src/conductor/runExecutor.ts b/middleware/src/conductor/runExecutor.ts index 96002146..fda17c9e 100644 --- a/middleware/src/conductor/runExecutor.ts +++ b/middleware/src/conductor/runExecutor.ts @@ -91,7 +91,11 @@ export class ConductorRunExecutor { if (step.kind === 'human') { await this.runStore.recordStepAndAdvance({ runId: run.id, seq, stepId, - actor: { kind: 'human', principal: step.human?.principal ?? null }, + actor: { + kind: 'human', + principalKind: step.human?.principal.kind ?? null, + principalRef: step.human?.principal.ref ?? null, + }, postconditionOutcome: 'n/a', transitionTaken: null, nextStepId: stepId, context, status: 'waiting', }); From 7868a5bb103ba1272ea6722a26f1dc0c7eb5c651 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 08:34:57 +0200 Subject: [PATCH 07/19] build(conductor): copy conductor migrations into the runtime image tsc does not emit .sql; runConductorMigrations scans dist/conductor/migrations at boot. Add the explicit COPY (same pattern as auth/routines/profile migrations) so the production image ships 0001_conductor.sql. Fixes ENOENT crash-loop on boot. --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 64841105..3d55ef26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,6 +89,9 @@ COPY middleware/src/auth/migrations ./dist/auth/migrations COPY middleware/src/profileStorage/migrations ./dist/profileStorage/migrations # Profile-snapshots migrations — same pattern (palaia-phase profile snapshots). COPY middleware/src/profileSnapshots/migrations ./dist/profileSnapshots/migrations +# Conductor migrations (Spec 005) — tsc skips .sql, so copy them next to the +# compiled migrator (runConductorMigrations scans dist/conductor/migrations). +COPY middleware/src/conductor/migrations ./dist/conductor/migrations # Multi-orchestrator runtime migrations — runMultiOrchestratorMigrations # (in @omadia/orchestrator) scans this dir. Top-level location matches the # spec convention (specs/001-multi-orchestrator-runtime/data-model.md); the From a4055e65ea0797a530a873887db039346293a5f7 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 17 Jun 2026 09:07:22 +0200 Subject: [PATCH 08/19] feat(conductor): real Agent-turn execution + operator UI (no stubs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine semantics corrected: an 'agent' step targets an **Agent (orchestrator instance)** resolved by slug from the multi-orchestrator registry — not a stub, not a bare model. Backend: - conductor-core: add step.prompt (agent message, {{ctx.x}} interpolation) and step.input (action input); agentId doc'd as the Agent slug. - RealStepEffects: agent step runs a genuine turn via registry.get(slug).built.bundle.agent.chat(...) (the schedule-worker path), answer.text becomes the step result; action step invokes the real connector tool via dynamicAgentRuntime.invokeAgentTool. StepMeta threads runId for session scoping. wireConductor now wires RealStepEffects (stub kept for tests). Operator UI (the missing entry point): - web-ui/app/conductor/page.tsx: list/publish workflows (graph validated by the engine before save), start runs, render the durable step trace + result context. - Nav: top-level 'Conductor' link; api.ts conductor client; en/de i18n (parity OK). Full middleware build + web-ui typecheck clean; conductor-core 46 tests green. --- .../schema/conductor-graph.schema.json | 2 + .../packages/conductor-core/src/schema.ts | 2 + .../packages/conductor-core/src/types.ts | 11 +- middleware/src/conductor/index.ts | 16 +- middleware/src/conductor/realStepEffects.ts | 103 +++++++ middleware/src/conductor/runExecutor.ts | 4 +- middleware/src/conductor/stepEffects.ts | 18 +- middleware/src/index.ts | 11 +- web-ui/app/_components/Nav.tsx | 1 + web-ui/app/_lib/api.ts | 68 ++++ web-ui/app/conductor/page.tsx | 291 ++++++++++++++++++ web-ui/messages/de.json | 31 ++ web-ui/messages/en.json | 31 ++ 13 files changed, 575 insertions(+), 14 deletions(-) create mode 100644 middleware/src/conductor/realStepEffects.ts create mode 100644 web-ui/app/conductor/page.tsx diff --git a/middleware/packages/conductor-core/schema/conductor-graph.schema.json b/middleware/packages/conductor-core/schema/conductor-graph.schema.json index d6e005df..6d468e67 100644 --- a/middleware/packages/conductor-core/schema/conductor-graph.schema.json +++ b/middleware/packages/conductor-core/schema/conductor-graph.schema.json @@ -21,6 +21,8 @@ "kind": { "enum": ["agent", "action", "human"] }, "agentId": { "type": "string" }, "actionId": { "type": "string" }, + "prompt": { "type": "string" }, + "input": { "type": "object" }, "human": { "$ref": "#/$defs/human" }, "postcondition": { "$ref": "#/$defs/predicate" }, "fallbackTransitionId": { "type": "string" }, diff --git a/middleware/packages/conductor-core/src/schema.ts b/middleware/packages/conductor-core/src/schema.ts index 37946919..19745911 100644 --- a/middleware/packages/conductor-core/src/schema.ts +++ b/middleware/packages/conductor-core/src/schema.ts @@ -29,6 +29,8 @@ export const conductorGraphSchema = { kind: { enum: ['agent', 'action', 'human'] }, agentId: { type: 'string' }, actionId: { type: 'string' }, + prompt: { type: 'string' }, + input: { type: 'object' }, human: { $ref: '#/$defs/human' }, postcondition: { $ref: '#/$defs/predicate' }, fallbackTransitionId: { type: 'string' }, diff --git a/middleware/packages/conductor-core/src/types.ts b/middleware/packages/conductor-core/src/types.ts index 32fe09da..0949076c 100644 --- a/middleware/packages/conductor-core/src/types.ts +++ b/middleware/packages/conductor-core/src/types.ts @@ -118,10 +118,17 @@ export interface CanvasPosition { export interface Step { id: string; kind: StepKind; - /** required when kind='agent'. */ + /** required when kind='agent'. The **slug of an Agent (orchestrator instance)** in the + * multi-orchestrator registry (e.g. "fallback") — NOT a sub-agent or a bare model. The + * Conductor resolves it live via the registry and runs a real turn on that orchestrator. */ agentId?: string; - /** required when kind='action'. */ + /** required when kind='action'. The deterministic-action / connector tool id to invoke. */ actionId?: string; + /** kind='agent': the message sent to the orchestrator turn. Supports `{{ctx.path}}` / + * `{{steps.stepId.field}}` interpolation against the run context. */ + prompt?: string; + /** kind='action': the input object passed to the connector action. */ + input?: JsonObject; /** required when kind='human'. */ human?: HumanStepConfig; /** the step's exit postcondition; absent ≡ always met. */ diff --git a/middleware/src/conductor/index.ts b/middleware/src/conductor/index.ts index 250a0c76..f5ddb17d 100644 --- a/middleware/src/conductor/index.ts +++ b/middleware/src/conductor/index.ts @@ -1,11 +1,12 @@ import type { Express, RequestHandler } from 'express'; import type { Pool } from 'pg'; +import type { OrchestratorRegistry } from '@omadia/orchestrator'; import { runConductorMigrations } from './migrator.js'; import { ConductorWorkflowStore } from './workflowStore.js'; import { ConductorRunStore } from './runStore.js'; import { ConductorRunExecutor } from './runExecutor.js'; -import { StubStepEffects } from './stepEffects.js'; +import { RealStepEffects } from './realStepEffects.js'; import { createConductorRouter } from './routes.js'; export { runConductorMigrations } from './migrator.js'; @@ -13,7 +14,8 @@ export { ConductorWorkflowStore } from './workflowStore.js'; export { ConductorRunStore } from './runStore.js'; export { ConductorRunExecutor } from './runExecutor.js'; export { StubStepEffects } from './stepEffects.js'; -export type { StepEffects, StepExecution } from './stepEffects.js'; +export { RealStepEffects } from './realStepEffects.js'; +export type { StepEffects, StepExecution, StepMeta } from './stepEffects.js'; export { createConductorRouter } from './routes.js'; export interface ConductorWiring { @@ -32,6 +34,10 @@ export async function wireConductor(deps: { pool: Pool; app: Express; requireAuth: RequestHandler; + /** resolves an Agent (orchestrator instance) by slug for agent steps. */ + getRegistry: () => OrchestratorRegistry | undefined; + /** invokes a deterministic-action / connector tool by id for action steps. */ + invokeAction?: (toolId: string, input: unknown) => Promise; log?: (msg: string) => void; }): Promise { const log = deps.log ?? (() => undefined); @@ -42,7 +48,11 @@ export async function wireConductor(deps: { const executor = new ConductorRunExecutor({ workflowStore, runStore, - effects: new StubStepEffects(), + effects: new RealStepEffects({ + getRegistry: deps.getRegistry, + ...(deps.invokeAction ? { invokeAction: deps.invokeAction } : {}), + log, + }), log, }); diff --git a/middleware/src/conductor/realStepEffects.ts b/middleware/src/conductor/realStepEffects.ts new file mode 100644 index 00000000..d0926c76 --- /dev/null +++ b/middleware/src/conductor/realStepEffects.ts @@ -0,0 +1,103 @@ +import type { OrchestratorRegistry } from '@omadia/orchestrator'; +import type { JsonObject, JsonValue, Step } from '@omadia/conductor-core'; + +import type { StepEffects, StepExecution, StepMeta } from './stepEffects.js'; + +/** Resolve a dot-path over a plain object root (for prompt interpolation). */ +function resolve(root: JsonObject, path: string): JsonValue | undefined { + let cur: JsonValue | undefined = root; + for (const seg of path.split('.')) { + if (cur === null || cur === undefined || typeof cur !== 'object' || Array.isArray(cur)) return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +/** Replace `{{ctx.path}}` / `{{steps.id.field}}` tokens in a prompt template. */ +function renderTemplate(tpl: string, root: JsonObject): string { + return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => { + const v = resolve(root, path); + if (v === undefined || v === null) return ''; + return typeof v === 'string' ? v : JSON.stringify(v); + }); +} + +function asObject(v: JsonValue | undefined): JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {}; +} + +export interface RealStepEffectsDeps { + /** the multi-orchestrator registry — resolves an Agent (orchestrator) by slug. */ + getRegistry: () => OrchestratorRegistry | undefined; + /** invoke a deterministic-action / connector tool by id (dynamicAgentRuntime). */ + invokeAction?: (toolId: string, input: unknown) => Promise; + log?: (msg: string) => void; +} + +/** + * Real step execution — no stubs. + * - agent step: resolves `step.agentId` (an Agent / orchestrator-instance slug) in the + * multi-orchestrator registry and runs a genuine turn via `bundle.agent.chat(...)`, + * the same headless entrypoint the schedule worker uses. The Agent's prose answer + * becomes the step result (`{ text }`). + * - action step: invokes the named connector/deterministic tool and captures its output. + * + * This is the seam that distinguishes an *Agent* (an independent orchestrator instance that + * runs a full tool/sub-agent/memory loop) from a sub-agent or a bare model call. + */ +export class RealStepEffects implements StepEffects { + constructor(private readonly deps: RealStepEffectsDeps) {} + + async runAgentStep(step: Step, context: JsonObject, meta: StepMeta): Promise { + const slug = step.agentId; + if (!slug) throw new Error(`agent step '${step.id}' has no agentId (Agent slug)`); + + const registry = this.deps.getRegistry(); + if (!registry) throw new Error('orchestrator registry is unavailable (no graphPool / registry not built)'); + + const entry = registry.get(slug); + if (!entry) { + throw new Error(`Agent '${slug}' is not active in the orchestrator registry`); + } + + const root: JsonObject = { ctx: context, steps: asObject(context.steps) }; + const userMessage = step.prompt + ? renderTemplate(step.prompt, root) + : `Conductor workflow step "${step.id}". Run your configured task. Run context: ${JSON.stringify(context)}`; + + this.deps.log?.(`[conductor] agent step '${step.id}' → Agent '${slug}' (run ${meta.runId})`); + const answer = await entry.built.bundle.agent.chat({ + userMessage, + sessionScope: `conductor:${meta.runId}:${step.id}`, + }); + + return { + result: { text: answer.text }, + actor: { kind: 'agent', agentSlug: slug }, + }; + } + + async runActionStep(step: Step, _context: JsonObject, meta: StepMeta): Promise { + const toolId = step.actionId; + if (!toolId) throw new Error(`action step '${step.id}' has no actionId`); + if (!this.deps.invokeAction) throw new Error('action execution is not wired (no deterministic-action invoker)'); + + const input = step.input ?? {}; + this.deps.log?.(`[conductor] action step '${step.id}' → tool '${toolId}' (run ${meta.runId})`); + const out = await this.deps.invokeAction(toolId, input); + if (out === undefined) { + throw new Error(`action '${toolId}' is not registered or returned nothing`); + } + + let data: JsonValue; + try { + data = JSON.parse(out) as JsonValue; + } catch { + data = out; + } + return { + result: { text: out, data }, + actor: { kind: 'action', actionId: toolId }, + }; + } +} diff --git a/middleware/src/conductor/runExecutor.ts b/middleware/src/conductor/runExecutor.ts index fda17c9e..79b1fc77 100644 --- a/middleware/src/conductor/runExecutor.ts +++ b/middleware/src/conductor/runExecutor.ts @@ -106,8 +106,8 @@ export class ConductorRunExecutor { let exec; try { exec = step.kind === 'agent' - ? await this.effects.runAgentStep(step, context) - : await this.effects.runActionStep(step, context); + ? await this.effects.runAgentStep(step, context, { runId: run.id }) + : await this.effects.runActionStep(step, context, { runId: run.id }); } catch (err) { this.log(`[conductor] run ${run.id} step '${stepId}' threw: ${err instanceof Error ? err.message : String(err)}`); await this.runStore.recordStepAndAdvance({ diff --git a/middleware/src/conductor/stepEffects.ts b/middleware/src/conductor/stepEffects.ts index 4b941488..1a31afcc 100644 --- a/middleware/src/conductor/stepEffects.ts +++ b/middleware/src/conductor/stepEffects.ts @@ -7,14 +7,20 @@ export interface StepExecution { actor: JsonValue; } +/** Per-call context the executor passes to effects (for session bucketing / tracing). */ +export interface StepMeta { + runId: string; +} + /** * The I/O side of step execution, injected into the run executor. Production wires real - * orchestrator turns / connector actions; preview (US8) and tests wire fakes. This is the - * seam that lets the deterministic engine stay pure while the executor performs side effects. + * orchestrator turns / connector actions (RealStepEffects); preview (US8) and tests wire fakes. + * This is the seam that lets the deterministic engine stay pure while the executor performs + * side effects. */ export interface StepEffects { - runAgentStep(step: Step, context: JsonObject): Promise; - runActionStep(step: Step, context: JsonObject): Promise; + runAgentStep(step: Step, context: JsonObject, meta: StepMeta): Promise; + runActionStep(step: Step, context: JsonObject, meta: StepMeta): Promise; } /** @@ -24,14 +30,14 @@ export interface StepEffects { * connector-action execution replace these two methods in a later phase. */ export class StubStepEffects implements StepEffects { - async runAgentStep(step: Step, _context: JsonObject): Promise { + async runAgentStep(step: Step, _context: JsonObject, _meta: StepMeta): Promise { return { result: { stub: true, kind: 'agent', agentId: step.agentId ?? null }, actor: { kind: 'agent', agentId: step.agentId ?? null }, }; } - async runActionStep(step: Step, _context: JsonObject): Promise { + async runActionStep(step: Step, _context: JsonObject, _meta: StepMeta): Promise { return { result: { stub: true, kind: 'action', actionId: step.actionId ?? null }, actor: { kind: 'action', actionId: step.actionId ?? null }, diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 9957023f..4bd9807d 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -2030,7 +2030,16 @@ async function main(): Promise { // Conductor (Spec 005) — deterministic workflow engine. Migrations + stores + // run executor + operator API, all behind the graphPool (inert in-memory). - await wireConductor({ pool: graphPool, app, requireAuth, log: (m) => console.log(m) }); + // Agent steps run real turns on Agents (orchestrator instances) resolved by slug + // from the registry; action steps invoke real connector tools. + await wireConductor({ + pool: graphPool, + app, + requireAuth, + getRegistry, + invokeAction: (toolId, input) => dynamicAgentRuntime.invokeAgentTool(toolId, input), + log: (m) => console.log(m), + }); console.log('[middleware] conductor wired at /api/v1/operator/conductors/* (auth-gated)'); const userStore = new UserStore(graphPool); diff --git a/web-ui/app/_components/Nav.tsx b/web-ui/app/_components/Nav.tsx index 9efa5cf6..284f69f1 100644 --- a/web-ui/app/_components/Nav.tsx +++ b/web-ui/app/_components/Nav.tsx @@ -46,6 +46,7 @@ const NAV: readonly NavItem[] = [ ], }, { kind: 'link', href: '/routines', key: 'routines' }, + { kind: 'link', href: '/conductor', key: 'conductor' }, { kind: 'cluster', key: 'adminCluster', diff --git a/web-ui/app/_lib/api.ts b/web-ui/app/_lib/api.ts index f5ca303e..343f5e2d 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -3516,3 +3516,71 @@ export async function installSelfExtensionProposal( proposal: resp.proposal, }; } + +// ───────────────────────────────────────────────────────────────────────── +// Conductor (Spec 005) — deterministic workflow engine operator API. +// Backed by the middleware /api/v1/operator/conductors router (cookie auth). +// ───────────────────────────────────────────────────────────────────────── + +export interface ConductorWorkflow { + id: string; + slug: string; + name: string; + description: string | null; + status: 'enabled' | 'disabled'; + activeVersionId: string | null; +} + +export interface ConductorRun { + id: string; + workflowVersionId: string; + status: 'running' | 'waiting' | 'completed' | 'failed'; + currentStepId: string | null; + context: unknown; + triggerKind: string; + startedAt: string; + endedAt: string | null; +} + +export interface ConductorRunStep { + id: string; + runId: string; + stepId: string; + seq: number; + actor: unknown; + postconditionOutcome: string | null; + transitionTaken: string | null; +} + +export interface ConductorRunResult { + run: ConductorRun; + steps: ConductorRunStep[]; +} + +const CONDUCTOR_BASE = '/v1/operator/conductors'; + +export async function listConductorWorkflows(): Promise<{ workflows: ConductorWorkflow[] }> { + return getJson(CONDUCTOR_BASE); +} + +export async function publishConductorWorkflow(body: { + slug: string; + name: string; + description?: string; + graph: unknown; + enable?: boolean; +}): Promise<{ workflow: ConductorWorkflow; version: { id: string; version: number } }> { + return postJson(CONDUCTOR_BASE, body); +} + +export async function startConductorRun(slug: string, payload: unknown): Promise { + return postJson(`${CONDUCTOR_BASE}/${encodeURIComponent(slug)}/runs`, { payload }); +} + +export async function listConductorRuns(slug: string): Promise<{ runs: ConductorRun[] }> { + return getJson(`${CONDUCTOR_BASE}/${encodeURIComponent(slug)}/runs`); +} + +export async function getConductorRun(slug: string, runId: string): Promise { + return getJson(`${CONDUCTOR_BASE}/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}`); +} diff --git a/web-ui/app/conductor/page.tsx b/web-ui/app/conductor/page.tsx new file mode 100644 index 00000000..58316e2b --- /dev/null +++ b/web-ui/app/conductor/page.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { useTranslations } from 'next-intl'; + +import { Button } from '@/app/_components/ui/Button'; +import { + ApiError, + listConductorWorkflows, + publishConductorWorkflow, + startConductorRun, + type ConductorRunResult, + type ConductorWorkflow, +} from '@/app/_lib/api'; + +const EXAMPLE_GRAPH = `{ + "entryStepId": "greet", + "steps": [ + { + "id": "greet", + "kind": "agent", + "agentId": "fallback", + "prompt": "Greet the team in one short, friendly sentence." + } + ], + "transitions": [], + "triggers": [{ "id": "tr", "kind": "manual" }] +}`; + +interface ValidationError { + code: string; + message: string; + nodeIds?: string[]; +} + +export default function ConductorPage(): React.JSX.Element { + const t = useTranslations('conductor'); + + const [workflows, setWorkflows] = useState([]); + const [loadError, setLoadError] = useState(null); + + const [slug, setSlug] = useState(''); + const [name, setName] = useState(''); + const [graph, setGraph] = useState(EXAMPLE_GRAPH); + const [enable, setEnable] = useState(true); + const [publishing, setPublishing] = useState(false); + const [publishError, setPublishError] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + + const [runningSlug, setRunningSlug] = useState(null); + const [runResult, setRunResult] = useState(null); + const [runError, setRunError] = useState(null); + + const reload = useCallback(async () => { + try { + setLoadError(null); + const res = await listConductorWorkflows(); + setWorkflows(res.workflows); + } catch (err) { + setLoadError(err instanceof ApiError ? err.message : String(err)); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const handlePublish = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setPublishing(true); + setPublishError(null); + setValidationErrors([]); + let parsed: unknown; + try { + parsed = JSON.parse(graph); + } catch { + setPublishError('Graph is not valid JSON'); + setPublishing(false); + return; + } + try { + await publishConductorWorkflow({ slug, name, graph: parsed, enable }); + setSlug(''); + setName(''); + await reload(); + } catch (err) { + if (err instanceof ApiError) { + try { + const body = JSON.parse(err.body) as { errors?: ValidationError[] }; + if (Array.isArray(body.errors)) setValidationErrors(body.errors); + } catch { + /* body not JSON */ + } + setPublishError(err.message); + } else { + setPublishError(String(err)); + } + } finally { + setPublishing(false); + } + }, + [graph, slug, name, enable, reload], + ); + + const handleRun = useCallback( + async (wfSlug: string) => { + setRunningSlug(wfSlug); + setRunError(null); + setRunResult(null); + try { + const res = await startConductorRun(wfSlug, {}); + setRunResult(res); + await reload(); + } catch (err) { + setRunError(err instanceof ApiError ? err.message : String(err)); + } finally { + setRunningSlug(null); + } + }, + [reload], + ); + + const card = 'rounded-lg border border-[color:var(--border)] bg-[color:var(--card)]/40 p-4'; + const input = + 'w-full rounded-md border border-[color:var(--border)] bg-transparent px-3 py-2 text-[14px] text-[color:var(--fg-strong)]'; + + return ( +
+
+

+ {t('title')} +

+

+ {t('intro')} +

+
+ + {/* Workflows list */} +
+
+

+ {t('workflowsHeading')} +

+ +
+ {loadError &&

{loadError}

} + {workflows.length === 0 ? ( +

{t('noWorkflows')}

+ ) : ( +
    + {workflows.map((wf) => ( +
  • +
    +
    {wf.name}
    +
    + {wf.slug} · {t('statusLabel')}: {wf.status} +
    +
    + +
  • + ))} +
+ )} +
+ + {/* Run result */} + {(runResult || runError) && ( +
+

+ {t('lastRunHeading')} +

+ {runError &&

{runError}

} + {runResult && ( +
+
+ {t('statusLabel')}: {runResult.run.status} +
+

+ {t('stepPathHeading')} +

+ + + + + + + + + + + {runResult.steps.map((s) => ( + + + + + + + ))} + +
{t('colSeq')}{t('colStep')}{t('colPostcondition')}{t('colTransition')}
{s.seq}{s.stepId}{s.postconditionOutcome ?? '—'}{s.transitionTaken ?? '—'}
+

+ {t('resultContext')} +

+
+                {JSON.stringify(runResult.run.context, null, 2)}
+              
+
+ )} +
+ )} + + {/* Publish form */} +
+

+ {t('publishHeading')} +

+

{t('publishHint')}

+
+
+ + +
+