diff --git a/Dockerfile b/Dockerfile index aaea4565..6b92912f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,6 +106,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 732b28eb..c9a8847a 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -1871,6 +1871,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 @@ -2844,6 +2848,119 @@ "node": ">=20.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -7793,6 +7910,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "license": "BSD-2-Clause" @@ -8072,93 +8292,55 @@ "ws": "^8.21.0" } }, - "packages/canvas-core/node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/canvas-core/node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, + "packages/canvas-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": { - "tinyrainbow": "^3.1.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "packages/canvas-core/node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "packages/canvas-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/canvas-core/node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "packages/canvas-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": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/canvas-core/node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=14.17" } }, - "packages/canvas-core/node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", + "packages/conductor-core": { + "name": "@omadia/conductor-core", + "version": "0.1.0", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "ajv": "^8.20.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^5.9.0", + "vitest": "^4.1.8" } }, - "packages/canvas-core/node_modules/ajv": { + "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==", @@ -8174,26 +8356,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "packages/canvas-core/node_modules/json-schema-traverse": { + "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/canvas-core/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "packages/canvas-core/node_modules/typescript": { + "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==", @@ -8207,123 +8376,6 @@ "node": ">=14.17" } }, - "packages/canvas-core/node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "packages/canvas-core/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "packages/harness-channel-sdk": { "name": "@omadia/channel-sdk", "version": "0.1.0", diff --git a/middleware/package.json b/middleware/package.json index f312f21c..6b73151f 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -20,14 +20,14 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && 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-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && 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-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && 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-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && 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-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/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-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/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-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/llm-adapter-anthropic && npm run typecheck -w @omadia/llm-adapter-openai && 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-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/llm-adapter-anthropic && npm run typecheck -w @omadia/llm-adapter-openai && 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..2ff72213 --- /dev/null +++ b/middleware/packages/conductor-core/src/types.ts @@ -0,0 +1,257 @@ +// 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' + | 'quorum_all_requires_deadline_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..ea1f7cd4 --- /dev/null +++ b/middleware/packages/conductor-core/src/validate.ts @@ -0,0 +1,211 @@ +// 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] }); + } + + // A quorum='all' human step must escalate if a required holder never responds — otherwise the run + // hangs forever. Require both a deadline and a fallback so the timeout path (expireAwait) can fire. + if (s.kind === 'human' && s.human?.quorum === 'all' && (s.human.deadline == null || s.fallbackTransitionId === undefined)) { + errors.push({ + code: 'quorum_all_requires_deadline_fallback', + message: `human step '${s.id}' uses quorum 'all' and must declare both a deadline and a fallbackTransitionId (else a non-responding holder hangs the run)`, + 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..812789bf --- /dev/null +++ b/middleware/packages/conductor-core/test/validate.test.ts @@ -0,0 +1,142 @@ +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("rejects a quorum='all' human step without a deadline + fallback (would hang forever)", () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'human', human: { principal: { kind: 'role', ref: 'r' }, channel: 'teams', message: 'm', quorum: 'all' } }, + { id: 's2', kind: 'action', actionId: 'x' }, + ], + transitions: [{ id: 't1', source: 's1', target: 's2', guard: { op: 'always' } }], + }; + expect(codes(g)).toContain('quorum_all_requires_deadline_fallback'); + }); + + it("accepts a quorum='all' human step that has both a deadline and a fallback", () => { + const g: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'human', fallbackTransitionId: 't1', human: { principal: { kind: 'role', ref: 'r' }, channel: 'teams', message: 'm', quorum: 'all', deadline: 'PT6H' } }, + { id: 's2', kind: 'action', actionId: 'x' }, + ], + transitions: [{ id: 't1', source: 's1', target: 's2', guard: { op: 'always' } }], + }; + expect(codes(g)).not.toContain('quorum_all_requires_deadline_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/packages/plugin-api/src/pluginContext.ts b/middleware/packages/plugin-api/src/pluginContext.ts index 2223314d..c7cceffd 100644 --- a/middleware/packages/plugin-api/src/pluginContext.ts +++ b/middleware/packages/plugin-api/src/pluginContext.ts @@ -126,6 +126,14 @@ export interface PluginContext { * accessor coexist with them. */ readonly jobs: JobsAccessor; + /** US4 (Conductor Surface) — emit a domain event the plugin declared. Present iff the manifest + * declares `permissions.events.emit: true`. A plugin may only emit an event id it declared via an + * `{ id, event_emit: true }` capability (deny-by-default → `EventNotDeclaredError`). `emit` throws + * `ConductorUnavailableError` when no Conductor event router is registered in this host (e.g. the + * in-memory backend, or during boot before Conductor has wired) — presence of the accessor does + * NOT guarantee the router. A successful emit is routed to every subscribed Conductor workflow. */ + readonly events?: EventsAccessor; + /** OB-29-1 — delegate a single-turn question to another agent registered * in the host. Present iff the manifest declares * `permissions.subAgents.calls` with at least one entry. Plugins without @@ -250,6 +258,37 @@ export class JobAlreadyRegisteredError extends Error { } } +/** Outcome of emitting a domain event — how many Conductor workflows matched and started. */ +export interface EmitResult { + eventId: string; + matchedWorkflows: number; + startedRuns: Array<{ workflowSlug: string; runId: string }>; +} + +export interface EventsAccessor { + /** Emit a declared domain event with a JSON payload. Routes to every subscribed Conductor + * workflow (matched by event id + optional payload filter). Throws if the plugin did not + * declare `id` as an emittable event. */ + emit(id: string, payload: Record): Promise; +} + +export class EventNotDeclaredError extends Error { + constructor(agentId: string, eventId: string) { + super(`plugin '${agentId}' did not declare event '${eventId}' (add an { id, event_emit: true } capability)`); + this.name = 'EventNotDeclaredError'; + } +} + +/** Thrown by `ctx.events.emit` when no Conductor event router is registered in this host — e.g. the + * in-memory backend, or before the Conductor subsystem has finished wiring. A typed error so plugins + * can detect "Conductor not available here" and degrade, rather than parsing a generic message. */ +export class ConductorUnavailableError extends Error { + constructor() { + super('Conductor event router is not available in this host'); + this.name = 'ConductorUnavailableError'; + } +} + // --------------------------------------------------------------------------- // Capabilities — manifest-declared contracts between plugins // --------------------------------------------------------------------------- diff --git a/middleware/src/api/admin-v1.ts b/middleware/src/api/admin-v1.ts index dcdeddc6..e52dbad2 100644 --- a/middleware/src/api/admin-v1.ts +++ b/middleware/src/api/admin-v1.ts @@ -167,6 +167,9 @@ export interface PluginPermissionsSummary { * the `ctx.flows` accessor (public-callback-URL resolution + kernel-held * state signing) is provisioned. Loader defaults to `false`. */ flows?: boolean; + /** Spec 005 (US4 Conductor Surface): plugin declares `permissions.events.emit: true` and may + * emit declared domain events via `ctx.events`. Loader defaults to `false`. */ + events_emit?: boolean; /** Spec 005: true when the manifest declares >=1 `oauth_providers` * descriptor — the plugin acquires standard authorization-code credentials * through the kernel OAuth broker (tokens stored + refreshed kernel-side; diff --git a/middleware/src/channels/channelRegistry.ts b/middleware/src/channels/channelRegistry.ts index 70d634fa..1b917a5e 100644 --- a/middleware/src/channels/channelRegistry.ts +++ b/middleware/src/channels/channelRegistry.ts @@ -1,4 +1,6 @@ import { createPluginContext } from '../platform/pluginContext.js'; +import { eventEmitIds } from '../platform/eventCatalogRegistry.js'; +import type { EventCatalogRegistry } from '../platform/eventCatalogRegistry.js'; import type { PluginRouteRegistry } from '../platform/pluginRouteRegistry.js'; import type { NotificationRouter } from '../platform/notificationRouter.js'; import type { PluginStatusRegistry } from '../platform/pluginStatusRegistry.js'; @@ -45,6 +47,13 @@ export interface ChannelRegistryDeps { flowPublicBaseUrl?: string; /** Spec 004 — backing store for `ctx.status`; cleared on deactivate. */ pluginStatusRegistry?: PluginStatusRegistry; + /** + * US4 event-emit catalog. A channel plugin that declares `event_emit` capabilities (e.g. Teams + * emitting `teams.message.posted`) has them registered here on activate, so `ctx.events.emit` is + * allowed (deny-by-default otherwise) and the Conductor event-trigger picker lists them. The tool + * and dynamic-agent runtimes already do this — the channel activation path was the missing third. + */ + eventCatalogRegistry?: EventCatalogRegistry; resolver: ChannelPluginResolver; coreApi: CoreApi; routes: ExpressRouteRegistry; @@ -114,6 +123,14 @@ export class DefaultChannelRegistry implements ChannelRegistry { const handle = await impl.activate(ctx, this.deps.coreApi); this.handles.set(agentId, handle); + // Register declared event-emit ids (raw manifest `capabilities[].event_emit`) so the channel's + // `ctx.events.emit(...)` passes the deny-by-default catalog gate and the events surface in the + // Conductor trigger picker. No-op when the manifest declares none. + const emitIds = eventEmitIds(catalogEntry.manifest); + if (emitIds.length > 0) { + this.deps.eventCatalogRegistry?.register(agentId, emitIds); + console.log(`[channels] event-emit capabilities registered for ${agentId}: ${emitIds.join(', ')}`); + } this.deps.routes.setActive(agentId, true); this.deps.webSockets?.setActive(agentId, true); console.log(`[channels] ✓ activated ${agentId}`); @@ -135,6 +152,7 @@ export class DefaultChannelRegistry implements ChannelRegistry { // a stale entry in channel-teams' Hub + Tab-Config dropdown. this.deps.uiRouteCatalog.disposeBySource(agentId); this.deps.pluginStatusRegistry?.clear(agentId); + this.deps.eventCatalogRegistry?.unregister(agentId); if (!handle) return; this.handles.delete(agentId); try { diff --git a/middleware/src/conductor/awaitStore.ts b/middleware/src/conductor/awaitStore.ts new file mode 100644 index 00000000..da67d0a2 --- /dev/null +++ b/middleware/src/conductor/awaitStore.ts @@ -0,0 +1,210 @@ +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; + /** true when the last reminder found no reachable holder (no channel binding) — operator signal. */ + unreachable: boolean; + createdAt: Date; +} + +/** Resolve an await's principal to concrete holder ids — `role:` via the resolver, `user:` as itself. + * Shared by the reminder worker and the operator inbox so the rule never drifts. */ +export async function resolveAwaitHolders( + aw: Pick, + resolveRole: (roleKey: string) => Promise, +): Promise { + return aw.principalKind === 'role' ? resolveRole(aw.principalRef) : [aw.principalRef]; +} + +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; + unreachable: boolean; + 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, unreachable, 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, + unreachable: r.unreachable, + 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 { + // Idempotent against a crash-and-resume: if an open await already exists for this + // (run, step), the partial unique index makes the insert a no-op and we return the + // existing row — never a duplicate await (and so never a duplicate notification). + 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) + ON CONFLICT (run_id, step_id) WHERE status = 'waiting' DO NOTHING + RETURNING ${COLS}`, + [ + input.runId, input.stepId, input.principalKind, input.principalRef, input.channelType, + input.message, input.quorum, input.reminderIntervalMs, input.deadlineAt, input.fallbackTransitionId, + ], + ); + if (r.rows[0]) return toAwait(r.rows[0]); + const existing = await this.pool.query( + `SELECT ${COLS} FROM conductor_awaits WHERE run_id = $1 AND step_id = $2 AND status = 'waiting' LIMIT 1`, + [input.runId, input.stepId], + ); + return toAwait(existing.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); + } + + /** + * Candidate awaits whose reminder interval has elapsed (and whose deadline has not yet passed). + * The interval counts from `COALESCE(last_reminder_at, created_at)`, so the FIRST reminder waits a + * full interval after the await opened (the holder was already notified at open time) rather than + * firing on the next tick. + */ + async listRemindersDue(now: Date): Promise { + const r = await this.pool.query( + `SELECT ${COLS} FROM conductor_awaits + WHERE status = 'waiting' + AND reminder_interval_ms IS NOT NULL + AND COALESCE(last_reminder_at, created_at) + (reminder_interval_ms * interval '1 millisecond') <= $1 + AND (deadline_at IS NULL OR deadline_at > $1) + ORDER BY created_at ASC LIMIT 100`, + [now], + ); + return r.rows.map(toAwait); + } + + /** + * Atomically claim a reminder slot: advance `last_reminder_at` to now ONLY if the await is still + * waiting and genuinely due (same predicate as listRemindersDue). Returns true iff this caller won. + * Claim-THEN-send: advancing the clock before delivery means a send/record failure (or a crash, or + * a second replica) can re-deliver at most once per interval rather than every tick (at-most-once + * nudges — losing one reminder on a crash is safer than a per-minute storm). Replica-safe. + */ + async claimReminderDue(awaitId: string, now: Date): Promise { + const r = await this.pool.query( + `UPDATE conductor_awaits SET last_reminder_at = $2 + WHERE id = $1 + AND status = 'waiting' + AND reminder_interval_ms IS NOT NULL + AND COALESCE(last_reminder_at, created_at) + (reminder_interval_ms * interval '1 millisecond') <= $2 + AND (deadline_at IS NULL OR deadline_at > $2)`, + [awaitId, now], + ); + return (r.rowCount ?? 0) > 0; + } + + /** Set the `unreachable` operator signal after a delivery attempt (false clears a stale flag). */ + async setReminderUnreachable(awaitId: string, unreachable: boolean): Promise { + await this.pool.query(`UPDATE conductor_awaits SET unreachable = $2 WHERE id = $1`, [awaitId, unreachable]); + } + + async recordResponse(awaitId: string, responderId: string, response: JsonValue): Promise { + // Only record while the await is still open. A response arriving after close (e.g. a double-click + // once the run already resumed) must not rewrite the audit row the decision was based on. + await this.pool.query( + `INSERT INTO conductor_await_responses (await_id, responder_id, response) + SELECT $1, $2, $3::jsonb + WHERE EXISTS (SELECT 1 FROM conductor_awaits WHERE id = $1 AND status = 'waiting') + ON CONFLICT (await_id, responder_id) DO UPDATE SET response = EXCLUDED.response, responded_at = now()`, + [awaitId, responderId, JSON.stringify(response)], + ); + } + + /** Every response recorded for an await (for quorum='all' completeness + the aggregate result). */ + async listResponses(awaitId: string): Promise> { + const r = await this.pool.query<{ responder_id: string; response: JsonValue }>( + `SELECT responder_id, response FROM conductor_await_responses WHERE await_id = $1 ORDER BY responded_at ASC`, + [awaitId], + ); + return r.rows.map((row) => ({ responderId: row.responder_id, response: row.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..940d85d1 --- /dev/null +++ b/middleware/src/conductor/awaitWorker.ts @@ -0,0 +1,148 @@ +import { resolveAwaitHolders } from './awaitStore.js'; +import type { ConductorAwait, ConductorAwaitStore } from './awaitStore.js'; +import type { ConductorRunExecutor } from './runExecutor.js'; +import type { ConductorChannelBindingStore } from './channelBindingStore.js'; + +interface ReminderDeps { + bindingStore: ConductorChannelBindingStore; + resolveRoleHolders: (roleKey: string) => Promise; + getProactiveSender: (channel: string) => ProactiveSenderLike | undefined; +} + +/** Minimal proactive-sender shape (structural) — keeps the worker decoupled from the channel SDK's + * SemanticAnswer type. `{ text }` is a valid SemanticAnswer: `text` is its ONLY required field + * (see `@omadia/channel-sdk` SemanticAnswer in harness-channel-sdk/src/outgoing.ts). If the SDK ever + * adds a second required field this structural call breaks — re-check there before relying on it. */ +export interface ProactiveSenderLike { + send(opts: { conversationRef: unknown; message: { text: string } }): Promise; +} + +/** + * 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; + private ticking = false; + + constructor( + private readonly deps: { + awaitStore: ConductorAwaitStore; + executor: ConductorRunExecutor; + // US5 reminders (all optional — absent ⇒ the worker only does the deadline path): + bindingStore?: ConductorChannelBindingStore; + resolveRoleHolders?: (roleKey: string) => Promise; + getProactiveSender?: (channel: string) => ProactiveSenderLike | undefined; + 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 { + if (this.ticking) return; // never let two ticks overlap (consistent with the resume/schedule workers) + this.ticking = true; + try { + 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)}`); + } + } + + // Reminders (US5) — only when the reminder substrate is wired (binding store + holder resolver + // + proactive sender). Resolve the optional deps into a non-null bundle once, here. + const { bindingStore, resolveRoleHolders, getProactiveSender } = this.deps; + if (bindingStore && resolveRoleHolders && getProactiveSender) { + let reminders: ConductorAwait[]; + try { + reminders = await this.deps.awaitStore.listRemindersDue(now); + } catch (err) { + this.deps.log?.(`[conductor] await worker reminder list failed: ${err instanceof Error ? err.message : String(err)}`); + reminders = []; + } + const reminderDeps: ReminderDeps = { bindingStore, resolveRoleHolders, getProactiveSender }; + for (const aw of reminders) { + try { + // Claim-then-send: atomically advance the reminder clock first; only the winner delivers, + // so two replicas (or a failed send/record) can't re-nudge before the next interval. + if (!(await this.deps.awaitStore.claimReminderDue(aw.id, now))) continue; + await this.sendReminder(aw, reminderDeps); + } catch (err) { + this.deps.log?.(`[conductor] await worker reminder ${aw.id} failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } finally { + this.ticking = false; + } + } + + /** + * Nudge a waiting await's current holder(s) on their bound channel — called only after the reminder + * slot is claimed (clock already advanced). Holders are resolved LIVE (FR-022) so a moved baton + * re-targets, and de-duplicated. For quorum='all' the holders who already responded are dropped (the + * await stays open for the others). Each send is isolated: one holder's stale/deleted ref + * (ProactiveSender.send throws) must not block the others. `unreachable` is set iff at least one + * holder still needs nudging but none could be reached (cleared on a successful delivery). + */ + private async sendReminder(aw: ConductorAwait, deps: ReminderDeps): Promise { + let holders = [...new Set(await resolveAwaitHolders(aw, deps.resolveRoleHolders))]; + if (aw.quorum === 'all') { + const responded = new Set((await this.deps.awaitStore.listResponses(aw.id)).map((r) => r.responderId)); + holders = holders.filter((h) => !responded.has(h)); + } + // Nothing to nudge (all responded, or no current holder) — not an unreachable condition. + if (holders.length === 0) return; + + const sender = deps.getProactiveSender(aw.channelType); + let delivered = 0; + if (sender) { + const refs = await deps.bindingStore.getMany(holders, aw.channelType); + const text = `Reminder: ${aw.message || 'a pending step awaits your response.'}`; + for (const holder of holders) { + const conversationRef = refs.get(holder); + if (!conversationRef) continue; + try { + await sender.send({ conversationRef, message: { text } }); + delivered += 1; + } catch (err) { + this.deps.log?.(`[conductor] await ${aw.id} reminder to '${holder}' failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + await this.deps.awaitStore.setReminderUnreachable(aw.id, delivered === 0); + this.deps.log?.( + delivered > 0 + ? `[conductor] await ${aw.id} reminder sent to ${delivered} holder(s)` + : `[conductor] await ${aw.id} reminder: no reachable holder on '${aw.channelType}' → unreachable`, + ); + } +} diff --git a/middleware/src/conductor/builderAgent.ts b/middleware/src/conductor/builderAgent.ts new file mode 100644 index 00000000..bd8f3022 --- /dev/null +++ b/middleware/src/conductor/builderAgent.ts @@ -0,0 +1,298 @@ +// Conductor conversational builder agent (US7). +// +// Lets an operator co-design a Conductor workflow by chatting. A turn is STATELESS: the client +// sends the current draft `WorkflowGraph` + the user's message (+ prior turns for context); the +// agent proposes a set of structured `GraphPatch`es; we apply them, run `@omadia/conductor-core` +// `validate()` on the result, and return the patched draft + the assistant's prose + the +// validation verdict. The draft itself lives client-side (parity with the visual Designer — both +// surfaces serialize the same graph), so there is no server draft store: this is a pure transform +// from (graph, message) → (graph, reply). +// +// The agent runs via the SAME proven seam Conductor agent-steps use: it resolves an Agent +// (orchestrator instance) in the multi-orchestrator registry and runs a real `bundle.agent.chat` +// turn — NOT a bare model call. The orchestrator is instructed to answer with a single JSON +// object `{ reply, patches }`; we parse robustly (tolerating markdown fences / surrounding prose) +// and self-correct once if the JSON is unparseable or the resulting graph fails validation. +// +// Native tool-calling (one tool per patch op) would be cleaner than JSON-in-text, but the headless +// `bundle.agent.chat` entrypoint is single-shot text; JSON-in-text + a bounded retry is the +// pragmatic seam today. Native tool-calling is a documented follow-up. + +import { randomUUID } from 'node:crypto'; + +import type { OrchestratorRegistry } from '@omadia/orchestrator'; +import { validate } from '@omadia/conductor-core'; +import type { KnownRefs, ValidationResult, WorkflowGraph } from '@omadia/conductor-core'; + +import { applyGraphPatches, emptyGraph, type GraphPatch } from './graphPatch.js'; + +// Bound the builder's LLM turn so a hung orchestrator can't hang the HTTP request (the retry would +// otherwise be two un-timed sequential calls). Mirrors realStepEffects' withTimeout — a 6th copy; +// the shared `withTimeout` util is a documented follow-up. +const DEFAULT_BUILDER_CHAT_TIMEOUT_MS = 180_000; + +async function withTimeout(p: Promise, ms: number, label: string): Promise { + if (!(ms > 0)) return p; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded the ${String(ms)}ms timeout`)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export interface BuilderChatMessage { + role: 'user' | 'assistant'; + text: string; +} + +export interface ConductorBuilderTurnInput { + /** the current draft (may be the empty graph for a fresh build). */ + graph?: WorkflowGraph; + /** the user's instruction for this turn. */ + message: string; + /** prior turns, oldest first, for multi-turn co-design context. */ + history?: BuilderChatMessage[]; +} + +export interface ConductorBuilderTurnResult { + graph: WorkflowGraph; + patches: GraphPatch[]; + reply: string; + validation: ValidationResult; + /** structural problems from applying the patches (unknown ids etc.); empty on a clean apply. */ + applyErrors: string[]; +} + +/** Thrown when no Agent (orchestrator) is available to drive the builder — surfaced as 503. */ +export class ConductorBuilderUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConductorBuilderUnavailableError'; + } +} + +export interface ConductorBuilderAgentDeps { + /** the multi-orchestrator registry — resolves the Agent that drives the builder. */ + getRegistry: () => OrchestratorRegistry | undefined; + /** slug of the Agent (orchestrator) used to drive the builder. Default 'fallback' (the standard one). */ + builderAgentSlug?: string; + /** known references (event ids etc.) so validate() can flag unknown refs and the prompt can list them. */ + knownRefs?: () => KnownRefs | Promise; + /** per-turn LLM call budget in ms (each of the ≤2 attempts). 0 disables. Default 180_000. */ + chatTimeoutMs?: number; + log?: (msg: string) => void; +} + +const DEFAULT_BUILDER_SLUG = 'fallback'; + +/** The minimal SemanticAnswer shape we depend on (the orchestrator chat result). */ +interface ChatAnswerLike { + text: string; +} + +export class ConductorBuilderAgent { + private readonly slug: string; + private readonly chatTimeoutMs: number; + + constructor(private readonly deps: ConductorBuilderAgentDeps) { + this.slug = deps.builderAgentSlug ?? DEFAULT_BUILDER_SLUG; + this.chatTimeoutMs = deps.chatTimeoutMs ?? DEFAULT_BUILDER_CHAT_TIMEOUT_MS; + } + + async runTurn(input: ConductorBuilderTurnInput): Promise { + const registry = this.deps.getRegistry(); + if (!registry) { + throw new ConductorBuilderUnavailableError('orchestrator registry is unavailable (no graphPool / registry not built)'); + } + const entry = registry.get(this.slug); + if (!entry) { + throw new ConductorBuilderUnavailableError(`builder Agent '${this.slug}' is not active in the orchestrator registry`); + } + + const knownRefs = (await this.deps.knownRefs?.()) ?? {}; + const baseGraph = input.graph ?? emptyGraph(); + + // Up to two attempts; the second is a self-correction. We keep the BEST attempt, not the latest: + // a parseable-but-invalid graph (inspectable, useful) must outrank an unparseable retry that left + // the base unchanged (which would otherwise "validate" vacuously). parse > validity > clean-apply. + let best: { result: ConductorBuilderTurnResult; score: number } | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + const correction = attempt === 0 ? null : (best?.result ?? null); + const prompt = buildPrompt(baseGraph, input.message, input.history ?? [], knownRefs, correction); + + this.deps.log?.(`[conductor] builder turn → Agent '${this.slug}' (attempt ${String(attempt + 1)})`); + // Unique per-turn session scope: the orchestrator qualifies sessionScope into its recall/memory + // pipeline, so a constant scope would accumulate state and bleed across operators. A fresh id + // keeps the turn genuinely stateless (history is passed explicitly, never recalled). + const answer = (await withTimeout( + entry.built.bundle.agent.chat({ + userMessage: prompt, + sessionScope: `conductor-builder:${this.slug}:${randomUUID()}`, + }), + this.chatTimeoutMs, + `conductor builder turn (Agent '${this.slug}')`, + )) as ChatAnswerLike; + + const text = typeof answer?.text === 'string' ? answer.text : ''; + const parsed = parseTurnResponse(text); + const applyRes = applyGraphPatches(baseGraph, parsed.patches); + const validation = validate(applyRes.graph, knownRefs); + + const result: ConductorBuilderTurnResult = { + graph: applyRes.graph, + patches: parsed.patches, + reply: parsed.reply || text.trim(), + validation, + applyErrors: applyRes.errors, + }; + const score = (parsed.ok ? 4 : 0) + (validation.ok ? 2 : 0) + (applyRes.errors.length === 0 ? 1 : 0); + if (!best || score > best.score) best = { result, score }; + + if (score === 7) return result; // clean parse + valid graph + no apply errors → accept immediately + } + + // The loop always runs attempt 0 and sets `best`, so this is non-null on exit. + return (best as { result: ConductorBuilderTurnResult }).result; + } +} + +// ── response parsing ──────────────────────────────────────────────────────── + +interface ParsedResponse { + ok: boolean; // true iff we extracted a well-formed {reply, patches} object + reply: string; + patches: GraphPatch[]; +} + +/** + * Extract `{ reply, patches }` from an orchestrator's prose answer. Tolerates ```json fences and + * leading/trailing commentary by scanning EVERY balanced top-level `{...}` block (not just the + * first) and returning the first that JSON-parses into a well-formed response — so prose that + * contains a stray `{` (e.g. "set the guard to {op:eq}. Here is the patch: {...}") before the real + * object doesn't drop the patches. Never throws — no parseable object yields `ok:false` with the + * raw text as the reply so the user still sees the agent's words and the turn can self-correct. + */ +export function parseTurnResponse(text: string): ParsedResponse { + for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) { + const block = balancedObjectFrom(text, start); + if (block === null) continue; + try { + const obj = JSON.parse(block) as unknown; + if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) { + const rec = obj as Record; + // A response with a reply and/or a patches array is well-formed even if patches is empty + // (the agent may just be asking a clarifying question). + if (typeof rec.reply === 'string' || Array.isArray(rec.patches)) { + return { + ok: true, + reply: typeof rec.reply === 'string' ? rec.reply : '', + patches: Array.isArray(rec.patches) ? (rec.patches as GraphPatch[]) : [], + }; + } + } + } catch { + /* not valid JSON from this start — try the next `{` */ + } + } + return { ok: false, reply: text.trim(), patches: [] }; +} + +/** The balanced `{...}` block starting at `start`, ignoring braces inside JSON strings; null if unbalanced. */ +function balancedObjectFrom(text: string, start: number): string | null { + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; +} + +// ── prompt ────────────────────────────────────────────────────────────────── + +function buildPrompt( + graph: WorkflowGraph, + message: string, + history: BuilderChatMessage[], + knownRefs: KnownRefs, + correction: ConductorBuilderTurnResult | null, +): string { + const historyBlock = + history.length > 0 + ? history.map((m) => `${m.role === 'user' ? 'User' : 'Builder'}: ${m.text}`).join('\n') + : '(no prior turns)'; + const eventIds = knownRefs.eventIds && knownRefs.eventIds.length > 0 ? knownRefs.eventIds.join(', ') : '(none declared)'; + + const correctionBlock = correction + ? `\nYOUR PREVIOUS RESPONSE NEEDS CORRECTION. ${correctionSummary(correction)}\nFix it and respond again with ONLY the JSON object.\n` + : ''; + + return [ + 'You are the Conductor workflow builder. You help an operator design a deterministic workflow GRAPH by conversation.', + 'Each turn you propose structured PATCHES that edit a draft graph, and a short natural-language reply explaining what you did or asking a clarifying question.', + '', + 'GRAPH SHAPE (JSON):', + ' WorkflowGraph = { entryStepId: string, steps: Step[], transitions: Transition[], triggers: Trigger[] }', + " Step = { id: string, kind: 'agent'|'action'|'human', agentId?, actionId?, prompt?, input?, human?, postcondition?, fallbackTransitionId? }", + " - kind 'agent': set agentId (the slug of an Agent/orchestrator, e.g. 'fallback') and an optional prompt.", + " - kind 'action': set actionId (a connector/tool id) and an optional input object.", + " - kind 'human': set human = { principal: {kind:'user'|'role', ref}, channel, message, reminderInterval?, deadline?, quorum?:'any'|'all' }.", + ' Transition = { id: string, source: stepId, target: stepId, guard? }', + " Trigger = { id: string, kind: 'manual'|'event'|'cron', eventId?, cron? }", + ' Predicate (for guard/postcondition) is a JSON AST, e.g. {"op":"eq","path":"stepResult.approved","value":true}. NEVER write code; only this AST.', + '', + 'PATCH OPS (emit an array of these):', + ' { "op":"add_step", "step": Step }', + ' { "op":"update_step", "id": stepId, "patch": Partial }', + ' { "op":"remove_step", "id": stepId }', + ' { "op":"add_transition", "transition": Transition }', + ' { "op":"remove_transition", "id": transitionId }', + ' { "op":"set_trigger", "trigger": Trigger }', + ' { "op":"set_entry", "stepId": stepId }', + '', + `KNOWN EVENT IDS (for event triggers): ${eventIds}`, + "If unsure of an Agent slug, use 'fallback' (the standard orchestrator).", + '', + 'CURRENT DRAFT GRAPH:', + '```json', + JSON.stringify(graph, null, 2), + '```', + '', + 'CONVERSATION SO FAR:', + historyBlock, + '', + `USER MESSAGE: ${message}`, + correctionBlock, + 'Respond with ONLY a single JSON object, no markdown fences, of the form:', + '{ "reply": "", "patches": [ , ... ] }', + 'If you only need to ask a question, return an empty patches array. Keep step and transition ids short and stable; reuse existing ids when editing.', + ].join('\n'); +} + +function correctionSummary(prev: ConductorBuilderTurnResult): string { + const parts: string[] = []; + if (!prev.validation.ok) { + parts.push(`The graph failed validation: ${prev.validation.errors.map((e) => `${e.code} (${e.message})`).join('; ')}.`); + } + if (prev.applyErrors.length > 0) { + parts.push(`Some patches could not apply: ${prev.applyErrors.join('; ')}.`); + } + if (parts.length === 0) parts.push('The previous response was not valid JSON of the form { "reply", "patches" }.'); + return parts.join(' '); +} diff --git a/middleware/src/conductor/channelBindingStore.ts b/middleware/src/conductor/channelBindingStore.ts new file mode 100644 index 00000000..3a59ac39 --- /dev/null +++ b/middleware/src/conductor/channelBindingStore.ts @@ -0,0 +1,50 @@ +import type { Pool } from 'pg'; + +/** + * Maps a user (in Conductor's identity space — the same id used as a role holder / await responder, + * e.g. session.sub / email / channel-native id) to the opaque channel conversation reference needed + * to proactively reach them (US5 reminders). Populated per inbound turn from the kernel-side + * `captureRoutineTurn` hook; read by the await worker when sending a reminder. + * + * IDENTITY CONTRACT: the `userId` key here MUST be the same id a human step's principal resolves to + * (a `user:` ref, or a role holder id). The capture hook writes the channel-native turn user id; + * delivery therefore resolves only when role/user principals are expressed in that same id space — + * otherwise the reminder is flagged `unreachable` (never silently dropped, never a hang). + */ +export class ConductorChannelBindingStore { + constructor(private readonly pool: Pool) {} + + /** Upsert a user's conversation reference for a channel (idempotent per inbound turn). */ + async upsert(userId: string, channelType: string, conversationRef: unknown): Promise { + if (!userId || !channelType) return; + await this.pool.query( + `INSERT INTO conductor_channel_bindings (user_id, channel_type, conversation_ref) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (user_id, channel_type) + DO UPDATE SET conversation_ref = EXCLUDED.conversation_ref, updated_at = now()`, + [userId, channelType, JSON.stringify(conversationRef ?? null)], + ); + } + + /** The conversation reference to reach `userId` on `channelType`, or null if none is bound. */ + async get(userId: string, channelType: string): Promise { + const r = await this.pool.query<{ conversation_ref: unknown }>( + `SELECT conversation_ref FROM conductor_channel_bindings WHERE user_id = $1 AND channel_type = $2`, + [userId, channelType], + ); + return r.rows[0]?.conversation_ref ?? null; + } + + /** Conversation references for many users on one channel in a single query (reminder fan-out). */ + async getMany(userIds: string[], channelType: string): Promise> { + const out = new Map(); + if (userIds.length === 0) return out; + const r = await this.pool.query<{ user_id: string; conversation_ref: unknown }>( + `SELECT user_id, conversation_ref FROM conductor_channel_bindings + WHERE channel_type = $2 AND user_id = ANY($1::text[])`, + [userIds, channelType], + ); + for (const row of r.rows) out.set(row.user_id, row.conversation_ref); + return out; + } +} 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/graphPatch.ts b/middleware/src/conductor/graphPatch.ts new file mode 100644 index 00000000..2ba731a5 --- /dev/null +++ b/middleware/src/conductor/graphPatch.ts @@ -0,0 +1,184 @@ +// Conductor draft-graph patch algebra (US7 conversational builder). +// +// The conversational builder agent authors a Conductor workflow by emitting a small, +// closed set of structured patches over a draft `WorkflowGraph`. These seven ops are the +// complete basis for mutating a graph: anything else is a composition of them. Application +// is a pure function — no I/O, no validation — so it is trivially unit-testable. The caller +// (the builder agent / route) runs `@omadia/conductor-core` `validate()` on the *result*; +// this module only applies the edit and reports per-patch structural problems (e.g. an +// update to a step that does not exist), never silently dropping a malformed op. +// +// Deliberately kept kernel-side rather than in the pure `@omadia/conductor-core` engine: +// patches are an LLM-authoring concern, not an execution concern, so the engine's surface +// stays minimal (validate + nextStep). + +import type { Step, Transition, Trigger, WorkflowGraph } from '@omadia/conductor-core'; + +export type GraphPatch = + | { op: 'add_step'; step: Step } + | { op: 'update_step'; id: string; patch: Partial } + | { op: 'remove_step'; id: string } + | { op: 'add_transition'; transition: Transition } + | { op: 'remove_transition'; id: string } + | { op: 'set_trigger'; trigger: Trigger } + | { op: 'set_entry'; stepId: string }; + +export interface ApplyResult { + graph: WorkflowGraph; + /** number of patches that applied cleanly. */ + applied: number; + /** human-readable problems for ops that referenced missing nodes / were malformed. */ + errors: string[]; +} + +/** An empty draft — the starting point for a brand-new conversational build. */ +export function emptyGraph(): WorkflowGraph { + return { entryStepId: '', steps: [], transitions: [], triggers: [] }; +} + +function clone(graph: WorkflowGraph): WorkflowGraph { + // structuredClone is available on Node 18+ and keeps the apply pure (no aliasing into the + // caller's draft). Steps/transitions are plain JSON, so this is a faithful deep copy. + return structuredClone(graph); +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Apply an ordered list of patches to a draft graph, purely. Returns a NEW graph (the input is + * never mutated), the count that applied, and a list of structural errors for ops that could + * not apply (unknown ids, duplicate ids, malformed shapes). Ops that error are skipped; the + * rest still apply, so a single bad patch never discards a whole turn's work. + */ +export function applyGraphPatches(base: WorkflowGraph, patches: readonly GraphPatch[]): ApplyResult { + const graph = clone(base); + // Normalize a possibly-malformed input graph (a truthy `{}` body reaches here without being + // replaced by emptyGraph()) so the ops below never throw on a non-array — a bad body becomes a + // clean validation failure downstream, not a 500. + if (!Array.isArray(graph.steps)) graph.steps = []; + if (!Array.isArray(graph.transitions)) graph.transitions = []; + if (!Array.isArray(graph.triggers)) graph.triggers = []; + const errors: string[] = []; + let applied = 0; + + for (const patch of patches) { + // A single patch can never throw out of the loop — the contract is "skip the bad op, apply the + // rest". Any unexpected error (e.g. a malformed nested shape) is recorded as an apply error so + // the turn can self-correct instead of 500-ing. + try { + if (!isObject(patch) || typeof patch.op !== 'string') { + errors.push(`malformed patch: ${JSON.stringify(patch)}`); + continue; + } + switch (patch.op) { + case 'add_step': { + const step = patch.step; + if (!isObject(step) || typeof step.id !== 'string' || !step.id) { + errors.push('add_step: step.id is required'); + break; + } + if (graph.steps.some((s) => s.id === step.id)) { + errors.push(`add_step: step '${step.id}' already exists`); + break; + } + graph.steps.push(step); + if (!graph.entryStepId) graph.entryStepId = step.id; // first step becomes entry by default + applied += 1; + break; + } + case 'update_step': { + const idx = graph.steps.findIndex((s) => s.id === patch.id); + const existing = idx === -1 ? undefined : graph.steps[idx]; + if (!existing) { + errors.push(`update_step: step '${patch.id}' not found`); + break; + } + // `patch` is LLM-authored — tolerate a missing/non-object `patch.patch` instead of throwing. + const fields = isObject(patch.patch) ? (patch.patch as Partial) : {}; + // id and kind are NEVER changed by an update (a kind change would orphan the prior kind's + // fields) — use remove_step + add_step to change a step's kind. + const next: Step = { ...existing, ...fields, id: existing.id, kind: existing.kind }; + graph.steps[idx] = next; + applied += 1; + break; + } + case 'remove_step': { + const before = graph.steps.length; + graph.steps = graph.steps.filter((s) => s.id !== patch.id); + if (graph.steps.length === before) { + errors.push(`remove_step: step '${patch.id}' not found`); + break; + } + // Drop transitions that dangle off the removed step so the result stays coherent. + graph.transitions = graph.transitions.filter((tr) => tr.source !== patch.id && tr.target !== patch.id); + // Clear any surviving step's fallbackTransitionId that pointed at a now-dropped transition, + // so the result doesn't validate-fail on `fallback_unknown_transition`. + const liveTransitionIds = new Set(graph.transitions.map((tr) => tr.id)); + graph.steps = graph.steps.map((s) => + s.fallbackTransitionId && !liveTransitionIds.has(s.fallbackTransitionId) + ? { ...s, fallbackTransitionId: undefined } + : s, + ); + // Re-home the entry pointer if the removed step was the entry. + if (graph.entryStepId === patch.id) graph.entryStepId = graph.steps[0]?.id ?? ''; + applied += 1; + break; + } + case 'add_transition': { + const tr = patch.transition; + if (!isObject(tr) || typeof tr.id !== 'string' || !tr.id) { + errors.push('add_transition: transition.id is required'); + break; + } + if (graph.transitions.some((t) => t.id === tr.id)) { + errors.push(`add_transition: transition '${tr.id}' already exists`); + break; + } + graph.transitions.push(tr); + applied += 1; + break; + } + case 'remove_transition': { + const before = graph.transitions.length; + graph.transitions = graph.transitions.filter((t) => t.id !== patch.id); + if (graph.transitions.length === before) { + errors.push(`remove_transition: transition '${patch.id}' not found`); + break; + } + applied += 1; + break; + } + case 'set_trigger': { + const tr = patch.trigger; + if (!isObject(tr) || typeof tr.id !== 'string' || typeof tr.kind !== 'string') { + errors.push('set_trigger: trigger.id and trigger.kind are required'); + break; + } + // The Designer models a single trigger; keep parity by replacing rather than appending. + graph.triggers = [tr]; + applied += 1; + break; + } + case 'set_entry': { + if (typeof patch.stepId !== 'string' || !patch.stepId) { + errors.push('set_entry: stepId is required'); + break; + } + graph.entryStepId = patch.stepId; // validate() flags an unknown entry — not enforced here + applied += 1; + break; + } + default: { + errors.push(`unknown patch op: ${String((patch as { op: unknown }).op)}`); + } + } + } catch (err) { + const op = isObject(patch) ? String(patch.op) : 'unknown'; + errors.push(`patch '${op}' failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return { graph, applied, errors }; +} diff --git a/middleware/src/conductor/index.ts b/middleware/src/conductor/index.ts new file mode 100644 index 00000000..bc075af2 --- /dev/null +++ b/middleware/src/conductor/index.ts @@ -0,0 +1,140 @@ +import { randomUUID } from 'node:crypto'; + +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 { ConductorScheduleStore } from './scheduleStore.js'; +import { ConductorChannelBindingStore } from './channelBindingStore.js'; +import { ConductorRunExecutor } from './runExecutor.js'; +import { ConductorAwaitWorker } from './awaitWorker.js'; +import type { ProactiveSenderLike } from './awaitWorker.js'; +import { ConductorRunResumeWorker } from './runResumeWorker.js'; +import { ConductorScheduleWorker } from './scheduleWorker.js'; +import { ConductorEventRouter } from './eventRouter.js'; +import { RealStepEffects } from './realStepEffects.js'; +import { ConductorBuilderAgent } from './builderAgent.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 { ConductorRunResumeWorker } from './runResumeWorker.js'; +export { ConductorScheduleWorker } from './scheduleWorker.js'; +export { ConductorScheduleStore } from './scheduleStore.js'; +export { ConductorChannelBindingStore } from './channelBindingStore.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 { ConductorBuilderAgent, ConductorBuilderUnavailableError } from './builderAgent.js'; +export type { ConductorBuilderTurnInput, ConductorBuilderTurnResult, BuilderChatMessage } from './builderAgent.js'; +export { applyGraphPatches, emptyGraph } from './graphPatch.js'; +export type { GraphPatch } from './graphPatch.js'; +export { createConductorRouter } from './routes.js'; + +export interface ConductorWiring { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + awaitStore: ConductorAwaitStore; + roleStore: ConductorRoleStore; + scheduleStore: ConductorScheduleStore; + channelBindingStore: ConductorChannelBindingStore; + executor: ConductorRunExecutor; + awaitWorker: ConductorAwaitWorker; + resumeWorker: ConductorRunResumeWorker; + scheduleWorker: ConductorScheduleWorker; + eventRouter: ConductorEventRouter; + builderAgent: ConductorBuilderAgent; +} + +/** + * 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; + /** read model of the event-emit catalog (declared `event_emit` capabilities) for the Designer. */ + eventCatalog?: { list(): string[]; byPluginId(): Record }; + /** resolves a proactive sender for a channel (US5 reminders) — from the routines senderRegistry. */ + getProactiveSender?: (channel: string) => ProactiveSenderLike | undefined; + 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 scheduleStore = new ConductorScheduleStore(deps.pool); + const channelBindingStore = new ConductorChannelBindingStore(deps.pool); + const executor = new ConductorRunExecutor({ + workflowStore, + runStore, + awaitStore, + effects: new RealStepEffects({ + getRegistry: deps.getRegistry, + ...(deps.invokeAction ? { invokeAction: deps.invokeAction } : {}), + log, + }), + resolveRoleHolders: (key) => roleStore.resolve(key), // quorum='all' required-responder resolution + log, + }); + + // Deadline + reminder worker — fires the in-graph fallback on timeout (US5) and nudges waiting + // holders on their channel when a reminder interval elapses (reminder deps optional / graphPool-gated). + const awaitWorker = new ConductorAwaitWorker({ + awaitStore, + executor, + bindingStore: channelBindingStore, + resolveRoleHolders: (key) => roleStore.resolve(key), + ...(deps.getProactiveSender ? { getProactiveSender: deps.getProactiveSender } : {}), + log, + }); + awaitWorker.start(); + + // Resume worker — re-drives runs orphaned by a process restart (US2 / SC-002). + const resumeWorker = new ConductorRunResumeWorker({ runStore, executor, claimerId: randomUUID(), log }); + resumeWorker.start(); + + // Schedule worker — fires workflows on their cron triggers (US4 cron). + const scheduleWorker = new ConductorScheduleWorker({ scheduleStore, executor, log }); + scheduleWorker.start(); + + // Event router — a domain event starts every subscribed workflow's run (US4). + const eventRouter = new ConductorEventRouter({ workflowStore, executor, log }); + + // Conversational builder agent (US7) — drives draft co-design via a registry Agent turn. Known + // refs are sourced live from the event catalog so the builder + validate can flag unknown events. + const builderAgent = new ConductorBuilderAgent({ + getRegistry: deps.getRegistry, + knownRefs: () => ({ eventIds: deps.eventCatalog?.list() ?? [] }), + log, + }); + + deps.app.use( + '/api/v1/operator/conductors', + deps.requireAuth, + createConductorRouter({ workflowStore, runStore, awaitStore, roleStore, scheduleStore, executor, eventRouter, eventCatalog: deps.eventCatalog, builderAgent }), + ); + + return { workflowStore, runStore, awaitStore, roleStore, scheduleStore, channelBindingStore, executor, awaitWorker, resumeWorker, scheduleWorker, eventRouter, builderAgent }; +} 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/migrations/0004_conductor_run_claim.sql b/middleware/src/conductor/migrations/0004_conductor_run_claim.sql new file mode 100644 index 00000000..2d699163 --- /dev/null +++ b/middleware/src/conductor/migrations/0004_conductor_run_claim.sql @@ -0,0 +1,23 @@ +-- Conductor — run-resume claim/lease columns (US2 durability tail / SC-002). +-- A run is driven in-process; a process restart leaves its 'running' row orphaned +-- (nothing re-drives it). The run-resume worker claims stale 'running' rows and +-- re-drives them from current_step_id. claimed_by is a per-drive LEASE token: every +-- step write is fenced on `WHERE claimed_by = `, so a driver that has been +-- superseded by a worker steal aborts on its next write instead of double-driving. +-- A live drive heartbeats claimed_at every step, so a row is "stale" only when its +-- owning process has gone away. Forward-only, idempotent. +ALTER TABLE conductor_runs ADD COLUMN IF NOT EXISTS claimed_by UUID; +ALTER TABLE conductor_runs ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ; + +-- The claim scan filters status='running' AND is_dry_run=false, ranges on claimed_at, +-- and orders by started_at — cover all of it in one partial index. +CREATE INDEX IF NOT EXISTS conductor_runs_running_idx + ON conductor_runs(claimed_at, started_at) + WHERE status = 'running' AND is_dry_run = false; + +-- Make re-opening a human await idempotent: if a crash between awaitStore.create() +-- and runStore.park() leaves a run 'running' at a human step, the resume re-drive must +-- not open a SECOND await for the same (run, step). One open await per step, enforced. +CREATE UNIQUE INDEX IF NOT EXISTS conductor_awaits_open_uniq + ON conductor_awaits(run_id, step_id) + WHERE status = 'waiting'; diff --git a/middleware/src/conductor/migrations/0005_channel_binding_text_id.sql b/middleware/src/conductor/migrations/0005_channel_binding_text_id.sql new file mode 100644 index 00000000..15e6446b --- /dev/null +++ b/middleware/src/conductor/migrations/0005_channel_binding_text_id.sql @@ -0,0 +1,7 @@ +-- Conductor US5 reminders — align the channel-binding key with the rest of Conductor's identity model. +-- Role holders (conductor_role_assignments.holder_id) and await responders (conductor_await_responses +-- .responder_id) are TEXT (session.sub / email / channel-native id), not UUID. The original +-- conductor_channel_bindings.user_id was UUID, which could never match a holder id. Switch it to TEXT +-- so a reminder can resolve holder -> conversation_ref. The table is empty (no store wrote to it yet), +-- so the type change is safe. Forward-only, idempotent. +ALTER TABLE conductor_channel_bindings ALTER COLUMN user_id TYPE 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..72a9850e --- /dev/null +++ b/middleware/src/conductor/realStepEffects.ts @@ -0,0 +1,151 @@ +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 : {}; +} + +/** Thrown when a single step's I/O exceeds the per-step hard budget (`stepTimeoutMs`). */ +export class StepTimeoutError extends Error { + constructor(label: string, ms: number) { + super(`${label} exceeded the ${ms}ms step timeout`); + this.name = 'StepTimeoutError'; + } +} + +/** + * Bound a step's I/O so no single step runs unbounded. This is the resume-safety pair to the run + * lease: with `stepTimeoutMs` < the resume worker's `staleMs`, a step always settles (or fails) before + * a stalled run could be claimed and re-driven — closing the last at-least-once window (a still-running + * step being re-executed). Best-effort: the underlying turn isn't force-killed, but the run stops + * waiting and records the step `failed`, so it can take its fallback deterministically. + * + * NOTE: this Promise.race timeout shape is duplicated in toolPluginRuntime / dynamicAgentRuntime / + * previewRuntime / migrationRunner. Follow-up: extract one shared `withTimeout` util. This copy adds + * the typed StepTimeoutError + the `ms<=0` disable guard the others lack. + */ +async function withTimeout(p: Promise, ms: number, label: string): Promise { + if (!(ms > 0)) return p; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + // Deliberately NOT unref'd: this timer MUST fire to enforce the budget (unlike the workers' + // long-lived poll timers). It is always cleared in `finally` once the race settles. + timer = setTimeout(() => reject(new StepTimeoutError(label, ms)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +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; + /** Per-step hard budget in ms. MUST be < the resume worker's staleMs (default 900_000). 0 disables. */ + stepTimeoutMs?: number; + log?: (msg: string) => void; +} + +// 10 min — generous for an agent turn, and strictly < the resume worker's DEFAULT_RESUME_STALE_MS +// (15 min). That ordering is the resume-safety invariant; a test asserts it so a tweak can't break it. +export const DEFAULT_STEP_TIMEOUT_MS = 600_000; + +/** + * 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 { + private readonly stepTimeoutMs: number; + + constructor(private readonly deps: RealStepEffectsDeps) { + this.stepTimeoutMs = deps.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS; + } + + 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 withTimeout( + entry.built.bundle.agent.chat({ + userMessage, + sessionScope: `conductor:${meta.runId}:${step.id}`, + }), + this.stepTimeoutMs, + `agent step '${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 withTimeout(this.deps.invokeAction(toolId, input), this.stepTimeoutMs, `action step '${step.id}'`); + 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..0ed02a5d --- /dev/null +++ b/middleware/src/conductor/routes.ts @@ -0,0 +1,370 @@ +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 { ConductorBuilderUnavailableError } from './builderAgent.js'; +import type { BuilderChatMessage, ConductorBuilderAgent } from './builderAgent.js'; +import { emptyGraph } from './graphPatch.js'; +import type { ConductorWorkflowStore } from './workflowStore.js'; +import type { ConductorRunStore } from './runStore.js'; +import { resolveAwaitHolders } from './awaitStore.js'; +import type { ConductorAwaitStore } from './awaitStore.js'; +import type { ConductorRoleStore } from './roleStore.js'; +import type { ConductorScheduleStore } from './scheduleStore.js'; +import type { ConductorEventRouter } from './eventRouter.js'; +import { + AwaitNotPendingError, + WorkflowDisabledError, + WorkflowNotFoundError, + WorkflowNotPublishedError, +} from './runExecutor.js'; +import type { ConductorRunExecutor } 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; + scheduleStore: ConductorScheduleStore; + executor: ConductorRunExecutor; + eventRouter: ConductorEventRouter; + /** Read model of declared emittable events (US4) — powers the Designer's event-trigger picker. */ + eventCatalog?: { list(): string[]; byPluginId(): Record }; + /** Conversational builder agent (US7) — co-design a draft graph by chat. Optional: absent on hosts without a registry. */ + builderAgent?: ConductorBuilderAgent; +} + +/** + * 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. + */ +// Caps on conversational-builder input — the message + history + graph are all inlined verbatim into +// a prompt sent to the LLM up to twice per request, so unbounded input is an authenticated +// cost/latency amplification vector. Generous enough for real workflows, tight enough to bound cost. +const MAX_BUILDER_MESSAGE_CHARS = 8_000; +const MAX_BUILDER_HISTORY_TURNS = 20; +const MAX_BUILDER_GRAPH_BYTES = 200_000; + +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, + // Reconcile cron schedules atomically with the publish: a reconcile failure rolls the whole + // publish back rather than leaving stale schedules firing (e.g. a just-removed cron trigger). + onPublished: (client, workflowId) => deps.scheduleStore.reconcileOnClient(client, workflowId, graph), + }); + 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) }); + } + }); + + // Event catalog (US4) — the events plugins declared they emit, for the Designer's trigger picker. + // Registered before '/:slug' so it is not swallowed by the catch-all workflow route. + router.get('/events/catalog', (_req: Request, res: Response): void => { + try { + res.json({ events: deps.eventCatalog?.list() ?? [], byPlugin: deps.eventCatalog?.byPluginId() ?? {} }); + } catch (err) { + res.status(500).json({ code: 'conductor.event_catalog_failed', message: errMsg(err) }); + } + }); + + // Conversational builder turn (US7): (draft graph + message) → patched draft + reply + validation. + // Stateless — the draft lives client-side (parity with the visual Designer); this just transforms it. + router.post('/builder/turn', async (req: Request, res: Response): Promise => { + if (!deps.builderAgent) { + res.status(503).json({ code: 'conductor.builder_unavailable', message: 'conversational builder is not wired (no orchestrator registry)' }); + return; + } + const body = asObject(req.body); + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (!message) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'message is required' }); + return; + } + if (message.length > MAX_BUILDER_MESSAGE_CHARS) { + res.status(400).json({ code: 'conductor.invalid_input', message: `message exceeds ${String(MAX_BUILDER_MESSAGE_CHARS)} characters` }); + return; + } + const graph = (body.graph as unknown as WorkflowGraph | undefined) ?? emptyGraph(); + if (JSON.stringify(graph).length > MAX_BUILDER_GRAPH_BYTES) { + res.status(400).json({ code: 'conductor.invalid_input', message: 'draft graph is too large' }); + return; + } + // Keep only well-formed {role,text} turns (a null/garbage element would otherwise crash prompt + // assembly) and cap to the most recent N so prompt size stays bounded. + const history: BuilderChatMessage[] = (Array.isArray(body.history) ? body.history : []) + .filter((m) => { + const r = asObject(m); + return typeof r.text === 'string' && (r.role === 'user' || r.role === 'assistant'); + }) + .slice(-MAX_BUILDER_HISTORY_TURNS) + .map((m) => { + const r = asObject(m); + return { role: r.role as 'user' | 'assistant', text: r.text as string }; + }); + try { + const result = await deps.builderAgent.runTurn({ graph, message, history }); + res.json(result); + } catch (err) { + if (err instanceof ConductorBuilderUnavailableError) { + res.status(503).json({ code: 'conductor.builder_unavailable', message: err.message }); + } else { + console.error('[conductor] builder turn failed:', err); + res.status(500).json({ code: 'conductor.builder_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, // includes `unreachable` so the operator sees awaits whose holders have no channel binding + resolvedHolders: await resolveAwaitHolders(aw, (key) => deps.roleStore.resolve(key)), + })), + ); + 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..efb351c5 --- /dev/null +++ b/middleware/src/conductor/runExecutor.ts @@ -0,0 +1,461 @@ +import { randomUUID } from 'node:crypto'; + +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 { RunLeaseLostError } 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 : {}; +} + +/** + * A human response counts as approval unless it is explicitly `{ approved: false }` (the reject + * button's payload). Fail-open by design: an absent/garbage/missing flag counts as approval, and + * only a strict boolean `false` is a reject (the inbox sends a typed boolean). A guard step's + * postcondition can still inspect the raw `responses` map for finer policy. + */ +function isApproved(response: JsonValue): boolean { + return !( + typeof response === 'object' && + response !== null && + !Array.isArray(response) && + (response as JsonObject).approved === false + ); +} + +/** 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; + /** Late-bound role→holders resolver — the required responders for a quorum='all' role await. + * Required (not optional) so a role-based 'all' can never silently degrade to 'any' when unwired. */ + private readonly resolveRoleHolders: (roleKey: string) => Promise; + private readonly log: (msg: string) => void; + + constructor(deps: { + workflowStore: ConductorWorkflowStore; + runStore: ConductorRunStore; + awaitStore: ConductorAwaitStore; + effects: StepEffects; + resolveRoleHolders: (roleKey: string) => Promise; + log?: (msg: string) => void; + }) { + this.workflowStore = deps.workflowStore; + this.runStore = deps.runStore; + this.awaitStore = deps.awaitStore; + this.effects = deps.effects; + this.resolveRoleHolders = deps.resolveRoleHolders; + 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 lease = randomUUID(); + 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, + claimedBy: lease, + }); + + if (input.awaitCompletion) { + return this.driveFrom(run.id, version.graph, version.graph.entryStepId, input.payload, lease); + } + const graph = version.graph; + void this.driveFrom(run.id, graph, graph.entryStepId, input.payload, lease).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. Every step/park + * write is fenced on `lease` (the driver's claimed_by token): if a resume worker has taken the + * run over (because this drive stalled past staleMs), the next write throws RunLeaseLostError and + * this superseded driver stops — the new owner is now driving, so the run is never double-driven. + */ + private async driveFrom( + runId: string, + graph: WorkflowGraph, + startStepId: string, + startContext: JsonObject, + lease: string, + ): Promise { + let context: JsonObject = { ...startContext }; + let currentStepId: string | null = startStepId; + let seq = (await this.runStore.stepsForRun(runId)).length; + + try { + 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', claimedBy: lease, + }); + break; + } + + // Human step → durable await + park; resolveAwait/expireAwait resume the run. + if (step.kind === 'human') { + const parked = await this.openHumanAwait(runId, step, context, lease); + if (parked) return (await this.runStore.get(runId)) ?? (await this.requireRun(runId)); + // No reachable holder → don't hang. Take the step's in-graph fallback (FR-024), else fail. + const fb = step.fallbackTransitionId ? graph.transitions.find((tr) => tr.id === step.fallbackTransitionId) : undefined; + await this.runStore.recordStepAndAdvance({ + runId, seq, stepId, actor: { kind: 'human', noHolder: true }, + postconditionOutcome: 'unmet', transitionTaken: fb?.id ?? null, nextStepId: fb?.target ?? null, + context, status: fb ? 'running' : 'failed', claimedBy: lease, + }); + if (!fb) break; + currentStepId = fb.target; + seq += 1; + continue; + } + + 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', claimedBy: lease, + }); + 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, lease); + if (currentStepId) seq += 1; + } + } catch (err) { + if (err instanceof RunLeaseLostError) { + this.log(`[conductor] run ${runId} drive yielded: ${err.message}`); + return (await this.runStore.get(runId)) ?? (await this.requireRun(runId)); + } + throw err; + } + + return (await this.runStore.get(runId)) ?? (await this.requireRun(runId)); + } + + /** + * Re-drive a run left 'running' by a process restart (US2 / SC-002). The run's + * `current_step_id` points at the next not-yet-executed step — `recordStepAndAdvance` + * persists the COMPLETED step and only then advances the pointer — so re-driving from + * there never re-runs a step that was already recorded. The single residual gap is a + * step whose effect ran but whose record never committed (a crash mid-effect): that one + * step is re-executed, the inherent at-least-once limit of crash-resume without effect + * idempotency keys. Called only by the resume worker, after it has claimed the run. + */ + async resumeRun(runId: string, lease: string): Promise { + const run = await this.requireRun(runId); + if (run.status !== 'running') return run; // completed/parked between claim and resume + if (!run.currentStepId) { + // 'running' with no next step is an inconsistent state — finalize rather than hang. + const seq = (await this.runStore.stepsForRun(runId)).length; + await this.runStore.recordStepAndAdvance({ + runId, seq, stepId: '(resume)', actor: { kind: 'resume', reason: 'no_current_step' }, + postconditionOutcome: 'n/a', transitionTaken: null, nextStepId: null, context: run.context, status: 'failed', claimedBy: lease, + }); + return (await this.runStore.get(runId)) ?? run; + } + const { graph } = await this.loadRunGraph(runId); + this.log(`[conductor] resuming run ${runId} at step '${run.currentStepId}'`); + return this.driveFrom(runId, graph, run.currentStepId, run.context, lease); + } + + /** 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); + + // Quorum: 'any' resumes on the first response (feeding that response on). 'all' records each + // response and resumes only once EVERY current holder has answered — holders resolved live, so a + // baton move correctly changes who is required. The aggregate is fed to the engine for 'all'. + let stepResult: JsonValue = response; + if (aw.quorum === 'all') { + const required = aw.principalKind === 'role' + ? await this.resolveRoleHolders(aw.principalRef) + : [aw.principalRef]; + const requiredSet = new Set(required); + const responses = await this.awaitStore.listResponses(awaitId); + const respondedRequired = new Set(responses.map((r) => r.responderId).filter((id) => requiredSet.has(id))); + // Empty `required` (a role with no current holders, e.g. all batons moved away) is NOT + // vacuously complete — that would let one stray response resolve a no-holder await. Such a + // run stays waiting until its deadline fires the fallback (FR-024). + const complete = required.length > 0 && required.every((h) => respondedRequired.has(h)); + if (!complete) { + this.log(`[conductor] await ${awaitId} quorum 'all': ${respondedRequired.size}/${required.length} required responded`); + return (await this.runStore.get(aw.runId)) ?? (await this.requireRun(aw.runId)); + } + // Aggregate over CURRENT required holders only — a holder who lost the baton (or whose stale + // answer predates a baton move) must not skew `approved` or appear in `responses` (review C#1). + const counted = responses.filter((r) => requiredSet.has(r.responderId)); + stepResult = { + quorum: 'all', + approved: counted.every((r) => isApproved(r.response)), + responses: Object.fromEntries(counted.map((r) => [r.responderId, r.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 lease = randomUUID(); + await this.runStore.acquireLease(aw.runId, lease); // take over the parked run's lease + const decision = nextStep(graph, aw.stepId, stepResult, run.context); + const context = this.accumulate(run.context, aw.stepId, stepResult); + const seq = (await this.runStore.stepsForRun(aw.runId)).length; + const next = await this.applyDecision(aw.runId, seq, aw.stepId, { kind: 'human', quorum: aw.quorum, resolvedUserId: responderId }, decision, context, lease); + if (next) return this.driveFrom(aw.runId, graph, next, context, lease); + 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 lease = randomUUID(); + await this.runStore.acquireLease(aw.runId, lease); // take over the parked run's lease + 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', claimedBy: lease, + }); + 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', claimedBy: lease, + }); + this.log(`[conductor] await ${awaitId} timed out → fallback '${fallback.id}' (run ${aw.runId})`); + await this.driveFrom(aw.runId, graph, fallback.target, run.context, lease); + } + + /** + * 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 ──────────────────────────────────────────────────────────────── + + /** Opens a durable await + parks the run. Returns false (without parking) when a role principal has + * NO current holder — nobody could answer, so the caller takes the step's fallback instead of + * hanging the run forever (FR-024). */ + private async openHumanAwait(runId: string, step: Step, context: JsonObject, lease: string): Promise { + const h = step.human; + if (h?.principal.kind === 'role') { + const holders = await this.resolveRoleHolders(h.principal.ref); + if (holders.length === 0) { + this.log(`[conductor] run ${runId} human step '${step.id}' role '${h.principal.ref}' has no current holder`); + return false; + } + } + const deadlineMs = parseIsoDurationMs(h?.deadline ?? null); + const reminderMs = parseIsoDurationMs(h?.reminderInterval ?? null); + // create() is idempotent (one open await per run+step), so a crash-and-resume between + // create and park never doubles the await; park is fenced on the lease. + 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, lease); + this.log(`[conductor] run ${runId} awaiting human at step '${step.id}' (${h?.principal.kind}:${h?.principal.ref})`); + return true; + } + + 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, + lease: string, + ): 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', claimedBy: lease, + }); + 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', claimedBy: lease, + }); + 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', claimedBy: lease, + }); + 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/runResumeWorker.ts b/middleware/src/conductor/runResumeWorker.ts new file mode 100644 index 00000000..be1ee1c8 --- /dev/null +++ b/middleware/src/conductor/runResumeWorker.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; + +import type { ConductorRunStore } from './runStore.js'; +import type { ConductorRunExecutor } from './runExecutor.js'; + +// Resume-safety invariant: this MUST stay strictly greater than RealStepEffects' DEFAULT_STEP_TIMEOUT_MS +// so a live step always settles before its run could be claimed as stale. A test asserts the ordering. +export const DEFAULT_RESUME_STALE_MS = 900_000; // 15 min + +/** + * Re-drives runs left 'running' by a process restart (US2 / SC-002). A run is driven + * in-process by the executor; if the process dies mid-drive, nothing re-drives it — the + * row just stays 'running' forever. This worker is the authoritative resume path + * (per plan §7-E: reconcile is the source of truth, LISTEN/NOTIFY is only an optimisation, + * and it is disabled by default here). + * + * Two guarantees keep it from racing a LIVE drive: + * 1. Heartbeat + staleness. Every step a live drive records refreshes `conductor_runs.claimed_at`. + * This worker claims only rows whose heartbeat is older than `staleMs`. `staleMs` MUST be set + * comfortably larger than the orchestrator's hard per-turn wall-clock cap (a single agent step + * is otherwise unbounded), so an actively-driven run is never mistaken for orphaned. + * 2. Lease fencing. The claim stamps a fresh per-tick lease onto `claimed_by`; the executor fences + * every step write on that token. If a stalled live drive is nonetheless claimed, its next write + * throws RunLeaseLostError and it stops — the new owner drives on. RealStepEffects additionally + * enforces a per-step hard timeout (`stepTimeoutMs`) strictly < `staleMs`, so a step always settles + * (or fails) before its run could be claimed — closing the last single-step at-least-once window. + * + * graphPool-gated by the caller, like the await worker. + */ +export class ConductorRunResumeWorker { + private timer: ReturnType | undefined; + private ticking = false; + /** Runs this worker is currently re-driving — never claim/drive the same run twice at once. */ + private readonly inFlight = new Set(); + + constructor( + private readonly deps: { + runStore: ConductorRunStore; + executor: ConductorRunExecutor; + /** Per-boot id for log attribution (the fencing token is a fresh per-tick lease, not this). */ + claimerId?: string; + intervalMs?: number; + staleMs?: number; + /** Max runs driven concurrently by this worker. */ + maxConcurrent?: number; + 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] run-resume worker started (orphaned-run reconcile)'); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async tick(): Promise { + if (this.ticking) return; // never let two ticks overlap + this.ticking = true; + try { + const staleMs = this.deps.staleMs ?? DEFAULT_RESUME_STALE_MS; // 15 min ≫ any single orchestrator turn + const maxConcurrent = this.deps.maxConcurrent ?? 20; + const slots = maxConcurrent - this.inFlight.size; + if (slots <= 0) return; // already saturated; let in-flight drives finish first + + const lease = randomUUID(); // fresh lease per tick — fences the runs claimed this round + let claimed; + try { + claimed = await this.deps.runStore.claimResumableRuns(lease, staleMs, slots); + } catch (err) { + this.deps.log?.(`[conductor] resume worker claim failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + for (const run of claimed) { + if (this.inFlight.has(run.id)) continue; // defensive: a previous tick still driving it + this.inFlight.add(run.id); + this.deps.log?.(`[conductor] resuming orphaned run ${run.id} at step '${run.currentStepId ?? '?'}'`); + // Fire-and-forget: a drive can take a while (real agent turns). The lease + heartbeat + // protect it from re-claim; the in-flight set bounds concurrency. Errors are logged, never thrown. + void this.deps.executor + .resumeRun(run.id, lease) + .catch((err) => { + this.deps.log?.(`[conductor] resume run ${run.id} failed: ${err instanceof Error ? err.message : String(err)}`); + }) + .finally(() => { + this.inFlight.delete(run.id); + }); + } + } finally { + this.ticking = false; + } + } +} diff --git a/middleware/src/conductor/runStore.ts b/middleware/src/conductor/runStore.ts new file mode 100644 index 00000000..2cff6e8a --- /dev/null +++ b/middleware/src/conductor/runStore.ts @@ -0,0 +1,259 @@ +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'; + +/** + * Thrown when a step/park write is fenced out because the run's lease (`claimed_by`) no + * longer matches the driver's token — i.e. a resume worker has taken the run over. The + * superseded driver catches this and stops, so a run is never driven by two owners at once. + */ +export class RunLeaseLostError extends Error { + constructor(runId: string) { + super(`run '${runId}' lease lost (claimed by another worker)`); + this.name = 'RunLeaseLostError'; + } +} + +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; + /** Lease token of the in-process driver — fences this run's step writes (see RunLeaseLostError). */ + claimedBy: string; + }): Promise { + const r = await this.pool.query( + // claimed_by/claimed_at set now: this run is driven in-process immediately, so the + // resume worker must neither treat it as orphaned nor steal it during its first step. + `INSERT INTO conductor_runs + (workflow_version_id, status, current_step_id, context, trigger_kind, trigger_source, is_dry_run, claimed_by, claimed_at) + VALUES ($1, 'running', $2, $3::jsonb, $4, $5::jsonb, $6, $7, now()) + RETURNING ${RUN_COLS}`, + [ + input.workflowVersionId, + input.entryStepId, + JSON.stringify(input.context), + input.triggerKind, + input.triggerSource === undefined ? null : JSON.stringify(input.triggerSource), + input.isDryRun ?? false, + input.claimedBy, + ], + ); + return toRun(r.rows[0]!); + } + + /** + * Take over a run's lease (used by the human-response / deadline paths, which resume a + * 'waiting' run that no driver currently owns). Unconditional: the resuming caller becomes + * the authoritative owner; its subsequent step writes are then fenced on this token. + */ + async acquireLease(runId: string, claimedBy: string): Promise { + await this.pool.query( + `UPDATE conductor_runs SET claimed_by = $2, claimed_at = now() WHERE id = $1`, + [runId, claimedBy], + ); + } + + 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; + /** Driver's lease token — the run UPDATE is fenced on it (throws RunLeaseLostError on mismatch). */ + claimedBy: string; + }): 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'; + const upd = await client.query( + // Fence on claimed_by: if a resume worker has taken this run over, the lease no longer + // matches and 0 rows update — we roll back (the step row too) and signal RunLeaseLostError. + // While the run stays 'running', refresh claimed_at — the per-step heartbeat the resume + // worker uses to tell a live drive from an orphaned one. + `UPDATE conductor_runs + SET current_step_id = $2, context = $3::jsonb, status = $4, + ended_at = CASE WHEN $5 THEN now() ELSE ended_at END, + claimed_at = CASE WHEN $4 = 'running' THEN now() ELSE claimed_at END + WHERE id = $1 AND claimed_by = $6`, + [input.runId, input.nextStepId, JSON.stringify(input.context), input.status, ended, input.claimedBy], + ); + if ((upd.rowCount ?? 0) === 0) throw new RunLeaseLostError(input.runId); + 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, claimedBy: string): Promise { + const r = await this.pool.query( + `UPDATE conductor_runs SET status = 'waiting', current_step_id = $2, context = $3::jsonb + WHERE id = $1 AND claimed_by = $4`, + [runId, stepId, JSON.stringify(context), claimedBy], + ); + if ((r.rowCount ?? 0) === 0) throw new RunLeaseLostError(runId); + } + + /** + * Atomically claim up to `limit` 'running' runs whose heartbeat (`claimed_at`) is + * stale — i.e. older than `staleMs`, or never set (pre-migration rows). These are + * runs orphaned by a process restart. `FOR UPDATE SKIP LOCKED` + the conditional + * UPDATE make the claim exclusive, so concurrent workers/replicas never both grab + * the same run. Dry-run rows are never resumed (they have no durable effects). + */ + async claimResumableRuns(claimerId: string, staleMs: number, limit: number): Promise { + const safe = Math.min(Math.max(1, Math.trunc(limit)), 200); + const r = await this.pool.query( + `UPDATE conductor_runs + SET claimed_by = $1, claimed_at = now() + WHERE id IN ( + SELECT id FROM conductor_runs + WHERE status = 'running' + AND is_dry_run = false + AND (claimed_at IS NULL OR claimed_at < now() - (interval '1 millisecond' * $2)) + ORDER BY started_at ASC + LIMIT $3 + FOR UPDATE SKIP LOCKED + ) + RETURNING ${RUN_COLS}`, + [claimerId, staleMs, safe], + ); + return r.rows.map(toRun); + } + + 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/scheduleStore.ts b/middleware/src/conductor/scheduleStore.ts new file mode 100644 index 00000000..984bb3ce --- /dev/null +++ b/middleware/src/conductor/scheduleStore.ts @@ -0,0 +1,114 @@ +import type { Pool, PoolClient } from 'pg'; +import type { WorkflowGraph } from '@omadia/conductor-core'; + +import { isValidCron } from '../scheduler/cron.js'; + +export interface ConductorSchedule { + id: string; + workflowId: string; + workflowSlug: string; + /** The workflow header's status — a cron only fires while its workflow is enabled. */ + workflowEnabled: boolean; + cron: string; + timezone: string; +} + +interface ScheduleRow { + id: string; + workflow_id: string; + workflow_slug: string; + workflow_status: 'enabled' | 'disabled'; + cron: string; + timezone: string; +} + +/** + * Persistence for a workflow's cron schedules (`conductor_schedules`). Sibling of the Agent + * Builder's `agent_schedules`, polled by ConductorScheduleWorker on the same minute tick + * (resolved decision #2). UTC-only, matching `cronMatches` (timezone is a future enhancement). + */ +export class ConductorScheduleStore { + constructor(private readonly pool: Pool) {} + + /** + * Reconcile a workflow's schedules to match the cron triggers in its published graph. + * Replace-all (a workflow has few cron triggers): drop the old rows, insert the current + * valid ones. Invalid cron expressions are skipped (the graph validator does not yet lint cron). + */ + async reconcile(workflowId: string, graph: WorkflowGraph): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await this.reconcileOnClient(client, workflowId, graph); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + /** + * The reconcile body on a caller-supplied client — lets `createOrPublish` run it INSIDE its own + * transaction so the new version and its cron schedules commit atomically. Without this, a failed + * reconcile after a committed publish would leave stale `conductor_schedules` firing forever + * (e.g. an operator removing a cron trigger but the reconcile rolling back). + */ + async reconcileOnClient(client: PoolClient, workflowId: string, graph: WorkflowGraph): Promise { + // De-duplicate identical expressions so two equal cron triggers don't double-fire each minute. + const crons = [ + ...new Set( + (graph.triggers ?? []) + .filter((t) => t.kind === 'cron' && typeof t.cron === 'string' && isValidCron(t.cron)) + .map((t) => t.cron!.trim()), + ), + ]; + await client.query('DELETE FROM conductor_schedules WHERE workflow_id = $1', [workflowId]); + for (const cron of crons) { + await client.query( + `INSERT INTO conductor_schedules (workflow_id, cron, status) VALUES ($1, $2, 'enabled')`, + [workflowId, cron], + ); + } + } + + /** All enabled schedules joined to their workflow (slug + status), for the worker tick. */ + async listEnabled(): Promise { + const r = await this.pool.query( + `SELECT s.id, s.workflow_id, w.slug AS workflow_slug, w.status AS workflow_status, + s.cron, s.timezone + FROM conductor_schedules s + JOIN conductor_workflows w ON w.id = s.workflow_id + WHERE s.status = 'enabled'`, + ); + return r.rows.map((row) => ({ + id: row.id, + workflowId: row.workflow_id, + workflowSlug: row.workflow_slug, + workflowEnabled: row.workflow_status === 'enabled', + cron: row.cron, + timezone: row.timezone, + })); + } + + /** + * Atomically claim this schedule's slot for the given minute. `last_run_at` advances to now() + * only if it was unset or in a prior minute, so a cron fires at most once per minute even + * across restarts and multiple replicas. Returns true iff this caller won the slot. + */ + async claimRun(scheduleId: string, claimedBy: string): Promise { + // The minute slot is computed entirely from the DB clock (`date_trunc('minute', now())`) on BOTH + // the compare and the write, so replica/app clock skew can never let two callers win the same + // minute (mixing app-time in WHERE with now() in SET would). First winner advances last_run_at + // to this minute; every other caller that minute then matches 0 rows. + const r = await this.pool.query( + `UPDATE conductor_schedules + SET last_run_at = date_trunc('minute', now()), claimed_by = $2, claimed_at = now() + WHERE id = $1 + AND (last_run_at IS NULL OR last_run_at < date_trunc('minute', now()))`, + [scheduleId, claimedBy], + ); + return (r.rowCount ?? 0) > 0; + } +} diff --git a/middleware/src/conductor/scheduleWorker.ts b/middleware/src/conductor/scheduleWorker.ts new file mode 100644 index 00000000..7b1a55b7 --- /dev/null +++ b/middleware/src/conductor/scheduleWorker.ts @@ -0,0 +1,109 @@ +import { randomUUID } from 'node:crypto'; + +import { cronMatches } from '../scheduler/cron.js'; +import type { ConductorSchedule, ConductorScheduleStore } from './scheduleStore.js'; +import type { ConductorRunExecutor } from './runExecutor.js'; + +/** + * Fires Conductor workflows on their cron triggers (US4 cron / FR-007). Polls `conductor_schedules` + * once per minute and starts a run for each enabled schedule whose cron matches the current UTC + * minute and whose workflow is enabled. Per-minute exactly-once is enforced in the DB + * (`scheduleStore.claimRun` advances `last_run_at` atomically), so it holds across restarts and + * replicas; an in-flight set additionally prevents overlapping startRun for the same schedule. + * graphPool-gated by the caller, like the await/resume workers. + */ +export class ConductorScheduleWorker { + private timer: ReturnType | undefined; + private ticking = false; + private readonly inFlight = new Set(); + private readonly claimerId = randomUUID(); + + constructor( + private readonly deps: { + scheduleStore: ConductorScheduleStore; + 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] schedule worker started (cron poll)'); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = (this.deps.now ?? (() => new Date()))(); + let schedules: ConductorSchedule[]; + try { + schedules = await this.deps.scheduleStore.listEnabled(); + } catch (err) { + this.deps.log?.(`[conductor] schedule worker list failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + for (const s of schedules) { + if (!s.workflowEnabled) continue; + if (this.inFlight.has(s.id)) continue; + + // A malformed cron on a legacy/manual row must not abort the whole tick (which would skip + // every later schedule this minute) — guard each match and skip only the bad row. + let due: boolean; + try { + due = cronMatches(s.cron, now); + } catch (err) { + this.deps.log?.(`[conductor] schedule worker bad cron '${s.cron}' on ${s.id}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + if (!due) continue; + + let won: boolean; + try { + won = await this.deps.scheduleStore.claimRun(s.id, this.claimerId); + } catch (err) { + this.deps.log?.(`[conductor] schedule worker claim ${s.id} failed: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + if (!won) continue; // another tick/replica already fired this minute + + this.inFlight.add(s.id); + void this.fire(s).finally(() => this.inFlight.delete(s.id)); + } + } finally { + this.ticking = false; + } + } + + // At-most-once semantics: the slot is already claimed before this runs. Once startRun() creates the + // durable conductor_runs row, a later drive crash is recovered by the resume worker. The only lost + // window is a startRun() failure BEFORE that row exists (e.g. a transient DB error) — that single + // occurrence is skipped rather than risk a double-fire by un-claiming. Acceptable for cron triggers. + private async fire(s: ConductorSchedule): Promise { + try { + this.deps.log?.(`[conductor] cron firing '${s.workflowSlug}' (${s.cron})`); + await this.deps.executor.startRun({ + slug: s.workflowSlug, + payload: {}, + triggerKind: 'cron', + triggerSource: { scheduleId: s.id, cron: s.cron }, + }); + } catch (err) { + this.deps.log?.(`[conductor] cron fire '${s.workflowSlug}' failed: ${err instanceof Error ? err.message : String(err)}`); + } + } +} 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..7d74599a --- /dev/null +++ b/middleware/src/conductor/workflowStore.ts @@ -0,0 +1,160 @@ +import type { Pool, PoolClient } 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; + /** Runs inside the publish transaction after the version is set active — used to reconcile cron + * schedules atomically with the publish (a throw rolls the whole publish back, so a failed + * reconcile never leaves stale schedules behind). */ + onPublished?: (client: PoolClient, workflowId: string) => Promise; + }): 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], + ); + + // Atomic side-effects of publishing (e.g. cron-schedule reconcile) — same transaction. + if (input.onPublished) await input.onPublished(client, workflowId); + + 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 794da644..8f3e2add 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -24,6 +24,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'; @@ -221,6 +222,7 @@ import { NotificationRouter } from './platform/notificationRouter.js'; import { PluginStatusRegistry } from './platform/pluginStatusRegistry.js'; import { UiRouteCatalog } from './platform/uiRouteCatalog.js'; import { CanvasOutputRegistry } from './platform/canvasOutputRegistry.js'; +import { EventCatalogRegistry } from './platform/eventCatalogRegistry.js'; import { DeterministicActionRegistry } from './platform/deterministicActionRegistry.js'; import { ServiceRegistry } from './platform/serviceRegistry.js'; import { TurnHookRegistry } from './platform/turnHookRegistry.js'; @@ -359,6 +361,11 @@ async function main(): Promise { // it lazily. The `deterministic_action_tools` config field stays as override. const deterministicActionRegistry = new DeterministicActionRegistry(); serviceRegistry.provide('deterministicActionRegistry', deterministicActionRegistry); + // Event-catalog autodiscovery (US4 Conductor Surface): plugins declaring `event_emit: true` + // capabilities resolve into this registry on (de)activation from BOTH runtimes (dynamic + tool), + // so the Designer can list emittable events and ctx.events.emit enforces deny-by-default. + const eventCatalogRegistry = new EventCatalogRegistry(); + serviceRegistry.provide('eventCatalogRegistry', eventCatalogRegistry); // #133 E0 — expose the kernel turn-hook registry to the orchestrator plugin // so it can fire onBeforeTurn / onAfterToolCall / onAfterTurn during turns. serviceRegistry.provide('turnHookRegistry', turnHookRegistry); @@ -730,6 +737,7 @@ async function main(): Promise { flowPublicBaseUrl, pluginStatusRegistry, canvasOutputRegistry, + eventCatalogRegistry, deterministicActionRegistry, log: (...a) => console.log(...a), }); @@ -802,6 +810,7 @@ async function main(): Promise { pluginStatusRegistry, selfExtendRegistry, extensionStore, + eventCatalogRegistry, // When an integration plugin activates — at boot OR via a live hot- // install — register every `manifest.service_types` entry into the // agent-builder's `serviceTypeRegistry`, and link its package into the @@ -1720,7 +1729,15 @@ async function main(): Promise { // builders) without constructor-injected Deps. serviceRegistry.provide( ROUTINES_INTEGRATION_SERVICE_NAME, - createRoutinesIntegration(routinesHandle), + createRoutinesIntegration(routinesHandle, (info) => { + // US5: persist a Conductor channel binding per inbound turn so awaits can be reminded. + // Lazy-resolve the store (Conductor wires later in boot); fire-and-forget — a turn must + // never be blocked or broken by it. + const bindings = serviceRegistry.get<{ upsert(u: string, c: string, r: unknown): Promise }>( + 'conductorChannelBindings', + ); + if (bindings) void bindings.upsert(String(info.userId), String(info.channel), info.conversationRef).catch(() => undefined); + }), ); console.log( '[middleware] routines feature ready (manage_routine tool registered, routinesIntegration published)', @@ -2105,6 +2122,31 @@ 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. + const conductorWiring = await wireConductor({ + pool: graphPool, + app, + requireAuth, + getRegistry, + invokeAction: (toolId, input) => dynamicAgentRuntime.invokeAgentTool(toolId, input), + eventCatalog: eventCatalogRegistry, + // US5 reminders: resolve a channel's proactive sender from the routines senderRegistry. Adapt + // ProactiveSender → the worker's minimal shape ({ text } is a valid SemanticAnswer). + getProactiveSender: (channel) => { + const sender = routinesHandle?.senderRegistry.get(channel); + return sender ? { send: (opts) => sender.send(opts) } : undefined; + }, + log: (m) => console.log(m), + }); + // Expose the event router so plugin contexts (ctx.events.emit) resolve it lazily — US4. + serviceRegistry.provide('conductorEventRouter', conductorWiring.eventRouter); + // Expose the channel-binding store so the routines turn-capture hook can populate it — US5. + serviceRegistry.provide('conductorChannelBindings', conductorWiring.channelBindingStore); + console.log('[middleware] conductor wired at /api/v1/operator/conductors/* (auth-gated)'); const userStore = new UserStore(graphPool); const bootstrapResult = await runAuthBootstrap({ @@ -3439,6 +3481,7 @@ async function main(): Promise { flowSigningKey: sessionSigningKey, flowPublicBaseUrl, pluginStatusRegistry, + eventCatalogRegistry, resolver: channelPluginResolver, coreApi: channelCoreApi, routes: routeRegistry, diff --git a/middleware/src/platform/eventCatalogRegistry.ts b/middleware/src/platform/eventCatalogRegistry.ts new file mode 100644 index 00000000..d05cab71 --- /dev/null +++ b/middleware/src/platform/eventCatalogRegistry.ts @@ -0,0 +1,81 @@ +/** + * Event-catalog capability registry (declare → resolve → emit). The Conductor Surface's + * connector half (US4): a plugin declares the domain events it emits, and at runtime it may + * emit one — which the {@link ../conductor/eventRouter.ts ConductorEventRouter} routes to every + * subscribed workflow. + * + * The sibling of {@link ./canvasOutputRegistry.ts} / {@link ./deterministicActionRegistry.ts}: + * a manifest capability entry `{ id, event_emit: true }` declares an emittable event id, and the + * registry collects those per plugin from BOTH activation runtimes (dynamic + tool). It is the + * authoritative catalog the Designer's event-trigger picker reads, and it backs deny-by-default + * at emit time — a plugin may only emit an id it declared (`allows`). + */ + +export interface EventCatalogLookup { + has(eventId: string): boolean; +} + +export class EventCatalogRegistry implements EventCatalogLookup { + private readonly byPlugin = new Map>(); + + register(pluginId: string, eventIds: readonly string[]): void { + if (eventIds.length === 0) { + this.byPlugin.delete(pluginId); + return; + } + this.byPlugin.set(pluginId, new Set(eventIds)); + } + + unregister(pluginId: string): void { + this.byPlugin.delete(pluginId); + } + + /** Any plugin declares this event id. */ + has(eventId: string): boolean { + for (const ids of this.byPlugin.values()) { + if (ids.has(eventId)) return true; + } + return false; + } + + /** This specific plugin declared this event id (deny-by-default for ctx.events.emit). */ + allows(pluginId: string, eventId: string): boolean { + return this.byPlugin.get(pluginId)?.has(eventId) ?? false; + } + + /** The full catalog — sorted union of all declared event ids (for the Designer picker). */ + list(): string[] { + const out = new Set(); + for (const ids of this.byPlugin.values()) { + for (const id of ids) out.add(id); + } + return [...out].sort(); + } + + /** The catalog grouped by source plugin (for an operator/Designer view). */ + byPluginId(): Record { + const out: Record = {}; + for (const [pluginId, ids] of this.byPlugin) out[pluginId] = [...ids].sort(); + return out; + } +} + +/** + * Extracts the event ids a manifest declares it emits — capability entries with a string `id` + * and a literal `event_emit: true`. Tolerant by design (mirrors the loader's permissive + * capability handling): anything else is ignored. + */ +export function eventEmitIds(manifest: unknown): string[] { + if (typeof manifest !== 'object' || manifest === null) return []; + const caps = (manifest as Record)['capabilities']; + if (!Array.isArray(caps)) return []; + const ids: string[] = []; + for (const cap of caps) { + if (typeof cap !== 'object' || cap === null) continue; + const rec = cap as Record; + if (rec['event_emit'] === true && typeof rec['id'] === 'string' && rec['id'].length > 0) { + ids.push(rec['id']); + } + } + return ids; +} diff --git a/middleware/src/platform/pluginContext.ts b/middleware/src/platform/pluginContext.ts index ead5f324..b949a7af 100644 --- a/middleware/src/platform/pluginContext.ts +++ b/middleware/src/platform/pluginContext.ts @@ -45,6 +45,10 @@ import { type SubAgentAccessor, type UiRoutesAccessor, type ToolsAccessor, + type EventsAccessor, + type EmitResult, + EventNotDeclaredError, + ConductorUnavailableError, } from '@omadia/plugin-api'; import type { DomainTool } from '@omadia/orchestrator'; import { turnContext } from '@omadia/orchestrator'; @@ -701,6 +705,31 @@ export function createPluginContext( vault, }); + // US4 Conductor Surface — ctx.events.emit, gated on permissions.events.emit and deny-by-default + // against the plugin's declared { id, event_emit:true } capabilities. The Conductor event router + // is resolved lazily from the service registry (Conductor wires it after its own boot block, so + // it may not exist when this context is built — only when emit() is actually called). + interface ConductorEventRouterLike { + emit(eventId: string, payload: Record, sourcePluginId?: string): Promise; + } + interface EventCatalogAllows { + allows(pluginId: string, eventId: string): boolean; + } + const eventsAllowed = catalog.get(agentId)?.plugin.permissions_summary.events_emit === true; + const events: EventsAccessor | undefined = eventsAllowed + ? { + emit(id: string, payload: Record) { + const router = serviceRegistry.get('conductorEventRouter'); + if (!router) throw new ConductorUnavailableError(); + // Deny-by-default fails CLOSED: with no catalog we cannot prove the plugin declared + // this id, so we reject rather than allow an unverified emit. + const eventCatalog = serviceRegistry.get('eventCatalogRegistry'); + if (!eventCatalog || !eventCatalog.allows(agentId, id)) throw new EventNotDeclaredError(agentId, id); + return router.emit(id, payload, agentId); + }, + } + : undefined; + return { agentId, domain, @@ -722,6 +751,7 @@ export function createPluginContext( ...(llm ? { llm } : {}), ...(flows ? { flows } : {}), ...(oauthTokens ? { oauthTokens } : {}), + ...(events ? { events } : {}), status, log, }; diff --git a/middleware/src/plugins/dynamicAgentRuntime.ts b/middleware/src/plugins/dynamicAgentRuntime.ts index 602eb989..0f3fde55 100644 --- a/middleware/src/plugins/dynamicAgentRuntime.ts +++ b/middleware/src/plugins/dynamicAgentRuntime.ts @@ -15,6 +15,7 @@ import type { z } from 'zod'; import { canvasOutputToolIds } from '../platform/canvasOutputRegistry.js'; import { deterministicActionToolIds } from '../platform/deterministicActionRegistry.js'; +import { eventEmitIds } from '../platform/eventCatalogRegistry.js'; import { createPluginContext } from '../platform/pluginContext.js'; import type { PluginRouteRegistry } from '../platform/pluginRouteRegistry.js'; import type { NotificationRouter } from '../platform/notificationRouter.js'; @@ -183,6 +184,14 @@ export interface DynamicAgentRuntimeDeps { register(pluginId: string, toolIds: readonly string[]): void; unregister(pluginId: string): void; }; + /** Event-catalog autodiscovery (US4 Conductor Surface): manifest capability entries declaring + * `event_emit: true` are resolved into this registry on (de)activation so the Conductor + * Designer can list emittable events and `ctx.events.emit` can enforce deny-by-default. + * Optional — absent in narrow test contexts. */ + eventCatalogRegistry?: { + register(pluginId: string, eventIds: readonly string[]): void; + unregister(pluginId: string): void; + }; log?: (...args: unknown[]) => void; } @@ -555,6 +564,16 @@ export class DynamicAgentRuntime { ); } + // Event-catalog autodiscovery (US4): same declare → resolve path. Capability entries with + // `event_emit: true` become emittable via ctx.events.emit and discoverable by the Designer. + const eventEmitIdList = eventEmitIds(catalogEntry.manifest); + if (eventEmitIdList.length > 0) { + this.deps.eventCatalogRegistry?.register(agentId, eventEmitIdList); + log( + `[dynamic-runtime] event-emit capabilities registered for ${agentId}: ${eventEmitIdList.join(', ')}`, + ); + } + // Circuit-breaker: clear any prior failure counter so an agent that // recovers (e.g. after a config fix + re-upload) returns to a healthy // starting state for the next boot. Best-effort — registry write errors @@ -585,6 +604,7 @@ export class DynamicAgentRuntime { // Symmetric to the activate-time canvas-output registration. this.deps.canvasOutputRegistry?.unregister(agentId); this.deps.deterministicActionRegistry?.unregister(agentId); + this.deps.eventCatalogRegistry?.unregister(agentId); try { await withTimeout( entry.handle.close(), diff --git a/middleware/src/plugins/manifestLoader.ts b/middleware/src/plugins/manifestLoader.ts index 9b1130ad..63bd93cb 100644 --- a/middleware/src/plugins/manifestLoader.ts +++ b/middleware/src/plugins/manifestLoader.ts @@ -647,6 +647,8 @@ function extractPermissions( llm_max_tokens_per_call: llmMaxTokensPerCall, secrets_runtime_write: secretsBlock?.['runtime_write'] === true, flows: permissions?.['flows'] === true, + // Spec 005 (US4 Conductor Surface) — plugin may emit declared domain events via ctx.events.emit. + events_emit: asRecord(permissions?.['events'])?.['emit'] === true, // Spec 005 — overridden to true in adaptManifestV1 when the manifest // declares >=1 valid oauth_providers descriptor. acquires_oauth: false, diff --git a/middleware/src/plugins/routines/integration.ts b/middleware/src/plugins/routines/integration.ts index 07dbdca0..192a355c 100644 --- a/middleware/src/plugins/routines/integration.ts +++ b/middleware/src/plugins/routines/integration.ts @@ -24,6 +24,9 @@ import { routineTurnContext } from './routineTurnContext.js'; */ export function createRoutinesIntegration( handle: RoutinesHandle, + /** Optional per-turn observer — the kernel uses it to persist a Conductor channel binding for + * reminders, without coupling routines to Conductor. Best-effort: failures must not break a turn. */ + onTurnCaptured?: (info: { userId: string; channel: string; conversationRef: unknown }) => void, ): RoutinesIntegration { return { captureRoutineTurn(info) { @@ -34,6 +37,11 @@ export function createRoutinesIntegration( conversationRef: info.conversationRef, canTargetOthers: info.canTargetOthers ?? false, }); + try { + onTurnCaptured?.({ userId: info.userId, channel: info.channel, conversationRef: info.conversationRef }); + } catch { + // never let a binding-capture error break the inbound turn + } }, async updateRoutineConversationRef(routineId, conversationRef) { diff --git a/middleware/src/plugins/toolPluginRuntime.ts b/middleware/src/plugins/toolPluginRuntime.ts index f316bff4..8a922016 100644 --- a/middleware/src/plugins/toolPluginRuntime.ts +++ b/middleware/src/plugins/toolPluginRuntime.ts @@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url'; import { promises as fs } from 'node:fs'; import { createPluginContext } from '../platform/pluginContext.js'; +import { eventEmitIds } from '../platform/eventCatalogRegistry.js'; import type { PluginRouteRegistry } from '../platform/pluginRouteRegistry.js'; import type { NotificationRouter } from '../platform/notificationRouter.js'; import type { PluginStatusRegistry } from '../platform/pluginStatusRegistry.js'; @@ -86,6 +87,14 @@ export interface ToolPluginRuntimeDeps { flowPublicBaseUrl?: string; /** Spec 004 — backing store for `ctx.status`; cleared on deactivate. */ pluginStatusRegistry?: PluginStatusRegistry; + /** Event-catalog autodiscovery (US4 Conductor Surface): capability entries declaring + * `event_emit: true` are resolved into this registry on (de)activation. This runtime is the + * ONLY resolve site for built-in/static tool plugins (landmine K — the dynamic runtime has its + * own). Optional — absent in narrow test contexts. */ + eventCatalogRegistry?: { + register(pluginId: string, eventIds: readonly string[]): void; + unregister(pluginId: string): void; + }; /** * Fired after a plugin's activate() succeeds and it is recorded active. * Used to register the plugin's manifest-declared `service_types` into the @@ -291,6 +300,15 @@ export class ToolPluginRuntime { this.active.set(agentId, { agentId, handle, extDisposes }); + // Event-catalog autodiscovery (US4 / landmine K): static + built-in tool plugins resolve their + // `event_emit: true` capabilities here — the only place they are picked up (the dynamic runtime + // covers hot-installed agents). Lets the Designer list emittable events + ctx.events.emit deny-by-default. + const eventEmitIdList = eventEmitIds(catalogEntry.manifest); + if (eventEmitIdList.length > 0) { + this.deps.eventCatalogRegistry?.register(agentId, eventEmitIdList); + log(`[tool-runtime] event-emit capabilities registered for ${agentId}: ${eventEmitIdList.join(', ')}`); + } + try { await this.deps.registry.markActivationSucceeded(agentId); } catch (err) { @@ -329,6 +347,7 @@ export class ToolPluginRuntime { } } this.deps.selfExtendRegistry?.unregister(agentId); + this.deps.eventCatalogRegistry?.unregister(agentId); try { await withTimeout( entry.handle.close(), diff --git a/middleware/test/conductorBuilder.test.ts b/middleware/test/conductorBuilder.test.ts new file mode 100644 index 00000000..8bf7f993 --- /dev/null +++ b/middleware/test/conductorBuilder.test.ts @@ -0,0 +1,283 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import type { OrchestratorRegistry } from '@omadia/orchestrator'; +import type { WorkflowGraph } from '@omadia/conductor-core'; + +import { applyGraphPatches, emptyGraph } from '../src/conductor/graphPatch.js'; +import { ConductorBuilderAgent, ConductorBuilderUnavailableError, parseTurnResponse } from '../src/conductor/builderAgent.js'; + +// Conductor US7 — conversational builder: pure patch algebra + the stateless turn (graph,message)→(graph,reply). + +describe('applyGraphPatches', () => { + it('add_step on an empty graph makes the first step the entry', () => { + const r = applyGraphPatches(emptyGraph(), [ + { op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback', prompt: 'hi' } }, + ]); + assert.equal(r.errors.length, 0); + assert.equal(r.applied, 1); + assert.equal(r.graph.entryStepId, 's1'); + assert.equal(r.graph.steps.length, 1); + }); + + it('update_step merges fields but never changes the id', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback' }], + transitions: [], + triggers: [], + }; + // patch tries to also change the id — applyGraphPatches must preserve the original id. + const r = applyGraphPatches(base, [{ op: 'update_step', id: 's1', patch: { prompt: 'do the thing', id: 'hacked' } }]); + assert.equal(r.errors.length, 0); + assert.equal(r.graph.steps[0].id, 's1'); + assert.equal(r.graph.steps[0].prompt, 'do the thing'); + }); + + it('remove_step drops the step AND its dangling transitions', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'agent', agentId: 'fallback' }, + { id: 's2', kind: 'agent', agentId: 'fallback' }, + ], + transitions: [{ id: 't1', source: 's1', target: 's2' }], + triggers: [], + }; + const r = applyGraphPatches(base, [{ op: 'remove_step', id: 's2' }]); + assert.equal(r.errors.length, 0); + assert.equal(r.graph.steps.length, 1); + assert.equal(r.graph.transitions.length, 0); // dangling transition removed + }); + + it('reports errors for unknown ids and duplicates, applying the rest', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback' }], + transitions: [], + triggers: [], + }; + const r = applyGraphPatches(base, [ + { op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback' } }, // duplicate + { op: 'update_step', id: 'ghost', patch: { prompt: 'x' } }, // unknown + { op: 'remove_transition', id: 'nope' }, // unknown + { op: 'add_step', step: { id: 's2', kind: 'agent', agentId: 'fallback' } }, // valid + ]); + assert.equal(r.applied, 1); + assert.equal(r.errors.length, 3); + assert.equal(r.graph.steps.length, 2); + }); + + it('set_trigger replaces (single-trigger parity) and set_entry sets the entry', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback' }], + transitions: [], + triggers: [{ id: 'old', kind: 'manual' }], + }; + const r = applyGraphPatches(base, [ + { op: 'set_trigger', trigger: { id: 'tr', kind: 'event', eventId: 'github.pull_request.merged' } }, + { op: 'set_entry', stepId: 's1' }, + ]); + assert.equal(r.graph.triggers?.length, 1); + assert.equal(r.graph.triggers?.[0].id, 'tr'); + assert.equal(r.graph.entryStepId, 's1'); + }); + + it('does not mutate the input graph', () => { + const base = emptyGraph(); + applyGraphPatches(base, [{ op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback' } }]); + assert.equal(base.steps.length, 0); + }); + + it('tolerates a malformed input graph (non-array steps/transitions) without throwing', () => { + const bad = { entryStepId: '', steps: 'nope', transitions: undefined } as unknown as WorkflowGraph; + const r = applyGraphPatches(bad, [{ op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback' } }]); + assert.equal(r.applied, 1); + assert.equal(r.graph.steps.length, 1); + }); + + it('update_step with a missing patch field records nothing-to-do but never throws', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback', prompt: 'keep' }], + transitions: [], + triggers: [], + }; + // LLM emitted update_step with no `patch` key — must not throw (would 500 the whole turn). + const r = applyGraphPatches(base, [{ op: 'update_step', id: 's1' } as unknown as Parameters[1][number]]); + assert.equal(r.errors.length, 0); + assert.equal(r.graph.steps[0].prompt, 'keep'); + }); + + it('update_step never changes a step kind (preserves it; use remove+add to change kind)', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback' }], + transitions: [], + triggers: [], + }; + const r = applyGraphPatches(base, [{ op: 'update_step', id: 's1', patch: { kind: 'human', prompt: 'x' } }]); + assert.equal(r.graph.steps[0].kind, 'agent'); + }); + + it('remove_step clears an orphaned fallbackTransitionId on a surviving step', () => { + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [ + { id: 's1', kind: 'agent', agentId: 'fallback', fallbackTransitionId: 't2' }, + { id: 's2', kind: 'agent', agentId: 'fallback' }, + { id: 's3', kind: 'agent', agentId: 'fallback' }, + ], + transitions: [{ id: 't2', source: 's3', target: 's2' }], + triggers: [], + }; + const r = applyGraphPatches(base, [{ op: 'remove_step', id: 's2' }]); // drops t2 (targets s2) + assert.equal(r.graph.transitions.length, 0); + assert.equal(r.graph.steps.find((s) => s.id === 's1')?.fallbackTransitionId, undefined); + }); +}); + +describe('parseTurnResponse', () => { + it('parses a bare JSON object', () => { + const r = parseTurnResponse('{"reply":"ok","patches":[]}'); + assert.equal(r.ok, true); + assert.equal(r.reply, 'ok'); + assert.deepEqual(r.patches, []); + }); + + it('parses JSON wrapped in markdown fences and prose', () => { + const r = parseTurnResponse('Sure! Here you go:\n```json\n{"reply":"added","patches":[{"op":"set_entry","stepId":"s1"}]}\n```\nDone.'); + assert.equal(r.ok, true); + assert.equal(r.reply, 'added'); + assert.equal(r.patches.length, 1); + }); + + it('handles braces inside JSON strings without breaking balance', () => { + const r = parseTurnResponse('{"reply":"use {{ctx.base}} here","patches":[]}'); + assert.equal(r.ok, true); + assert.equal(r.reply, 'use {{ctx.base}} here'); + }); + + it('falls back to ok:false with raw text when there is no JSON', () => { + const r = parseTurnResponse('I cannot do that.'); + assert.equal(r.ok, false); + assert.equal(r.reply, 'I cannot do that.'); + assert.deepEqual(r.patches, []); + }); + + it('parses the real object even when brace-bearing prose precedes it', () => { + // First `{` is invalid JSON ({op:eq}); the scanner must try the next candidate. + const r = parseTurnResponse('I will set the guard to {op:eq}. Here is the patch: {"reply":"ok","patches":[]}'); + assert.equal(r.ok, true); + assert.equal(r.reply, 'ok'); + }); +}); + +// ── stub orchestrator registry ────────────────────────────────────────────── + +function stubRegistry(responses: string[]): { registry: OrchestratorRegistry; calls: () => number } { + let i = 0; + const registry = { + get: (_slug: string) => ({ + built: { + bundle: { + agent: { + chat: async (_args: { userMessage: string; sessionScope: string }) => { + const text = responses[Math.min(i, responses.length - 1)]; + i += 1; + return { text }; + }, + }, + }, + }, + }), + } as unknown as OrchestratorRegistry; + return { registry, calls: () => i }; +} + +describe('ConductorBuilderAgent.runTurn', () => { + it('applies the agent-proposed patches and validates the result', async () => { + const { registry } = stubRegistry([ + JSON.stringify({ + reply: 'Added a greeting step.', + patches: [ + { op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback', prompt: 'Say hi' } }, + { op: 'set_trigger', trigger: { id: 'tr', kind: 'manual' } }, + ], + }), + ]); + const agent = new ConductorBuilderAgent({ getRegistry: () => registry }); + const res = await agent.runTurn({ message: 'start with a greeting' }); + assert.equal(res.validation.ok, true); + assert.equal(res.graph.steps.length, 1); + assert.equal(res.graph.entryStepId, 's1'); + assert.equal(res.reply, 'Added a greeting step.'); + assert.equal(res.applyErrors.length, 0); + }); + + it('surfaces validation errors (never publishes a broken graph) and still returns the draft', async () => { + // Always returns a graph whose entry points at a missing step → invalid; both attempts fail. + const { registry, calls } = stubRegistry([ + JSON.stringify({ + reply: 'here', + patches: [ + { op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback' } }, + { op: 'set_entry', stepId: 'does-not-exist' }, + ], + }), + ]); + const agent = new ConductorBuilderAgent({ getRegistry: () => registry }); + const res = await agent.runTurn({ message: 'break it' }); + assert.equal(res.validation.ok, false); + assert.ok(res.validation.errors.some((e) => e.code === 'unknown_entry_step')); + assert.equal(res.graph.steps.length, 1); // draft still returned for the user to see + assert.equal(calls(), 2); // retried once on the invalid result + }); + + it('self-corrects: retries once when the first response is unparseable, accepts the valid retry', async () => { + const { registry, calls } = stubRegistry([ + 'sorry, no json here', + JSON.stringify({ + reply: 'fixed', + patches: [ + { op: 'add_step', step: { id: 's1', kind: 'agent', agentId: 'fallback' } }, + { op: 'set_trigger', trigger: { id: 'tr', kind: 'manual' } }, + ], + }), + ]); + const agent = new ConductorBuilderAgent({ getRegistry: () => registry }); + const res = await agent.runTurn({ message: 'make a step' }); + assert.equal(calls(), 2); + assert.equal(res.validation.ok, true); + assert.equal(res.reply, 'fixed'); + assert.equal(res.graph.steps.length, 1); + }); + + it('throws ConductorBuilderUnavailableError when no registry is present', async () => { + const agent = new ConductorBuilderAgent({ getRegistry: () => undefined }); + await assert.rejects(() => agent.runTurn({ message: 'hi' }), ConductorBuilderUnavailableError); + }); + + it('keeps the BEST attempt: a parseable-but-invalid result outranks an unparseable retry', async () => { + // Attempt 0: parseable, but sets entry to a missing step → invalid (still inspectable, score 5). + // Attempt 1: unparseable junk → no patches → base unchanged & valid (vacuous, score 3). + // The builder must return attempt 0, not the worse latest attempt. + const base: WorkflowGraph = { + entryStepId: 's1', + steps: [{ id: 's1', kind: 'agent', agentId: 'fallback' }], + transitions: [], + triggers: [{ id: 'tr', kind: 'manual' }], + }; + const { registry, calls } = stubRegistry([ + JSON.stringify({ reply: 'attempt0', patches: [{ op: 'set_entry', stepId: 'ghost' }] }), + 'sorry, no json here', + ]); + const agent = new ConductorBuilderAgent({ getRegistry: () => registry }); + const res = await agent.runTurn({ graph: base, message: 'edit it' }); + assert.equal(calls(), 2); + assert.equal(res.reply, 'attempt0'); + assert.equal(res.validation.ok, false); + assert.equal(res.graph.entryStepId, 'ghost'); + }); +}); diff --git a/middleware/test/conductorChannelEventEmit.test.ts b/middleware/test/conductorChannelEventEmit.test.ts new file mode 100644 index 00000000..d324d790 --- /dev/null +++ b/middleware/test/conductorChannelEventEmit.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { EventCatalogRegistry, eventEmitIds } from '../src/platform/eventCatalogRegistry.js'; +import { DefaultChannelRegistry } from '../src/channels/channelRegistry.js'; + +// Conductor real-world P1a — a CHANNEL plugin (e.g. Teams) declaring `event_emit` capabilities must +// have them registered in the event catalog on activation, exactly like the tool/dynamic runtimes, +// so `ctx.events.emit(...)` passes the deny-by-default gate and the events surface as Conductor +// triggers. The channel activation path was the one runtime missing this. These tests drive the real +// `DefaultChannelRegistry.activate`/`deactivate` so reverting the production wiring fails them. + +// A Teams-shaped channel manifest (schema_version "1") with the new event-emit declarations. The +// `capabilities[].event_emit` objects are what `eventEmitIds` reads off the RAW manifest doc. +const teamsManifest = { + schema_version: '1', + identity: { id: '@omadia/channel-teams', kind: 'channel', domain: 'channel.teams' }, + capabilities: [ + { id: 'teams.message.posted', event_emit: true }, + { id: 'teams.mention', event_emit: true }, + { id: 'teams.reaction.added', event_emit: true }, + { id: 'teams.member.added', event_emit: false }, // declared but not an emitter → ignored + ], +}; + +const TEAMS_ID = '@omadia/channel-teams'; + +// Minimal stub deps for DefaultChannelRegistry. activate() builds a real PluginContext via +// createPluginContext (most accessors are lazy) then calls the resolved plugin's activate(); the +// fake plugin is a no-op handle, so only construction-time deref of catalog/serviceRegistry matters. +function makeRegistry(catalog: EventCatalogRegistry): DefaultChannelRegistry { + const noop = (): void => undefined; + const unsub = (): (() => void) => () => undefined; + const entry = { + plugin: { + kind: 'channel', + domain: 'channel.teams', + depends_on: [], + permissions_summary: {}, + setup_fields: [], + integrations_summary: [], + provides: [], + requires: [], + jobs: [], + }, + manifest: teamsManifest, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deps: any = { + catalog: { get: () => entry, has: () => true, list: () => [entry] }, + installedRegistry: { list: () => [], get: () => entry }, + vault: { get: noop, set: noop, list: () => [] }, + serviceRegistry: { get: () => undefined, has: () => false, provide: unsub, replace: unsub }, + nativeToolRegistry: { register: noop, unregister: noop, list: () => [] }, + pluginRouteRegistry: { register: noop, deactivate: noop }, + notificationRouter: { registerChannel: unsub }, + uiRouteCatalog: { disposeBySource: noop }, + jobScheduler: { stopForPlugin: noop }, + pluginStatusRegistry: { clear: noop }, + eventCatalogRegistry: catalog, + resolver: { + resolve: async () => ({ activate: async () => ({ close: async () => undefined }) }), + invalidate: noop, + }, + coreApi: { log: noop, registerRoute: noop, registerRouter: noop }, + routes: { setActive: noop, deactivateChannel: noop }, + webSockets: { setActive: noop, deactivateChannel: noop }, + }; + return new DefaultChannelRegistry(deps); +} + +describe('eventEmitIds (Teams manifest shape)', () => { + it('extracts only the event_emit:true capability ids from a channel manifest', () => { + assert.deepEqual(eventEmitIds(teamsManifest).sort(), [ + 'teams.mention', + 'teams.message.posted', + 'teams.reaction.added', + ]); + }); +}); + +describe('DefaultChannelRegistry event-emit catalog wiring (P1a)', () => { + it('activate registers the channel’s declared events → emit allowed (deny-by-default)', async () => { + const catalog = new EventCatalogRegistry(); + const reg = makeRegistry(catalog); + await reg.activate(TEAMS_ID); + + assert.equal(catalog.has('teams.message.posted'), true); + assert.equal(catalog.allows(TEAMS_ID, 'teams.message.posted'), true); // proves register wiring + assert.equal(catalog.allows('@omadia/other', 'teams.message.posted'), false); // deny-by-default + assert.equal(catalog.allows(TEAMS_ID, 'teams.member.added'), false); // event_emit:false ignored + }); + + it('deactivate unregisters the channel’s events from the catalog', async () => { + const catalog = new EventCatalogRegistry(); + const reg = makeRegistry(catalog); + await reg.activate(TEAMS_ID); + await reg.deactivate(TEAMS_ID); + + assert.equal(catalog.has('teams.message.posted'), false); // proves unregister wiring + assert.equal(catalog.allows(TEAMS_ID, 'teams.message.posted'), false); + assert.equal(catalog.byPluginId()[TEAMS_ID], undefined); + }); +}); diff --git a/middleware/test/conductorEventCatalog.test.ts b/middleware/test/conductorEventCatalog.test.ts new file mode 100644 index 00000000..49ffcd65 --- /dev/null +++ b/middleware/test/conductorEventCatalog.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { EventCatalogRegistry, eventEmitIds } from '../src/platform/eventCatalogRegistry.js'; + +// Conductor US4 (connector half) — the event-emit capability catalog + deny-by-default. + +describe('eventEmitIds', () => { + it('extracts capability ids declaring event_emit:true, ignoring everything else', () => { + const manifest = { + capabilities: [ + { id: 'github.pull_request.merged', event_emit: true }, + { id: 'github.issue.opened', event_emit: true }, + { id: 'some.canvas', canvas_output: true }, // not an event + { id: 'no.flag' }, // no event_emit + { event_emit: true }, // no id + 'garbage', + ], + }; + assert.deepEqual(eventEmitIds(manifest).sort(), ['github.issue.opened', 'github.pull_request.merged']); + }); + + it('returns [] for a manifest with no capabilities', () => { + assert.deepEqual(eventEmitIds({}), []); + assert.deepEqual(eventEmitIds(null), []); + assert.deepEqual(eventEmitIds({ capabilities: 'nope' }), []); + }); +}); + +describe('EventCatalogRegistry', () => { + it('registers, lists, and resolves catalog membership per plugin', () => { + const reg = new EventCatalogRegistry(); + reg.register('plugin-a', ['a.one', 'a.two']); + reg.register('plugin-b', ['b.one']); + + assert.deepEqual(reg.list(), ['a.one', 'a.two', 'b.one']); // sorted union + assert.equal(reg.has('a.one'), true); + assert.equal(reg.has('missing'), false); + assert.deepEqual(reg.byPluginId(), { 'plugin-a': ['a.one', 'a.two'], 'plugin-b': ['b.one'] }); + }); + + it('enforces deny-by-default per plugin (allows only the declaring plugin)', () => { + const reg = new EventCatalogRegistry(); + reg.register('plugin-a', ['a.one']); + assert.equal(reg.allows('plugin-a', 'a.one'), true); + assert.equal(reg.allows('plugin-a', 'b.one'), false); // not declared by a + assert.equal(reg.allows('plugin-b', 'a.one'), false); // b can't emit a's event + assert.equal(reg.allows('unknown', 'a.one'), false); + }); + + it('unregister (and empty register) removes a plugin from the catalog', () => { + const reg = new EventCatalogRegistry(); + reg.register('plugin-a', ['a.one']); + reg.unregister('plugin-a'); + assert.equal(reg.has('a.one'), false); + reg.register('plugin-a', ['a.one']); + reg.register('plugin-a', []); // empty => delete + assert.equal(reg.allows('plugin-a', 'a.one'), false); + }); +}); diff --git a/middleware/test/conductorQuorumAndTimeout.test.ts b/middleware/test/conductorQuorumAndTimeout.test.ts new file mode 100644 index 00000000..5b24a5f5 --- /dev/null +++ b/middleware/test/conductorQuorumAndTimeout.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { RealStepEffects, StepTimeoutError, DEFAULT_STEP_TIMEOUT_MS } from '../src/conductor/realStepEffects.js'; +import { DEFAULT_RESUME_STALE_MS } from '../src/conductor/runResumeWorker.js'; +import { ConductorRunExecutor } from '../src/conductor/runExecutor.js'; + +describe('resume-safety invariant', () => { + it('the default per-step timeout is strictly less than the resume worker stale window', () => { + // If this fails, a stalled step could be re-driven before it settles → at-least-once re-execution. + assert.ok( + DEFAULT_STEP_TIMEOUT_MS < DEFAULT_RESUME_STALE_MS, + `step timeout (${DEFAULT_STEP_TIMEOUT_MS}) must be < resume stale window (${DEFAULT_RESUME_STALE_MS})`, + ); + }); +}); + +// Conductor Wave 3 — quorum 'all' (US5) + per-step hard timeout (US2 resume safety). + +describe('RealStepEffects per-step hard timeout', () => { + it('rejects an agent step that exceeds stepTimeoutMs', async () => { + const neverResolves = new Promise(() => {}); // a turn that hangs forever + const registry = { + get: () => ({ built: { bundle: { agent: { chat: () => neverResolves } } } }), + }; + const effects = new RealStepEffects({ + getRegistry: () => registry as never, + stepTimeoutMs: 20, + }); + await assert.rejects( + () => effects.runAgentStep({ id: 's1', kind: 'agent', agentId: 'fallback' } as never, {}, { runId: 'r1' }), + (err: unknown) => err instanceof StepTimeoutError, + ); + }); + + it('returns normally when the agent answers within budget', async () => { + const registry = { + get: () => ({ built: { bundle: { agent: { chat: async () => ({ text: 'done' }) } } } }), + }; + const effects = new RealStepEffects({ getRegistry: () => registry as never, stepTimeoutMs: 1000 }); + const out = await effects.runAgentStep({ id: 's1', kind: 'agent', agentId: 'fallback' } as never, {}, { runId: 'r1' }); + assert.deepEqual(out.result, { text: 'done' }); + }); +}); + +describe('ConductorRunExecutor.resolveAwait quorum=all', () => { + // Minimal graph: a single human step with no outgoing transition → resolving it completes the run + // (no driveFrom needed), so the test stays focused on the quorum close-gating. + const graph = { + entryStepId: 'h1', + steps: [{ id: 'h1', kind: 'human', human: { principal: { kind: 'role', ref: 'approvers' }, channel: 'teams', message: 'ok?' } }], + transitions: [], + }; + + function makeExecutor(opts: { responders: string[]; holders: string[]; onClose: () => void }) { + const responses = opts.responders.map((id) => ({ responderId: id, response: { approved: true } })); + const awaitRow = { + id: 'aw1', runId: 'run1', stepId: 'h1', principalKind: 'role', principalRef: 'approvers', + channelType: 'teams', message: 'ok?', quorum: 'all', reminderIntervalMs: null, deadlineAt: null, + fallbackTransitionId: null, status: 'waiting', createdAt: new Date(0), + }; + const run = { + id: 'run1', workflowVersionId: 'v1', status: 'waiting', currentStepId: 'h1', context: {}, + triggerKind: 'manual', triggerSource: null, isDryRun: false, startedAt: new Date(0), endedAt: null, + }; + const awaitStore = { + async get() { return awaitRow; }, + async recordResponse() {}, + async listResponses() { return responses; }, + async close() { opts.onClose(); return true; }, + }; + const runStore = { + async get() { return run; }, + async acquireLease() {}, + async stepsForRun() { return []; }, + async recordStepAndAdvance() {}, + }; + const workflowStore = { + async getVersion() { return { id: 'v1', workflowId: 'w1', version: 1, graph }; }, + }; + return new ConductorRunExecutor({ + workflowStore: workflowStore as never, + runStore: runStore as never, + awaitStore: awaitStore as never, + effects: {} as never, + resolveRoleHolders: async () => opts.holders, + }); + } + + it('does NOT close while holders are still outstanding', async () => { + let closed = false; + // alice responded; bob has not → quorum 'all' over [alice, bob] is incomplete. + const exec = makeExecutor({ responders: ['alice'], holders: ['alice', 'bob'], onClose: () => { closed = true; } }); + await exec.resolveAwait('aw1', 'alice', { approved: true }); + assert.equal(closed, false); + }); + + it('closes + resumes once every current holder has responded', async () => { + let closed = false; + const exec = makeExecutor({ responders: ['alice', 'bob'], holders: ['alice', 'bob'], onClose: () => { closed = true; } }); + await exec.resolveAwait('aw1', 'bob', { approved: true }); + assert.equal(closed, true); + }); +}); diff --git a/middleware/test/conductorReminders.test.ts b/middleware/test/conductorReminders.test.ts new file mode 100644 index 00000000..a01f848e --- /dev/null +++ b/middleware/test/conductorReminders.test.ts @@ -0,0 +1,129 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { ConductorAwaitWorker, type ProactiveSenderLike } from '../src/conductor/awaitWorker.js'; +import type { ConductorAwaitStore } from '../src/conductor/awaitStore.js'; +import type { ConductorChannelBindingStore } from '../src/conductor/channelBindingStore.js'; +import type { ConductorRunExecutor } from '../src/conductor/runExecutor.js'; + +// Conductor US5 reminders — the await worker nudges a waiting holder on their bound channel, +// or flags `unreachable` when no binding resolves. Tick logic exercised with fakes (no DB). + +const NOW = () => new Date('2026-06-29T12:00:00.000Z'); + +function reminderAwait() { + return { + id: 'aw1', runId: 'run1', stepId: 'h1', principalKind: 'role', principalRef: 'approvers', + channelType: 'teams', message: 'approve the release', quorum: 'any', reminderIntervalMs: 3_600_000, + deadlineAt: null, fallbackTransitionId: null, status: 'waiting', createdAt: new Date(0), + }; +} + +function makeWorker(opts: { + bindings: Record; // holderId -> conversationRef + holders: string[]; + reminders?: unknown[]; + responded?: string[]; // responders already recorded (quorum='all' filtering) + withSender?: boolean; + claimWins?: boolean; +}) { + const sent: Array<{ conversationRef: unknown; text: string }> = []; + const claims: string[] = []; + const unreachableCalls: Array<{ id: string; unreachable: boolean }> = []; + const awaitStore = { + async listDue() { return []; }, + async listRemindersDue() { return opts.reminders ?? [reminderAwait()]; }, + async claimReminderDue(id: string) { claims.push(id); return opts.claimWins ?? true; }, + async setReminderUnreachable(id: string, unreachable: boolean) { unreachableCalls.push({ id, unreachable }); }, + async listResponses() { return (opts.responded ?? []).map((responderId) => ({ responderId, response: { approved: true } })); }, + } as unknown as ConductorAwaitStore; + const bindingStore = { + async getMany(userIds: string[]) { + const m = new Map(); + for (const u of userIds) if (opts.bindings[u] !== undefined) m.set(u, opts.bindings[u]); + return m; + }, + } as unknown as ConductorChannelBindingStore; + const sender: ProactiveSenderLike = { + async send({ conversationRef, message }) { sent.push({ conversationRef, text: message.text }); }, + }; + const worker = new ConductorAwaitWorker({ + awaitStore, + executor: {} as unknown as ConductorRunExecutor, + bindingStore, + resolveRoleHolders: async () => opts.holders, + // Dep is always wired; `withSender:false` models "no sender registered for this channel" (returns undefined). + getProactiveSender: () => (opts.withSender === false ? undefined : sender), + now: NOW, + }); + return { worker, sent, claims, unreachableCalls }; +} + +describe('ConductorAwaitWorker reminders', () => { + it('sends a reminder to each holder that has a channel binding', async () => { + const { worker, sent, unreachableCalls } = makeWorker({ + holders: ['alice', 'bob'], + bindings: { alice: { conv: 'A' }, bob: { conv: 'B' } }, + }); + await worker.tick(); + assert.equal(sent.length, 2); + assert.ok(sent[0]!.text.includes('approve the release')); + assert.deepEqual(unreachableCalls, [{ id: 'aw1', unreachable: false }]); + }); + + it('flags unreachable when no holder has a binding (and still advances the clock)', async () => { + const { worker, sent, unreachableCalls } = makeWorker({ + holders: ['alice', 'bob'], + bindings: {}, // nobody bound a channel + }); + await worker.tick(); + assert.equal(sent.length, 0); + assert.deepEqual(unreachableCalls, [{ id: 'aw1', unreachable: true }]); + }); + + it('only reminds holders with a binding; partial binding is not unreachable', async () => { + const { worker, sent, unreachableCalls } = makeWorker({ + holders: ['alice', 'bob'], + bindings: { bob: { conv: 'B' } }, // only bob bound + }); + await worker.tick(); + assert.equal(sent.length, 1); + assert.deepEqual(unreachableCalls, [{ id: 'aw1', unreachable: false }]); // at least one delivered + }); + + it('flags unreachable when no sender is registered for the channel', async () => { + const { worker, sent, unreachableCalls } = makeWorker({ + holders: ['alice'], + bindings: { alice: { conv: 'A' } }, + withSender: false, + }); + await worker.tick(); + assert.equal(sent.length, 0); + assert.deepEqual(unreachableCalls, [{ id: 'aw1', unreachable: true }]); // no sender → cannot deliver + }); + + it('does not send when the per-interval claim is lost (another replica won)', async () => { + const { worker, sent, claims, unreachableCalls } = makeWorker({ + holders: ['alice'], + bindings: { alice: { conv: 'A' } }, + claimWins: false, + }); + await worker.tick(); + assert.deepEqual(claims, ['aw1']); // claim attempted + assert.equal(sent.length, 0); // lost → no send + assert.deepEqual(unreachableCalls, []); // and no bookkeeping (the winner owns it) + }); + + it("quorum='all' does not re-nudge holders who already responded", async () => { + const allAwait = { ...reminderAwait(), quorum: 'all' }; + const { worker, sent } = makeWorker({ + holders: ['alice', 'bob'], + responded: ['alice'], // alice already approved + bindings: { alice: { conv: 'A' }, bob: { conv: 'B' } }, + reminders: [allAwait], + }); + await worker.tick(); + assert.equal(sent.length, 1); // only bob is nudged + assert.deepEqual(sent[0]!.conversationRef, { conv: 'B' }); + }); +}); diff --git a/middleware/test/conductorResumeWorker.test.ts b/middleware/test/conductorResumeWorker.test.ts new file mode 100644 index 00000000..804762b8 --- /dev/null +++ b/middleware/test/conductorResumeWorker.test.ts @@ -0,0 +1,99 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { ConductorRunResumeWorker } from '../src/conductor/runResumeWorker.js'; +import type { ConductorRunStore } from '../src/conductor/runStore.js'; +import type { ConductorRunExecutor } from '../src/conductor/runExecutor.js'; + +// Conductor US2 / SC-002 — the resume worker re-drives runs orphaned by a process restart. +// These tests exercise the tick logic with fakes (no Postgres): the at-most-once / staleness +// guarantees themselves live in the SQL claim, verified separately against a live DB. + +function fakeRun(id: string, currentStepId: string | null = 's1'): Record { + return { + id, + workflowVersionId: 'v1', + status: 'running', + currentStepId, + context: {}, + triggerKind: 'manual', + triggerSource: null, + isDryRun: false, + startedAt: new Date(0), + endedAt: null, + }; +} + +describe('ConductorRunResumeWorker.tick', () => { + it('resumes every claimed run once, threading the claim lease through to resumeRun', async () => { + const resumed: Array<{ runId: string; lease: string }> = []; + let claimArgs: { lease: string; staleMs: number; limit: number } | null = null; + const runStore = { + async claimResumableRuns(lease: string, staleMs: number, limit: number) { + claimArgs = { lease, staleMs, limit }; + return [fakeRun('a'), fakeRun('b')]; + }, + } as unknown as ConductorRunStore; + const executor = { + async resumeRun(runId: string, lease: string) { + resumed.push({ runId, lease }); + return fakeRun(runId); + }, + } as unknown as ConductorRunExecutor; + + const worker = new ConductorRunResumeWorker({ runStore, executor, claimerId: 'boot-1' }); + await worker.tick(); + // resumeRun is fire-and-forget inside the tick — let the microtasks flush. + await new Promise((r) => setImmediate(r)); + + assert.deepEqual(resumed.map((r) => r.runId).sort(), ['a', 'b']); + assert.equal(claimArgs!.staleMs, 900_000); // default 15 min ≫ orchestrator wall-clock cap + // The fencing lease the worker claimed with must be the same token it drives each run under. + assert.ok(claimArgs!.lease.length > 0); + assert.ok(resumed.every((r) => r.lease === claimArgs!.lease)); + }); + + it('does nothing when no runs are claimable', async () => { + const resumed: string[] = []; + const runStore = { async claimResumableRuns() { return []; } } as unknown as ConductorRunStore; + const executor = { + async resumeRun(runId: string) { resumed.push(runId); return fakeRun(runId); }, + } as unknown as ConductorRunExecutor; + + const worker = new ConductorRunResumeWorker({ runStore, executor, claimerId: 'boot-1' }); + await worker.tick(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(resumed, []); + }); + + it('swallows a claim error rather than throwing out of the tick', async () => { + const runStore = { + async claimResumableRuns() { throw new Error('db down'); }, + } as unknown as ConductorRunStore; + const executor = { async resumeRun() { return fakeRun('x'); } } as unknown as ConductorRunExecutor; + + const worker = new ConductorRunResumeWorker({ runStore, executor, claimerId: 'boot-1' }); + await assert.doesNotReject(() => worker.tick()); + }); + + it('never overlaps ticks (the in-flight guard)', async () => { + let claimCalls = 0; + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const runStore = { + async claimResumableRuns() { + claimCalls += 1; + await gate; // hold the first tick open + return []; + }, + } as unknown as ConductorRunStore; + const executor = { async resumeRun() { return fakeRun('x'); } } as unknown as ConductorRunExecutor; + + const worker = new ConductorRunResumeWorker({ runStore, executor, claimerId: 'boot-1' }); + const first = worker.tick(); + await worker.tick(); // should early-return while the first is still claiming + assert.equal(claimCalls, 1); + release(); + await first; + }); +}); diff --git a/middleware/test/conductorScheduleWorker.test.ts b/middleware/test/conductorScheduleWorker.test.ts new file mode 100644 index 00000000..853adca1 --- /dev/null +++ b/middleware/test/conductorScheduleWorker.test.ts @@ -0,0 +1,74 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { ConductorScheduleWorker } from '../src/conductor/scheduleWorker.js'; +import type { ConductorSchedule, ConductorScheduleStore } from '../src/conductor/scheduleStore.js'; +import type { ConductorRunExecutor } from '../src/conductor/runExecutor.js'; + +// Conductor US4 (cron) — the schedule worker fires workflows on their cron triggers. +// Tick logic exercised with fakes (no Postgres); per-minute exactly-once lives in the SQL claim. + +const NOON = () => new Date('2026-06-29T12:00:00.000Z'); // UTC — cronMatches reads UTC parts + +function sched(id: string, slug: string, cron: string, workflowEnabled = true): ConductorSchedule { + return { id, workflowId: `w-${id}`, workflowSlug: slug, workflowEnabled, cron, timezone: 'UTC' }; +} + +function fakes(opts: { + schedules: ConductorSchedule[]; + claimWins?: boolean; +}): { store: ConductorScheduleStore; executor: ConductorRunExecutor; fired: string[]; claims: string[] } { + const fired: string[] = []; + const claims: string[] = []; + const store = { + async listEnabled() { return opts.schedules; }, + async claimRun(scheduleId: string) { claims.push(scheduleId); return opts.claimWins ?? true; }, + } as unknown as ConductorScheduleStore; + const executor = { + async startRun(input: { slug: string; triggerKind?: string }) { + fired.push(`${input.slug}:${input.triggerKind}`); + return { id: 'r1' } as never; + }, + } as unknown as ConductorRunExecutor; + return { store, executor, fired, claims }; +} + +describe('ConductorScheduleWorker.tick', () => { + it('fires a matching, enabled, claim-won schedule as a cron run', async () => { + const { store, executor, fired } = fakes({ schedules: [sched('s1', 'daily-report', '0 12 * * *')] }); + const worker = new ConductorScheduleWorker({ scheduleStore: store, executor, now: NOON }); + await worker.tick(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(fired, ['daily-report:cron']); + }); + + it('skips a schedule whose workflow is disabled', async () => { + const { store, executor, fired, claims } = fakes({ schedules: [sched('s1', 'wf', '0 12 * * *', false)] }); + const worker = new ConductorScheduleWorker({ scheduleStore: store, executor, now: NOON }); + await worker.tick(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(fired, []); + assert.deepEqual(claims, []); // never even attempts the claim + }); + + it('does not fire when the cron does not match the current minute', async () => { + const { store, executor, fired, claims } = fakes({ schedules: [sched('s1', 'wf', '0 13 * * *')] }); + const worker = new ConductorScheduleWorker({ scheduleStore: store, executor, now: NOON }); + await worker.tick(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(fired, []); + assert.deepEqual(claims, []); + }); + + it('does not fire when the per-minute claim is lost (another replica won)', async () => { + const { store, executor, fired, claims } = fakes({ + schedules: [sched('s1', 'wf', '0 12 * * *')], + claimWins: false, + }); + const worker = new ConductorScheduleWorker({ scheduleStore: store, executor, now: NOON }); + await worker.tick(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(claims, ['s1']); // attempted + assert.deepEqual(fired, []); // but lost, so no run + }); +}); diff --git a/specs/005-omadia-conductor/data-model.md b/specs/005-omadia-conductor/data-model.md new file mode 100644 index 00000000..aa62068b --- /dev/null +++ b/specs/005-omadia-conductor/data-model.md @@ -0,0 +1,405 @@ +# Data Model: Omadia Conductor + +Phase 1 output. Entities, persistent schema, declarative (manifest) schema, and +in-memory runtime structures. DDL is illustrative — final column types/constraints +follow the repo's migration conventions (`middleware/migrations/`). Enums are stored +as `TEXT` + `CHECK` (not Postgres `ENUM`) so the value set can extend without +`ALTER TYPE`, consistent with `specs/001-multi-orchestrator-runtime/data-model.md`. + +## Entity Overview + +| Entity | Kind | Lifetime | +|---|---|---| +| Workflow | persistent (DB row) | until operator deletes | +| Workflow Version | persistent (DB row, immutable) | retained for audit; runs bind to it | +| Workflow Draft | persistent (DB row, mutable) | editable working copy until published | +| Run | persistent (DB row) | until retention policy prunes | +| Run Step | persistent (DB row) | with its run (durable step record / trace) | +| Await (`conductor_awaits`) | persistent (DB row) | from human step entry to resolve/timeout | +| Await Response | persistent (DB row) | with its await (per-holder, for `quorum: all`) | +| Role | persistent (DB row) | until operator deletes | +| Role Assignment | persistent (DB row) | the baton; until moved/expired | +| Event Catalog Entry | runtime registry (derived from manifests) | per installed connector | +| Conductor Surface | declarative (`manifest.yaml` `emits:`/`provides:`) | versioned with the connector | +| Conductor Engine state | runtime (in-memory, pure) | per step evaluation; no I/O | + +## Persistent Schema (Postgres / Neon) + +### `conductor_workflows` + +The workflow header. The graph itself lives in immutable versions and a mutable draft. + +```sql +CREATE TABLE conductor_workflows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, -- stable id, e.g. "release-signoff" + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'disabled'-- 'enabled' | 'disabled' + CHECK (status IN ('enabled','disabled')), + active_version_id UUID, -- FK set after first publish + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +- `status = 'disabled'` keeps the row but suppresses all triggers (FR-009). +- `active_version_id` is the version new runs bind to; it changes only on publish. + +### `conductor_workflow_versions` + +An immutable snapshot of the full graph. Runs reference exactly one version (FR-027). + +```sql +CREATE TABLE conductor_workflow_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL REFERENCES conductor_workflows(id) ON DELETE CASCADE, + version INT NOT NULL, -- monotonic per workflow + graph JSONB NOT NULL, -- steps + transitions + triggers (see below) + published_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workflow_id, version) +); +``` + +`graph` shape (validated by `@omadia/conductor-core` before publish): + +```jsonc +{ + "entryStepId": "s1", + "steps": [ + { "id": "s1", "kind": "agent", "agentId": "...", "postcondition": {...}, + "fallbackTransitionId": "t_fail", "position": { "x": 40, "y": 40 } }, + { "id": "s2", "kind": "human", "human": { /* see human-step config */ }, + "fallbackTransitionId": "t_deadline" }, + { "id": "s3", "kind": "action", "actionId": "github.create_release" } + ], + "transitions": [ + { "id": "t1", "source": "s1", "target": "s2", "guard": {...} }, + { "id": "t_fail", "source": "s1", "target": "s_end_fail" }, + { "id": "t_deadline","source": "s2","target": "s_autoreject" } // in-graph deadline fallback + ], + "triggers": [ + { "id": "tr1", "kind": "event", "eventId": "github.pull_request.merged", + "filter": { "base": "main" } }, + { "id": "tr2", "kind": "manual" } + ] +} +``` + +Human-step config (embedded in a `kind: "human"` step): + +```jsonc +{ + "principal": { "kind": "role", "ref": "approver.release" }, // or { "kind":"user","ref":"" } + "channel": "teams", + "message": "Release {{ctx.tag}} ready — approve?", + "reminderInterval": "PT6H", // ISO-8601 duration; null = no reminders + "deadline": "PT24H", // relative to step entry; null = no deadline + "quorum": "any", // 'any' | 'all' (default 'any') + "responseSchema": {...} // shape of the expected decision/input +} +``` + +### `conductor_workflow_drafts` + +The mutable working copy the Designer edits; publishing snapshots it into a version. + +```sql +CREATE TABLE conductor_workflow_drafts ( + workflow_id UUID PRIMARY KEY REFERENCES conductor_workflows(id) ON DELETE CASCADE, + graph JSONB NOT NULL DEFAULT '{}', -- same shape as versions.graph + base_version INT, -- version this draft was forked from + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### `conductor_runs` + +A live or completed execution, bound to one immutable version. + +```sql +CREATE TABLE conductor_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_version_id UUID NOT NULL REFERENCES conductor_workflow_versions(id), + status TEXT NOT NULL DEFAULT 'running' -- 'running'|'waiting'|'completed'|'failed' + CHECK (status IN ('running','waiting','completed','failed')), + current_step_id TEXT, -- node id within the version graph + context JSONB NOT NULL DEFAULT '{}', -- accumulated run context + trigger_kind TEXT NOT NULL, -- 'manual'|'cron'|'channel'|'agent'|'webhook'|'workflow'|'event' + trigger_source JSONB, -- e.g. { eventId, sourcePluginId } for event triggers + is_dry_run BOOLEAN NOT NULL DEFAULT false, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ +); +CREATE INDEX conductor_runs_waiting_idx ON conductor_runs(status) WHERE status = 'waiting'; +``` + +- `context` is persisted before each step transition (FR-004) so a restart rehydrates + an accurate run. `is_dry_run` runs never create real awaits or fire connector actions + (FR-029). + +### `conductor_run_steps` + +Durable per-step record — both the resume checkpoint (FR-004) and the audit trace +(FR-030). The human-facing view integrates with omadia's existing per-run trace viewer. + +```sql +CREATE TABLE conductor_run_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, -- node id in the version graph + seq INT NOT NULL, -- order within the run + actor JSONB, -- { kind:'agent', agentId } | { kind:'human', resolvedUserId } | { kind:'action', actionId } + postcondition_outcome TEXT, -- 'met' | 'unmet' | 'n/a' + transition_taken TEXT, -- transition id (incl. fallback) + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + UNIQUE (run_id, seq) +); +``` + +### `conductor_awaits` + +The durable pending human action — the one genuinely net-new substrate (today +`ask_user_choice` is in-memory and dies on restart). Drives reminders, deadline, and +resume. + +```sql +CREATE TABLE conductor_awaits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES conductor_runs(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + principal_kind TEXT NOT NULL CHECK (principal_kind IN ('user','role')), + principal_ref TEXT NOT NULL, -- user uuid OR role key + channel_type TEXT NOT NULL, -- 'teams'|'telegram'|... + message TEXT NOT NULL, + quorum TEXT NOT NULL DEFAULT 'any' CHECK (quorum IN ('any','all')), + reminder_interval_ms BIGINT, -- null = no reminders + deadline_at TIMESTAMPTZ, -- null = no deadline + fallback_transition_id TEXT, -- in-graph fallback (required if deadline set) + status TEXT NOT NULL DEFAULT 'waiting' + CHECK (status IN ('waiting','resolved','timed_out','cancelled')), + last_reminder_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX conductor_awaits_due_idx ON conductor_awaits(status, deadline_at, last_reminder_at) + WHERE status = 'waiting'; +``` + +- `principal_ref` holds a **role key**, not a frozen user id, when `principal_kind = + 'role'` — access and reminders re-resolve the current holder (FR-022, FR-023). +- A row with `deadline_at` set MUST carry `fallback_transition_id` (FR-017); enforced in + validation, not the DB. +- The scheduler polls `conductor_awaits_due_idx`: send a reminder when + `now ≥ last_reminder_at + reminder_interval_ms`; fire the fallback when + `now ≥ deadline_at` (reusing the `scheduleWorker` tick). + +### `conductor_await_responses` + +Per-holder responses, needed for `quorum: all` and for audit. + +```sql +CREATE TABLE conductor_await_responses ( + await_id UUID NOT NULL REFERENCES conductor_awaits(id) ON DELETE CASCADE, + responder_id UUID NOT NULL REFERENCES users(id), + response JSONB NOT NULL, -- the decision/input, shaped by responseSchema + responded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (await_id, responder_id) +); +``` + +- `quorum = 'any'`: the first qualifying row resolves the await. +- `quorum = 'all'`: resolved only when every *current* holder (re-resolved at check + time) has a row — a departed holder's obligation is dropped, a new holder's is added + (FR-019). + +### `conductor_roles` + +A named seat addressable by a human step. + +```sql +CREATE TABLE conductor_roles ( + key TEXT PRIMARY KEY, -- e.g. "approver.release" + label TEXT NOT NULL, + description TEXT, + scope TEXT, -- optional namespacing/tenant + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### `conductor_role_assignments` + +The baton. Read by the **default** `RoleResolver`. An external resolver may ignore this +table entirely and answer from its own source — Conductor only ever calls the resolver. + +```sql +CREATE TABLE conductor_role_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key TEXT NOT NULL REFERENCES conductor_roles(key) ON DELETE CASCADE, + holder_id UUID NOT NULL REFERENCES users(id), + provenance TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'resolver:' + delegate_id UUID REFERENCES users(id), -- optional stand-in + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_to TIMESTAMPTZ, -- null = open-ended + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX conductor_role_assignments_role_idx ON conductor_role_assignments(role_key); +``` + +- Multiple live rows for one `role_key` = a multi-holder role (interacts with `quorum`). +- Moving the baton = closing one assignment (`valid_to = now()`) and opening another; + this fires `role.assignment.changed` (below). + +### Change-notification triggers (run resume + baton moves) + +```sql +-- Wake a waiting run when its human responds (US2 resume hook, FR-004). +CREATE OR REPLACE FUNCTION notify_await_resolved() RETURNS trigger AS $$ +BEGIN + IF NEW.status IN ('resolved','timed_out') AND OLD.status = 'waiting' THEN + PERFORM pg_notify('conductor_await_resolved', NEW.run_id::text); + END IF; + RETURN NULL; +END; $$ LANGUAGE plpgsql; +-- AFTER UPDATE ON conductor_awaits + +-- Emit baton moves for audit + external subscription (FR-025). +CREATE OR REPLACE FUNCTION notify_role_changed() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('conductor_role_changed', + COALESCE(NEW.role_key, OLD.role_key)); + RETURN NULL; +END; $$ LANGUAGE plpgsql; +-- AFTER INSERT/UPDATE/DELETE ON conductor_role_assignments +``` + +The kernel runs `LISTEN conductor_await_resolved` and resumes the named run; a periodic +reconcile (scan `conductor_awaits_due_idx` and `status='waiting'` runs) is the fallback +for a dropped `LISTEN` connection, mirroring the multi-orchestrator reconcile (spec 001 +D3). + +## Declarative Schema — Manifest Extension (the "Conductor Surface") + +This feature adds an `emits:` block and an `events` permission to the *existing* plugin +`manifest.yaml` (loaded by `manifestLoader`, validated by `manifestLinter`). It is a +sibling of the existing `provides:` block — no parallel manifest format (FR-010). + +```yaml +# in a connector plugin's manifest.yaml +emits: + - id: github.pull_request.merged # stable, namespaced event id + label: "Pull request merged" + payload_schema: # JSON Schema; the Designer reads this + type: object + required: [repo, number, base, mergeSha] + properties: + repo: { type: string } + number: { type: integer } + base: { type: string } + mergeSha: { type: string } + schema_version: 1 + +permissions: + events: + emit: [github.pull_request.merged, github.release.created] # deny-by-default +``` + +- `provides:` (existing) already enumerates the **actions** a workflow can call back + into the connector. Together `emits:` + `provides:` are the connector's **Conductor + Surface** the Designer renders (FR-014). +- Absence of `emits:` is meaningful: the Designer shows the connector exposes no + Conductor triggers (FR-014). + +## Runtime Structures + +### `@omadia/conductor-core` (pure engine — no I/O) + +- `validate(graph): ValidationResult` — reachability, cycles, deadline-without-fallback, + unknown references (FR-003). +- `nextStep(graph, currentStepId, stepResult, ctx): Decision` — deterministic + advancement: postcondition verdict → matching guarded transition → else fallback → + else `Stuck` error (FR-001, FR-002, FR-006). +- No persistence, scheduling, notification, or LLM calls — those are kernel wiring + (FR-032). Unit-testable with fixtures exactly like `@omadia/canvas-core`. + +### `EventCatalogRegistry` (kernel, via `serviceRegistry`) + +Autodiscovered from installed manifests' `emits:` blocks ("declare → resolve → derive", +the canvas-output / deterministic-action pattern). Hot — install adds, uninstall removes +(FR-011). Read by: the Conductor's event subscription router (to start runs) and the +Designer (to offer triggers + payload fields). + +### `RoleResolver` registry (kernel, via `serviceRegistry`) + +`resolve(roleKey, ctx) → { holders: Principal[]; unavailable?: Principal[]; delegate?: Principal }`. +A default resolver reads `conductor_role_assignments`; an integration registers its own +(FR-021). Called late — at dispatch and on each reminder (FR-022). + +### `ctx.events.emit(id, payload)` (kernel, gated) + +Present only when the manifest declares `permissions.events.emit` (deny-by-default). +Validates `payload` against the catalog's declared schema; rejects + logs a +non-conforming emit; otherwise stamps provenance and routes to subscribed workflows +(FR-012). + +## State Machines + +### Run + +```text + ┌───────────── (step needs human/timer/event) ─────────────┐ + ▼ │ + (start) running ──(step completes, more steps)──▶ running │ + │ │ │ + │ └──▶ waiting ───────┘ (awaited signal arrives) + ├──(entry/end step, no more steps)──▶ completed + └──(step error / stuck-no-fallback)─▶ failed +``` + +### Await + +```text + (human step entered) waiting + ├── qualifying response (quorum satisfied) ──▶ resolved → resume run + ├── deadline passes, no qualifying response ──▶ timed_out → fire fallback transition + └── run cancelled/superseded ───────────────▶ cancelled +``` + +`waiting → {resolved, timed_out}` is atomic (FR-018): the first of {qualifying response, +deadline} wins; the transition emits `conductor_await_resolved`. + +## Relationships + +```text +conductor_workflows 1───n conductor_workflow_versions (a workflow has many versions) +conductor_workflows 1───1 conductor_workflow_drafts (one editable draft) +conductor_workflow_versions 1───n conductor_runs (a version backs many runs) +conductor_runs 1───n conductor_run_steps (a run records its step path) +conductor_runs 1───n conductor_awaits (a run may open several human steps) +conductor_awaits 1───n conductor_await_responses (per-holder responses; quorum) +conductor_roles 1───n conductor_role_assignments (a role has current holder(s) = the baton) +conductor_awaits n───1 conductor_roles (role-addressed await; resolved live) +users 1───n conductor_role_assignments (a user may hold several roles) +EventCatalogRegistry ──derives──> connector manifest `emits:` (runtime, per install) +``` + +## Validation Rules + +- `conductor_workflows.slug`: unique, immutable, URL-safe. +- A published version's `graph`: must pass `@omadia/conductor-core` `validate()` — + reachable steps, no unguarded cycle, every deadline-bearing human step has a + `fallbackTransitionId`, every referenced `agentId`/`actionId`/`role`/`eventId` resolves + (FR-003). Validation runs in the Designer and again on publish/activate. +- `conductor_runs` bind to an immutable `workflow_version_id`; a workflow edit creates a + new version and never mutates an in-flight run's version (FR-027). +- `conductor_awaits` with `deadline_at` set must carry `fallback_transition_id`. +- An emit is validated against the **installed** connector's declared `payload_schema` + for that `schema_version`; a non-conforming emit starts no run (FR-012). +- A role-addressed await authorizes read/answer against the role's *current* holders at + access time; a user who no longer holds the role cannot read or answer it (FR-023). +- A role that resolves to zero available holders makes the human step's postcondition + unmet → fallback transition fires (FR-024). +- `quorum = 'all'` evaluates the required-holder set at the completion check, re-resolved + via the `RoleResolver` (not frozen at await creation) (FR-019). diff --git a/specs/005-omadia-conductor/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..cac2424b --- /dev/null +++ b/specs/005-omadia-conductor/spec.md @@ -0,0 +1,683 @@ +# Feature Specification: Omadia Conductor — Deterministic Workflow Engine, Designer & Human-in-the-Loop + +**Feature Branch**: `005-omadia-conductor` +**Created**: 2026-06-16 +**Status**: Draft +**Input**: An operator wants to build real, auditable processes that combine +**agentic steps** (an Agent does work) and **human steps** (a person decides, +approves, or supplies input) and that start automatically on real-world events — +e.g. a release pipeline that runs on every merge / RC-build and then asks a human +for release sign-off; a customer-handover preparation; a step that fires when a +calendar appointment approaches; or an applicant flow that starts when a +candidate is set to "invite" in an external ATS. The headline requirement is a +**deterministic harness**: the runtime — not the LLM — owns step progression and +hand-offs, so a process cannot silently stall the way prompt-only multi-agent +frameworks do (an agent that "forgets" to delegate). The operator must be able to +design these workflows visually and conversationally (a sibling of the Agent +Builder), save and later update them, and — after connecting an external system +via a connector plugin — immediately see whether and how that system can interact +with the Conductor. + +## Overview + +Today omadia runs Agents as single-agent orchestrator loops (`@omadia/orchestrator`, +`buildOrchestratorForAgent`). Multi-agent coordination is **LLM-decided**: an Agent +may call a domain sub-agent as a tool, or a plugin may call `ctx.subAgent.ask(...)`, +but **nothing in the runtime owns the order of steps or enforces a hand-off**. The +canvas Agent Graph (`@omadia/plugin-api` `agentGraph.ts`) is structural wiring, not +an executed sequence. The platform already ships the *atoms* of a deterministic +harness — tool **postconditions** + the verifier (`dynamicAgentRuntime.ts`, +`@omadia/verifier`), the OB-31 tool-obligation / repeat-failure loop guards +(`localSubAgent.ts`), and the `deterministic_action` fast-path +(`deterministicActionRegistry.ts`) — but only **per tool / per turn**, never across +a multi-step process or a hand-off. + +This feature introduces **Conductor**: a process layer that promotes those atoms to +**process scope**. A *Workflow* is a declarative graph of **steps** (an agent turn, +a deterministic action, or a human step) connected by **guarded transitions**. The +**Conductor** runtime owns advancement: after each step it evaluates the step's +**exit postcondition** and, when it is unmet, it does not hope the LLM self-corrects +— it acts deterministically (re-inject / force a tool obligation / route to a +declared fallback transition). A hand-off is a transition the Conductor fires, not a +prompt line an Agent can drop. + +A **human step** is the same pattern with a person as the actor: its postcondition is +"the addressed principal responded by the deadline"; if unmet, the deterministic +action is "send a reminder"; on deadline it fires the fallback transition. The +addressed principal is either a **specific user** or a **role** (a baton that is +late-bound at dispatch to whoever currently holds it). + +Workflows start on **triggers**. Every trigger funnels into a single entry point +(`startRun(workflowId, payload)`); from there the Conductor owns the run. One trigger +class is first-class and designed in from day one: **events emitted by connector +plugins**, declared in the connector's manifest (a self-describing "Conductor +Surface") so the Designer can surface them automatically. + +Architecture placement (see Assumptions): Conductor ships **in this repo, modular** — +a pure `@omadia/conductor-core` engine package (sibling of `@omadia/canvas-core`), +kernel wiring in `middleware/src/` via the existing `serviceRegistry`, and a Designer +under `web-ui/app/admin/conductor/` that mirrors the Agent Builder. No separate repo. + +Out of scope (handled elsewhere, deferred, or owned by the live instance): + +- **Connector plugins themselves** (GitHub/CI, ATS/HR, calendar, ERP, …). Conductor + defines the *contract* a connector implements; building connectors is separate + plugin work. Conductor never hard-codes knowledge of any specific connector. +- **The HR/ERP role-movement policy** — *when and why* a baton moves automatically + (sickness, vacation, org change). Conductor exposes the resolver seam and the + assignment store/APIs/events; the live instance + its integration own the policy. +- **N-of-M quorum** beyond the two-value `any | all` switch — a later extension. +- **Sub-workflow invocation as the deadline fallback** — per the 2026-06-16 + clarification the deadline fallback is an **in-graph transition only**. Workflow→ + workflow *triggering* is in scope; calling a separate workflow *as a deadline + handler* is not. +- **Distributed multi-process scheduling** — the existing single-process scheduler + model (`scheduleWorker`) is reused; horizontal scale-out of the timer loop is a + later concern, consistent with the platform today. +- **Knowledge-Graph / per-record ACL redesign** — Conductor consumes existing scoping + and adds only the await/role access rule defined here. + +## Clarifications + +### Session 2026-06-16 + +- Q: Conductor as a separate repo or in this monorepo? → A: **In-repo, modular** — + `@omadia/conductor-core` (pure engine) + kernel wiring via `serviceRegistry` + + Designer under `web-ui/app/admin/conductor/`. A separate repo would force + publishing internal `@omadia/*` packages and a cross-repo version matrix for + something that ships as one Docker image. Only an HR/ERP role resolver belongs in + a separate, swappable plugin. +- Q: When a human step's deadline passes, branch within the same workflow or call a + separate sub-workflow? → A: **In-graph branch only** (a guarded transition such as + "auto-reject" or "escalate"). Keeps the engine lean. +- Q: When a role has several holders, who must respond? → A: **Per-step switch** + `quorum: any | all` (default `any`). `any` = first responder decides; `all` = + every current holder must respond. +- Q: How important is the event/condition trigger? → A: **First-class, day one** (not + Phase 2). The *contract* — a connector's manifest `emits:` block, the event + catalog, `ctx.events.emit`, the subscription/filter model — ships now. Only the + connectors themselves are out of scope. +- Q: How is a role resolved to a person? → A: **Late binding** — resolved at step + dispatch and re-resolved on every reminder, via a pluggable `RoleResolver` seam + (registered like `LlmProvider`/channels). A default resolver reads omadia's own + manual assignment table; an integration may register a resolver that consults + external availability. Access to a pending await is granted to whoever holds the + role **at access time**, never frozen to a user id. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Deterministic Process Engine (`conductor-core`) (Priority: P1) + +A platform developer defines a Workflow as a graph of steps and guarded transitions +in a pure, I/O-free engine. The engine, given a current step result, decides the next +step deterministically: it evaluates the completed step's exit postcondition and +selects the matching transition; if no postcondition is satisfied and no transition +matches, it selects the step's declared fallback transition rather than ending +ambiguously. + +**Why this priority**: This is the harness — the headline capability. Every other +story builds on a runtime that owns advancement. It is pure and unit-testable in +isolation, exactly like `@omadia/canvas-core`. + +**Independent Test**: Construct a three-step workflow with a postcondition on step 1 +and two outgoing guarded transitions; feed synthetic step results; confirm the engine +selects the correct next step for a satisfied postcondition, the fallback transition +for an unmet one, and rejects a graph with an unreachable step or a cycle without a +progress guard at validation time. + +**Acceptance Scenarios**: + +1. **Given** a workflow graph, **When** a step completes with a result that satisfies + exactly one outgoing transition guard, **Then** the engine advances to that + transition's target step. +2. **Given** a step whose exit postcondition is unmet, **When** the engine evaluates + it, **Then** it does not advance on a "happy path" transition; it selects the + step's declared fallback (or raises a precise "stuck, no fallback" error if none). +3. **Given** a graph with an unreachable step or an unguarded cycle, **When** it is + validated, **Then** validation fails naming the offending node(s). +4. **Given** the same workflow and the same sequence of step results, **When** the + engine runs twice, **Then** it produces the identical step path (determinism). + +--- + +### User Story 2 - Durable Run Lifecycle & Resume (Priority: P1) + +A workflow run is persisted from start to finish. A run that is waiting (on a human, +a timer, or an external event) survives a process restart and resumes exactly where +it left off when the awaited signal arrives. + +**Why this priority**: Without durability the engine is a demo. Real processes wait +hours or days; a restart must not lose a run or double-fire a step. + +**Independent Test**: Start a run, advance it to a waiting step, restart the +middleware process, deliver the awaited signal, and confirm the run resumes at the +correct step with its accumulated context intact and no step re-executed. + +**Acceptance Scenarios**: + +1. **Given** a started run, **When** it advances, **Then** each completed step and the + run's accumulated context are persisted before the next step begins. +2. **Given** a run in a `waiting` state, **When** the process restarts, **Then** the + run is rehydrated and remains `waiting` — no step is re-executed and no timer is + lost. +3. **Given** a waiting run, **When** its awaited signal (human response, timer tick, + event) arrives, **Then** the run resumes at the waiting step and advances. +4. **Given** a step that throws or times out, **When** the engine handles it, **Then** + the run transitions to a `failed`/fallback state per the graph — never a silent + hang with no recorded state. + +--- + +### User Story 3 - Triggers Start a Run (Priority: P1) + +An operator (or another system) starts a workflow run. All entry paths — an inbound +channel message, a cron schedule, a manual UI/API start, an Agent calling a +`start_workflow` tool, an external webhook, or another workflow — funnel into one +`startRun(workflowId, payload)` entry point, and the trigger payload becomes the run's +initial context. + +**Why this priority**: A workflow that cannot be started is inert. The unified funnel +keeps the engine independent of how a run begins and makes new trigger types cheap. + +**Independent Test**: Define a workflow with a manual trigger and a cron trigger; +start it both ways; confirm both produce a run whose initial context equals the +supplied payload and whose first step is the workflow's entry step. + +**Acceptance Scenarios**: + +1. **Given** a workflow with a manual trigger, **When** an operator starts it with a + payload, **Then** a run is created with that payload as initial context. +2. **Given** a workflow with a cron trigger, **When** the schedule matches, **Then** a + run starts automatically, reusing the existing `scheduleWorker` mechanism. +3. **Given** a workflow bound to an inbound channel, **When** a matching message + arrives, **Then** a run starts with the message as initial context. +4. **Given** a disabled workflow, **When** any trigger fires, **Then** no run starts + and the suppressed trigger is logged — never silently dropped. + +--- + +### User Story 4 - Event Triggers & the Connector "Conductor Surface" (Priority: P1) + +A connector plugin declares, in its `manifest.yaml`, the **events it can emit** (each +with a stable id, label, and a payload JSON Schema) and the **actions it provides**. +On install, the kernel autodiscovers these into an event catalog. When the connector's +external system fires, the connector calls `ctx.events.emit(id, payload)`; the kernel +validates the payload against the declared schema and routes it. A workflow's event +trigger names an event id and an optional filter; a matching emit starts a run with +the validated payload as context. The Designer reads the catalog so the operator +immediately sees which Conductor interactions a freshly connected system supports. + +**Why this priority**: This is the trigger class the operator's real use cases depend +on (merge / RC-build, ATS "invite", calendar). Elevated to day-one in the 2026-06-16 +clarification. It reuses the existing manifest self-description (`provides:`) and the +"declare → resolve → derive" autodiscovery pattern (canvas-output / +deterministic-action), so it is idiomatic, not a foreign body. + +**Independent Test**: Install a fixture connector whose manifest declares an `emits:` +event with a payload schema; confirm the catalog lists it; emit a valid payload and a +schema-violating payload; confirm the valid one starts a subscribed workflow run with +the payload as context and the invalid one is rejected at the seam and logged; confirm +uninstalling the connector removes the event from the catalog. + +**Acceptance Scenarios**: + +1. **Given** a connector manifest with an `emits:` block, **When** it is installed, + **Then** each declared event (id, label, payload schema) appears in the event + catalog and is offered by the Designer as a selectable trigger. +2. **Given** a workflow subscribed to event `X` with filter `F`, **When** the + connector emits `X` with a payload matching `F`, **Then** a run starts with the + payload as initial context; a non-matching payload starts no run. +3. **Given** an emit whose payload violates the declared schema, **When** it reaches + `ctx.events.emit`, **Then** it is rejected with a precise error and logged — no run + starts on malformed data. +4. **Given** a connector that declares no `emits:`, **When** it is installed, **Then** + the Designer clearly shows it exposes no Conductor triggers (absence is as explicit + as presence) while still listing any actions it `provides:`. +5. **Given** a connector that is uninstalled, **When** the catalog is read, **Then** + its events are gone and workflows that subscribed to them surface a clear + "trigger source missing" diagnostic rather than silently never firing. + +--- + +### User Story 5 - Human Step with Durable Awaits, Reminders & Deadline (Priority: P1) + +A workflow step addresses a human for a decision, approval, or input. The step +notifies the addressed principal on a configured channel and creates a **durable +pending await**. If the human does not respond within the reminder interval, omadia +re-sends a reminder; if an optional deadline passes with no response, the Conductor +fires the step's in-graph fallback transition. When the human responds, the run +resumes. For a role with multiple holders the step's `quorum` decides whether one +response (`any`) or all current holders (`all`) are required. + +**Why this priority**: Human-in-the-loop is the explicit product requirement that +distinguishes Conductor from a pure agent pipeline. The durable await is the one +genuinely net-new substrate (today `ask_user_choice` is in-memory and dies on +restart). + +**Independent Test**: Build a workflow with a human approval step (target principal, +channel, 6h reminder, 24h deadline, fallback = "auto-reject"); start a run; confirm +the principal is notified and an await row persists; advance the clock past the +reminder with no response and confirm a reminder is sent; advance past the deadline +and confirm the fallback transition fires; in a second run, respond before the +deadline and confirm the run resumes on the approval branch. Verify both `quorum` +modes for a multi-holder role. + +**Acceptance Scenarios**: + +1. **Given** a human step, **When** the run reaches it, **Then** the addressed + principal is notified on the configured channel and a durable await is created in + the `waiting` state. +2. **Given** a pending await with a reminder interval, **When** the interval elapses + with no response, **Then** a reminder is sent (re-resolving a role to its *current* + holder), bounded so reminders stop once the await is resolved. +3. **Given** a pending await with a deadline, **When** the deadline passes with no + qualifying response, **Then** the Conductor fires the step's declared in-graph + fallback transition and the await is closed as `timed_out`. +4. **Given** a human response that arrives, **When** it is recorded, **Then** the run + resumes; a late response arriving after the deadline/resolution is rejected and + logged, never double-advancing the run. +5. **Given** a role-addressed step with `quorum: all`, **When** responses arrive, + **Then** the step completes only after every current holder has responded; with + `quorum: any` the first qualifying response completes it. + +--- + +### User Story 6 - Principals & the Role Resolver Seam (the "baton") (Priority: P1) + +A workflow step addresses a **principal**: either `user:` (a specific person, who +may be any omadia user of the instance, not only the workflow's creator) or +`role:` (a named seat). A role is resolved to its current holder(s) **at dispatch +time and re-resolved on every reminder**, through a pluggable `RoleResolver`. omadia +ships a default resolver backed by a manual assignment store (the baton can be moved +by an API/Designer action); an integration may register a resolver that reports the +current holder and unavailability from an external source. Access to a pending await +and its payload is granted to whoever holds the role **at access time** — when the +baton moves, access moves with it. + +**Why this priority**: Addressing a fixed person is brittle (people change roles, go +on leave). The role indirection is required for the operator's real processes and must +be in the data model from the start, not retrofitted. + +**Independent Test**: Define `role:approver`; assign it to user A; start a run that +addresses `role:approver`; confirm A is notified and can see/answer the await; move the +baton to user B; confirm the next reminder targets B, that B can now see/answer the +await, and that A no longer can; with no holder assigned, confirm the step takes the +fallback transition. + +**Acceptance Scenarios**: + +1. **Given** a step addressing `user:`, **When** the run reaches it, **Then** that + specific omadia user is the addressed principal regardless of who started the run. +2. **Given** a step addressing `role:`, **When** the step dispatches, **Then** the + holder is resolved live via the `RoleResolver`; the registered resolver (default: + manual store) determines the result and Conductor hard-codes no role semantics. +3. **Given** a pending await for a role, **When** the baton moves to a new holder, + **Then** the new holder gains access to the await and its payload and the previous + holder loses it, resolved at access time — not frozen to a user id. +4. **Given** a role with no current holder (or all holders reported unavailable with no + delegate), **When** the step dispatches or a reminder is due, **Then** it is treated + as an unmet postcondition and the fallback transition fires — reusing the same + harness, no special-casing. +5. **Given** a baton move, **When** it occurs, **Then** a `role.assignment.changed` + event and an `await.reassigned` event are emitted for audit and for any external + subscriber. + +--- + +### User Story 7 - Conductor Designer: Visual & Conversational Co-Design (Priority: P2) + +An operator opens the Conductor Designer, designs a workflow in conversation with a +builder agent and on a visual flow diagram (the same UX as the Agent Builder, applied +to the collaboration *between* Agents), and saves it. Saved workflows can be reopened +and updated; saves are versioned so a later edit does not silently mutate the +definition a running release depends on. + +**Why this priority**: The capability is usable via API/config once US1–US6 land; the +Designer is the ergonomics layer that makes it a product. It is high value but depends +on the engine and the catalog existing first — the same sequencing the Agent Builder's +Operator UI followed. + +**Independent Test**: Use the Designer to build a workflow with a trigger, an agentic +step, and a human step with a role and a deadline fallback; save it; confirm it +validates and persists; reopen it, change the reminder interval, save again, and +confirm a new version is recorded while a run started on the prior version is +unaffected. + +**Acceptance Scenarios**: + +1. **Given** the Designer canvas, **When** the operator adds steps, transitions, and a + trigger and wires them, **Then** the visual graph and the persisted workflow + definition stay in sync via the same optimistic-mutation-with-rollback pattern the + Agent Builder uses. +2. **Given** the builder agent, **When** the operator describes a process in chat, + **Then** the agent mutates the workflow definition incrementally (create step, wire + transition, set postcondition, add human step) and the canvas reflects each change. +3. **Given** installed connectors, **When** the operator picks a trigger, **Then** the + Designer offers the event catalog's events with their payload fields available for + filters/branches (field autocomplete from the declared schema). +4. **Given** a saved workflow, **When** the operator edits and re-saves it, **Then** a + new version is recorded and runs already in flight continue on the version they + started with. +5. **Given** an invalid workflow (unreachable step, missing fallback on a deadline, + unknown role), **When** the operator tries to save/activate it, **Then** validation + blocks it and names the failing check. + +--- + +### User Story 8 - Workflow Dry-Run / Preview (Priority: P2) + +Before activating a workflow, the operator runs it in a preview mode that simulates the +multi-agent path and lets the operator stand in for human steps, without notifying real +users or performing irreversible connector actions. + +**Why this priority**: Mirrors the Agent Builder's preview value — confidence before +go-live — but for a process. Multi-agent preview is net-new (the single-agent +`previewRuntime` does not cover it), so it is its own story, not a reuse. + +**Independent Test**: Dry-run a workflow with one agentic and one human step; confirm +the agentic step executes against preview-scoped tools, the human step prompts the +operator inline (no real channel notification, no durable await against a real user), +and the simulated path matches the engine's deterministic decisions. + +**Acceptance Scenarios**: + +1. **Given** dry-run mode, **When** a run executes, **Then** human steps are answered + inline by the operator and no real notification, reminder, or durable await against + a real user is created. +2. **Given** dry-run mode, **When** a step would call a connector action flagged + irreversible, **Then** it is simulated/stubbed rather than executed. +3. **Given** a dry-run, **When** it completes, **Then** the operator sees the full step + path, each step's postcondition outcome, and where fallbacks would have fired. + +--- + +### User Story 9 - Run Audit & Observability (Priority: P3) + +Every run produces an auditable trace: which trigger started it, each step with its +actor (Agent or resolved human principal), each postcondition outcome, each transition +taken (including fallbacks), every reminder sent, and every baton resolution. This +plugs into omadia's existing per-run trace / call-stack viewer. + +**Why this priority**: Auditability is a core omadia promise and a selling point over +prompt-only frameworks, but the run is functional without the viewer; this is the +observability layer on top. + +**Independent Test**: Run a workflow that takes a fallback transition and sends a +reminder; open the run trace; confirm the trigger, every step, the postcondition +verdicts, the reminder, the resolved human principal, and the fallback transition are +all present and ordered. + +**Acceptance Scenarios**: + +1. **Given** a completed run, **When** its trace is opened, **Then** it shows the + trigger, the ordered step path, each actor, each postcondition outcome, and each + transition (including fallbacks). +2. **Given** a human step, **When** its trace entry is inspected, **Then** it records + the addressed principal, the *resolved* holder at dispatch, any reminders, and the + final response or timeout. +3. **Given** an event-triggered run, **When** its trace is inspected, **Then** the + originating event id, source connector, and (redaction-respecting) payload are + recorded. + +--- + +### Edge Cases + +- **Process restart mid-wait**: the run stays `waiting`; the timer for reminders/ + deadline is re-derived from persisted timestamps on boot, not from an in-memory + timer (US2). +- **Deadline fires while a response is in flight**: resolution is atomic — the first of + {qualifying response, deadline} wins; the loser is rejected and logged, the run never + double-advances (US5). +- **Reminder after resolution**: reminders are bounded by the await state; once + `resolved`/`timed_out`, no further reminder is sent (US5). +- **Baton moves mid-wait**: the next reminder re-resolves and targets the new holder; + access to the await follows the current holder at access time (US6). +- **Role with no holder / all unavailable**: treated as an unmet postcondition → the + fallback transition fires; no silent hang (US6). +- **`quorum: all` and a holder leaves the role mid-wait**: the required set is the + holders current *at completion check* time; a departed holder's outstanding + obligation is dropped, a newly added holder's is added — re-resolved, not frozen + (US5/US6). +- **Connector uninstalled while a run is subscribed/waiting on its events**: the + workflow surfaces a "trigger source missing" diagnostic; in-flight runs already + started are unaffected (US4). +- **Event payload schema changes between connector versions**: the catalog records the + schema version; an emit is validated against the installed version; a subscribed + workflow referencing a now-absent field surfaces a validation diagnostic in the + Designer (US4). +- **Cyclic graph / unreachable step / deadline step with no fallback**: rejected at + workflow validation time, in the Designer and on activation (US1/US7). +- **Workflow edited while runs are in flight**: in-flight runs continue on their + started version; only new runs use the new version (US7). +- **Two triggers fire for the same workflow near-simultaneously**: each produces an + independent run; runs do not share mutable state. +- **Human step targets a user who has no binding on the configured channel**: the await + is created but flagged "principal unreachable on channel"; per configuration this + either escalates via the fallback or surfaces an operator diagnostic — never a silent + no-op. +- **Agentic step stalls (LLM ends without satisfying the postcondition)**: the + Conductor applies the existing tool-obligation/repeat-failure guards at step scope + and, if still unmet, fires the fallback — the harness on track (US1). + +## Requirements *(mandatory)* + +### Functional Requirements + +**Engine & runs** + +- **FR-001**: The system MUST provide a pure, I/O-free engine package + (`@omadia/conductor-core`) that models a Workflow as steps + guarded transitions and, + given a completed step's result, deterministically selects the next step or the + step's declared fallback. +- **FR-002**: The engine MUST evaluate a completed step's **exit postcondition** and + MUST NOT advance on a happy-path transition when the postcondition is unmet; it MUST + instead select the step's fallback transition, or raise a precise error if none is + declared. +- **FR-003**: Workflow validation MUST reject unreachable steps, unguarded cycles, a + deadline-bearing human step without a fallback transition, and references to unknown + roles, events, agents, or actions — naming the offending node. +- **FR-004**: A workflow **run** MUST be persisted such that each completed step and the + run's accumulated context are durable before the next step begins, and a `waiting` + run MUST survive a process restart and resume without re-executing or skipping a step. +- **FR-005**: A step that throws or exceeds its time budget MUST drive the run to a + recorded `failed`/fallback state per the graph — never an unrecorded hang. +- **FR-006**: The engine MUST be deterministic: identical workflow + identical sequence + of step results MUST yield the identical step path. + +**Triggers** + +- **FR-007**: All trigger types MUST funnel into a single `startRun(workflowId, + payload)` entry point, and the trigger payload MUST become the run's initial context. +- **FR-008**: The system MUST support, as start triggers, at minimum: manual + (UI/API), cron (reusing `scheduleWorker`/`agent_schedules`), inbound channel message, + an Agent-invoked `start_workflow` tool, an external webhook, and workflow→workflow. +- **FR-009**: A trigger that fires for a disabled or non-existent workflow MUST start no + run and MUST be logged — never silently dropped. + +**Event triggers / Conductor Surface** + +- **FR-010**: The plugin `manifest.yaml` MUST be extendable with an `emits:` block in + which a connector declares events it can emit — each with a stable `id`, a human + label, and a payload JSON Schema. This is a sibling of the existing `provides:` block; + no parallel manifest format is introduced. +- **FR-011**: On install/activation the kernel MUST autodiscover declared `emits:` + entries into an event catalog (the "declare → resolve → derive" pattern, provided via + `serviceRegistry`), and MUST remove them on uninstall/hot-unload. +- **FR-012**: The kernel MUST expose `ctx.events.emit(id, payload)`, gated by a manifest + permission (`permissions.events.emit`, deny-by-default), and MUST validate the payload + against the declared schema, rejecting and logging a non-conforming emit so no run + starts on malformed data. +- **FR-013**: A workflow event trigger MUST be able to name an event `id` plus an + optional filter over payload fields; a matching emit MUST start a run with the + validated payload as initial context; a non-matching emit MUST start no run. +- **FR-014**: The system MUST expose the catalog such that, after a connector is + installed, an operator can see which events (triggers) and which actions a connector + makes available to the Conductor — and the absence of `emits:` MUST be presented as + clearly as its presence. + +**Human steps & awaits** + +- **FR-015**: A human step MUST create a **durable** pending await (surviving process + restart) carrying its addressed principal, channel, message, reminder interval, + optional deadline, fallback transition reference, `quorum`, and status. +- **FR-016**: The system MUST notify the addressed principal on the configured channel + using the existing proactive-send mechanism, and MUST send reminders at the configured + interval until the await is resolved or timed out. +- **FR-017**: When a deadline passes with no qualifying response, the system MUST fire + the human step's **in-graph fallback transition** (not a separate sub-workflow) and + close the await as `timed_out`. +- **FR-018**: Await resolution MUST be atomic between a qualifying response and the + deadline; a response arriving after resolution/timeout MUST be rejected and logged, + never double-advancing the run. +- **FR-019**: A human step MUST support `quorum: any | all` (default `any`); `all` MUST + complete only when every *current* holder of the addressed role has responded, with + the required set re-resolved (not frozen) at the completion check. + +**Principals & roles** + +- **FR-020**: A human step MUST address a **principal** that is either `user:` (any + omadia user of the instance, not only the run's initiator) or `role:`. +- **FR-021**: A `role:` MUST be resolved to its current holder(s) via a pluggable + `RoleResolver` registered through `serviceRegistry` (the same seam pattern as + `LlmProvider`/channels); Conductor MUST hard-code no role semantics. A default + resolver MUST be provided, backed by a manual assignment store with APIs to move the + baton. +- **FR-022**: Role resolution MUST be **late-bound**: performed at step dispatch and + re-performed on each reminder, so a baton that moves before or during a wait routes to + the current holder. +- **FR-023**: Access to a pending await and its payload MUST be authorized against the + role's holder **at access time**; when the baton moves, the new holder gains access + and the previous holder loses it. +- **FR-024**: A role with no current holder (or all holders reported unavailable with no + delegate) MUST be treated as an unmet postcondition and fire the fallback transition. +- **FR-025**: Baton moves and await reassignments MUST emit `role.assignment.changed` + and `await.reassigned` events for audit and external subscription. + +**Designer** + +- **FR-026**: The system MUST provide a Conductor Designer under + `web-ui/app/admin/conductor/` that lets an operator build a workflow visually (a flow + diagram reusing the Agent Builder's React-Flow canvas, optimistic-mutation, and REST + patterns) and conversationally (a builder agent that incrementally mutates the + workflow definition). +- **FR-027**: The Designer MUST persist workflows with **versioning**; editing and + re-saving a workflow MUST create a new version and MUST NOT alter the definition used + by runs already in flight. +- **FR-028**: The Designer MUST source trigger options from the live event catalog and + MUST offer payload fields (from the declared schema) for filters and branch + conditions; it MUST block save/activation of an invalid workflow, naming the failing + check. + +**Preview & audit** + +- **FR-029**: The system MUST provide a dry-run/preview mode in which human steps are + answered inline by the operator (no real notification, reminder, or durable await + against a real user) and connector actions flagged irreversible are simulated. +- **FR-030**: Every run MUST emit a structured, auditable trace — trigger, ordered step + path, each actor (Agent or resolved human holder), each postcondition outcome, each + transition (including fallbacks), reminders, and baton resolutions — integrating with + omadia's existing per-run trace viewer and respecting existing redaction. + +**Architecture & reuse** + +- **FR-031**: Conductor MUST reuse the existing platform primitives rather than + duplicate them: orchestrator/sub-agent loop and its postcondition/obligation guards, + `scheduleWorker` for time-driven signals, the channel registry + proactive sender for + notifications, the user store for principals, and the verifier — extended, not + replaced. +- **FR-032**: The engine (`@omadia/conductor-core`) MUST be pure and I/O-free; all + persistence, scheduling, notification, and LLM I/O MUST live in kernel wiring outside + the engine package, so the engine is unit-testable in isolation. + +### Key Entities + +- **Workflow**: a named, versioned process definition — a graph of steps + guarded + transitions + one or more triggers. Identified by a slug; immutable per version. +- **Workflow Version**: an immutable snapshot of a workflow's graph; runs bind to the + version they start on. +- **Step**: a node of kind `agent` (an Agent turn), `action` (a deterministic action), + or `human` (a human step). Carries an exit postcondition and a fallback transition + reference. +- **Transition**: a guarded directed edge from one step to another; the guard is + evaluated against the source step's result/context. A step's fallback is a designated + transition. +- **Trigger**: a run starter bound to a workflow — kind `manual | cron | channel | + agent | webhook | workflow | event`. An `event` trigger names a catalog event id + an + optional payload filter. +- **Run**: a live or completed execution of a Workflow Version — state, current step, + accumulated context, audit trace. States include `running | waiting | completed | + failed`. +- **Await (`conductor_awaits`)**: a durable pending human action for a run's human step + — addressed principal, channel, message, reminder interval, optional deadline, + fallback reference, `quorum`, status (`waiting | resolved | timed_out | cancelled`), + recorded response. +- **Principal**: the addressee of a human step — `user:` or `role:`. +- **Role**: a named seat (`key`, label, scope) addressable by a human step. +- **Role Assignment**: the binding of a role to current holder principal(s) — the baton; + provenance (`manual | resolver:`), validity window, optional delegate. +- **Role Resolver**: a registered provider that resolves a role key to current + holder(s) and availability; default is the manual-assignment-backed resolver. +- **Event Catalog Entry**: a declared connector event — id, source plugin, label, + payload JSON Schema (versioned) — autodiscovered from a connector's `emits:` block. +- **Conductor Surface**: a connector's declared interaction set with the Conductor — + its `emits:` events (triggers) plus its `provides:` actions — surfaced in the Designer. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An operator can build, save, and run a workflow that combines at least one + agentic step and at least one human step, end to end, without writing code. +- **SC-002**: A workflow run that is waiting on a human step survives a middleware + process restart and resumes correctly when the human responds — verified by an + automated restart test (no step re-executed, none skipped). +- **SC-003**: A human step with a reminder interval and a deadline sends the reminder at + the interval and fires the in-graph fallback at the deadline, in 100% of no-response + cases — verified by a clock-driven automated test for both `quorum: any` and `all`. +- **SC-004**: After installing a fixture connector that declares `emits:`, the declared + events appear in the catalog and are selectable as triggers in the Designer with their + payload fields available — with zero manual wiring. +- **SC-005**: An emit whose payload violates the declared schema starts no run and is + logged with a precise error — verified by an automated test. +- **SC-006**: Moving a role's baton from holder A to holder B causes the next reminder + to target B and transfers await access from A to B, resolved at access time — + verified by an automated test; A can no longer read the await, B can. +- **SC-007**: A role with no current holder causes the human step to take its fallback + transition rather than hang — verified by an automated test. +- **SC-008**: Editing and re-saving a workflow creates a new version while a run started + on the prior version completes unchanged — verified by an automated test. +- **SC-009**: The deterministic engine produces an identical step path for identical + inputs across repeated runs — verified by a property/fixture test in + `@omadia/conductor-core` with no I/O. +- **SC-010**: A completed run's trace contains the trigger, every step with its actor, + every postcondition outcome, every transition (including fallbacks), reminders, and + baton resolutions. + +## Assumptions + +- Conductor ships **in this repo, modular**: `@omadia/conductor-core` (pure engine) + + kernel wiring in `middleware/src/` via the existing `serviceRegistry` + a Designer + under `web-ui/app/admin/conductor/`. No separate repository; only an HR/ERP role + resolver is expected to live in a separate, swappable connector plugin. +- The existing primitives are reused as-is and extended, not replaced: the orchestrator + / sub-agent loop and its postcondition, tool-obligation, and repeat-failure guards; + the `scheduleWorker` cron scheduler (minute granularity, DB-durable, single-process); + the channel registry + proactive sender; the user store; the verifier; the Agent + Builder's canvas, builder-agent, and REST patterns. +- Connector plugins (GitHub/CI, ATS/HR, calendar, ERP, …) are separate plugin work. + Conductor defines and depends only on the *contract* (`emits:` / `provides:` / the + event catalog / `ctx.events.emit`), never on a specific connector. +- The HR/ERP role-movement *policy* (when/why a baton moves) is owned by the live + instance and its integration; Conductor provides the resolver seam, the manual + assignment store + APIs, and the events — and exposes state/data access scoped to the + current holder so any integration can drive movement. +- A human principal is reachable proactively on a channel only if a channel binding / + conversation reference for that user exists; provisioning those bindings is an + operational concern reusing existing channel mechanisms. +- The existing Postgres (Neon) instance is available for workflow, run, await, role, and + catalog storage and supports `LISTEN/NOTIFY` for run resume on human response. +- The reminder/deadline timing granularity inherits the scheduler's minute-level + resolution, which is sufficient for human-response cadences (hours/days). +- `deterministic_action`, postconditions, and the verifier already exist at tool/turn + scope; this feature promotes their use to process scope and does not redefine them. diff --git a/web-ui/app/_components/Nav.tsx b/web-ui/app/_components/Nav.tsx index d63d9a30..dff03aae 100644 --- a/web-ui/app/_components/Nav.tsx +++ b/web-ui/app/_components/Nav.tsx @@ -40,6 +40,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 1a30fea6..73682def 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -3595,6 +3595,198 @@ export async function installSelfExtensionProposal( }; } +// ───────────────────────────────────────────────────────────────────────── +// 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)}`); +} + +/** Declared emittable domain events (US4), for the Designer's event-trigger picker. */ +export async function getConductorEventCatalog(): Promise<{ events: string[]; byPlugin: Record }> { + return getJson(`${CONDUCTOR_BASE}/events/catalog`); +} + +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)}`); +} + +// Conversational builder (US7) — co-design a draft graph by chat. A turn is stateless: the +// client posts the current draft graph + the message, and gets back the patched draft, the +// applied patches, the assistant's reply, and a validation verdict. The draft stays client-side +// (parity with the visual designer), so there is no draft id — the graph IS the state. + +export interface ConductorValidationError { + code: string; + message: string; + nodeIds: string[]; +} + +export interface ConductorValidationResult { + ok: boolean; + errors: ConductorValidationError[]; +} + +export interface ConductorGraphPatch { + op: string; + [key: string]: unknown; +} + +export interface ConductorBuilderMessage { + role: 'user' | 'assistant'; + text: string; +} + +export interface ConductorBuilderTurnResult { + graph: unknown; + patches: ConductorGraphPatch[]; + reply: string; + validation: ConductorValidationResult; + applyErrors: string[]; +} + +export async function conductorBuilderTurn(body: { + graph: unknown; + message: string; + history?: ConductorBuilderMessage[]; +}): Promise { + return postJson(`${CONDUCTOR_BASE}/builder/turn`, body); +} + // ----------------------------------------------------------------------------- // In-app issue reporting — "Create Issue" button (/api/v1/issues) // diff --git a/web-ui/app/conductor/_components/ConductorCanvas.tsx b/web-ui/app/conductor/_components/ConductorCanvas.tsx new file mode 100644 index 00000000..0628da6c --- /dev/null +++ b/web-ui/app/conductor/_components/ConductorCanvas.tsx @@ -0,0 +1,782 @@ +'use client'; + +import { useCallback, useEffect, useId, 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, + getConductorEventCatalog, + 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; +} + +// A request from the parent to render a draft graph (e.g. the conversational builder's evolving +// draft, US7) directly into the canvas. The nonce changes each push so the same graph re-renders. +export interface CanvasGraphRequest { + graph: unknown; + nonce: number; +} + +function CanvasInner({ + workflows, + onSaved, + editRequest, + loadGraphRequest, +}: { + workflows: ConductorWorkflow[]; + onSaved: () => void; + editRequest: CanvasEditRequest | null; + loadGraphRequest: CanvasGraphRequest | 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(''); + // Declared emittable events (US4 / FR-028) — the Designer sources the event-trigger picker from the + // live catalog. Best-effort: an empty catalog just falls back to free-text entry. + const [eventCatalog, setEventCatalog] = useState([]); + const eventListId = useId(); // unique per canvas instance — no datalist id collision on double-mount + + useEffect(() => { + let cancelled = false; + void getConductorEventCatalog() + .then((c) => { + // Defensive: a 200 with an unexpected body would otherwise make state non-array and crash render. + if (!cancelled) setEventCatalog(Array.isArray(c?.events) ? c.events : []); + }) + // Errors degrade to the empty-catalog hint + free-text entry. (A 401 still triggers getJson's + // standard login redirect — same as every other page fetch — which is the desired behaviour.) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + // 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]); + + // Rehydrate the canvas (nodes/edges/trigger) from a serialized WorkflowGraph. Shared by the + // "Load existing workflow" path and the conversational builder (US7), which pushes its evolving + // draft graph in via `loadGraphRequest` so the chat and the canvas are two windows on one draft. + const hydrateFromGraph = useCallback((graph: unknown) => { + const g = graph as { + entryStepId: string; + steps: Array>; + transitions?: Array>; + triggers?: Array>; + }; + if (!g || !Array.isArray(g.steps)) return; + const transitionsIn = Array.isArray(g.transitions) ? g.transitions : []; + { + 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[] = transitionsIn.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 + transitionsIn.length; + const trig = g.triggers?.[0]; + if (trig) { + setTriggerKind((trig.kind as 'manual' | 'event' | 'cron') ?? 'manual'); + setTriggerEventId(String(trig.eventId ?? '')); + setTriggerCron(String(trig.cron ?? '')); + } + } + }, []); + + const loadWorkflow = useCallback( + async (wfSlug: string) => { + try { + const { workflow, graph } = await getConductorWorkflowGraph(wfSlug); + setSlug(workflow.slug); + setName(workflow.name); + hydrateFromGraph(graph); + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : String(err)); + } + }, + [hydrateFromGraph], + ); + + // 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(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- prop-nonce-triggered imperative load + if (editRequest?.slug) void loadWorkflow(editRequest.slug); + }, [editRequest, loadWorkflow]); + + // Mirror the conversational builder's draft into the canvas (US7). A new nonce each turn means a + // re-push of the same-shaped graph still re-renders, so chat edits show up live on the canvas. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- prop-nonce-triggered imperative load + if (loadGraphRequest) hydrateFromGraph(loadGraphRequest.graph); + }, [loadGraphRequest, hydrateFromGraph]); + + 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' && ( + <> + +