Compare commits

...

4 Commits

24 changed files with 2732 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# Discord
DISCORD_TOKEN=
DATABASE_PATH=discord.sqlite # SQLite file path
ALLOWED_CHANNEL_IDS= # Comma-separated Discord channel IDs
DISCORD_ROLE_ID= # Role ID that triggers the bot (optional, for @role mentions)
DISCORD_CATEGORY_ID= # Optional category ID that is allowed
DISCORD_REQUIRED_ROLE_ID= # Optional role required to talk to bot
# Daytona
DAYTONA_API_KEY=
# OpenCode (injected into sandboxes)
OPENCODE_ZEN_API_KEY=
GITHUB_TOKEN= # Optional; enables authenticated gh CLI inside sandbox
# Observability
LOG_LEVEL=info
LOG_PRETTY=false
HEALTH_HOST=0.0.0.0
HEALTH_PORT=8787
TURN_ROUTING_MODE=ai # off | heuristic | ai
TURN_ROUTING_MODEL=claude-haiku-4-5
# Bot behavior
SANDBOX_REUSE_POLICY=resume_preferred
SANDBOX_TIMEOUT_MINUTES=30
PAUSED_TTL_MINUTES=180
RESUME_HEALTH_TIMEOUT_MS=120000
SANDBOX_CREATION_TIMEOUT=180
OPENCODE_MODEL=opencode/claude-sonnet-4-5

38
packages/discord/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment
.env
.env.local
.env.*.local
# Bun
*.lockb
# Turbo
.turbo/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Local SQLite
*.sqlite
*.sqlite-shm
*.sqlite-wal
# Sensitive
.censitive

211
packages/discord/AGENTS.md Normal file
View File

@@ -0,0 +1,211 @@
# AGENTS.md
Guide for coding agents working in this repository.
Use this file for build/test commands and coding conventions.
## Project Snapshot
- Stack: Bun + TypeScript (ESM, strict mode)
- App: Discord bot that provisions Daytona sandboxes
- Persistence: SQLite (`discord.sqlite`, table `discord_sessions`)
- Runtime flow: Discord thread -> sandbox -> OpenCode session
- Ops: structured JSON logs + `/healthz` and `/readyz`
## Repository Map
- `src/index.ts`: startup, wiring, graceful shutdown
- `src/config.ts`: env schema and parsing (Zod)
- `src/discord/`: Discord client + handlers + routing logic
- `src/sandbox/`: sandbox lifecycle + OpenCode transport
- `src/sessions/store.ts`: SQLite-backed session store
- `src/db/init.ts`: idempotent DB schema initialization
- `src/http/health.ts`: health/readiness HTTP server
- `.env.example`: env contract
## Setup and Run Commands
### Install
- `bun install`
### First-time local setup
- `cp .env.example .env`
- Fill required secrets in `.env`
- Initialize schema: `bun run db:init`
### Development run
- Watch mode: `bun run dev`
- Normal run: `bun run start`
- Dev bootstrap helper: `bun run dev:setup`
### Static checks
- Typecheck: `bun run typecheck`
- Build: `bun run build`
- Combined check: `bun run check`
### Health checks
- `curl -s http://127.0.0.1:8787/healthz`
- `curl -i http://127.0.0.1:8787/readyz`
## Testing Commands
There is no first-party test suite in `src/` yet.
Use Bun test commands for new tests.
- Run all tests (if present): `bun test`
- Run a single test file: `bun test path/to/file.test.ts`
- Run one file in watch mode: `bun test --watch path/to/file.test.ts`
When adding tests, prefer colocated `*.test.ts` near implementation files.
## Cursor / Copilot Rules
Checked these paths:
- `.cursor/rules/`
- `.cursorrules`
- `.github/copilot-instructions.md`
No Cursor/Copilot rule files currently exist in this repo.
If added later, update this file and follow those rules.
## Code Style
### TypeScript and modules
- Keep code strict-TypeScript compatible.
- Use ESM imports/exports only.
- Prefer named exports over default exports.
- Add explicit return types on exported functions.
### Imports
- Group imports as: external first, then internal.
- Use `import type` for type-only imports.
- Keep import paths consistent with existing relative style.
### Formatting
- Match existing style:
- double quotes
- semicolons
- trailing commas where appropriate
- Keep functions small and focused.
- Avoid comments unless logic is non-obvious.
### Naming
- `camelCase`: variables/functions
- `PascalCase`: classes/interfaces/type aliases
- `UPPER_SNAKE_CASE`: env keys and constants
- Log events should be stable (`domain.action.result`).
### Types and contracts
- Reuse shared types from `src/types.ts`.
- Preserve `SessionStatus` semantics when adding new states.
- Prefer `unknown` over `any` at untrusted boundaries.
- Narrow and validate external data before use.
## Error Handling and Logging
- Use `logger` from `src/observability/logger.ts`.
- Do not add raw `console.log` in app paths.
- Include context fields when available:
- `threadId`
- `channelId`
- `guildId`
- `sandboxId`
- `sessionId`
- Fail fast on invalid config in `src/config.ts`.
- Wrap network/process operations in contextual `try/catch`.
- Separate recoverable errors from terminal errors.
- Never log secret values.
## Environment and Secrets
- Read env only through `getEnv()`.
- Update `.env.example` for env schema changes.
- Keep auth tokens out of command strings and logs.
- Pass runtime secrets via environment variables.
## Domain-Specific Rules
### Session lifecycle
- Session mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative.
- Resume existing sandbox/session before creating replacements.
- Recreate only when sandbox is unavailable/destroyed.
- If session changes, replay Discord thread history as fallback context.
### Daytona behavior
- `stop()` clears running processes but keeps filesystem state.
- `start()` requires process bootstrap (`opencode serve`).
- Keep lifecycle transitions deterministic and observable.
### OpenCode transport
- Keep preview token separate from persisted URL when possible.
- Send token using `x-daytona-preview-token` header.
- Keep retry loops bounded and configurable.
### Discord handler behavior
- Ignore bot/self chatter and respect mention/role gating.
- Preserve thread ownership checks for bot-managed threads.
- Keep outbound messages chunked for Discord size limits.
## Non-Obvious Discoveries
### OpenCode session persistence
- Sessions are disk-persistent JSON files in `~/.local/share/opencode/storage/session/<projectID>/`
- Sessions survive `opencode serve` restarts if filesystem intact AND process restarts from same git repo directory
- Sessions are scoped by `projectID` = git root commit hash (or `"global"` for non-git dirs)
- After `daytona.start()`, processes are guaranteed dead - always restart `opencode serve` immediately, don't wait for health first (`src/sandbox/manager.ts:400-420`)
### Session reattach debugging
- If `sessionExists()` returns false but sandbox filesystem is intact, search by title (`Discord thread <threadId>`) via `listSessions()` - session may exist under different ID due to OpenCode internal state changes
- Thread lock per `threadId` prevents concurrent create/resume races (`src/sandbox/manager.ts:614-632`)
- Never fall back to new sandbox when `daytona.start()` succeeds - filesystem is intact, only OpenCode process needs restart
### Discord + multiple processes
- Multiple bot processes with same `DISCORD_TOKEN` cause race conditions - one succeeds, others fail with `DiscordAPIError[160004]` (thread already created)
- PTY sessions with `exec bash -l` stay alive after command exits, leading to duplicate bot runtimes if not cleaned up
### Sandbox runtime auth
- Pass `GITHUB_TOKEN` as process env to `opencode serve` via `sandbox.process.executeCommand()` `env` parameter
- Never interpolate tokens into command strings - use `env` parameter in `exec()` options (`src/sandbox/manager.ts:27-72`)
## Agent Workflow Checklist
### Before coding
- Read related modules and follow existing patterns.
- Prefer narrow, minimal changes over broad refactors.
### During coding
- Keep behavior backwards-compatible unless intentionally changing it.
- Keep changes cohesive (schema + store + manager together).
- Add/update logs for important lifecycle branches.
### After coding
- Run `bun run typecheck`
- Run `bun run build`
- Run `bun run db:init` for schema-affecting changes
- Smoke-check health endpoints if bootstrap/runtime changed
## Git/PR Safety for Agents
- Do not commit or push unless explicitly requested.
- Do not amend commits unless explicitly requested.
- Avoid destructive git commands unless explicitly requested.
- Summaries should cite changed files and operational impact.

View File

@@ -0,0 +1,24 @@
# Stage 1: Install dependencies
FROM oven/bun:1.2-alpine AS deps
WORKDIR /app
COPY package.json bun.lock* ./
COPY web/package.json web/
COPY discord-bot/package.json discord-bot/
RUN bun install --frozen-lockfile
# Stage 2: Production image
FROM oven/bun:1.2-alpine AS runner
WORKDIR /app
COPY package.json bun.lock* ./
COPY web/package.json web/
COPY discord-bot/package.json discord-bot/
RUN bun install --production --frozen-lockfile
# Copy shared DB schema (discord-bot imports from main project)
COPY src/db/schema.ts src/db/schema.ts
# Copy discord bot source
COPY discord-bot/src/ discord-bot/src/
CMD ["bun", "run", "discord-bot/src/index.ts"]

122
packages/discord/README.md Normal file
View File

