From 0937ad9388600d128f862c97f47474bd169b94a4 Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Fri, 21 Aug 2026 11:55:56 +1200 Subject: [PATCH 1/3] fix: make `npm run deploy` run end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent defects stopped `npm run deploy` from running against a Nano gateway: 1. Loader — the scripts ran `node --experimental-strip-types`, which cannot compile the TypeScript parameter property in `EffectClient`'s constructor (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`), so it failed to parse. Switch the scripts to `--experimental-transform-types` and rewrite the constructor to an explicit field assignment so the source is also portable to strip-only tooling. 2. Transport — the worker-start null-transport race on the Falcon path (jwulf/nano-sdk-js#12, nanobpm/nano-ide#415), now fixed upstream. Adopt `@nanobpm/workflow` ^0.13.1 + `@nanobpm/nano-sdk` 1.2.7 (floored via `overrides`, as workflow only requires ^1.2.5), and make transport selectable via `CAMUNDA_TRANSPORT` (default `auto`). Verified end-to-end on the Falcon path: deploy → 8 job workers start → instance created → jobs (classify, search-web, search-kb, synthesize) complete, with no null-transport crash. `npm run typecheck` passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 14 +++++++------- package.json | 17 ++++++++++------- src/effect/client.ts | 6 +++++- src/main.ts | 5 ++++- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32bf36c..0ba5b39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@nanobpm/workflow": "^0.13.0", + "@nanobpm/workflow": "^0.13.1", "effect": "4.0.0-beta.9" }, "devDependencies": { @@ -121,9 +121,9 @@ ] }, "node_modules/@nanobpm/nano-sdk": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@nanobpm/nano-sdk/-/nano-sdk-1.2.5.tgz", - "integrity": "sha512-su/0iM428i0/T+rAXYuijU6N+MlV2EpzSktrSwylK5YYBfubZK+0Qp4B8v5P5a/ne09Hlc5qXwpytxOoVsjH9Q==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@nanobpm/nano-sdk/-/nano-sdk-1.2.7.tgz", + "integrity": "sha512-2RDRZFDyZS189gdgmlh6L0zDEpw7r6YwB9S9F/l+Q8uE6Qj2X9JCiyIc/5RkXHjn6+461FfgXST8SzqNL4+Wow==", "license": "Apache-2.0", "dependencies": { "@camunda8/orchestration-cluster-api": "10.0.0-alpha.22", @@ -134,9 +134,9 @@ } }, "node_modules/@nanobpm/workflow": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@nanobpm/workflow/-/workflow-0.13.0.tgz", - "integrity": "sha512-1CgSQESNV/3M3p07bHr26eBCSoh9q1ptQcrK8cr9zX+VQgL50Q5IO+aDsJ25o+JlirKvYkcHP56tvGTTm1mLvw==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@nanobpm/workflow/-/workflow-0.13.1.tgz", + "integrity": "sha512-vZjI2jnuVgVgGZP5w7BNLJhqhI3Du76yX2fOF3ebO5u88rR94DTKRfv5vEQmODNGOJ8WlVQMhBd57lIQR0057w==", "license": "Apache-2.0", "dependencies": { "@nanobpm/nano-sdk": "^1.2.5" diff --git a/package.json b/package.json index d5b0671..534ec8e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "effect-agent-demo", "version": "0.1.0", "private": true, - "description": "Code-first agent orchestration in Effect — defineFlow model + Effect agent workers on Camunda 8.", + "description": "Code-first agent orchestration in Effect \u2014 defineFlow model + Effect agent workers on Camunda 8.", "type": "module", "license": "Apache-2.0", "engines": { @@ -10,12 +10,12 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test \"test/*.test.ts\"", - "model:emit": "node --experimental-strip-types scripts/emit-bpmn.ts", - "deploy": "node --experimental-strip-types src/main.ts" + "test": "node --experimental-transform-types --test \"test/*.test.ts\"", + "model:emit": "node --experimental-transform-types scripts/emit-bpmn.ts", + "deploy": "node --experimental-transform-types src/main.ts" }, "dependencies": { - "@nanobpm/workflow": "^0.13.0", + "@nanobpm/workflow": "^0.13.1", "effect": "4.0.0-beta.9" }, "devDependencies": { @@ -24,7 +24,10 @@ "typescript": "^5.6.3" }, "comments": { - "effect": "Optional at every SDK layer, but a DIRECT dependency of this demo — the demo opts into Effect. Targets Effect v4 (beta), matching the published ./effect surface (#437/#438).", - "transport": "The published transport target is @camunda8/orchestration-cluster-api/effect (S1 #437 + S2 #438). Until it publishes, the demo drives the engine through @nanobpm/workflow's WorkflowClient (Promise SDK) behind a thin Effect surface in src/effect/ — swap the internals when ./effect lands." + "effect": "Optional at every SDK layer, but a DIRECT dependency of this demo \u2014 the demo opts into Effect. Targets Effect v4 (beta), matching the published ./effect surface (#437/#438).", + "transport": "The published transport target is @camunda8/orchestration-cluster-api/effect (S1 #437 + S2 #438). Until it publishes, the demo drives the engine through @nanobpm/workflow's WorkflowClient (Promise SDK) behind a thin Effect surface in src/effect/ \u2014 swap the internals when ./effect lands." + }, + "overrides": { + "@nanobpm/nano-sdk": "^1.2.7" } } diff --git a/src/effect/client.ts b/src/effect/client.ts index 992c761..6de8bd0 100644 --- a/src/effect/client.ts +++ b/src/effect/client.ts @@ -11,7 +11,11 @@ import { PermanentAgentError } from "./errors.ts"; * Effect. Swap the internals when `./effect` lands; call sites are unchanged. */ export class EffectClient { - private constructor(private readonly client: WorkflowClient) {} + private readonly client: WorkflowClient; + + private constructor(client: WorkflowClient) { + this.client = client; + } /** The underlying nano-sdk client, so the worker runtime can serve jobs over * the same transport. */ diff --git a/src/main.ts b/src/main.ts index aac2cac..4a4eaf3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,8 @@ import { researchAgentFlow } from "./model/research-agent.ts"; * Env: * CAMUNDA_REST_ADDRESS base URL of the C8 / nanobpmn gateway (default localhost:8080) * CAMUNDA_TOKEN bearer token, if the gateway requires one + * CAMUNDA_TRANSPORT "auto" | "falcon" | "rest" (default "auto"). Against a + * Nano gateway "auto" selects the Falcon push transport. * LLM_API_KEY when set, the agents use the real `LlmLive`; otherwise * the deterministic stand-in (so the demo runs offline) * @@ -24,12 +26,13 @@ import { researchAgentFlow } from "./model/research-agent.ts"; const baseUrl = process.env.CAMUNDA_REST_ADDRESS ?? "http://localhost:8080"; const token = process.env.CAMUNDA_TOKEN; +const transport = (process.env.CAMUNDA_TRANSPORT ?? "auto") as "auto" | "falcon" | "rest"; const llmLayer: Layer.Layer = process.env.LLM_API_KEY ? LlmLive : LlmDeterministic; const program = Effect.gen(function* () { const flow = researchAgentFlow(); - const client = EffectClient.make(token ? { baseUrl, token } : { baseUrl }); + const client = EffectClient.make(token ? { baseUrl, token, transport } : { baseUrl, transport }); yield* Effect.log(`deploying 'research-agent' to ${baseUrl}`); yield* client.deploy(flow); From e57353520efc80cfbf248c3e6e17852cd05b262e Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Fri, 21 Aug 2026 12:17:08 +1200 Subject: [PATCH 2/3] feat: add Urban human front-end for the review task + Biome linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `human(review)` step parks each instance on a user task that nothing in `npm run deploy` completes, so the convergence loop never resolves. Add a thin `@nanobpm/urban` app that reuses the built-in `taskInbox` surface (ADR 0026) to act on it — no bespoke UI, no embedded engine, no duplicated workers: - `nano.app.json` — manifest enabling `surfaces.taskInbox` and declaring the form under `models.forms`. - `resources/forms/research-review.form` — form-js schema (read-only question + drafted answer, required approve/revise verdict, conditional revision notes); field keys match the `review` task I/O. - `urban-app.ts` — `runFromEnv` deploys the form and mounts the surface against the same engine `npm run deploy` targets; `npm run app` script. Verified end-to-end against a live node: the surface lists the task, renders the form, and completing it converges the loop (approve → publish → archive; revise → record-revision → re-synthesize → fresh review). Also add Biome for linting + formatting and make the project compliant: - `biome.json` (recommended lint, 2-space/120 formatter), `lint`/`lint:fix` scripts, and a CI lint step. - Format the existing sources to comply; fix an unused parameter. - Correct the stale `--experimental-strip-types` prereq note (scripts use `--experimental-transform-types`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + README.md | 33 ++- biome.json | 29 +++ nano.app.json | 13 + package-lock.json | 342 ++++++++++++++++++++++++++- package.json | 7 +- resources/forms/research-review.form | 40 ++++ scripts/emit-bpmn.ts | 2 +- src/agents/index.ts | 6 +- src/agents/search.ts | 28 ++- src/agents/util.ts | 8 +- src/effect/Llm.ts | 4 +- src/effect/client.ts | 11 +- src/effect/worker.ts | 42 +++- src/main.ts | 12 +- src/model/research-agent.ts | 2 +- test/agents.test.ts | 13 +- test/model.test.ts | 4 +- test/worker.test.ts | 34 ++- tsconfig.json | 2 +- urban-app.ts | 39 +++ 21 files changed, 601 insertions(+), 72 deletions(-) create mode 100644 biome.json create mode 100644 nano.app.json create mode 100644 resources/forms/research-review.form create mode 100644 urban-app.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 202e77a..36e089e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci + - name: Lint (biome) + run: npm run lint - name: Typecheck run: npm run typecheck - name: Test (TestClock-deterministic) diff --git a/README.md b/README.md index 706e493..ac68edd 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Agent workflows authored code-first with [`@nanobpm/workflow`](https://www.npmjs | **Transport / client** | Effect surface over the C8 SDK ([S1 #437](https://github.com/camunda/orchestration-cluster-api-js/issues/437)) | [`src/effect/client.ts`](src/effect/client.ts) | | **Agent runtime** | Effect job workers — `activate → handle → complete/fail` ([S2 #438](https://github.com/camunda/orchestration-cluster-api-js/issues/438)) | [`src/effect/worker.ts`](src/effect/worker.ts) | | **Agents** | one `Effect` program per capability | [`src/agents/`](src/agents/) | +| **Human front-end** | an [`@nanobpm/urban`](https://www.npmjs.com/package/@nanobpm/urban) app reusing the built-in `taskInbox` surface to act on the `review` task | [`urban-app.ts`](urban-app.ts), [`nano.app.json`](nano.app.json) | Effect is an **optional** layer at every SDK level — the demo opts in; it is never forced on the Promise-based Camunda 8 SDK. It targets **Effect v4** (beta), pinned deliberately. @@ -75,11 +76,12 @@ exact outcomes — no real waiting, no flakiness. See [`test/worker.test.ts`](te ## Run it -Prereqs: **Node ≥ 22.6** (uses `--experimental-strip-types` to run TypeScript directly). +Prereqs: **Node ≥ 22.6** (uses `--experimental-transform-types` to run TypeScript directly). ```sh npm install npm run typecheck # tsc --noEmit +npm run lint # biome check (lint + format); `npm run lint:fix` to apply npm test # node --test, all deterministic (no engine, no network) ``` @@ -95,6 +97,35 @@ npm run deploy `npm run deploy` deploys the model, leases the eight agent workers, starts one instance, and serves until interrupted. +## The human front-end — acting on the `review` task + +The `human(review)` step parks each instance on a user task until a reviewer approves the draft or +asks for a revision — nothing in `npm run deploy` completes it. Rather than build a bespoke reviewer +UI, the demo mounts [`@nanobpm/urban`](https://www.npmjs.com/package/@nanobpm/urban)'s +batteries-included **`taskInbox` surface** (ADR 0026): it lists the open `review` tasks, renders their +linked form, and completes them. + +- [`nano.app.json`](nano.app.json) — the Urban app manifest: enables `surfaces.taskInbox` and declares + the form under `models.forms`. +- [`resources/forms/research-review.form`](resources/forms/research-review.form) — the form-js schema: + read-only `question` + drafted `finalAnswer`, a required `verdict` (approve / revise), and + conditional `revisionNotes`. Its field keys match the `review` task's I/O. +- [`urban-app.ts`](urban-app.ts) — a thin entrypoint: `runFromEnv` deploys the form and mounts the + surface against the **same** engine `npm run deploy` targets (no embedded engine, no duplicated + workers). + +Run it alongside the deploy process: + +```sh +npm run deploy # terminal 1 — deploys the flow, serves agents, starts an instance +npm run app # terminal 2 — deploys the form, serves the task inbox (default :8090/tasks) +``` + +Open [`http://localhost:8090/tasks`](http://localhost:8090/tasks), complete the `review` task, and the +loop converges: **approve** → `publish` → `archive`; **revise** → `record-revision` → re-`synthesize` +→ a fresh `review`. The app also embeds in the Nano console at `/console/app-view/research-agent/tasks` +(ADR 0057). + ## License Apache-2.0 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..92a1ce4 --- /dev/null +++ b/biome.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "src/**/*.ts", + "scripts/**/*.ts", + "test/**/*.ts", + "urban-app.ts", + "package.json", + "nano.app.json", + "biome.json", + "!bpmn", + "!resources", + "!node_modules" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { "preset": "recommended" } + }, + "assist": { "enabled": true } +} diff --git a/nano.app.json b/nano.app.json new file mode 100644 index 0000000..8d0d3fe --- /dev/null +++ b/nano.app.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://nanobpm.io/spec-app/nano-app.schema.json", + "schemaVersion": 1, + "id": "research-agent", + "name": "Research Agent", + "codename": "Urban", + "surfaces": { + "taskInbox": { "enabled": true, "path": "/tasks" } + }, + "models": { + "forms": ["resources/forms/*.form"] + } +} diff --git a/package-lock.json b/package-lock.json index 0ba5b39..cbf225c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@nanobpm/urban": "^0.73.0", "@nanobpm/workflow": "^0.13.1", "effect": "4.0.0-beta.9" }, "devDependencies": { + "@biomejs/biome": "2.5.7", "@types/node": "^22.20.1", "bpmn-auto-layout": "^2.0.0-alpha.2", "typescript": "^5.6.3" @@ -21,6 +23,181 @@ "node": ">=22.6" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz", + "integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.7", + "@biomejs/cli-darwin-x64": "2.5.7", + "@biomejs/cli-linux-arm64": "2.5.7", + "@biomejs/cli-linux-arm64-musl": "2.5.7", + "@biomejs/cli-linux-x64": "2.5.7", + "@biomejs/cli-linux-x64-musl": "2.5.7", + "@biomejs/cli-win32-arm64": "2.5.7", + "@biomejs/cli-win32-x64": "2.5.7" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz", + "integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz", + "integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz", + "integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz", + "integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz", + "integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz", + "integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz", + "integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz", + "integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@camunda8/orchestration-cluster-api": { "version": "10.0.0-alpha.22", "resolved": "https://registry.npmjs.org/@camunda8/orchestration-cluster-api/-/orchestration-cluster-api-10.0.0-alpha.22.tgz", @@ -120,6 +297,70 @@ "win32" ] }, + "node_modules/@nanobpm/nano-app-schema": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@nanobpm/nano-app-schema/-/nano-app-schema-0.9.0.tgz", + "integrity": "sha512-rr+0GHH+Mu1f24Dg+8B4YhFAIWJMqvldmLESDZml9kyKeNbywfox3tLnbjkwyTloc2VsMD7FutCRcpWuyY52EA==", + "dependencies": { + "bpmn-moddle": "^9.0.4", + "dmn-moddle": "^11.0.0", + "zeebe-bpmn-moddle": "^1.17.0" + } + }, + "node_modules/@nanobpm/nano-app-schema/node_modules/bpmn-moddle": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-9.0.4.tgz", + "integrity": "sha512-dr5s3vtOG8NkVSwa8CC55XBIKKwajomSZRb0RiMOOOF6TpqZBZvtbDjpzWICvdd/plDF6uOtaRfSgblPQLAioQ==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.2.1", + "moddle": "^7.0.0", + "moddle-xml": "^11.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@nanobpm/nano-app-schema/node_modules/min-dash": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.3.tgz", + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==", + "license": "MIT" + }, + "node_modules/@nanobpm/nano-app-schema/node_modules/moddle": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/moddle/-/moddle-7.2.0.tgz", + "integrity": "sha512-x1+JREThy7JBOBR3g2hbOnOfrlC/YAWXX9RzrSZS5HhqeuBly9H/PCtOBtcQs+Y2sjRAXF+WTNSgHvn8Uq+6Yw==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.2.1" + } + }, + "node_modules/@nanobpm/nano-app-schema/node_modules/moddle-xml": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-11.0.0.tgz", + "integrity": "sha512-L3Sseepfcq9Uy0iIfqEDTXSoYLva1Y/JGbN/4AMOeQ6cqbu8Ma/SDJIdOFm7smsAa64j2z3SwCGG3FIilQVnUg==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0", + "saxen": "^10.0.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "moddle": ">= 6.2.0" + } + }, + "node_modules/@nanobpm/nano-app-schema/node_modules/saxen": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-10.0.0.tgz", + "integrity": "sha512-RXsmWok/SAWqOG/f5ADEz51DN9WtZEzqih3e08ranldcaXekxjx8NBKjGh/y5hlowjo0JH/LekBu6gtPFD1G6g==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@nanobpm/nano-sdk": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@nanobpm/nano-sdk/-/nano-sdk-1.2.7.tgz", @@ -133,6 +374,26 @@ "node": ">=18" } }, + "node_modules/@nanobpm/urban": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@nanobpm/urban/-/urban-0.73.0.tgz", + "integrity": "sha512-XguOgWYWhYSDSVDhYnOxQD1gNj9Mzftn2ryMZejM+D2Sf9YLutLsYVGwBpD+1Lba9cUEh8Ln2lYDr8Cw5vLKfQ==", + "license": "Apache-2.0", + "dependencies": { + "@nanobpm/nano-app-schema": "^0.9.0", + "@nanobpm/nano-sdk": "^1.2.5", + "@nanobpm/workflow": "^0.13.1", + "bpmn-auto-layout": "^2.0.0-alpha.2", + "create-urban-app": "^0.15.0", + "yaml": "^2.9.0" + }, + "bin": { + "urban": "src/bin.mjs" + }, + "engines": { + "node": ">=22.6" + } + }, "node_modules/@nanobpm/workflow": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@nanobpm/workflow/-/workflow-0.13.1.tgz", @@ -173,7 +434,6 @@ "version": "2.0.0-alpha.2", "resolved": "https://registry.npmjs.org/bpmn-auto-layout/-/bpmn-auto-layout-2.0.0-alpha.2.tgz", "integrity": "sha512-Xg8BoRMijfECgWljIlE44hbGDAuF0BkjJuLfd5o3oOx1ileL06EJQhlgO040xFIegfjxq91dVFAoxCL17wo53A==", - "devOptional": true, "license": "MIT", "workspaces": [ "example" @@ -193,7 +453,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.1.0.tgz", "integrity": "sha512-k9Vl97hiFwlr4bJTQ5+pZp21bmrQwil5jnMQ30wF8vney2/WnIcJmWwYzRTAKFVoViTGowtDfbO5rGEF4viO0w==", - "devOptional": true, "license": "MIT", "dependencies": { "min-dash": "^5.1.0", @@ -204,6 +463,18 @@ "node": ">= 20.12" } }, + "node_modules/create-urban-app": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/create-urban-app/-/create-urban-app-0.15.0.tgz", + "integrity": "sha512-C626tVh2Up49euo6AuTCRfduK/WFMR5HGfdkNuZt8gd5DleH25Vkcooq7V21hzFnVtj2cmJxGxfvTBqs2060DQ==", + "license": "Apache-2.0", + "bin": { + "create-urban-app": "src/bin.mjs" + }, + "engines": { + "node": ">=22.6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -214,6 +485,60 @@ "node": ">=8" } }, + "node_modules/dmn-moddle": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/dmn-moddle/-/dmn-moddle-11.0.0.tgz", + "integrity": "sha512-gQfiBZx6rQNQqCN21csZWF/Ti2XbjHt8uON76cxk8qsgHFvrsvO18UGPzKQp34wJtJyuj/nREQIbVW1hmQf1LQ==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0", + "moddle": "^7.0.0", + "moddle-xml": "^11.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/dmn-moddle/node_modules/min-dash": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.3.tgz", + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==", + "license": "MIT" + }, + "node_modules/dmn-moddle/node_modules/moddle": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/moddle/-/moddle-7.2.0.tgz", + "integrity": "sha512-x1+JREThy7JBOBR3g2hbOnOfrlC/YAWXX9RzrSZS5HhqeuBly9H/PCtOBtcQs+Y2sjRAXF+WTNSgHvn8Uq+6Yw==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.2.1" + } + }, + "node_modules/dmn-moddle/node_modules/moddle-xml": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-11.0.0.tgz", + "integrity": "sha512-L3Sseepfcq9Uy0iIfqEDTXSoYLva1Y/JGbN/4AMOeQ6cqbu8Ma/SDJIdOFm7smsAa64j2z3SwCGG3FIilQVnUg==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0", + "saxen": "^10.0.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "moddle": ">= 6.2.0" + } + }, + "node_modules/dmn-moddle/node_modules/saxen": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-10.0.0.tgz", + "integrity": "sha512-RXsmWok/SAWqOG/f5ADEz51DN9WtZEzqih3e08ranldcaXekxjx8NBKjGh/y5hlowjo0JH/LekBu6gtPFD1G6g==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/effect": { "version": "4.0.0-beta.9", "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.9.tgz", @@ -279,14 +604,12 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.1.0.tgz", "integrity": "sha512-HAvN6XzmCQj+js43G9sJRRw8kj/7DvoxN7qhoIN9Xo5ZN1NMljtpyKs/NunXmw0QSWwlgZzNv6tLeEa4xaZ0tg==", - "devOptional": true, "license": "MIT" }, "node_modules/moddle": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/moddle/-/moddle-8.2.1.tgz", "integrity": "sha512-20qzO/ZpMGO9j9gdQgXk9bfGxb9wA3JKnGth/21YZKODLrZE4RW/uVMIL5pTskYm34Z61DucBEPkq93qByNsBQ==", - "devOptional": true, "license": "MIT", "dependencies": { "min-dash": "^5.1.0" @@ -296,7 +619,6 @@ "version": "12.1.0", "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-12.1.0.tgz", "integrity": "sha512-+m50j9/MFGhvWbLJoKSSjQj3uv7SYm2w5i2lfv6goWarlWW1Nd0A31NYUXmKuWnEZ8Mm/bH1RZYtuf4x8ptY7g==", - "devOptional": true, "license": "MIT", "dependencies": { "min-dash": "^5.1.0", @@ -381,7 +703,6 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.1.tgz", "integrity": "sha512-J4BkmJFaM7VgE7pgkFGsNEcqqM3h7+Mz80vfLWFhx7uNOCOXIu6LLjQHYWNejdst3pf/3JUaBIG9+pkk1umlow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.12" @@ -472,6 +793,15 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/zeebe-bpmn-moddle": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/zeebe-bpmn-moddle/-/zeebe-bpmn-moddle-1.18.0.tgz", + "integrity": "sha512-tFlAzeADwrkszJzz17Izh12p/nzpufaNDRXVEoWns7XK8ZcA+C0WzC1+mpKFTlwg9r70BS5+At/wo0HFwd6kjg==", + "license": "MIT", + "peerDependencies": { + "moddle": ">=8.2.0" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 534ec8e..2c71bdb 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,20 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "biome check", + "lint:fix": "biome check --write", "test": "node --experimental-transform-types --test \"test/*.test.ts\"", "model:emit": "node --experimental-transform-types scripts/emit-bpmn.ts", - "deploy": "node --experimental-transform-types src/main.ts" + "deploy": "node --experimental-transform-types src/main.ts", + "app": "node --experimental-transform-types urban-app.ts" }, "dependencies": { + "@nanobpm/urban": "^0.73.0", "@nanobpm/workflow": "^0.13.1", "effect": "4.0.0-beta.9" }, "devDependencies": { + "@biomejs/biome": "2.5.7", "@types/node": "^22.20.1", "bpmn-auto-layout": "^2.0.0-alpha.2", "typescript": "^5.6.3" diff --git a/resources/forms/research-review.form b/resources/forms/research-review.form new file mode 100644 index 0000000..8e785af --- /dev/null +++ b/resources/forms/research-review.form @@ -0,0 +1,40 @@ +{ + "components": [ + { + "type": "text", + "text": "# Research review\n\nRevision round **{{round}}**. Review the drafted answer below, then approve it for publication or request a revision." + }, + { + "label": "Question", + "type": "textfield", + "key": "question", + "disabled": true + }, + { + "label": "Drafted answer", + "type": "textarea", + "key": "finalAnswer", + "disabled": true + }, + { + "label": "Verdict", + "type": "radio", + "key": "verdict", + "validate": { "required": true }, + "values": [ + { "label": "Approve & publish", "value": "approve" }, + { "label": "Request a revision", "value": "revise" } + ] + }, + { + "label": "Revision notes", + "type": "textarea", + "key": "revisionNotes", + "description": "What should the next draft change?", + "conditional": { "hide": "=verdict != \"revise\"" } + } + ], + "type": "default", + "id": "research-review", + "schemaVersion": 16 +} diff --git a/scripts/emit-bpmn.ts b/scripts/emit-bpmn.ts index 08d0652..bfe4e8b 100644 --- a/scripts/emit-bpmn.ts +++ b/scripts/emit-bpmn.ts @@ -1,4 +1,4 @@ -import { writeFile, mkdir } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { toDeployableBpmn } from "@nanobpm/workflow"; diff --git a/src/agents/index.ts b/src/agents/index.ts index 972fc92..c507be3 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,11 +1,11 @@ -import { JobTypes } from "../model/research-agent.ts"; import type { Llm } from "../effect/Llm.ts"; import type { AgentSpec } from "../effect/worker.ts"; +import { JobTypes } from "../model/research-agent.ts"; import { classify } from "./classify.ts"; +import { archive, nudge, recordRevision } from "./misc.ts"; +import { publish } from "./publish.ts"; import { searchKb, searchWeb } from "./search.ts"; import { synthesize } from "./synthesize.ts"; -import { publish } from "./publish.ts"; -import { archive, nudge, recordRevision } from "./misc.ts"; /** * The agent registry — every derived job type in the model wired to the Effect diff --git a/src/agents/search.ts b/src/agents/search.ts index 1b879bc..05df14a 100644 --- a/src/agents/search.ts +++ b/src/agents/search.ts @@ -9,21 +9,23 @@ import { requireString } from "./util.ts"; * `topic` and reports a `sourceCount`. They are the same program parameterised by * a source label — a small demonstration that an Effect agent is just a value. */ -const makeSearch = (source: "web" | "kb"): AgentHandler => (job) => - Effect.gen(function* () { - const topic = yield* requireString(`search-${source}`, job.variables, "topic"); - const llm = yield* Llm; +const makeSearch = + (source: "web" | "kb"): AgentHandler => + (job) => + Effect.gen(function* () { + const topic = yield* requireString(`search-${source}`, job.variables, "topic"); + const llm = yield* Llm; - const findings = yield* llm.complete({ - agent: `search-${source}`, - prompt: `Search the ${source === "web" ? "public web" : "internal knowledge base"} for material on: ${topic}. Summarise the key findings in 3 bullet points.`, - }); + const findings = yield* llm.complete({ + agent: `search-${source}`, + prompt: `Search the ${source === "web" ? "public web" : "internal knowledge base"} for material on: ${topic}. Summarise the key findings in 3 bullet points.`, + }); - return { - findings, - sourceCount: countBullets(findings), - }; - }); + return { + findings, + sourceCount: countBullets(findings), + }; + }); const countBullets = (text: string): number => { const matches = text.match(/^\s*[-*•]/gm); diff --git a/src/agents/util.ts b/src/agents/util.ts index 9ba51fd..b5828c4 100644 --- a/src/agents/util.ts +++ b/src/agents/util.ts @@ -1,10 +1,14 @@ -import { Effect } from "effect"; import type { JsonObject } from "@nanobpm/workflow"; +import { Effect } from "effect"; import { PermanentAgentError } from "../effect/errors.ts"; /** Read a required string job variable, failing PERMANENTLY when it is missing * or the wrong type — bad input is not something a retry can fix. */ -export const requireString = (agent: string, vars: JsonObject, key: string): Effect.Effect => { +export const requireString = ( + agent: string, + vars: JsonObject, + key: string, +): Effect.Effect => { const value = vars[key]; return typeof value === "string" && value.length > 0 ? Effect.succeed(value) diff --git a/src/effect/Llm.ts b/src/effect/Llm.ts index b5dc246..8224796 100644 --- a/src/effect/Llm.ts +++ b/src/effect/Llm.ts @@ -56,9 +56,7 @@ interface LlmConfig { } const llmConfig: Config.Config = Config.all({ - endpoint: Config.string("LLM_ENDPOINT").pipe( - Config.withDefault(() => "https://api.openai.com/v1/chat/completions"), - ), + endpoint: Config.string("LLM_ENDPOINT").pipe(Config.withDefault(() => "https://api.openai.com/v1/chat/completions")), apiKey: Config.redacted("LLM_API_KEY"), model: Config.string("LLM_MODEL").pipe(Config.withDefault(() => "gpt-4o-mini")), timeout: Config.duration("LLM_TIMEOUT").pipe(Config.withDefault(() => Duration.seconds(30))), diff --git a/src/effect/client.ts b/src/effect/client.ts index 6de8bd0..c9efff7 100644 --- a/src/effect/client.ts +++ b/src/effect/client.ts @@ -1,6 +1,13 @@ -import { Effect } from "effect"; +import type { + DeclarativeFlow, + DeployResult, + JsonObject, + StartResult, + Workflow, + WorkflowClientOptions, +} from "@nanobpm/workflow"; import { WorkflowClient } from "@nanobpm/workflow"; -import type { DeclarativeFlow, DeployResult, JsonObject, StartResult, Workflow, WorkflowClientOptions } from "@nanobpm/workflow"; +import { Effect } from "effect"; import { PermanentAgentError } from "./errors.ts"; /** diff --git a/src/effect/worker.ts b/src/effect/worker.ts index b85af75..6d157c4 100644 --- a/src/effect/worker.ts +++ b/src/effect/worker.ts @@ -1,8 +1,8 @@ -import { Duration, Effect, ManagedRuntime, Schedule } from "effect"; -import type { Layer, Scope } from "effect"; import type { ActivatedJob, JsonObject, NanoSdkClient } from "@nanobpm/workflow"; -import { isTransient } from "./errors.ts"; +import type { Layer, Scope } from "effect"; +import { Duration, Effect, ManagedRuntime, Schedule } from "effect"; import type { AgentError } from "./errors.ts"; +import { isTransient } from "./errors.ts"; /** * The Effect job-worker surface (S2). The published target is @@ -47,7 +47,13 @@ export interface JobActions { /** What became of a single job — surfaced to tests and observers. */ export type JobOutcome = | { readonly _tag: "completed"; readonly jobType: string; readonly jobKey: string; readonly variables: JsonObject } - | { readonly _tag: "failed"; readonly jobType: string; readonly jobKey: string; readonly reason: string; readonly retries: number }; + | { + readonly _tag: "failed"; + readonly jobType: string; + readonly jobKey: string; + readonly reason: string; + readonly retries: number; + }; /** * The deterministic retry policy for an agent: an exponential backoff, capped at @@ -74,12 +80,18 @@ export const handleJob = ( Effect.retry({ schedule: retrySchedule(spec), while: isTransient }), Effect.matchEffect({ onSuccess: (variables) => - actions.complete(variables).pipe( - Effect.as({ _tag: "completed", jobType: spec.jobType, jobKey: job.jobKey, variables }), - ), + actions + .complete(variables) + .pipe(Effect.as({ _tag: "completed", jobType: spec.jobType, jobKey: job.jobKey, variables })), onFailure: (error: AgentError) => actions.fail(error.reason, 0).pipe( - Effect.as({ _tag: "failed", jobType: spec.jobType, jobKey: job.jobKey, reason: error.reason, retries: 0 }), + Effect.as({ + _tag: "failed", + jobType: spec.jobType, + jobKey: job.jobKey, + reason: error.reason, + retries: 0, + }), ), }), ); @@ -137,7 +149,11 @@ export const serveAgents = ( }; try { const outcome = await runtime.runPromise( - handleJob(spec, { jobKey: job.jobKey, type: job.type, variables: job.variables }, actionsForActivatedJob(job)), + handleJob( + spec, + { jobKey: job.jobKey, type: job.type, variables: job.variables }, + actionsForActivatedJob(job), + ), ); notify(outcome); } catch (cause) { @@ -151,7 +167,13 @@ export const serveAgents = ( // `errorMessage` the engine records. const errorMessage = `worker defect (${spec.jobType}): ${detail}`; await job.fail({ errorMessage, retries: 0 }).catch(() => {}); - notify({ _tag: "failed", jobType: spec.jobType, jobKey: job.jobKey, reason: errorMessage, retries: 0 }); + notify({ + _tag: "failed", + jobType: spec.jobType, + jobKey: job.jobKey, + reason: errorMessage, + retries: 0, + }); } }, }); diff --git a/src/main.ts b/src/main.ts index 4a4eaf3..126d22f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ -import { Config, Effect, Layer } from "effect"; +import { type Config, Effect, type Layer } from "effect"; +import { agentSpecs } from "./agents/index.ts"; import { EffectClient } from "./effect/client.ts"; -import { LlmDeterministic, LlmLive } from "./effect/Llm.ts"; import type { Llm } from "./effect/Llm.ts"; +import { LlmDeterministic, LlmLive } from "./effect/Llm.ts"; import { serveAgents } from "./effect/worker.ts"; -import { agentSpecs } from "./agents/index.ts"; import { researchAgentFlow } from "./model/research-agent.ts"; /** @@ -37,10 +37,10 @@ const program = Effect.gen(function* () { yield* Effect.log(`deploying 'research-agent' to ${baseUrl}`); yield* client.deploy(flow); - yield* serveAgents(client.sdk, llmLayer, agentSpecs, (o) => - Effect.runSync(Effect.log(`job ${o.jobType} ${o._tag}`)), + yield* serveAgents(client.sdk, llmLayer, agentSpecs, (o) => Effect.runSync(Effect.log(`job ${o.jobType} ${o._tag}`))); + yield* Effect.log( + `serving ${agentSpecs.length} agents (${process.env.LLM_API_KEY ? "LlmLive" : "LlmDeterministic"})`, ); - yield* Effect.log(`serving ${agentSpecs.length} agents (${process.env.LLM_API_KEY ? "LlmLive" : "LlmDeterministic"})`); const started = yield* client.start(flow, { question: process.env.QUESTION ?? "How does Effect's TestClock make agent orchestration deterministic?", diff --git a/src/model/research-agent.ts b/src/model/research-agent.ts index c0ab207..dff7663 100644 --- a/src/model/research-agent.ts +++ b/src/model/research-agent.ts @@ -1,5 +1,5 @@ -import { defineFlow, envelope } from "@nanobpm/workflow"; import type { DeclarativeFlow } from "@nanobpm/workflow"; +import { defineFlow, envelope } from "@nanobpm/workflow"; /** * The agent-orchestration model — authored code-first with `@nanobpm/workflow` diff --git a/test/agents.test.ts b/test/agents.test.ts index 2bf3825..b03fe27 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -1,13 +1,12 @@ -import { test } from "node:test"; import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { JsonObject } from "@nanobpm/workflow"; import { Duration, Effect, Fiber, Layer } from "effect"; import { TestClock } from "effect/testing"; -import type { JsonObject } from "@nanobpm/workflow"; -import { Llm } from "../src/effect/Llm.ts"; -import { LlmDeterministic } from "../src/effect/Llm.ts"; -import { TransientAgentError } from "../src/effect/errors.ts"; import { classify } from "../src/agents/classify.ts"; import { synthesize } from "../src/agents/synthesize.ts"; +import { TransientAgentError } from "../src/effect/errors.ts"; +import { Llm, LlmDeterministic } from "../src/effect/Llm.ts"; import type { EffectJob } from "../src/effect/worker.ts"; const jobOf = (variables: JsonObject): EffectJob => ({ jobKey: "j", type: "t", variables }); @@ -31,9 +30,7 @@ test("classify fails permanently on missing input (no LLM call)", async () => { }); test("synthesize carries the convergence-loop round forward", async () => { - const out = await runDeterministic( - synthesize(jobOf({ webFindings: "- a", kbFindings: "- b", round: 2 })), - ); + const out = await runDeterministic(synthesize(jobOf({ webFindings: "- a", kbFindings: "- b", round: 2 }))); assert.equal(out.round, 2); assert.equal(typeof out.finalAnswer, "string"); }); diff --git a/test/model.test.ts b/test/model.test.ts index bea0b93..5e7aa08 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -1,6 +1,6 @@ -import { test } from "node:test"; import assert from "node:assert/strict"; -import { toBpmn, externalJobTypes } from "@nanobpm/workflow"; +import { test } from "node:test"; +import { externalJobTypes, toBpmn } from "@nanobpm/workflow"; import { JobTypes, researchAgentFlow } from "../src/model/research-agent.ts"; /** diff --git a/test/worker.test.ts b/test/worker.test.ts index 305b3e9..e9960ce 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,11 +1,11 @@ -import { test } from "node:test"; import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { JsonObject } from "@nanobpm/workflow"; import { Duration, Effect, Fiber, Ref } from "effect"; import { TestClock } from "effect/testing"; -import type { JsonObject } from "@nanobpm/workflow"; -import { handleJob } from "../src/effect/worker.ts"; -import type { AgentHandler, EffectJob, JobActions, JobOutcome } from "../src/effect/worker.ts"; import { PermanentAgentError, TransientAgentError } from "../src/effect/errors.ts"; +import type { AgentHandler, EffectJob, JobActions, JobOutcome } from "../src/effect/worker.ts"; +import { handleJob } from "../src/effect/worker.ts"; /** * The Effect job-worker core (the S2 `handle → complete/fail` step) under @@ -39,12 +39,14 @@ const runVirtual = (effect: Effect.Effect, advance = Duration.seconds(60)) ).pipe(Effect.provide(TestClock.layer())), ); -const flaky = (attempts: Ref.Ref, failFor: number): AgentHandler => (j) => - Effect.gen(function* () { - const n = yield* Ref.updateAndGet(attempts, (x) => x + 1); - if (n <= failFor) return yield* Effect.fail(new TransientAgentError({ agent: "flaky", reason: `blip ${n}` })); - return { ok: true, attempt: n } satisfies JsonObject; - }); +const flaky = + (attempts: Ref.Ref, failFor: number): AgentHandler => + (_job) => + Effect.gen(function* () { + const n = yield* Ref.updateAndGet(attempts, (x) => x + 1); + if (n <= failFor) return yield* Effect.fail(new TransientAgentError({ agent: "flaky", reason: `blip ${n}` })); + return { ok: true, attempt: n } satisfies JsonObject; + }); test("a succeeding agent completes the job with its variables", async () => { const { completes, fails, actions } = recorder(); @@ -62,7 +64,11 @@ test("a transient failure is retried on the backoff, then completes — determin const attempts = yield* Ref.make(0); const rec = recorder(); const outcome = yield* Effect.forkChild( - handleJob({ jobType: "agent:test", handler: flaky(attempts, 2), baseBackoff: Duration.millis(200) }, job, rec.actions), + handleJob( + { jobType: "agent:test", handler: flaky(attempts, 2), baseBackoff: Duration.millis(200) }, + job, + rec.actions, + ), ); yield* TestClock.adjust(Duration.seconds(5)); const result = yield* Fiber.join(outcome); @@ -114,7 +120,11 @@ test("retry backoff exhaustion raises an incident (retries: 0)", async () => { return yield* Effect.fail(new TransientAgentError({ agent: "down", reason: `still down ${n}` })); }); const fiber = yield* Effect.forkChild( - handleJob({ jobType: "agent:test", handler, maxRetries: 2, baseBackoff: Duration.millis(100) }, job, rec.actions), + handleJob( + { jobType: "agent:test", handler, maxRetries: 2, baseBackoff: Duration.millis(100) }, + job, + rec.actions, + ), ); yield* TestClock.adjust(Duration.seconds(30)); const outcome = (yield* Fiber.join(fiber)) as JobOutcome; diff --git a/tsconfig.json b/tsconfig.json index 1bd7e2e..15d7c95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "urban-app.ts"] } diff --git a/urban-app.ts b/urban-app.ts new file mode 100644 index 0000000..4dfb648 --- /dev/null +++ b/urban-app.ts @@ -0,0 +1,39 @@ +// Urban human-surface app for the research-agent demo. +// +// node --experimental-transform-types urban-app.ts +// +// Mounts Urban's batteries-included `taskInbox` surface (ADR 0026) against the SAME +// Nano engine the Effect runtime deploys to, and deploys the `research-review` form +// (declared in nano.app.json `models.forms`) so the review user task renders. The +// Effect runtime (`npm run deploy`) still owns flow deployment, agent-job serving and +// instance start; this process is purely the human front-end that lets a reviewer +// approve / request a revision, converging the review loop. +// +// It embeds in the Nano console at /console/app-view/research-agent/tasks (ADR 0057). +// +// Env: +// CAMUNDA_REST_ADDRESS base URL of the engine (default http://localhost:8080). A +// `/v2` suffix is appended if absent — the SDK engine client +// takes the versioned base, unlike WorkflowClient. +// CAMUNDA_TOKEN bearer token, if the gateway requires one +// PORT HTTP port for the surface (default 8090) +import { runFromEnv, selectHost } from "@nanobpm/urban"; + +const raw = process.env.CAMUNDA_REST_ADDRESS ?? "http://localhost:8080"; +const restAddress = /\/v2\/?$/.test(raw) ? raw : `${raw.replace(/\/+$/, "")}/v2`; + +// Thin human surface: no app-local DB, no in-process workers/triggers/instance +// tracking. Only the form deploy (`models.forms`) and the taskInbox surface mount. +const app = await runFromEnv({ + host: selectHost(), + restAddress, + root: import.meta.dirname ?? ".", + mount: { data: false, workers: false, triggers: false, instanceTracking: false }, +}); + +const info = app.inspect(); +app.log.info("research-agent human surface started", { + httpPort: info.httpPort ?? null, + tasks: info.httpPort ? `http://localhost:${info.httpPort}/tasks` : "/tasks", + engine: restAddress, +}); From 69751f8d2f71f5228823c70ef6dedb0740b59040 Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Fri, 21 Aug 2026 13:22:06 +1200 Subject: [PATCH 3/3] fix: validate CAMUNDA_TRANSPORT and pin nano-sdk override - Validate CAMUNDA_TRANSPORT against the supported set (auto|falcon|rest) and fail fast with a clear error instead of coercing via a type assertion. - Pin the @nanobpm/nano-sdk override to exact 1.2.7 so a fresh install cannot drift onto an untested minor and reintroduce the transport race. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf --- package.json | 2 +- src/main.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2c71bdb..4a6788a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,6 @@ "transport": "The published transport target is @camunda8/orchestration-cluster-api/effect (S1 #437 + S2 #438). Until it publishes, the demo drives the engine through @nanobpm/workflow's WorkflowClient (Promise SDK) behind a thin Effect surface in src/effect/ \u2014 swap the internals when ./effect lands." }, "overrides": { - "@nanobpm/nano-sdk": "^1.2.7" + "@nanobpm/nano-sdk": "1.2.7" } } diff --git a/src/main.ts b/src/main.ts index 126d22f..d99f071 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,16 @@ import { researchAgentFlow } from "./model/research-agent.ts"; const baseUrl = process.env.CAMUNDA_REST_ADDRESS ?? "http://localhost:8080"; const token = process.env.CAMUNDA_TOKEN; -const transport = (process.env.CAMUNDA_TRANSPORT ?? "auto") as "auto" | "falcon" | "rest"; +const TRANSPORTS = ["auto", "falcon", "rest"] as const; +type Transport = (typeof TRANSPORTS)[number]; + +const rawTransport = process.env.CAMUNDA_TRANSPORT ?? "auto"; +if (!TRANSPORTS.includes(rawTransport as Transport)) { + throw new Error( + `Invalid CAMUNDA_TRANSPORT "${rawTransport}"; expected one of ${TRANSPORTS.map((t) => `"${t}"`).join(", ")}.`, + ); +} +const transport: Transport = rawTransport as Transport; const llmLayer: Layer.Layer = process.env.LLM_API_KEY ? LlmLive : LlmDeterministic;