From 6c054ffb5ade98b8a584b7399f1c3922e1917e0a Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 09:56:06 +0000 Subject: [PATCH 01/18] feat: add headless Slack workflow runner via Cursor ACP Introduces a runner module that enables headless workflow execution driven by Slack, using Cursor's Agent Client Protocol (ACP) for the agent runtime. Each workflow run gets an isolated git worktree and its own agent process, allowing parallel execution. Components: - ACP client: JSON-RPC 2.0 over stdio to `agent acp` - Slack bot: Bolt SDK with Socket Mode, /workflow slash command - Checkpoint bridge: cursor/ask_question <-> Slack interactive messages - Session manager: lifecycle tracking, status streaming to threads - Worktree manager: git worktree creation/cleanup, MCP + permission config Made-with: Cursor --- .env.example | 16 + package-lock.json | 624 ++++++++++++++++++++++++- package.json | 6 +- src/runner/acp-client.ts | 318 +++++++++++++ src/runner/checkpoint-bridge.ts | 141 ++++++ src/runner/config.ts | 69 +++ src/runner/index.ts | 35 ++ src/runner/session-manager.ts | 297 ++++++++++++ src/runner/slack-bot.ts | 141 ++++++ src/runner/worktree-manager.ts | 119 +++++ tests/runner/acp-client.test.ts | 170 +++++++ tests/runner/checkpoint-bridge.test.ts | 123 +++++ tests/runner/worktree-manager.test.ts | 108 +++++ 13 files changed, 2164 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 src/runner/acp-client.ts create mode 100644 src/runner/checkpoint-bridge.ts create mode 100644 src/runner/config.ts create mode 100644 src/runner/index.ts create mode 100644 src/runner/session-manager.ts create mode 100644 src/runner/slack-bot.ts create mode 100644 src/runner/worktree-manager.ts create mode 100644 tests/runner/acp-client.test.ts create mode 100644 tests/runner/checkpoint-bridge.test.ts create mode 100644 tests/runner/worktree-manager.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c59b7902 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Slack App (Socket Mode) +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_APP_TOKEN=xapp-your-app-level-token + +# Cursor CLI +CURSOR_API_KEY=key_your-cursor-api-key +CURSOR_AGENT_BINARY=agent + +# Repository +REPO_PATH=/path/to/midnight-agent-eng +WORKTREE_BASE_DIR=/path/to/worktrees + +# MCP Servers (JSON object, same format as .cursor/mcp.json mcpServers) +# Each key is a server name, value is { command, args, env } +MCP_SERVERS_JSON={} diff --git a/package-lock.json b/package-lock.json index 05e6647d..e91b847a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", + "@slack/bolt": "^4.6.0", "@toon-format/toon": "^2.1.0", + "dotenv": "^17.3.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.4" }, @@ -892,12 +894,152 @@ "dev": true, "license": "MIT" }, + "node_modules/@slack/bolt": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.6.0.tgz", + "integrity": "sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/oauth": "^3.0.4", + "@slack/socket-mode": "^2.0.5", + "@slack/types": "^2.18.0", + "@slack/web-api": "^7.12.0", + "axios": "^1.12.0", + "express": "^5.0.0", + "path-to-regexp": "^8.1.0", + "raw-body": "^3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", + "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18.0.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.4.tgz", + "integrity": "sha512-+8H0g7mbrHndEUbYCP7uYyBCbwqmm3E6Mo3nfsDvZZW74zKk1ochfH/fWSvGInYNCVvaBUbg3RZBbTp0j8yJCg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4", + "@slack/web-api": "^7.10.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.5.tgz", + "integrity": "sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4", + "@slack/web-api": "^7.10.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.0.tgz", + "integrity": "sha512-PVF6P6nxzDMrzPC8fSCsnwaI+kF8YfEpxf3MqXmdyjyWTYsZQURpkK7WWUWvP5QpH55pB7zyYL9Qem/xSgc5VA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.14.1.tgz", + "integrity": "sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/types": "^2.20.0", + "@types/node": ">=18.0.0", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/web-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@toon-format/toon": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.1.0.tgz", "integrity": "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==", "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -905,16 +1047,113 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -1084,6 +1323,23 @@ "node": "*" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1108,6 +1364,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1188,6 +1450,18 @@ "node": "*" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -1292,6 +1566,15 @@ "node": ">=6" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1311,6 +1594,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1325,6 +1620,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1370,6 +1674,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1437,6 +1756,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1583,6 +1908,63 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1722,6 +2104,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1805,6 +2202,12 @@ "node": ">= 0.10" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1858,6 +2261,49 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/local-pkg": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", @@ -1875,6 +2321,48 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -2111,6 +2599,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -2127,6 +2624,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2264,6 +2808,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -2329,6 +2879,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", @@ -2390,12 +2949,44 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2655,6 +3246,15 @@ "node": ">=0.6" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2724,7 +3324,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3362,6 +3961,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/package.json b/package.json index 01696c55..7a14052b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "test": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "runner": "tsx src/runner/index.ts", + "runner:start": "node dist/runner/index.js" }, "license": "MIT", "devDependencies": { @@ -23,7 +25,9 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", + "@slack/bolt": "^4.6.0", "@toon-format/toon": "^2.1.0", + "dotenv": "^17.3.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.4" } diff --git a/src/runner/acp-client.ts b/src/runner/acp-client.ts new file mode 100644 index 00000000..db87cc57 --- /dev/null +++ b/src/runner/acp-client.ts @@ -0,0 +1,318 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import readline from 'node:readline'; +import { EventEmitter } from 'node:events'; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 types +// --------------------------------------------------------------------------- + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification; + +function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { + return 'id' in msg && ('result' in msg || 'error' in msg); +} + +function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest { + return 'id' in msg && 'method' in msg && !('result' in msg) && !('error' in msg); +} + +// --------------------------------------------------------------------------- +// ACP-specific types +// --------------------------------------------------------------------------- + +export interface AcpQuestion { + id: string; + prompt: string; + options: Array<{ id: string; label: string }>; + allow_multiple?: boolean; +} + +export interface AcpAskQuestionParams { + title?: string; + questions: AcpQuestion[]; +} + +export interface AcpQuestionResponse { + questionId: string; + selectedOptions: string[]; +} + +export interface AcpSessionUpdate { + sessionUpdate: string; + content?: { text?: string }; + [key: string]: unknown; +} + +export interface AcpClientEvents { + ask_question: [requestId: number, params: AcpAskQuestionParams]; + request_permission: [requestId: number, params: Record]; + update: [update: AcpSessionUpdate]; + create_plan: [requestId: number, params: Record]; + update_todos: [params: Record]; + error: [error: Error]; + close: [code: number | null]; +} + +export interface McpServerEntry { + command: string; + args?: string[]; + env?: Record; +} + +export interface PromptResult { + stopReason: string; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// ACP Client +// --------------------------------------------------------------------------- + +export class AcpClient extends EventEmitter { + private process: ChildProcess | null = null; + private nextId = 1; + private pending = new Map void; + reject: (error: Error) => void; + }>(); + private sessionId: string | null = null; + + constructor( + private readonly agentBinary: string, + private readonly apiKey: string, + ) { + super(); + } + + get pid(): number | undefined { + return this.process?.pid; + } + + get active(): boolean { + return this.process !== null && !this.process.killed; + } + + /** + * Spawn the `agent acp` process and wire up stdio. + */ + spawn(cwd: string): void { + if (this.process) throw new Error('ACP process already running'); + + this.process = spawn(this.agentBinary, ['acp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + CURSOR_API_KEY: this.apiKey, + }, + }); + + const rl = readline.createInterface({ input: this.process.stdout! }); + rl.on('line', (line) => this.handleLine(line)); + + this.process.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString().trim(); + if (text) this.emit('error', new Error(`[agent stderr] ${text}`)); + }); + + this.process.on('close', (code) => { + this.rejectAllPending(new Error(`Agent process exited with code ${code}`)); + this.process = null; + this.emit('close', code); + }); + + this.process.on('error', (err) => { + this.emit('error', err); + }); + } + + /** + * Send the ACP initialize handshake. + */ + async initialize(): Promise { + await this.send('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: 'workflow-runner', version: '0.1.0' }, + }); + } + + /** + * Authenticate using the pre-configured Cursor API key. + */ + async authenticate(): Promise { + await this.send('authenticate', { methodId: 'cursor_login' }); + } + + /** + * Create a new ACP session. + * @returns The session ID. + */ + async createSession( + cwd: string, + mcpServers: Record = {}, + ): Promise { + const result = await this.send('session/new', { + cwd, + mcpServers: Object.entries(mcpServers).map(([name, cfg]) => ({ + name, + ...cfg, + })), + }) as { sessionId: string }; + this.sessionId = result.sessionId; + return result.sessionId; + } + + /** + * Send a prompt to the agent. This is a long-running call that resolves + * when the agent finishes processing. During execution, events are emitted + * for session updates, checkpoints, and permission requests. + */ + async prompt(text: string): Promise { + if (!this.sessionId) throw new Error('No active session'); + return await this.send('session/prompt', { + sessionId: this.sessionId, + prompt: [{ type: 'text', text }], + }) as PromptResult; + } + + /** + * Send a follow-up prompt to the agent. + */ + async followUp(text: string): Promise { + if (!this.sessionId) throw new Error('No active session'); + return await this.send('session/prompt', { + sessionId: this.sessionId, + prompt: [{ type: 'text', text }], + }); + } + + /** + * Respond to an incoming JSON-RPC request from the agent + * (e.g. cursor/ask_question, session/request_permission). + */ + respond(requestId: number, result: unknown): void { + this.write({ jsonrpc: '2.0', id: requestId, result }); + } + + /** + * Kill the agent process. + */ + kill(): void { + if (this.process && !this.process.killed) { + this.process.stdin?.end(); + this.process.kill(); + } + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private send(method: string, params?: Record): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + this.pending.set(id, { resolve, reject }); + this.write({ jsonrpc: '2.0', id, method, params }); + }); + } + + private write(msg: Record): void { + if (!this.process?.stdin?.writable) { + throw new Error('Agent stdin not writable'); + } + this.process.stdin.write(JSON.stringify(msg) + '\n'); + } + + private handleLine(line: string): void { + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + return; // ignore non-JSON lines (logs, etc.) + } + + if (isResponse(msg)) { + const waiter = this.pending.get(msg.id); + if (!waiter) return; + this.pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(`RPC error ${msg.error.code}: ${msg.error.message}`)); + } else { + waiter.resolve(msg.result); + } + return; + } + + if (isRequest(msg)) { + this.routeIncomingRequest(msg); + return; + } + + // Notification (no id) + this.routeNotification(msg as JsonRpcNotification); + } + + private routeIncomingRequest(req: JsonRpcRequest): void { + switch (req.method) { + case 'cursor/ask_question': + this.emit('ask_question', req.id, req.params as unknown as AcpAskQuestionParams); + break; + case 'session/request_permission': + this.emit('request_permission', req.id, req.params ?? {}); + break; + case 'cursor/create_plan': + this.emit('create_plan', req.id, req.params ?? {}); + break; + default: + // Auto-accept unknown requests to avoid blocking the agent + this.respond(req.id, {}); + break; + } + } + + private routeNotification(notif: JsonRpcNotification): void { + switch (notif.method) { + case 'session/update': { + const update = (notif.params as { update?: AcpSessionUpdate })?.update; + if (update) this.emit('update', update); + break; + } + case 'cursor/update_todos': + this.emit('update_todos', notif.params ?? {}); + break; + default: + break; + } + } + + private rejectAllPending(error: Error): void { + for (const [id, waiter] of this.pending) { + waiter.reject(error); + this.pending.delete(id); + } + } +} diff --git a/src/runner/checkpoint-bridge.ts b/src/runner/checkpoint-bridge.ts new file mode 100644 index 00000000..f7885f4c --- /dev/null +++ b/src/runner/checkpoint-bridge.ts @@ -0,0 +1,141 @@ +import type { WebClient } from '@slack/web-api'; +import type { AcpClient, AcpAskQuestionParams, AcpQuestionResponse } from './acp-client.js'; + +type SlackBlock = Record; + +/** + * Tracks a pending checkpoint waiting for a Slack interaction response. + */ +export interface PendingCheckpoint { + acpRequestId: number; + questions: AcpAskQuestionParams; + slackChannel: string; + slackThreadTs: string; + /** Map from Slack action_id → { questionId, optionId } */ + actionMap: Map; + createdAt: number; +} + +/** + * Bridges ACP cursor/ask_question requests to Slack interactive messages + * and routes Slack button clicks back as ACP responses. + */ +export class CheckpointBridge { + /** + * Keyed by a composite of channel + thread_ts so we can look up the + * pending checkpoint when a Slack interaction arrives. + */ + private pending = new Map(); + + constructor(private readonly slackClient: WebClient) {} + + /** + * Called when the ACP client emits a cursor/ask_question request. + * Posts an interactive message to the Slack thread and stores the + * pending state for later resolution. + */ + async presentCheckpoint( + acpRequestId: number, + params: AcpAskQuestionParams, + channel: string, + threadTs: string, + ): Promise { + const actionMap = new Map(); + const blocks: SlackBlock[] = []; + + if (params.title) { + blocks.push({ + type: 'header', + text: { type: 'plain_text', text: params.title, emoji: true }, + }); + } + + for (const question of params.questions) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: question.prompt }, + }); + + const buttonElements = question.options.map((opt) => { + const actionId = `checkpoint_${question.id}_${opt.id}`; + actionMap.set(actionId, { questionId: question.id, optionId: opt.id }); + return { + type: 'button' as const, + text: { type: 'plain_text' as const, text: opt.label, emoji: true }, + action_id: actionId, + value: opt.id, + }; + }); + + blocks.push({ type: 'actions', elements: buttonElements }); + } + + await this.slackClient.chat.postMessage({ + channel, + thread_ts: threadTs, + text: params.title ?? 'Workflow checkpoint', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + blocks: blocks as any[], + }); + + const key = this.pendingKey(channel, threadTs); + this.pending.set(key, { + acpRequestId, + questions: params, + slackChannel: channel, + slackThreadTs: threadTs, + actionMap, + createdAt: Date.now(), + }); + } + + /** + * Called when a Slack button interaction arrives. Resolves the pending + * checkpoint by responding to the ACP client. + * + * @returns true if a pending checkpoint was resolved, false if none was found. + */ + resolveCheckpoint( + channel: string, + threadTs: string, + actionId: string, + acpClient: AcpClient, + ): boolean { + const key = this.pendingKey(channel, threadTs); + const checkpoint = this.pending.get(key); + if (!checkpoint) return false; + + const mapping = checkpoint.actionMap.get(actionId); + if (!mapping) return false; + + const responses: AcpQuestionResponse[] = [{ + questionId: mapping.questionId, + selectedOptions: [mapping.optionId], + }]; + + acpClient.respond(checkpoint.acpRequestId, { + outcome: { outcome: 'selected', responses }, + }); + + this.pending.delete(key); + return true; + } + + /** + * Check whether a thread has a pending checkpoint. + */ + hasPending(channel: string, threadTs: string): boolean { + return this.pending.has(this.pendingKey(channel, threadTs)); + } + + /** + * Cancel all pending checkpoints (e.g. on agent crash). + */ + cancelAll(channel: string, threadTs: string): void { + this.pending.delete(this.pendingKey(channel, threadTs)); + } + + private pendingKey(channel: string, threadTs: string): string { + return `${channel}:${threadTs}`; + } +} diff --git a/src/runner/config.ts b/src/runner/config.ts new file mode 100644 index 00000000..64fb04a8 --- /dev/null +++ b/src/runner/config.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; +import os from 'node:os'; +import { z } from 'zod'; + +const McpServerConfigSchema = z.object({ + command: z.string(), + args: z.array(z.string()).default([]), + env: z.record(z.string()).optional(), +}); + +export interface McpServerConfig { + command: string; + args: string[]; + env: Record | undefined; +} + +const RunnerConfigSchema = z.object({ + slack: z.object({ + botToken: z.string().startsWith('xoxb-'), + signingSecret: z.string().min(1), + appToken: z.string().startsWith('xapp-'), + }), + cursor: z.object({ + apiKey: z.string().min(1), + agentBinary: z.string(), + }), + repo: z.object({ + path: z.string().min(1), + worktreeBaseDir: z.string().min(1), + }), + mcpServers: z.record(McpServerConfigSchema).default({}), +}); + +export type RunnerConfig = z.infer; + +function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Required environment variable ${name} is not set`); + return val; +} + +export function loadRunnerConfig(): RunnerConfig { + return RunnerConfigSchema.parse({ + slack: { + botToken: requireEnv('SLACK_BOT_TOKEN'), + signingSecret: requireEnv('SLACK_SIGNING_SECRET'), + appToken: requireEnv('SLACK_APP_TOKEN'), + }, + cursor: { + apiKey: requireEnv('CURSOR_API_KEY'), + agentBinary: process.env['CURSOR_AGENT_BINARY'] ?? 'agent', + }, + repo: { + path: requireEnv('REPO_PATH'), + worktreeBaseDir: process.env['WORKTREE_BASE_DIR'] ?? path.join(os.homedir(), 'worktrees'), + }, + mcpServers: parseMcpServers(), + }); +} + +function parseMcpServers(): Record { + const raw = process.env['MCP_SERVERS_JSON']; + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + throw new Error('MCP_SERVERS_JSON is not valid JSON'); + } +} diff --git a/src/runner/index.ts b/src/runner/index.ts new file mode 100644 index 00000000..39c50845 --- /dev/null +++ b/src/runner/index.ts @@ -0,0 +1,35 @@ +import 'dotenv/config'; +import { WebClient } from '@slack/web-api'; +import { loadRunnerConfig } from './config.js'; +import { SessionManager } from './session-manager.js'; +import { createSlackApp } from './slack-bot.js'; + +async function main(): Promise { + const config = loadRunnerConfig(); + console.log('Runner config loaded', { + repo: config.repo.path, + worktreeBase: config.repo.worktreeBaseDir, + mcpServers: Object.keys(config.mcpServers), + }); + + const slackClient = new WebClient(config.slack.botToken); + const sessionManager = new SessionManager(config, slackClient); + const app = createSlackApp(config, sessionManager); + + await app.start(); + console.log('Workflow Runner is listening (Socket Mode)'); + + const shutdown = async () => { + console.log('Shutting down...'); + await app.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts new file mode 100644 index 00000000..11da8f45 --- /dev/null +++ b/src/runner/session-manager.ts @@ -0,0 +1,297 @@ +import type { WebClient } from '@slack/web-api'; +import { AcpClient, type AcpSessionUpdate } from './acp-client.js'; +import { CheckpointBridge } from './checkpoint-bridge.js'; +import { WorktreeManager, type WorktreeInfo } from './worktree-manager.js'; +import type { RunnerConfig } from './config.js'; + +// --------------------------------------------------------------------------- +// Session types +// --------------------------------------------------------------------------- + +export type SessionStatus = + | 'creating' + | 'running' + | 'awaiting_checkpoint' + | 'completed' + | 'error'; + +export interface WorkflowSession { + id: string; + status: SessionStatus; + workflowId: string; + targetSubmodule: string; + issueRef: string | undefined; + slackChannel: string; + slackThreadTs: string; + worktree: WorktreeInfo | null; + acpClient: AcpClient | null; + createdAt: number; + completedAt: number | undefined; + error: string | undefined; + /** Accumulated agent text for periodic Slack updates. */ + pendingText: string; + /** Timer handle for batched Slack status posts. */ + updateTimer: ReturnType | null; +} + +// --------------------------------------------------------------------------- +// Session Manager +// --------------------------------------------------------------------------- + +const STATUS_POST_INTERVAL_MS = 5_000; +const MAX_SLACK_TEXT_LENGTH = 3_000; + +export class SessionManager { + private sessions = new Map(); + /** Reverse lookup: Slack thread → session ID */ + private threadToSession = new Map(); + + private worktreeManager: WorktreeManager; + private checkpointBridge: CheckpointBridge; + + constructor( + private readonly config: RunnerConfig, + private readonly slackClient: WebClient, + ) { + this.worktreeManager = new WorktreeManager(config.repo.path, config.repo.worktreeBaseDir); + this.checkpointBridge = new CheckpointBridge(slackClient); + } + + /** + * Start a new workflow run: create worktree, spawn agent, send prompt. + */ + async startWorkflow( + workflowId: string, + targetSubmodule: string, + issueRef: string | undefined, + slackChannel: string, + slackThreadTs: string, + ): Promise { + const id = this.generateId(); + + const session: WorkflowSession = { + id, + status: 'creating', + workflowId, + targetSubmodule, + issueRef, + slackChannel, + slackThreadTs, + worktree: null, + acpClient: null, + createdAt: Date.now(), + completedAt: undefined, + error: undefined, + pendingText: '', + updateTimer: null, + }; + + this.sessions.set(id, session); + this.threadToSession.set(`${slackChannel}:${slackThreadTs}`, id); + + try { + await this.postStatus(session, `Creating worktree for \`${targetSubmodule}\`...`); + + session.worktree = await this.worktreeManager.create( + id, 'main', targetSubmodule, this.config.mcpServers as Record, + ); + session.status = 'running'; + + const acp = new AcpClient(this.config.cursor.agentBinary, this.config.cursor.apiKey); + session.acpClient = acp; + + this.wireAcpEvents(session, acp); + + acp.spawn(session.worktree.path); + await acp.initialize(); + await acp.authenticate(); + await acp.createSession( + session.worktree.path, + this.config.mcpServers as unknown as Record, + ); + + this.startUpdateTimer(session); + + await this.postStatus(session, `Workflow \`${workflowId}\` started. Agent is running...`); + + const prompt = this.buildPrompt(workflowId, targetSubmodule, issueRef); + + // prompt() is long-running — it resolves when the agent finishes. + // Checkpoints and updates are handled via events in the meantime. + acp.prompt(prompt).then( + (result) => this.handleCompletion(session, result), + (err: unknown) => this.handleError(session, err instanceof Error ? err : new Error(String(err))), + ); + + return session; + } catch (err) { + await this.handleError(session, err instanceof Error ? err : new Error(String(err))); + throw err; + } + } + + /** + * Look up a session by its Slack thread. + */ + getByThread(channel: string, threadTs: string): WorkflowSession | undefined { + const id = this.threadToSession.get(`${channel}:${threadTs}`); + return id ? this.sessions.get(id) : undefined; + } + + /** + * Handle a Slack button click for a checkpoint response. + */ + handleCheckpointResponse(channel: string, threadTs: string, actionId: string): boolean { + const session = this.getByThread(channel, threadTs); + if (!session?.acpClient) return false; + + const resolved = this.checkpointBridge.resolveCheckpoint( + channel, threadTs, actionId, session.acpClient, + ); + if (resolved) { + session.status = 'running'; + } + return resolved; + } + + /** + * List active sessions. + */ + listActive(): WorkflowSession[] { + return [...this.sessions.values()].filter( + (s) => s.status === 'running' || s.status === 'awaiting_checkpoint' || s.status === 'creating', + ); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private wireAcpEvents(session: WorkflowSession, acp: AcpClient): void { + acp.on('ask_question', async (requestId, params) => { + session.status = 'awaiting_checkpoint'; + await this.flushPendingText(session); + await this.checkpointBridge.presentCheckpoint( + requestId, params, session.slackChannel, session.slackThreadTs, + ); + }); + + acp.on('request_permission', (requestId, _params) => { + // Auto-approve all permission requests in PoC. + // The .cursor/cli.json allowlist handles most cases; this catches stragglers. + acp.respond(requestId, { + outcome: { outcome: 'selected', optionId: 'allow-always' }, + }); + }); + + acp.on('create_plan', (requestId, _params) => { + // Auto-approve plans — the workflow orchestrator manages flow control. + acp.respond(requestId, { accepted: true }); + }); + + acp.on('update', (update: AcpSessionUpdate) => { + if (update.sessionUpdate === 'agent_message_chunk' && update.content?.text) { + session.pendingText += update.content.text; + } + }); + + acp.on('error', async (err) => { + console.error(`[session ${session.id}] ACP error:`, err.message); + }); + + acp.on('close', async (code) => { + if (session.status !== 'completed' && session.status !== 'error') { + await this.handleError(session, new Error(`Agent process exited unexpectedly (code ${code})`)); + } + }); + } + + private buildPrompt(workflowId: string, targetSubmodule: string, issueRef?: string): string { + const parts = [`Start workflow: ${workflowId}`]; + parts.push(`Target: ${targetSubmodule}`); + if (issueRef) parts.push(`Issue: ${issueRef}`); + return parts.join('\n'); + } + + private async handleCompletion(session: WorkflowSession, result: unknown): Promise { + session.status = 'completed'; + session.completedAt = Date.now(); + this.stopUpdateTimer(session); + await this.flushPendingText(session); + + const elapsed = Math.round((session.completedAt - session.createdAt) / 1000); + await this.postStatus(session, + `Workflow \`${session.workflowId}\` completed in ${elapsed}s.` + + (result && typeof result === 'object' && 'stopReason' in result + ? ` Stop reason: ${(result as { stopReason: string }).stopReason}` + : ''), + ); + + await this.cleanupSession(session); + } + + private async handleError(session: WorkflowSession, err: Error): Promise { + session.status = 'error'; + session.error = err.message; + session.completedAt = Date.now(); + this.stopUpdateTimer(session); + await this.flushPendingText(session); + this.checkpointBridge.cancelAll(session.slackChannel, session.slackThreadTs); + + await this.postStatus(session, `Workflow error: ${err.message}`); + await this.cleanupSession(session); + } + + private async cleanupSession(session: WorkflowSession): Promise { + session.acpClient?.kill(); + if (session.worktree) { + try { + await this.worktreeManager.cleanup(session.worktree); + } catch (err) { + console.error(`[session ${session.id}] Worktree cleanup failed:`, err); + } + } + } + + private startUpdateTimer(session: WorkflowSession): void { + session.updateTimer = setInterval(() => { + void this.flushPendingText(session); + }, STATUS_POST_INTERVAL_MS); + } + + private stopUpdateTimer(session: WorkflowSession): void { + if (session.updateTimer) { + clearInterval(session.updateTimer); + session.updateTimer = null; + } + } + + private async flushPendingText(session: WorkflowSession): Promise { + if (!session.pendingText.trim()) return; + + let text = session.pendingText.trim(); + session.pendingText = ''; + + if (text.length > MAX_SLACK_TEXT_LENGTH) { + text = '...' + text.slice(-MAX_SLACK_TEXT_LENGTH); + } + + await this.postStatus(session, text); + } + + private async postStatus(session: WorkflowSession, text: string): Promise { + try { + await this.slackClient.chat.postMessage({ + channel: session.slackChannel, + thread_ts: session.slackThreadTs, + text, + }); + } catch (err) { + console.error(`[session ${session.id}] Failed to post to Slack:`, err); + } + } + + private generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } +} diff --git a/src/runner/slack-bot.ts b/src/runner/slack-bot.ts new file mode 100644 index 00000000..18cf18ff --- /dev/null +++ b/src/runner/slack-bot.ts @@ -0,0 +1,141 @@ +import { App, type SlackCommandMiddlewareArgs, type AllMiddlewareArgs } from '@slack/bolt'; +import type { SessionManager } from './session-manager.js'; +import type { RunnerConfig } from './config.js'; + +const HELP_TEXT = [ + '*Workflow Runner Commands*', + '`/workflow start [issue-ref]`', + ' Start a workflow (e.g. `/workflow start work-package midnight-node PM-12345`)', + '`/workflow list`', + ' List active workflow sessions', + '`/workflow help`', + ' Show this help message', +].join('\n'); + +export function createSlackApp( + config: RunnerConfig, + sessionManager: SessionManager, +): App { + const app = new App({ + token: config.slack.botToken, + signingSecret: config.slack.signingSecret, + appToken: config.slack.appToken, + socketMode: true, + }); + + // ----------------------------------------------------------------------- + // Slash command: /workflow + // ----------------------------------------------------------------------- + + app.command('/workflow', async (args) => { + const { command, ack, say } = args as SlackCommandMiddlewareArgs & AllMiddlewareArgs; + await ack(); + + const parts = command.text.trim().split(/\s+/); + const subcommand = parts[0]?.toLowerCase(); + + switch (subcommand) { + case 'start': + await handleStart(parts.slice(1), command.channel_id, say, sessionManager); + break; + case 'list': + await handleList(say, sessionManager); + break; + case 'help': + default: + await say(HELP_TEXT); + break; + } + }); + + // ----------------------------------------------------------------------- + // Interactive: button clicks (checkpoint responses) + // ----------------------------------------------------------------------- + + app.action(/^checkpoint_/, async ({ action, body, ack }) => { + await ack(); + + if (body.type !== 'block_actions' || !('actions' in body)) return; + + const channel = body.channel?.id; + // Thread timestamp: prefer the message's thread_ts, fall back to the message ts + const message = body.message as Record | undefined; + const threadTs = (message?.['thread_ts'] ?? message?.['ts']) as string | undefined; + const actionId = 'action_id' in action ? action.action_id : undefined; + + if (!channel || !threadTs || !actionId) return; + + const resolved = sessionManager.handleCheckpointResponse(channel, threadTs, actionId); + + if (!resolved) { + // Could be a stale button click — no action needed + console.warn(`Unresolved checkpoint action: ${actionId} in ${channel}:${threadTs}`); + } + }); + + return app; +} + +// ------------------------------------------------------------------------- +// Subcommand handlers +// ------------------------------------------------------------------------- + +async function handleStart( + args: string[], + channel: string, + say: SlackCommandMiddlewareArgs['say'], + sessionManager: SessionManager, +): Promise { + const workflowId = args[0]; + const targetSubmodule = args[1]; + + if (!workflowId || !targetSubmodule) { + await say('Usage: `/workflow start [issue-ref]`'); + return; + } + + const issueRef = args[2]; + + // Post an initial message and use its timestamp as the thread root + const result = await say( + `Starting workflow \`${workflowId}\` targeting \`${targetSubmodule}\`` + + (issueRef ? ` (${issueRef})` : '') + + '...', + ); + + const threadTs = typeof result === 'object' && 'ts' in result ? result.ts : undefined; + if (!threadTs) { + await say('Failed to create workflow thread.'); + return; + } + + try { + await sessionManager.startWorkflow( + workflowId, targetSubmodule, issueRef, channel, threadTs, + ); + } catch (err) { + await say({ + text: `Failed to start workflow: ${err instanceof Error ? err.message : String(err)}`, + thread_ts: threadTs, + }); + } +} + +async function handleList( + say: SlackCommandMiddlewareArgs['say'], + sessionManager: SessionManager, +): Promise { + const active = sessionManager.listActive(); + + if (active.length === 0) { + await say('No active workflow sessions.'); + return; + } + + const lines = active.map((s) => { + const elapsed = Math.round((Date.now() - s.createdAt) / 1000); + return `- \`${s.workflowId}\` on \`${s.targetSubmodule}\` [${s.status}] (${elapsed}s)`; + }); + + await say(`*Active Sessions (${active.length})*\n${lines.join('\n')}`); +} diff --git a/src/runner/worktree-manager.ts b/src/runner/worktree-manager.ts new file mode 100644 index 00000000..3863f1bb --- /dev/null +++ b/src/runner/worktree-manager.ts @@ -0,0 +1,119 @@ +import { execFile } from 'node:child_process'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { McpServerConfig } from './config.js'; + +const exec = promisify(execFile); + +export interface WorktreeInfo { + runId: string; + path: string; + branch: string; + targetSubmodule: string | undefined; +} + +const AUTO_APPROVE_PERMISSIONS = { + permissions: { + allow: [ + 'Mcp(workflow-server:*)', + 'Mcp(atlassian:*)', + 'Mcp(gitnexus:*)', + 'Mcp(concept-rag:*)', + 'Shell(git)', + 'Shell(cargo)', + 'Shell(npm)', + 'Shell(npx)', + 'Shell(ls)', + 'Shell(head)', + 'Read(**)', + 'Write(**)', + 'WebFetch(*)', + ], + deny: [ + 'Shell(rm)', + ], + }, +}; + +export class WorktreeManager { + constructor( + private readonly repoPath: string, + private readonly baseDir: string, + ) {} + + /** + * Create a new git worktree for a workflow run. + */ + async create( + runId: string, + baseBranch: string = 'main', + targetSubmodule?: string, + mcpServers: Record = {}, + ): Promise { + await mkdir(this.baseDir, { recursive: true }); + + const worktreePath = path.join(this.baseDir, `run-${runId}`); + const branchName = `runner/${runId}`; + + await exec('git', ['worktree', 'add', worktreePath, '-b', branchName, baseBranch], { + cwd: this.repoPath, + }); + + if (targetSubmodule) { + await exec('git', ['submodule', 'update', '--init', targetSubmodule], { + cwd: worktreePath, + }); + } + + await this.placeCursorConfig(worktreePath, mcpServers); + + return { runId, path: worktreePath, branch: branchName, targetSubmodule }; + } + + /** + * Remove a worktree and its branch after a workflow run completes. + */ + async cleanup(info: WorktreeInfo): Promise { + try { + await exec('git', ['worktree', 'remove', info.path, '--force'], { + cwd: this.repoPath, + }); + } catch { + // Worktree may already be removed; force-delete the directory + await rm(info.path, { recursive: true, force: true }); + await exec('git', ['worktree', 'prune'], { cwd: this.repoPath }); + } + + try { + await exec('git', ['branch', '-D', info.branch], { cwd: this.repoPath }); + } catch { + // Branch may not exist if worktree creation failed partway + } + } + + /** + * Place .cursor/mcp.json and .cursor/cli.json in the worktree so the + * Cursor agent picks up MCP servers and auto-approved permissions. + */ + private async placeCursorConfig( + worktreePath: string, + mcpServers: Record, + ): Promise { + const cursorDir = path.join(worktreePath, '.cursor'); + await mkdir(cursorDir, { recursive: true }); + + if (Object.keys(mcpServers).length > 0) { + const mcpConfig = { mcpServers }; + await writeFile( + path.join(cursorDir, 'mcp.json'), + JSON.stringify(mcpConfig, null, 2), + ); + } + + await writeFile( + path.join(cursorDir, 'cli.json'), + JSON.stringify(AUTO_APPROVE_PERMISSIONS, null, 2), + ); + } +} diff --git a/tests/runner/acp-client.test.ts b/tests/runner/acp-client.test.ts new file mode 100644 index 00000000..6fd884aa --- /dev/null +++ b/tests/runner/acp-client.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter, Readable, Writable } from 'node:stream'; +import readline from 'node:readline'; +import { AcpClient } from '../../src/runner/acp-client.js'; + +function createMockProcess() { + const stdin = new Writable({ + write(_chunk, _encoding, callback) { callback(); }, + }); + const stdout = new Readable({ read() {} }); + const stderr = new Readable({ read() {} }); + + const proc = Object.assign(new EventEmitter(), { + stdin, + stdout, + stderr, + pid: 12345, + killed: false, + kill: vi.fn(() => { (proc as any).killed = true; }), + }); + + return proc; +} + +function injectMockProcess(client: AcpClient, mockProc: ReturnType) { + (client as any).process = mockProc; + + const rl = readline.createInterface({ input: mockProc.stdout }); + rl.on('line', (line: string) => (client as any).handleLine(line)); + + mockProc.on('close', (code: number | null) => { + (client as any).rejectAllPending(new Error(`Agent process exited with code ${code}`)); + (client as any).process = null; + client.emit('close', code); + }); +} + +function agentRespond(proc: ReturnType, id: number, result: unknown) { + proc.stdout.push(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +} + +function agentRequest(proc: ReturnType, id: number, method: string, params: unknown) { + proc.stdout.push(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); +} + +function agentNotify(proc: ReturnType, method: string, params: unknown) { + proc.stdout.push(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); +} + +const tick = () => new Promise((r) => setTimeout(r, 10)); + +describe('AcpClient', () => { + let client: AcpClient; + let proc: ReturnType; + + beforeEach(() => { + client = new AcpClient('agent', 'test-key'); + proc = createMockProcess(); + injectMockProcess(client, proc); + }); + + afterEach(() => { + client.kill(); + }); + + it('should send initialize and resolve on response', async () => { + const initPromise = client.initialize(); + await tick(); + agentRespond(proc, 1, { protocolVersion: 1, capabilities: {} }); + await expect(initPromise).resolves.toBeUndefined(); + }); + + it('should send authenticate and resolve on response', async () => { + const authPromise = client.authenticate(); + await tick(); + agentRespond(proc, 1, {}); + await expect(authPromise).resolves.toBeUndefined(); + }); + + it('should create a session and store sessionId', async () => { + const sessionPromise = client.createSession('/tmp/test', {}); + await tick(); + agentRespond(proc, 1, { sessionId: 'sess-abc' }); + + const sessionId = await sessionPromise; + expect(sessionId).toBe('sess-abc'); + }); + + it('should emit ask_question when agent sends cursor/ask_question', async () => { + const handler = vi.fn(); + client.on('ask_question', handler); + + agentRequest(proc, 99, 'cursor/ask_question', { + title: 'Checkpoint', + questions: [{ + id: 'q1', + prompt: 'Proceed?', + options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }], + }], + }); + + await tick(); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0]![0]).toBe(99); + expect(handler.mock.calls[0]![1]).toMatchObject({ + title: 'Checkpoint', + questions: expect.arrayContaining([ + expect.objectContaining({ id: 'q1' }), + ]), + }); + }); + + it('should auto-approve session/request_permission when wired', async () => { + const writeSpy = vi.spyOn(proc.stdin, 'write'); + + client.on('request_permission', (requestId) => { + client.respond(requestId, { outcome: { outcome: 'selected', optionId: 'allow-always' } }); + }); + + agentRequest(proc, 42, 'session/request_permission', { tool: 'shell', command: 'git status' }); + await tick(); + + const lastCall = writeSpy.mock.calls.find((call) => { + const str = call[0] as string; + return str.includes('"id":42'); + }); + expect(lastCall).toBeDefined(); + const parsed = JSON.parse(lastCall![0] as string); + expect(parsed.result.outcome.optionId).toBe('allow-always'); + }); + + it('should emit update on session/update notification', async () => { + const handler = vi.fn(); + client.on('update', handler); + + agentNotify(proc, 'session/update', { + update: { + sessionUpdate: 'agent_message_chunk', + content: { text: 'Working on it...' }, + }, + }); + + await tick(); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0]![0]).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + content: { text: 'Working on it...' }, + }); + }); + + it('should reject pending promises when process closes', async () => { + const promise = client.initialize(); + proc.emit('close', 1); + + await expect(promise).rejects.toThrow('exited with code 1'); + }); + + it('should respond to agent requests via respond()', () => { + const writeSpy = vi.spyOn(proc.stdin, 'write'); + + client.respond(77, { accepted: true }); + + const lastCall = writeSpy.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const parsed = JSON.parse(lastCall![0] as string); + expect(parsed).toMatchObject({ jsonrpc: '2.0', id: 77, result: { accepted: true } }); + }); +}); diff --git a/tests/runner/checkpoint-bridge.test.ts b/tests/runner/checkpoint-bridge.test.ts new file mode 100644 index 00000000..5c1546ae --- /dev/null +++ b/tests/runner/checkpoint-bridge.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CheckpointBridge } from '../../src/runner/checkpoint-bridge.js'; +import type { AcpClient, AcpAskQuestionParams } from '../../src/runner/acp-client.js'; + +function createMockSlackClient() { + return { + chat: { + postMessage: vi.fn().mockResolvedValue({ ok: true, ts: '1234567890.123456' }), + }, + }; +} + +function createMockAcpClient() { + return { + respond: vi.fn(), + } as unknown as AcpClient; +} + +const SAMPLE_CHECKPOINT: AcpAskQuestionParams = { + title: 'Review Checkpoint', + questions: [{ + id: 'proceed', + prompt: 'The analysis is complete. How would you like to proceed?', + options: [ + { id: 'continue', label: 'Continue to implementation' }, + { id: 'revise', label: 'Revise the plan' }, + { id: 'abort', label: 'Abort workflow' }, + ], + }], +}; + +describe('CheckpointBridge', () => { + let bridge: CheckpointBridge; + let slackClient: ReturnType; + + beforeEach(() => { + slackClient = createMockSlackClient(); + bridge = new CheckpointBridge(slackClient as any); + }); + + it('should post an interactive message to Slack on presentCheckpoint', async () => { + await bridge.presentCheckpoint(42, SAMPLE_CHECKPOINT, 'C123', '1234.5678'); + + expect(slackClient.chat.postMessage).toHaveBeenCalledOnce(); + const call = slackClient.chat.postMessage.mock.calls[0]![0]!; + expect(call.channel).toBe('C123'); + expect(call.thread_ts).toBe('1234.5678'); + expect(call.blocks).toBeDefined(); + expect(call.blocks.length).toBe(3); // header + section + actions + }); + + it('should create a pending checkpoint', async () => { + await bridge.presentCheckpoint(42, SAMPLE_CHECKPOINT, 'C123', '1234.5678'); + + expect(bridge.hasPending('C123', '1234.5678')).toBe(true); + expect(bridge.hasPending('C123', 'other')).toBe(false); + }); + + it('should resolve checkpoint when correct action is clicked', async () => { + await bridge.presentCheckpoint(42, SAMPLE_CHECKPOINT, 'C123', '1234.5678'); + + const acpClient = createMockAcpClient(); + const resolved = bridge.resolveCheckpoint( + 'C123', '1234.5678', 'checkpoint_proceed_continue', acpClient, + ); + + expect(resolved).toBe(true); + expect(acpClient.respond).toHaveBeenCalledWith(42, { + outcome: { + outcome: 'selected', + responses: [{ questionId: 'proceed', selectedOptions: ['continue'] }], + }, + }); + + expect(bridge.hasPending('C123', '1234.5678')).toBe(false); + }); + + it('should return false for unknown action', async () => { + await bridge.presentCheckpoint(42, SAMPLE_CHECKPOINT, 'C123', '1234.5678'); + + const acpClient = createMockAcpClient(); + const resolved = bridge.resolveCheckpoint( + 'C123', '1234.5678', 'checkpoint_unknown_action', acpClient, + ); + + expect(resolved).toBe(false); + expect(acpClient.respond).not.toHaveBeenCalled(); + expect(bridge.hasPending('C123', '1234.5678')).toBe(true); + }); + + it('should return false when no pending checkpoint exists', () => { + const acpClient = createMockAcpClient(); + const resolved = bridge.resolveCheckpoint( + 'C999', '0000.0000', 'checkpoint_proceed_continue', acpClient, + ); + + expect(resolved).toBe(false); + }); + + it('should cancel all pending checkpoints', async () => { + await bridge.presentCheckpoint(42, SAMPLE_CHECKPOINT, 'C123', '1234.5678'); + expect(bridge.hasPending('C123', '1234.5678')).toBe(true); + + bridge.cancelAll('C123', '1234.5678'); + expect(bridge.hasPending('C123', '1234.5678')).toBe(false); + }); + + it('should render multiple questions in blocks', async () => { + const multiQ: AcpAskQuestionParams = { + title: 'Multi', + questions: [ + { id: 'q1', prompt: 'First?', options: [{ id: 'a', label: 'A' }] }, + { id: 'q2', prompt: 'Second?', options: [{ id: 'b', label: 'B' }] }, + ], + }; + + await bridge.presentCheckpoint(10, multiQ, 'C123', '1234.5678'); + + const call = slackClient.chat.postMessage.mock.calls[0]![0]!; + // header + (section + actions) * 2 = 5 blocks + expect(call.blocks.length).toBe(5); + }); +}); diff --git a/tests/runner/worktree-manager.test.ts b/tests/runner/worktree-manager.test.ts new file mode 100644 index 00000000..a2622956 --- /dev/null +++ b/tests/runner/worktree-manager.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { WorktreeManager } from '../../src/runner/worktree-manager.js'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => { + if (cb) cb(null, '', ''); + }), +})); + +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), +})); + +describe('WorktreeManager', () => { + let manager: WorktreeManager; + const mockExecFile = vi.mocked(execFile); + + beforeEach(() => { + vi.clearAllMocks(); + manager = new WorktreeManager('/repo', '/tmp/worktrees'); + + mockExecFile.mockImplementation( + (_cmd: any, _args: any, _opts: any, cb?: any) => { + if (typeof cb === 'function') cb(null, '', ''); + return undefined as any; + }, + ); + }); + + it('should create a worktree with correct git commands', async () => { + const info = await manager.create('abc123', 'main', 'midnight-node', {}); + + expect(info.runId).toBe('abc123'); + expect(info.path).toBe('/tmp/worktrees/run-abc123'); + expect(info.branch).toBe('runner/abc123'); + expect(info.targetSubmodule).toBe('midnight-node'); + + const calls = mockExecFile.mock.calls; + + // First call: git worktree add + expect(calls[0]![0]).toBe('git'); + expect(calls[0]![1]).toEqual( + ['worktree', 'add', '/tmp/worktrees/run-abc123', '-b', 'runner/abc123', 'main'], + ); + expect((calls[0]![2] as any).cwd).toBe('/repo'); + + // Second call: git submodule update --init + expect(calls[1]![0]).toBe('git'); + expect(calls[1]![1]).toEqual(['submodule', 'update', '--init', 'midnight-node']); + expect((calls[1]![2] as any).cwd).toBe('/tmp/worktrees/run-abc123'); + + // .cursor/cli.json was written + expect(vi.mocked(writeFile)).toHaveBeenCalledWith( + '/tmp/worktrees/run-abc123/.cursor/cli.json', + expect.stringContaining('permissions'), + ); + }); + + it('should write MCP config when mcpServers are provided', async () => { + await manager.create('def456', 'main', undefined, { + 'workflow-server': { + command: 'node', + args: ['dist/index.js'], + env: undefined, + }, + }); + + expect(vi.mocked(writeFile)).toHaveBeenCalledWith( + '/tmp/worktrees/run-def456/.cursor/mcp.json', + expect.stringContaining('workflow-server'), + ); + }); + + it('should not write mcp.json when no MCP servers configured', async () => { + await manager.create('ghi789', 'main', undefined, {}); + + const writeFileCalls = vi.mocked(writeFile).mock.calls; + const mcpWrite = writeFileCalls.find((c) => String(c[0]).includes('mcp.json')); + expect(mcpWrite).toBeUndefined(); + }); + + it('should cleanup worktree and branch', async () => { + await manager.cleanup({ + runId: 'abc123', + path: '/tmp/worktrees/run-abc123', + branch: 'runner/abc123', + targetSubmodule: 'midnight-node', + }); + + const calls = mockExecFile.mock.calls; + + // git worktree remove + expect(calls[0]![0]).toBe('git'); + expect(calls[0]![1]).toEqual( + ['worktree', 'remove', '/tmp/worktrees/run-abc123', '--force'], + ); + expect((calls[0]![2] as any).cwd).toBe('/repo'); + + // git branch -D + expect(calls[1]![0]).toBe('git'); + expect(calls[1]![1]).toEqual(['branch', '-D', 'runner/abc123']); + expect((calls[1]![2] as any).cwd).toBe('/repo'); + }); +}); From d15d8c981512ea262917a3069c9c336351b07aec Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 11:26:58 +0000 Subject: [PATCH 02/18] chore: update .engineering submodule with planning artifacts Made-with: Cursor --- .engineering | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.engineering b/.engineering index a9e31687..93e57a5d 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit a9e31687a403030721b91bfcf01ee7e9130f705b +Subproject commit 93e57a5d6fe4d82adc9040e3bc3bd9118d375b00 From 56827ba5e0ec4617aa35d04ae281d30f04b7ca8b Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 11:53:46 +0000 Subject: [PATCH 03/18] fix: downgrade stderr handling from error to diagnostic event Cursor CLI emits diagnostic output on stderr that was being treated as an error, causing false-positive noise. Change the stderr handler in AcpClient to emit a 'stderr' event instead of 'error', and wire the new event to console.error in SessionManager for visibility. Made-with: Cursor --- src/runner/acp-client.ts | 3 ++- src/runner/session-manager.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/runner/acp-client.ts b/src/runner/acp-client.ts index db87cc57..55acdf0d 100644 --- a/src/runner/acp-client.ts +++ b/src/runner/acp-client.ts @@ -69,6 +69,7 @@ export interface AcpClientEvents { update: [update: AcpSessionUpdate]; create_plan: [requestId: number, params: Record]; update_todos: [params: Record]; + stderr: [text: string]; error: [error: Error]; close: [code: number | null]; } @@ -132,7 +133,7 @@ export class AcpClient extends EventEmitter { this.process.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString().trim(); - if (text) this.emit('error', new Error(`[agent stderr] ${text}`)); + if (text) this.emit('stderr', text); }); this.process.on('close', (code) => { diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts index 11da8f45..b550fe8e 100644 --- a/src/runner/session-manager.ts +++ b/src/runner/session-manager.ts @@ -195,6 +195,10 @@ export class SessionManager { } }); + acp.on('stderr', (text) => { + console.error(`[session ${session.id}] agent stderr: ${text}`); + }); + acp.on('error', async (err) => { console.error(`[session ${session.id}] ACP error:`, err.message); }); From ff78ed809404a8b2670383e53e3219fecc7dbf6f Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 12:00:41 +0000 Subject: [PATCH 04/18] feat: add npm run runner script for headless Slack runner Made-with: Cursor --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 7a14052b..d066c9e8 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "dev": "tsx src/index.ts", "test": "vitest", "typecheck": "tsc --noEmit", - "runner": "tsx src/runner/index.ts", - "runner:start": "node dist/runner/index.js" + "runner": "tsx src/runner/index.ts" }, "license": "MIT", "devDependencies": { From cc56dec9e11fd74d6d0a70811c36ce1157828316 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 12:01:03 +0000 Subject: [PATCH 05/18] feat: add shutdownAll to SessionManager for graceful cleanup Iterates active sessions and cleans up worktrees/agents before stopping the Slack app on SIGINT/SIGTERM. Made-with: Cursor --- src/runner/index.ts | 1 + src/runner/session-manager.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/runner/index.ts b/src/runner/index.ts index 39c50845..a2f46310 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -21,6 +21,7 @@ async function main(): Promise { const shutdown = async () => { console.log('Shutting down...'); + await sessionManager.shutdownAll(); await app.stop(); process.exit(0); }; diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts index b550fe8e..7da26154 100644 --- a/src/runner/session-manager.ts +++ b/src/runner/session-manager.ts @@ -163,6 +163,18 @@ export class SessionManager { ); } + /** + * Gracefully shut down all active sessions (cleanup worktrees, kill agents). + */ + async shutdownAll(): Promise { + const active = this.listActive(); + await Promise.allSettled( + active.map((s) => this.cleanupSession(s)), + ); + this.sessions.clear(); + this.threadToSession.clear(); + } + // ----------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------- From b0b60146b70f69e471f5e723ef6e52ddbf0092fe Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 12:02:10 +0000 Subject: [PATCH 06/18] feat: add structured logging with pino and pino-roll - New logger module with daily rotation and 14-file retention - Replace all console.* calls with structured pino logging - Wire stderr events to logger.debug - Add LOG_LEVEL env var support to config schema Made-with: Cursor --- package-lock.json | 153 ++++++++++++++++++++++++++++++++++ package.json | 2 + src/runner/config.ts | 2 + src/runner/index.ts | 11 +-- src/runner/logger.ts | 20 +++++ src/runner/session-manager.ts | 13 ++- src/runner/slack-bot.ts | 3 +- 7 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 src/runner/logger.ts diff --git a/package-lock.json b/package-lock.json index e91b847a..5e85ae60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@slack/bolt": "^4.6.0", "@toon-format/toon": "^2.1.0", "dotenv": "^17.3.1", + "pino": "^10.3.1", + "pino-roll": "^4.0.0", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.4" }, @@ -537,6 +539,12 @@ } } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -1329,6 +1337,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", @@ -1536,6 +1553,16 @@ "node": ">= 8" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2562,6 +2589,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2723,6 +2759,53 @@ "dev": true, "license": "ISC" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-roll": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pino-roll/-/pino-roll-4.0.0.tgz", + "integrity": "sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg==", + "license": "MIT", + "dependencies": { + "date-fns": "^4.1.0", + "sonic-boom": "^4.0.1" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2795,6 +2878,22 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2829,6 +2928,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2860,6 +2965,15 @@ "dev": true, "license": "MIT" }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2969,6 +3083,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3151,6 +3274,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3161,6 +3293,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3210,6 +3351,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index d066c9e8..ac0e05d7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "@slack/bolt": "^4.6.0", "@toon-format/toon": "^2.1.0", "dotenv": "^17.3.1", + "pino": "^10.3.1", + "pino-roll": "^4.0.0", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.4" } diff --git a/src/runner/config.ts b/src/runner/config.ts index 64fb04a8..30f63485 100644 --- a/src/runner/config.ts +++ b/src/runner/config.ts @@ -29,6 +29,7 @@ const RunnerConfigSchema = z.object({ worktreeBaseDir: z.string().min(1), }), mcpServers: z.record(McpServerConfigSchema).default({}), + logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), }); export type RunnerConfig = z.infer; @@ -55,6 +56,7 @@ export function loadRunnerConfig(): RunnerConfig { worktreeBaseDir: process.env['WORKTREE_BASE_DIR'] ?? path.join(os.homedir(), 'worktrees'), }, mcpServers: parseMcpServers(), + logLevel: process.env['LOG_LEVEL'] as RunnerConfig['logLevel'], }); } diff --git a/src/runner/index.ts b/src/runner/index.ts index a2f46310..4a6e3943 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -1,26 +1,27 @@ import 'dotenv/config'; import { WebClient } from '@slack/web-api'; import { loadRunnerConfig } from './config.js'; +import { logger } from './logger.js'; import { SessionManager } from './session-manager.js'; import { createSlackApp } from './slack-bot.js'; async function main(): Promise { const config = loadRunnerConfig(); - console.log('Runner config loaded', { + logger.info({ repo: config.repo.path, worktreeBase: config.repo.worktreeBaseDir, mcpServers: Object.keys(config.mcpServers), - }); + }, 'Runner config loaded'); const slackClient = new WebClient(config.slack.botToken); const sessionManager = new SessionManager(config, slackClient); const app = createSlackApp(config, sessionManager); await app.start(); - console.log('Workflow Runner is listening (Socket Mode)'); + logger.info('Workflow Runner is listening (Socket Mode)'); const shutdown = async () => { - console.log('Shutting down...'); + logger.info('Shutting down...'); await sessionManager.shutdownAll(); await app.stop(); process.exit(0); @@ -31,6 +32,6 @@ async function main(): Promise { } main().catch((err) => { - console.error('Fatal error:', err); + logger.fatal({ err }, 'Fatal error'); process.exit(1); }); diff --git a/src/runner/logger.ts b/src/runner/logger.ts new file mode 100644 index 00000000..2a6e462c --- /dev/null +++ b/src/runner/logger.ts @@ -0,0 +1,20 @@ +import pino from 'pino'; +import type { Logger } from 'pino'; + +const LOG_LEVEL = process.env['LOG_LEVEL'] ?? 'info'; + +const transport = pino.transport({ + target: 'pino-roll', + options: { + file: 'logs/runner', + frequency: 'daily', + limit: { count: 14 }, + mkdir: true, + }, +}); + +export const logger: Logger = pino({ level: LOG_LEVEL }, transport); + +export function createChildLogger(context: Record): Logger { + return logger.child(context); +} diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts index 7da26154..cbbfe078 100644 --- a/src/runner/session-manager.ts +++ b/src/runner/session-manager.ts @@ -1,6 +1,7 @@ import type { WebClient } from '@slack/web-api'; import { AcpClient, type AcpSessionUpdate } from './acp-client.js'; import { CheckpointBridge } from './checkpoint-bridge.js'; +import { createChildLogger } from './logger.js'; import { WorktreeManager, type WorktreeInfo } from './worktree-manager.js'; import type { RunnerConfig } from './config.js'; @@ -208,11 +209,13 @@ export class SessionManager { }); acp.on('stderr', (text) => { - console.error(`[session ${session.id}] agent stderr: ${text}`); + const log = createChildLogger({ sessionId: session.id }); + log.debug({ text }, 'agent stderr'); }); acp.on('error', async (err) => { - console.error(`[session ${session.id}] ACP error:`, err.message); + const log = createChildLogger({ sessionId: session.id }); + log.error({ err: err.message }, 'ACP error'); }); acp.on('close', async (code) => { @@ -264,7 +267,8 @@ export class SessionManager { try { await this.worktreeManager.cleanup(session.worktree); } catch (err) { - console.error(`[session ${session.id}] Worktree cleanup failed:`, err); + const log = createChildLogger({ sessionId: session.id }); + log.error({ err }, 'Worktree cleanup failed'); } } } @@ -303,7 +307,8 @@ export class SessionManager { text, }); } catch (err) { - console.error(`[session ${session.id}] Failed to post to Slack:`, err); + const log = createChildLogger({ sessionId: session.id }); + log.error({ err }, 'Failed to post to Slack'); } } diff --git a/src/runner/slack-bot.ts b/src/runner/slack-bot.ts index 18cf18ff..f74b3cb3 100644 --- a/src/runner/slack-bot.ts +++ b/src/runner/slack-bot.ts @@ -1,4 +1,5 @@ import { App, type SlackCommandMiddlewareArgs, type AllMiddlewareArgs } from '@slack/bolt'; +import { logger } from './logger.js'; import type { SessionManager } from './session-manager.js'; import type { RunnerConfig } from './config.js'; @@ -69,7 +70,7 @@ export function createSlackApp( if (!resolved) { // Could be a stale button click — no action needed - console.warn(`Unresolved checkpoint action: ${actionId} in ${channel}:${threadTs}`); + logger.warn({ actionId, channel, threadTs }, 'Unresolved checkpoint action'); } }); From cceefbf92ba2a7fdda5338bfac495facc1d6d6ff Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 12:03:38 +0000 Subject: [PATCH 07/18] feat: add SQLite session persistence via node:sqlite - New SessionStore class with sessions table (create/load/update/close) - SessionManager accepts optional store, persists session lifecycle - Stale sessions from previous runs are logged and marked as errored - Add DB_PATH env var to config schema (default: data/runner.db) Made-with: Cursor --- .gitignore | 3 + src/runner/config.ts | 2 + src/runner/index.ts | 7 ++- src/runner/session-manager.ts | 31 +++++++++++ src/runner/session-store.ts | 102 ++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/runner/session-store.ts diff --git a/.gitignore b/.gitignore index b4bd6092..c8eafee9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ Thumbs.db *.log logs/ +# Database +data/ + # Test coverage coverage/ diff --git a/src/runner/config.ts b/src/runner/config.ts index 30f63485..d33faea9 100644 --- a/src/runner/config.ts +++ b/src/runner/config.ts @@ -30,6 +30,7 @@ const RunnerConfigSchema = z.object({ }), mcpServers: z.record(McpServerConfigSchema).default({}), logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), + dbPath: z.string().optional(), }); export type RunnerConfig = z.infer; @@ -57,6 +58,7 @@ export function loadRunnerConfig(): RunnerConfig { }, mcpServers: parseMcpServers(), logLevel: process.env['LOG_LEVEL'] as RunnerConfig['logLevel'], + dbPath: process.env['DB_PATH'], }); } diff --git a/src/runner/index.ts b/src/runner/index.ts index 4a6e3943..0f27e33e 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -3,6 +3,7 @@ import { WebClient } from '@slack/web-api'; import { loadRunnerConfig } from './config.js'; import { logger } from './logger.js'; import { SessionManager } from './session-manager.js'; +import { SessionStore } from './session-store.js'; import { createSlackApp } from './slack-bot.js'; async function main(): Promise { @@ -13,8 +14,11 @@ async function main(): Promise { mcpServers: Object.keys(config.mcpServers), }, 'Runner config loaded'); + const store = new SessionStore(); + store.open(config.dbPath ?? 'data/runner.db'); + const slackClient = new WebClient(config.slack.botToken); - const sessionManager = new SessionManager(config, slackClient); + const sessionManager = new SessionManager(config, slackClient, store); const app = createSlackApp(config, sessionManager); await app.start(); @@ -23,6 +27,7 @@ async function main(): Promise { const shutdown = async () => { logger.info('Shutting down...'); await sessionManager.shutdownAll(); + store.close(); await app.stop(); process.exit(0); }; diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts index cbbfe078..9c239454 100644 --- a/src/runner/session-manager.ts +++ b/src/runner/session-manager.ts @@ -2,6 +2,7 @@ import type { WebClient } from '@slack/web-api'; import { AcpClient, type AcpSessionUpdate } from './acp-client.js'; import { CheckpointBridge } from './checkpoint-bridge.js'; import { createChildLogger } from './logger.js'; +import type { SessionStore } from './session-store.js'; import { WorktreeManager, type WorktreeInfo } from './worktree-manager.js'; import type { RunnerConfig } from './config.js'; @@ -49,13 +50,28 @@ export class SessionManager { private worktreeManager: WorktreeManager; private checkpointBridge: CheckpointBridge; + private store: SessionStore | undefined; constructor( private readonly config: RunnerConfig, private readonly slackClient: WebClient, + store?: SessionStore, ) { this.worktreeManager = new WorktreeManager(config.repo.path, config.repo.worktreeBaseDir); this.checkpointBridge = new CheckpointBridge(slackClient); + this.store = store; + + if (store) { + const log = createChildLogger({ component: 'SessionManager' }); + const stale = store.loadActive(); + if (stale.length > 0) { + log.info({ count: stale.length, ids: stale.map((s) => s.id) }, + 'Found previously-active sessions (not re-attached)'); + for (const row of stale) { + store.updateStatus(row.id, 'error', 'Stale session from previous run'); + } + } + } } /** @@ -90,6 +106,18 @@ export class SessionManager { this.sessions.set(id, session); this.threadToSession.set(`${slackChannel}:${slackThreadTs}`, id); + this.store?.save({ + id, + workflowId, + targetSubmodule, + issueRef, + slackChannel, + slackThreadTs, + status: session.status, + worktreePath: undefined, + createdAt: session.createdAt, + }); + try { await this.postStatus(session, `Creating worktree for \`${targetSubmodule}\`...`); @@ -97,6 +125,7 @@ export class SessionManager { id, 'main', targetSubmodule, this.config.mcpServers as Record, ); session.status = 'running'; + this.store?.updateStatus(id, 'running'); const acp = new AcpClient(this.config.cursor.agentBinary, this.config.cursor.apiKey); session.acpClient = acp; @@ -235,6 +264,7 @@ export class SessionManager { private async handleCompletion(session: WorkflowSession, result: unknown): Promise { session.status = 'completed'; session.completedAt = Date.now(); + this.store?.updateStatus(session.id, 'completed'); this.stopUpdateTimer(session); await this.flushPendingText(session); @@ -253,6 +283,7 @@ export class SessionManager { session.status = 'error'; session.error = err.message; session.completedAt = Date.now(); + this.store?.updateStatus(session.id, 'error', err.message); this.stopUpdateTimer(session); await this.flushPendingText(session); this.checkpointBridge.cancelAll(session.slackChannel, session.slackThreadTs); diff --git a/src/runner/session-store.ts b/src/runner/session-store.ts new file mode 100644 index 00000000..3c183ed9 --- /dev/null +++ b/src/runner/session-store.ts @@ -0,0 +1,102 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { SessionStatus } from './session-manager.js'; + +export interface SessionRow { + id: string; + workflow_id: string; + target_submodule: string; + issue_ref: string | null; + slack_channel: string; + slack_thread_ts: string; + status: string; + worktree_path: string | null; + created_at: number; + completed_at: number | null; + error: string | null; +} + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + target_submodule TEXT NOT NULL, + issue_ref TEXT, + slack_channel TEXT NOT NULL, + slack_thread_ts TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'creating', + worktree_path TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER, + error TEXT +)`; + +export class SessionStore { + private db: DatabaseSync | null = null; + + open(dbPath: string): void { + mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new DatabaseSync(dbPath); + this.db.exec(SCHEMA); + } + + save(session: { + id: string; + workflowId: string; + targetSubmodule: string; + issueRef: string | undefined; + slackChannel: string; + slackThreadTs: string; + status: SessionStatus; + worktreePath: string | undefined; + createdAt: number; + }): void { + const stmt = this.requireDb().prepare(` + INSERT OR REPLACE INTO sessions + (id, workflow_id, target_submodule, issue_ref, slack_channel, slack_thread_ts, status, worktree_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + session.id, + session.workflowId, + session.targetSubmodule, + session.issueRef ?? null, + session.slackChannel, + session.slackThreadTs, + session.status, + session.worktreePath ?? null, + session.createdAt, + ); + } + + load(id: string): SessionRow | undefined { + const stmt = this.requireDb().prepare('SELECT * FROM sessions WHERE id = ?'); + return stmt.get(id) as SessionRow | undefined; + } + + loadActive(): SessionRow[] { + const stmt = this.requireDb().prepare( + "SELECT * FROM sessions WHERE status IN ('creating', 'running', 'awaiting_checkpoint')", + ); + return stmt.all() as SessionRow[]; + } + + updateStatus(id: string, status: SessionStatus, error?: string): void { + const completedAt = (status === 'completed' || status === 'error') ? Date.now() : null; + const stmt = this.requireDb().prepare( + 'UPDATE sessions SET status = ?, error = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?', + ); + stmt.run(status, error ?? null, completedAt, id); + } + + close(): void { + this.db?.close(); + this.db = null; + } + + private requireDb(): DatabaseSync { + if (!this.db) throw new Error('SessionStore is not open'); + return this.db; + } +} From 5e3bde28bbd9e67ea6564ebaefcba9f68fc78bce Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 12:04:46 +0000 Subject: [PATCH 08/18] feat: rename worktree prefix to wf-runner- and add orphan sweep - Rename directory prefix from run- to wf-runner- for disambiguation - Rename branch prefix from runner/ to wf-runner/ - Add sweepOrphaned() method that removes stale worktrees on startup - Update tests for new prefix Made-with: Cursor --- src/runner/index.ts | 7 +++++ src/runner/worktree-manager.ts | 39 +++++++++++++++++++++++++-- tests/runner/worktree-manager.test.ts | 20 +++++++------- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/runner/index.ts b/src/runner/index.ts index 0f27e33e..b0ca6fdc 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -5,6 +5,7 @@ import { logger } from './logger.js'; import { SessionManager } from './session-manager.js'; import { SessionStore } from './session-store.js'; import { createSlackApp } from './slack-bot.js'; +import { WorktreeManager } from './worktree-manager.js'; async function main(): Promise { const config = loadRunnerConfig(); @@ -17,6 +18,12 @@ async function main(): Promise { const store = new SessionStore(); store.open(config.dbPath ?? 'data/runner.db'); + const worktreeManager = new WorktreeManager(config.repo.path, config.repo.worktreeBaseDir); + const swept = await worktreeManager.sweepOrphaned(); + if (swept > 0) { + logger.info({ swept }, 'Swept orphaned worktrees'); + } + const slackClient = new WebClient(config.slack.botToken); const sessionManager = new SessionManager(config, slackClient, store); const app = createSlackApp(config, sessionManager); diff --git a/src/runner/worktree-manager.ts b/src/runner/worktree-manager.ts index 3863f1bb..af6628e6 100644 --- a/src/runner/worktree-manager.ts +++ b/src/runner/worktree-manager.ts @@ -53,8 +53,8 @@ export class WorktreeManager { ): Promise { await mkdir(this.baseDir, { recursive: true }); - const worktreePath = path.join(this.baseDir, `run-${runId}`); - const branchName = `runner/${runId}`; + const worktreePath = path.join(this.baseDir, `wf-runner-${runId}`); + const branchName = `wf-runner/${runId}`; await exec('git', ['worktree', 'add', worktreePath, '-b', branchName, baseBranch], { cwd: this.repoPath, @@ -92,6 +92,41 @@ export class WorktreeManager { } } + /** + * Remove orphaned worktrees left over from previous runs. + * Returns the number of worktrees swept. + */ + async sweepOrphaned(): Promise { + const { stdout } = await exec('git', ['worktree', 'list', '--porcelain'], { + cwd: this.repoPath, + }); + + const worktreePaths = stdout + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length)) + .filter((p) => path.basename(p).startsWith('wf-runner-')); + + let swept = 0; + for (const wtPath of worktreePaths) { + try { + await exec('git', ['worktree', 'remove', wtPath, '--force'], { + cwd: this.repoPath, + }); + swept++; + } catch { + await rm(wtPath, { recursive: true, force: true }); + swept++; + } + } + + if (swept > 0) { + await exec('git', ['worktree', 'prune'], { cwd: this.repoPath }); + } + + return swept; + } + /** * Place .cursor/mcp.json and .cursor/cli.json in the worktree so the * Cursor agent picks up MCP servers and auto-approved permissions. diff --git a/tests/runner/worktree-manager.test.ts b/tests/runner/worktree-manager.test.ts index a2622956..4150d430 100644 --- a/tests/runner/worktree-manager.test.ts +++ b/tests/runner/worktree-manager.test.ts @@ -35,8 +35,8 @@ describe('WorktreeManager', () => { const info = await manager.create('abc123', 'main', 'midnight-node', {}); expect(info.runId).toBe('abc123'); - expect(info.path).toBe('/tmp/worktrees/run-abc123'); - expect(info.branch).toBe('runner/abc123'); + expect(info.path).toBe('/tmp/worktrees/wf-runner-abc123'); + expect(info.branch).toBe('wf-runner/abc123'); expect(info.targetSubmodule).toBe('midnight-node'); const calls = mockExecFile.mock.calls; @@ -44,18 +44,18 @@ describe('WorktreeManager', () => { // First call: git worktree add expect(calls[0]![0]).toBe('git'); expect(calls[0]![1]).toEqual( - ['worktree', 'add', '/tmp/worktrees/run-abc123', '-b', 'runner/abc123', 'main'], + ['worktree', 'add', '/tmp/worktrees/wf-runner-abc123', '-b', 'wf-runner/abc123', 'main'], ); expect((calls[0]![2] as any).cwd).toBe('/repo'); // Second call: git submodule update --init expect(calls[1]![0]).toBe('git'); expect(calls[1]![1]).toEqual(['submodule', 'update', '--init', 'midnight-node']); - expect((calls[1]![2] as any).cwd).toBe('/tmp/worktrees/run-abc123'); + expect((calls[1]![2] as any).cwd).toBe('/tmp/worktrees/wf-runner-abc123'); // .cursor/cli.json was written expect(vi.mocked(writeFile)).toHaveBeenCalledWith( - '/tmp/worktrees/run-abc123/.cursor/cli.json', + '/tmp/worktrees/wf-runner-abc123/.cursor/cli.json', expect.stringContaining('permissions'), ); }); @@ -70,7 +70,7 @@ describe('WorktreeManager', () => { }); expect(vi.mocked(writeFile)).toHaveBeenCalledWith( - '/tmp/worktrees/run-def456/.cursor/mcp.json', + '/tmp/worktrees/wf-runner-def456/.cursor/mcp.json', expect.stringContaining('workflow-server'), ); }); @@ -86,8 +86,8 @@ describe('WorktreeManager', () => { it('should cleanup worktree and branch', async () => { await manager.cleanup({ runId: 'abc123', - path: '/tmp/worktrees/run-abc123', - branch: 'runner/abc123', + path: '/tmp/worktrees/wf-runner-abc123', + branch: 'wf-runner/abc123', targetSubmodule: 'midnight-node', }); @@ -96,13 +96,13 @@ describe('WorktreeManager', () => { // git worktree remove expect(calls[0]![0]).toBe('git'); expect(calls[0]![1]).toEqual( - ['worktree', 'remove', '/tmp/worktrees/run-abc123', '--force'], + ['worktree', 'remove', '/tmp/worktrees/wf-runner-abc123', '--force'], ); expect((calls[0]![2] as any).cwd).toBe('/repo'); // git branch -D expect(calls[1]![0]).toBe('git'); - expect(calls[1]![1]).toEqual(['branch', '-D', 'runner/abc123']); + expect(calls[1]![1]).toEqual(['branch', '-D', 'wf-runner/abc123']); expect((calls[1]![2] as any).cwd).toBe('/repo'); }); }); From 5a9a9c8b42acfd3d3f0485fc8c0b984176453956 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 13:46:42 +0000 Subject: [PATCH 09/18] fix: address high-severity code review findings (H1-H3) H1: Log warnings when auto-approving permission requests for tools not in the known APPROVED_TOOL_TYPES set (session-manager.ts). H2: Add configurable timeout (default 60s) to JSON-RPC send() method. Pending promises are rejected with a descriptive error on timeout. Long-running prompt/followUp calls opt out with timeout=0. H3: Validate submodule paths against path traversal (worktree-manager.ts) and validate slash command arguments against allowlisted patterns (slack-bot.ts). Made-with: Cursor --- src/runner/acp-client.ts | 32 ++++++++++++++++++++++++++++---- src/runner/session-manager.ts | 19 ++++++++++++++++--- src/runner/slack-bot.ts | 19 +++++++++++++++++++ src/runner/worktree-manager.ts | 19 +++++++++++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/runner/acp-client.ts b/src/runner/acp-client.ts index 55acdf0d..51dc3827 100644 --- a/src/runner/acp-client.ts +++ b/src/runner/acp-client.ts @@ -89,6 +89,8 @@ export interface PromptResult { // ACP Client // --------------------------------------------------------------------------- +const DEFAULT_SEND_TIMEOUT_MS = 60_000; + export class AcpClient extends EventEmitter { private process: ChildProcess | null = null; private nextId = 1; @@ -101,6 +103,7 @@ export class AcpClient extends EventEmitter { constructor( private readonly agentBinary: string, private readonly apiKey: string, + private readonly defaultTimeoutMs: number = DEFAULT_SEND_TIMEOUT_MS, ) { super(); } @@ -197,7 +200,7 @@ export class AcpClient extends EventEmitter { return await this.send('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }], - }) as PromptResult; + }, 0) as PromptResult; // 0 = no timeout for long-running prompts } /** @@ -208,7 +211,7 @@ export class AcpClient extends EventEmitter { return await this.send('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }], - }); + }, 0); // 0 = no timeout for long-running prompts } /** @@ -233,10 +236,31 @@ export class AcpClient extends EventEmitter { // Internal // ----------------------------------------------------------------------- - private send(method: string, params?: Record): Promise { + private send( + method: string, + params?: Record, + timeoutMs: number = this.defaultTimeoutMs, + ): Promise { return new Promise((resolve, reject) => { const id = this.nextId++; - this.pending.set(id, { resolve, reject }); + let timer: ReturnType | undefined; + + this.pending.set(id, { + resolve: (value) => { if (timer) clearTimeout(timer); resolve(value); }, + reject: (err) => { if (timer) clearTimeout(timer); reject(err); }, + }); + + if (timeoutMs > 0) { + timer = setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id); + reject(new Error( + `RPC request '${method}' (id=${id}) timed out after ${timeoutMs}ms`, + )); + } + }, timeoutMs); + } + this.write({ jsonrpc: '2.0', id, method, params }); }); } diff --git a/src/runner/session-manager.ts b/src/runner/session-manager.ts index 9c239454..8a7ce628 100644 --- a/src/runner/session-manager.ts +++ b/src/runner/session-manager.ts @@ -43,6 +43,16 @@ export interface WorkflowSession { const STATUS_POST_INTERVAL_MS = 5_000; const MAX_SLACK_TEXT_LENGTH = 3_000; +/** + * Tool types expected during headless operation. Mirrors the permission + * categories granted via AUTO_APPROVE_PERMISSIONS in worktree-manager.ts. + * Tools outside this set are still auto-approved (headless mode cannot + * prompt a human), but a warning is logged for audit visibility. + */ +const APPROVED_TOOL_TYPES = new Set([ + 'shell', 'read', 'write', 'edit', 'mcp', 'web_fetch', +]); + export class SessionManager { private sessions = new Map(); /** Reverse lookup: Slack thread → session ID */ @@ -218,9 +228,12 @@ export class SessionManager { ); }); - acp.on('request_permission', (requestId, _params) => { - // Auto-approve all permission requests in PoC. - // The .cursor/cli.json allowlist handles most cases; this catches stragglers. + acp.on('request_permission', (requestId, params) => { + const tool = String(params['tool'] ?? 'unknown'); + if (!APPROVED_TOOL_TYPES.has(tool.toLowerCase())) { + const log = createChildLogger({ sessionId: session.id }); + log.warn({ tool, params }, 'Auto-approving permission for unlisted tool type'); + } acp.respond(requestId, { outcome: { outcome: 'selected', optionId: 'allow-always' }, }); diff --git a/src/runner/slack-bot.ts b/src/runner/slack-bot.ts index f74b3cb3..74c59ed6 100644 --- a/src/runner/slack-bot.ts +++ b/src/runner/slack-bot.ts @@ -81,6 +81,10 @@ export function createSlackApp( // Subcommand handlers // ------------------------------------------------------------------------- +const WORKFLOW_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; +const SUBMODULE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/; +const ISSUE_REF_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*[-#]?\d*$/; + async function handleStart( args: string[], channel: string, @@ -95,8 +99,23 @@ async function handleStart( return; } + if (!WORKFLOW_ID_PATTERN.test(workflowId)) { + await say('Invalid workflow ID. Use alphanumeric characters, hyphens, dots, and underscores.'); + return; + } + + if (!SUBMODULE_PATTERN.test(targetSubmodule)) { + await say('Invalid target submodule. Use alphanumeric characters, hyphens, dots, underscores, and forward slashes.'); + return; + } + const issueRef = args[2]; + if (issueRef && !ISSUE_REF_PATTERN.test(issueRef)) { + await say('Invalid issue reference format.'); + return; + } + // Post an initial message and use its timestamp as the thread root const result = await say( `Starting workflow \`${workflowId}\` targeting \`${targetSubmodule}\`` + diff --git a/src/runner/worktree-manager.ts b/src/runner/worktree-manager.ts index af6628e6..cd35c35d 100644 --- a/src/runner/worktree-manager.ts +++ b/src/runner/worktree-manager.ts @@ -51,6 +51,10 @@ export class WorktreeManager { targetSubmodule?: string, mcpServers: Record = {}, ): Promise { + if (targetSubmodule) { + this.validateSubmodulePath(targetSubmodule); + } + await mkdir(this.baseDir, { recursive: true }); const worktreePath = path.join(this.baseDir, `wf-runner-${runId}`); @@ -127,6 +131,21 @@ export class WorktreeManager { return swept; } + private validateSubmodulePath(submodule: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/.test(submodule)) { + throw new Error( + `Invalid submodule path '${submodule}': only alphanumeric characters, dots, hyphens, underscores, and forward slashes are allowed`, + ); + } + + const resolved = path.resolve(this.repoPath, submodule); + if (!resolved.startsWith(this.repoPath + path.sep)) { + throw new Error( + `Invalid submodule path '${submodule}': resolves outside the repository root`, + ); + } + } + /** * Place .cursor/mcp.json and .cursor/cli.json in the worktree so the * Cursor agent picks up MCP servers and auto-approved permissions. From 141f0841b7bc87c7377e7c2201df154a912875e3 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 14:12:38 +0000 Subject: [PATCH 10/18] test: add session-store and session-manager unit tests Made-with: Cursor --- tests/runner/session-manager.test.ts | 305 +++++++++++++++++++++++++++ tests/runner/session-store.test.ts | 211 ++++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 tests/runner/session-manager.test.ts create mode 100644 tests/runner/session-store.test.ts diff --git a/tests/runner/session-manager.test.ts b/tests/runner/session-manager.test.ts new file mode 100644 index 00000000..3bcbe3a4 --- /dev/null +++ b/tests/runner/session-manager.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { SessionManager } from '../../src/runner/session-manager.js'; +import type { RunnerConfig } from '../../src/runner/config.js'; + +// --------------------------------------------------------------------------- +// Module-level mock instance handles +// --------------------------------------------------------------------------- + +let mockAcpInstance: any; +let mockWorktreeInstance: any; +let mockCheckpointInstance: any; +let promptDeferred: { resolve: (v: unknown) => void; reject: (e: Error) => void }; + +vi.mock('../../src/runner/acp-client.js', () => ({ + AcpClient: vi.fn().mockImplementation(() => { + let res: (v: unknown) => void; + let rej: (e: Error) => void; + const promise = new Promise((r, j) => { res = r; rej = j; }); + promptDeferred = { resolve: res!, reject: rej! }; + + mockAcpInstance = Object.assign(new EventEmitter(), { + spawn: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + authenticate: vi.fn().mockResolvedValue(undefined), + createSession: vi.fn().mockResolvedValue('sess-123'), + prompt: vi.fn().mockReturnValue(promise), + respond: vi.fn(), + kill: vi.fn(), + pid: 12345, + active: true, + }); + return mockAcpInstance; + }), +})); + +vi.mock('../../src/runner/worktree-manager.js', () => ({ + WorktreeManager: vi.fn().mockImplementation(() => { + mockWorktreeInstance = { + create: vi.fn().mockResolvedValue({ + runId: 'test-run', + path: '/tmp/worktrees/wf-runner-test-run', + branch: 'wf-runner/test-run', + targetSubmodule: 'midnight-node', + }), + cleanup: vi.fn().mockResolvedValue(undefined), + sweepOrphaned: vi.fn().mockResolvedValue(0), + }; + return mockWorktreeInstance; + }), +})); + +vi.mock('../../src/runner/checkpoint-bridge.js', () => ({ + CheckpointBridge: vi.fn().mockImplementation(() => { + mockCheckpointInstance = { + presentCheckpoint: vi.fn().mockResolvedValue(undefined), + resolveCheckpoint: vi.fn().mockReturnValue(true), + hasPending: vi.fn().mockReturnValue(false), + cancelAll: vi.fn(), + }; + return mockCheckpointInstance; + }), +})); + +vi.mock('../../src/runner/logger.js', () => ({ + createChildLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const tick = () => new Promise((r) => setTimeout(r, 10)); + +const MOCK_CONFIG: RunnerConfig = { + slack: { + botToken: 'xoxb-test', + signingSecret: 'test-secret', + appToken: 'xapp-test', + }, + cursor: { + apiKey: 'test-key', + agentBinary: 'agent', + }, + repo: { + path: '/repo', + worktreeBaseDir: '/tmp/worktrees', + }, + mcpServers: {}, +}; + +function createMockSlackClient() { + return { + chat: { + postMessage: vi.fn().mockResolvedValue({ ok: true, ts: '1234.5678' }), + }, + }; +} + +function createMockStore() { + return { + open: vi.fn(), + save: vi.fn(), + load: vi.fn(), + loadActive: vi.fn().mockReturnValue([]), + updateStatus: vi.fn(), + close: vi.fn(), + }; +} + +function clearSessionTimers(manager: SessionManager): void { + const sessions = (manager as any).sessions as Map; + for (const session of sessions.values()) { + if (session.updateTimer) { + clearInterval(session.updateTimer); + session.updateTimer = null; + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SessionManager', () => { + let manager: SessionManager; + let slackClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + slackClient = createMockSlackClient(); + manager = new SessionManager(MOCK_CONFIG, slackClient as any); + }); + + afterEach(async () => { + clearSessionTimers(manager); + await manager.shutdownAll(); + }); + + it('should start a workflow and return a running session', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', 'PM-123', 'C123', '1234.5678', + ); + + expect(session.status).toBe('running'); + expect(session.workflowId).toBe('work-package'); + expect(session.targetSubmodule).toBe('midnight-node'); + expect(session.issueRef).toBe('PM-123'); + expect(session.worktree).toBeDefined(); + expect(session.acpClient).toBeDefined(); + + expect(mockWorktreeInstance.create).toHaveBeenCalledOnce(); + expect(mockAcpInstance.spawn).toHaveBeenCalledOnce(); + expect(mockAcpInstance.initialize).toHaveBeenCalledOnce(); + expect(mockAcpInstance.authenticate).toHaveBeenCalledOnce(); + expect(mockAcpInstance.createSession).toHaveBeenCalledOnce(); + expect(mockAcpInstance.prompt).toHaveBeenCalledOnce(); + }); + + it('should post Slack status messages during startup', async () => { + await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + expect(slackClient.chat.postMessage).toHaveBeenCalled(); + const texts = slackClient.chat.postMessage.mock.calls.map( + (c: any[]) => c[0]?.text as string, + ); + expect(texts.some((t) => t.includes('Creating worktree'))).toBe(true); + expect(texts.some((t) => t.includes('started'))).toBe(true); + }); + + it('should look up session by Slack thread', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + expect(manager.getByThread('C123', '1234.5678')).toBe(session); + expect(manager.getByThread('C999', '0000.0000')).toBeUndefined(); + }); + + it('should list active sessions', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + const active = manager.listActive(); + expect(active).toHaveLength(1); + expect(active[0]!.id).toBe(session.id); + }); + + it('should forward checkpoint responses to the bridge', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + session.status = 'awaiting_checkpoint'; + + const resolved = manager.handleCheckpointResponse( + 'C123', '1234.5678', 'checkpoint_q1_yes', + ); + + expect(resolved).toBe(true); + expect(mockCheckpointInstance.resolveCheckpoint).toHaveBeenCalledWith( + 'C123', '1234.5678', 'checkpoint_q1_yes', session.acpClient, + ); + expect(session.status).toBe('running'); + }); + + it('should return false for checkpoint response without matching session', () => { + expect(manager.handleCheckpointResponse('C999', '0000', 'x')).toBe(false); + }); + + it('should shut down all active sessions', async () => { + await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + clearSessionTimers(manager); + await manager.shutdownAll(); + + expect(mockAcpInstance.kill).toHaveBeenCalled(); + expect(mockWorktreeInstance.cleanup).toHaveBeenCalled(); + expect(manager.listActive()).toHaveLength(0); + }); + + it('should handle ACP close event by setting error status', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + mockAcpInstance.emit('close', 1); + await tick(); + + expect(session.status).toBe('error'); + expect(session.error).toContain('exited unexpectedly'); + expect(mockCheckpointInstance.cancelAll).toHaveBeenCalledWith('C123', '1234.5678'); + }); + + it('should handle prompt completion', async () => { + const session = await manager.startWorkflow( + 'work-package', 'midnight-node', undefined, 'C123', '1234.5678', + ); + + promptDeferred.resolve({ stopReason: 'end_turn' }); + await tick(); + + expect(session.status).toBe('completed'); + expect(session.completedAt).toBeGreaterThan(0); + }); + + it('should mark stale sessions as error on construction with store', () => { + const store = createMockStore(); + store.loadActive.mockReturnValue([ + { id: 'stale-1', status: 'running' }, + { id: 'stale-2', status: 'creating' }, + ]); + + const mgr = new SessionManager(MOCK_CONFIG, slackClient as any, store as any); + + expect(store.updateStatus).toHaveBeenCalledWith( + 'stale-1', 'error', 'Stale session from previous run', + ); + expect(store.updateStatus).toHaveBeenCalledWith( + 'stale-2', 'error', 'Stale session from previous run', + ); + + // no sessions were added to this manager, shutdown is a no-op + void mgr.shutdownAll(); + }); + + it('should persist session to store when provided', async () => { + const store = createMockStore(); + const mgr = new SessionManager(MOCK_CONFIG, slackClient as any, store as any); + + const session = await mgr.startWorkflow( + 'work-package', 'midnight-node', 'PM-123', 'C123', '1234.5678', + ); + + expect(store.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: session.id, + workflowId: 'work-package', + targetSubmodule: 'midnight-node', + issueRef: 'PM-123', + status: 'creating', + }), + ); + expect(store.updateStatus).toHaveBeenCalledWith(session.id, 'running'); + + clearSessionTimers(mgr); + await mgr.shutdownAll(); + }); +}); diff --git a/tests/runner/session-store.test.ts b/tests/runner/session-store.test.ts new file mode 100644 index 00000000..3a51179d --- /dev/null +++ b/tests/runner/session-store.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock node:sqlite — vitest hoists vi.mock, so all classes must be inline. +// --------------------------------------------------------------------------- + +vi.mock('node:sqlite', () => { + type Row = Record; + + class MockStatement { + constructor( + private rows: Map, + private sql: string, + ) {} + + run(...params: unknown[]): void { + if (this.sql.includes('INSERT OR REPLACE')) { + const row: Row = { + id: params[0], + workflow_id: params[1], + target_submodule: params[2], + issue_ref: params[3], + slack_channel: params[4], + slack_thread_ts: params[5], + status: params[6], + worktree_path: params[7], + created_at: params[8], + completed_at: null, + error: null, + }; + this.rows.set(params[0] as string, row); + } else if (this.sql.includes('UPDATE sessions SET')) { + const id = params[3] as string; + const row = this.rows.get(id); + if (row) { + row.status = params[0]; + row.error = params[1]; + if (params[2] !== null) { + row.completed_at = params[2]; + } + } + } + } + + get(...params: unknown[]): Row | undefined { + return this.rows.get(params[0] as string); + } + + all(): Row[] { + const activeStatuses = ['creating', 'running', 'awaiting_checkpoint']; + if (this.sql.includes('status IN')) { + return [...this.rows.values()].filter( + (r) => activeStatuses.includes(r.status as string), + ); + } + return [...this.rows.values()]; + } + } + + class MockDatabaseSync { + private rows = new Map(); + private _closed = false; + + exec(_sql: string): void {} + + prepare(sql: string): MockStatement { + if (this._closed) throw new Error('Database is closed'); + return new MockStatement(this.rows, sql); + } + + close(): void { + this._closed = true; + } + } + + return { DatabaseSync: MockDatabaseSync }; +}); + +vi.mock('node:fs', () => ({ + mkdirSync: vi.fn(), +})); + +import { SessionStore } from '../../src/runner/session-store.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSampleSession(overrides: Partial<{ + id: string; + workflowId: string; + targetSubmodule: string; + issueRef: string | undefined; + slackChannel: string; + slackThreadTs: string; + status: string; + worktreePath: string | undefined; + createdAt: number; +}> = {}) { + return { + id: overrides.id ?? 'test-001', + workflowId: overrides.workflowId ?? 'work-package', + targetSubmodule: overrides.targetSubmodule ?? 'midnight-node', + issueRef: ('issueRef' in overrides ? overrides.issueRef : 'PM-123') as string | undefined, + slackChannel: overrides.slackChannel ?? 'C123', + slackThreadTs: overrides.slackThreadTs ?? '1234.5678', + status: (overrides.status ?? 'creating') as any, + worktreePath: overrides.worktreePath as string | undefined, + createdAt: overrides.createdAt ?? 1709600000000, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SessionStore', () => { + let store: SessionStore; + + beforeEach(() => { + store = new SessionStore(); + store.open('/tmp/test.db'); + }); + + afterEach(() => { + store.close(); + }); + + it('should create the database and sessions table on open', () => { + store.save(makeSampleSession()); + expect(store.load('test-001')).toBeDefined(); + }); + + it('should persist and retrieve a session', () => { + store.save(makeSampleSession({ id: 'sess-1' })); + + const row = store.load('sess-1')!; + expect(row.id).toBe('sess-1'); + expect(row.workflow_id).toBe('work-package'); + expect(row.target_submodule).toBe('midnight-node'); + expect(row.issue_ref).toBe('PM-123'); + expect(row.slack_channel).toBe('C123'); + expect(row.slack_thread_ts).toBe('1234.5678'); + expect(row.status).toBe('creating'); + expect(row.created_at).toBe(1709600000000); + expect(row.completed_at).toBeNull(); + expect(row.error).toBeNull(); + }); + + it('should return undefined for a non-existent session', () => { + expect(store.load('does-not-exist')).toBeUndefined(); + }); + + it('should replace an existing record when saving with the same ID', () => { + store.save(makeSampleSession({ id: 'dup', targetSubmodule: 'first' })); + store.save(makeSampleSession({ id: 'dup', targetSubmodule: 'second' })); + + expect(store.load('dup')!.target_submodule).toBe('second'); + }); + + it('should return only active sessions from loadActive', () => { + store.save(makeSampleSession({ id: 's1', status: 'creating' })); + store.save(makeSampleSession({ id: 's2', status: 'running' })); + store.save(makeSampleSession({ id: 's3', status: 'awaiting_checkpoint' })); + store.save(makeSampleSession({ id: 's4', status: 'completed' })); + store.save(makeSampleSession({ id: 's5', status: 'error' })); + + const active = store.loadActive(); + expect(active).toHaveLength(3); + expect(active.map((r: any) => r.id).sort()).toEqual(['s1', 's2', 's3']); + }); + + it('should set completed_at when updating to completed', () => { + store.save(makeSampleSession({ id: 'c1', status: 'running' })); + store.updateStatus('c1', 'completed'); + + const row = store.load('c1')!; + expect(row.status).toBe('completed'); + expect(row.completed_at).toBeGreaterThan(0); + expect(row.error).toBeNull(); + }); + + it('should store the error message on error status', () => { + store.save(makeSampleSession({ id: 'e1', status: 'running' })); + store.updateStatus('e1', 'error', 'Something failed'); + + const row = store.load('e1')!; + expect(row.status).toBe('error'); + expect(row.error).toBe('Something failed'); + expect(row.completed_at).toBeGreaterThan(0); + }); + + it('should not set completed_at for non-terminal status changes', () => { + store.save(makeSampleSession({ id: 'r1', status: 'creating' })); + store.updateStatus('r1', 'running'); + + const row = store.load('r1')!; + expect(row.status).toBe('running'); + expect(row.completed_at).toBeNull(); + }); + + it('should store null for undefined issueRef', () => { + store.save(makeSampleSession({ id: 'no-ref', issueRef: undefined })); + expect(store.load('no-ref')!.issue_ref).toBeNull(); + }); + + it('should throw when operations are called before open', () => { + const closed = new SessionStore(); + expect(() => closed.load('x')).toThrow('SessionStore is not open'); + }); +}); From 106a066ca7f52b8bba17c4282eef7429a25ca06d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 14:33:33 +0000 Subject: [PATCH 11/18] fix: resolve validation failures (types, test index) - Upgrade @types/node from ^20 to ^22 for node:sqlite type support - Fix type cast in session-store.ts for updated stmt.all() return type - Fix mcp-server test resource index from "00" to "01" Made-with: Cursor --- package-lock.json | 8 ++++---- package.json | 2 +- src/runner/session-store.ts | 2 +- tests/mcp-server.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e85ae60..468494c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "zod-to-json-schema": "^3.22.4" }, "devDependencies": { - "@types/node": "^20.19.30", + "@types/node": "^22.19.13", "ajv": "^8.17.1", "tsx": "^4.7.0", "typescript": "^5.3.3", @@ -1104,9 +1104,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/package.json b/package.json index ac0e05d7..93af3c59 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "license": "MIT", "devDependencies": { - "@types/node": "^20.19.30", + "@types/node": "^22.19.13", "ajv": "^8.17.1", "tsx": "^4.7.0", "typescript": "^5.3.3", diff --git a/src/runner/session-store.ts b/src/runner/session-store.ts index 3c183ed9..8653bd37 100644 --- a/src/runner/session-store.ts +++ b/src/runner/session-store.ts @@ -79,7 +79,7 @@ export class SessionStore { const stmt = this.requireDb().prepare( "SELECT * FROM sessions WHERE status IN ('creating', 'running', 'awaiting_checkpoint')", ); - return stmt.all() as SessionRow[]; + return stmt.all() as unknown as SessionRow[]; } updateStatus(id: string, status: SessionStatus, error?: string): void { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 46fa2c5d..3a4d50ba 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -230,7 +230,7 @@ describe('mcp-server integration', () => { it('should get specific resource', async () => { const result = await client.callTool({ name: 'get_resource', - arguments: { workflow_id: 'work-package', index: '00' }, + arguments: { workflow_id: 'work-package', index: '01' }, }); // get_resource returns raw content (TOON or markdown format) From 153ac90e7120888a0e6c449abc2f3f7a94b8cf2d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 14:42:30 +0000 Subject: [PATCH 12/18] refactor: address strategic review findings (S1, S2, O1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1: Revert .engineering submodule pointer to match main — the pointer was updated during planning and is unrelated to the runner feature. S2: Keep mcp-server.test.ts fix (index '00' → '01') — resource '00' never existed, so this fixes a genuinely broken test on main. O1: Remove dead followUp() method from AcpClient — no callers in the codebase, functionally identical to prompt(). Made-with: Cursor --- .engineering | 2 +- src/runner/acp-client.ts | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.engineering b/.engineering index 93e57a5d..a9e31687 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit 93e57a5d6fe4d82adc9040e3bc3bd9118d375b00 +Subproject commit a9e31687a403030721b91bfcf01ee7e9130f705b diff --git a/src/runner/acp-client.ts b/src/runner/acp-client.ts index 51dc3827..8b47cd1c 100644 --- a/src/runner/acp-client.ts +++ b/src/runner/acp-client.ts @@ -203,17 +203,6 @@ export class AcpClient extends EventEmitter { }, 0) as PromptResult; // 0 = no timeout for long-running prompts } - /** - * Send a follow-up prompt to the agent. - */ - async followUp(text: string): Promise { - if (!this.sessionId) throw new Error('No active session'); - return await this.send('session/prompt', { - sessionId: this.sessionId, - prompt: [{ type: 'text', text }], - }, 0); // 0 = no timeout for long-running prompts - } - /** * Respond to an incoming JSON-RPC request from the agent * (e.g. cursor/ask_question, session/request_permission). From 286e36a9258663262463c097c57c5344e51a06ef Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 14:48:45 +0000 Subject: [PATCH 13/18] chore: update .engineering submodule with implementation artifacts Made-with: Cursor --- .engineering | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.engineering b/.engineering index a9e31687..57a4bf45 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit a9e31687a403030721b91bfcf01ee7e9130f705b +Subproject commit 57a4bf4538de79c7d4ae5e3825ec2edbe23bcb7a From 224cf8bbf0f535273996fb22c3467652e1c43433 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 5 Mar 2026 14:56:34 +0000 Subject: [PATCH 14/18] docs: add setup guide for headless Slack workflow runner Covers Slack app creation, environment configuration, starting the runner, executing workflows via slash commands, monitoring, and troubleshooting. Made-with: Cursor --- docs/runner-setup.md | 259 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/runner-setup.md diff --git a/docs/runner-setup.md b/docs/runner-setup.md new file mode 100644 index 00000000..1ec8c8ee --- /dev/null +++ b/docs/runner-setup.md @@ -0,0 +1,259 @@ +# Headless Slack Workflow Runner — Setup Guide + +This guide covers how to create the Slack app, configure the runner, start it, and execute a workflow. + +--- + +## Prerequisites + +- **Node.js >= 22.5.0** (the runner uses `node:sqlite` which requires v22.5.0+; tested on v24.2.0) +- **Cursor CLI** — the `agent` binary must be on your PATH. Install Cursor, then verify: + ```bash + agent --version + ``` + If the binary is elsewhere, set `CURSOR_AGENT_BINARY` to the full path. +- **A Cursor API key** — required for `agent acp` mode. Available from your Cursor account settings. +- **A git repository** with submodules you want to target for workflow execution. + +--- + +## 1. Create the Slack App + +### 1.1 Create the app + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** +2. Choose **From scratch** +3. Name it (e.g. `Workflow Runner`) and select your workspace +4. Click **Create App** + +### 1.2 Enable Socket Mode + +Socket Mode lets the bot connect via outbound WebSocket — no public URL needed. + +1. In the app settings, go to **Socket Mode** (left sidebar) +2. Toggle **Enable Socket Mode** to On +3. You'll be prompted to create an **App-Level Token**: + - Name it `socket-mode` + - Add the scope `connections:write` + - Click **Generate** +4. Copy the token (`xapp-...`) — this is your `SLACK_APP_TOKEN` + +### 1.3 Add a slash command + +1. Go to **Slash Commands** (left sidebar) +2. Click **Create New Command** +3. Fill in: + - **Command:** `/workflow` + - **Short Description:** `Start and manage workflow sessions` + - **Usage Hint:** `start [issue-ref] | list | help` +4. Click **Save** + +### 1.4 Enable Interactivity + +Interactivity is required for checkpoint buttons (the agent asks questions via Slack buttons). + +1. Go to **Interactivity & Shortcuts** (left sidebar) +2. Toggle **Interactivity** to On +3. No Request URL is needed when using Socket Mode +4. Click **Save Changes** + +### 1.5 Set Bot Token Scopes + +1. Go to **OAuth & Permissions** (left sidebar) +2. Under **Scopes → Bot Token Scopes**, add: + - `chat:write` — post messages and replies in threads + - `commands` — receive slash commands +3. Click **Save Changes** + +### 1.6 Install to workspace + +1. Go to **Install App** (left sidebar) +2. Click **Install to Workspace** and authorize +3. Copy the **Bot User OAuth Token** (`xoxb-...`) — this is your `SLACK_BOT_TOKEN` + +### 1.7 Get the Signing Secret + +1. Go to **Basic Information** (left sidebar) +2. Under **App Credentials**, copy the **Signing Secret** — this is your `SLACK_SIGNING_SECRET` + +--- + +## 2. Configure the Runner + +### 2.1 Create a `.env` file + +In the workflow-server root, copy the example and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```bash +# Slack App (from steps above) +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_APP_TOKEN=xapp-your-app-level-token + +# Cursor CLI +CURSOR_API_KEY=key_your-cursor-api-key +CURSOR_AGENT_BINARY=agent # or full path to the binary + +# Repository — the repo whose submodules you want to target +REPO_PATH=/home/you/projects/midnight-agent-eng +WORKTREE_BASE_DIR=/home/you/worktrees # optional, defaults to ~/worktrees + +# Log level (optional, default: info) +LOG_LEVEL=info + +# Database path (optional, default: data/runner.db) +DB_PATH=data/runner.db +``` + +### 2.2 MCP servers (optional) + +To pass MCP server configurations to agent sessions, set `MCP_SERVERS_JSON` as a JSON object. Each key is a server name, and the value has `command`, `args`, and optionally `env`: + +```bash +MCP_SERVERS_JSON='{"workflow-server":{"command":"npx","args":["tsx","src/index.ts"],"env":{"NODE_ENV":"production"}}}' +``` + +These are written to `.cursor/mcp.json` in each worktree so the Cursor agent discovers them. + +### 2.3 Install dependencies + +```bash +cd /path/to/workflow-server +npm install +``` + +--- + +## 3. Start the Runner + +```bash +npm run runner +``` + +On startup the runner: +1. Validates configuration (Zod schema checks token prefixes, required fields) +2. Opens the SQLite database at `data/runner.db` (created automatically) +3. Sweeps any orphaned worktrees from previous crashes (`wf-runner-*` prefix) +4. Connects to Slack via Socket Mode + +You should see: + +``` +{"level":30,"msg":"Runner config loaded","repo":"/home/you/projects/midnight-agent-eng",...} +{"level":30,"msg":"Workflow Runner is listening (Socket Mode)"} +``` + +Logs are written to `logs/runner.YYYY-MM-DD.log` with daily rotation and 14-file retention. + +### Stopping + +Press `Ctrl+C` (SIGINT) or send SIGTERM. The runner will: +1. Clean up all active agent sessions (kill ACP processes) +2. Remove associated worktrees +3. Close the SQLite database +4. Disconnect from Slack + +--- + +## 4. Execute a Workflow + +### Start a workflow + +In any Slack channel where the bot is present, type: + +``` +/workflow start [issue-ref] +``` + +**Parameters:** + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `workflow-id` | Yes | The workflow definition to execute | `work-package` | +| `target-submodule` | Yes | Submodule or directory within the repo to target | `midnight-node` | +| `issue-ref` | No | Issue reference for traceability | `PM-12345` | + +**Example:** + +``` +/workflow start work-package midnight-node PM-22119 +``` + +This will: +1. Post an initial message in the channel and create a thread +2. Create a git worktree (`wf-runner-`) branching from `main` +3. Initialize the target submodule in the worktree +4. Spawn a Cursor ACP agent process pointing at the worktree +5. Send the workflow prompt to the agent +6. Stream agent status updates to the Slack thread every 5 seconds + +### Respond to checkpoints + +When the agent reaches a checkpoint (e.g., asking a question), it appears as **interactive buttons** in the Slack thread. Click the appropriate button to respond. The agent resumes automatically. + +### List active sessions + +``` +/workflow list +``` + +Shows all currently running workflow sessions with their workflow ID, target, status, and elapsed time. + +### Show help + +``` +/workflow help +``` + +--- + +## 5. Monitoring + +### Logs + +Structured JSON logs are written to `logs/`: + +```bash +# Tail the current log file +tail -f logs/runner.$(date +%Y-%m-%d).log + +# Pretty-print with pino-pretty (install separately) +tail -f logs/runner.$(date +%Y-%m-%d).log | npx pino-pretty +``` + +Each log entry includes `level`, `time`, `msg`, and contextual fields like `sessionId` and `workflowId`. + +### Session database + +Session state is persisted in SQLite at `data/runner.db`: + +```bash +sqlite3 data/runner.db "SELECT id, workflow_id, target_submodule, status, created_at FROM sessions ORDER BY created_at DESC;" +``` + +Sessions survive runner restarts. On startup, any sessions left in a non-terminal state (`creating`, `running`, `awaiting_checkpoint`) are marked as `error` with a stale session diagnostic. + +### Worktrees + +Active worktrees live under `WORKTREE_BASE_DIR` (default `~/worktrees`), named `wf-runner-`. On startup, any orphaned `wf-runner-*` worktrees are automatically cleaned up. + +--- + +## 6. Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `Required environment variable X is not set` | Missing `.env` entry | Add the variable to `.env` | +| `SLACK_BOT_TOKEN must start with xoxb-` | Wrong token type | Use the Bot User OAuth Token, not the User Token | +| `SLACK_APP_TOKEN must start with xapp-` | Wrong token type | Use the App-Level Token from Socket Mode settings | +| `agent: command not found` | Cursor CLI not on PATH | Set `CURSOR_AGENT_BINARY` to the full path | +| Runner starts but `/workflow` does nothing | Bot not in channel | Invite the bot to the channel with `/invite @WorkflowRunner` | +| Checkpoint buttons don't respond | Interactivity not enabled | Enable Interactivity in the Slack app settings | +| `MCP_SERVERS_JSON is not valid JSON` | Malformed JSON string | Validate the JSON with `echo $MCP_SERVERS_JSON | jq .` | +| Orphaned worktrees accumulating | Runner crashed without cleanup | Restart the runner — it sweeps `wf-runner-*` on startup | From ab90486eb0259c237ee92ab676cced12a04fbdc3 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 11 Mar 2026 13:59:57 +0000 Subject: [PATCH 15/18] docs: add Slack app manifest for headless workflow runner Provides the Slack app configuration (slash commands, bot scopes, Socket Mode) needed to set up the runner's Slack integration. Made-with: Cursor --- docs/slack-app-manifest.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/slack-app-manifest.yml diff --git a/docs/slack-app-manifest.yml b/docs/slack-app-manifest.yml new file mode 100644 index 00000000..ebb94897 --- /dev/null +++ b/docs/slack-app-manifest.yml @@ -0,0 +1,27 @@ +display_information: + name: Workflow Runner + description: Headless AI workflow execution via Cursor ACP + background_color: "#1a1a2e" + +features: + bot_user: + display_name: workflow-runner + always_online: true + slash_commands: + - command: /workflow + description: Start and manage workflow sessions + usage_hint: "start [issue-ref] | list | help" + should_escape: false + +oauth_config: + scopes: + bot: + - chat:write + - commands + +settings: + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false From 9f81f91fd4965de5148c4676422027f8e6e3a325 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 11 Mar 2026 15:26:30 +0000 Subject: [PATCH 16/18] chore: update .engineering submodule with progress tracking Made-with: Cursor --- .engineering | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.engineering b/.engineering index 57a4bf45..6bd321ca 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit 57a4bf4538de79c7d4ae5e3825ec2edbe23bcb7a +Subproject commit 6bd321caf386c9485c2e9788bf837821cdcb3fa6 From 4cb5344c44b894cb64fa3e6c990428e0724adc75 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 11 Mar 2026 17:20:07 +0000 Subject: [PATCH 17/18] fix(schema): allow blocking: false on checkpoints Change checkpoint.blocking from z.literal(true) to z.boolean() in the Zod schema and remove "const": true from the JSON schema. Non-blocking checkpoints are now valid. Fixes 22 test failures caused by work-package activities that use blocking: false. Made-with: Cursor --- schemas/activity.schema.json | 3 +-- src/schema/activity.schema.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/schemas/activity.schema.json b/schemas/activity.schema.json index 774efdf4..15f484e4 100644 --- a/schemas/activity.schema.json +++ b/schemas/activity.schema.json @@ -142,9 +142,8 @@ }, "blocking": { "type": "boolean", - "const": true, "default": true, - "description": "Always true. Checkpoints pause workflow execution until the user responds." + "description": "Whether this checkpoint blocks workflow execution until the user responds. Defaults to true." } }, "required": ["id", "name", "message", "options"], diff --git a/src/schema/activity.schema.ts b/src/schema/activity.schema.ts index 12a5dc72..f5ec0ac5 100644 --- a/src/schema/activity.schema.ts +++ b/src/schema/activity.schema.ts @@ -52,7 +52,7 @@ export const CheckpointSchema = z.object({ prerequisite: z.string().optional().describe('Action to complete before presenting checkpoint'), options: z.array(CheckpointOptionSchema).min(1), required: z.boolean().default(true), - blocking: z.literal(true).default(true), + blocking: z.boolean().default(true), }); export type Checkpoint = z.infer; From 3dd011bcfeeae22ca56128a3867b64cdc7f1edb0 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 12 Mar 2026 09:21:42 +0000 Subject: [PATCH 18/18] test: update work-package version assertions to 3.4.0 Made-with: Cursor --- tests/mcp-server.test.ts | 2 +- tests/workflow-loader.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 3a4d50ba..c279d34a 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -61,7 +61,7 @@ describe('mcp-server integration', () => { const workflow = JSON.parse((result.content[0] as { type: 'text'; text: string }).text); expect(workflow.id).toBe('work-package'); - expect(workflow.version).toBe('3.3.0'); + expect(workflow.version).toBe('3.4.0'); expect(workflow.activities).toHaveLength(14); expect(workflow.initialActivity).toBe('start-work-package'); }); diff --git a/tests/workflow-loader.test.ts b/tests/workflow-loader.test.ts index 3a416cf5..3681c6f0 100644 --- a/tests/workflow-loader.test.ts +++ b/tests/workflow-loader.test.ts @@ -33,7 +33,7 @@ describe('workflow-loader', () => { expect(workPackage).toBeDefined(); expect(workPackage?.title).toBe('Work Package Implementation Workflow'); - expect(workPackage?.version).toBe('3.3.0'); + expect(workPackage?.version).toBe('3.4.0'); }); });