@@ -0,0 +1,122 @@
# OpenCord
Discord bot that provisions [Daytona](https://daytona.io) sandboxes running [OpenCode](https://opencode.ai) sessions. Each Discord thread gets its own isolated sandbox with full conversational context.
## How It Works
1. Mention the bot in an allowed channel
2. Bot creates a Discord thread and provisions a Daytona sandbox
3. OpenCode runs inside the sandbox, responding to messages in the thread
4. Inactive threads pause their sandbox automatically; activity resumes them
5. Conversational context is preserved across bot restarts
## Setup
### Prerequisites
- [Bun](https://bun.sh) installed
- A Discord bot application (see below)
- A [Daytona](https://daytona.io) account with API access
- An [OpenCode](https://opencode.ai) API key
### 1. Create a Discord Bot
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application
3. Go to **Bot** and click **Reset Token** — save this as `DISCORD_TOKEN`
4. Enable **Message Content Intent** under **Privileged Gateway Intents**
5. Go to **OAuth2 > URL Generator**, select the `bot` scope with permissions: **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, **Read Message History**
6. Use the generated URL to invite the bot to your server
### 2. Get Your API Keys
- **Daytona**: Sign up at [daytona.io](https://daytona.io) and generate an API key from your dashboard
- **OpenCode**: Get an API key from [opencode.ai](https://opencode.ai)
- **GitHub Token** (optional): A personal access token — enables authenticated `gh` CLI inside sandboxes
### 3. Configure and Run
```bash
bun install
cp .env.example .env
# Fill in required values (see below)
bun run db:init
bun run dev
```
### Environment Variables
#### Required
| Variable | Description |
| ---------------------- | ------------------------------------------- |
| `DISCORD_TOKEN` | Bot token from the Discord Developer Portal |
| `DAYTONA_API_KEY` | API key from your Daytona dashboard |
| `OPENCODE_ZEN_API_KEY` | API key from OpenCode |
#### Optional — Discord
| Variable | Default | Description |
| -------------------------- | --------- | ----------------------------------------------------------------------- |
| `ALLOWED_CHANNEL_IDS` | _(empty)_ | Comma-separated channel IDs where the bot listens. Empty = all channels |
| `DISCORD_CATEGORY_ID` | _(empty)_ | Restrict the bot to a specific channel category |
| `DISCORD_ROLE_ID` | _(empty)_ | Role ID that triggers the bot via @role mentions |
| `DISCORD_REQUIRED_ROLE_ID` | _(empty)_ | Role users must have to interact with the bot |
#### Optional — Storage & Runtime
| Variable | Default | Description |
| ---------------- | ---------------------------- | -------------------------------------------------- |
| `DATABASE_PATH` | `discord.sqlite` | Path to the local SQLite file |
| `GITHUB_TOKEN` | _(empty)_ | Injected into sandboxes for authenticated `gh` CLI |
| `OPENCODE_MODEL` | `opencode/claude-sonnet-4-5` | Model used inside OpenCode sessions |
#### Optional — Bot Behavior
| Variable | Default | Description |
| -------------------------- | ------------------ | ------------------------------------------------------------------------------ |
| `SANDBOX_REUSE_POLICY` | `resume_preferred` | `resume_preferred` or `recreate` |
| `SANDBOX_TIMEOUT_MINUTES` | `30` | Minutes of inactivity before pausing a sandbox |
| `PAUSED_TTL_MINUTES` | `180` | Minutes a paused sandbox lives before being destroyed |
| `RESUME_HEALTH_TIMEOUT_MS` | `120000` | Timeout (ms) when waiting for a sandbox to resume |
| `SANDBOX_CREATION_TIMEOUT` | `180` | Timeout (s) for sandbox creation |
| `TURN_ROUTING_MODE` | `ai` | How the bot decides if a message needs a response: `off`, `heuristic`, or `ai` |
| `TURN_ROUTING_MODEL` | `claude-haiku-4-5` | Model used for AI turn routing |
#### Optional — Observability
| Variable | Default | Description |
| ------------- | --------- | ----------------------------------- |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
| `LOG_PRETTY` | `false` | Pretty-print JSON logs |
| `HEALTH_HOST` | `0.0.0.0` | Host for the health HTTP server |
| `HEALTH_PORT` | `8787` | Port for the health HTTP server |
## Commands
| Command | Description |
| ------------------- | --------------------------- |
| `bun run dev` | Watch mode |
| `bun run start` | Production run |
| `bun run db:init` | Initialize/migrate database |
| `bun run typecheck` | TypeScript checks |
| `bun run build` | Bundle for deployment |
| `bun run check` | Typecheck + build |
## Health Endpoints
- `GET /healthz` — Liveness check (uptime, Discord status, active sessions)
- `GET /readyz` — Readiness check (200 when Discord connected, 503 otherwise)
## Architecture
```
Discord thread
└─ message-create handler
└─ SandboxManager.resolveSessionForMessage()
├─ active? → health check → reuse
├─ paused? → daytona.start() → restart opencode → reattach session
└─ missing? → create sandbox → clone repo → start opencode → new session
```
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.

View File

@@ -0,0 +1,27 @@
{
"name": "@opencode/discord",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"db:init": "bun run src/db/init.ts",
"dev": "bun run --watch src/index.ts",
"dev:setup": "bun run db:init",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --target=bun --outdir=dist",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck && bun run build"
},
"dependencies": {
"discord.js": "^14",
"@daytonaio/sdk": "latest",
"@opencode-ai/sdk": "latest",
"effect": "^3",
"zod": "^3"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,28 @@
You're a senior engineer on the OpenCode team. You're in a Discord channel where teammates and community members ask questions about the codebase. You have the full opencode repo cloned at your working directory.
This is an internal tool — people tag you to ask about how things work, where code lives, why something was built a certain way, or to get help debugging. Think of it like someone pinging you on Slack.
## Tone
- Just answer the question. Don't preface with "Based on my analysis" or "I'd be happy to help" or "Let me look into that for you." Just give the answer.
- Write like you're messaging a coworker. Lowercase is fine. Short paragraphs. No essays.
- Don't over-format. Use markdown for code blocks and the occasional list, but don't turn every response into a formatted document with headers and bullet points. Just talk.
- Be direct and opinionated when it makes sense. "yeah that's a bug" or "I'd just use X here" is better than hedging everything.
- If you don't know, say "not sure" or "I'd have to dig into that more." Don't make stuff up.
- Match the vibe. Quick question = quick answer. Detailed question = longer answer with code refs.
## What you do
- Search and read the codebase to answer questions
- Run git, grep, gh CLI to find things
- Reference specific files and line numbers like `src/tui/app.ts:142`
- Quote relevant code when it helps
- Explain architecture and design decisions based on what's actually in the code
## Rules
- **Search the code first.** Don't answer from memory — look it up and cite where things are.
- **Don't edit files unless someone explicitly asks you to.**
- **Keep it short.** Under 1500 chars unless the question actually needs a longer answer.
- **Summarize command output.** Don't paste raw terminal dumps.
- When you reference code, include the file path so people can go look at it.

View File

@@ -0,0 +1,61 @@
import { z } from "zod"
const envSchema = z.object({
DISCORD_TOKEN: z.string().min(1),
ALLOWED_CHANNEL_IDS: z
.string()
.default("")
.transform((s) =>
s
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0),
),
DISCORD_CATEGORY_ID: z.string().default(""),
DISCORD_ROLE_ID: z.string().default(""),
DISCORD_REQUIRED_ROLE_ID: z.string().default(""),
DATABASE_PATH: z.string().default("discord.sqlite"),
DAYTONA_API_KEY: z.string().min(1),
OPENCODE_ZEN_API_KEY: z.string().min(1),
GITHUB_TOKEN: z.string().default(""),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
LOG_PRETTY: z
.string()
.default("false")
.transform((value) => value.toLowerCase() === "true"),
HEALTH_HOST: z.string().default("0.0.0.0"),
HEALTH_PORT: z.coerce.number().default(8787),
TURN_ROUTING_MODE: z.enum(["off", "heuristic", "ai"]).default("ai"),
TURN_ROUTING_MODEL: z.string().default("claude-haiku-4-5"),
SANDBOX_REUSE_POLICY: z.enum(["resume_preferred", "recreate"]).default("resume_preferred"),
SANDBOX_TIMEOUT_MINUTES: z.coerce.number().default(30),
PAUSED_TTL_MINUTES: z.coerce.number().default(180),
RESUME_HEALTH_TIMEOUT_MS: z.coerce.number().default(120000),
SANDBOX_CREATION_TIMEOUT: z.coerce.number().default(180),
OPENCODE_MODEL: z.string().default("opencode/claude-sonnet-4-5"),
})
export type Env = z.infer<typeof envSchema>
let _config: Env | null = null
export function getEnv(): Env {
if (!_config) {
const result = envSchema.safeParse(process.env)
if (!result.success) {
console.error(
JSON.stringify({
ts: new Date().toISOString(),
level: "error",
event: "config.invalid",
component: "config",
message: "Invalid environment variables",
fieldErrors: result.error.flatten().fieldErrors,
}),
)
throw new Error("Invalid environment configuration")
}
_config = result.data
}
return _config
}

View File

@@ -0,0 +1,13 @@
import { Database } from "bun:sqlite"
import { getEnv } from "../config"
let _db: Database | null = null
export function getDb(): Database {
if (!_db) {
_db = new Database(getEnv().DATABASE_PATH, { create: true })
_db.exec("PRAGMA journal_mode = WAL;")
_db.exec("PRAGMA busy_timeout = 5000;")
}
return _db
}

View File

@@ -0,0 +1,76 @@
import type { Database } from "bun:sqlite"
import { getDb } from "./client"
import { logger } from "../observability/logger"
const PREFIX = "[db]"
export async function initializeDatabase(): Promise<void> {
const db = getDb()
db.exec(`CREATE TABLE IF NOT EXISTS discord_sessions (
thread_id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
sandbox_id TEXT NOT NULL,
session_id TEXT NOT NULL,
preview_url TEXT NOT NULL,
preview_token TEXT,
status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error')),
last_activity TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
pause_requested_at TEXT,
paused_at TEXT,
resume_attempted_at TEXT,
resumed_at TEXT,
destroyed_at TEXT,
last_health_ok_at TEXT,
last_error TEXT,
resume_fail_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
addColumn(db, "preview_token", "TEXT")
addColumn(db, "last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
addColumn(db, "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
addColumn(db, "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
addColumn(db, "pause_requested_at", "TEXT")
addColumn(db, "paused_at", "TEXT")
addColumn(db, "resume_attempted_at", "TEXT")
addColumn(db, "resumed_at", "TEXT")
addColumn(db, "destroyed_at", "TEXT")
addColumn(db, "last_health_ok_at", "TEXT")
addColumn(db, "last_error", "TEXT")
addColumn(db, "resume_fail_count", "INTEGER NOT NULL DEFAULT 0")
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
ON discord_sessions (status, last_activity)`)
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
ON discord_sessions (status, updated_at)`)
}
function addColumn(db: Database, name: string, definition: string): void {
if (hasColumn(db, name)) return
db.exec(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`)
}
function hasColumn(db: Database, name: string): boolean {
const rows = db.query("PRAGMA table_info(discord_sessions)").all() as Array<{ name: string }>
return rows.some((row) => row.name === name)
}
if (import.meta.main) {
initializeDatabase()
.then(() => {
logger.info({ event: "db.schema.ready", component: "db", message: `${PREFIX} Schema is ready` })
})
.catch((err) => {
logger.error({
event: "db.schema.failed",
component: "db",
message: `${PREFIX} Failed to initialize schema`,
error: err,
})
process.exit(1)
})
}

View File

@@ -0,0 +1,14 @@
import { Client, GatewayIntentBits, Partials } from "discord.js";
export function createDiscordClient(): Client {
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Channel],
});
return client;
}

View File

@@ -0,0 +1,70 @@
const MAX_MESSAGE_LENGTH = 1900;
/**
* Splits a long response into Discord-safe message chunks (<2000 chars).
* Splits at code block boundaries, paragraph breaks, or sentence ends.
* Handles unclosed code blocks across chunks.
*/
export function splitForDiscord(text: string): string[] {
if (!text || text.length === 0) return ["(No response)"];
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
const messages: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= MAX_MESSAGE_LENGTH) {
messages.push(remaining);
break;
}
// Find best split point
let splitAt = -1;
// Prefer splitting at end of code block
splitAt = remaining.lastIndexOf("\n```\n", MAX_MESSAGE_LENGTH);
if (splitAt !== -1) splitAt += 4; // include the closing ```\n
// Then paragraph break
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 2) {
const paraBreak = remaining.lastIndexOf("\n\n", MAX_MESSAGE_LENGTH);
if (paraBreak > MAX_MESSAGE_LENGTH / 2) splitAt = paraBreak;
}
// Then sentence end
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 2) {
const sentenceEnd = remaining.lastIndexOf(". ", MAX_MESSAGE_LENGTH);
if (sentenceEnd > MAX_MESSAGE_LENGTH / 2) splitAt = sentenceEnd + 1;
}
// Fallback: hard cut
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 4) {
splitAt = MAX_MESSAGE_LENGTH;
}
const chunk = remaining.slice(0, splitAt);
remaining = remaining.slice(splitAt).trimStart();
// Handle unclosed code blocks
const backtickCount = (chunk.match(/```/g) || []).length;
if (backtickCount % 2 !== 0) {
// Odd = unclosed code block
messages.push(chunk + "\n```");
remaining = "```\n" + remaining;
} else {
messages.push(chunk);
}
}
return messages.filter((m) => m.trim().length > 0);
}
/**
* Cleans up the response text for Discord.
* Strips any leading/trailing whitespace and limits consecutive newlines.
*/
export function cleanResponse(text: string): string {
return text
.trim()
.replace(/\n{4,}/g, "\n\n\n"); // max 3 consecutive newlines
}

