Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/bot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Wright Telegram Bot — environment variables
# Copy to .env and fill in values: cp .env.example .env

# Telegram bot token from @BotFather
BOT_TOKEN=

# Supabase project URL
SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co

# Supabase service_role key (NOT the anon key)
SUPABASE_KEY=

# GitHub personal access token (needs repo scope for PR creation)
GITHUB_TOKEN=

# Comma-separated Telegram user IDs allowed to use the bot (empty = no restriction)
ALLOWED_TELEGRAM_USERS=
80 changes: 80 additions & 0 deletions apps/bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# =============================================================================
# Wright Bot -- Production Multi-Stage Dockerfile
#
# Stages:
# 1. deps -- install pnpm + all workspace dependencies
# 2. build -- compile TypeScript (shared + bot)
# 3. runtime -- minimal image with built artifacts
#
# Build context: repo root (run with `docker build -f apps/bot/Dockerfile .`)
# =============================================================================

# ---------------------------------------------------------------------------
# Stage 1: Install dependencies
# ---------------------------------------------------------------------------
FROM node:22-slim AS deps

RUN corepack enable && corepack prepare pnpm@9.15.0 --activate

WORKDIR /app

# Copy only what pnpm needs for dependency resolution (layer cache)
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* turbo.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/bot/package.json apps/bot/

# Install all deps (including devDependencies needed for build)
RUN pnpm install --frozen-lockfile || pnpm install

# ---------------------------------------------------------------------------
# Stage 2: Build TypeScript
# ---------------------------------------------------------------------------
FROM deps AS build

# Copy source files
COPY tsconfig.base.json ./
COPY packages/shared/tsconfig.json packages/shared/
COPY packages/shared/src/ packages/shared/src/
COPY apps/bot/tsconfig.json apps/bot/
COPY apps/bot/src/ apps/bot/src/

# Build shared first, then bot
RUN pnpm --filter @wright/shared build && pnpm --filter @wright/bot build

# Prune devDependencies for a leaner copy into runtime.
RUN pnpm prune --prod \
&& mkdir -p packages/shared/node_modules apps/bot/node_modules

# ---------------------------------------------------------------------------
# Stage 3: Runtime
# ---------------------------------------------------------------------------
FROM node:22-slim AS runtime

WORKDIR /app

# Copy workspace manifests (needed for pnpm workspace resolution at runtime)
COPY --from=build /app/package.json /app/pnpm-workspace.yaml ./

# Copy shared package (built artifacts + package.json)
COPY --from=build /app/packages/shared/package.json packages/shared/
COPY --from=build /app/packages/shared/dist/ packages/shared/dist/

# Copy bot (built artifacts + package.json)
COPY --from=build /app/apps/bot/package.json apps/bot/
COPY --from=build /app/apps/bot/dist/ apps/bot/dist/

# Copy production node_modules from pruned build stage
COPY --from=build /app/node_modules/ node_modules/
COPY --from=build /app/packages/shared/node_modules/ packages/shared/node_modules/
COPY --from=build /app/apps/bot/node_modules/ apps/bot/node_modules/

# Non-root user
RUN groupadd --gid 1001 wright \
&& useradd --uid 1001 --gid wright --shell /bin/bash --create-home wright \
&& chown -R wright:wright /app

USER wright

WORKDIR /app/apps/bot

CMD ["node", "dist/index.js"]
16 changes: 16 additions & 0 deletions apps/bot/fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Fly.io configuration for the wright Telegram bot
# Long-polling bot — no HTTP service needed, just a persistent process

app = "wright-bot"
primary_region = "ord"

[build]
dockerfile = "Dockerfile"

[env]
NODE_ENV = "production"

[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
24 changes: 24 additions & 0 deletions apps/worker/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Wright Worker — environment variables (for local development)
# Copy to .env and fill in values: cp .env.example .env
# In production (Fly.io), these are set via `fly secrets set`.

# Supabase project URL
SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co

# Supabase service_role key (NOT the anon key)
SUPABASE_SERVICE_ROLE_KEY=

# Anthropic API key for Claude sessions
ANTHROPIC_API_KEY=

# Optional: Claude model to use (default: claude-sonnet-4-20250514)
# CLAUDE_MODEL=claude-sonnet-4-20250514

# Optional: Max turns per Claude session loop (default: 30)
# MAX_TURNS_PER_LOOP=30

# Optional: Test timeout in seconds (default: 300)
# TEST_TIMEOUT_SECONDS=300

# Optional: Workspace directory for cloned repos (default: /tmp/wright-work)
# WORKSPACE_DIR=/tmp/wright-work
38 changes: 29 additions & 9 deletions apps/worker/src/dev-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,20 @@ import { detectTestRunner, detectPackageManager, installDependencies, runTests }
import { runClaudeSession } from './claude-session.js'
import { existsSync, rmSync, mkdirSync } from 'fs'

/**
* Strip Telegram HTML-like formatting delimiters (e.g. `<b>`, `</b>`, `<code>`) from
* raw message text so they don't leak into prompts, PR bodies, or commit messages.
*/
function sanitizeTaskText(text: string): string {
return text.replace(/<\/?[^>]+>/g, '').trim()
}

export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult> {
const { job } = config
// Sanitize task text early so all downstream uses (prompts, PR body, commit messages)
// receive clean text without Telegram formatting delimiters.
job.task = sanitizeTaskText(job.task)

const maxLoops = job.max_loops
const maxBudget = job.max_budget_usd
const maxTurns = config.maxTurnsPerLoop || DEFAULT_MAX_TURNS_PER_LOOP
Expand Down Expand Up @@ -195,7 +207,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
})

