mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
core: replace Neon Postgres with bun:sqlite to eliminate external DB signup
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Discord
|
||||
DISCORD_TOKEN=
|
||||
DATABASE_URL= # Neon Postgres connection string
|
||||
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
|
||||
|
||||
5
packages/discord/.gitignore
vendored
5
packages/discord/.gitignore
vendored
@@ -29,5 +29,10 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local SQLite
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# Sensitive
|
||||
.censitive
|
||||
|
||||
@@ -4,74 +4,90 @@ 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: Neon Postgres (`discord_sessions`)
|
||||
- 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`: Neon-backed session store
|
||||
- `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.
|
||||
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.
|
||||
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
|
||||
@@ -80,18 +96,21 @@ If added later, update this file and follow those rules.
|
||||
- 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:
|
||||
@@ -106,29 +125,35 @@ If added later, update this file and follow those rules.
|
||||
- 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
|
||||
- Neon mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative.
|
||||
|
||||
- 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.
|
||||
@@ -136,41 +161,50 @@ If added later, update this file and follow those rules.
|
||||
## 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.
|
||||
|
||||
@@ -22,21 +22,21 @@ bun run dev
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
|
||||
## Configuration
|
||||
|
||||
See [`.env.example`](.env.example) for all available environment variables. Required:
|
||||
|
||||
- `DISCORD_TOKEN` — Discord bot token
|
||||
- `DATABASE_URL` — Neon Postgres connection string
|
||||
- `DATABASE_PATH` — SQLite file path (default: `discord.sqlite`)
|
||||
- `DAYTONA_API_KEY` — Daytona API key
|
||||
- `OPENCODE_ZEN_API_KEY` — OpenCode API key
|
||||
|
||||
@@ -56,4 +56,4 @@ Discord thread
|
||||
└─ missing? → create sandbox → clone repo → start opencode → new session
|
||||
```
|
||||
|
||||
Sessions are persisted in Neon Postgres. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14",
|
||||
"@neondatabase/serverless": "^0.10",
|
||||
"@daytonaio/sdk": "latest",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"effect": "^3",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { z } from "zod";
|
||||
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),
|
||||
),
|
||||
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_URL: z.string().min(1),
|
||||
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(""),
|
||||
@@ -30,27 +33,29 @@ const envSchema = z.object({
|
||||
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>;
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
let _config: Env | null = null;
|
||||
let _config: Env | null = null
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_config) {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
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");
|
||||
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;
|
||||
_config = result.data
|
||||
}
|
||||
return _config;
|
||||
return _config
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { neon } from "@neondatabase/serverless";
|
||||
import { getEnv } from "../config";
|
||||
import { Database } from "bun:sqlite"
|
||||
import { getEnv } from "../config"
|
||||
|
||||
let _sql: ReturnType<typeof neon> | null = null;
|
||||
let _db: Database | null = null
|
||||
|
||||
export function getSql() {
|
||||
if (!_sql) {
|
||||
_sql = neon(getEnv().DATABASE_URL);
|
||||
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 _sql;
|
||||
return _db
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { getSql } from "./client";
|
||||
import { logger } from "../observability/logger";
|
||||
import type { Database } from "bun:sqlite"
|
||||
import { getDb } from "./client"
|
||||
import { logger } from "../observability/logger"
|
||||
|
||||
const PREFIX = "[db]";
|
||||
const PREFIX = "[db]"
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const sql = getSql();
|
||||
const db = getDb()
|
||||
|
||||
await sql`CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
guild_id TEXT NOT NULL,
|
||||
@@ -15,51 +16,61 @@ export async function initializeDatabase(): Promise<void> {
|
||||
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
pause_requested_at TIMESTAMPTZ,
|
||||
paused_at TIMESTAMPTZ,
|
||||
resume_attempted_at TIMESTAMPTZ,
|
||||
resumed_at TIMESTAMPTZ,
|
||||
destroyed_at TIMESTAMPTZ,
|
||||
last_health_ok_at TIMESTAMPTZ,
|
||||
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`;
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS preview_token TEXT`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW()`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS pause_requested_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resume_attempted_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resumed_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS destroyed_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_health_ok_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_error TEXT`;
|
||||
await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resume_fail_count INTEGER NOT NULL DEFAULT 0`;
|
||||
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")
|
||||
|
||||
await sql`ALTER TABLE discord_sessions DROP CONSTRAINT IF EXISTS discord_sessions_status_check`;
|
||||
await sql`ALTER TABLE discord_sessions
|
||||
ADD CONSTRAINT discord_sessions_status_check
|
||||
CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error'))`;
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
|
||||
ON discord_sessions (status, last_activity)`)
|
||||
|
||||
await sql`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)`)
|
||||
}
|
||||
|
||||
await sql`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` });
|
||||
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);
|
||||
});
|
||||
logger.error({
|
||||
event: "db.schema.failed",
|
||||
component: "db",
|
||||
message: `${PREFIX} Failed to initialize schema`,
|
||||
error: err,
|
||||
})
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import { getSql } from "../db/client";
|
||||
import type { SessionInfo, SessionStatus } from "../types";
|
||||
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[]>;
|
||||
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
|
||||
}
|
||||
|
||||
class NeonSessionStore implements SessionStore {
|
||||
private readonly sql = getSql();
|
||||
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> {
|
||||
await this.sql`
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
INSERT INTO discord_sessions (
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -48,38 +50,53 @@ class NeonSessionStore implements SessionStore {
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
${session.threadId},
|
||||
${session.channelId},
|
||||
${session.guildId},
|
||||
${session.sandboxId},
|
||||
${session.sessionId},
|
||||
${session.previewUrl},
|
||||
${session.previewToken},
|
||||
${session.status},
|
||||
${session.lastError ?? null},
|
||||
NOW(),
|
||||
CASE WHEN ${session.status} = 'active' THEN NOW() ELSE NULL END,
|
||||
NOW(),
|
||||
NOW()
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
CURRENT_TIMESTAMP,
|
||||
CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (thread_id)
|
||||
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 = NOW(),
|
||||
resumed_at = CASE WHEN EXCLUDED.status = 'active' THEN NOW() ELSE discord_sessions.resumed_at END,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
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 rows = await this.sql`
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -92,27 +109,35 @@ class NeonSessionStore implements SessionStore {
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ${threadId}
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
` as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return toSessionInfo(rows[0]);
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async hasTrackedThread(threadId: string): Promise<boolean> {
|
||||
const rows = await this.sql`
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT thread_id
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ${threadId}
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
` as Array<{ thread_id: string }>;
|
||||
`,
|
||||
)
|
||||
.get(threadId) as { thread_id: string } | null
|
||||
|
||||
return rows.length > 0;
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
async getActive(threadId: string): Promise<SessionInfo | null> {
|
||||
const rows = await this.sql`
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -125,60 +150,80 @@ class NeonSessionStore implements SessionStore {
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ${threadId}
|
||||
WHERE thread_id = ?
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
` as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return toSessionInfo(rows[0]);
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async markActivity(threadId: string): Promise<void> {
|
||||
await this.sql`
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_activity = NOW(), updated_at = NOW()
|
||||
WHERE thread_id = ${threadId}
|
||||
`;
|
||||
SET last_activity = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(threadId)
|
||||
}
|
||||
|
||||
async markHealthOk(threadId: string): Promise<void> {
|
||||
await this.sql`
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_health_ok_at = NOW(), updated_at = NOW()
|
||||
WHERE thread_id = ${threadId}
|
||||
`;
|
||||
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> {
|
||||
await this.sql`
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
status = ${status},
|
||||
last_error = ${lastError ?? null},
|
||||
pause_requested_at = CASE WHEN ${status} = 'pausing' THEN NOW() ELSE pause_requested_at END,
|
||||
paused_at = CASE WHEN ${status} = 'paused' THEN NOW() ELSE paused_at END,
|
||||
resume_attempted_at = CASE WHEN ${status} = 'resuming' THEN NOW() ELSE resume_attempted_at END,
|
||||
resumed_at = CASE WHEN ${status} = 'active' THEN NOW() ELSE resumed_at END,
|
||||
destroyed_at = CASE WHEN ${status} = 'destroyed' THEN NOW() ELSE destroyed_at END,
|
||||
updated_at = NOW()
|
||||
WHERE thread_id = ${threadId}
|
||||
`;
|
||||
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> {
|
||||
await this.sql`
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
resume_fail_count = resume_fail_count + 1,
|
||||
last_error = ${lastError},
|
||||
updated_at = NOW()
|
||||
WHERE thread_id = ${threadId}
|
||||
`;
|
||||
last_error = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(lastError, threadId)
|
||||
}
|
||||
|
||||
async listActive(): Promise<SessionInfo[]> {
|
||||
const rows = await this.sql`
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -193,13 +238,17 @@ class NeonSessionStore implements SessionStore {
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_activity DESC
|
||||
` as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.all() as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo);
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = await this.sql`
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -213,15 +262,19 @@ class NeonSessionStore implements SessionStore {
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
AND last_activity < NOW() - (${cutoffMinutes} || ' minutes')::interval
|
||||
AND last_activity < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY last_activity ASC
|
||||
` as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.all(cutoffMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo);
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = await this.sql`
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
@@ -236,11 +289,13 @@ class NeonSessionStore implements SessionStore {
|
||||
FROM discord_sessions
|
||||
WHERE status = 'paused'
|
||||
AND paused_at IS NOT NULL
|
||||
AND paused_at < NOW() - (${pausedTtlMinutes} || ' minutes')::interval
|
||||
AND paused_at < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY paused_at ASC
|
||||
` as SessionRow[];
|
||||
`,
|
||||
)
|
||||
.all(pausedTtlMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo);
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,11 +311,11 @@ function toSessionInfo(row: SessionRow): SessionInfo {
|
||||
status: row.status,
|
||||
lastError: row.last_error,
|
||||
resumeFailCount: row.resume_fail_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sessionStore: SessionStore = new NeonSessionStore();
|
||||
const sessionStore: SessionStore = new SqliteSessionStore()
|
||||
|
||||
export function getSessionStore(): SessionStore {
|
||||
return sessionStore;
|
||||
return sessionStore
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user