View File

@@ -0,0 +1,321 @@
import type { Client, Message, TextChannel, ThreadChannel, GuildMember } from "discord.js";
import { ChannelType } from "discord.js";
import { getEnv } from "../../config";
import { cleanResponse, splitForDiscord } from "../format";
import { shouldRespondToOwnedThreadTurn } from "../turn-routing";
import { generateThreadName } from "../thread-name";
import type { SandboxManager } from "../../sandbox/manager";
import { logger } from "../../observability/logger";
/**
* Checks if a channel (or its parent for threads) is allowed.
* Allowed means: in the ALLOWED_CHANNEL_IDS list, OR in the DISCORD_CATEGORY_ID category.
*/
function isChannelAllowed(channelId: string, categoryId: string | null, env: ReturnType<typeof getEnv>): boolean {
if (env.ALLOWED_CHANNEL_IDS.length > 0 && env.ALLOWED_CHANNEL_IDS.includes(channelId)) {
return true;
}
if (env.DISCORD_CATEGORY_ID && categoryId === env.DISCORD_CATEGORY_ID) {
return true;
}
return false;
}
/**
* Checks if a user has the required role (if configured).
*/
function hasRequiredRole(member: GuildMember | null, env: ReturnType<typeof getEnv>): boolean {
if (!env.DISCORD_REQUIRED_ROLE_ID) return true; // no role requirement
if (!member) return false;
return member.roles.cache.has(env.DISCORD_REQUIRED_ROLE_ID);
}
const HISTORY_FETCH_LIMIT = 40;
const HISTORY_LINE_CHAR_LIMIT = 500;
const HISTORY_TOTAL_CHAR_LIMIT = 6000;
function normalizeHistoryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
async function buildHistoryReplayPrompt(
thread: ThreadChannel,
currentMessage: Message,
latestUserContent: string,
): Promise<{ prompt: string; historyCount: number }> {
try {
const fetched = await thread.messages.fetch({ limit: HISTORY_FETCH_LIMIT });
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
const lines: string[] = [];
for (const prior of ordered) {
if (prior.id === currentMessage.id || prior.system) continue;
let lineContent = normalizeHistoryText(prior.content);
if (!lineContent && prior.attachments.size > 0) {
const files = [...prior.attachments.values()].map((att) => att.name ?? "file").join(", ");
lineContent = `[attachments: ${files}]`;
}
if (!lineContent) continue;
if (lineContent.length > HISTORY_LINE_CHAR_LIMIT) {
lineContent = `${lineContent.slice(0, HISTORY_LINE_CHAR_LIMIT)}...`;
}
const role = prior.author.bot ? "assistant" : "user";
lines.push(`${role}: ${lineContent}`);
}
if (lines.length === 0) {
return { prompt: latestUserContent, historyCount: 0 };
}
const selected: string[] = [];
let totalChars = 0;
for (let i = lines.length - 1; i >= 0; i -= 1) {
const candidate = lines[i];
if (totalChars + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && selected.length > 0) break;
selected.unshift(candidate);
totalChars += candidate.length;
}
return {
prompt: [
"Conversation history from this same Discord thread (oldest to newest):",
selected.join("\n"),
"",
"Continue the same conversation and respond to the latest user message:",
latestUserContent,
].join("\n"),
historyCount: selected.length,
};
} catch {
return { prompt: latestUserContent, historyCount: 0 };
}
}
/**
* Creates a messageCreate event handler bound to the given SandboxManager.
*/
export function createMessageHandler(client: Client, sandboxManager: SandboxManager) {
const env = getEnv();
return async (message: Message): Promise<void> => {
if (message.author.bot) return;
// Check if the bot is mentioned (ignore @everyone and @here)
if (message.mentions.everyone) return;
const botUserId = client.user?.id ?? "";
const roleId = env.DISCORD_ROLE_ID;
const mentionedByUser = client.user ? message.mentions.has(client.user, { ignoreEveryone: true, ignoreRoles: false }) : false;
const mentionedByRole = roleId ? message.mentions.roles.has(roleId) : false;
const mentionedInContent = message.content.includes(`<@${botUserId}>`) || message.content.includes(`<@!${botUserId}>`);
const roleMentionInContent = roleId ? message.content.includes(`<@&${roleId}>`) : false;
const isMentioned = mentionedByUser || mentionedByRole || mentionedInContent || roleMentionInContent;
// In threads the bot owns, respond to ALL messages (no mention needed)
const isInThread = message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread;
let isOwnedThread = false;
if (isInThread && !isMentioned) {
isOwnedThread = await sandboxManager.hasTrackedThread(message.channelId);
if (isOwnedThread) {
const decision = await shouldRespondToOwnedThreadTurn({
mode: env.TURN_ROUTING_MODE,
model: env.TURN_ROUTING_MODEL,
apiKey: env.OPENCODE_ZEN_API_KEY,
content: message.content,
botUserId,
botRoleId: roleId,
mentionedUserIds: [...message.mentions.users.keys()],
mentionedRoleIds: [...message.mentions.roles.keys()],
});
if (!decision.shouldRespond) {
logger.info({
event: "discord.message.skipped.not_directed",
component: "message-handler",
message: "Skipped turn not directed at bot",
channelId: message.channelId,
userId: message.author.id,
reason: decision.reason,
});
return;
}
}
}
if (!isMentioned && !isOwnedThread) return;
// Check required role
const member = message.member;
if (!hasRequiredRole(member, env)) {
logger.info({
event: "discord.message.ignored.role",
component: "message-handler",
message: "Ignored message from user without required role",
channelId: message.channelId,
userId: message.author.id,
});
return;
}
logger.info({
event: "discord.message.triggered",
component: "message-handler",
message: "Bot triggered",
channelId: message.channelId,
userId: message.author.id,
isMentioned,
isOwnedThread,
contentLength: message.content.length,
});
// Strip mentions from content
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim();
if (!content) {
await message.reply("Tag me with a question!").catch(() => {});
return;
}
let thread: ThreadChannel;
let parentChannelId: string;
let parentCategoryId: string | null = null;
try {
if (isInThread) {
thread = message.channel as ThreadChannel;
parentChannelId = thread.parentId ?? "";
// Get the category from the parent channel
const parentChannel = thread.parent;
parentCategoryId = parentChannel?.parentId ?? null;
} else {
parentChannelId = message.channelId;
parentCategoryId = (message.channel as TextChannel).parentId ?? null;
if (!isChannelAllowed(parentChannelId, parentCategoryId, env)) {
return;
}
const threadName = await generateThreadName(content);
thread = await (message.channel as TextChannel).threads.create({
name: threadName,
startMessage: message,
autoArchiveDuration: 60,
});
}
// Check allowed for threads too
if (!isOwnedThread && !isChannelAllowed(parentChannelId, parentCategoryId, env)) {
return;
}
const threadId = thread.id;
const channelId = parentChannelId;
const guildId = message.guildId ?? "";
const trackedBeforeResolve = await sandboxManager.getTrackedSession(threadId);
// Typing indicator
let typingActive = true;
const sendTyping = () => {
if (typingActive) thread.sendTyping().catch(() => {});
};
sendTyping();
const typingInterval = setInterval(sendTyping, 8000);
try {
let session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
let historyPromptCache: { prompt: string; historyCount: number } | null = null;
const getHistoryPrompt = async (): Promise<{ prompt: string; historyCount: number }> => {
if (!historyPromptCache) {
historyPromptCache = await buildHistoryReplayPrompt(thread, message, content);
}
return historyPromptCache;
};
let promptForAgent = content;
if (trackedBeforeResolve && trackedBeforeResolve.sessionId !== session.sessionId) {
const replay = await getHistoryPrompt();
promptForAgent = replay.prompt;
logger.info({
event: "discord.context.replayed",
component: "message-handler",
message: "Replayed thread history into replacement session",
threadId,
previousSessionId: trackedBeforeResolve.sessionId,
sessionId: session.sessionId,
historyMessages: replay.historyCount,
});
}
let response: string;
try {
response = await sandboxManager.sendMessage(session, promptForAgent);
} catch (err: any) {
if (err?.recoverable && err?.message === "SANDBOX_DEAD") {
logger.warn({
event: "discord.message.recovering",
component: "message-handler",
message: "Recovering by resolving session again",
threadId,
});
await thread.send("*Session changed state, recovering...*").catch(() => {});
const sessionBeforeRecovery = session.sessionId;
session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
let recoveryPrompt = content;
if (sessionBeforeRecovery !== session.sessionId) {
const replay = await getHistoryPrompt();
recoveryPrompt = replay.prompt;
logger.info({
event: "discord.context.replayed",
component: "message-handler",
message: "Replayed thread history after recovery",
threadId,
previousSessionId: sessionBeforeRecovery,
sessionId: session.sessionId,
historyMessages: replay.historyCount,
});
}
response = await sandboxManager.sendMessage(session, recoveryPrompt);
} else {
throw err;
}
}
const cleaned = cleanResponse(response);
const chunks = splitForDiscord(cleaned);
for (const chunk of chunks) {
await thread.send(chunk);
}
} catch (err) {
logger.error({
event: "discord.message.failed",
component: "message-handler",
message: "Error handling message",
threadId,
error: err,
});
const errorMsg = err instanceof Error ? err.message : "Unknown error";
await thread.send(`Something went wrong: ${errorMsg}`).catch(() => {});
} finally {
typingActive = false;
clearInterval(typingInterval);
}
} catch (err) {
logger.error({
event: "discord.message.setup_failed",
component: "message-handler",
message: "Error processing message",
channelId: message.channelId,
error: err,
});
await message.reply("Something went wrong setting up the thread.").catch(() => {});
}
};
}