const eventType =
lastTestResults.failed === 0 && lastTestResults.total > 0
lastTestResults.failed === 0 && lastTestResults.errors === 0
? 'test_pass'
: 'test_fail'
await emit(supabase, job.id, eventType, loop, {
Expand Down Expand Up @@ -230,15 +242,15 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>

// 5c. Check pass
allTestsPassed =
lastTestResults.failed === 0 && lastTestResults.total > 0
lastTestResults.failed === 0 && lastTestResults.errors === 0
}

// 6. Commit and push
const commitMessage = allTestsPassed
? `feat: ${job.task.slice(0, 60)}`
: `wip: ${job.task.slice(0, 60)} (${lastTestResults.passed}/${lastTestResults.total} tests passing)`

const commitSha = await commitAndPush(workDir, commitMessage)
const commitSha = await commitAndPush(workDir, commitMessage, job.github_token)

// 7. Create PR
let prUrl: string | undefined
Expand All @@ -248,6 +260,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
job.task.slice(0, 70),
buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost),
job.branch,
job.github_token,
)
await emit(supabase, job.id, 'pr_created', undefined, { prUrl })
} catch (err) {
Expand Down Expand Up @@ -304,12 +317,19 @@ ${workDir}
- Dependencies are already installed

## Rules
1. Make changes to implement the requested task
2. After making changes, run tests to verify they pass
3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong
4. Keep changes minimal and focused on the task
5. Do not refactor unrelated code
6. Do not add unnecessary dependencies`
1. Make changes to implement the requested task.
2. After making changes, run tests to verify they pass.
3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong.
4. Keep changes minimal and focused ONLY on the files directly required by the task.
5. Do not refactor unrelated code.
6. Do not add unnecessary dependencies.

## Hard constraints — NEVER violate these
- NEVER modify test files unless the task explicitly asks for test changes.
- NEVER modify package manifests (package.json, pyproject.toml, Cargo.toml, go.mod, setup.py, setup.cfg, etc.) unless the task explicitly requires adding or removing a dependency.
- NEVER modify lock files (uv.lock, pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, poetry.lock, etc.) under any circumstances.
- If the task is about documentation (README, docs/, *.md), ONLY modify documentation files. Do NOT touch source code, tests, or manifests.
- When in doubt about whether a file is in scope, leave it unchanged.`
}

function buildInitialPrompt(
Expand Down
47 changes: 44 additions & 3 deletions apps/worker/src/github-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export async function cloneRepo(repoUrl: string, workDir: string, token: string,
try {
const cloneArgs = branch ? ['--branch', branch] : []
await git.clone(authedUrl, workDir, cloneArgs)
// Strip credentials from the stored remote URL so they aren't
// accessible via .git/config (e.g. by the Claude subprocess).
const repoGit = simpleGit(workDir)
await repoGit.remote(['set-url', 'origin', repoUrl])
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(sanitizeGitError(msg, cleanToken))
Expand All @@ -31,7 +35,7 @@ export async function createFeatureBranch(
return branchName
}

export async function commitAndPush(workDir: string, message: string): Promise<string> {
export async function commitAndPush(workDir: string, message: string, githubToken?: string): Promise<string> {
const git = simpleGit(workDir)

await git.addConfig('user.email', 'wright@openadaptai.noreply.github.com')
Expand All @@ -47,28 +51,65 @@ export async function commitAndPush(workDir: string, message: string): Promise<s

await git.commit(message)

// Re-inject credentials for push (they were stripped from the remote after clone).
const cleanToken = githubToken?.trim()
if (cleanToken) {
const remotes = await git.remote(['-v'])
const originMatch = (remotes || '').match(/origin\s+(https:\/\/[^\s]+)/)
if (originMatch) {
const authedUrl = originMatch[1].replace('https://', `https://x-access-token:${cleanToken}@`)
await git.remote(['set-url', 'origin', authedUrl])
}
}

const branchStatus = await git.branch()
const currentBranch = branchStatus.current
await git.push('origin', currentBranch)

try {
await git.push('origin', currentBranch)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(cleanToken ? sanitizeGitError(msg, cleanToken) : msg)
} finally {
// Strip credentials from remote again after push.
if (cleanToken) {
const remotes = await git.remote(['-v'])
const originMatch = (remotes || '').match(/origin\s+(https:\/\/[^\s@]+@)?([^\s]+)/)
if (originMatch) {
const plainUrl = originMatch[0].replace(/x-access-token:[^@]+@/, '')
await git.remote(['set-url', 'origin', plainUrl.replace(/^origin\s+/, '')]).catch(() => {})
}
}
}

const log = await git.log({ maxCount: 1 })
return log.latest?.hash || ''
}

/**
* Create a pull request using the gh CLI.
* Requires `githubToken` so the `gh` CLI can authenticate without `gh auth login`.
*/
export async function createPullRequest(
workDir: string,
title: string,
body: string,
baseBranch: string = 'main',
githubToken?: string,
): Promise<string> {
const { execFileSync } = await import('child_process')
// Minimal env — only what `gh` needs. Avoids leaking SUPABASE_SERVICE_ROLE_KEY etc.
const env: Record<string, string> = {
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
HOME: process.env.HOME || '/home/wright',
}
if (githubToken) {
env.GH_TOKEN = githubToken
}
const result = execFileSync(
'gh',
['pr', 'create', '--title', title, '--body', body, '--base', baseBranch],
{ cwd: workDir, encoding: 'utf-8' },
{ cwd: workDir, encoding: 'utf-8', env },
)
return result.trim() // Returns the PR URL
}