diff --git a/.engineering b/.engineering index a9e31687..6bd321ca 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit a9e31687a403030721b91bfcf01ee7e9130f705b +Subproject commit 6bd321caf386c9485c2e9788bf837821cdcb3fa6 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/.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/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 | 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 diff --git a/package-lock.json b/package-lock.json index 05e6647d..468494c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,16 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", + "@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" }, "devDependencies": { - "@types/node": "^20.19.30", + "@types/node": "^22.19.13", "ajv": "^8.17.1", "tsx": "^4.7.0", "typescript": "^5.3.3", @@ -535,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", @@ -892,12 +902,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 +1055,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, + "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" } }, + "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 +1331,32 @@ "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/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", + "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 +1381,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 +1467,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", @@ -1262,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", @@ -1292,6 +1593,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 +1621,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 +1647,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 +1701,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 +1783,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 +1935,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 +2131,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 +2229,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 +2288,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 +2348,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", @@ -2074,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", @@ -2111,6 +2635,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 +2660,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", @@ -2179,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", @@ -2251,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", @@ -2264,6 +2907,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", @@ -2279,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", @@ -2310,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", @@ -2329,6 +2993,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 +3063,53 @@ "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/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", "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", @@ -2560,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", @@ -2570,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", @@ -2619,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", @@ -2655,6 +3399,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 +3477,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 +4114,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..93af3c59 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "test": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "runner": "tsx src/runner/index.ts" }, "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", @@ -23,7 +24,11 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", + "@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/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/runner/acp-client.ts b/src/runner/acp-client.ts new file mode 100644 index 00000000..8b47cd1c --- /dev/null +++ b/src/runner/acp-client.ts @@ -0,0 +1,332 @@ +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]; + stderr: [text: string]; + 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 +// --------------------------------------------------------------------------- + +const DEFAULT_SEND_TIMEOUT_MS = 60_000; + +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, + private readonly defaultTimeoutMs: number = DEFAULT_SEND_TIMEOUT_MS, + ) { + 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('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 }], + }, 0) as PromptResult; // 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). + */ + 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, + timeoutMs: number = this.defaultTimeoutMs, + ): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + 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 }); + }); + } + + 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..d33faea9 --- /dev/null +++ b/src/runner/config.ts @@ -0,0 +1,73 @@ +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({}), + logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), + dbPath: z.string().optional(), +}); + +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(), + logLevel: process.env['LOG_LEVEL'] as RunnerConfig['logLevel'], + dbPath: process.env['DB_PATH'], + }); +} + +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..b0ca6fdc --- /dev/null +++ b/src/runner/index.ts @@ -0,0 +1,49 @@ +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 { SessionStore } from './session-store.js'; +import { createSlackApp } from './slack-bot.js'; +import { WorktreeManager } from './worktree-manager.js'; + +async function main(): Promise { + const config = loadRunnerConfig(); + logger.info({ + repo: config.repo.path, + worktreeBase: config.repo.worktreeBaseDir, + mcpServers: Object.keys(config.mcpServers), + }, 'Runner config loaded'); + + 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); + + await app.start(); + logger.info('Workflow Runner is listening (Socket Mode)'); + + const shutdown = async () => { + logger.info('Shutting down...'); + await sessionManager.shutdownAll(); + store.close(); + await app.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); +} + +main().catch((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 new file mode 100644 index 00000000..8a7ce628 --- /dev/null +++ b/src/runner/session-manager.ts @@ -0,0 +1,362 @@ +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'; + +// --------------------------------------------------------------------------- +// 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; + +/** + * 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 */ + private threadToSession = new Map(); + + 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'); + } + } + } + } + + /** + * 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); + + 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}\`...`); + + session.worktree = await this.worktreeManager.create( + 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; + + 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', + ); + } + + /** + * 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 + // ----------------------------------------------------------------------- + + 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) => { + 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' }, + }); + }); + + 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('stderr', (text) => { + const log = createChildLogger({ sessionId: session.id }); + log.debug({ text }, 'agent stderr'); + }); + + acp.on('error', async (err) => { + const log = createChildLogger({ sessionId: session.id }); + log.error({ err: err.message }, 'ACP error'); + }); + + 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.store?.updateStatus(session.id, 'completed'); + 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.store?.updateStatus(session.id, 'error', err.message); + 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) { + const log = createChildLogger({ sessionId: session.id }); + log.error({ err }, 'Worktree cleanup failed'); + } + } + } + + 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) { + const log = createChildLogger({ sessionId: session.id }); + log.error({ err }, 'Failed to post to Slack'); + } + } + + private generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } +} diff --git a/src/runner/session-store.ts b/src/runner/session-store.ts new file mode 100644 index 00000000..8653bd37 --- /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 unknown 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; + } +} diff --git a/src/runner/slack-bot.ts b/src/runner/slack-bot.ts new file mode 100644 index 00000000..74c59ed6 --- /dev/null +++ b/src/runner/slack-bot.ts @@ -0,0 +1,161 @@ +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'; + +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 + logger.warn({ actionId, channel, threadTs }, 'Unresolved checkpoint action'); + } + }); + + return app; +} + +// ------------------------------------------------------------------------- +// 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, + 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; + } + + 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}\`` + + (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..cd35c35d --- /dev/null +++ b/src/runner/worktree-manager.ts @@ -0,0 +1,173 @@ +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 { + if (targetSubmodule) { + this.validateSubmodulePath(targetSubmodule); + } + + await mkdir(this.baseDir, { recursive: true }); + + 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, + }); + + 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 + } + } + + /** + * 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; + } + + 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. + */ + 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/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; diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 46fa2c5d..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'); }); @@ -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) 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/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'); + }); +}); diff --git a/tests/runner/worktree-manager.test.ts b/tests/runner/worktree-manager.test.ts new file mode 100644 index 00000000..4150d430 --- /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/wf-runner-abc123'); + expect(info.branch).toBe('wf-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/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/wf-runner-abc123'); + + // .cursor/cli.json was written + expect(vi.mocked(writeFile)).toHaveBeenCalledWith( + '/tmp/worktrees/wf-runner-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/wf-runner-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/wf-runner-abc123', + branch: 'wf-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/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', 'wf-runner/abc123']); + expect((calls[1]![2] as any).cwd).toBe('/repo'); + }); +}); 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'); }); });