View File

@@ -0,0 +1,59 @@
import { getEnv } from "../config";
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
/**
* Uses Claude Haiku 4.5 via OpenCode Zen to generate a concise thread name
* from the user's message. Falls back to truncation on error.
*/
export async function generateThreadName(userMessage: string): Promise<string> {
try {
const env = getEnv();
const res = await fetch(ZEN_MESSAGES_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": env.OPENCODE_ZEN_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5",
max_tokens: 60,
messages: [
{
role: "user",
content: `Generate a short, descriptive thread title (max 90 chars) for this Discord question. Return ONLY the title, no quotes, no explanation.\n\nQuestion: ${userMessage}`,
},
],
}),
});
if (!res.ok) {
console.warn(`[thread-name] Zen API returned ${res.status}, falling back to truncation`);
return fallback(userMessage);
}
const data = await res.json() as {
content?: Array<{ type: string; text?: string }>;
};
const title = data.content
?.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("")
.trim();
if (!title || title.length === 0) return fallback(userMessage);
// Ensure it fits Discord's thread name limit (100 chars)
return title.slice(0, 95) + (title.length > 95 ? "..." : "");
} catch (err) {
console.warn("[thread-name] Failed to generate name:", err);
return fallback(userMessage);
}
}
function fallback(message: string): string {
return message.slice(0, 95) + (message.length > 95 ? "..." : "");
}

View File

@@ -0,0 +1,127 @@
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
export type TurnRoutingMode = "off" | "heuristic" | "ai";
type TurnRoutingInput = {
mode: TurnRoutingMode;
model: string;
apiKey: string;
content: string;
botUserId: string;
botRoleId: string;
mentionedUserIds: string[];
mentionedRoleIds: string[];
};
export type TurnRoutingDecision = {
shouldRespond: boolean;
reason: string;
};
const QUICK_CHAT_RE = /^(ok|okay|k|kk|thanks|thank you|thx|lol|lmao|haha|nice|cool|yup|yep|nah|nope|got it|sgtm)[!. ]*$/i;
function heuristicDecision(input: TurnRoutingInput): TurnRoutingDecision | null {
const text = input.content.trim();
const lower = text.toLowerCase();
if (!text) {
return { shouldRespond: false, reason: "empty-message" };
}
const mentionsOtherUser = input.mentionedUserIds.some((id) => id !== input.botUserId);
if (mentionsOtherUser) {
return { shouldRespond: false, reason: "mentions-other-user" };
}
const mentionsOtherRole = input.mentionedRoleIds.some((id) => id !== input.botRoleId);
if (mentionsOtherRole) {
return { shouldRespond: false, reason: "mentions-other-role" };
}
if (text.length <= 40 && QUICK_CHAT_RE.test(text)) {
return { shouldRespond: false, reason: "quick-chat" };
}
if (/\b(opencode|bot)\b/i.test(text)) {
return { shouldRespond: true, reason: "bot-keyword" };
}
if (text.includes("?") && /\b(you|your|can you|could you|would you|please|help)\b/i.test(text)) {
return { shouldRespond: true, reason: "direct-question" };
}
if (text.includes("?") && /\b(how|what|why|where|when|which)\b/i.test(text)) {
return { shouldRespond: true, reason: "general-question" };
}
if (lower.startsWith("do this") || lower.startsWith("run ") || lower.startsWith("fix ")) {
return { shouldRespond: true, reason: "instruction" };
}
return null;
}
async function aiDecision(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
const prompt = [
"You route turns for an engineering Discord bot.",
"Decide if the latest message is directed at the bot assistant or is side conversation.",
"Return EXACTLY one token: RESPOND or SKIP.",
"",
`Message: ${input.content}`,
`MentionsOtherUser: ${input.mentionedUserIds.some((id) => id !== input.botUserId)}`,
`MentionsOtherRole: ${input.mentionedRoleIds.some((id) => id !== input.botRoleId)}`,
].join("\n");
const res = await fetch(ZEN_MESSAGES_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": input.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: input.model,
max_tokens: 10,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
return { shouldRespond: true, reason: `ai-http-${res.status}` };
}
const data = await res.json() as { content?: Array<{ type: string; text?: string }> };
const output = data.content
?.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join(" ")
.trim()
.toUpperCase();
if (output?.includes("SKIP")) {
return { shouldRespond: false, reason: "ai-skip" };
}
return { shouldRespond: true, reason: output?.includes("RESPOND") ? "ai-respond" : "ai-default-respond" };
}
export async function shouldRespondToOwnedThreadTurn(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
if (input.mode === "off") {
return { shouldRespond: true, reason: "routing-off" };
}
const heuristic = heuristicDecision(input);
if (heuristic) {
return heuristic;
}
if (input.mode === "heuristic") {
return { shouldRespond: true, reason: "heuristic-uncertain-default-respond" };
}
try {
return await aiDecision(input);
} catch {
return { shouldRespond: true, reason: "ai-error-default-respond" };
}
}

