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 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", 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..6d468e67 --- /dev/null +++ b/middleware/packages/conductor-core/schema/conductor-graph.schema.json @@ -0,0 +1,95 @@ +{ + "$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" }, + "prompt": { "type": "string" }, + "input": { "type": "object" }, + "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..19745911 --- /dev/null +++ b/middleware/packages/conductor-core/src/schema.ts @@ -0,0 +1,119 @@ +// 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' }, + prompt: { type: 'string' }, + input: { type: 'object' }, + 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..0949076c --- /dev/null +++ b/middleware/packages/conductor-core/src/types.ts @@ -0,0 +1,256 @@ +// 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'. 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'. 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. */ + 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"] +} diff --git a/middleware/src/conductor/awaitStore.ts b/middleware/src/conductor/awaitStore.ts new file mode 100644 index 00000000..45f383ad --- /dev/null +++ b/middleware/src/conductor/awaitStore.ts @@ -0,0 +1,132 @@ +import type { Pool } from 'pg'; +import type { JsonValue } from '@omadia/conductor-core'; + +export type AwaitStatus = 'waiting' | 'resolved' | 'timed_out' | 'cancelled'; + +export interface ConductorAwait { + id: string; + runId: string; + stepId: string; + principalKind: 'user' | 'role'; + principalRef: string; + channelType: string; + message: string; + quorum: 'any' | 'all'; + reminderIntervalMs: number | null; + deadlineAt: Date | null; + fallbackTransitionId: string | null; + status: AwaitStatus; + createdAt: Date; +} + +interface AwaitRow { + id: string; + run_id: string; + step_id: string; + principal_kind: 'user' | 'role'; + principal_ref: string; + channel_type: string; + message: string; + quorum: 'any' | 'all'; + reminder_interval_ms: string | null; + deadline_at: Date | null; + fallback_transition_id: string | null; + status: AwaitStatus; + created_at: Date; +} + +const COLS = `id, run_id, step_id, principal_kind, principal_ref, channel_type, message, quorum, + reminder_interval_ms, deadline_at, fallback_transition_id, status, created_at`; + +function toAwait(r: AwaitRow): ConductorAwait { + return { + id: r.id, + runId: r.run_id, + stepId: r.step_id, + principalKind: r.principal_kind, + principalRef: r.principal_ref, + channelType: r.channel_type, + message: r.message, + quorum: r.quorum, + reminderIntervalMs: r.reminder_interval_ms === null ? null : Number(r.reminder_interval_ms), + deadlineAt: r.deadline_at, + fallbackTransitionId: r.fallback_transition_id, + status: r.status, + createdAt: r.created_at, + }; +} + +/** Durable pending human action — the net-new substrate (ask_user_choice was in-memory). */ +export class ConductorAwaitStore { + constructor(private readonly pool: Pool) {} + + async create(input: { + runId: string; + stepId: string; + principalKind: 'user' | 'role'; + principalRef: string; + channelType: string; + message: string; + quorum: 'any' | 'all'; + reminderIntervalMs: number | null; + deadlineAt: Date | null; + fallbackTransitionId: string | null; + }): Promise { + const r = await this.pool.query( + `INSERT INTO conductor_awaits + (run_id, step_id, principal_kind, principal_ref, channel_type, message, quorum, + reminder_interval_ms, deadline_at, fallback_transition_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING ${COLS}`, + [ + input.runId, input.stepId, input.principalKind, input.principalRef, input.channelType, + input.message, input.quorum, input.reminderIntervalMs, input.deadlineAt, input.fallbackTransitionId, + ], + ); + return toAwait(r.rows[0]!); + } + + async get(awaitId: string): Promise { + const r = await this.pool.query(`SELECT ${COLS} FROM conductor_awaits WHERE id = $1`, [awaitId]); + return r.rows[0] ? toAwait(r.rows[0]) : null; + } + + /** All waiting awaits (the operator inbox). */ + async listWaiting(limit = 100): Promise { + const r = await this.pool.query( + `SELECT ${COLS} FROM conductor_awaits WHERE status = 'waiting' ORDER BY created_at ASC LIMIT $1`, + [Math.min(Math.max(1, limit), 500)], + ); + return r.rows.map(toAwait); + } + + /** Waiting awaits whose deadline has passed (for the deadline worker). */ + async listDue(now: Date): Promise { + const r = await this.pool.query( + `SELECT ${COLS} FROM conductor_awaits + WHERE status = 'waiting' AND deadline_at IS NOT NULL AND deadline_at <= $1 + ORDER BY deadline_at ASC LIMIT 100`, + [now], + ); + return r.rows.map(toAwait); + } + + async recordResponse(awaitId: string, responderId: string, response: JsonValue): Promise { + await this.pool.query( + `INSERT INTO conductor_await_responses (await_id, responder_id, response) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (await_id, responder_id) DO UPDATE SET response = EXCLUDED.response, responded_at = now()`, + [awaitId, responderId, JSON.stringify(response)], + ); + } + + /** Atomic transition waiting → resolved/timed_out (FR-018). Returns true iff this call won. */ + async close(awaitId: string, status: 'resolved' | 'timed_out'): Promise { + const r = await this.pool.query( + `UPDATE conductor_awaits SET status = $2, resolved_at = now() + WHERE id = $1 AND status = 'waiting'`, + [awaitId, status], + ); + return (r.rowCount ?? 0) > 0; + } +} diff --git a/middleware/src/conductor/awaitWorker.ts b/middleware/src/conductor/awaitWorker.ts new file mode 100644 index 00000000..4b242923 --- /dev/null +++ b/middleware/src/conductor/awaitWorker.ts @@ -0,0 +1,56 @@ +import type { ConductorAwaitStore } from './awaitStore.js'; +import type { ConductorRunExecutor } from './runExecutor.js'; + +/** + * Polls `conductor_awaits` on a minute tick and expires any waiting await whose deadline has + * passed — firing the human step's in-graph fallback transition (FR-017). Reminders (which need + * proactive channel notification) are a later addition; this worker handles the deadline path. + * graphPool-gated by the caller (only started when Postgres is available). + */ +export class ConductorAwaitWorker { + private timer: ReturnType | undefined; + + constructor( + private readonly deps: { + awaitStore: ConductorAwaitStore; + executor: ConductorRunExecutor; + intervalMs?: number; + now?: () => Date; + log?: (msg: string) => void; + }, + ) {} + + start(): void { + if (this.timer) return; + const interval = this.deps.intervalMs ?? 60_000; + void this.tick(); + this.timer = setInterval(() => void this.tick(), interval); + if (typeof this.timer.unref === 'function') this.timer.unref(); + this.deps.log?.('[conductor] await worker started (deadline poll)'); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async tick(): Promise { + const now = (this.deps.now ?? (() => new Date()))(); + let due; + try { + due = await this.deps.awaitStore.listDue(now); + } catch (err) { + this.deps.log?.(`[conductor] await worker list failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + for (const aw of due) { + try { + await this.deps.executor.expireAwait(aw.id); + } catch (err) { + this.deps.log?.(`[conductor] await worker expire ${aw.id} failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } +} diff --git a/middleware/src/conductor/eventRouter.ts b/middleware/src/conductor/eventRouter.ts new file mode 100644 index 00000000..10dcc7e6 --- /dev/null +++ b/middleware/src/conductor/eventRouter.ts @@ -0,0 +1,68 @@ +import { evaluatePredicate } from '@omadia/conductor-core'; +import type { JsonObject, Predicate } from '@omadia/conductor-core'; + +import type { ConductorWorkflowStore } from './workflowStore.js'; +import type { ConductorRunExecutor } from './runExecutor.js'; + +export interface EmitResult { + eventId: string; + startedRuns: Array<{ workflowSlug: string; runId: string }>; + matchedWorkflows: number; +} + +/** + * Routes a domain event to the workflows that subscribe to it. A workflow subscribes via an + * `event` trigger in its active version graph (an `eventId` plus an optional payload `filter` + * predicate). A matching emit starts a run with the validated payload as initial context (US4 / + * FR-013). This is the kernel side of the Conductor Surface; a connector calls it (today via the + * operator emit route; `ctx.events.emit` for plugins is a follow-up). + */ +export class ConductorEventRouter { + constructor( + private readonly deps: { + workflowStore: ConductorWorkflowStore; + executor: ConductorRunExecutor; + log?: (msg: string) => void; + }, + ) {} + + async emit(eventId: string, payload: JsonObject, sourcePluginId?: string): Promise { + const workflows = await this.deps.workflowStore.list(); + const started: Array<{ workflowSlug: string; runId: string }> = []; + let matched = 0; + + for (const wf of workflows) { + if (wf.status !== 'enabled' || !wf.activeVersionId) continue; + const version = await this.deps.workflowStore.getVersion(wf.activeVersionId); + if (!version) continue; + + const triggers = version.graph.triggers ?? []; + const match = triggers.find( + (tr) => tr.kind === 'event' && tr.eventId === eventId && this.filterMatches(tr.filter, payload), + ); + if (!match) continue; + matched += 1; + + try { + const run = await this.deps.executor.startRun({ + slug: wf.slug, + payload, + triggerKind: 'event', + triggerSource: { eventId, ...(sourcePluginId ? { sourcePluginId } : {}) }, + }); + started.push({ workflowSlug: wf.slug, runId: run.id }); + this.deps.log?.(`[conductor] event '${eventId}' started run ${run.id} on '${wf.slug}'`); + } catch (err) { + this.deps.log?.(`[conductor] event '${eventId}' failed to start '${wf.slug}': ${err instanceof Error ? err.message : String(err)}`); + } + } + + return { eventId, startedRuns: started, matchedWorkflows: matched }; + } + + /** An absent filter always matches; otherwise the predicate is evaluated against the payload. */ + private filterMatches(filter: Predicate | undefined, payload: JsonObject): boolean { + if (!filter) return true; + return evaluatePredicate(filter, { ctx: payload, stepResult: payload }); + } +} diff --git a/middleware/src/conductor/index.ts b/middleware/src/conductor/index.ts new file mode 100644 index 00000000..396bf791 --- /dev/null +++ b/middleware/src/conductor/index.ts @@ -0,0 +1,88 @@ +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 { ConductorAwaitStore } from './awaitStore.js'; +import { ConductorRoleStore } from './roleStore.js'; +import { ConductorRunExecutor } from './runExecutor.js'; +import { ConductorAwaitWorker } from './awaitWorker.js'; +import { ConductorEventRouter } from './eventRouter.js'; +import { RealStepEffects } from './realStepEffects.js'; +import { createConductorRouter } from './routes.js'; + +export { runConductorMigrations } from './migrator.js'; +export { ConductorWorkflowStore } from './workflowStore.js'; +export { ConductorRunStore } from './runStore.js'; +export { ConductorAwaitStore } from './awaitStore.js'; +export { ConductorRoleStore } from './roleStore.js'; +export { ConductorRunExecutor } from './runExecutor.js'; +export { ConductorAwaitWorker } from './awaitWorker.js'; +export { ConductorEventRouter } from './eventRouter.js'; +export { StubStepEffects } from './stepEffects.js'; +export { RealStepEffects } from './realStepEffects.js'; +export type { StepEffects, StepExecution, StepMeta } from './stepEffects.js'; +export { createConductorRouter } from './routes.js'; + +export interface ConductorWiring { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + awaitStore: ConductorAwaitStore; + roleStore: ConductorRoleStore; + executor: ConductorRunExecutor; + awaitWorker: ConductorAwaitWorker; + eventRouter: ConductorEventRouter; +} + +/** + * 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; + /** 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); + await runConductorMigrations(deps.pool, log); + + const workflowStore = new ConductorWorkflowStore(deps.pool); + const runStore = new ConductorRunStore(deps.pool); + const awaitStore = new ConductorAwaitStore(deps.pool); + const roleStore = new ConductorRoleStore(deps.pool); + const executor = new ConductorRunExecutor({ + workflowStore, + runStore, + awaitStore, + effects: new RealStepEffects({ + getRegistry: deps.getRegistry, + ...(deps.invokeAction ? { invokeAction: deps.invokeAction } : {}), + log, + }), + log, + }); + + // Deadline worker — fires the in-graph fallback when a human await times out. + const awaitWorker = new ConductorAwaitWorker({ awaitStore, executor, log }); + awaitWorker.start(); + + // Event router — a domain event starts every subscribed workflow's run (US4). + const eventRouter = new ConductorEventRouter({ workflowStore, executor, log }); + + deps.app.use( + '/api/v1/operator/conductors', + deps.requireAuth, + createConductorRouter({ workflowStore, runStore, awaitStore, roleStore, executor, eventRouter }), + ); + + return { workflowStore, runStore, awaitStore, roleStore, executor, awaitWorker, eventRouter }; +} 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/migrations/0002_await_responder_text.sql b/middleware/src/conductor/migrations/0002_await_responder_text.sql new file mode 100644 index 00000000..fafba5e3 --- /dev/null +++ b/middleware/src/conductor/migrations/0002_await_responder_text.sql @@ -0,0 +1,5 @@ +-- Store the responder as the session identity (provider 'sub' / email), not a users.id UUID, +-- so an operator answering a pending await via the UI doesn't require a users-table join. +-- Forward-only, idempotent. +ALTER TABLE conductor_await_responses DROP CONSTRAINT IF EXISTS conductor_await_responses_responder_id_fkey; +ALTER TABLE conductor_await_responses ALTER COLUMN responder_id TYPE TEXT USING responder_id::text; diff --git a/middleware/src/conductor/migrations/0003_role_holder_text.sql b/middleware/src/conductor/migrations/0003_role_holder_text.sql new file mode 100644 index 00000000..574faa28 --- /dev/null +++ b/middleware/src/conductor/migrations/0003_role_holder_text.sql @@ -0,0 +1,7 @@ +-- Store role holders/delegates as session identities (sub/email), not users.id UUIDs, so the +-- operator can assign roles by identity without a users-table join (MVP, mirrors await responder). +-- Forward-only, idempotent. +ALTER TABLE conductor_role_assignments DROP CONSTRAINT IF EXISTS conductor_role_assignments_holder_id_fkey; +ALTER TABLE conductor_role_assignments DROP CONSTRAINT IF EXISTS conductor_role_assignments_delegate_id_fkey; +ALTER TABLE conductor_role_assignments ALTER COLUMN holder_id TYPE TEXT USING holder_id::text; +ALTER TABLE conductor_role_assignments ALTER COLUMN delegate_id TYPE TEXT USING delegate_id::text; 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/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/roleStore.ts b/middleware/src/conductor/roleStore.ts new file mode 100644 index 00000000..4dac8a52 --- /dev/null +++ b/middleware/src/conductor/roleStore.ts @@ -0,0 +1,75 @@ +import type { Pool } from 'pg'; + +export interface ConductorRole { + key: string; + label: string; + description: string | null; + scope: string | null; + holders: string[]; +} + +interface RoleRow { + key: string; + label: string; + description: string | null; + scope: string | null; +} + +/** + * Roles + assignments (the "baton"). The default RoleResolver: a role's current holders are the + * assignment rows that are still open (valid_to null or future). `resolve()` is late-bound — call + * it at dispatch and on every reminder so a moved baton routes to the current holder (FR-022). An + * integration could register an external resolver in front of this; that seam is a follow-up. + */ +export class ConductorRoleStore { + constructor(private readonly pool: Pool) {} + + async createRole(input: { key: string; label: string; description?: string | null; scope?: string | null }): Promise { + await this.pool.query( + `INSERT INTO conductor_roles (key, label, description, scope) + VALUES ($1, $2, $3, $4) + ON CONFLICT (key) DO UPDATE SET label = EXCLUDED.label, description = EXCLUDED.description, scope = EXCLUDED.scope`, + [input.key, input.label, input.description ?? null, input.scope ?? null], + ); + } + + /** Current holders of a role (the default resolver). Re-resolved live — never frozen. */ + async resolve(roleKey: string): Promise { + const r = await this.pool.query<{ holder_id: string }>( + `SELECT holder_id FROM conductor_role_assignments + WHERE role_key = $1 AND (valid_to IS NULL OR valid_to > now()) + ORDER BY valid_from ASC`, + [roleKey], + ); + return r.rows.map((row) => row.holder_id); + } + + async listRoles(): Promise { + const roles = await this.pool.query('SELECT key, label, description, scope FROM conductor_roles ORDER BY key'); + const out: ConductorRole[] = []; + for (const role of roles.rows) { + out.push({ ...role, holders: await this.resolve(role.key) }); + } + return out; + } + + /** Add a holder (open a new assignment). Fires conductor_role_changed (notify trigger). */ + async addHolder(roleKey: string, holderId: string): Promise { + // idempotent: skip if already an open holder + const existing = await this.resolve(roleKey); + if (existing.includes(holderId)) return; + await this.pool.query( + `INSERT INTO conductor_role_assignments (role_key, holder_id, provenance) VALUES ($1, $2, 'manual')`, + [roleKey, holderId], + ); + } + + /** Move the baton: close the open holder's assignment (a removeHolder + addHolder = a move). */ + async removeHolder(roleKey: string, holderId: string): Promise { + await this.pool.query( + `UPDATE conductor_role_assignments SET valid_to = now() + WHERE role_key = $1 AND holder_id = $2 AND valid_to IS NULL`, + [roleKey, holderId], + ); + } +} diff --git a/middleware/src/conductor/routes.ts b/middleware/src/conductor/routes.ts new file mode 100644 index 00000000..ac370412 --- /dev/null +++ b/middleware/src/conductor/routes.ts @@ -0,0 +1,293 @@ +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 type { ConductorAwaitStore } from './awaitStore.js'; +import type { ConductorRoleStore } from './roleStore.js'; +import type { ConductorEventRouter } from './eventRouter.js'; +import { + AwaitNotPendingError, + 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) : {}; +} + +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; + awaitStore: ConductorAwaitStore; + roleStore: ConductorRoleStore; + executor: ConductorRunExecutor; + eventRouter: ConductorEventRouter; +} + +/** + * 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 unknown 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) { + console.error('[conductor] publish failed:', err); + res.status(500).json({ code: 'conductor.publish_failed', message: errMsg(err) }); + } + }); + + // Emit a domain event — starts a run for every workflow with a matching event trigger (US4). + // The kernel-side seam a connector calls; exposed here so the operator can fire/test events. + router.post('/emit', async (req: Request, res: Response): Promise => { + const body = asObject(req.body); + const eventId = typeof body.eventId === 'string' ? body.eventId : ''; + if (!eventId) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'eventId is required' }); + return; + } + try { + const result = await deps.eventRouter.emit(eventId, asObject(body.payload)); + res.status(202).json(result); + } catch (err) { + console.error('[conductor] emit failed:', err); + res.status(500).json({ code: 'conductor.emit_failed', message: errMsg(err) }); + } + }); + + // Roles + baton management (US6). + router.get('/roles', async (_req: Request, res: Response): Promise => { + try { + res.json({ roles: await deps.roleStore.listRoles() }); + } catch (err) { + res.status(500).json({ code: 'conductor.roles_failed', message: errMsg(err) }); + } + }); + + router.post('/roles', async (req: Request, res: Response): Promise => { + const body = asObject(req.body); + const key = typeof body.key === 'string' ? body.key : ''; + const label = typeof body.label === 'string' ? body.label : ''; + if (!key || !label) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'key and label are required' }); + return; + } + try { + await deps.roleStore.createRole({ key, label, description: typeof body.description === 'string' ? body.description : null }); + res.status(201).json({ ok: true }); + } catch (err) { + res.status(500).json({ code: 'conductor.role_create_failed', message: errMsg(err) }); + } + }); + + // Assign (add) or move (unassign) a baton holder. + router.post('/roles/:key/holders', async (req: Request, res: Response): Promise => { + const body = asObject(req.body); + const holderId = typeof body.holderId === 'string' ? body.holderId : ''; + const action = body.action === 'remove' ? 'remove' : 'add'; + if (!holderId) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'holderId is required' }); + return; + } + try { + const key = paramStr(req.params.key); + if (action === 'remove') await deps.roleStore.removeHolder(key, holderId); + else await deps.roleStore.addHolder(key, holderId); + res.status(200).json({ holders: await deps.roleStore.resolve(key) }); + } catch (err) { + res.status(500).json({ code: 'conductor.role_assign_failed', message: errMsg(err) }); + } + }); + + // Operator inbox — all pending human awaits across runs, with role principals resolved live. + router.get('/awaits/pending', async (_req: Request, res: Response): Promise => { + try { + const awaits = await deps.awaitStore.listWaiting(); + const enriched = await Promise.all( + awaits.map(async (aw) => ({ + ...aw, + resolvedHolders: aw.principalKind === 'role' ? await deps.roleStore.resolve(aw.principalRef) : [aw.principalRef], + })), + ); + res.json({ awaits: enriched }); + } catch (err) { + res.status(500).json({ code: 'conductor.awaits_failed', message: errMsg(err) }); + } + }); + + // Answer a pending human await — records the response, resolves the await, resumes the run. + router.post('/awaits/:awaitId/respond', async (req: Request, res: Response): Promise => { + const awaitId = paramStr(req.params.awaitId); + const responder = req.session?.sub ?? 'operator'; + const response = asObject(req.body).response ?? asObject(req.body); + try { + const run = await deps.executor.resolveAwait(awaitId, responder, response); + res.json({ run }); + } catch (err) { + if (err instanceof AwaitNotPendingError) { + res.status(409).json({ code: 'conductor.await_not_pending', message: err.message }); + } else { + console.error('[conductor] respond failed:', err); + res.status(500).json({ code: 'conductor.respond_failed', message: errMsg(err) }); + } + } + }); + + // Fetch a workflow + its active version graph (for the visual editor to load). + router.get('/:slug', async (req: Request, res: Response): Promise => { + try { + 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; + } + const version = await deps.workflowStore.getVersion(wf.activeVersionId); + res.json({ workflow: wf, graph: version?.graph ?? null }); + } catch (err) { + res.status(500).json({ code: 'conductor.get_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(paramStr(req.params.slug), status); + res.status(204).end(); + } catch (err) { + res.status(500).json({ code: 'conductor.status_failed', message: errMsg(err) }); + } + }); + + // Dry-run / preview (US8): simulate the path with no side effects, no durable awaits. + router.post('/:slug/preview', async (req: Request, res: Response): Promise => { + const slug = paramStr(req.params.slug); + const body = asObject(req.body); + try { + const result = await deps.executor.previewRun(slug, asObject(body.payload), asObject(body.humanResponses)); + res.json(result); + } catch (err) { + if (err instanceof WorkflowNotFoundError) { + res.status(404).json({ code: 'conductor.not_found', message: err.message }); + } else if (err instanceof WorkflowNotPublishedError) { + res.status(409).json({ code: 'conductor.not_published', message: err.message }); + } else { + console.error('[conductor] preview failed:', err); + res.status(500).json({ code: 'conductor.preview_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 = paramStr(req.params.slug); + const payload = asObject(asObject(req.body).payload); + try { + // Async: the run is created + driven in the background (real agent turns are slow). + // 202 Accepted; the client polls GET /:slug/runs/:runId for the final status + trace. + const run = await deps.executor.startRun({ slug, payload, triggerKind: 'manual' }); + const steps = await deps.runStore.stepsForRun(run.id); + res.status(202).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 { + console.error('[conductor] run start failed:', err); + 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(paramStr(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(paramStr(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..fad73b2c --- /dev/null +++ b/middleware/src/conductor/runExecutor.ts @@ -0,0 +1,332 @@ +import { nextStep } from '@omadia/conductor-core'; +import type { JsonObject, JsonValue, Step, WorkflowGraph } from '@omadia/conductor-core'; + +import type { ConductorWorkflowStore } from './workflowStore.js'; +import type { ConductorRun, ConductorRunStore, TriggerKind } from './runStore.js'; +import type { ConductorAwaitStore } from './awaitStore.js'; +import type { StepEffects } from './stepEffects.js'; + +export class WorkflowNotFoundError extends Error {} +export class WorkflowDisabledError extends Error {} +export class WorkflowNotPublishedError extends Error {} +export class AwaitNotPendingError extends Error {} + +export interface PreviewStep { + stepId: string; + kind: 'agent' | 'action' | 'human'; + actor: string; + postcondition: string; + transition: string | null; + result: JsonValue; +} + +export interface PreviewResult { + status: 'completed' | 'failed'; + steps: PreviewStep[]; + context: JsonObject; +} + +const MAX_STEPS = 1000; + +function asObject(v: JsonValue | undefined): JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {}; +} + +/** Parse an ISO-8601 duration (PT6H, PT24H, PT30M, P1D, P1DT2H) to milliseconds, or null. */ +export function parseIsoDurationMs(iso: string | null | undefined): number | null { + if (!iso) return null; + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso.trim()); + if (!m) return null; + const [, d, h, min, s] = m; + const ms = (Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(min ?? 0) * 60 + Number(s ?? 0)) * 1000; + return ms > 0 ? ms : null; +} + +/** + * Owns run advancement: the engine (`@omadia/conductor-core`) decides the path; this executor + * performs per-step I/O (via StepEffects) and persists each step + accumulated context before + * advancing (FR-004). A human step opens a durable await and parks the run as `waiting`; when a + * human responds (resolveAwait) or the deadline passes (expireAwait) the run resumes. + */ +export class ConductorRunExecutor { + private readonly workflowStore: ConductorWorkflowStore; + private readonly runStore: ConductorRunStore; + private readonly awaitStore: ConductorAwaitStore; + private readonly effects: StepEffects; + private readonly log: (msg: string) => void; + + constructor(deps: { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + awaitStore: ConductorAwaitStore; + effects: StepEffects; + log?: (msg: string) => void; + }) { + this.workflowStore = deps.workflowStore; + this.runStore = deps.runStore; + this.awaitStore = deps.awaitStore; + this.effects = deps.effects; + this.log = deps.log ?? (() => undefined); + } + + async startRun(input: { + slug: string; + payload: JsonObject; + triggerKind?: TriggerKind; + triggerSource?: JsonValue | null; + isDryRun?: boolean; + awaitCompletion?: boolean; + }): Promise { + const wf = await this.workflowStore.getBySlug(input.slug); + if (!wf) throw new WorkflowNotFoundError(`workflow '${input.slug}' not found`); + if (wf.status === 'disabled') { + 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, + }); + + if (input.awaitCompletion) { + return this.driveFrom(run.id, version.graph, version.graph.entryStepId, input.payload); + } + const graph = version.graph; + void this.driveFrom(run.id, graph, graph.entryStepId, input.payload).catch((err) => { + this.log(`[conductor] run ${run.id} drive crashed: ${err instanceof Error ? err.message : String(err)}`); + }); + return run; + } + + /** Drive a run forward from `startStepId`. Human steps open an await and park. */ + private async driveFrom(runId: string, graph: WorkflowGraph, startStepId: string, startContext: JsonObject): Promise { + let context: JsonObject = { ...startContext }; + let currentStepId: string | null = startStepId; + let seq = (await this.runStore.stepsForRun(runId)).length; + + 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, seq, stepId, actor: null, postconditionOutcome: 'n/a', transitionTaken: null, + nextStepId: null, context, status: 'failed', + }); + break; + } + + // Human step → durable await + park; resolveAwait/expireAwait resume the run. + if (step.kind === 'human') { + await this.openHumanAwait(runId, step, context); + return (await this.runStore.get(runId)) ?? (await this.requireRun(runId)); + } + + let exec; + try { + exec = step.kind === 'agent' + ? await this.effects.runAgentStep(step, context, { runId }) + : await this.effects.runActionStep(step, context, { runId }); + } catch (err) { + this.log(`[conductor] run ${runId} step '${stepId}' threw: ${err instanceof Error ? err.message : String(err)}`); + await this.runStore.recordStepAndAdvance({ + runId, 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); + context = this.accumulate(context, stepId, exec.result); + currentStepId = await this.applyDecision(runId, seq, stepId, exec.actor, decision, context); + if (currentStepId) seq += 1; + } + + return (await this.runStore.get(runId)) ?? (await this.requireRun(runId)); + } + + /** A human responded — resolve the await and resume the run. */ + async resolveAwait(awaitId: string, responderId: string, response: JsonValue): Promise { + const aw = await this.awaitStore.get(awaitId); + if (!aw || aw.status !== 'waiting') throw new AwaitNotPendingError(`await '${awaitId}' is not pending`); + await this.awaitStore.recordResponse(awaitId, responderId, response); + const won = await this.awaitStore.close(awaitId, 'resolved'); + if (!won) throw new AwaitNotPendingError(`await '${awaitId}' was already resolved`); + + const { graph, run } = await this.loadRunGraph(aw.runId); + const decision = nextStep(graph, aw.stepId, response, run.context); + const context = this.accumulate(run.context, aw.stepId, response); + const seq = (await this.runStore.stepsForRun(aw.runId)).length; + const next = await this.applyDecision(aw.runId, seq, aw.stepId, { kind: 'human', resolvedUserId: responderId }, decision, context); + if (next) return this.driveFrom(aw.runId, graph, next, context); + return (await this.runStore.get(aw.runId)) ?? run; + } + + /** A deadline passed with no response — close the await and fire the in-graph fallback (FR-017). */ + async expireAwait(awaitId: string): Promise { + const aw = await this.awaitStore.get(awaitId); + if (!aw || aw.status !== 'waiting') return; + const won = await this.awaitStore.close(awaitId, 'timed_out'); + if (!won) return; + + const { graph, run } = await this.loadRunGraph(aw.runId); + const seq = (await this.runStore.stepsForRun(aw.runId)).length; + const fallback = aw.fallbackTransitionId ? graph.transitions.find((tr) => tr.id === aw.fallbackTransitionId) : undefined; + if (!fallback) { + await this.runStore.recordStepAndAdvance({ + runId: aw.runId, seq, stepId: aw.stepId, actor: { kind: 'human', timedOut: true }, + postconditionOutcome: 'unmet', transitionTaken: null, nextStepId: null, context: run.context, status: 'failed', + }); + return; + } + await this.runStore.recordStepAndAdvance({ + runId: aw.runId, seq, stepId: aw.stepId, actor: { kind: 'human', timedOut: true }, + postconditionOutcome: 'unmet', transitionTaken: fallback.id, nextStepId: fallback.target, context: run.context, status: 'running', + }); + this.log(`[conductor] await ${awaitId} timed out → fallback '${fallback.id}' (run ${aw.runId})`); + await this.driveFrom(aw.runId, graph, fallback.target, run.context); + } + + /** + * Dry-run / preview (US8 / FR-029): simulate the workflow path in memory with NO persistence + * and NO side effects — no conductor_runs/awaits rows, no real notification, no durable await. + * Human steps are answered inline (supplied `humanResponses[stepId]`, default `{approved:true}`); + * agent steps run a real turn; action steps are stubbed (irreversible connector actions are not + * executed). Returns the full simulated step path so the operator gains confidence before activating. + */ + async previewRun(slug: string, payload: JsonObject, humanResponses: Record = {}): Promise { + const wf = await this.workflowStore.getBySlug(slug); + if (!wf) throw new WorkflowNotFoundError(`workflow '${slug}' not found`); + if (!wf.activeVersionId) throw new WorkflowNotPublishedError(`workflow '${slug}' has no active version`); + const version = await this.workflowStore.getVersion(wf.activeVersionId); + if (!version) throw new WorkflowNotPublishedError(`active version of '${slug}' missing`); + const graph = version.graph; + + let context: JsonObject = { ...payload }; + let currentStepId: string | null = graph.entryStepId; + const steps: PreviewStep[] = []; + let status: 'completed' | 'failed' = 'completed'; + let guard = MAX_STEPS; + + while (currentStepId && guard-- > 0) { + const stepId: string = currentStepId; + const step = graph.steps.find((s) => s.id === stepId); + if (!step) { + status = 'failed'; + break; + } + + let result: JsonValue; + let actor: string; + if (step.kind === 'human') { + result = humanResponses[stepId] ?? { approved: true }; + actor = 'human (inline)'; + } else if (step.kind === 'agent') { + const exec = await this.effects.runAgentStep(step, context, { runId: `preview:${slug}` }); + result = exec.result; + actor = `agent:${step.agentId ?? '?'}`; + } else { + result = { simulated: true, actionId: step.actionId ?? null }; + actor = `action (stubbed):${step.actionId ?? '?'}`; + } + + const decision = nextStep(graph, stepId, result, context); + context = this.accumulate(context, stepId, result); + steps.push({ + stepId, + kind: step.kind, + actor, + postcondition: decision.postcondition, + transition: decision.kind === 'advance' ? decision.transitionId : null, + result, + }); + + if (decision.kind === 'advance') { + currentStepId = decision.targetStepId; + } else { + status = decision.kind === 'complete' ? 'completed' : 'failed'; + currentStepId = null; + } + } + + return { status, steps, context }; + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + private async openHumanAwait(runId: string, step: Step, context: JsonObject): Promise { + const h = step.human; + const deadlineMs = parseIsoDurationMs(h?.deadline ?? null); + const reminderMs = parseIsoDurationMs(h?.reminderInterval ?? null); + await this.awaitStore.create({ + runId, + stepId: step.id, + principalKind: h?.principal.kind ?? 'role', + principalRef: h?.principal.ref ?? '', + channelType: h?.channel ?? 'teams', + message: h?.message ?? '', + quorum: h?.quorum ?? 'any', + reminderIntervalMs: reminderMs, + deadlineAt: deadlineMs ? new Date(Date.now() + deadlineMs) : null, + fallbackTransitionId: step.fallbackTransitionId ?? null, + }); + await this.runStore.park(runId, step.id, context); + this.log(`[conductor] run ${runId} awaiting human at step '${step.id}' (${h?.principal.kind}:${h?.principal.ref})`); + } + + private accumulate(context: JsonObject, stepId: string, result: JsonValue): JsonObject { + const prev = asObject(context.steps); + return { ...context, steps: { ...prev, [stepId]: result } }; + } + + /** Persist a step's decision; returns the next step id to drive, or null if the run ended/parked. */ + private async applyDecision( + runId: string, + seq: number, + stepId: string, + actor: JsonValue, + decision: ReturnType, + context: JsonObject, + ): Promise { + if (decision.kind === 'advance') { + await this.runStore.recordStepAndAdvance({ + runId, seq, stepId, actor, postconditionOutcome: decision.postcondition, transitionTaken: decision.transitionId, + nextStepId: decision.targetStepId, context, status: 'running', + }); + return decision.targetStepId; + } + if (decision.kind === 'complete') { + await this.runStore.recordStepAndAdvance({ + runId, seq, stepId, actor, postconditionOutcome: decision.postcondition, transitionTaken: null, + nextStepId: null, context, status: 'completed', + }); + return null; + } + this.log(`[conductor] run ${runId} stuck at '${stepId}': ${decision.message}`); + await this.runStore.recordStepAndAdvance({ + runId, seq, stepId, actor, postconditionOutcome: decision.postcondition, transitionTaken: null, + nextStepId: stepId, context, status: 'failed', + }); + return null; + } + + private async loadRunGraph(runId: string): Promise<{ graph: WorkflowGraph; run: ConductorRun }> { + const run = await this.requireRun(runId); + const version = await this.workflowStore.getVersion(run.workflowVersionId); + if (!version) throw new WorkflowNotPublishedError(`version for run '${runId}' missing`); + return { graph: version.graph, run }; + } + + private async requireRun(runId: string): Promise { + const run = await this.runStore.get(runId); + if (!run) throw new WorkflowNotFoundError(`run '${runId}' not found`); + return run; + } +} diff --git a/middleware/src/conductor/runStore.ts b/middleware/src/conductor/runStore.ts new file mode 100644 index 00000000..218a18a6 --- /dev/null +++ b/middleware/src/conductor/runStore.ts @@ -0,0 +1,193 @@ +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(); + } + } + + /** Park a run as `waiting` at a step (a durable human await is open) without a step record. */ + async park(runId: string, stepId: string, context: JsonObject): Promise { + await this.pool.query( + `UPDATE conductor_runs SET status = 'waiting', current_step_id = $2, context = $3::jsonb WHERE id = $1`, + [runId, stepId, JSON.stringify(context)], + ); + } + + 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..1a31afcc --- /dev/null +++ b/middleware/src/conductor/stepEffects.ts @@ -0,0 +1,46 @@ +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; +} + +/** 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 (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, meta: StepMeta): Promise; + runActionStep(step: Step, context: JsonObject, meta: StepMeta): 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, _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, _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/conductor/workflowStore.ts b/middleware/src/conductor/workflowStore.ts new file mode 100644 index 00000000..b2e3bf4c --- /dev/null +++ b/middleware/src/conductor/workflowStore.ts @@ -0,0 +1,153 @@ +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'); + + // Idempotent upsert — race-safe under concurrent/double-submitted publishes of the + // same slug (a SELECT-then-INSERT would let two requests both pass the check and one + // hit the unique-constraint). Status is only set on first create, never changed here. + const upserted = await client.query<{ id: string }>( + `INSERT INTO conductor_workflows (slug, name, description, status) + VALUES ($1, $2, $3, $4) + ON CONFLICT (slug) DO UPDATE + SET name = EXCLUDED.name, description = EXCLUDED.description, updated_at = now() + RETURNING id`, + [input.slug, input.name, input.description ?? null, input.enable ? 'enabled' : 'disabled'], + ); + const workflowId = upserted.rows[0]!.id; + // Serialize concurrent publishes of the same workflow so version numbering can't collide. + await client.query('SELECT id FROM conductor_workflows WHERE id = $1 FOR UPDATE', [workflowId]); + + 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..4bd9807d 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,20 @@ 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). + // 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); const bootstrapResult = await runAuthBootstrap({ diff --git a/specs/005-omadia-conductor/data-model.md b/specs/005-omadia-conductor/data-model.md new file mode 100644 index 00000000..7a27a6d2 --- /dev/null +++ b/specs/005-omadia-conductor/data-model.md @@ -0,0 +1,416 @@ +# 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 — the **default / stand-alone-fallback** holder store, read by the default +`RoleResolver`. **Primary path (#333, Identity & Role Projection):** role holders are +projected from external systems of record (Entra groups/app-roles, HR/ERP) and matched to +users on a primary key; an external resolver registered *in front of* this answers from +that source and ignores this table. The local table is used only when no external source +is configured. (Implementation note: the shipped migration stores `holder_id` / +`delegate_id` as a **session-identity TEXT** — the provider `sub` / email — not a +`users(id)` FK, so holders can be assigned by identity without a users-table join; the +illustrative DDL below predates that — see migration `0003_role_holder_text.sql`.) + +```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 }`. +The **default** resolver reads the local `conductor_role_assignments` table (stand-alone +fallback). The **primary** resolver (#333, Identity & Role Projection) projects holders +from external systems of record (Entra groups/app-roles, HR/ERP) and is registered *in +front of* the default — exactly the "external resolver in front" follow-up the +implementation's `roleStore` already anticipates. 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/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. diff --git a/specs/005-omadia-conductor/spec.md b/specs/005-omadia-conductor/spec.md new file mode 100644 index 00000000..73df95e3 --- /dev/null +++ b/specs/005-omadia-conductor/spec.md @@ -0,0 +1,699 @@ +# 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`. Per +**#333 (Identity & Role Projection)**, the **primary** resolver projects role holders +from the organization's systems of record (an IdP such as Entra via groups/app-roles, +and/or an HR/ERP source such as Odoo), correlated to omadia users on a primary key. +omadia's **local** manual assignment store is the **default / stand-alone fallback** +(used when no external source is configured; the baton is then moved by an API/Designer +action). Conductor only ever calls the resolver and hard-codes no role semantics. 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:`. Per the + platform-wide paradigm in **#333**, `Principal = user | role` is the **default** + addressee type for *every* surface that targets a person (human steps, escalation / + fallback targets, report and interim-status recipients, notifications, assignments). + Restricting a surface to **user-only** is the exception, permitted only when + technically or legally necessary to bind one named natural person, and MUST carry a + documented justification (mirroring the `multi_instance_justification` precedent). +- **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. The **primary** + resolver MUST project holders from external systems of record per **#333 (Identity & + Role Projection)** — an IdP (Entra groups/app-roles) and/or an HR/ERP source — + correlated to omadia users on a primary key. A **local** manual assignment store MUST + exist as the **default / stand-alone fallback** (with APIs to move the baton) for + deployments that configure no external source; the local table MUST NOT be treated as + the long-term system of record. +- **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. +- Roles and user identities are **projected from the organization's systems of record** + per **#333 (Identity & Role Projection)** — an IdP (Entra) for access identity, an + HR/ERP source for org roles/attributes — joined on a primary key; omadia maintains no + user/role master copy except in the stand-alone fallback. 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 and consumes the projection, exposing 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. 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..45eef2e3 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -3516,3 +3516,148 @@ 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 getConductorWorkflowGraph( + slug: string, +): Promise<{ workflow: ConductorWorkflow; graph: unknown }> { + return getJson(`${CONDUCTOR_BASE}/${encodeURIComponent(slug)}`); +} + +export interface ConductorAwait { + id: string; + runId: string; + stepId: string; + principalKind: 'user' | 'role'; + principalRef: string; + channelType: string; + message: string; + quorum: 'any' | 'all'; + deadlineAt: string | null; + status: string; + resolvedHolders?: string[]; +} + +export async function listPendingAwaits(): Promise<{ awaits: ConductorAwait[] }> { + return getJson(`${CONDUCTOR_BASE}/awaits/pending`); +} + +export interface ConductorRole { + key: string; + label: string; + description: string | null; + scope: string | null; + holders: string[]; +} + +export async function listConductorRoles(): Promise<{ roles: ConductorRole[] }> { + return getJson(`${CONDUCTOR_BASE}/roles`); +} + +export async function createConductorRole(key: string, label: string): Promise { + return postJson(`${CONDUCTOR_BASE}/roles`, { key, label }); +} + +export async function assignRoleHolder(key: string, holderId: string, action: 'add' | 'remove'): Promise<{ holders: string[] }> { + return postJson(`${CONDUCTOR_BASE}/roles/${encodeURIComponent(key)}/holders`, { holderId, action }); +} + +export interface ConductorEmitResult { + eventId: string; + matchedWorkflows: number; + startedRuns: Array<{ workflowSlug: string; runId: string }>; +} + +export async function emitConductorEvent(eventId: string, payload: unknown): Promise { + return postJson(`${CONDUCTOR_BASE}/emit`, { eventId, payload }); +} + +export async function respondToAwait(awaitId: string, response: unknown): Promise<{ run: ConductorRun }> { + return postJson(`${CONDUCTOR_BASE}/awaits/${encodeURIComponent(awaitId)}/respond`, { response }); +} + +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 interface ConductorPreviewStep { + stepId: string; + kind: string; + actor: string; + postcondition: string; + transition: string | null; + result: unknown; +} + +export interface ConductorPreviewResult { + status: string; + steps: ConductorPreviewStep[]; + context: unknown; +} + +export async function previewConductorWorkflow(slug: string, payload: unknown): Promise { + return postJson(`${CONDUCTOR_BASE}/${encodeURIComponent(slug)}/preview`, { 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/_components/ConductorCanvas.tsx b/web-ui/app/conductor/_components/ConductorCanvas.tsx new file mode 100644 index 00000000..29ee39b3 --- /dev/null +++ b/web-ui/app/conductor/_components/ConductorCanvas.tsx @@ -0,0 +1,711 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Background, + Controls, + Handle, + Position, + ReactFlow, + ReactFlowProvider, + type Connection, + type Edge, + type EdgeChange, + type Node, + type NodeChange, + type NodeProps, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { Button } from '@/app/_components/ui/Button'; +import { + ApiError, + getConductorRun, + getConductorWorkflowGraph, + previewConductorWorkflow, + publishConductorWorkflow, + startConductorRun, + type ConductorPreviewResult, + type ConductorRunResult, + type ConductorWorkflow, +} from '@/app/_lib/api'; + +// ── Node data model ──────────────────────────────────────────────────────── +// node.id is a stable internal id; data.stepId is the user-facing (renameable) +// step id used in the serialized graph, so renaming never breaks edges. + +type StepKind = 'agent' | 'action' | 'human'; + +interface StepNodeData extends Record { + stepId: string; + kind: StepKind; + agentId: string; + prompt: string; + actionId: string; + input: string; // JSON string + human: { + principalKind: 'user' | 'role'; + principalRef: string; + channel: string; + message: string; + reminderInterval: string; + deadline: string; + quorum: 'any' | 'all'; + }; + postcondition: string; // JSON string, optional + fallbackTransitionId: string; + isEntry: boolean; +} + +type StepNode = Node; + +const KIND_COLOR: Record = { + agent: '#6ab7ff', + action: '#8b9cff', + human: '#f2b95e', +}; + +function StepNodeView({ data, selected }: NodeProps): React.JSX.Element { + const primary = + data.kind === 'agent' ? data.agentId || '—' : data.kind === 'action' ? data.actionId || '—' : data.human.principalRef || '—'; + return ( +
+ +
+ + {data.kind} + + {data.isEntry && ( + + entry + + )} +
+
{data.stepId}
+
{primary}
+ +
+ ); +} + +const nodeTypes: NodeTypes = { step: StepNodeView }; + +function emptyData(kind: StepKind, n: number): StepNodeData { + return { + stepId: `${kind}-${n}`, + kind, + agentId: kind === 'agent' ? 'fallback' : '', + prompt: kind === 'agent' ? 'Do your task.' : '', + actionId: '', + input: '', + human: { + principalKind: 'role', + principalRef: '', + channel: 'teams', + message: '', + reminderInterval: '', + deadline: '', + quorum: 'any', + }, + postcondition: '', + fallbackTransitionId: '', + isEntry: n === 1, + }; +} + +interface ValidationError { + code: string; + message: string; +} + +// A request from the parent (e.g. the "Edit" button in the workflows list) to load a +// workflow into the canvas. The nonce changes on every click so re-editing the same +// workflow reloads it even though the slug is unchanged. +export interface CanvasEditRequest { + slug: string; + nonce: number; +} + +function CanvasInner({ + workflows, + onSaved, + editRequest, +}: { + workflows: ConductorWorkflow[]; + onSaved: () => void; + editRequest: CanvasEditRequest | null; +}): React.JSX.Element { + const t = useTranslations('conductor'); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); + + const [slug, setSlug] = useState(''); + const [name, setName] = useState(''); + const [triggerKind, setTriggerKind] = useState<'manual' | 'event' | 'cron'>('manual'); + const [triggerEventId, setTriggerEventId] = useState(''); + const [triggerCron, setTriggerCron] = useState(''); + + // Monotonic id source — guarantees unique node/edge ids even if a click double-fires. + const nextId = useRef(0); + const lastAction = useRef(0); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + const [runResult, setRunResult] = useState(null); + const [busy, setBusy] = useState(false); + const [previewResult, setPreviewResult] = useState(null); + const [previewing, setPreviewing] = useState(false); + + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((ns) => applyNodeChanges(changes, ns) as StepNode[]); + }, []); + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + setEdges((es) => applyEdgeChanges(changes, es)); + }, []); + const onConnect = useCallback((c: Connection) => { + if (!c.source || !c.target) return; + nextId.current += 1; + const id = `t-${String(nextId.current)}`; + setEdges((es) => addEdge({ ...c, id, data: { guard: '' } }, es)); + }, []); + + const addStep = useCallback((kind: StepKind) => { + // Swallow a double-fired click (synthetic input / accidental double-click) so a + // single intent never produces two nodes. + const now = Date.now(); + if (now - lastAction.current < 350) return; + lastAction.current = now; + nextId.current += 1; + const n = nextId.current; + const id = `node-${String(n)}`; + setNodes((ns) => [ + ...ns, + { + id, + type: 'step', + position: { x: 80 + (ns.length % 4) * 200, y: 80 + Math.floor(ns.length / 4) * 130 }, + data: { ...emptyData(kind, n), isEntry: ns.length === 0 }, + }, + ]); + }, []); + + const patchNode = useCallback((nodeId: string, patch: Partial) => { + setNodes((ns) => ns.map((node) => (node.id === nodeId ? { ...node, data: { ...node.data, ...patch } } : node))); + }, []); + + const setEntry = useCallback((nodeId: string) => { + setNodes((ns) => ns.map((node) => ({ ...node, data: { ...node.data, isEntry: node.id === nodeId } }))); + }, []); + + const deleteSelected = useCallback(() => { + if (selectedNode) { + setNodes((ns) => ns.filter((n) => n.id !== selectedNode)); + setEdges((es) => es.filter((e) => e.source !== selectedNode && e.target !== selectedNode)); + setSelectedNode(null); + } else if (selectedEdge) { + setEdges((es) => es.filter((e) => e.id !== selectedEdge)); + setSelectedEdge(null); + } + }, [selectedNode, selectedEdge]); + + // ── serialize canvas → graph JSON ───────────────────────────────────────── + const buildGraph = useCallback((): { graph: unknown; error?: string } => { + const idMap = new Map(); // node.id → stepId + for (const n of nodes) idMap.set(n.id, n.data.stepId); + + const transitions = edges.map((e) => { + const tr: Record = { + id: e.id, + source: idMap.get(e.source) ?? e.source, + target: idMap.get(e.target) ?? e.target, + }; + const guard = (e.data?.guard as string | undefined)?.trim(); + if (guard) tr.guard = JSON.parse(guard); + return tr; + }); + + const steps = nodes.map((n) => { + const d = n.data; + const s: Record = { id: d.stepId, kind: d.kind, position: n.position }; + if (d.kind === 'agent') { + s.agentId = d.agentId; + if (d.prompt.trim()) s.prompt = d.prompt; + } else if (d.kind === 'action') { + s.actionId = d.actionId; + if (d.input.trim()) s.input = JSON.parse(d.input); + } else { + s.human = { + principal: { kind: d.human.principalKind, ref: d.human.principalRef }, + channel: d.human.channel, + message: d.human.message, + ...(d.human.reminderInterval.trim() ? { reminderInterval: d.human.reminderInterval } : {}), + ...(d.human.deadline.trim() ? { deadline: d.human.deadline } : {}), + quorum: d.human.quorum, + }; + } + if (d.postcondition.trim()) s.postcondition = JSON.parse(d.postcondition); + if (d.fallbackTransitionId.trim()) s.fallbackTransitionId = d.fallbackTransitionId; + return s; + }); + + const entry = nodes.find((n) => n.data.isEntry) ?? nodes[0]; + const trigger: Record = { id: 'tr', kind: triggerKind }; + if (triggerKind === 'event' && triggerEventId.trim()) trigger.eventId = triggerEventId; + if (triggerKind === 'cron' && triggerCron.trim()) trigger.cron = triggerCron; + + return { + graph: { + entryStepId: entry?.data.stepId ?? '', + steps, + transitions, + triggers: [trigger], + }, + }; + }, [nodes, edges, triggerKind, triggerEventId, triggerCron]); + + const handleSave = useCallback(async () => { + setSaving(true); + setSaveError(null); + setValidationErrors([]); + let graph: unknown; + try { + graph = buildGraph().graph; + } catch (err) { + setSaveError(`JSON field error: ${err instanceof Error ? err.message : String(err)}`); + setSaving(false); + return; + } + try { + await publishConductorWorkflow({ slug, name, graph, enable: true }); + onSaved(); + } 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 { + /* not json */ + } + setSaveError(err.message); + } else setSaveError(String(err)); + } finally { + setSaving(false); + } + }, [buildGraph, slug, name, onSaved]); + + const loadWorkflow = useCallback(async (wfSlug: string) => { + try { + const { workflow, graph } = await getConductorWorkflowGraph(wfSlug); + setSlug(workflow.slug); + setName(workflow.name); + const g = graph as { + entryStepId: string; + steps: Array>; + transitions: Array>; + triggers?: Array>; + }; + const newNodes: StepNode[] = g.steps.map((step, i) => { + const kind = step.kind as StepKind; + const base = emptyData(kind, i + 1); + const human = (step.human ?? {}) as Record; + const principal = (human.principal ?? {}) as Record; + const pos = (step.position ?? { x: 80 + (i % 4) * 200, y: 80 + Math.floor(i / 4) * 130 }) as { x: number; y: number }; + return { + id: String(step.id), + type: 'step', + position: pos, + data: { + ...base, + stepId: String(step.id), + kind, + agentId: String(step.agentId ?? ''), + prompt: String(step.prompt ?? ''), + actionId: String(step.actionId ?? ''), + input: step.input ? JSON.stringify(step.input, null, 2) : '', + human: { + principalKind: (principal.kind as 'user' | 'role') ?? 'role', + principalRef: String(principal.ref ?? ''), + channel: String(human.channel ?? 'teams'), + message: String(human.message ?? ''), + reminderInterval: String(human.reminderInterval ?? ''), + deadline: String(human.deadline ?? ''), + quorum: (human.quorum as 'any' | 'all') ?? 'any', + }, + postcondition: step.postcondition ? JSON.stringify(step.postcondition, null, 2) : '', + fallbackTransitionId: String(step.fallbackTransitionId ?? ''), + isEntry: step.id === g.entryStepId, + }, + }; + }); + const newEdges: Edge[] = g.transitions.map((tr) => ({ + id: String(tr.id), + source: String(tr.source), + target: String(tr.target), + data: { guard: tr.guard ? JSON.stringify(tr.guard) : '' }, + })); + setNodes(newNodes); + setEdges(newEdges); + nextId.current += g.steps.length + g.transitions.length; + const trig = g.triggers?.[0]; + if (trig) { + setTriggerKind((trig.kind as 'manual' | 'event' | 'cron') ?? 'manual'); + setTriggerEventId(String(trig.eventId ?? '')); + setTriggerCron(String(trig.cron ?? '')); + } + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : String(err)); + } + }, []); + + // Load the workflow the parent asked us to edit. The parent hands us a fresh + // object (new nonce) on every "Edit" click, so this fires once per click. + useEffect(() => { + if (editRequest?.slug) void loadWorkflow(editRequest.slug); + }, [editRequest, loadWorkflow]); + + const handleRun = useCallback(async () => { + if (!slug) return; + const now = Date.now(); + if (now - lastAction.current < 600) return; + lastAction.current = now; + setBusy(true); + setRunResult(null); + try { + const started = await startConductorRun(slug, {}); + setRunResult(started); + for (let i = 0; i < 60; i += 1) { + await new Promise((r) => setTimeout(r, 2000)); + const latest = await getConductorRun(slug, started.run.id); + setRunResult(latest); + if (latest.run.status !== 'running') break; + } + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [slug]); + + const handlePreview = useCallback(async () => { + if (!slug) return; + const now = Date.now(); + if (now - lastAction.current < 600) return; + lastAction.current = now; + setPreviewing(true); + setPreviewResult(null); + try { + setPreviewResult(await previewConductorWorkflow(slug, {})); + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : String(err)); + } finally { + setPreviewing(false); + } + }, [slug]); + + const sel = useMemo(() => nodes.find((n) => n.id === selectedNode) ?? null, [nodes, selectedNode]); + const selEdge = useMemo(() => edges.find((e) => e.id === selectedEdge) ?? null, [edges, selectedEdge]); + + const input = + 'w-full rounded-md border border-[color:var(--border)] bg-transparent px-2 py-1 text-[13px] text-[color:var(--fg-strong)]'; + const lbl = 'grid gap-1 text-[12px] text-[color:var(--fg-muted)]'; + + return ( +
+ {/* Toolbar */} +
+ + + + {triggerKind === 'event' && ( + + )} + {triggerKind === 'cron' && ( + + )} + + + + +
+ + {saveError &&

{saveError}

} + {validationErrors.length > 0 && ( +
+
{t('validationHeading')}
+
    + {validationErrors.map((v, i) => ( +
  • + {v.code}: {v.message} +
  • + ))} +
+
+ )} + + {/* Palette */} +
+ + + + {(selectedNode || selectedEdge) && ( + + )} +
+ +
+ {/* Canvas */} +
+ { + setSelectedNode(n.id); + setSelectedEdge(null); + }} + onEdgeClick={(_e, ed) => { + setSelectedEdge(ed.id); + setSelectedNode(null); + }} + onPaneClick={() => { + setSelectedNode(null); + setSelectedEdge(null); + }} + fitView + > + + + +
+ + {/* Inspector */} +
+ {!sel && !selEdge &&

{t('inspectorEmpty')}

} + + {sel && ( +
+
+ {sel.data.kind} {t('stepLabel')} +
+ + {sel.data.kind === 'agent' && ( + <> + +