View File

@@ -0,0 +1,43 @@
import type { Client } from "discord.js";
type HealthDependencies = {
client: Client;
isCleanupLoopRunning: () => boolean;
getActiveSessionCount: () => Promise<number>;
};
export function startHealthServer(host: string, port: number, deps: HealthDependencies): Bun.Server<unknown> {
const startedAt = Date.now();
return Bun.serve({
hostname: host,
port,
fetch: async (request) => {
const url = new URL(request.url);
if (url.pathname === "/healthz") {
const activeSessions = await deps.getActiveSessionCount().catch(() => 0);
return Response.json({
ok: true,
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
discordReady: deps.client.isReady(),
cleanupLoopRunning: deps.isCleanupLoopRunning(),
activeSessions,
});
}
if (url.pathname === "/readyz") {
const ready = deps.client.isReady();
return Response.json(
{
ok: ready,
discordReady: ready,
},
{ status: ready ? 200 : 503 },
);
}
return new Response("Not Found", { status: 404 });
},
});
}

View File

@@ -0,0 +1,83 @@
import { getEnv } from "./config";
import { createDiscordClient } from "./discord/client";
import { createMessageHandler } from "./discord/handlers/message-create";
import { initializeDatabase } from "./db/init";
import { startHealthServer } from "./http/health";
import { logger } from "./observability/logger";
import { SandboxManager } from "./sandbox/manager";
async function main() {
const env = getEnv();
logger.info({ event: "app.starting", component: "index", message: "Starting Discord bot" });
await initializeDatabase();
logger.info({ event: "db.ready", component: "index", message: "Database ready" });
const client = createDiscordClient();
const sandboxManager = new SandboxManager();
const healthServer = startHealthServer(env.HEALTH_HOST, env.HEALTH_PORT, {
client,
isCleanupLoopRunning: () => sandboxManager.isCleanupLoopRunning(),
getActiveSessionCount: () => sandboxManager.getActiveSessionCount(),
});
logger.info({
event: "health.server.started",
component: "index",
message: "Health server started",
host: env.HEALTH_HOST,
port: env.HEALTH_PORT,
});
// Register message handler
const messageHandler = createMessageHandler(client, sandboxManager);
client.on("messageCreate", messageHandler);
// Ready event
client.on("clientReady", () => {
logger.info({
event: "discord.ready",
component: "index",
message: "Discord client ready",
tag: client.user?.tag,
allowedChannels: env.ALLOWED_CHANNEL_IDS,
});
sandboxManager.startCleanupLoop();
});
// Login
await client.login(env.DISCORD_TOKEN);
let shuttingDown = false;
// Graceful shutdown
const shutdown = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info({
event: "app.shutdown.start",
component: "index",
message: "Shutting down",
signal,
});
healthServer.stop();
sandboxManager.stopCleanupLoop();
await sandboxManager.destroyAll();
client.destroy();
logger.info({ event: "app.shutdown.complete", component: "index", message: "Shutdown complete" });
process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
main().catch((err) => {
logger.error({
event: "app.fatal",
component: "index",
message: "Fatal error",
error: err,
});
process.exit(1);
});

View File

@@ -0,0 +1,76 @@
import { getEnv } from "../config";
type LogLevel = "debug" | "info" | "warn" | "error";
type LogFields = {
event: string;
message: string;
component?: string;
threadId?: string;
channelId?: string;
guildId?: string;
sandboxId?: string;
sessionId?: string;
durationMs?: number;
[key: string]: unknown;
};
const levelOrder: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function shouldLog(level: LogLevel): boolean {
const env = getEnv();
return levelOrder[level] >= levelOrder[env.LOG_LEVEL];
}
function serializeError(err: unknown) {
if (!(err instanceof Error)) return err;
return {
name: err.name,
message: err.message,
stack: err.stack,
};
}
function write(level: LogLevel, fields: LogFields): void {
if (!shouldLog(level)) return;
const env = getEnv();
const payload = {
ts: new Date().toISOString(),
level,
...fields,
};
if (env.LOG_PRETTY) {
const line = `[${payload.ts}] ${level.toUpperCase()} ${fields.event} ${fields.message}`;
console.log(line, JSON.stringify(payload));
return;
}
console.log(JSON.stringify(payload));
}
export const logger = {
debug(fields: LogFields) {
write("debug", fields);
},
info(fields: LogFields) {
write("info", fields);
},
warn(fields: LogFields) {
write("warn", fields);
},
error(fields: LogFields & { error?: unknown }) {
write("error", {
...fields,
error: fields.error ? serializeError(fields.error) : undefined,
});
},
};
export type { LogFields, LogLevel };

View File

@@ -0,0 +1,15 @@
import { Image } from "@daytonaio/sdk";
/**
* Custom Daytona sandbox image with git, gh CLI, opencode, and bun.
* Cached by Daytona for 24h — subsequent creates are near-instant.
*/
export function getDiscordBotImage() {
return Image.base("node:22-bookworm-slim")
.runCommands(
"apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*",
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*",
"npm install -g opencode-ai@latest bun",
)
.workdir("/home/daytona");
}

View File

@@ -0,0 +1,735 @@
import { Daytona } from "@daytonaio/sdk";
import { readFileSync } from "node:fs";
import { getEnv } from "../config";
import { logger } from "../observability/logger";
import { getSessionStore } from "../sessions/store";
import type { SessionInfo, SessionStatus } from "../types";
import { getDiscordBotImage } from "./image";
import { createSession, listSessions, sendPrompt, sessionExists, waitForHealthy } from "./opencode-client";
/** In-memory timeout handles keyed by threadId */
const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
type ResumeAttemptResult = {
session: SessionInfo | null;
allowRecreate: boolean;
};
function timer() {
const start = Date.now();
return {
elapsedMs: () => Date.now() - start,
};
}
function createDaytona() {
return new Daytona({
apiKey: getEnv().DAYTONA_API_KEY,
_experimental: {},
});
}
function isSandboxMissingError(error: unknown): boolean {
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return message.includes("not found") || message.includes("does not exist") || message.includes("destroyed");
}
async function exec(
sandbox: {
process: {
executeCommand: (
cmd: string,
cwd?: string,
env?: Record<string, string>,
timeout?: number,
) => Promise<{ exitCode: number; result: string }>;
};
},
label: string,
command: string,
context: Pick<SessionInfo, "threadId" | "sandboxId">,
options?: { cwd?: string; env?: Record<string, string> },
): Promise<string> {
const t = timer();
const result = await sandbox.process.executeCommand(command, options?.cwd, options?.env);
if (result.exitCode !== 0) {
logger.error({
event: "sandbox.exec.failed",
component: "sandbox-manager",
message: "Sandbox command failed",
threadId: context.threadId,
sandboxId: context.sandboxId,
label,
exitCode: result.exitCode,
durationMs: t.elapsedMs(),
stdout: result.result.slice(0, 500),
});
throw new Error(`${label} failed (exit ${result.exitCode})`);
}
logger.debug({
event: "sandbox.exec.ok",
component: "sandbox-manager",
message: "Sandbox command completed",
threadId: context.threadId,
sandboxId: context.sandboxId,
label,
durationMs: t.elapsedMs(),
});
return result.result.trim();
}
export class SandboxManager {
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
private readonly store = getSessionStore();
private readonly threadLocks = new Map<string, Promise<void>>();
async getActiveSessionCount(): Promise<number> {
return (await this.store.listActive()).length;
}
isCleanupLoopRunning(): boolean {
return this.cleanupInterval !== null;
}
async hasTrackedThread(threadId: string): Promise<boolean> {
return this.store.hasTrackedThread(threadId);
}
async getTrackedSession(threadId: string): Promise<SessionInfo | null> {
return this.store.getByThread(threadId);
}
async getSession(threadId: string): Promise<SessionInfo | null> {
return this.store.getActive(threadId);
}
async resolveSessionForMessage(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
return this.withThreadLock(threadId, async () => {
const existing = await this.store.getByThread(threadId);
const env = getEnv();
if (!existing) {
return this.createSessionUnlocked(threadId, channelId, guildId);
}
let candidate = existing;
if (candidate.status === "active") {
const healthy = await this.ensureSessionHealthy(candidate, 15_000);
if (healthy) return candidate;
candidate = (await this.store.getByThread(threadId)) ?? { ...candidate, status: "error" };
}
if (env.SANDBOX_REUSE_POLICY === "resume_preferred") {
const resumed = await this.tryResumeSession(candidate);
if (resumed.session) return resumed.session;
if (!resumed.allowRecreate) {
throw new Error("Unable to reattach to existing sandbox session. Try again shortly.");
}
}
return this.createSessionUnlocked(threadId, channelId, guildId);
});
}
async createSession(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
return this.withThreadLock(threadId, async () => this.createSessionUnlocked(threadId, channelId, guildId));
}
private async createSessionUnlocked(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
const env = getEnv();
const totalTimer = timer();
await this.store.updateStatus(threadId, "creating").catch(() => {});
const daytona = createDaytona();
const image = getDiscordBotImage();
const sandbox = await daytona.create(
{
image,
labels: {
app: "opencord",
threadId,
guildId,
},
autoStopInterval: 0,
autoArchiveInterval: 0,
},
{ timeout: env.SANDBOX_CREATION_TIMEOUT },
);
const sandboxId = sandbox.id;
logger.info({
event: "sandbox.create.started",
component: "sandbox-manager",
message: "Created sandbox",
threadId,
channelId,
guildId,
sandboxId,
});
try {
const context = { threadId, sandboxId };
const home = await exec(sandbox, "discover-home", "echo $HOME", context);
await exec(
sandbox,
"clone-opencode",
`git clone --depth=1 https://github.com/anomalyco/opencode.git ${home}/opencode`,
context,
);
const authJson = JSON.stringify({
opencode: { type: "api", key: env.OPENCODE_ZEN_API_KEY },
});
await exec(
sandbox,
"write-auth",
`mkdir -p ${home}/.local/share/opencode && cat > ${home}/.local/share/opencode/auth.json << 'AUTHEOF'\n${authJson}\nAUTHEOF`,
context,
);
const agentPromptPath = new URL("../agent-prompt.md", import.meta.url);
const agentPrompt = readFileSync(agentPromptPath, "utf-8");
const opencodeConfig = JSON.stringify({
model: env.OPENCODE_MODEL,
share: "disabled",
permission: "allow",
agent: {
build: {
mode: "primary",
prompt: agentPrompt,
},
},
});
const configB64 = Buffer.from(opencodeConfig).toString("base64");
await exec(
sandbox,
"write-config",
`echo "${configB64}" | base64 -d > ${home}/opencode/opencode.json`,
context,
);
const opencodeEnv = this.buildRuntimeEnv();
const githubToken = opencodeEnv.GITHUB_TOKEN ?? "";
logger.info({
event: "sandbox.github.auth",
component: "sandbox-manager",
message: githubToken.length > 0
? "Configured authenticated gh CLI in sandbox runtime"
: "Running sandbox gh CLI unauthenticated (no GITHUB_TOKEN provided)",
threadId,
sandboxId,
authenticated: githubToken.length > 0,
});
await exec(
sandbox,
"start-opencode",
"setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 &",
context,
{
cwd: `${home}/opencode`,
env: opencodeEnv,
},
);
await new Promise((resolve) => setTimeout(resolve, 3000));
const preview = await sandbox.getPreviewLink(4096);
const previewUrl = preview.url.replace(/\/$/, "");
const previewToken = preview.token ?? null;
const healthy = await waitForHealthy({ previewUrl, previewToken }, 120_000);
if (!healthy) {
const startupLog = await exec(sandbox, "read-opencode-log", "cat /tmp/opencode.log 2>/dev/null | tail -100", context);
throw new Error(`OpenCode server did not become healthy: ${startupLog.slice(0, 400)}`);
}
const sessionId = await createSession({ previewUrl, previewToken }, `Discord thread ${threadId}`);
const session: SessionInfo = {
threadId,
channelId,
guildId,
sandboxId,
sessionId,
previewUrl,
previewToken,
status: "active",
};
await this.store.upsert(session);
await this.store.markHealthOk(threadId);
this.resetTimeout(threadId);
logger.info({
event: "sandbox.create.ready",
component: "sandbox-manager",
message: "Session is ready",
threadId,
channelId,
guildId,
sandboxId,
sessionId,
durationMs: totalTimer.elapsedMs(),
});
return session;
} catch (error) {
logger.error({
event: "sandbox.create.failed",
component: "sandbox-manager",
message: "Failed to create session",
threadId,
channelId,
guildId,
sandboxId,
durationMs: totalTimer.elapsedMs(),
error,
});
await this.store.updateStatus(threadId, "error", error instanceof Error ? error.message : String(error)).catch(() => {});
await daytona.delete(sandbox).catch(() => {});
throw error;
}
}
async sendMessage(session: SessionInfo, text: string): Promise<string> {
return this.withThreadLock(session.threadId, async () => {
const t = timer();
await this.store.markActivity(session.threadId);
this.resetTimeout(session.threadId);
try {
const response = await sendPrompt(
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
session.sessionId,
text,
);
logger.info({
event: "session.message.ok",
component: "sandbox-manager",
message: "Message processed",
threadId: session.threadId,
sandboxId: session.sandboxId,
sessionId: session.sessionId,
durationMs: t.elapsedMs(),
responseChars: response.length,
});
return response;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const sessionMissing = message.includes("Failed to send prompt (404");
const recoverable =
message.includes("no IP address found") ||
message.includes("Is the Sandbox started") ||
message.includes("sandbox not found") ||
message.includes("Failed to send prompt (5") ||
sessionMissing;
if (recoverable) {
await this.store.incrementResumeFailure(session.threadId, message);
if (sessionMissing) {
await this.store.updateStatus(session.threadId, "error", "opencode-session-missing").catch(() => {});
} else {
await this.pauseSessionUnlocked(session.threadId, "recoverable send failure").catch(() => {});
}
const recoveryError = new Error("SANDBOX_DEAD");
(recoveryError as any).recoverable = true;
throw recoveryError;
}
throw error;
}
});
}
async pauseSession(threadId: string, reason = "manual"): Promise<void> {
await this.withThreadLock(threadId, async () => {
await this.pauseSessionUnlocked(threadId, reason);
});
}
private async pauseSessionUnlocked(threadId: string, reason: string): Promise<void> {
const session = await this.store.getByThread(threadId);
if (!session) return;
if (session.status === "paused") return;
await this.store.updateStatus(threadId, "pausing", reason);
try {
const daytona = createDaytona();
const sandbox = await daytona.get(session.sandboxId);
await daytona.stop(sandbox);
await this.store.updateStatus(threadId, "paused", null);
this.clearTimeout(threadId);
logger.info({
event: "sandbox.paused",
component: "sandbox-manager",
message: "Paused sandbox",
threadId,
sandboxId: session.sandboxId,
reason,
});
} catch (error) {
await this.store.updateStatus(threadId, "destroyed", error instanceof Error ? error.message : String(error));
logger.warn({
event: "sandbox.pause.missing",
component: "sandbox-manager",
message: "Sandbox unavailable while pausing; marked destroyed",
threadId,
sandboxId: session.sandboxId,
});
}
}
private async tryResumeSession(session: SessionInfo): Promise<ResumeAttemptResult> {
if (!["paused", "destroyed", "error", "pausing", "resuming"].includes(session.status)) {
return { session: null, allowRecreate: true };
}
await this.store.updateStatus(session.threadId, "resuming");
const daytona = createDaytona();
let sandbox: Awaited<ReturnType<typeof daytona.get>>;
try {
sandbox = await daytona.get(session.sandboxId);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
await this.store.updateStatus(session.threadId, "destroyed", errorMessage).catch(() => {});
logger.warn({
event: "sandbox.resume.sandbox_missing",
component: "sandbox-manager",
message: "Sandbox missing during resume; safe to recreate",
threadId: session.threadId,
sandboxId: session.sandboxId,
errorMessage,
});
return { session: null, allowRecreate: true };
}
try {
await daytona.start(sandbox, getEnv().SANDBOX_CREATION_TIMEOUT);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
const allowRecreate = isSandboxMissingError(error);
logger.warn({
event: "sandbox.resume.start_failed",
component: "sandbox-manager",
message: allowRecreate
? "Sandbox no longer exists while starting; safe to recreate"
: "Sandbox start failed; refusing automatic recreate to avoid context loss",
threadId: session.threadId,
sandboxId: session.sandboxId,
errorMessage,
allowRecreate,
});
return { session: null, allowRecreate };
}
try {
const preview = await sandbox.getPreviewLink(4096);
const previewUrl = preview.url.replace(/\/$/, "");
const previewToken = preview.token ?? null;
const context = { threadId: session.threadId, sandboxId: session.sandboxId };
logger.info({
event: "sandbox.resume.restarting_opencode",
component: "sandbox-manager",
message: "Restarting opencode serve after sandbox start",
threadId: session.threadId,
sandboxId: session.sandboxId,
});
await exec(
sandbox,
"restart-opencode-serve",
"pkill -f 'opencode serve --port 4096' >/dev/null 2>&1 || true; for d in \"$HOME/opencode\" \"/home/daytona/opencode\" \"/root/opencode\"; do if [ -d \"$d\" ]; then cd \"$d\" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1",
context,
{ env: this.buildRuntimeEnv() },
);
const healthy = await waitForHealthy(
{ previewUrl, previewToken },
getEnv().RESUME_HEALTH_TIMEOUT_MS,
);
if (!healthy) {
const startupLog = await exec(
sandbox,
"read-opencode-log-after-resume",
"cat /tmp/opencode.log 2>/dev/null | tail -120",
context,
).catch(() => "(unable to read opencode log)");
const errorMessage = `OpenCode health check failed after resume. Log: ${startupLog.slice(0, 500)}`;
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
logger.error({
event: "sandbox.resume.health_failed",
component: "sandbox-manager",
message: "OpenCode did not become healthy after restart; refusing recreate",
threadId: session.threadId,
sandboxId: session.sandboxId,
errorMessage,
});
return { session: null, allowRecreate: false };
}
let sessionId = session.sessionId;
const existingSession = await sessionExists({ previewUrl, previewToken }, sessionId);
if (!existingSession) {
const expectedTitle = `Discord thread ${session.threadId}`;
const sessions = await listSessions({ previewUrl, previewToken }, 50).catch(() => []);
const replacement = sessions
.filter((candidate) => candidate.title === expectedTitle)
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
if (replacement) {
sessionId = replacement.id;
logger.info({
event: "sandbox.resume.session_reused_by_title",
component: "sandbox-manager",
message: "Reattached to existing session by title",
threadId: session.threadId,
sandboxId: session.sandboxId,
previousSessionId: session.sessionId,
sessionId,
});
} else {
logger.warn({
event: "sandbox.resume.session_missing",
component: "sandbox-manager",
message: "OpenCode session missing after resume; creating replacement session",
threadId: session.threadId,
sandboxId: session.sandboxId,
sessionId,
});
sessionId = await createSession({ previewUrl, previewToken }, expectedTitle);
}
}
const resumed: SessionInfo = {
...session,
sessionId,
previewUrl,
previewToken,
status: "active",
};
await this.store.upsert(resumed);
await this.store.markHealthOk(session.threadId);
this.resetTimeout(session.threadId);
logger.info({
event: "sandbox.resumed",
component: "sandbox-manager",
message: "Resumed existing sandbox",
threadId: session.threadId,
sandboxId: session.sandboxId,
previousSessionId: session.sessionId,
sessionId,
sessionReattached: sessionId === session.sessionId,
});
return { session: resumed, allowRecreate: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
logger.warn({
event: "sandbox.resume.failed",
component: "sandbox-manager",
message: "Resume failed after sandbox start; refusing automatic recreate",
threadId: session.threadId,
sandboxId: session.sandboxId,
errorMessage,
});
return { session: null, allowRecreate: false };
}
}
async destroySession(threadId: string): Promise<void> {
await this.withThreadLock(threadId, async () => {
const session = await this.store.getByThread(threadId);
if (!session) return;
await this.store.updateStatus(threadId, "destroying");
try {
const daytona = createDaytona();
const sandbox = await daytona.get(session.sandboxId);
await daytona.delete(sandbox);
} catch {
// no-op
}
await this.store.updateStatus(threadId, "destroyed");
this.clearTimeout(threadId);
});
}
resetTimeout(threadId: string): void {
this.clearTimeout(threadId);
const timeoutMs = getEnv().SANDBOX_TIMEOUT_MINUTES * 60 * 1000;
const handle = setTimeout(async () => {
timeouts.delete(threadId);
await this.pauseSession(threadId, "inactivity-timeout").catch((error) => {
logger.error({
event: "sandbox.pause.timeout.failed",
component: "sandbox-manager",
message: "Failed to pause sandbox on inactivity timeout",
threadId,
error,
});
});
}, timeoutMs);
timeouts.set(threadId, handle);
}
private clearTimeout(threadId: string): void {
const existing = timeouts.get(threadId);
if (!existing) return;
clearTimeout(existing);
timeouts.delete(threadId);
}
startCleanupLoop(): void {
const intervalMs = 5 * 60 * 1000;
this.cleanupInterval = setInterval(async () => {
try {
const env = getEnv();
const staleActive = await this.store.listStaleActive(env.SANDBOX_TIMEOUT_MINUTES + 5);
for (const session of staleActive) {
await this.pauseSession(session.threadId, "cleanup-stale-active");
}
const expiredPaused = await this.store.listExpiredPaused(env.PAUSED_TTL_MINUTES);
for (const session of expiredPaused) {
await this.destroySession(session.threadId);
}
} catch (error) {
logger.error({
event: "cleanup.loop.failed",
component: "sandbox-manager",
message: "Cleanup loop failed",
error,
});
}
}, intervalMs);
logger.info({
event: "cleanup.loop.started",
component: "sandbox-manager",
message: "Started cleanup loop",
intervalMs,
timeoutMinutes: getEnv().SANDBOX_TIMEOUT_MINUTES,
pausedTtlMinutes: getEnv().PAUSED_TTL_MINUTES,
});
}
stopCleanupLoop(): void {
if (!this.cleanupInterval) return;
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
async destroyAll(): Promise<void> {
const active = await this.store.listActive();
await Promise.allSettled(active.map((session) => this.pauseSession(session.threadId, "shutdown")));
}
private buildRuntimeEnv(): Record<string, string> {
const env = getEnv();
const runtimeEnv: Record<string, string> = {};
const githubToken = env.GITHUB_TOKEN.trim();
if (githubToken.length > 0) {
runtimeEnv.GH_TOKEN = githubToken;
runtimeEnv.GITHUB_TOKEN = githubToken;
}
return runtimeEnv;
}
private async ensureSessionHealthy(session: SessionInfo, maxWaitMs: number): Promise<boolean> {
const healthy = await waitForHealthy(
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
maxWaitMs,
);
if (!healthy) {
await this.store.incrementResumeFailure(session.threadId, "active-session-healthcheck-failed").catch(() => {});
await this.store.updateStatus(session.threadId, "error", "active-session-healthcheck-failed").catch(() => {});
return false;
}
const attached = await sessionExists(
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
session.sessionId,
).catch(() => false);
if (!attached) {
await this.store.incrementResumeFailure(session.threadId, "active-session-missing").catch(() => {});
await this.store.updateStatus(session.threadId, "error", "active-session-missing").catch(() => {});
return false;
}
await this.store.markHealthOk(session.threadId).catch(() => {});
return true;
}
private async withThreadLock<T>(threadId: string, fn: () => Promise<T>): Promise<T> {
const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
this.threadLocks.set(threadId, previous.then(() => current));
await previous;
try {
return await fn();
} finally {
release();
if (this.threadLocks.get(threadId) === current) {
this.threadLocks.delete(threadId);
}
}
}
}
export type { SessionStatus };

View File

@@ -0,0 +1,198 @@
import { Effect, Schedule } from "effect";
import { logger } from "../observability/logger";
type PreviewAccess = {
previewUrl: string;
previewToken?: string | null;
};
export type OpenCodeSessionSummary = {
id: string;
title: string;
updatedAt?: number;
};
/**
* Parse a Daytona preview URL into base URL and token.
* Preview URLs look like: https://4096-xxx.proxy.daytona.works?tkn=abc123
*/
function parsePreview(input: string | PreviewAccess): { base: string; token: string | null } {
const previewUrl = typeof input === "string" ? input : input.previewUrl;
const url = new URL(previewUrl);
const token = typeof input === "string"
? url.searchParams.get("tkn")
: (input.previewToken ?? url.searchParams.get("tkn"));
url.searchParams.delete("tkn");
return { base: url.toString().replace(/\/$/, ""), token };
}
/**
* Fetch wrapper that properly handles Daytona preview URL token auth.
* Sends token as x-daytona-preview-token header.
*/
async function previewFetch(preview: string | PreviewAccess, path: string, init?: RequestInit): Promise<Response> {
const { base, token } = parsePreview(preview);
const url = `${base}${path}`;
const headers = new Headers(init?.headers);
if (token) {
headers.set("x-daytona-preview-token", token);
}
return fetch(url, { ...init, headers });
}
/**
* Waits for the OpenCode server inside a sandbox to become healthy.
* Polls GET /global/health every 2s up to maxWaitMs.
*/
export async function waitForHealthy(preview: string | PreviewAccess, maxWaitMs = 120_000): Promise<boolean> {
const start = Date.now();
let lastStatus = "";
const poll = Effect.tryPromise(async () => {
const res = await previewFetch(preview, "/global/health");
lastStatus = `${res.status}`;
if (res.ok) {
const body = await res.json() as { healthy?: boolean };
if (body.healthy) return true;
lastStatus = `200 but healthy=${body.healthy}`;
throw new Error(lastStatus);
}
const body = await res.text().catch(() => "");
lastStatus = `${res.status}: ${body.slice(0, 150)}`;
throw new Error(lastStatus);
}).pipe(
Effect.tapError(() =>
Effect.sync(() => {
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
logger.warn({
event: "opencode.health.poll",
component: "opencode-client",
message: "Health check poll failed",
elapsedSec: Number(elapsed),
lastStatus,
});
}),
),
);
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000));
return Effect.runPromise(
poll.pipe(
Effect.retry(
Schedule.intersect(
Schedule.spaced("2 seconds"),
Schedule.recurs(maxAttempts - 1),
),
),
Effect.as(true),
Effect.catchAll(() =>
Effect.sync(() => {
logger.error({
event: "opencode.health.failed",
component: "opencode-client",
message: "Health check failed",
maxWaitMs,
lastStatus,
});
return false;
}),
),
),
);
}
/**
* Creates a new OpenCode session and returns the session ID.
*/
export async function createSession(preview: string | PreviewAccess, title: string): Promise<string> {
const res = await previewFetch(preview, "/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Failed to create session (${res.status}): ${body}`);
}
const session = await res.json() as { id: string };
return session.id;
}
export async function sessionExists(preview: string | PreviewAccess, sessionId: string): Promise<boolean> {
const res = await previewFetch(preview, `/session/${sessionId}`, {
method: "GET",
});
if (res.ok) return true;
if (res.status === 404) return false;
const body = await res.text().catch(() => "");
throw new Error(`Failed to check session (${res.status}): ${body}`);
}
export async function listSessions(preview: string | PreviewAccess, limit = 50): Promise<OpenCodeSessionSummary[]> {
const query = limit > 0 ? `?limit=${limit}` : "";
const res = await previewFetch(preview, `/session${query}`, {
method: "GET",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
}
const sessions = await res.json() as Array<{
id?: string;
title?: string;
time?: { updated?: number };
}>;
return sessions
.filter((session) => typeof session.id === "string")
.map((session) => ({
id: session.id as string,
title: session.title ?? "",
updatedAt: session.time?.updated,
}));
}
/**
* Sends a prompt to an existing session and returns the text response.
* This call blocks until the agent finishes processing.
*/
export async function sendPrompt(preview: string | PreviewAccess, sessionId: string, text: string): Promise<string> {
const res = await previewFetch(preview, `/session/${sessionId}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parts: [{ type: "text", text }],
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Failed to send prompt (${res.status}): ${body}`);
}
const result = await res.json() as { parts?: Array<{ type: string; text?: string; content?: string }> };
const parts = result.parts ?? [];
const textContent = parts
.filter((p) => p.type === "text")
.map((p) => p.text || p.content || "")
.filter(Boolean);
return textContent.join("\n\n") || "(No response from agent)";
}
/**
* Aborts a running session.
*/
export async function abortSession(preview: string | PreviewAccess, sessionId: string): Promise<void> {
await previewFetch(preview, `/session/${sessionId}/abort`, { method: "POST" }).catch(() => {});
}

View File

@@ -0,0 +1,321 @@
import { getDb } from "../db/client"
import type { SessionInfo, SessionStatus } from "../types"
type SessionRow = {
thread_id: string
channel_id: string
guild_id: string
sandbox_id: string
session_id: string
preview_url: string
preview_token: string | null
status: SessionStatus
last_error: string | null
resume_fail_count: number
}
export interface SessionStore {
upsert(session: SessionInfo): Promise<void>
getByThread(threadId: string): Promise<SessionInfo | null>
hasTrackedThread(threadId: string): Promise<boolean>
getActive(threadId: string): Promise<SessionInfo | null>
markActivity(threadId: string): Promise<void>
markHealthOk(threadId: string): Promise<void>
updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void>
incrementResumeFailure(threadId: string, lastError: string): Promise<void>
listActive(): Promise<SessionInfo[]>
listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]>
listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]>
}
class SqliteSessionStore implements SessionStore {
private readonly db = getDb()
async upsert(session: SessionInfo): Promise<void> {
this.db
.query(
`
INSERT INTO discord_sessions (
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
last_activity,
resumed_at,
created_at,
updated_at
) VALUES (
?,
?,
?,
?,
?,
?,
?,
?,
?,
CURRENT_TIMESTAMP,
CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT(thread_id)
DO UPDATE SET
channel_id = excluded.channel_id,
guild_id = excluded.guild_id,
sandbox_id = excluded.sandbox_id,
session_id = excluded.session_id,
preview_url = excluded.preview_url,
preview_token = excluded.preview_token,
status = excluded.status,
last_error = excluded.last_error,
last_activity = CURRENT_TIMESTAMP,
resumed_at = CASE WHEN excluded.status = 'active' THEN CURRENT_TIMESTAMP ELSE discord_sessions.resumed_at END,
updated_at = CURRENT_TIMESTAMP
`,
)
.run(
session.threadId,
session.channelId,
session.guildId,
session.sandboxId,
session.sessionId,
session.previewUrl,
session.previewToken,
session.status,
session.lastError ?? null,
session.status,
)
}
async getByThread(threadId: string): Promise<SessionInfo | null> {
const row = this.db
.query(
`
SELECT
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
resume_fail_count
FROM discord_sessions
WHERE thread_id = ?
LIMIT 1
`,
)
.get(threadId) as SessionRow | null
if (!row) return null
return toSessionInfo(row)
}
async hasTrackedThread(threadId: string): Promise<boolean> {
const row = this.db
.query(
`
SELECT thread_id
FROM discord_sessions
WHERE thread_id = ?
LIMIT 1
`,
)
.get(threadId) as { thread_id: string } | null
return Boolean(row)
}
async getActive(threadId: string): Promise<SessionInfo | null> {
const row = this.db
.query(
`
SELECT
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
resume_fail_count
FROM discord_sessions
WHERE thread_id = ?
AND status = 'active'
LIMIT 1
`,
)
.get(threadId) as SessionRow | null
if (!row) return null
return toSessionInfo(row)
}
async markActivity(threadId: string): Promise<void> {
this.db
.query(
`
UPDATE discord_sessions
SET last_activity = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE thread_id = ?
`,
)
.run(threadId)
}
async markHealthOk(threadId: string): Promise<void> {
this.db
.query(
`
UPDATE discord_sessions
SET last_health_ok_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE thread_id = ?
`,
)
.run(threadId)
}
async updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void> {
this.db
.query(
`
UPDATE discord_sessions
SET
status = ?,
last_error = ?,
pause_requested_at = CASE WHEN ? = 'pausing' THEN CURRENT_TIMESTAMP ELSE pause_requested_at END,
paused_at = CASE WHEN ? = 'paused' THEN CURRENT_TIMESTAMP ELSE paused_at END,
resume_attempted_at = CASE WHEN ? = 'resuming' THEN CURRENT_TIMESTAMP ELSE resume_attempted_at END,
resumed_at = CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE resumed_at END,
destroyed_at = CASE WHEN ? = 'destroyed' THEN CURRENT_TIMESTAMP ELSE destroyed_at END,
updated_at = CURRENT_TIMESTAMP
WHERE thread_id = ?
`,
)
.run(status, lastError ?? null, status, status, status, status, status, threadId)
}
async incrementResumeFailure(threadId: string, lastError: string): Promise<void> {
this.db
.query(
`
UPDATE discord_sessions
SET
resume_fail_count = resume_fail_count + 1,
last_error = ?,
updated_at = CURRENT_TIMESTAMP
WHERE thread_id = ?
`,
)
.run(lastError, threadId)
}
async listActive(): Promise<SessionInfo[]> {
const rows = this.db
.query(
`
SELECT
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
resume_fail_count
FROM discord_sessions
WHERE status = 'active'
ORDER BY last_activity DESC
`,
)
.all() as SessionRow[]
return rows.map(toSessionInfo)
}
async listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]> {
const rows = this.db
.query(
`
SELECT
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
resume_fail_count
FROM discord_sessions
WHERE status = 'active'
AND last_activity < datetime('now', '-' || ? || ' minutes')
ORDER BY last_activity ASC
`,
)
.all(cutoffMinutes) as SessionRow[]
return rows.map(toSessionInfo)
}
async listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]> {
const rows = this.db
.query(
`
SELECT
thread_id,
channel_id,
guild_id,
sandbox_id,
session_id,
preview_url,
preview_token,
status,
last_error,
resume_fail_count
FROM discord_sessions
WHERE status = 'paused'
AND paused_at IS NOT NULL
AND paused_at < datetime('now', '-' || ? || ' minutes')
ORDER BY paused_at ASC
`,
)
.all(pausedTtlMinutes) as SessionRow[]
return rows.map(toSessionInfo)
}
}
function toSessionInfo(row: SessionRow): SessionInfo {
return {
threadId: row.thread_id,
channelId: row.channel_id,
guildId: row.guild_id,
sandboxId: row.sandbox_id,
sessionId: row.session_id,
previewUrl: row.preview_url,
previewToken: row.preview_token,
status: row.status,
lastError: row.last_error,
resumeFailCount: row.resume_fail_count,
}
}
const sessionStore: SessionStore = new SqliteSessionStore()
export function getSessionStore(): SessionStore {
return sessionStore
}

View File

@@ -0,0 +1,22 @@
export type SessionStatus =
| "creating"
| "active"
| "pausing"
| "paused"
| "resuming"
| "destroying"
| "destroyed"
| "error";
export interface SessionInfo {
threadId: string;
channelId: string;
guildId: string;
sandboxId: string;
sessionId: string;
previewUrl: string;
previewToken: string | null;
status: SessionStatus;
lastError?: string | null;
resumeFailCount?: number;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"outDir": "./dist",
"types": ["bun-types", "node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}