discord: idiomatic Effect refactor with conversation service, durable ledger, and split sandbox architecture
Refactor the Discord bot to idiomatic Effect TypeScript: - Branded types (ThreadId, ChannelId, etc.) and Schema.Class for all data - Split SandboxManager into SandboxProvisioner (stateless lifecycle) + ThreadAgentPool (per-thread orchestration) - Pure Conversation service with port interfaces (Inbox/Outbox/History/Threads) - ConversationLedger for message dedup, at-least-once delivery, and replay on restart - Per-thread serialized execution via ActorMap with idle timeouts - Discord slash commands (/status, /reset) and in-thread commands (!status, !reset) - Catch-up on missed messages at startup via offset tracking - Typed errors (Schema.TaggedError) with retriable/non-retriable classification - Local CLI (conversation:cli) and automation CLI (conversation:ctl) - Test coverage for conversation service, ledger, session store, and actors
This commit is contained in:
parent
ef92226c33
commit
3c22e16386
58 changed files with 8023 additions and 2363 deletions
|
|
@ -5,6 +5,7 @@ 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
|
||||
DISCORD_COMMAND_GUILD_ID= # Optional guild ID for instant slash command updates (dev only)
|
||||
|
||||
# Daytona
|
||||
DAYTONA_API_KEY=
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
# 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.
|
||||
1
packages/discord/AGENTS.md
Symbolic link
1
packages/discord/AGENTS.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
CLAUDE.md
|
||||
88
packages/discord/CLAUDE.md
Normal file
88
packages/discord/CLAUDE.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Discord Bot Package
|
||||
|
||||
Discord bot that provisions Daytona sandboxes running OpenCode sessions in threads.
|
||||
|
||||
## Architecture
|
||||
|
||||
Bun + TypeScript (ESM, strict mode) with Effect for all business logic. SQLite persistence via `@effect/sql`.
|
||||
|
||||
- `src/index.ts` — startup, layer composition, graceful shutdown
|
||||
- `src/config.ts` — env schema (Effect Schema + branded types)
|
||||
- `src/conversation/` — pure conversation service (Inbox/Outbox ports, turn logic, ConversationLedger for dedup/replay)
|
||||
- `src/discord/` — Discord.js adapter (message handler, turn routing, formatting)
|
||||
- `src/sandbox/` — sandbox lifecycle (SandboxProvisioner, OpenCode client, ThreadAgentPool)
|
||||
- `src/sessions/store.ts` — SQLite-backed session store
|
||||
- `src/lib/actors/` — ActorMap (per-key serialized execution with idle timeouts)
|
||||
- `src/db/` — database client, schema init, migrations
|
||||
- `src/http/health.ts` — health/readiness HTTP server
|
||||
- `src/types.ts` — shared branded types and data classes
|
||||
|
||||
## Effect Conventions
|
||||
|
||||
- Services use `Context.Tag("@discord/<Name>")`
|
||||
- Errors use `Schema.TaggedError` with `Schema.Defect` for defect-like causes
|
||||
- Use `Effect.gen(function*() { ... })` for composition
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects
|
||||
- Layer composition: `Layer.mergeAll`, `Layer.provide`, `Layer.provideMerge`
|
||||
- Use `Schema.Class` for data types with multiple fields
|
||||
- Use branded schemas (`Schema.brand`) for single-value IDs
|
||||
- Construct branded values and Schema.Class instances with `.make()`
|
||||
- Module pattern for utilities: namespace for types, const for implementation (e.g. `ActorMap.make()`, `ActorMap.ActorMap<K>`)
|
||||
|
||||
## Type Safety
|
||||
|
||||
- **No `any`** — use `unknown` at untrusted boundaries, narrow with Schema decoding
|
||||
- **No `as` casts** — prefer Schema decode, type guards, or restructuring
|
||||
- **Non-null assertions (`!`) are banned** — use Option, optional chaining, or early returns
|
||||
- **Use `Option<T>` instead of `T | null`** — Effect's Option type for absent values from stores/lookups
|
||||
- **Branded types everywhere** — `ThreadId`, `ChannelId`, `GuildId`, `SandboxId`, `SessionId` from `src/types.ts`
|
||||
- **Accept branded types in function signatures** — don't accept `string` and `.make()` inside; push branding to the boundary
|
||||
- `as const` is fine (const assertion, not a cast)
|
||||
|
||||
## Branded Types
|
||||
|
||||
All branded ID schemas live in `src/types.ts`:
|
||||
- `ThreadId`, `ChannelId`, `GuildId` — Discord identifiers
|
||||
- `SandboxId` — Daytona sandbox identifier
|
||||
- `SessionId` — OpenCode session identifier
|
||||
|
||||
Brand at the system boundary (Discord event parsing, schema classes), then pass branded types through all internal code.
|
||||
|
||||
## Testing
|
||||
|
||||
- `bun test` — run all tests
|
||||
- `bun test path/to/file.test.ts` — single file
|
||||
- Test helpers in `src/test/effect.ts`
|
||||
- Colocate tests as `*.test.ts` next to implementation
|
||||
|
||||
## Build & Check
|
||||
|
||||
- `bun run typecheck` — type checking
|
||||
- `bun run build` — production build
|
||||
- `bun run check` — combined
|
||||
|
||||
## Local Debug CLIs
|
||||
|
||||
- `bun run conversation:cli` — interactive local conversation shell
|
||||
- `/channel` to return to channel mode
|
||||
- `/threads` to list known threads with indexes
|
||||
- `/pick [n]` to select a thread by index
|
||||
- `/thread [id|n]` to jump to a thread by id or index
|
||||
- channel auto-switch only follows newly seen threads (prevents jumping to old active threads)
|
||||
|
||||
- `bun run conversation:ctl` — non-interactive JSON CLI for agents/automation
|
||||
- `active`
|
||||
- `status --thread <id>`
|
||||
- `logs --thread <id> [--lines 120]`
|
||||
- `pause --thread <id>`
|
||||
- `destroy --thread <id>`
|
||||
- `resume --thread <id> [--channel <id> --guild <id>]`
|
||||
- `restart --thread <id>`
|
||||
- `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
- Session mapping (`threadId` -> `sandboxId` -> `sessionId`) is authoritative
|
||||
- Resume existing sandbox/session before creating replacements
|
||||
- Recreate only when sandbox is truly unavailable/destroyed
|
||||
- If session changes, replay Discord thread history as context
|
||||
|
|
@ -25,7 +25,7 @@ Discord bot that provisions [Daytona](https://daytona.io) sandboxes running [Ope
|
|||
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**
|
||||
5. Go to **OAuth2 > URL Generator**, select scopes `bot` and `applications.commands` 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
|
||||
|
|
@ -89,6 +89,7 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| `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 |
|
||||
| `DISCORD_COMMAND_GUILD_ID` | _(empty)_ | Register slash commands in one guild for instant updates (dev-friendly) |
|
||||
|
||||
#### Optional — Storage & Runtime
|
||||
|
||||
|
|
@ -130,6 +131,13 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| `bun run build` | Bundle for deployment |
|
||||
| `bun run check` | Typecheck + build |
|
||||
|
||||
### Discord Slash Commands
|
||||
|
||||
- `/status` — show current sandbox session for the thread
|
||||
- `/reset` — destroy session so next message provisions a fresh sandbox
|
||||
|
||||
These map to the existing `!status` / `!reset` behavior.
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
- `GET /healthz` — Liveness check (uptime, Discord status, active sessions)
|
||||
|
|
@ -138,12 +146,13 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
## Architecture
|
||||
|
||||
```
|
||||
Discord thread
|
||||
└─ message-create handler
|
||||
└─ SandboxManager.resolveSessionForMessage()
|
||||
Discord / CLI
|
||||
└─ Conversation service (Inbox → turn logic → Outbox)
|
||||
├─ ConversationLedger (dedup, at-least-once delivery, replay on restart)
|
||||
└─ ThreadAgentPool.getOrCreate(threadId)
|
||||
├─ active? → health check → reuse
|
||||
├─ paused? → daytona.start() → restart opencode → reattach session
|
||||
└─ missing? → create sandbox → clone repo → start opencode → new session
|
||||
├─ paused? → SandboxProvisioner.resume() → reattach session
|
||||
└─ missing? → SandboxProvisioner.provision() → new sandbox + session
|
||||
```
|
||||
|
||||
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
|
|
|
|||
|
|
@ -8,18 +8,29 @@
|
|||
"dev": "bun run --watch src/index.ts",
|
||||
"dev:setup": "bun run db:init",
|
||||
"start": "bun run src/index.ts",
|
||||
"conversation:cli": "bun run src/conversation/control/cli.ts",
|
||||
"conversation:controller": "bun run src/conversation/control/controller.ts",
|
||||
"conversation:ctl": "bun run src/conversation/control/controller.ts",
|
||||
"build": "bun build src/index.ts --target=bun --outdir=dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run build"
|
||||
"check": "bun run typecheck && bun run build",
|
||||
"prepare": "effect-language-service patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14",
|
||||
"@daytonaio/sdk": "latest",
|
||||
"@effect/ai": "^0.33.2",
|
||||
"@effect/ai-anthropic": "^0.23.0",
|
||||
"@effect/experimental": "^0.58.0",
|
||||
"@effect/sql-sqlite-bun": "^0.50.0",
|
||||
"@effect/platform": "latest",
|
||||
"@effect/platform-bun": "0.87.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"effect": "^3",
|
||||
"zod": "^3"
|
||||
"discord.js": "^14",
|
||||
"effect": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "0.73.1",
|
||||
"@effect/sql": "^0.49.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5"
|
||||
|
|
|
|||
|
|
@ -1,28 +1,51 @@
|
|||
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.
|
||||
you are an engineering assistant for the opencode repo, running inside a discord workflow.
|
||||
|
||||
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.
|
||||
your job is to help people solve real code and operations problems quickly.
|
||||
|
||||
## Tone
|
||||
## communication style
|
||||
|
||||
- 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.
|
||||
- default to lowercase developer style.
|
||||
- mirror the user's phrasing and level of formality.
|
||||
- be concise by default. expand only when the question needs it.
|
||||
- skip fluff and generic preambles. answer directly.
|
||||
- if unsure, say so and state exactly what you need to verify.
|
||||
|
||||
## What you do
|
||||
## operating process
|
||||
|
||||
- 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
|
||||
1. understand the request and goal.
|
||||
2. inspect the codebase and runtime signals first (files, logs, tests, git history).
|
||||
3. use tools to gather evidence before concluding.
|
||||
4. give a concrete answer with file references and next action.
|
||||
5. if asked to implement, make the change and verify.
|
||||
|
||||
## Rules
|
||||
## tool usage
|
||||
|
||||
- **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.
|
||||
- you can use repo tools and shell commands.
|
||||
- prefer fast code search (`rg`) and direct file reads.
|
||||
- use git commands for context and diffs.
|
||||
- use github cli (`gh`) for issues/prs when asked, or when explicitly instructed to file findings.
|
||||
- use web lookup when external, time-sensitive, or non-repo facts are needed.
|
||||
|
||||
## github issue workflow
|
||||
|
||||
when creating an issue, include:
|
||||
- summary
|
||||
- impact
|
||||
- reproduction steps
|
||||
- expected vs actual behavior
|
||||
- suspected files/components
|
||||
- proposed next step
|
||||
|
||||
## quality bar
|
||||
|
||||
- do not invent behavior; verify from code or logs.
|
||||
- include file paths and line references for technical claims.
|
||||
- summarize command outputs; do not dump noisy logs unless requested.
|
||||
- do not edit files unless explicitly asked.
|
||||
- avoid risky/destructive actions unless explicitly approved.
|
||||
|
||||
## continuous improvement (for later expansion)
|
||||
|
||||
- if a recurring workflow appears, propose a reusable skill/process.
|
||||
- present a short skill spec: trigger, inputs, steps, outputs.
|
||||
- do not self-modify prompt or automation config without explicit user approval.
|
||||
|
|
|
|||
40
packages/discord/src/config.test.ts
Normal file
40
packages/discord/src/config.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Duration, Effect, Redacted } from "effect"
|
||||
import { AppConfig } from "./config"
|
||||
import { effectTest } from "./test/effect"
|
||||
|
||||
const provider = (input?: ReadonlyArray<readonly [string, string]>) =>
|
||||
ConfigProvider.fromMap(
|
||||
new Map([
|
||||
["DISCORD_TOKEN", "discord-token"],
|
||||
["DAYTONA_API_KEY", "daytona-token"],
|
||||
["OPENCODE_ZEN_API_KEY", "zen-token"],
|
||||
...(input ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
const load = (input?: ReadonlyArray<readonly [string, string]>) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return config
|
||||
}).pipe(
|
||||
Effect.provide(AppConfig.layer),
|
||||
Effect.withConfigProvider(provider(input)),
|
||||
)
|
||||
|
||||
describe("AppConfig", () => {
|
||||
effectTest("parses SANDBOX_TIMEOUT as Duration", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load([["SANDBOX_TIMEOUT", "45 minutes"]])
|
||||
expect(Duration.toMinutes(config.sandboxTimeout)).toBe(45)
|
||||
expect(Redacted.value(config.discordToken)).toBe("discord-token")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("falls back to SANDBOX_TIMEOUT_MINUTES", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load([["SANDBOX_TIMEOUT_MINUTES", "31"]])
|
||||
expect(Duration.toMinutes(config.sandboxTimeout)).toBe(31)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,61 +1,189 @@
|
|||
import { z } from "zod"
|
||||
import { Config, Context, Duration, Effect, Layer, Redacted, Schema } from "effect"
|
||||
|
||||
const envSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1),
|
||||
ALLOWED_CHANNEL_IDS: z
|
||||
.string()
|
||||
.default("")
|
||||
.transform((s) =>
|
||||
const TurnRoutingMode = Schema.Literal("off", "heuristic", "ai")
|
||||
type TurnRoutingMode = typeof TurnRoutingMode.Type
|
||||
|
||||
const SandboxReusePolicy = Schema.Literal("resume_preferred", "recreate")
|
||||
type SandboxReusePolicy = typeof SandboxReusePolicy.Type
|
||||
|
||||
const LogLevel = Schema.Literal("debug", "info", "warn", "error")
|
||||
type LogLevel = typeof LogLevel.Type
|
||||
|
||||
const Port = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 65535),
|
||||
)
|
||||
|
||||
export const Minutes = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Minutes"),
|
||||
)
|
||||
export type Minutes = typeof Minutes.Type
|
||||
|
||||
export const Milliseconds = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Milliseconds"),
|
||||
)
|
||||
export type Milliseconds = typeof Milliseconds.Type
|
||||
|
||||
export const Seconds = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Seconds"),
|
||||
)
|
||||
export type Seconds = typeof Seconds.Type
|
||||
|
||||
const CommaSeparatedList = Schema.transform(
|
||||
Schema.String,
|
||||
Schema.Array(Schema.String),
|
||||
{
|
||||
decode: (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"),
|
||||
})
|
||||
encode: (a) => a.join(","),
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
export declare namespace AppConfig {
|
||||
export interface Service {
|
||||
readonly discordToken: Redacted.Redacted
|
||||
readonly allowedChannelIds: ReadonlyArray<string>
|
||||
readonly discordCategoryId: string
|
||||
readonly discordRoleId: string
|
||||
readonly discordRequiredRoleId: string
|
||||
readonly discordCommandGuildId: string
|
||||
readonly databasePath: string
|
||||
readonly daytonaApiKey: Redacted.Redacted
|
||||
readonly openCodeZenApiKey: Redacted.Redacted
|
||||
readonly githubToken: string
|
||||
readonly logLevel: LogLevel
|
||||
readonly healthHost: string
|
||||
readonly healthPort: number
|
||||
readonly turnRoutingMode: TurnRoutingMode
|
||||
readonly turnRoutingModel: string
|
||||
readonly sandboxReusePolicy: SandboxReusePolicy
|
||||
readonly sandboxTimeout: Duration.Duration
|
||||
readonly cleanupInterval: Duration.Duration
|
||||
readonly staleActiveGraceMinutes: Minutes
|
||||
readonly pausedTtlMinutes: Minutes
|
||||
readonly activeHealthCheckTimeoutMs: Milliseconds
|
||||
readonly startupHealthTimeoutMs: Milliseconds
|
||||
readonly resumeHealthTimeoutMs: Milliseconds
|
||||
readonly sandboxCreationTimeout: Seconds
|
||||
readonly openCodeModel: string
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppConfig.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
AppConfig,
|
||||
Effect.gen(function* () {
|
||||
const discordToken = yield* Config.redacted("DISCORD_TOKEN")
|
||||
const allowedChannelIds = yield* Schema.Config("ALLOWED_CHANNEL_IDS", CommaSeparatedList).pipe(
|
||||
Config.orElse(() => Config.succeed([] as ReadonlyArray<string>)),
|
||||
)
|
||||
const discordCategoryId = yield* Config.string("DISCORD_CATEGORY_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordRoleId = yield* Config.string("DISCORD_ROLE_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordRequiredRoleId = yield* Config.string("DISCORD_REQUIRED_ROLE_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordCommandGuildId = yield* Config.string("DISCORD_COMMAND_GUILD_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const databasePath = yield* Config.string("DATABASE_PATH").pipe(
|
||||
Config.withDefault("discord.sqlite"),
|
||||
)
|
||||
const daytonaApiKey = yield* Config.redacted("DAYTONA_API_KEY")
|
||||
const openCodeZenApiKey = yield* Config.redacted("OPENCODE_ZEN_API_KEY")
|
||||
const githubToken = yield* Config.string("GITHUB_TOKEN").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const logLevel = yield* Schema.Config("LOG_LEVEL", LogLevel).pipe(
|
||||
Config.orElse(() => Config.succeed("info" as const)),
|
||||
)
|
||||
const healthHost = yield* Config.string("HEALTH_HOST").pipe(
|
||||
Config.withDefault("0.0.0.0"),
|
||||
)
|
||||
const healthPort = yield* Schema.Config("HEALTH_PORT", Port).pipe(
|
||||
Config.orElse(() => Config.succeed(8787)),
|
||||
)
|
||||
const turnRoutingMode = yield* Schema.Config("TURN_ROUTING_MODE", TurnRoutingMode).pipe(
|
||||
Config.orElse(() => Config.succeed("ai" as const)),
|
||||
)
|
||||
const turnRoutingModel = yield* Config.string("TURN_ROUTING_MODEL").pipe(
|
||||
Config.withDefault("claude-haiku-4-5"),
|
||||
)
|
||||
const sandboxReusePolicy = yield* Schema.Config("SANDBOX_REUSE_POLICY", SandboxReusePolicy).pipe(
|
||||
Config.orElse(() => Config.succeed("resume_preferred" as const)),
|
||||
)
|
||||
const sandboxTimeout = yield* Config.duration("SANDBOX_TIMEOUT").pipe(
|
||||
Config.orElse(() =>
|
||||
Schema.Config("SANDBOX_TIMEOUT_MINUTES", Minutes).pipe(
|
||||
Config.map((n) => Duration.minutes(n)),
|
||||
),
|
||||
),
|
||||
Config.withDefault(Duration.minutes(30)),
|
||||
)
|
||||
const cleanupInterval = yield* Config.duration("SANDBOX_CLEANUP_INTERVAL").pipe(
|
||||
Config.withDefault(Duration.minutes(5)),
|
||||
)
|
||||
const staleActiveGraceMinutes = yield* Schema.Config("STALE_ACTIVE_GRACE_MINUTES", Minutes).pipe(
|
||||
Config.orElse(() => Config.succeed(Minutes.make(5))),
|
||||
)
|
||||
const pausedTtlMinutes = yield* Schema.Config("PAUSED_TTL_MINUTES", Minutes).pipe(
|
||||
Config.orElse(() => Config.succeed(Minutes.make(180))),
|
||||
)
|
||||
const activeHealthCheckTimeoutMs = yield* Schema.Config("ACTIVE_HEALTH_CHECK_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(15000))),
|
||||
)
|
||||
const startupHealthTimeoutMs = yield* Schema.Config("STARTUP_HEALTH_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(120000))),
|
||||
)
|
||||
const resumeHealthTimeoutMs = yield* Schema.Config("RESUME_HEALTH_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(120000))),
|
||||
)
|
||||
const sandboxCreationTimeout = yield* Schema.Config("SANDBOX_CREATION_TIMEOUT", Seconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Seconds.make(180))),
|
||||
)
|
||||
const openCodeModel = yield* Config.string("OPENCODE_MODEL").pipe(
|
||||
Config.withDefault("opencode/claude-sonnet-4-5"),
|
||||
)
|
||||
|
||||
return AppConfig.of({
|
||||
discordToken,
|
||||
allowedChannelIds,
|
||||
discordCategoryId,
|
||||
discordRoleId,
|
||||
discordRequiredRoleId,
|
||||
discordCommandGuildId,
|
||||
databasePath,
|
||||
daytonaApiKey,
|
||||
openCodeZenApiKey,
|
||||
githubToken,
|
||||
logLevel,
|
||||
healthHost,
|
||||
healthPort,
|
||||
turnRoutingMode,
|
||||
turnRoutingModel,
|
||||
sandboxReusePolicy,
|
||||
sandboxTimeout,
|
||||
cleanupInterval,
|
||||
staleActiveGraceMinutes,
|
||||
pausedTtlMinutes,
|
||||
activeHealthCheckTimeoutMs,
|
||||
startupHealthTimeoutMs,
|
||||
resumeHealthTimeoutMs,
|
||||
sandboxCreationTimeout,
|
||||
openCodeModel,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
}
|
||||
|
|
|
|||
52
packages/discord/src/conversation/README.md
Normal file
52
packages/discord/src/conversation/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Conversation Experiment
|
||||
|
||||
This folder contains the active Discord conversation runtime.
|
||||
|
||||
Goal:
|
||||
|
||||
- keep inbound transport as a stream (`Inbox.events`)
|
||||
- keep outbound transport as actions (`Outbox.publish`)
|
||||
- support first-contact channel messages by resolving a thread target through `Threads.ensure`
|
||||
- move orchestration into a transport-agnostic `Conversation` service
|
||||
|
||||
Current status:
|
||||
|
||||
- `model/schema.ts`: normalized event/action schema (`thread_message` and `channel_message`)
|
||||
- `services/*`: service contracts (`Inbox`, `Outbox`, `History`, `Threads`, `ConversationLedger`) + `Conversation`
|
||||
- `implementations/local/index.ts`: local implementation with `send()` / `take()` for non-Discord chat loops
|
||||
- `implementations/discord/index.ts`: Discord implementation mapping message events to `Inbound` and actions to Discord sends
|
||||
- `control/state.ts` + `control/cli.ts` + `control/controller.ts`: local CLI state, interactive CLI, and non-interactive controller commands
|
||||
|
||||
This module is wired into `src/index.ts`.
|
||||
|
||||
Reliability semantics:
|
||||
|
||||
- inbound events are durably admitted by `message_id` before processing
|
||||
- pending events replay on startup
|
||||
- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets
|
||||
- response text is cached before Discord delivery so retries can re-publish without re-calling the model
|
||||
|
||||
Local CLI notes (`bun run conversation:cli`):
|
||||
|
||||
- `typing` now emits as soon as a target thread is resolved (before sandbox/session resolution), so startup latency is visible.
|
||||
- channel and thread modes are explicit:
|
||||
- `/channel` routes to top-level channel mode
|
||||
- `/thread [id|n]` routes directly to thread mode (`n` is 1-based index from `/threads`; without arg, uses last seen thread)
|
||||
- `/threads` lists known thread ids with indexes
|
||||
- `/pick [n]` shows/selects a thread by index
|
||||
- auto-switch from channel mode now only follows newly-seen threads (prevents jumping to old threads still emitting output)
|
||||
- local thread simulation now mirrors Discord intent: each channel-mode message creates a new thread root, while explicit thread mode continues an existing thread
|
||||
- local thread ids are human-readable (`thread-adjective-noun-n`) to make `/threads` easy to scan
|
||||
|
||||
Agent CLI notes (`bun run conversation:controller` or `bun run conversation:ctl`):
|
||||
|
||||
- non-interactive JSON output for automation (`ok: true/false`)
|
||||
- commands:
|
||||
- `active`
|
||||
- `status --thread <id>`
|
||||
- `logs --thread <id> [--lines 120]`
|
||||
- `pause --thread <id>`
|
||||
- `destroy --thread <id>`
|
||||
- `resume --thread <id> [--channel <id> --guild <id>]`
|
||||
- `restart --thread <id>`
|
||||
- `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
|
||||
456
packages/discord/src/conversation/control/cli.ts
Normal file
456
packages/discord/src/conversation/control/cli.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { stdin, stdout } from "node:process"
|
||||
import readline from "node:readline/promises"
|
||||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Effect, Layer, LogLevel, Logger, Option, Stream } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { TurnRouter } from "../../discord/turn-routing"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { DaytonaService } from "../../sandbox/daytona"
|
||||
import { OpenCodeClient } from "../../sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import { SandboxProvisioner } from "../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../sessions/store"
|
||||
import { PreviewAccess, ThreadId } from "../../types"
|
||||
import type { Action } from "../model/schema"
|
||||
import { makeTui } from "../implementations/local"
|
||||
import { Conversation } from "../services/conversation"
|
||||
import { ConversationLedger } from "../services/ledger"
|
||||
import { autoThread, base, channelFrom, parse, prompt, scopeText, threadFrom } from "./state"
|
||||
|
||||
const AnthropicLayer = Layer.unwrapEffect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
|
||||
Layer.provide(AnthropicClient.layer({
|
||||
apiKey: config.openCodeZenApiKey,
|
||||
apiUrl: "https://opencode.ai/zen",
|
||||
})),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const BaseLayer = Layer.mergeAll(
|
||||
AppConfig.layer,
|
||||
FetchHttpClient.layer,
|
||||
BunContext.layer,
|
||||
Logger.minimumLogLevel(LogLevel.Warning),
|
||||
)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
red: "\x1b[31m",
|
||||
} as const
|
||||
|
||||
const now = () => new Date().toLocaleTimeString("en-US", { hour12: false })
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const tui = yield* makeTui
|
||||
const layer = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(CoreLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const daytona = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const sessions = yield* SessionStore
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true })
|
||||
const restart =
|
||||
'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'
|
||||
let scope = base()
|
||||
let pending = 0
|
||||
let last: ThreadId | null = null
|
||||
const seen = new Set<ThreadId>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
rl.close()
|
||||
}),
|
||||
)
|
||||
|
||||
const draw = (line: string, keep = true): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
stdout.write(`\r\x1b[2K${line}\n`)
|
||||
if (keep) {
|
||||
stdout.write(`${prompt(scope)}${rl.line}`)
|
||||
}
|
||||
})
|
||||
|
||||
const stamp = (label: string, color: string, text: string) =>
|
||||
`${colors.dim}${now()}${colors.reset} ${color}${label}${colors.reset} ${text}`
|
||||
|
||||
const info = (text: string): Effect.Effect<void> => draw(stamp("info", colors.blue, text), false)
|
||||
|
||||
const block = (head: string, body: string): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
stdout.write(`\r\x1b[2K${head}\n${body}\n`)
|
||||
stdout.write(`${prompt(scope)}${rl.line}`)
|
||||
})
|
||||
|
||||
const noteThread = (thread_id: ThreadId): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
seen.add(thread_id)
|
||||
last = thread_id
|
||||
})
|
||||
|
||||
const pick = (thread_id: ThreadId | null): ThreadId | null => {
|
||||
if (thread_id) return thread_id
|
||||
if (scope.kind === "thread") return scope.thread_id
|
||||
return last
|
||||
}
|
||||
const list = () => Array.from(seen)
|
||||
const byIndex = (index: number) => list().at(index - 1) ?? null
|
||||
const fromRef = (thread_id: ThreadId | null) => {
|
||||
if (!thread_id) return null
|
||||
const raw = `${thread_id}`.trim()
|
||||
if (!/^\d+$/.test(raw)) return thread_id
|
||||
const index = Number(raw)
|
||||
if (!Number.isInteger(index) || index <= 0) return null
|
||||
return byIndex(index)
|
||||
}
|
||||
|
||||
const tracked = (thread_id: ThreadId) =>
|
||||
pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.map((row) => Option.isSome(row) ? row.value : null),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const sessionText = (thread_id: ThreadId, session: {
|
||||
status: string
|
||||
sandboxId: string
|
||||
sessionId: string
|
||||
resumeFailCount: number
|
||||
lastError: string | null
|
||||
}) =>
|
||||
`${colors.dim}${thread_id}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}`
|
||||
|
||||
const render = (action: Action) => {
|
||||
if (action.kind === "typing") {
|
||||
return stamp("typing", colors.yellow, `${colors.dim}[${action.thread_id}]${colors.reset}`)
|
||||
}
|
||||
return stamp("assistant", colors.cyan, `${colors.dim}[${action.thread_id}]${colors.reset} ${action.text}`)
|
||||
}
|
||||
|
||||
yield* draw(
|
||||
stamp(
|
||||
"ready",
|
||||
colors.yellow,
|
||||
`${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /restart, /pause, /destroy, /resume, /active, /help, /exit${colors.reset}`,
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
tui.actions,
|
||||
(action) =>
|
||||
Effect.gen(function* () {
|
||||
const known = seen.has(action.thread_id)
|
||||
yield* noteThread(action.thread_id)
|
||||
const next = autoThread(scope, action, known)
|
||||
const switched = scope.kind === "channel" && next.kind === "thread"
|
||||
scope = next
|
||||
if (switched) {
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)} (/channel to go back)${colors.reset}`)
|
||||
}
|
||||
if ((action.kind === "send" || action.kind === "reply") && pending > 0) {
|
||||
pending -= 1
|
||||
}
|
||||
yield* draw(render(action))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
const queue = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const target = scopeText(scope)
|
||||
if (scope.kind === "channel") {
|
||||
yield* tui.send(text)
|
||||
} else {
|
||||
yield* tui.sendTo(scope.thread_id, text)
|
||||
}
|
||||
pending += 1
|
||||
yield* draw(stamp("queued", colors.green, `${colors.dim}[${target}]${colors.reset} ${text}`), false)
|
||||
yield* Effect.fork(
|
||||
Effect.suspend(() =>
|
||||
pending > 0
|
||||
? draw(stamp("waiting", colors.yellow, `${colors.dim}[${target}] preparing sandbox/session...${colors.reset}`), false)
|
||||
: Effect.void,
|
||||
).pipe(
|
||||
Effect.delay("2 seconds"),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const command = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const cmd = parse(text)
|
||||
if (!cmd) return false
|
||||
|
||||
if (cmd.kind === "help") {
|
||||
yield* info(
|
||||
`${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /restart [thread], /pause [thread], /destroy [thread], /resume [thread], /active, /exit${colors.reset}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "threads") {
|
||||
if (seen.size === 0) {
|
||||
yield* info(`${colors.dim}no known threads yet${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}${list().map((id, i) => `${i + 1}:${id}`).join(", ")}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "pick") {
|
||||
if (seen.size === 0) {
|
||||
yield* info(`${colors.dim}no known threads yet${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (!cmd.index) {
|
||||
yield* info(`${colors.dim}${list().map((id, i) => `${i + 1}:${id}`).join(", ")}${colors.reset}`)
|
||||
yield* info(`${colors.dim}pick one with /pick <n>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const thread_id = byIndex(cmd.index)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}invalid thread index ${cmd.index}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
scope = threadFrom(scope, thread_id)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "active") {
|
||||
yield* sessions.listActive().pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}active query failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (active) =>
|
||||
active.length === 0
|
||||
? info(`${colors.dim}no active sessions${colors.reset}`)
|
||||
: info(`${colors.dim}${active.map((s) => `${s.threadId}(${s.status})`).join(", ")}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "channel") {
|
||||
scope = channelFrom(scope)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "thread") {
|
||||
const selected = fromRef(cmd.thread_id)
|
||||
if (selected) {
|
||||
scope = threadFrom(scope, selected)
|
||||
yield* noteThread(selected)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (cmd.thread_id) {
|
||||
yield* info(`${colors.dim}invalid thread id/index${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (last) {
|
||||
scope = threadFrom(scope, last)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}no thread id yet. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "status") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(sessionText(thread_id, session))
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "logs") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* daytona.exec(
|
||||
session.sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${cmd.lines}`,
|
||||
).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}log read failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (result) =>
|
||||
block(
|
||||
stamp("logs", colors.blue, `${colors.dim}[${thread_id}]${colors.reset}`),
|
||||
result.output.trim() || "(empty log)",
|
||||
),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "pause") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.pauseSession(thread_id, "manual-cli").pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}pause failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}paused ${thread_id}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "destroy") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.destroySession(thread_id).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}destroy failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}destroyed ${thread_id}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "resume") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* pool.getOrCreate(thread_id, session.channelId, session.guildId).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (agent) =>
|
||||
agent.current().pipe(
|
||||
Effect.flatMap((current) =>
|
||||
info(
|
||||
`${colors.dim}resumed ${thread_id} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`,
|
||||
),
|
||||
),
|
||||
Effect.catchAll((error) =>
|
||||
info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "restart") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const restarted = yield* daytona.exec(session.sandboxId, "restart-opencode-serve", restart).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchAll((error) =>
|
||||
info(`${colors.red}restart failed${colors.reset} ${String(error)}`).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!restarted) return true
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), config.activeHealthCheckTimeoutMs).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!healthy) {
|
||||
yield* info(`${colors.red}restart ran, but health check failed${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}restart complete and healthy${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
yield* info(`${colors.dim}unknown command: /${cmd.name}${colors.reset}`)
|
||||
return true
|
||||
})
|
||||
|
||||
const loop: Effect.Effect<void> = Effect.gen(function* () {
|
||||
const text = (yield* Effect.promise(() => rl.question(prompt(scope)))).trim()
|
||||
if (!text) return yield* loop
|
||||
if (text === "/exit" || text === "exit" || text === "quit") return
|
||||
const handled = yield* command(text)
|
||||
if (handled) return yield* loop
|
||||
yield* queue(text)
|
||||
return yield* loop
|
||||
})
|
||||
|
||||
yield* loop
|
||||
}).pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.scoped,
|
||||
)
|
||||
})
|
||||
|
||||
run.pipe(
|
||||
Logger.withMinimumLogLevel(LogLevel.Warning),
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
436
packages/discord/src/conversation/control/controller.ts
Normal file
436
packages/discord/src/conversation/control/controller.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, LogLevel, Logger, Option, Ref, Schema } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { DaytonaService } from "../../sandbox/daytona"
|
||||
import { OpenCodeClient } from "../../sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import { SandboxProvisioner } from "../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../../types"
|
||||
|
||||
const BaseLayer = Layer.mergeAll(
|
||||
AppConfig.layer,
|
||||
FetchHttpClient.layer,
|
||||
BunContext.layer,
|
||||
Logger.minimumLogLevel(LogLevel.None),
|
||||
)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithSqlite)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithOpenCode)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
|
||||
const restart =
|
||||
'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'
|
||||
|
||||
type Opt = Record<string, string | boolean>
|
||||
|
||||
class CtlUsageError extends Schema.TaggedError<CtlUsageError>()("CtlUsageError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
class CtlInternalError extends Schema.TaggedError<CtlInternalError>()("CtlInternalError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
const usage = (message: string) => CtlUsageError.make({ message })
|
||||
const internal = (cause: unknown, message = text(cause)) => CtlInternalError.make({ message, cause })
|
||||
|
||||
const text = (cause: unknown) => {
|
||||
if (cause instanceof Error) return cause.message
|
||||
if (typeof cause === "object" && cause !== null && "_tag" in cause && "message" in cause) {
|
||||
const tag = (cause as { _tag?: unknown })._tag
|
||||
const message = (cause as { message?: unknown }).message
|
||||
if (typeof tag === "string" && typeof message === "string") return `${tag}: ${message}`
|
||||
}
|
||||
if (typeof cause === "object" && cause !== null) return String(cause)
|
||||
return String(cause)
|
||||
}
|
||||
|
||||
const parse = (argv: ReadonlyArray<string>) => {
|
||||
const input = argv.slice(2)
|
||||
const cmd = input.at(0)?.toLowerCase() ?? "help"
|
||||
const scan = input.slice(1).reduce(
|
||||
(state: { opts: Opt; args: ReadonlyArray<string>; key: string | null }, token) => {
|
||||
if (token.startsWith("--")) {
|
||||
const key = token.slice(2)
|
||||
if (key.length === 0) return state
|
||||
if (state.key) {
|
||||
return {
|
||||
opts: { ...state.opts, [state.key]: true },
|
||||
args: state.args,
|
||||
key,
|
||||
}
|
||||
}
|
||||
return { ...state, key }
|
||||
}
|
||||
if (state.key) {
|
||||
return {
|
||||
opts: { ...state.opts, [state.key]: token },
|
||||
args: state.args,
|
||||
key: null,
|
||||
}
|
||||
}
|
||||
return { ...state, args: [...state.args, token] }
|
||||
},
|
||||
{ opts: {} as Opt, args: [] as ReadonlyArray<string>, key: null as string | null },
|
||||
)
|
||||
if (!scan.key) return { cmd, opts: scan.opts, args: scan.args }
|
||||
return {
|
||||
cmd,
|
||||
opts: { ...scan.opts, [scan.key]: true },
|
||||
args: scan.args,
|
||||
}
|
||||
}
|
||||
|
||||
const value = (opts: Opt, key: string) => {
|
||||
const raw = opts[key]
|
||||
if (typeof raw !== "string") return null
|
||||
const out = raw.trim()
|
||||
if (!out) return null
|
||||
return out
|
||||
}
|
||||
|
||||
const number = (opts: Opt, key: string, fallback: number) => {
|
||||
const raw = value(opts, key)
|
||||
if (!raw) return fallback
|
||||
const out = Number(raw)
|
||||
if (!Number.isInteger(out) || out <= 0) return fallback
|
||||
return out
|
||||
}
|
||||
|
||||
const flag = (opts: Opt, key: string) => {
|
||||
const raw = opts[key]
|
||||
if (raw === true) return true
|
||||
if (typeof raw !== "string") return false
|
||||
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
|
||||
}
|
||||
|
||||
let ctlSeq = 0
|
||||
const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
|
||||
const raw = value(opts, "thread")
|
||||
if (raw) return Effect.succeed(ThreadId.make(raw))
|
||||
if (active.length === 1) return Effect.succeed(active[0].threadId)
|
||||
if (active.length > 1) return Effect.fail(usage("missing --thread (multiple active sessions)"))
|
||||
ctlSeq += 1
|
||||
return Effect.succeed(ThreadId.make(`ctl-${ctlSeq}`))
|
||||
}
|
||||
|
||||
const print = (ok: boolean, command: string, payload: Record<string, unknown>) =>
|
||||
Effect.sync(() => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok,
|
||||
command,
|
||||
...payload,
|
||||
}, null, 2)}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const event = (command: string, name: string, payload: Record<string, unknown>) =>
|
||||
Effect.sync(() => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
command,
|
||||
event: name,
|
||||
...payload,
|
||||
})}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const ctl = parse(process.argv)
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const daytona = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const active = yield* sessions.listActive()
|
||||
const tracked = (thread_id: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id)
|
||||
if (Option.isSome(row)) return row.value
|
||||
return yield* usage(`no tracked session for thread ${thread_id}`)
|
||||
})
|
||||
const resolve = (thread_id: ThreadId, opts: Opt): Effect.Effect<SessionInfo, CtlUsageError | CtlInternalError> =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
if (Option.isSome(row)) {
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.value.channelId, row.value.guildId).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
return yield* agent.current().pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
}
|
||||
const channel = value(opts, "channel") ?? "ctl"
|
||||
const guild = value(opts, "guild") ?? "local"
|
||||
const agent = yield* pool.getOrCreate(thread_id, ChannelId.make(channel), GuildId.make(guild)).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
return yield* agent.current().pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
if (ctl.cmd === "help") {
|
||||
return yield* print(true, ctl.cmd, {
|
||||
usage: [
|
||||
"conversation:ctl active",
|
||||
"conversation:ctl status --thread <id>",
|
||||
"conversation:ctl logs --thread <id> [--lines 120]",
|
||||
"conversation:ctl pause --thread <id>",
|
||||
"conversation:ctl destroy --thread <id>",
|
||||
"conversation:ctl resume --thread <id> [--channel <id> --guild <id>]",
|
||||
"conversation:ctl restart --thread <id>",
|
||||
"conversation:ctl send --thread <id> --text <message> [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "active") {
|
||||
return yield* print(true, ctl.cmd, {
|
||||
count: active.length,
|
||||
sessions: active.map((row) => ({
|
||||
threadId: row.threadId,
|
||||
channelId: row.channelId,
|
||||
guildId: row.guildId,
|
||||
sandboxId: row.sandboxId,
|
||||
sessionId: row.sessionId,
|
||||
status: row.status,
|
||||
resumeFailCount: row.resumeFailCount,
|
||||
lastError: row.lastError,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "status") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
return yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.flatMap((row) =>
|
||||
print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
tracked: Option.isSome(row),
|
||||
session: Option.isSome(row)
|
||||
? {
|
||||
threadId: row.value.threadId,
|
||||
channelId: row.value.channelId,
|
||||
guildId: row.value.guildId,
|
||||
sandboxId: row.value.sandboxId,
|
||||
sessionId: row.value.sessionId,
|
||||
status: row.value.status,
|
||||
resumeFailCount: row.value.resumeFailCount,
|
||||
lastError: row.value.lastError,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
if (ctl.cmd === "logs") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const lines = number(ctl.opts, "lines", 120)
|
||||
const row = yield* tracked(thread_id)
|
||||
const out = yield* daytona.exec(
|
||||
row.sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
|
||||
)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: row.sandboxId,
|
||||
lines,
|
||||
output: out.output,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "pause") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
yield* pool.pauseSession(thread_id, "manual-ctl")
|
||||
return yield* print(true, ctl.cmd, { threadId: thread_id })
|
||||
}
|
||||
|
||||
if (ctl.cmd === "destroy") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
yield* pool.destroySession(thread_id)
|
||||
return yield* print(true, ctl.cmd, { threadId: thread_id })
|
||||
}
|
||||
|
||||
if (ctl.cmd === "resume") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
session: {
|
||||
sandboxId: row.sandboxId,
|
||||
sessionId: row.sessionId,
|
||||
status: row.status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "restart") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
yield* daytona.exec(row.sandboxId, "restart-opencode-serve", restart)
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(row), config.activeHealthCheckTimeoutMs).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: row.sandboxId,
|
||||
healthy,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "send") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const message = value(ctl.opts, "text") ?? ctl.args.join(" ").trim()
|
||||
if (!message) {
|
||||
return yield* usage("missing message text (pass --text \"...\")")
|
||||
}
|
||||
const wait = number(ctl.opts, "wait-ms", 0)
|
||||
const every = number(ctl.opts, "logs-every-ms", 2000)
|
||||
const lines = number(ctl.opts, "lines", 80)
|
||||
const follow = flag(ctl.opts, "follow") || wait > 0
|
||||
|
||||
if (!follow) {
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
|
||||
const reply = yield* agent.send(message)
|
||||
const current = yield* agent.current()
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: current.sandboxId,
|
||||
sessionId: current.sessionId,
|
||||
reply,
|
||||
})
|
||||
}
|
||||
|
||||
const known = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(Option.none())),
|
||||
)
|
||||
const sandbox = yield* Ref.make<SandboxId | null>(Option.isSome(known) ? known.value.sandboxId : null)
|
||||
const last = yield* Ref.make<string>("")
|
||||
const started = Date.now()
|
||||
|
||||
const fiber = yield* Effect.fork(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
yield* Ref.set(sandbox, row.sandboxId)
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
|
||||
const reply = yield* agent.send(message)
|
||||
const current = yield* agent.current()
|
||||
return { row: current, reply }
|
||||
}),
|
||||
)
|
||||
|
||||
yield* event(ctl.cmd, "started", {
|
||||
threadId: thread_id,
|
||||
waitMs: wait,
|
||||
logsEveryMs: every,
|
||||
lines,
|
||||
})
|
||||
|
||||
const waitTick = Effect.void.pipe(Effect.delay(Duration.millis(every)))
|
||||
const loop = (): Effect.Effect<{ row: SessionInfo; reply: string }, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const done = yield* Fiber.poll(fiber)
|
||||
if (Option.isSome(done)) {
|
||||
if (Exit.isSuccess(done.value)) return done.value.value
|
||||
return yield* Effect.failCause(done.value.cause)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - started
|
||||
if (wait > 0 && elapsed >= wait) {
|
||||
yield* Fiber.interrupt(fiber)
|
||||
return yield* usage(`send timed out after ${wait}ms`)
|
||||
}
|
||||
|
||||
const sandboxId = yield* Ref.get(sandbox)
|
||||
if (!sandboxId) {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
stage: "resolving-session",
|
||||
})
|
||||
yield* waitTick
|
||||
return yield* loop()
|
||||
}
|
||||
|
||||
const output = yield* daytona.exec(
|
||||
sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
|
||||
).pipe(
|
||||
Effect.map((row) => row.output),
|
||||
Effect.catchAll((cause) =>
|
||||
Effect.succeed(`(log read failed: ${text(cause)})`),
|
||||
),
|
||||
)
|
||||
|
||||
const previous = yield* Ref.get(last)
|
||||
if (output !== previous) {
|
||||
yield* Ref.set(last, output)
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: output,
|
||||
})
|
||||
} else {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: "(no change)",
|
||||
})
|
||||
}
|
||||
|
||||
yield* waitTick
|
||||
return yield* loop()
|
||||
})
|
||||
|
||||
const result = yield* loop()
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: result.row.sandboxId,
|
||||
sessionId: result.row.sessionId,
|
||||
reply: result.reply,
|
||||
})
|
||||
}
|
||||
|
||||
return yield* usage(`unknown command: ${ctl.cmd}`)
|
||||
}).pipe(
|
||||
Effect.catchAll((cause) => {
|
||||
const command = parse(process.argv).cmd
|
||||
return print(false, command, { error: text(cause) }).pipe(
|
||||
Effect.zipRight(Effect.sync(() => {
|
||||
process.exitCode = 1
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
run.pipe(
|
||||
Effect.provide(CoreLayer),
|
||||
Effect.scoped,
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
52
packages/discord/src/conversation/control/state.test.ts
Normal file
52
packages/discord/src/conversation/control/state.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { ThreadId } from "../../types"
|
||||
import { Send, Typing } from "../model/schema"
|
||||
import { autoThread, base, channelFrom, parse, prompt, queueTarget, scopeText, threadFrom } from "./state"
|
||||
|
||||
describe("cli-state", () => {
|
||||
it("parses commands", () => {
|
||||
expect(parse("hello")).toBeNull()
|
||||
expect(parse("/help")).toEqual({ kind: "help" })
|
||||
expect(parse("/channel")).toEqual({ kind: "channel" })
|
||||
expect(parse("/threads")).toEqual({ kind: "threads" })
|
||||
expect(parse("/pick")).toEqual({ kind: "pick", index: null })
|
||||
expect(parse("/pick 2")).toEqual({ kind: "pick", index: 2 })
|
||||
expect(parse("/active")).toEqual({ kind: "active" })
|
||||
expect(parse("/thread")).toEqual({ kind: "thread", thread_id: null })
|
||||
expect(parse("/thread abc")).toEqual({ kind: "thread", thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/status")).toEqual({ kind: "status", thread_id: null })
|
||||
expect(parse("/status abc")).toEqual({ kind: "status", thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, thread_id: null })
|
||||
expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, thread_id: null })
|
||||
expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/pause")).toEqual({ kind: "pause", thread_id: null })
|
||||
expect(parse("/destroy")).toEqual({ kind: "destroy", thread_id: null })
|
||||
expect(parse("/resume")).toEqual({ kind: "resume", thread_id: null })
|
||||
expect(parse("/restart")).toEqual({ kind: "restart", thread_id: null })
|
||||
expect(parse("/nope")).toEqual({ kind: "unknown", name: "nope" })
|
||||
})
|
||||
|
||||
it("formats scope and prompt", () => {
|
||||
const a = base()
|
||||
const b = threadFrom(a, ThreadId.make("t1"))
|
||||
expect(scopeText(a)).toBe("channel:local-channel")
|
||||
expect(scopeText(b)).toBe("thread:t1")
|
||||
expect(prompt(a)).toBe("channel> ")
|
||||
expect(prompt(b)).toBe("thread:t1> ")
|
||||
expect(queueTarget(a)).toBe("channel")
|
||||
expect(queueTarget(b)).toBe("thread")
|
||||
expect(channelFrom(b)).toEqual(a)
|
||||
})
|
||||
|
||||
it("auto switches from channel to thread on action", () => {
|
||||
const a = base()
|
||||
const typing = Typing.make({ kind: "typing", thread_id: ThreadId.make("t-a") })
|
||||
const send = Send.make({ kind: "send", thread_id: ThreadId.make("t-b"), text: "ok" })
|
||||
|
||||
expect(autoThread(a, typing)).toEqual(threadFrom(a, ThreadId.make("t-a")))
|
||||
expect(autoThread(a, send)).toEqual(threadFrom(a, ThreadId.make("t-b")))
|
||||
expect(autoThread(a, send, true)).toEqual(a)
|
||||
expect(autoThread(threadFrom(a, ThreadId.make("t0")), send)).toEqual(threadFrom(a, ThreadId.make("t0")))
|
||||
})
|
||||
})
|
||||
104
packages/discord/src/conversation/control/state.ts
Normal file
104
packages/discord/src/conversation/control/state.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { ThreadId } from "../../types"
|
||||
import type { Action } from "../model/schema"
|
||||
|
||||
const LOCAL_CHANNEL = "local-channel" as const
|
||||
|
||||
export type Scope =
|
||||
| { kind: "channel"; channel_id: typeof LOCAL_CHANNEL }
|
||||
| { kind: "thread"; thread_id: ThreadId; channel_id: typeof LOCAL_CHANNEL }
|
||||
|
||||
export type Command =
|
||||
| { kind: "channel" }
|
||||
| { kind: "help" }
|
||||
| { kind: "threads" }
|
||||
| { kind: "pick"; index: number | null }
|
||||
| { kind: "active" }
|
||||
| { kind: "thread"; thread_id: ThreadId | null }
|
||||
| { kind: "status"; thread_id: ThreadId | null }
|
||||
| { kind: "logs"; thread_id: ThreadId | null; lines: number }
|
||||
| { kind: "pause"; thread_id: ThreadId | null }
|
||||
| { kind: "destroy"; thread_id: ThreadId | null }
|
||||
| { kind: "resume"; thread_id: ThreadId | null }
|
||||
| { kind: "restart"; thread_id: ThreadId | null }
|
||||
| { kind: "unknown"; name: string }
|
||||
|
||||
export const base = (): Scope => ({ kind: "channel", channel_id: LOCAL_CHANNEL })
|
||||
|
||||
const target = (value: string | undefined) => {
|
||||
const raw = value?.trim() ?? ""
|
||||
if (!raw) return null
|
||||
return ThreadId.make(raw)
|
||||
}
|
||||
|
||||
const parseLines = (raw: string | undefined) => {
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return null
|
||||
if (!Number.isInteger(n)) return null
|
||||
if (n <= 0) return null
|
||||
return n
|
||||
}
|
||||
|
||||
const parseIndex = (raw: string | undefined) => {
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n <= 0) return null
|
||||
return n
|
||||
}
|
||||
|
||||
export const parse = (line: string): Command | null => {
|
||||
const text = line.trim()
|
||||
if (!text.startsWith("/")) return null
|
||||
const parts = text.slice(1).split(/\s+/)
|
||||
const head = parts.at(0)?.toLowerCase() ?? ""
|
||||
const args = parts.slice(1)
|
||||
|
||||
if (head === "channel") return { kind: "channel" }
|
||||
if (head === "help") return { kind: "help" }
|
||||
if (head === "threads") return { kind: "threads" }
|
||||
if (head === "pick") return { kind: "pick", index: parseIndex(args.at(0)) }
|
||||
if (head === "active") return { kind: "active" }
|
||||
if (head === "thread") return { kind: "thread", thread_id: target(args.at(0)) }
|
||||
if (head === "status") return { kind: "status", thread_id: target(args.at(0)) }
|
||||
|
||||
if (head === "logs") {
|
||||
const lines = parseLines(args.at(0))
|
||||
if (lines === null) {
|
||||
return { kind: "logs", lines: 120, thread_id: target(args.at(0)) }
|
||||
}
|
||||
return { kind: "logs", lines, thread_id: target(args.at(1)) }
|
||||
}
|
||||
|
||||
if (head === "pause") return { kind: "pause", thread_id: target(args.at(0)) }
|
||||
if (head === "destroy") return { kind: "destroy", thread_id: target(args.at(0)) }
|
||||
if (head === "resume") return { kind: "resume", thread_id: target(args.at(0)) }
|
||||
if (head === "restart") return { kind: "restart", thread_id: target(args.at(0)) }
|
||||
|
||||
return { kind: "unknown", name: head }
|
||||
}
|
||||
|
||||
export const scopeText = (scope: Scope) => scope.kind === "channel"
|
||||
? `channel:${scope.channel_id}`
|
||||
: `thread:${scope.thread_id}`
|
||||
|
||||
export const prompt = (scope: Scope) => scope.kind === "channel"
|
||||
? "channel> "
|
||||
: `thread:${scope.thread_id}> `
|
||||
|
||||
export const queueTarget = (scope: Scope) => scope.kind === "channel" ? "channel" : "thread"
|
||||
|
||||
export const threadFrom = (scope: Scope, thread_id: ThreadId): Scope => ({
|
||||
kind: "thread",
|
||||
channel_id: scope.channel_id,
|
||||
thread_id,
|
||||
})
|
||||
|
||||
export const channelFrom = (scope: Scope): Scope => ({
|
||||
kind: "channel",
|
||||
channel_id: scope.channel_id,
|
||||
})
|
||||
|
||||
export const autoThread = (scope: Scope, action: Action, known = false): Scope => {
|
||||
if (scope.kind === "thread") return scope
|
||||
if (action.kind !== "typing" && action.kind !== "send" && action.kind !== "reply") return scope
|
||||
if (known) return scope
|
||||
return threadFrom(scope, action.thread_id)
|
||||
}
|
||||
|
|
@ -0,0 +1,849 @@
|
|||
import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel, ThreadChannel } from "discord.js"
|
||||
import { ChannelType, MessageFlags } from "discord.js"
|
||||
import { Context, Effect, Layer, Option, Queue, Ref, Runtime, Schedule, Stream } from "effect"
|
||||
import { AppConfig } from "../../../config"
|
||||
import { DiscordClient } from "../../../discord/client"
|
||||
import { TYPING_INTERVAL } from "../../../discord/constants"
|
||||
import { cleanResponse, splitForDiscord } from "../../../discord/format"
|
||||
import { ThreadAgentPool } from "../../../sandbox/pool"
|
||||
import { SessionStore } from "../../../sessions/store"
|
||||
import { ChannelId, GuildId, ThreadId } from "../../../types"
|
||||
import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../../model/errors"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
|
||||
import { ConversationLedger, History, Inbox, Outbox, Threads } from "../../services"
|
||||
|
||||
type ChatChannel = TextChannel | ThreadChannel
|
||||
|
||||
const HISTORY_FETCH_LIMIT = 40
|
||||
const HISTORY_LINE_CHAR_LIMIT = 500
|
||||
const HISTORY_TOTAL_CHAR_LIMIT = 6000
|
||||
const INGRESS_DEDUP_LIMIT = 4_000
|
||||
const EMPTY_MENTION_REPLY = "Tag me with a question!"
|
||||
const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread."
|
||||
const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread."
|
||||
const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command."
|
||||
const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot."
|
||||
const COMMAND_ACK = "Running command in this thread..."
|
||||
const CATCHUP_PAGE_SIZE = 100
|
||||
const COMMANDS = [
|
||||
{
|
||||
name: "status",
|
||||
description: "Show sandbox status for this thread",
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Destroy the sandbox session for this thread",
|
||||
},
|
||||
] as const
|
||||
|
||||
const commandText = (name: string): string => {
|
||||
if (name === "status") return "!status"
|
||||
if (name === "reset") return "!reset"
|
||||
return ""
|
||||
}
|
||||
|
||||
const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => {
|
||||
if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true
|
||||
if (config.discordCategoryId && categoryId === config.discordCategoryId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => {
|
||||
if (!config.discordRequiredRoleId) return true
|
||||
if (!member) return false
|
||||
return member.roles.cache.has(config.discordRequiredRoleId)
|
||||
}
|
||||
|
||||
const asThreadChannel = (value: unknown): ThreadChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const asTextChannel = (value: unknown): TextChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.GuildText) return value as TextChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => {
|
||||
if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true
|
||||
if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true
|
||||
if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
|
||||
function* (channel: ChatChannel, latest: string) {
|
||||
const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
const lines = ordered
|
||||
.filter((prior) => !prior.system)
|
||||
.flatMap((prior) => {
|
||||
const text = prior.content.replace(/\s+/g, " ").trim()
|
||||
const files = prior.attachments.size > 0
|
||||
? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
|
||||
: ""
|
||||
const line = text || files
|
||||
if (!line) return []
|
||||
const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
|
||||
return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
|
||||
})
|
||||
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
|
||||
const selected = prior.reduceRight(
|
||||
(state, candidate) => {
|
||||
if (state.stop) return state
|
||||
if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
|
||||
return { ...state, stop: true }
|
||||
}
|
||||
return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
|
||||
},
|
||||
{ list: [] as ReadonlyArray<string>, total: 0, stop: false },
|
||||
).list
|
||||
|
||||
return [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
},
|
||||
)
|
||||
|
||||
const statusOf = (cause: unknown): number | null => {
|
||||
if (typeof cause !== "object" || cause === null) return null
|
||||
const status = (cause as { status?: unknown }).status
|
||||
if (typeof status === "number") return status
|
||||
const code = (cause as { code?: unknown }).code
|
||||
if (typeof code === "number") return code
|
||||
return null
|
||||
}
|
||||
|
||||
const deliveryRetriable = (cause: unknown): boolean => {
|
||||
const status = statusOf(cause)
|
||||
if (status === 429) return true
|
||||
if (status !== null && status >= 500) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const catchupBenign = (cause: unknown): boolean => {
|
||||
const text = messageOf(cause).toLowerCase()
|
||||
if (text.includes("missing access")) return true
|
||||
if (text.includes("missing permissions")) return true
|
||||
if (text.includes("unknown channel")) return true
|
||||
if (text.includes("50001")) return true
|
||||
if (text.includes("50013")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const deliveryRetry = Schedule.exponential("200 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(3)),
|
||||
Schedule.whileInput((error: DeliveryError) => error.retriable),
|
||||
)
|
||||
|
||||
export class DiscordConversationServices {
|
||||
static readonly portLayer = Layer.scopedContext(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const ledger = yield* ConversationLedger
|
||||
const runtime = yield* Effect.runtime<never>()
|
||||
const input = yield* Queue.unbounded<Inbound>()
|
||||
const chats = new Map<string, ChatChannel>()
|
||||
const texts = new Map<string, TextChannel>()
|
||||
const refs = new Map<string, Message>()
|
||||
const roots = new Map<string, ThreadId>()
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
const ref_ids: Array<string> = []
|
||||
const root_ids: Array<string> = []
|
||||
|
||||
const mark = (message_id: string): boolean => {
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
order.push(message_id)
|
||||
if (order.length <= INGRESS_DEDUP_LIMIT) return true
|
||||
const oldest = order.shift()
|
||||
if (!oldest) return true
|
||||
seen.delete(oldest)
|
||||
return true
|
||||
}
|
||||
|
||||
const stash = <A>(map: Map<string, A>, keys: Array<string>, key: string, value: A) => {
|
||||
if (!map.has(key)) keys.push(key)
|
||||
map.set(key, value)
|
||||
if (keys.length <= INGRESS_DEDUP_LIMIT) return
|
||||
const oldest = keys.shift()
|
||||
if (!oldest) return
|
||||
map.delete(oldest)
|
||||
}
|
||||
|
||||
const sourceChannel = (channel_id: string) => `channel:${channel_id}`
|
||||
const sourceThread = (thread_id: string) => `thread:${thread_id}`
|
||||
const uniq = <A>(values: ReadonlyArray<A>): Array<A> => [...new Set(values)]
|
||||
|
||||
const offer = (event: Inbound, onFresh: Effect.Effect<void>) =>
|
||||
ledger.admit(event).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) {
|
||||
return Effect.logDebug("Message deduped (already seen)").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.message.deduped",
|
||||
message_id: event.message_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.logInfo("Message queued").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.message.queued",
|
||||
kind: event.kind,
|
||||
message_id: event.message_id,
|
||||
author_id: event.author_id,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.zipRight(onFresh),
|
||||
Effect.zipRight(input.offer(event)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const memberOf = (message: Message) => {
|
||||
if (message.member) return Effect.succeed(message.member)
|
||||
if (config.discordRequiredRoleId.length === 0) return Effect.succeed<GuildMember | null>(null)
|
||||
const guild = message.guild
|
||||
if (!guild) return Effect.succeed<GuildMember | null>(null)
|
||||
return Effect.tryPromise(() => guild.members.fetch(message.author.id)).pipe(
|
||||
Effect.catchAll(() => Effect.succeed<GuildMember | null>(null)),
|
||||
)
|
||||
}
|
||||
|
||||
const ingestMessage = Effect.fn("DiscordAdapter.ingestMessage")(function* (message: Message) {
|
||||
const source = message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread
|
||||
? sourceThread(message.channel.id)
|
||||
: message.channel.type === ChannelType.GuildText
|
||||
? sourceChannel(message.channel.id)
|
||||
: null
|
||||
if (source === null) return
|
||||
|
||||
if (message.author.bot || message.mentions.everyone) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
const member = yield* memberOf(message)
|
||||
if (!hasRequiredRole(member, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const bot_user_id = client.user?.id ?? ""
|
||||
const bot_role_id = config.discordRoleId
|
||||
const mentioned = isMentioned(message, bot_user_id, bot_role_id)
|
||||
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim()
|
||||
const mentions = Mention.make({
|
||||
user_ids: [...message.mentions.users.keys()],
|
||||
role_ids: [...message.mentions.roles.keys()],
|
||||
})
|
||||
|
||||
if (!content && mentioned) {
|
||||
yield* Effect.tryPromise(() => message.reply(EMPTY_MENTION_REPLY)).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread) {
|
||||
const thread = message.channel as ThreadChannel
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
const channel_id = ChannelId.make(thread.parentId ?? thread.id)
|
||||
const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
|
||||
|
||||
if (!allowed) {
|
||||
const owned = yield* pool.hasTrackedThread(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned || mentioned) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const event = ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: message.id,
|
||||
guild_id: GuildId.make(message.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id,
|
||||
author_id: message.author.id,
|
||||
author_is_bot: message.author.bot,
|
||||
mentions_everyone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
chats.set(event.thread_id, thread)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = message.channel as TextChannel
|
||||
if (!isChannelAllowed(channel.id, channel.parentId ?? null, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const event = ChannelMessage.make({
|
||||
kind: "channel_message",
|
||||
channel_id: ChannelId.make(channel.id),
|
||||
message_id: message.id,
|
||||
guild_id: GuildId.make(message.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id,
|
||||
author_id: message.author.id,
|
||||
author_is_bot: message.author.bot,
|
||||
mentions_everyone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
texts.set(event.channel_id, channel)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
})
|
||||
|
||||
const onMessage = (message: Message): void => {
|
||||
if (!mark(message.id)) return
|
||||
const run = ingestMessage(message).pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Failed ingesting Discord message").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.ingest.failed",
|
||||
message_id: message.id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
)),
|
||||
)
|
||||
void Runtime.runPromise(runtime)(run)
|
||||
}
|
||||
|
||||
const pullAfter = (channel: ChatChannel, after: string): Effect.Effect<number, unknown> =>
|
||||
Effect.tryPromise(() =>
|
||||
channel.messages.fetch({
|
||||
limit: CATCHUP_PAGE_SIZE,
|
||||
after,
|
||||
})
|
||||
).pipe(
|
||||
Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
|
||||
Effect.flatMap((rows) => {
|
||||
if (rows.length === 0) return Effect.succeed(0)
|
||||
const last = rows.at(-1)
|
||||
if (!last) return Effect.succeed(0)
|
||||
return Effect.forEach(rows, (row) => ingestMessage(row), { discard: true }).pipe(
|
||||
Effect.zipRight(
|
||||
rows.length < CATCHUP_PAGE_SIZE
|
||||
? Effect.succeed(rows.length)
|
||||
: pullAfter(channel, last.id).pipe(Effect.map((tail: number) => rows.length + tail)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const catchupSource = (source: string, channel: ChatChannel) =>
|
||||
Effect.gen(function* () {
|
||||
const offset = yield* ledger.getOffset(source)
|
||||
if (Option.isNone(offset)) {
|
||||
const page = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: 1 }))
|
||||
const latest = page.first()
|
||||
if (latest) yield* ledger.setOffset(source, latest.id)
|
||||
return 0
|
||||
}
|
||||
return yield* pullAfter(channel, offset.value)
|
||||
})
|
||||
|
||||
const categoryChannels = () =>
|
||||
Effect.gen(function* () {
|
||||
if (config.discordCategoryId.length === 0) return [] as Array<string>
|
||||
const guilds = [...client.guilds.cache.values()]
|
||||
const nested = yield* Effect.forEach(
|
||||
guilds,
|
||||
(guild) =>
|
||||
Effect.tryPromise(() => guild.channels.fetch()).pipe(
|
||||
Effect.map((channels) =>
|
||||
[...channels.values()].flatMap((channel) => {
|
||||
const text = asTextChannel(channel)
|
||||
if (!text) return []
|
||||
if (text.parentId !== config.discordCategoryId) return []
|
||||
return [text.id]
|
||||
}),
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed([] as Array<string>)),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
const fetchText = (channel_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(channel_id)).pipe(
|
||||
Effect.map((channel) => asTextChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const fetchThread = (thread_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
Effect.map((channel) => asThreadChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const recoverMissedMessages = Effect.gen(function* () {
|
||||
const channels = config.allowedChannelIds.length > 0
|
||||
? uniq(config.allowedChannelIds)
|
||||
: uniq(yield* categoryChannels())
|
||||
const threads = uniq((yield* sessions.listTrackedThreads()).map((id) => String(id)))
|
||||
|
||||
const fromChannels = yield* Effect.forEach(
|
||||
channels,
|
||||
(channel_id) =>
|
||||
fetchText(channel_id).pipe(
|
||||
Effect.flatMap((channel) => {
|
||||
if (!channel) return Effect.succeed(0)
|
||||
return catchupSource(sourceChannel(channel_id), channel)
|
||||
}),
|
||||
Effect.catchAll((error) => {
|
||||
const log = catchupBenign(error) ? Effect.logDebug("Channel catch-up skipped") : Effect.logWarning("Channel catch-up failed")
|
||||
return log.pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.channel.failed",
|
||||
channel_id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const fromThreads = yield* Effect.forEach(
|
||||
threads,
|
||||
(thread_id) =>
|
||||
fetchThread(thread_id).pipe(
|
||||
Effect.flatMap((thread) => {
|
||||
if (!thread) return Effect.succeed(0)
|
||||
return catchupSource(sourceThread(thread_id), thread)
|
||||
}),
|
||||
Effect.catchAll((error) => {
|
||||
const log = catchupBenign(error) ? Effect.logDebug("Thread catch-up skipped") : Effect.logWarning("Thread catch-up failed")
|
||||
return log.pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.thread.failed",
|
||||
thread_id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const fetched = [...fromChannels, ...fromThreads].reduce((n, x) => n + x, 0)
|
||||
yield* Effect.logInfo("Discord catch-up complete").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.complete",
|
||||
channels: channels.length,
|
||||
threads: threads.length,
|
||||
fetched,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const acknowledge = (interaction: ChatInputCommandInteraction, content: string) =>
|
||||
Effect.tryPromise(async () => {
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content })
|
||||
} else {
|
||||
await interaction.reply({ content, flags: MessageFlags.Ephemeral })
|
||||
}
|
||||
}).pipe(Effect.catchAll(() => Effect.void))
|
||||
|
||||
const onInteraction = (interaction: Interaction): void => {
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const text = commandText(interaction.commandName)
|
||||
if (!text) return
|
||||
if (!mark(interaction.id)) return
|
||||
const handle = Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() =>
|
||||
interaction.deferReply({
|
||||
flags: MessageFlags.Ephemeral,
|
||||
})
|
||||
).pipe(Effect.catchAll(() => Effect.void))
|
||||
const thread = asThreadChannel(interaction.channel)
|
||||
if (!thread) {
|
||||
yield* acknowledge(interaction, COMMAND_NOT_THREAD_REPLY)
|
||||
return
|
||||
}
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
const channel_id = ChannelId.make(thread.parentId ?? thread.id)
|
||||
const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
|
||||
if (!allowed) {
|
||||
const owned = yield* pool.hasTrackedThread(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned) {
|
||||
yield* acknowledge(interaction, COMMAND_CHANNEL_REPLY)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const member = yield* Effect.tryPromise(() =>
|
||||
interaction.guild ? interaction.guild.members.fetch(interaction.user.id) : Promise.resolve(null),
|
||||
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
||||
if (!hasRequiredRole(member, config)) {
|
||||
yield* acknowledge(interaction, COMMAND_FORBIDDEN_REPLY)
|
||||
return
|
||||
}
|
||||
|
||||
const bot_user_id = client.user?.id ?? ""
|
||||
const event = ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: interaction.id,
|
||||
guild_id: GuildId.make(interaction.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id: config.discordRoleId,
|
||||
author_id: interaction.user.id,
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({
|
||||
user_ids: bot_user_id.length > 0 ? [bot_user_id] : [],
|
||||
role_ids: [],
|
||||
}),
|
||||
content: text,
|
||||
})
|
||||
const ingest = ledger.admit(event).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
chats.set(event.thread_id, thread)
|
||||
input.unsafeOffer(event)
|
||||
})
|
||||
}),
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
yield* ingest
|
||||
yield* acknowledge(interaction, COMMAND_ACK)
|
||||
})
|
||||
void Runtime.runPromise(runtime)(handle)
|
||||
}
|
||||
|
||||
const registerCommands = Effect.gen(function* () {
|
||||
if (!client.isReady()) {
|
||||
yield* Effect.async<void, never>((resume) => {
|
||||
const ready = () => {
|
||||
resume(Effect.void)
|
||||
}
|
||||
client.once("clientReady", ready)
|
||||
return Effect.sync(() => {
|
||||
client.off("clientReady", ready)
|
||||
})
|
||||
})
|
||||
}
|
||||
const app = client.application
|
||||
if (!app) return
|
||||
const guild = config.discordCommandGuildId.trim()
|
||||
const registered = yield* Effect.tryPromise(() =>
|
||||
guild.length > 0
|
||||
? app.commands.set([...COMMANDS], guild)
|
||||
: app.commands.set([...COMMANDS]),
|
||||
)
|
||||
yield* Effect.logInfo("Discord slash commands registered").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "discord.commands.registered",
|
||||
scope: guild.length > 0 ? "guild" : "global",
|
||||
guild_id: guild.length > 0 ? guild : "global",
|
||||
count: registered.size,
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.tapError((cause) =>
|
||||
Effect.logError("Discord slash command registration failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "discord.commands.failed",
|
||||
message: messageOf(cause),
|
||||
}),
|
||||
)),
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
|
||||
yield* registerCommands
|
||||
|
||||
client.on("messageCreate", onMessage)
|
||||
client.on("interactionCreate", onInteraction)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
client.off("messageCreate", onMessage)
|
||||
client.off("interactionCreate", onInteraction)
|
||||
yield* input.shutdown
|
||||
}),
|
||||
)
|
||||
|
||||
yield* recoverMissedMessages.pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Discord catch-up failed").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.catchup.failed", error: messageOf(error) }),
|
||||
)),
|
||||
)
|
||||
|
||||
yield* ledger.replayPending().pipe(
|
||||
Effect.flatMap((events) =>
|
||||
Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
input.unsafeOffer(event)
|
||||
}),
|
||||
{ discard: true },
|
||||
).pipe(
|
||||
Effect.zipRight(
|
||||
Effect.logInfo("Replayed pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay", count: events.length }),
|
||||
),
|
||||
),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Failed replaying pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay.failed", error: messageOf(error) }),
|
||||
)),
|
||||
)
|
||||
|
||||
const inbox = Inbox.of({
|
||||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
})
|
||||
|
||||
const channelOf = (thread_id: ThreadId, action: Action["kind"]) => {
|
||||
const channel = chats.get(thread_id)
|
||||
if (channel) return Effect.succeed(channel)
|
||||
return Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
Effect.flatMap((fetched) => {
|
||||
const thread = asThreadChannel(fetched)
|
||||
if (thread) {
|
||||
chats.set(thread_id, thread)
|
||||
return Effect.succeed(thread)
|
||||
}
|
||||
return DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: "missing-thread-channel",
|
||||
retriable: false,
|
||||
})
|
||||
}),
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const deliver = (thread_id: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
yield* send.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
Effect.tapError((error) =>
|
||||
Ref.updateAndGet(attempts, (n) => n + 1).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
Effect.logWarning("Discord delivery attempt failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.retry",
|
||||
thread_id,
|
||||
action,
|
||||
attempt,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
),
|
||||
Effect.retry(deliveryRetry),
|
||||
Effect.tapError((error) =>
|
||||
Ref.get(attempts).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
Effect.logError("Discord delivery failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.failed",
|
||||
thread_id,
|
||||
action,
|
||||
attempts: attempt,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const sendTyping = (thread_id: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, "typing")
|
||||
yield* deliver(thread_id, "typing", Effect.tryPromise(() => channel.sendTyping()))
|
||||
})
|
||||
|
||||
const sendText = (thread_id: ThreadId, action: "send" | "reply", text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, action)
|
||||
yield* Effect.forEach(
|
||||
splitForDiscord(cleanResponse(text)),
|
||||
(chunk) => deliver(thread_id, action, Effect.tryPromise(() => channel.send(chunk))),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const publish = (action: Action) => {
|
||||
if (action.kind === "typing") return sendTyping(action.thread_id)
|
||||
return sendText(action.thread_id, action.kind, action.text)
|
||||
}
|
||||
|
||||
const withTyping = <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = publish(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* pulse
|
||||
yield* Effect.forkScoped(
|
||||
Effect.repeat(pulse, Schedule.spaced(TYPING_INTERVAL)).pipe(
|
||||
Effect.delay(TYPING_INTERVAL),
|
||||
),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
)
|
||||
|
||||
const outbox = Outbox.of({ publish, withTyping })
|
||||
|
||||
const history = History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = chats.get(thread_id)
|
||||
if (!channel) return latest
|
||||
return yield* buildHistoryReplayPrompt(channel, latest).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
HistoryError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const threads = Threads.of({
|
||||
ensure: (event, name: string) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
|
||||
const known = roots.get(event.message_id)
|
||||
if (known) {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: known, channel_id: event.channel_id }))
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const local = texts.get(event.channel_id)
|
||||
const channel = local
|
||||
? local
|
||||
: yield* Effect.tryPromise(() => client.channels.fetch(event.channel_id)).pipe(
|
||||
Effect.map((fetched) => asTextChannel(fetched)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
if (!channel) {
|
||||
return yield* ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: "missing-parent-channel",
|
||||
retriable: false,
|
||||
})
|
||||
}
|
||||
texts.set(event.channel_id, channel)
|
||||
const base = refs.get(event.message_id)
|
||||
const thread = yield* Effect.tryPromise(() =>
|
||||
channel.threads.create({
|
||||
name,
|
||||
startMessage: base ?? event.message_id,
|
||||
autoArchiveDuration: 60,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.tryPromise(() =>
|
||||
base
|
||||
? base.reply(SETUP_FAILURE_REPLY).then(() => undefined)
|
||||
: Promise.resolve(undefined)
|
||||
).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
chats.set(thread_id, thread)
|
||||
stash(roots, root_ids, event.message_id, thread_id)
|
||||
return ThreadRef.make({ thread_id, channel_id: event.channel_id })
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return Context.empty().pipe(
|
||||
Context.add(Inbox, inbox),
|
||||
Context.add(Outbox, outbox),
|
||||
Context.add(History, history),
|
||||
Context.add(Threads, threads),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const DiscordConversationServicesLive = DiscordConversationServices.portLayer
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../../../discord/turn-routing"
|
||||
import { ThreadAgentPool, type ThreadAgent } from "../../../sandbox/pool"
|
||||
import { effectTest, testConfigLayer } from "../../../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types"
|
||||
import { Conversation } from "../../services/conversation"
|
||||
import { ConversationLedger } from "../../services/ledger"
|
||||
import { makeTui } from "./index"
|
||||
|
||||
const makeSession = (id: string) =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make("thread-local-channel"),
|
||||
channelId: ChannelId.make("local-channel"),
|
||||
guildId: GuildId.make("local"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(id),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const routerLayer = Layer.succeed(
|
||||
TurnRouter,
|
||||
TurnRouter.of({
|
||||
shouldRespond: () =>
|
||||
Effect.succeed(TurnRoutingDecision.make({ shouldRespond: true, reason: "test" })),
|
||||
generateThreadName: () => Effect.succeed("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const makePoolLayer = (opts: {
|
||||
getOrCreate?: ThreadAgentPool.Service["getOrCreate"]
|
||||
send?: (prompt: string) => string
|
||||
seen?: Array<string>
|
||||
gate?: Deferred.Deferred<void>
|
||||
}) => {
|
||||
const session = makeSession("s1")
|
||||
const seen = opts.seen ?? []
|
||||
const defaultGetOrCreate: ThreadAgentPool.Service["getOrCreate"] = () =>
|
||||
Effect.gen(function* () {
|
||||
if (opts.gate) yield* Deferred.await(opts.gate)
|
||||
return {
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (prompt: string) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(prompt)
|
||||
return opts.send ? opts.send(prompt) : `local:${prompt}`
|
||||
}),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
} satisfies ThreadAgent
|
||||
})
|
||||
|
||||
return Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: opts.getOrCreate ?? defaultGetOrCreate,
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(Option.none()),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("makeTui", () => {
|
||||
effectTest("drives conversation locally without Discord", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string> = []
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ seen })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("hello local")
|
||||
|
||||
const first = yield* tui.take
|
||||
const second = yield* tui.take
|
||||
|
||||
expect(seen).toEqual(["hello local"])
|
||||
expect(first.kind).toBe("typing")
|
||||
expect(second.kind).toBe("send")
|
||||
expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.thread_id))).toBe(true)
|
||||
if (second.kind === "send") expect(second.text).toBe("local:hello local")
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("publishes typing before session resolution completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ gate })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("hello local")
|
||||
const first = yield* tui.take
|
||||
expect(first.kind).toBe("typing")
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
const second = yield* tui.take
|
||||
expect(second.kind).toBe("send")
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("channel messages create distinct threads", () =>
|
||||
Effect.gen(function* () {
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({})
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("one")
|
||||
const firstTyping = yield* tui.take
|
||||
const firstSend = yield* tui.take
|
||||
|
||||
yield* tui.send("two")
|
||||
const secondTyping = yield* tui.take
|
||||
const secondSend = yield* tui.take
|
||||
|
||||
expect(firstTyping.kind).toBe("typing")
|
||||
expect(firstSend.kind).toBe("send")
|
||||
expect(secondTyping.kind).toBe("typing")
|
||||
expect(secondSend.kind).toBe("send")
|
||||
if (firstTyping.kind === "typing" && secondTyping.kind === "typing") {
|
||||
expect(firstTyping.thread_id === secondTyping.thread_id).toBe(false)
|
||||
}
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
})
|
||||
169
packages/discord/src/conversation/implementations/local/index.ts
Normal file
169
packages/discord/src/conversation/implementations/local/index.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { Effect, Layer, Queue, Schedule, Stream } from "effect"
|
||||
import { TYPING_INTERVAL } from "../../../discord/constants"
|
||||
import { ChannelId, GuildId, ThreadId } from "../../../types"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
|
||||
import { History, Inbox, Outbox, Threads } from "../../services"
|
||||
|
||||
export type Tui = {
|
||||
layer: Layer.Layer<Inbox | Outbox | History | Threads, never, never>
|
||||
send: (text: string) => Effect.Effect<void>
|
||||
sendTo: (thread_id: ThreadId, text: string) => Effect.Effect<void>
|
||||
take: Effect.Effect<Action>
|
||||
actions: Stream.Stream<Action>
|
||||
}
|
||||
|
||||
export const makeTui = Effect.gen(function* () {
|
||||
const input = yield* Queue.unbounded<Inbound>()
|
||||
const output = yield* Queue.unbounded<Action>()
|
||||
const history = new Map<string, Array<string>>()
|
||||
const roots = new Map<string, ThreadId>()
|
||||
const parents = new Map<string, ChannelId>()
|
||||
const words = {
|
||||
a: ["brisk", "calm", "dapper", "eager", "fuzzy", "gentle", "jolly", "mellow", "nimble", "sunny"],
|
||||
b: ["otter", "falcon", "panda", "badger", "fox", "heron", "lemur", "raven", "tiger", "whale"],
|
||||
} as const
|
||||
let seq = 0
|
||||
|
||||
const name = () => {
|
||||
const i = seq
|
||||
seq += 1
|
||||
const x = words.a[i % words.a.length] ?? "brisk"
|
||||
const y = words.b[Math.floor(i / words.a.length) % words.b.length] ?? "otter"
|
||||
const z = Math.floor(i / (words.a.length * words.b.length)) + 1
|
||||
return ThreadId.make(`thread-${x}-${y}-${z}`)
|
||||
}
|
||||
|
||||
const remember = (thread_id: ThreadId, line: string) => {
|
||||
const current = history.get(thread_id)
|
||||
if (current) {
|
||||
current.push(line)
|
||||
return
|
||||
}
|
||||
history.set(thread_id, [line])
|
||||
}
|
||||
|
||||
const sendTo = (thread_id: ThreadId, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
remember(thread_id, `user: ${text}`)
|
||||
const channel_id = parents.get(thread_id) ?? ChannelId.make(`channel-${thread_id}`)
|
||||
yield* input.offer(
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: crypto.randomUUID(),
|
||||
guild_id: GuildId.make("local"),
|
||||
bot_user_id: "local-bot",
|
||||
bot_role_id: "",
|
||||
author_id: "local-user",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: [], role_ids: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel_id = ChannelId.make("local-channel")
|
||||
yield* input.offer(
|
||||
ChannelMessage.make({
|
||||
kind: "channel_message",
|
||||
channel_id,
|
||||
message_id: crypto.randomUUID(),
|
||||
guild_id: GuildId.make("local"),
|
||||
bot_user_id: "local-bot",
|
||||
bot_role_id: "",
|
||||
author_id: "local-user",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["local-bot"], role_ids: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.gen(function* () {
|
||||
if (action.kind === "send" || action.kind === "reply") {
|
||||
remember(action.thread_id, `assistant: ${action.text}`)
|
||||
}
|
||||
yield* output.offer(action).pipe(Effect.asVoid)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = output.offer(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
yield* pulse
|
||||
yield* Effect.forkScoped(
|
||||
Effect.repeat(pulse, Schedule.spaced(TYPING_INTERVAL)).pipe(
|
||||
Effect.delay(TYPING_INTERVAL),
|
||||
),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
Effect.sync(() => {
|
||||
const lines = history.get(thread_id) ?? []
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
return [
|
||||
"Conversation history from this same thread (oldest to newest):",
|
||||
prior.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.kind === "thread_message") {
|
||||
parents.set(event.thread_id, event.channel_id)
|
||||
return ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })
|
||||
}
|
||||
const known = roots.get(event.message_id)
|
||||
if (known) return ThreadRef.make({ thread_id: known, channel_id: event.channel_id })
|
||||
const thread_id = name()
|
||||
roots.set(event.message_id, thread_id)
|
||||
parents.set(thread_id, event.channel_id)
|
||||
return ThreadRef.make({ thread_id, channel_id: event.channel_id })
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
layer,
|
||||
send,
|
||||
sendTo,
|
||||
take: output.take,
|
||||
actions: Stream.fromQueue(output, { shutdown: false }),
|
||||
} satisfies Tui
|
||||
})
|
||||
98
packages/discord/src/conversation/model/errors.ts
Normal file
98
packages/discord/src/conversation/model/errors.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Schema } from "effect"
|
||||
import { ChannelId, ThreadId } from "../../types"
|
||||
|
||||
const DeliveryAction = Schema.Literal("typing", "send", "reply")
|
||||
|
||||
export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
|
||||
"ThreadEnsureError",
|
||||
{
|
||||
channel_id: ChannelId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class HistoryError extends Schema.TaggedError<HistoryError>()(
|
||||
"HistoryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DeliveryError extends Schema.TaggedError<DeliveryError>()(
|
||||
"DeliveryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
action: DeliveryAction,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class RoutingError extends Schema.TaggedError<RoutingError>()(
|
||||
"RoutingError",
|
||||
{
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxSendError extends Schema.TaggedError<SandboxSendError>()(
|
||||
"SandboxSendError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ReliabilityError extends Schema.TaggedError<ReliabilityError>()(
|
||||
"ReliabilityError",
|
||||
{
|
||||
message_id: Schema.String,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export const ConversationError = Schema.Union(
|
||||
ThreadEnsureError,
|
||||
HistoryError,
|
||||
DeliveryError,
|
||||
RoutingError,
|
||||
SandboxSendError,
|
||||
ReliabilityError,
|
||||
)
|
||||
|
||||
export type ConversationError = typeof ConversationError.Type
|
||||
|
||||
const messageFrom = (value: unknown, depth: number): string => {
|
||||
if (depth > 4) return String(value)
|
||||
if (typeof value === "string") return value
|
||||
|
||||
if (value instanceof Error) {
|
||||
const nested = Reflect.get(value, "cause")
|
||||
if (nested === undefined) return value.message
|
||||
const inner = messageFrom(nested, depth + 1)
|
||||
if (inner.length === 0 || inner === value.message) return value.message
|
||||
return `${value.message}: ${inner}`
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const message = Reflect.get(value, "message")
|
||||
const nested = Reflect.get(value, "cause")
|
||||
if (typeof message === "string" && nested === undefined) return message
|
||||
if (typeof message === "string") {
|
||||
const inner = messageFrom(nested, depth + 1)
|
||||
if (inner.length === 0 || inner === message) return message
|
||||
return `${message}: ${inner}`
|
||||
}
|
||||
if (nested !== undefined) return messageFrom(nested, depth + 1)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const messageOf = (cause: unknown): string => messageFrom(cause, 0)
|
||||
73
packages/discord/src/conversation/model/schema.ts
Normal file
73
packages/discord/src/conversation/model/schema.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Schema } from "effect"
|
||||
import { ThreadId, ChannelId, GuildId } from "../../types"
|
||||
|
||||
export class Mention extends Schema.Class<Mention>("Mention")({
|
||||
user_ids: Schema.Array(Schema.String),
|
||||
role_ids: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ThreadMessage extends Schema.Class<ThreadMessage>("ThreadMessage")({
|
||||
kind: Schema.Literal("thread_message"),
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ChannelMessage extends Schema.Class<ChannelMessage>("ChannelMessage")({
|
||||
kind: Schema.Literal("channel_message"),
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Inbound = Schema.Union(
|
||||
ThreadMessage,
|
||||
ChannelMessage,
|
||||
)
|
||||
|
||||
export type Inbound = typeof Inbound.Type
|
||||
|
||||
export class ThreadRef extends Schema.Class<ThreadRef>("ThreadRef")({
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
}) {}
|
||||
|
||||
export class Send extends Schema.Class<Send>("Send")({
|
||||
kind: Schema.Literal("send"),
|
||||
thread_id: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Reply extends Schema.Class<Reply>("Reply")({
|
||||
kind: Schema.Literal("reply"),
|
||||
thread_id: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Typing extends Schema.Class<Typing>("Typing")({
|
||||
kind: Schema.Literal("typing"),
|
||||
thread_id: ThreadId,
|
||||
}) {}
|
||||
|
||||
export const Action = Schema.Union(
|
||||
Send,
|
||||
Reply,
|
||||
Typing,
|
||||
)
|
||||
|
||||
export type Action = typeof Action.Type
|
||||
799
packages/discord/src/conversation/services/conversation.test.ts
Normal file
799
packages/discord/src/conversation/services/conversation.test.ts
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../../discord/turn-routing"
|
||||
import { DatabaseError, OpenCodeClientError, SandboxDeadError } from "../../errors"
|
||||
import { ThreadAgentPool, type ThreadAgent } from "../../sandbox/pool"
|
||||
import { effectTest, testConfigLayer } from "../../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../types"
|
||||
import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger, MessageState } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
import { Conversation } from "./conversation"
|
||||
|
||||
const makeSession = (id: string, threadId = "t1", channelId = "c1") =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make(threadId),
|
||||
channelId: ChannelId.make(channelId),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(id),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const makeThreadEvent = (props: {
|
||||
threadId: string
|
||||
channelId: string
|
||||
messageId: string
|
||||
content: string
|
||||
}) =>
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id: ThreadId.make(props.threadId),
|
||||
channel_id: ChannelId.make(props.channelId),
|
||||
message_id: props.messageId,
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: [], role_ids: [] }),
|
||||
content: props.content,
|
||||
})
|
||||
|
||||
const makeEvent = (content: string) =>
|
||||
makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content })
|
||||
|
||||
const makeChannelEvent = (content: string) => ({
|
||||
kind: "channel_message" as const,
|
||||
channel_id: ChannelId.make("c-root"),
|
||||
message_id: "m-root",
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
const makeRouterLayer = (shouldRespond: boolean) =>
|
||||
Layer.succeed(
|
||||
TurnRouter,
|
||||
TurnRouter.of({
|
||||
shouldRespond: () =>
|
||||
Effect.succeed(
|
||||
TurnRoutingDecision.make({
|
||||
shouldRespond,
|
||||
reason: "test",
|
||||
}),
|
||||
),
|
||||
generateThreadName: () => Effect.succeed("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const makeAgent = (
|
||||
session: SessionInfo,
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>,
|
||||
prompts: Array<string>,
|
||||
): ThreadAgent => ({
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (text: string) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push(text)
|
||||
return { session, text }
|
||||
}).pipe(
|
||||
Effect.flatMap(({ session, text }) => send(session, text)),
|
||||
),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
|
||||
const makeConversationLayer = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
resolves: ReadonlyArray<SessionInfo>
|
||||
resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string>
|
||||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromIterable(props.events),
|
||||
}),
|
||||
)
|
||||
|
||||
const outboxLayer = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const historyLayer = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: props.rehydrate,
|
||||
}),
|
||||
)
|
||||
|
||||
const threadsLayer = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveSession = (threadId: ThreadId, channelId: ChannelId, guildId: GuildId): SessionInfo => {
|
||||
if (props.resolve) return props.resolve(threadId, channelId, guildId)
|
||||
const ix = Math.min(resolveIndex.value, props.resolves.length - 1)
|
||||
const session = props.resolves[ix]!
|
||||
resolveIndex.value += 1
|
||||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
}),
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(props.tracked),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
return Conversation.layer.pipe(
|
||||
Layer.provideMerge(inboxLayer),
|
||||
Layer.provideMerge(outboxLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Conversation", () => {
|
||||
effectTest("run consumes fake inbox and publishes typing + send", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [makeEvent("hello")],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
expect(prompts).toEqual(["hello"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("echo:hello")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn rehydrates prompt when tracked and resolved sessions differ", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.some(makeSession("s-old")),
|
||||
resolves: [makeSession("s-new")],
|
||||
send: () => Effect.succeed("ok"),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("help me"))
|
||||
|
||||
expect(prompts).toEqual(["rehydrated:help me"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn recovers from dead sandbox by re-resolving and retrying", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const calls = { value: 0 }
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s-a"), makeSession("s-b")],
|
||||
send: (_session, text) => {
|
||||
if (calls.value === 0) {
|
||||
calls.value += 1
|
||||
return Effect.fail(
|
||||
SandboxDeadError.make({
|
||||
threadId: ThreadId.make("t1"),
|
||||
reason: "dead",
|
||||
}),
|
||||
)
|
||||
}
|
||||
calls.value += 1
|
||||
return Effect.succeed(`ok:${text}`)
|
||||
},
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("fix build"))
|
||||
|
||||
expect(prompts).toEqual(["fix build", "rehydrated:fix build"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send", "send"])
|
||||
const recovery = actions[1]
|
||||
if (!recovery) throw new Error("missing recovery action")
|
||||
expect(recovery.kind).toBe("send")
|
||||
expect(recovery.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (recovery.kind === "send") expect(recovery.text).toBe("*Session changed state, recovering...*")
|
||||
|
||||
const sent = actions[2]
|
||||
if (!sent) throw new Error("missing final action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok:rehydrated:fix build")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn handles a channel message by using ensured thread target", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeChannelEvent("from channel"))
|
||||
|
||||
expect(prompts).toEqual(["from channel"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t-new"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("echo:from channel")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("run retries retriable failures in-process", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const calls = { value: 0 }
|
||||
const live = makeConversationLayer({
|
||||
events: [makeEvent("retry now")],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) =>
|
||||
Effect.gen(function* () {
|
||||
calls.value += 1
|
||||
if (calls.value === 1) {
|
||||
return yield* Effect.fail(new OpenCodeClientError({
|
||||
operation: "sendPrompt",
|
||||
statusCode: 502,
|
||||
body: "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
|
||||
}))
|
||||
}
|
||||
return `ok:${text}`
|
||||
}),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
expect(prompts).toEqual(["retry now", "retry now"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "typing", "send"])
|
||||
const sent = actions[2]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok:retry now")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn sends generic non-retriable error text", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: () =>
|
||||
Effect.fail(new DatabaseError({
|
||||
cause: new Error("StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)"),
|
||||
})),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("oops")).pipe(Effect.either)
|
||||
|
||||
expect(prompts).toEqual(["oops"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") {
|
||||
expect(sent.text).toBe("Something went wrong. Please try again in a moment.")
|
||||
expect(sent.text.includes("proxy.daytona.works")).toBe(false)
|
||||
}
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("run processes different thread keys concurrently", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const gate = Effect.runSync(Deferred.make<void>())
|
||||
const fast = Effect.runSync(Deferred.make<void>())
|
||||
const live = makeConversationLayer({
|
||||
events: [
|
||||
makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "slow" }),
|
||||
makeThreadEvent({ threadId: "t2", channelId: "c2", messageId: "m2", content: "fast" }),
|
||||
],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s-a", "t1", "c1"), makeSession("s-b", "t2", "c2")],
|
||||
resolve: (threadId, channelId) => {
|
||||
if (threadId === "t2") return makeSession("s-b", "t2", channelId)
|
||||
return makeSession("s-a", "t1", channelId)
|
||||
},
|
||||
send: (session, text) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.threadId === ThreadId.make("t2")) {
|
||||
yield* Deferred.succeed(fast, undefined)
|
||||
return `ok:${text}`
|
||||
}
|
||||
yield* Deferred.await(gate)
|
||||
return `ok:${text}`
|
||||
}),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
const fiber = yield* Effect.forkScoped(conversation.run)
|
||||
yield* Deferred.await(fast).pipe(
|
||||
Effect.timeoutFail({ duration: "1 second", onTimeout: () => "thread-concurrency-blocked" }),
|
||||
)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
expect(prompts.sort()).toEqual(["fast", "slow"])
|
||||
expect(actions.filter((x) => x.kind === "typing").length).toBe(2)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
})
|
||||
|
||||
// --- Duplicate processing tests ---
|
||||
|
||||
/** A ledger that tracks admit/start calls and enforces dedup like the real one */
|
||||
const makeTrackingLedger = () => {
|
||||
const admitted = new Set<string>()
|
||||
const started = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
const admitCalls: Array<string> = []
|
||||
const startCalls: Array<string> = []
|
||||
|
||||
const service: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
admitCalls.push(event.message_id)
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
startCalls.push(message_id)
|
||||
if (started.has(message_id) || completed.has(message_id)) return Option.none()
|
||||
started.add(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
completed.add(message_id)
|
||||
}),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
// Back to pending — NOT completed, so start will work again
|
||||
}),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
return { service, admitted, started, completed, admitCalls, startCalls }
|
||||
}
|
||||
|
||||
const makeConversationLayerWithLedger = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
resolves: ReadonlyArray<SessionInfo>
|
||||
resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string>
|
||||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
ledger: ConversationLedger.Service
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromIterable(props.events),
|
||||
}),
|
||||
)
|
||||
|
||||
const outboxLayer = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const historyLayer = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: props.rehydrate,
|
||||
}),
|
||||
)
|
||||
|
||||
const threadsLayer = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveSession = (threadId: ThreadId, channelId: ChannelId, guildId: GuildId): SessionInfo => {
|
||||
if (props.resolve) return props.resolve(threadId, channelId, guildId)
|
||||
const ix = Math.min(resolveIndex.value, props.resolves.length - 1)
|
||||
const session = props.resolves[ix]!
|
||||
resolveIndex.value += 1
|
||||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
}),
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(props.tracked),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const ledgerLayer = Layer.succeed(ConversationLedger, ConversationLedger.of(props.ledger))
|
||||
|
||||
return Conversation.layer.pipe(
|
||||
Layer.provideMerge(inboxLayer),
|
||||
Layer.provideMerge(outboxLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
Layer.provideMerge(ledgerLayer),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Conversation duplicate processing", () => {
|
||||
effectTest("same message_id queued twice via run is only sent once", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event = makeEvent("hello")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
// Feed the same event twice to simulate catch-up + real-time race
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// The ledger should have been called twice with admit
|
||||
expect(ledger.admitCalls).toEqual(["m1", "m1"])
|
||||
// But only one start should have succeeded
|
||||
expect(ledger.startCalls.length).toBeLessThanOrEqual(2)
|
||||
// The agent should have received the prompt only once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
// Only one typing + one send
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("noop ledger now deduplicates (bug is fixed)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const event = makeEvent("hello")
|
||||
|
||||
// The noop ledger now tracks seen message_ids
|
||||
const live = makeConversationLayer({
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// Same event fed twice but only processed once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn called twice with same event only processes once with tracking ledger", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event = makeEvent("help me")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
// Call turn twice with the same event (simulating race between real-time and catch-up)
|
||||
yield* conversation.turn(event)
|
||||
yield* conversation.turn(event)
|
||||
|
||||
expect(prompts).toEqual(["help me"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("retry resets to pending and second run processes again", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const sendCount = { value: 0 }
|
||||
|
||||
// A ledger that allows retry -> re-process flow
|
||||
const admitted = new Set<string>()
|
||||
const state = new Map<string, "pending" | "processing" | "completed">()
|
||||
const ledgerService: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
state.set(event.message_id, "pending")
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (state.get(message_id) !== "pending") return Option.none()
|
||||
state.set(message_id, "processing")
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "completed") }),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "pending") }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
const event = makeEvent("build it")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1"), makeSession("s2")],
|
||||
send: (_session, text) => {
|
||||
sendCount.value += 1
|
||||
if (sendCount.value === 1) {
|
||||
return Effect.fail(
|
||||
SandboxDeadError.make({
|
||||
threadId: ThreadId.make("t1"),
|
||||
reason: "dead",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(`ok:${text}`)
|
||||
},
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledgerService,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
// First turn fails (SandboxDeadError caught + recovery), so it processes once
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// After the first turn, the ledger state should be completed (recovery succeeded inline)
|
||||
// A second turn with the same event should be blocked by admit
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// The second turn should NOT have sent the prompt again
|
||||
// (admit returns false because the message was already admitted)
|
||||
const sendActions = actions.filter((x) => x.kind === "send")
|
||||
// The first turn should have: recovery message + successful reply
|
||||
expect(sendActions.length).toBeGreaterThanOrEqual(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("two different messages on same thread are processed sequentially (not lost)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event1 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "first" })
|
||||
const event2 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m2", content: "second" })
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [event1, event2],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// Both messages should be processed (different message_ids)
|
||||
expect(prompts).toEqual(["first", "second"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(2)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
})
|
||||
328
packages/discord/src/conversation/services/conversation.ts
Normal file
328
packages/discord/src/conversation/services/conversation.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { Context, Effect, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { TurnRouter } from "../../discord/turn-routing"
|
||||
import { ActorMap } from "../../lib/actors/keyed"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import type { ChannelId, ThreadId } from "../../types"
|
||||
import { type ConversationError, messageOf, ReliabilityError, RoutingError, SandboxSendError } from "../model/errors"
|
||||
import { Send, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
|
||||
export declare namespace Conversation {
|
||||
export interface Service {
|
||||
readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
|
||||
Conversation,
|
||||
Conversation.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
Conversation,
|
||||
Effect.gen(function* () {
|
||||
const inbox = yield* Inbox
|
||||
const outbox = yield* Outbox
|
||||
const history = yield* History
|
||||
const threads = yield* Threads
|
||||
const ledger = yield* ConversationLedger
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const router = yield* TurnRouter
|
||||
const actors = yield* ActorMap.make<string>()
|
||||
const RETRIABLE_TAGS: ReadonlySet<string> = new Set([
|
||||
"SandboxDeadError",
|
||||
"OpenCodeClientError",
|
||||
"HealthCheckError",
|
||||
"SandboxStartError",
|
||||
])
|
||||
const RETRY_MESSAGE = "Something went wrong. Please try again in a moment."
|
||||
const turnRetry = Schedule.exponential("500 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(2)),
|
||||
Schedule.whileInput((error: ConversationError) => error.retriable),
|
||||
)
|
||||
|
||||
const asSendError =
|
||||
(thread_id: ThreadId) =>
|
||||
(cause: { readonly _tag: string }): SandboxSendError =>
|
||||
SandboxSendError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: RETRIABLE_TAGS.has(cause._tag),
|
||||
})
|
||||
|
||||
const asReliabilityError = (message_id: string) =>
|
||||
(cause: unknown): ReliabilityError =>
|
||||
ReliabilityError.make({
|
||||
message_id,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
})
|
||||
|
||||
const publishText = (threadId: ThreadId, text: string) =>
|
||||
outbox.publish(
|
||||
Send.make({
|
||||
kind: "send",
|
||||
thread_id: threadId,
|
||||
text,
|
||||
}),
|
||||
)
|
||||
|
||||
const reportFailure = (thread_id: ThreadId) => (error: ConversationError) => {
|
||||
if (error.retriable) return Effect.fail(error)
|
||||
return publishText(thread_id, RETRY_MESSAGE).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)
|
||||
}
|
||||
|
||||
const route = Effect.fn("Conversation.route")(function* (event: Inbound) {
|
||||
if (event.author_is_bot) return false
|
||||
if (event.mentions_everyone) return false
|
||||
if (!event.content.trim()) return false
|
||||
|
||||
const mentioned = event.mentions.user_ids.includes(event.bot_user_id)
|
||||
|| (event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id))
|
||||
if (event.kind === "channel_message") return mentioned
|
||||
if (mentioned) return true
|
||||
|
||||
const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(
|
||||
Effect.mapError(asSendError(event.thread_id)),
|
||||
)
|
||||
if (!owned) return false
|
||||
|
||||
const decision = yield* router.shouldRespond({
|
||||
content: event.content,
|
||||
botUserId: event.bot_user_id,
|
||||
botRoleId: event.bot_role_id,
|
||||
mentionedUserIds: event.mentions.user_ids,
|
||||
mentionedRoleIds: event.mentions.role_ids,
|
||||
}).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
RoutingError.make({
|
||||
message: messageOf(cause),
|
||||
retriable: false,
|
||||
})),
|
||||
)
|
||||
return decision.shouldRespond
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) {
|
||||
if (event.kind === "thread_message") {
|
||||
return { thread_id: event.thread_id, channel_id: event.channel_id }
|
||||
}
|
||||
const name = yield* router.generateThreadName(event.content)
|
||||
return yield* threads.ensure(event, name)
|
||||
})
|
||||
|
||||
const buildInput = Effect.fn("Conversation.buildInput")(function* (
|
||||
event: Inbound,
|
||||
target: { thread_id: ThreadId; channel_id: ChannelId },
|
||||
) {
|
||||
const toSendError = asSendError(target.thread_id)
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const agent = yield* pool.getOrCreate(target.thread_id, target.channel_id, event.guild_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const current = yield* agent.current().pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const prompt = Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId
|
||||
? yield* history.rehydrate(target.thread_id, event.content)
|
||||
: event.content
|
||||
return { target, agent, prompt, session: current }
|
||||
})
|
||||
|
||||
const command = (event: Inbound, target: { thread_id: ThreadId; channel_id: ChannelId }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = event.content.trim().toLowerCase()
|
||||
if (text === "!reset") {
|
||||
yield* pool.destroySession(target.thread_id).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* publishText(target.thread_id, "*☠️ Session destroyed. Next message will provision a fresh sandbox.*")
|
||||
return true
|
||||
}
|
||||
if (text === "!status") {
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
|
||||
if (Option.isNone(tracked)) {
|
||||
yield* publishText(target.thread_id, "*No active session for this thread.*")
|
||||
} else {
|
||||
const s = tracked.value
|
||||
const model = config.openCodeModel.replace("opencode/", "")
|
||||
const lines = [
|
||||
`**Status:** ${s.status}`,
|
||||
`**Model:** \`${model}\``,
|
||||
`**Sandbox:** \`${s.sandboxId}\``,
|
||||
`**Session:** \`${s.sessionId}\``,
|
||||
s.resumeFailCount > 0 ? `**Resume failures:** ${s.resumeFailCount}` : null,
|
||||
s.lastError ? `**Last error:** ${s.lastError.slice(0, 200)}` : null,
|
||||
].filter(Boolean)
|
||||
yield* publishText(target.thread_id, lines.join("\n"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const turnRaw = Effect.fn("Conversation.turnRaw")(function* (
|
||||
event: Inbound,
|
||||
state: {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: string | null
|
||||
},
|
||||
) {
|
||||
if (!(yield* route(event))) return
|
||||
|
||||
const target = state.thread_id && state.channel_id
|
||||
? { thread_id: state.thread_id, channel_id: state.channel_id }
|
||||
: yield* resolve(event)
|
||||
|
||||
yield* ledger.setTarget(event.message_id, target.thread_id, target.channel_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
|
||||
if (state.response_text) {
|
||||
yield* publishText(target.thread_id, state.response_text)
|
||||
return
|
||||
}
|
||||
|
||||
if (yield* command(event, target)) return
|
||||
|
||||
yield* Effect.logInfo("User message").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.user.message",
|
||||
thread_id: target.thread_id,
|
||||
author_id: event.author_id,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* outbox.withTyping(
|
||||
target.thread_id,
|
||||
Effect.gen(function* () {
|
||||
const input = yield* buildInput(event, target)
|
||||
const reuse = state.prompt_text !== null
|
||||
&& state.session_id !== null
|
||||
&& state.session_id === input.session.sessionId
|
||||
const prompt = reuse ? (state.prompt_text ?? input.prompt) : input.prompt
|
||||
if (!reuse) {
|
||||
yield* ledger.setPrompt(event.message_id, prompt, input.session.sessionId).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
} else {
|
||||
yield* Effect.logInfo("Recovered in-flight prompt from ledger").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.ledger.prompt.reused",
|
||||
message_id: event.message_id,
|
||||
thread_id: target.thread_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const reply = yield* input.agent.send(prompt).pipe(
|
||||
Effect.catchTag("SandboxDeadError", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* publishText(input.target.thread_id, "*Session changed state, recovering...*")
|
||||
const toErr = asSendError(input.target.thread_id)
|
||||
const next = yield* pool.getOrCreate(
|
||||
input.target.thread_id,
|
||||
input.target.channel_id,
|
||||
event.guild_id,
|
||||
).pipe(Effect.mapError(toErr))
|
||||
const nextSession = yield* next.current().pipe(
|
||||
Effect.mapError(toErr),
|
||||
)
|
||||
const prompt = nextSession.sessionId !== input.session.sessionId
|
||||
? yield* history.rehydrate(input.target.thread_id, event.content)
|
||||
: event.content
|
||||
return yield* next.send(prompt)
|
||||
}),
|
||||
),
|
||||
Effect.mapError(asSendError(input.target.thread_id)),
|
||||
)
|
||||
|
||||
yield* Effect.logInfo("Bot reply").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.bot.reply",
|
||||
thread_id: input.target.thread_id,
|
||||
content: reply.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
yield* ledger.setResponse(event.message_id, reply).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
yield* publishText(input.target.thread_id, reply)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchAll(reportFailure(target.thread_id)),
|
||||
)
|
||||
})
|
||||
|
||||
const keyOf = (event: Inbound) =>
|
||||
event.kind === "thread_message"
|
||||
? `thread:${event.thread_id}`
|
||||
: `channel:${event.channel_id}`
|
||||
|
||||
const runEvent = Effect.fn("Conversation.runEvent")(function* (event: Inbound) {
|
||||
yield* ledger.admit(event).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
const state = yield* ledger.start(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
if (Option.isNone(state)) return
|
||||
|
||||
yield* turnRaw(event, state.value).pipe(
|
||||
Effect.tap(() =>
|
||||
ledger.complete(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
ledger.retry(event.message_id, messageOf(error).slice(0, 500)).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)),
|
||||
)
|
||||
})
|
||||
|
||||
const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
|
||||
yield* actors.run(
|
||||
keyOf(event),
|
||||
runEvent(event),
|
||||
{ touch: false },
|
||||
)
|
||||
})
|
||||
|
||||
const run = inbox.events.pipe(
|
||||
Stream.mapEffect(
|
||||
(event) =>
|
||||
turn(event).pipe(
|
||||
Effect.retry(turnRetry),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation turn failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.turn.failed",
|
||||
tag: error._tag,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
{ concurrency: "unbounded", unordered: true },
|
||||
),
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
return Conversation.of({ turn, run })
|
||||
}),
|
||||
)
|
||||
}
|
||||
18
packages/discord/src/conversation/services/history.ts
Normal file
18
packages/discord/src/conversation/services/history.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { HistoryError } from "../model/errors"
|
||||
|
||||
export declare namespace History {
|
||||
export interface Service {
|
||||
readonly rehydrate: (thread_id: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
|
||||
}
|
||||
}
|
||||
|
||||
export class History extends Context.Tag("@discord/conversation/History")<History, History.Service>() {
|
||||
static readonly passthrough = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (_thread_id: ThreadId, latest: string) => Effect.succeed(latest),
|
||||
}),
|
||||
)
|
||||
}
|
||||
17
packages/discord/src/conversation/services/inbox.ts
Normal file
17
packages/discord/src/conversation/services/inbox.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Context, Layer, Stream } from "effect"
|
||||
import type { Inbound } from "../model/schema"
|
||||
|
||||
export declare namespace Inbox {
|
||||
export interface Service {
|
||||
readonly events: Stream.Stream<Inbound>
|
||||
}
|
||||
}
|
||||
|
||||
export class Inbox extends Context.Tag("@discord/conversation/Inbox")<Inbox, Inbox.Service>() {
|
||||
static readonly empty = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.empty as Stream.Stream<Inbound>,
|
||||
}),
|
||||
)
|
||||
}
|
||||
6
packages/discord/src/conversation/services/index.ts
Normal file
6
packages/discord/src/conversation/services/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { Conversation } from "./conversation"
|
||||
export { History } from "./history"
|
||||
export { Inbox } from "./inbox"
|
||||
export { ConversationLedger } from "./ledger"
|
||||
export { Outbox } from "./outbox"
|
||||
export { Threads } from "./threads"
|
||||
166
packages/discord/src/conversation/services/ledger.test.ts
Normal file
166
packages/discord/src/conversation/services/ledger.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Redacted } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { effectTest, withTempSqliteFile } from "../../test/effect"
|
||||
import { ChannelId, GuildId, SessionId, ThreadId } from "../../types"
|
||||
import { Mention, ThreadMessage, type Inbound } from "../model/schema"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("token"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
openCodeZenApiKey: Redacted.make("zen"),
|
||||
githubToken: "",
|
||||
logLevel: "info",
|
||||
healthHost: "127.0.0.1",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off",
|
||||
turnRoutingModel: "claude-haiku-4-5",
|
||||
sandboxReusePolicy: "resume_preferred",
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
|
||||
pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
|
||||
activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
|
||||
startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
|
||||
resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
|
||||
sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
})
|
||||
|
||||
const event = (message_id: string, content: string): Inbound =>
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id: ThreadId.make("t1"),
|
||||
channel_id: ChannelId.make("c1"),
|
||||
message_id,
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
const withLedger = <A, E, R>(
|
||||
run: (ledger: ConversationLedger.Service, sql: Client.SqlClient) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
|
||||
const deps = Layer.merge(sqlite, config)
|
||||
const live = Layer.merge(
|
||||
ConversationLedger.layer.pipe(Layer.provide(deps)),
|
||||
sqlite,
|
||||
)
|
||||
const program = Effect.all([ConversationLedger, SqliteDb]).pipe(
|
||||
Effect.flatMap(([ledger, sql]) =>
|
||||
initializeSchema.pipe(
|
||||
Effect.provideService(Client.SqlClient, sql),
|
||||
Effect.zipRight(run(ledger, sql)),
|
||||
)),
|
||||
)
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-ledger-",
|
||||
)
|
||||
|
||||
describe("ConversationLedger", () => {
|
||||
effectTest("deduplicates by message id and tracks completion", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m1", "hello")
|
||||
expect(yield* ledger.admit(m)).toBe(true)
|
||||
expect(yield* ledger.admit(m)).toBe(false)
|
||||
|
||||
const started = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
if (Option.isNone(started)) return
|
||||
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:hello", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:hello")
|
||||
yield* ledger.complete(m.message_id)
|
||||
|
||||
const next = yield* ledger.start(m.message_id)
|
||||
expect(Option.isNone(next)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("replays pending rows and recovers processing rows", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const a = event("m-a", "one")
|
||||
const b = event("m-b", "two")
|
||||
yield* ledger.admit(a)
|
||||
yield* ledger.admit(b)
|
||||
|
||||
const started = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
|
||||
const replay = yield* ledger.replayPending()
|
||||
expect(replay.map((x) => x.message_id)).toEqual(["m-a", "m-b"])
|
||||
|
||||
const again = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(again)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("retains cached response across retry and prunes old completed rows", () =>
|
||||
withLedger((ledger, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m-cache", "cache")
|
||||
yield* ledger.admit(m)
|
||||
yield* ledger.start(m.message_id)
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:cache", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:cache")
|
||||
yield* ledger.retry(m.message_id, "send failed")
|
||||
|
||||
const resumed = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(resumed)).toBe(true)
|
||||
if (Option.isSome(resumed)) {
|
||||
expect(resumed.value.response_text).toBe("reply:cache")
|
||||
expect(resumed.value.thread_id).toBe(ThreadId.make("t1"))
|
||||
expect(resumed.value.channel_id).toBe(ChannelId.make("c1"))
|
||||
}
|
||||
|
||||
yield* ledger.complete(m.message_id)
|
||||
yield* sql`UPDATE conversation_inbox
|
||||
SET completed_at = datetime('now', '-10 minutes')
|
||||
WHERE message_id = ${m.message_id}`
|
||||
yield* ledger.prune()
|
||||
|
||||
const rows = yield* sql<{ n: number }>`SELECT COUNT(*) AS n FROM conversation_inbox WHERE message_id = ${m.message_id}`
|
||||
expect(rows[0]?.n ?? 0).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("stores and updates source offsets", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
yield* ledger.setOffset("thread:t1", "m1")
|
||||
expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m1"))
|
||||
yield* ledger.setOffset("thread:t1", "m9")
|
||||
expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m9"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
274
packages/discord/src/conversation/services/ledger.ts
Normal file
274
packages/discord/src/conversation/services/ledger.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { DatabaseError } from "../../errors"
|
||||
import { ChannelId, SessionId, ThreadId } from "../../types"
|
||||
import { Inbound } from "../model/schema"
|
||||
|
||||
const DEDUP_TTL_MINUTES = 5
|
||||
const PRUNE_BATCH_SIZE = 500
|
||||
|
||||
type Snapshot = {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: SessionId | null
|
||||
}
|
||||
|
||||
export class MessageState extends Schema.Class<MessageState>("MessageState")({
|
||||
thread_id: Schema.NullOr(ThreadId),
|
||||
channel_id: Schema.NullOr(ChannelId),
|
||||
response_text: Schema.NullOr(Schema.String),
|
||||
prompt_text: Schema.NullOr(Schema.String),
|
||||
session_id: Schema.NullOr(SessionId),
|
||||
}) {}
|
||||
|
||||
const InboundJson = Schema.parseJson(Inbound)
|
||||
const decode = Schema.decodeUnknown(InboundJson)
|
||||
const encode = Schema.encode(InboundJson)
|
||||
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const changes = (sql: Client.SqlClient) =>
|
||||
db(
|
||||
sql<{ n: number }>`SELECT changes() AS n`.pipe(
|
||||
Effect.map((rows) => rows[0]?.n ?? 0),
|
||||
),
|
||||
)
|
||||
|
||||
const toState = (row: Snapshot) =>
|
||||
MessageState.make({
|
||||
thread_id: row.thread_id,
|
||||
channel_id: row.channel_id,
|
||||
response_text: row.response_text,
|
||||
prompt_text: row.prompt_text,
|
||||
session_id: row.session_id,
|
||||
})
|
||||
|
||||
export declare namespace ConversationLedger {
|
||||
export interface Service {
|
||||
readonly admit: (event: Inbound) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly replayPending: () => Effect.Effect<ReadonlyArray<Inbound>, DatabaseError>
|
||||
readonly start: (message_id: string) => Effect.Effect<Option.Option<MessageState>, DatabaseError>
|
||||
readonly setTarget: (message_id: string, thread_id: ThreadId, channel_id: ChannelId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setPrompt: (message_id: string, prompt: string, session_id: SessionId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setResponse: (message_id: string, response: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly complete: (message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly retry: (message_id: string, error: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly prune: () => Effect.Effect<void, DatabaseError>
|
||||
readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
|
||||
readonly setOffset: (source_id: string, message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")<
|
||||
ConversationLedger,
|
||||
ConversationLedger.Service
|
||||
>() {
|
||||
static readonly noop = Layer.effect(
|
||||
ConversationLedger,
|
||||
Effect.sync(() => {
|
||||
const pending = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
return ConversationLedger.of({
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (pending.has(event.message_id) || completed.has(event.message_id)) return false
|
||||
pending.add(event.message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (!pending.has(message_id)) return Option.none()
|
||||
pending.delete(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) => Effect.sync(() => { completed.add(message_id) }),
|
||||
retry: (message_id) => Effect.sync(() => { pending.add(message_id) }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly layer = Layer.scoped(
|
||||
ConversationLedger,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
const config = yield* AppConfig
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
const admit = Effect.fn("ConversationLedger.admit")(function* (event: Inbound) {
|
||||
const payload = yield* encode(event).pipe(
|
||||
Effect.mapError((cause) => new DatabaseError({ cause })),
|
||||
)
|
||||
yield* db(
|
||||
sql`INSERT OR IGNORE INTO conversation_inbox (message_id, kind, payload_json, status, created_at, updated_at)
|
||||
VALUES (${event.message_id}, ${event.kind}, ${payload}, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
)
|
||||
return (yield* changes(sql)) > 0
|
||||
})
|
||||
|
||||
const replayPending = Effect.fn("ConversationLedger.replayPending")(function* () {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'processing'`,
|
||||
)
|
||||
const rows = yield* db(
|
||||
sql<{ payload_json: string }>`SELECT payload_json
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode(row.payload_json).pipe(Effect.mapError((cause) => new DatabaseError({ cause }))),
|
||||
)
|
||||
})
|
||||
|
||||
const start = Effect.fn("ConversationLedger.start")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'processing', attempts = attempts + 1,
|
||||
processing_started_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id} AND status = 'pending'`,
|
||||
)
|
||||
if ((yield* changes(sql)) === 0) return Option.none<MessageState>()
|
||||
const rows = yield* db(
|
||||
sql<Snapshot>`SELECT thread_id, channel_id, response_text, prompt_text, session_id
|
||||
FROM conversation_inbox
|
||||
WHERE message_id = ${message_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<MessageState>()
|
||||
return Option.some(toState(row))
|
||||
})
|
||||
|
||||
const setTarget = Effect.fn("ConversationLedger.setTarget")(function* (
|
||||
message_id: string,
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET thread_id = ${thread_id}, channel_id = ${channel_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setPrompt = Effect.fn("ConversationLedger.setPrompt")(function* (
|
||||
message_id: string,
|
||||
prompt: string,
|
||||
session_id: SessionId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET prompt_text = ${prompt}, session_id = ${session_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setResponse = Effect.fn("ConversationLedger.setResponse")(function* (message_id: string, response: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET response_text = ${response}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const complete = Effect.fn("ConversationLedger.complete")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
|
||||
processing_started_at = NULL, last_error = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const retry = Effect.fn("ConversationLedger.retry")(function* (message_id: string, error: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', last_error = ${error}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const prune = Effect.fn("ConversationLedger.prune")(function* () {
|
||||
yield* db(
|
||||
sql`DELETE FROM conversation_inbox
|
||||
WHERE message_id IN (
|
||||
SELECT message_id
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'completed'
|
||||
AND completed_at IS NOT NULL
|
||||
AND completed_at < datetime('now', '-' || ${DEDUP_TTL_MINUTES} || ' minutes')
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ${PRUNE_BATCH_SIZE}
|
||||
)`,
|
||||
)
|
||||
})
|
||||
|
||||
const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) {
|
||||
const rows = yield* db(
|
||||
sql<{ last_message_id: string }>`SELECT last_message_id
|
||||
FROM conversation_offsets
|
||||
WHERE source_id = ${source_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<string>()
|
||||
return Option.some(row.last_message_id)
|
||||
})
|
||||
|
||||
const setOffset = Effect.fn("ConversationLedger.setOffset")(function* (source_id: string, message_id: string) {
|
||||
yield* db(
|
||||
sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
|
||||
VALUES (${source_id}, ${message_id}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(source_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
})
|
||||
|
||||
yield* prune().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation ledger prune failed").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.prune.failed", error: String(error) }),
|
||||
)),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ConversationLedger.of({
|
||||
admit,
|
||||
replayPending,
|
||||
start,
|
||||
setTarget,
|
||||
setPrompt,
|
||||
setResponse,
|
||||
complete,
|
||||
retry,
|
||||
prune,
|
||||
getOffset,
|
||||
setOffset,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
21
packages/discord/src/conversation/services/outbox.ts
Normal file
21
packages/discord/src/conversation/services/outbox.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { DeliveryError } from "../model/errors"
|
||||
import type { Action } from "../model/schema"
|
||||
|
||||
export declare namespace Outbox {
|
||||
export interface Service {
|
||||
readonly publish: (action: Action) => Effect.Effect<void, DeliveryError>
|
||||
readonly withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
|
||||
}
|
||||
}
|
||||
|
||||
export class Outbox extends Context.Tag("@discord/conversation/Outbox")<Outbox, Outbox.Service>() {
|
||||
static readonly noop = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: () => Effect.void,
|
||||
withTyping: (_thread_id, self) => self,
|
||||
}),
|
||||
)
|
||||
}
|
||||
29
packages/discord/src/conversation/services/threads.ts
Normal file
29
packages/discord/src/conversation/services/threads.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { ThreadEnsureError } from "../model/errors"
|
||||
import type { Inbound, ThreadRef } from "../model/schema"
|
||||
|
||||
export declare namespace Threads {
|
||||
export interface Service {
|
||||
readonly ensure: (event: Inbound, name: string) => Effect.Effect<ThreadRef, ThreadEnsureError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Threads extends Context.Tag("@discord/conversation/Threads")<Threads, Threads.Service>() {
|
||||
static readonly empty = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed({ thread_id: event.thread_id, channel_id: event.channel_id })
|
||||
}
|
||||
return Effect.fail(
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: "threads adapter missing for channel message",
|
||||
retriable: false,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,22 @@
|
|||
import { Database } from "bun:sqlite"
|
||||
import { getEnv } from "../config"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AppConfig } 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
|
||||
export class SqliteDb extends Context.Tag("@discord/SqliteDb")<SqliteDb, Client.SqlClient>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SqliteDb,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db`PRAGMA busy_timeout = 5000`
|
||||
return db
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrapEffect(
|
||||
Effect.map(AppConfig, (config) => SqliteClient.layer({ filename: config.databasePath })),
|
||||
),
|
||||
),
|
||||
Layer.orDie,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
129
packages/discord/src/db/init.test.ts
Normal file
129
packages/discord/src/db/init.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { initializeSchema } from "./init"
|
||||
import { effectTest, withSqlite, withTempSqliteFile } from "../test/effect"
|
||||
|
||||
const columns = [
|
||||
"thread_id",
|
||||
"channel_id",
|
||||
"guild_id",
|
||||
"sandbox_id",
|
||||
"session_id",
|
||||
"preview_url",
|
||||
"preview_token",
|
||||
"status",
|
||||
"last_activity",
|
||||
"pause_requested_at",
|
||||
"paused_at",
|
||||
"resume_attempted_at",
|
||||
"resumed_at",
|
||||
"destroyed_at",
|
||||
"last_health_ok_at",
|
||||
"last_error",
|
||||
"resume_fail_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const indexes = [
|
||||
"discord_sessions_status_last_activity_idx",
|
||||
"discord_sessions_status_updated_at_idx",
|
||||
]
|
||||
|
||||
const inboxColumns = [
|
||||
"message_id",
|
||||
"kind",
|
||||
"payload_json",
|
||||
"status",
|
||||
"thread_id",
|
||||
"channel_id",
|
||||
"prompt_text",
|
||||
"session_id",
|
||||
"response_text",
|
||||
"attempts",
|
||||
"processing_started_at",
|
||||
"completed_at",
|
||||
"last_error",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const inboxIndexes = [
|
||||
"conversation_inbox_status_created_at_idx",
|
||||
"conversation_inbox_completed_at_idx",
|
||||
]
|
||||
|
||||
const offsetColumns = [
|
||||
"source_id",
|
||||
"last_message_id",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const offsetIndexes = [
|
||||
"conversation_offsets_updated_at_idx",
|
||||
]
|
||||
|
||||
const getColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(discord_sessions)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(discord_sessions)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getOffsetColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_offsets)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getOffsetIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(conversation_offsets)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
describe("initializeSchema", () => {
|
||||
effectTest("creates schema and is idempotent", () =>
|
||||
withTempSqliteFile((filename) =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const one = yield* withSqlite(filename, getColumns)
|
||||
const inboxOne = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetOne = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(one).toEqual(columns)
|
||||
expect(inboxOne).toEqual(inboxColumns)
|
||||
expect(offsetOne).toEqual(offsetColumns)
|
||||
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const two = yield* withSqlite(filename, getColumns)
|
||||
const inboxTwo = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetTwo = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(two).toEqual(one)
|
||||
expect(inboxTwo).toEqual(inboxOne)
|
||||
expect(offsetTwo).toEqual(offsetOne)
|
||||
|
||||
const seen = new Set(two)
|
||||
expect(seen.size).toBe(two.length)
|
||||
const actual = (yield* withSqlite(filename, getIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const inboxActual = (yield* withSqlite(filename, getInboxIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const offsetActual = (yield* withSqlite(filename, getOffsetIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
expect(new Set(actual)).toEqual(new Set(indexes))
|
||||
expect(new Set(inboxActual)).toEqual(new Set(inboxIndexes))
|
||||
expect(new Set(offsetActual)).toEqual(new Set(offsetIndexes))
|
||||
}),
|
||||
"discord-sessions-",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,76 +1,23 @@
|
|||
import type { Database } from "bun:sqlite"
|
||||
import { getDb } from "./client"
|
||||
import { logger } from "../observability/logger"
|
||||
import { Reactivity } from "@effect/experimental"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import * as Migrator from "@effect/sql/Migrator"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
import migration0001 from "./migrations/0001_discord_sessions"
|
||||
import migration0002 from "./migrations/0002_conversation_offsets"
|
||||
|
||||
const PREFIX = "[db]"
|
||||
const run = Migrator.make({})({
|
||||
loader: Migrator.fromRecord({
|
||||
"0001_discord_sessions": migration0001,
|
||||
"0002_conversation_offsets": migration0002,
|
||||
}),
|
||||
})
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const db = getDb()
|
||||
export const initializeSchema = run
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
export const initializeSchemaForFile = (filename: string) =>
|
||||
SqliteClient.make({ filename }).pipe(
|
||||
Effect.provide(Reactivity.layer),
|
||||
Effect.flatMap((db) => run.pipe(Effect.provideService(Client.SqlClient, db))),
|
||||
Effect.scoped
|
||||
)
|
||||
|
|
|
|||
131
packages/discord/src/db/migrations/0001_discord_sessions.ts
Normal file
131
packages/discord/src/db/migrations/0001_discord_sessions.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const TABLE = `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
|
||||
)`
|
||||
|
||||
const INBOX_TABLE = `CREATE TABLE IF NOT EXISTS conversation_inbox (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('thread_message', 'channel_message')),
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'completed')),
|
||||
thread_id TEXT,
|
||||
channel_id TEXT,
|
||||
prompt_text TEXT,
|
||||
session_id TEXT,
|
||||
response_text TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
processing_started_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const OFFSETS_TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const COLUMNS = [
|
||||
["preview_token", "TEXT"],
|
||||
["last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["updated_at", "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"],
|
||||
] as const
|
||||
|
||||
const INBOX_COLUMNS = [
|
||||
["status", "TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed'))"],
|
||||
["thread_id", "TEXT"],
|
||||
["channel_id", "TEXT"],
|
||||
["prompt_text", "TEXT"],
|
||||
["session_id", "TEXT"],
|
||||
["response_text", "TEXT"],
|
||||
["attempts", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["processing_started_at", "TEXT"],
|
||||
["completed_at", "TEXT"],
|
||||
["last_error", "TEXT"],
|
||||
["created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const OFFSET_COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
|
||||
ON discord_sessions (status, last_activity)`,
|
||||
`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
|
||||
ON discord_sessions (status, updated_at)`,
|
||||
] as const
|
||||
|
||||
const INBOX_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_status_created_at_idx
|
||||
ON conversation_inbox (status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_completed_at_idx
|
||||
ON conversation_inbox (completed_at)`,
|
||||
] as const
|
||||
|
||||
const OFFSET_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
] as const
|
||||
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
yield* db.unsafe(INBOX_TABLE)
|
||||
yield* db.unsafe(OFFSETS_TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(discord_sessions)`).map((row) => row.name))
|
||||
const missing = COLUMNS.filter(([name]) => !names.has(name))
|
||||
yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const inboxNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_inbox)`).map((row) => row.name))
|
||||
const inboxMissing = INBOX_COLUMNS.filter(([name]) => !inboxNames.has(name))
|
||||
yield* Effect.forEach(inboxMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_inbox ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const offsetNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const offsetMissing = OFFSET_COLUMNS.filter(([name]) => !offsetNames.has(name))
|
||||
yield* Effect.forEach(offsetMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(INBOX_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(OFFSET_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
] as const
|
||||
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const missing = COLUMNS.filter(([name]) => !names.has(name))
|
||||
yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
|
@ -1,14 +1,33 @@
|
|||
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
||||
import { Client, GatewayIntentBits, Partials } from "discord.js"
|
||||
import { Context, Effect, Layer, Redacted } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export function createDiscordClient(): Client {
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
export class DiscordClient extends Context.Tag("@discord/DiscordClient")<DiscordClient, Client>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
DiscordClient,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
})
|
||||
|
||||
return client;
|
||||
yield* Effect.tryPromise(() => client.login(Redacted.value(config.discordToken)))
|
||||
yield* Effect.logInfo("Discord client logged in").pipe(
|
||||
Effect.annotateLogs({ event: "discord.login", tag: client.user?.tag ?? "unknown" }),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
client.destroy()
|
||||
}),
|
||||
)
|
||||
|
||||
return client
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
2
packages/discord/src/discord/constants.ts
Normal file
2
packages/discord/src/discord/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const TYPING_INTERVAL = "8 seconds" as const
|
||||
export const TYPING_INTERVAL_MS = 8_000
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
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(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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 ? "..." : "");
|
||||
}
|
||||
|
|
@ -1,127 +1,134 @@
|
|||
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
|
||||
import { LanguageModel } from "@effect/ai"
|
||||
import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export type TurnRoutingMode = "off" | "heuristic" | "ai";
|
||||
export class TurnRoutingDecision extends Schema.Class<TurnRoutingDecision>("TurnRoutingDecision")({
|
||||
shouldRespond: Schema.Boolean,
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
type TurnRoutingInput = {
|
||||
mode: TurnRoutingMode;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
content: string;
|
||||
botUserId: string;
|
||||
botRoleId: string;
|
||||
mentionedUserIds: string[];
|
||||
mentionedRoleIds: string[];
|
||||
};
|
||||
export class TurnRoutingInput extends Schema.Class<TurnRoutingInput>("TurnRoutingInput")({
|
||||
content: Schema.String,
|
||||
botUserId: Schema.String,
|
||||
botRoleId: Schema.String,
|
||||
mentionedUserIds: Schema.Array(Schema.String),
|
||||
mentionedRoleIds: Schema.Array(Schema.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
|
||||
|
||||
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;
|
||||
const heuristicDecision = (input: TurnRoutingInput): TurnRoutingDecision | null => {
|
||||
const text = input.content.trim()
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
function heuristicDecision(input: TurnRoutingInput): TurnRoutingDecision | null {
|
||||
const text = input.content.trim();
|
||||
const lower = text.toLowerCase();
|
||||
if (!text) return TurnRoutingDecision.make({ shouldRespond: false, reason: "empty-message" })
|
||||
|
||||
if (!text) {
|
||||
return { shouldRespond: false, reason: "empty-message" };
|
||||
}
|
||||
if (input.mentionedUserIds.some((id) => id !== input.botUserId))
|
||||
return TurnRoutingDecision.make({ shouldRespond: false, reason: "mentions-other-user" })
|
||||
|
||||
const mentionsOtherUser = input.mentionedUserIds.some((id) => id !== input.botUserId);
|
||||
if (mentionsOtherUser) {
|
||||
return { shouldRespond: false, reason: "mentions-other-user" };
|
||||
}
|
||||
if (input.mentionedRoleIds.some((id) => id !== input.botRoleId))
|
||||
return TurnRoutingDecision.make({ shouldRespond: false, reason: "mentions-other-role" })
|
||||
|
||||
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 TurnRoutingDecision.make({ shouldRespond: false, reason: "quick-chat" })
|
||||
|
||||
if (text.length <= 40 && QUICK_CHAT_RE.test(text)) {
|
||||
return { shouldRespond: false, reason: "quick-chat" };
|
||||
}
|
||||
if (/\b(opencode|bot)\b/i.test(text))
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "bot-keyword" })
|
||||
|
||||
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 TurnRoutingDecision.make({ shouldRespond: true, reason: "direct-question" })
|
||||
|
||||
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 TurnRoutingDecision.make({ shouldRespond: true, reason: "general-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 TurnRoutingDecision.make({ shouldRespond: true, reason: "instruction" })
|
||||
|
||||
if (lower.startsWith("do this") || lower.startsWith("run ") || lower.startsWith("fix ")) {
|
||||
return { shouldRespond: true, reason: "instruction" };
|
||||
}
|
||||
|
||||
return null;
|
||||
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 fallbackThreadName = (message: string): string =>
|
||||
message.slice(0, 95) + (message.length > 95 ? "..." : "")
|
||||
|
||||
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 }],
|
||||
export declare namespace TurnRouter {
|
||||
export interface Service {
|
||||
readonly shouldRespond: (input: TurnRoutingInput) => Effect.Effect<TurnRoutingDecision>
|
||||
readonly generateThreadName: (userMessage: string) => Effect.Effect<string>
|
||||
}
|
||||
}
|
||||
|
||||
export class TurnRouter extends Context.Tag("@discord/TurnRouter")<TurnRouter, TurnRouter.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
TurnRouter,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const model = yield* LanguageModel.LanguageModel
|
||||
|
||||
const aiDecision = (input: TurnRoutingInput): Effect.Effect<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")
|
||||
|
||||
return AnthropicLanguageModel.withConfigOverride(
|
||||
model.generateText({ prompt }).pipe(
|
||||
Effect.map((response) => {
|
||||
const output = response.text.trim().toUpperCase()
|
||||
if (output.includes("SKIP")) return TurnRoutingDecision.make({ shouldRespond: false, reason: "ai-skip" })
|
||||
return TurnRoutingDecision.make({
|
||||
shouldRespond: true,
|
||||
reason: output.includes("RESPOND") ? "ai-respond" : "ai-default-respond",
|
||||
})
|
||||
}),
|
||||
Effect.catchAll(() =>
|
||||
Effect.succeed(TurnRoutingDecision.make({ shouldRespond: true, reason: "ai-error-default-respond" })),
|
||||
),
|
||||
),
|
||||
{ model: config.turnRoutingModel, max_tokens: 10 },
|
||||
)
|
||||
}
|
||||
|
||||
const shouldRespond = Effect.fn("TurnRouter.shouldRespond")(function* (input: TurnRoutingInput) {
|
||||
if (config.turnRoutingMode === "off") {
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "routing-off" })
|
||||
}
|
||||
|
||||
const heuristic = heuristicDecision(input)
|
||||
if (heuristic) return heuristic
|
||||
|
||||
if (config.turnRoutingMode === "heuristic") {
|
||||
return TurnRoutingDecision.make({
|
||||
shouldRespond: true,
|
||||
reason: "heuristic-uncertain-default-respond",
|
||||
})
|
||||
}
|
||||
|
||||
return yield* aiDecision(input)
|
||||
})
|
||||
|
||||
const generateThreadName = Effect.fn("TurnRouter.generateThreadName")(function* (userMessage: string) {
|
||||
return yield* AnthropicLanguageModel.withConfigOverride(
|
||||
model.generateText({
|
||||
prompt: `Generate a short, descriptive thread title (max 90 chars) for this Discord question. Return ONLY the title, no quotes, no explanation.\n\nQuestion: ${userMessage}`,
|
||||
}).pipe(
|
||||
Effect.map((response) => {
|
||||
const title = response.text.trim()
|
||||
if (!title || title.length === 0) return fallbackThreadName(userMessage)
|
||||
return title.slice(0, 95) + (title.length > 95 ? "..." : "")
|
||||
}),
|
||||
Effect.catchAll(() => Effect.succeed(fallbackThreadName(userMessage))),
|
||||
),
|
||||
{ model: "claude-haiku-4-5", max_tokens: 60 },
|
||||
)
|
||||
})
|
||||
|
||||
return TurnRouter.of({ shouldRespond, generateThreadName })
|
||||
}),
|
||||
});
|
||||
|
||||
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" };
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
100
packages/discord/src/errors.ts
Normal file
100
packages/discord/src/errors.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Schema } from "effect"
|
||||
import { SandboxId, SessionId, ThreadId } from "./types"
|
||||
|
||||
// -- Sandbox errors (Daytona SDK) --
|
||||
|
||||
export class SandboxCreateError extends Schema.TaggedError<SandboxCreateError>()(
|
||||
"SandboxCreateError",
|
||||
{
|
||||
sandboxId: Schema.optional(SandboxId),
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxNotFoundError extends Schema.TaggedError<SandboxNotFoundError>()(
|
||||
"SandboxNotFoundError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxExecError extends Schema.TaggedError<SandboxExecError>()(
|
||||
"SandboxExecError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
label: Schema.String,
|
||||
exitCode: Schema.Number,
|
||||
output: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxStartError extends Schema.TaggedError<SandboxStartError>()(
|
||||
"SandboxStartError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- OpenCode client errors --
|
||||
|
||||
export class HealthCheckError extends Schema.TaggedError<HealthCheckError>()(
|
||||
"HealthCheckError",
|
||||
{
|
||||
lastStatus: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class OpenCodeClientError extends Schema.TaggedError<OpenCodeClientError>()(
|
||||
"OpenCodeClientError",
|
||||
{
|
||||
operation: Schema.String,
|
||||
statusCode: Schema.Number,
|
||||
body: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SessionMissingError extends Schema.TaggedError<SessionMissingError>()(
|
||||
"SessionMissingError",
|
||||
{
|
||||
sessionId: SessionId,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Session lifecycle errors --
|
||||
|
||||
export class SandboxDeadError extends Schema.TaggedError<SandboxDeadError>()(
|
||||
"SandboxDeadError",
|
||||
{
|
||||
threadId: ThreadId,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResumeFailedError extends Schema.TaggedError<ResumeFailedError>()(
|
||||
"ResumeFailedError",
|
||||
{
|
||||
threadId: ThreadId,
|
||||
sandboxId: SandboxId,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Config errors --
|
||||
|
||||
export class ConfigEncodeError extends Schema.TaggedError<ConfigEncodeError>()(
|
||||
"ConfigEncodeError",
|
||||
{
|
||||
config: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Database errors --
|
||||
|
||||
export class DatabaseError extends Schema.TaggedError<DatabaseError>()(
|
||||
"DatabaseError",
|
||||
{
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
|
@ -1,43 +1,71 @@
|
|||
import type { Client } from "discord.js";
|
||||
import { HttpLayerRouter, HttpServerResponse } from "@effect/platform"
|
||||
import { BunHttpServer } from "@effect/platform-bun"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { DiscordClient } from "../discord/client"
|
||||
import { ThreadAgentPool } from "../sandbox/pool"
|
||||
|
||||
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 });
|
||||
},
|
||||
});
|
||||
export declare namespace HealthServer {
|
||||
export interface Service {
|
||||
readonly started: true
|
||||
}
|
||||
}
|
||||
|
||||
export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthServer, HealthServer.Service>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
HealthServer,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const client = yield* DiscordClient
|
||||
const pool = yield* ThreadAgentPool
|
||||
const startedAt = Date.now()
|
||||
|
||||
const routes = HttpLayerRouter.use((router) =>
|
||||
Effect.all([
|
||||
router.add(
|
||||
"GET",
|
||||
"/healthz",
|
||||
Effect.gen(function* () {
|
||||
const activeSessions = yield* pool.getActiveSessionCount().pipe(
|
||||
Effect.catchAll(() => Effect.succeed(0)),
|
||||
)
|
||||
return HttpServerResponse.unsafeJson({
|
||||
ok: true,
|
||||
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
|
||||
discordReady: client.isReady(),
|
||||
activeSessions,
|
||||
})
|
||||
}),
|
||||
),
|
||||
router.add(
|
||||
"GET",
|
||||
"/readyz",
|
||||
Effect.sync(() => {
|
||||
const ready = client.isReady()
|
||||
return HttpServerResponse.unsafeJson({ ok: ready, discordReady: ready }, { status: ready ? 200 : 503 })
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const server = HttpLayerRouter.serve(routes, { disableLogger: true, disableListenLog: true }).pipe(
|
||||
Layer.provide(
|
||||
BunHttpServer.layer({
|
||||
hostname: config.healthHost,
|
||||
port: config.healthPort,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Layer.launch(server).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.logInfo("Health server started").pipe(
|
||||
Effect.annotateLogs({ event: "health.server.started", host: config.healthHost, port: config.healthPort }),
|
||||
)
|
||||
|
||||
return {
|
||||
started: true as const,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,80 @@
|
|||
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";
|
||||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { DiscordConversationServicesLive } from "./conversation/implementations/discord"
|
||||
import { Conversation } from "./conversation/services/conversation"
|
||||
import { ConversationLedger } from "./conversation/services/ledger"
|
||||
import { AppConfig } from "./config"
|
||||
import { SqliteDb } from "./db/client"
|
||||
import { DiscordClient } from "./discord/client"
|
||||
import { TurnRouter } from "./discord/turn-routing"
|
||||
import { HealthServer } from "./http/health"
|
||||
import { LoggerLive } from "./observability/logger"
|
||||
import { DaytonaService } from "./sandbox/daytona"
|
||||
import { OpenCodeClient } from "./sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "./sandbox/pool"
|
||||
import { SandboxProvisioner } from "./sandbox/provisioner"
|
||||
import { SessionStore } from "./sessions/store"
|
||||
|
||||
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 AnthropicLayer = Layer.unwrapEffect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
|
||||
Layer.provide(AnthropicClient.layer({
|
||||
apiKey: config.openCodeZenApiKey,
|
||||
apiUrl: "https://opencode.ai/zen",
|
||||
})),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const client = createDiscordClient();
|
||||
const sandboxManager = new SandboxManager();
|
||||
const healthServer = startHealthServer(env.HEALTH_HOST, env.HEALTH_PORT, {
|
||||
client,
|
||||
isCleanupLoopRunning: () => sandboxManager.isCleanupLoopRunning(),
|
||||
getActiveSessionCount: () => sandboxManager.getActiveSessionCount(),
|
||||
});
|
||||
type AppServices =
|
||||
| AppConfig
|
||||
| DiscordClient
|
||||
| HealthServer
|
||||
| OpenCodeClient
|
||||
| SessionStore
|
||||
| DaytonaService
|
||||
| TurnRouter
|
||||
| SandboxProvisioner
|
||||
| ThreadAgentPool
|
||||
| ConversationLedger
|
||||
| Conversation
|
||||
|
||||
logger.info({
|
||||
event: "health.server.started",
|
||||
component: "index",
|
||||
message: "Health server started",
|
||||
host: env.HEALTH_HOST,
|
||||
port: env.HEALTH_PORT,
|
||||
});
|
||||
const BaseLayer = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer, LoggerLive)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const WithSandbox = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
const WithLedger = Layer.provideMerge(ConversationLedger.layer, WithSandbox)
|
||||
const WithDiscord = Layer.provideMerge(DiscordClient.layer, WithLedger)
|
||||
const WithDiscordConversation = Layer.provideMerge(DiscordConversationServicesLive, WithDiscord)
|
||||
const WithConversation = Layer.provideMerge(Conversation.layer, WithDiscordConversation)
|
||||
const AppLayer = Layer.provideMerge(HealthServer.layer, WithConversation) as Layer.Layer<AppServices | SqliteDb, never, never>
|
||||
|
||||
// Register message handler
|
||||
const messageHandler = createMessageHandler(client, sandboxManager);
|
||||
client.on("messageCreate", messageHandler);
|
||||
const main = Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const conversation = yield* Conversation
|
||||
yield* ThreadAgentPool
|
||||
yield* HealthServer
|
||||
|
||||
// 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();
|
||||
});
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
yield* Effect.logInfo("Discord bot ready").pipe(
|
||||
Effect.annotateLogs({ event: "discord.ready", tag: client.user?.tag }),
|
||||
)
|
||||
|
||||
// Login
|
||||
await client.login(env.DISCORD_TOKEN);
|
||||
yield* Effect.logInfo("Discord bot started")
|
||||
return yield* Effect.never
|
||||
})
|
||||
|
||||
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);
|
||||
});
|
||||
main.pipe(
|
||||
Effect.provide(AppLayer),
|
||||
Effect.scoped,
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
|
|
|
|||
338
packages/discord/src/lib/actors/keyed.test.ts
Normal file
338
packages/discord/src/lib/actors/keyed.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Either, Exit, Option, Ref, Schema } from "effect"
|
||||
import { effectTest } from "../../test/effect"
|
||||
import { ActorMap } from "./keyed"
|
||||
|
||||
class LoadTestError extends Schema.TaggedError<LoadTestError>()("LoadTestError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
describe("ActorMap", () => {
|
||||
effectTest("serializes work for the same key", () =>
|
||||
Effect.gen(function* () {
|
||||
const log: Array<string> = []
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const one = keyed.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
log.push("one:start")
|
||||
yield* Effect.sleep("40 millis")
|
||||
log.push("one:end")
|
||||
return "one"
|
||||
}),
|
||||
)
|
||||
const two = keyed.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
log.push("two:start")
|
||||
log.push("two:end")
|
||||
return "two"
|
||||
}),
|
||||
)
|
||||
|
||||
const out = yield* Effect.all([one, two], { concurrency: "unbounded" })
|
||||
expect(out).toEqual(["one", "two"])
|
||||
expect(log).toEqual(["one:start", "one:end", "two:start", "two:end"])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("allows different keys to run concurrently", () =>
|
||||
Effect.gen(function* () {
|
||||
const log: Array<string> = []
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const slow = keyed.run(
|
||||
"a",
|
||||
Effect.gen(function* () {
|
||||
log.push("a:start")
|
||||
yield* Effect.sleep("50 millis")
|
||||
log.push("a:end")
|
||||
}),
|
||||
)
|
||||
const fast = keyed.run(
|
||||
"b",
|
||||
Effect.gen(function* () {
|
||||
log.push("b:start")
|
||||
log.push("b:end")
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.all([slow, fast], { concurrency: "unbounded" })
|
||||
expect(log.indexOf("b:end")).toBeLessThan(log.indexOf("a:end"))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("triggers idle callback once after inactivity", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "30 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* Effect.sleep("80 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("touch extends idle deadline", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "40 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* Effect.sleep("25 millis")
|
||||
yield* keyed.touch("t1")
|
||||
yield* Effect.sleep("25 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("run can skip idle touch", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "25 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void, { touch: false })
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
yield* keyed.touch("t1")
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove clears entry and allows recreation", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
yield* keyed.remove("t1")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
yield* keyed.run("t1", Effect.succeed("ok"))
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("failure does not poison the key queue", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const first = yield* keyed.run("t1", Effect.fail("boom")).pipe(Effect.either)
|
||||
expect(Either.isLeft(first)).toBe(true)
|
||||
if (Either.isLeft(first)) {
|
||||
expect(first.left).toBe("boom")
|
||||
}
|
||||
|
||||
const second = yield* keyed.run("t1", Effect.succeed("ok"))
|
||||
expect(second).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("cancelIdle cancels the pending idle timer", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "25 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* keyed.cancelIdle("t1")
|
||||
yield* Effect.sleep("60 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stop removes all keys and cancels all idle timers", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "30 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
keyed.run("t1", Effect.void),
|
||||
keyed.run("t2", Effect.void),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(yield* keyed.size).toBe(2)
|
||||
|
||||
yield* keyed.stop
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
yield* Effect.sleep("70 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("touch and cancelIdle on unknown key are no-ops", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "20 millis",
|
||||
onIdle: () => Effect.void,
|
||||
})
|
||||
|
||||
yield* keyed.touch("missing")
|
||||
yield* keyed.cancelIdle("missing")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove on unknown key is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
yield* keyed.remove("missing")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove interrupts in-flight run calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const fiber = yield* keyed
|
||||
.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sleep("5 seconds")
|
||||
return "should not reach"
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.fork)
|
||||
|
||||
// Give the job time to start executing on the worker fiber
|
||||
yield* Effect.sleep("20 millis")
|
||||
yield* keyed.remove("t1")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
|
||||
const exit = yield* fiber.await
|
||||
expect(Exit.isInterrupted(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ActorMap (stateful)", () => {
|
||||
effectTest("load hydrates state on first activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: (key) => Effect.succeed(key === "a" ? Option.some(42) : Option.none()),
|
||||
})
|
||||
|
||||
const result = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : -1)),
|
||||
)
|
||||
expect(result).toBe(42)
|
||||
|
||||
const result2 = yield* keyed.run("b", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : -1)),
|
||||
)
|
||||
expect(result2).toBe(-1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("save is called when state changes during run", () =>
|
||||
Effect.gen(function* () {
|
||||
const saved: Array<[string, number]> = []
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
save: (key, value) =>
|
||||
Effect.sync(() => {
|
||||
saved.push([key, value])
|
||||
}),
|
||||
})
|
||||
|
||||
yield* keyed.run("a", (state) => Ref.set(state, Option.some(10)))
|
||||
expect(saved).toEqual([["a", 10]])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("save is not called when state is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const saved: Array<[string, number]> = []
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.succeed(Option.some(5)),
|
||||
save: (key, value) =>
|
||||
Effect.sync(() => {
|
||||
saved.push([key, value])
|
||||
}),
|
||||
})
|
||||
|
||||
// Run without touching state
|
||||
yield* keyed.run("a", (_state) => Effect.succeed("noop"))
|
||||
expect(saved).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("getState returns current state for existing key", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.succeed(Option.some(99)),
|
||||
})
|
||||
|
||||
yield* keyed.run("a", Effect.void)
|
||||
const result = yield* keyed.getState("a")
|
||||
expect(result).toEqual(Option.some(99))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("getState returns None for unknown key", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>()
|
||||
const result = yield* keyed.getState("missing")
|
||||
expect(result).toEqual(Option.none())
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stateful run with function receives state ref", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, string>()
|
||||
|
||||
yield* keyed.run("a", (state) => Ref.set(state, Option.some("hello")))
|
||||
const result = yield* keyed.getState("a")
|
||||
expect(result).toEqual(Option.some("hello"))
|
||||
|
||||
const read = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : "")),
|
||||
)
|
||||
expect(read).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stateless run still works with stateful actor map", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>()
|
||||
|
||||
const result = yield* keyed.run("a", Effect.succeed(42))
|
||||
expect(result).toBe(42)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("load error falls back to None", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.fail(LoadTestError.make({ message: "db down" })),
|
||||
})
|
||||
|
||||
const result = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map(Option.isNone)),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
217
packages/discord/src/lib/actors/keyed.ts
Normal file
217
packages/discord/src/lib/actors/keyed.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { Deferred, Effect, FiberMap, Option, Queue, Ref, SynchronizedRef, type Duration } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
|
||||
type Job = {
|
||||
run: Effect.Effect<void, never>
|
||||
cancel: Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyed actor map — a concurrent map of serial work queues.
|
||||
*
|
||||
* Each key gets its own fiber-backed queue. Effects submitted to the same key
|
||||
* are executed sequentially (preserving order), while different keys run
|
||||
* concurrently. Optionally supports idle timeouts per key and per-key state
|
||||
* with persistence hooks.
|
||||
*/
|
||||
export declare namespace ActorMap {
|
||||
/**
|
||||
* Configuration for idle-timeout behavior and optional per-key state.
|
||||
*
|
||||
* When both `idleTimeout` and `onIdle` are provided, each key starts a timer
|
||||
* after activity. If no further activity (or explicit `touch`) occurs before
|
||||
* the timer expires, `onIdle` is called with that key.
|
||||
*
|
||||
* When `load` and/or `save` are provided, the actor map manages per-key
|
||||
* state of type `S`. `load` is called when an actor is first created to
|
||||
* hydrate state from storage. `save` is called after `run` completes when
|
||||
* the state has been modified.
|
||||
*/
|
||||
export interface Options<K, S = void> {
|
||||
/** How long a key must be idle before `onIdle` fires. */
|
||||
idleTimeout?: Duration.DurationInput
|
||||
/** Callback invoked when a key's idle timer expires. */
|
||||
onIdle?: (key: K) => Effect.Effect<void, unknown, never>
|
||||
/** Load persisted state when an actor is first activated. */
|
||||
load?: (key: K) => Effect.Effect<Option.Option<S>, unknown, never>
|
||||
/** Save state after it has been modified during `run`. */
|
||||
save?: (key: K, state: S) => Effect.Effect<void, unknown, never>
|
||||
}
|
||||
|
||||
export interface ActorMap<K, S = void> {
|
||||
/** Enqueue an effect onto a key's serial queue. Creates the actor if it
|
||||
* doesn't exist yet. By default resets the key's idle timer (`touch: true`). */
|
||||
run: {
|
||||
<A, E>(
|
||||
key: K,
|
||||
effect: Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
): Effect.Effect<A, E>
|
||||
<A, E>(
|
||||
key: K,
|
||||
f: (state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
): Effect.Effect<A, E>
|
||||
}
|
||||
/** Reset the idle timer for a key without enqueuing work. No-op if the key
|
||||
* doesn't exist or no idle timeout is configured. */
|
||||
touch: (key: K) => Effect.Effect<void>
|
||||
/** Cancel the pending idle timer for a key without removing the actor. */
|
||||
cancelIdle: (key: K) => Effect.Effect<void>
|
||||
/** Tear down an actor: cancel its idle timer, interrupt its worker fiber,
|
||||
* and shut down its queue. In-flight `run` calls are interrupted.
|
||||
* The key can be re-created by a subsequent `run`. */
|
||||
remove: (key: K) => Effect.Effect<void>
|
||||
/** Remove all actors and cancel all idle timers. */
|
||||
stop: Effect.Effect<void>
|
||||
/** The number of currently active actor keys. */
|
||||
size: Effect.Effect<number>
|
||||
/** Read the current state for a key without running an effect.
|
||||
* Returns None if the actor doesn't exist or has no state. */
|
||||
getState: (key: K) => Effect.Effect<Option.Option<S>>
|
||||
}
|
||||
}
|
||||
|
||||
interface Entry<S> {
|
||||
queue: Queue.Queue<Job>
|
||||
state: Ref.Ref<Option.Option<S>>
|
||||
}
|
||||
|
||||
export const ActorMap = {
|
||||
make: <K, S = void>(options?: ActorMap.Options<K, S>): Effect.Effect<ActorMap.ActorMap<K, S>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const workers = yield* FiberMap.make<K>()
|
||||
const timers = yield* FiberMap.make<K>()
|
||||
const state = yield* SynchronizedRef.make(new Map<K, Entry<S>>())
|
||||
|
||||
const has = (key: K) =>
|
||||
Effect.map(SynchronizedRef.get(state), (map) => map.has(key))
|
||||
|
||||
const ensure = (key: K): Effect.Effect<Entry<S>> =>
|
||||
SynchronizedRef.modifyEffect(state, (map) => {
|
||||
const current = map.get(key)
|
||||
if (current) {
|
||||
return Effect.succeed([current, map] as const)
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const q = yield* Queue.unbounded<Job>()
|
||||
// Load initial state from persistence hook if provided
|
||||
const initial: Option.Option<S> = options?.load
|
||||
? yield* options.load(key).pipe(Effect.catchAll(() => Effect.succeed(Option.none<S>())))
|
||||
: Option.none<S>()
|
||||
const stateRef = yield* Ref.make(initial)
|
||||
yield* FiberMap.run(
|
||||
workers,
|
||||
key,
|
||||
Effect.forever(
|
||||
q.take.pipe(Effect.flatMap((job) => job.run)),
|
||||
),
|
||||
).pipe(Effect.asVoid)
|
||||
const entry: Entry<S> = { queue: q, state: stateRef }
|
||||
const next = new Map(map)
|
||||
next.set(key, entry)
|
||||
return [entry, next] as const
|
||||
})
|
||||
})
|
||||
|
||||
const cancelIdle = (key: K) =>
|
||||
FiberMap.remove(timers, key)
|
||||
|
||||
const remove = (key: K) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* SynchronizedRef.modify(state, (map) => {
|
||||
const current = map.get(key)
|
||||
if (!current) return [null as Entry<S> | null, map] as const
|
||||
const next = new Map(map)
|
||||
next.delete(key)
|
||||
return [current, next] as const
|
||||
})
|
||||
if (!entry) {
|
||||
yield* cancelIdle(key)
|
||||
return
|
||||
}
|
||||
yield* cancelIdle(key)
|
||||
yield* FiberMap.remove(workers, key)
|
||||
yield* entry.queue.takeAll.pipe(
|
||||
Effect.flatMap(
|
||||
Effect.forEach((job) => job.cancel, { discard: true }),
|
||||
),
|
||||
)
|
||||
yield* entry.queue.shutdown
|
||||
})
|
||||
|
||||
const touch = (key: K) =>
|
||||
Effect.gen(function* () {
|
||||
if (!options?.idleTimeout || !options.onIdle) return
|
||||
if (!(yield* has(key))) return
|
||||
yield* FiberMap.run(
|
||||
timers,
|
||||
key,
|
||||
options.onIdle(key).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.delay(options.idleTimeout),
|
||||
),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const run = <A, E>(
|
||||
key: K,
|
||||
effectOrFn: Effect.Effect<A, E> | ((state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>),
|
||||
runOptions?: { touch?: boolean },
|
||||
): Effect.Effect<A, E> =>
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* ensure(key)
|
||||
const done = yield* Deferred.make<A, E>()
|
||||
|
||||
// Snapshot state before running so we can detect changes
|
||||
const stateBefore = yield* Ref.get(entry.state)
|
||||
|
||||
const effect: Effect.Effect<A, E> = Effect.isEffect(effectOrFn)
|
||||
? effectOrFn
|
||||
: (effectOrFn as (state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>)(entry.state)
|
||||
|
||||
yield* entry.queue.offer({
|
||||
run: Effect.uninterruptibleMask((restore) =>
|
||||
restore(effect).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.asVoid,
|
||||
)),
|
||||
cancel: Deferred.interrupt(done).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.asVoid)
|
||||
if (runOptions?.touch ?? true) {
|
||||
yield* touch(key)
|
||||
}
|
||||
const result = yield* Deferred.await(done)
|
||||
|
||||
// Persist state if it changed and a save hook is configured
|
||||
if (options?.save) {
|
||||
const stateAfter = yield* Ref.get(entry.state)
|
||||
if (stateBefore !== stateAfter && Option.isSome(stateAfter)) {
|
||||
yield* options.save(key, stateAfter.value).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const stop = Effect.gen(function* () {
|
||||
const keys = [...(yield* SynchronizedRef.get(state)).keys()]
|
||||
yield* Effect.forEach(keys, (key) => remove(key), { discard: true, concurrency: "unbounded" })
|
||||
})
|
||||
|
||||
const size = Effect.map(SynchronizedRef.get(state), (map) => map.size)
|
||||
|
||||
const getState = (key: K): Effect.Effect<Option.Option<S>> =>
|
||||
Effect.gen(function* () {
|
||||
const map = yield* SynchronizedRef.get(state)
|
||||
const entry = map.get(key)
|
||||
if (!entry) return Option.none<S>()
|
||||
return yield* Ref.get(entry.state)
|
||||
})
|
||||
|
||||
return { run, touch, cancelIdle, remove, stop, size, getState } satisfies ActorMap.ActorMap<K, S>
|
||||
}),
|
||||
}
|
||||
9
packages/discord/src/lib/log.ts
Normal file
9
packages/discord/src/lib/log.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Effect } from "effect"
|
||||
|
||||
/** Swallow errors with a warning log. Use for best-effort bookkeeping writes. */
|
||||
export const logIgnore = <A>(effect: Effect.Effect<A, unknown>, context: string) =>
|
||||
effect.pipe(
|
||||
Effect.catchAll((err) =>
|
||||
Effect.logWarning(`${context} failed (ignored)`).pipe(Effect.annotateLogs({ error: String(err) })),
|
||||
),
|
||||
)
|
||||
4
packages/discord/src/md.d.ts
vendored
Normal file
4
packages/discord/src/md.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*.md" {
|
||||
const text: string
|
||||
export default text
|
||||
}
|
||||
|
|
@ -1,76 +1,6 @@
|
|||
import { getEnv } from "../config";
|
||||
import { Layer, Logger, LogLevel } from "effect"
|
||||
|
||||
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 };
|
||||
export const LoggerLive = Layer.merge(
|
||||
Logger.replace(Logger.defaultLogger, Logger.jsonLogger),
|
||||
Logger.minimumLogLevel(LogLevel.Debug),
|
||||
)
|
||||
|
|
|
|||
170
packages/discord/src/sandbox/daytona.ts
Normal file
170
packages/discord/src/sandbox/daytona.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Daytona as DaytonaSDK, Image } from "@daytonaio/sdk"
|
||||
import { Context, Effect, Layer, Redacted, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../errors"
|
||||
import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../types"
|
||||
|
||||
export class SandboxHandle extends Schema.Class<SandboxHandle>("SandboxHandle")({
|
||||
id: SandboxId,
|
||||
previewUrl: Schema.String,
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ExecResult extends Schema.Class<ExecResult>("ExecResult")({
|
||||
exitCode: Schema.Number,
|
||||
output: Schema.String,
|
||||
}) {}
|
||||
|
||||
export declare namespace DaytonaService {
|
||||
export interface Service {
|
||||
readonly create: (opts: {
|
||||
threadId: ThreadId
|
||||
guildId: GuildId
|
||||
timeout: number
|
||||
}) => Effect.Effect<SandboxHandle, SandboxCreateError>
|
||||
readonly exec: (
|
||||
sandboxId: SandboxId,
|
||||
label: string,
|
||||
command: string,
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
) => Effect.Effect<ExecResult, SandboxExecError | SandboxNotFoundError>
|
||||
readonly start: (
|
||||
sandboxId: SandboxId,
|
||||
timeout: number,
|
||||
) => Effect.Effect<SandboxHandle, SandboxStartError | SandboxNotFoundError>
|
||||
readonly stop: (sandboxId: SandboxId) => Effect.Effect<void, SandboxNotFoundError>
|
||||
readonly destroy: (sandboxId: SandboxId) => Effect.Effect<void>
|
||||
readonly getPreview: (sandboxId: SandboxId) => Effect.Effect<PreviewAccess, SandboxNotFoundError>
|
||||
}
|
||||
}
|
||||
|
||||
const discordBotImage = 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")
|
||||
|
||||
export class DaytonaService extends Context.Tag("@discord/DaytonaService")<DaytonaService, DaytonaService.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
DaytonaService,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const sdk = new DaytonaSDK({
|
||||
apiKey: Redacted.value(config.daytonaApiKey),
|
||||
_experimental: {},
|
||||
})
|
||||
|
||||
const getSandbox = (sandboxId: SandboxId) =>
|
||||
Effect.tryPromise({
|
||||
try: () => sdk.get(sandboxId),
|
||||
catch: () => new SandboxNotFoundError({ sandboxId }),
|
||||
})
|
||||
|
||||
const toHandle = <E>(
|
||||
sandboxId: SandboxId,
|
||||
sandbox: { getPreviewLink: (timeout: number) => Promise<{ url: string; token?: string | null }> },
|
||||
error: (cause: unknown) => E,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => sandbox.getPreviewLink(4096),
|
||||
catch: error,
|
||||
}).pipe(
|
||||
Effect.map((preview) =>
|
||||
SandboxHandle.make({
|
||||
id: sandboxId,
|
||||
previewUrl: preview.url.replace(/\/$/, ""),
|
||||
previewToken: preview.token ?? null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("DaytonaService.create")(
|
||||
function* (opts: { threadId: ThreadId; guildId: GuildId; timeout: number }) {
|
||||
const sandbox = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
sdk.create(
|
||||
{
|
||||
image: discordBotImage,
|
||||
labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId },
|
||||
autoStopInterval: 0,
|
||||
autoArchiveInterval: 0,
|
||||
},
|
||||
{ timeout: opts.timeout },
|
||||
),
|
||||
catch: (cause) => new SandboxCreateError({ cause }),
|
||||
})
|
||||
const sandboxId = SandboxId.make(sandbox.id)
|
||||
return yield* toHandle(
|
||||
sandboxId,
|
||||
sandbox,
|
||||
(cause) => new SandboxCreateError({ sandboxId, cause }),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const exec = Effect.fn("DaytonaService.exec")(
|
||||
function* (
|
||||
sandboxId: SandboxId,
|
||||
label: string,
|
||||
command: string,
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => sandbox.process.executeCommand(command, opts?.cwd, opts?.env),
|
||||
catch: () => new SandboxExecError({ sandboxId, label, exitCode: -1, output: "exec failed" }),
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* new SandboxExecError({
|
||||
sandboxId,
|
||||
label,
|
||||
exitCode: result.exitCode,
|
||||
output: result.result.slice(0, 500),
|
||||
})
|
||||
}
|
||||
return ExecResult.make({
|
||||
exitCode: result.exitCode,
|
||||
output: result.result.trim(),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const start = Effect.fn("DaytonaService.start")(function* (sandboxId: SandboxId, timeout: number) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => sdk.start(sandbox, timeout),
|
||||
catch: (cause) => new SandboxStartError({ sandboxId, cause }),
|
||||
})
|
||||
return yield* toHandle(sandboxId, sandbox, (cause) => new SandboxStartError({ sandboxId, cause }))
|
||||
})
|
||||
|
||||
const stop = Effect.fn("DaytonaService.stop")(function* (sandboxId: SandboxId) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => sdk.stop(sandbox),
|
||||
catch: () => new SandboxNotFoundError({ sandboxId }),
|
||||
})
|
||||
})
|
||||
|
||||
const destroy = Effect.fn("DaytonaService.destroy")(function* (sandboxId: SandboxId) {
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const sandbox = await sdk.get(sandboxId)
|
||||
await sdk.delete(sandbox)
|
||||
},
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const getPreview = Effect.fn("DaytonaService.getPreview")(function* (sandboxId: SandboxId) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
const handle = yield* toHandle(sandboxId, sandbox, () => new SandboxNotFoundError({ sandboxId }))
|
||||
return PreviewAccess.from(handle)
|
||||
})
|
||||
|
||||
return DaytonaService.of({ create, exec, start, stop, destroy, getPreview })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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");
|
||||
}
|
||||
|
|
@ -1,735 +0,0 @@
|
|||
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 };
|
||||
|
|
@ -1,198 +1,242 @@
|
|||
import { Effect, Schedule } from "effect";
|
||||
import { logger } from "../observability/logger";
|
||||
import { Context, Effect, Layer, ParseResult, Schema, Schedule } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
||||
import { HealthCheckError, OpenCodeClientError } from "../errors"
|
||||
import { PreviewAccess, SessionId } from "../types"
|
||||
|
||||
type PreviewAccess = {
|
||||
previewUrl: string;
|
||||
previewToken?: string | null;
|
||||
};
|
||||
const HealthResponse = Schema.Struct({
|
||||
healthy: Schema.Boolean,
|
||||
})
|
||||
|
||||
export type OpenCodeSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
const CreateSessionResponse = Schema.Struct({
|
||||
id: SessionId,
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
const ListSessionsResponse = Schema.Array(
|
||||
Schema.Struct({
|
||||
id: SessionId,
|
||||
title: Schema.optional(Schema.String),
|
||||
time: Schema.optional(Schema.Struct({ updated: Schema.optional(Schema.Number) })),
|
||||
}),
|
||||
)
|
||||
|
||||
const SendPromptResponse = Schema.Struct({
|
||||
parts: Schema.optional(
|
||||
Schema.Array(Schema.Struct({
|
||||
type: Schema.String,
|
||||
text: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.String),
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
export class OpenCodeSessionSummary extends Schema.Class<OpenCodeSessionSummary>("OpenCodeSessionSummary")({
|
||||
id: SessionId,
|
||||
title: Schema.String,
|
||||
updatedAt: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
const parsePreview = (input: PreviewAccess): { base: string; token: string | null } => {
|
||||
const url = new URL(input.previewUrl)
|
||||
const token = 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.
|
||||
* HTTP client for an OpenCode server running inside a Daytona sandbox.
|
||||
*
|
||||
* Each method takes a {@link PreviewAccess} to locate the sandbox's preview
|
||||
* tunnel. Typical lifecycle:
|
||||
*
|
||||
* 1. `waitForHealthy` — poll until the server is ready after creation/resume
|
||||
* 2. `createSession` — start a new chat session
|
||||
* 3. `sendPrompt` — send user messages, returns the agent's text response
|
||||
* 4. `abortSession` — cancel an in-flight generation
|
||||
*/
|
||||
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);
|
||||
export declare namespace OpenCodeClient {
|
||||
export interface Service {
|
||||
/** Poll the health endpoint until the server responds healthy, or timeout. */
|
||||
readonly waitForHealthy: (
|
||||
preview: PreviewAccess,
|
||||
maxWaitMs?: number,
|
||||
) => Effect.Effect<boolean, HealthCheckError>
|
||||
/** Create a new chat session with the given title. Returns the session ID. */
|
||||
readonly createSession: (
|
||||
preview: PreviewAccess,
|
||||
title: string,
|
||||
) => Effect.Effect<SessionId, OpenCodeClientError>
|
||||
/** Check whether a session still exists on the server. */
|
||||
readonly sessionExists: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
) => Effect.Effect<boolean, OpenCodeClientError>
|
||||
/** List recent sessions, ordered by update time. */
|
||||
readonly listSessions: (
|
||||
preview: PreviewAccess,
|
||||
limit?: number,
|
||||
) => Effect.Effect<ReadonlyArray<OpenCodeSessionSummary>, OpenCodeClientError>
|
||||
/** Send a user prompt and return the agent's text response. */
|
||||
readonly sendPrompt: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError>
|
||||
/** Cancel an in-flight generation. Best-effort, errors are swallowed. */
|
||||
readonly abortSession: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
) => Effect.Effect<void>
|
||||
}
|
||||
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 = "";
|
||||
export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenCodeClient, OpenCodeClient.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
OpenCodeClient,
|
||||
Effect.gen(function* () {
|
||||
const baseClient = yield* HttpClient.HttpClient
|
||||
|
||||
const poll = Effect.tryPromise(async () => {
|
||||
const res = await previewFetch(preview, "/global/health");
|
||||
lastStatus = `${res.status}`;
|
||||
/** Build a scoped client for a specific preview, with auth header and 2xx filtering. */
|
||||
const scopedClient = (preview: PreviewAccess) => {
|
||||
const { base, token } = parsePreview(preview)
|
||||
return baseClient.pipe(
|
||||
HttpClient.mapRequest((req) =>
|
||||
token ? HttpClientRequest.setHeader(req, "x-daytona-preview-token", token) : req
|
||||
),
|
||||
HttpClient.mapRequest(HttpClientRequest.prependUrl(base)),
|
||||
HttpClient.filterStatusOk,
|
||||
)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
/** Map HttpClientError + ParseError to OpenCodeClientError for a given operation. */
|
||||
const mapErrors = <A, R>(
|
||||
operation: string,
|
||||
effect: Effect.Effect<A, HttpClientError.HttpClientError | ParseResult.ParseError, R>,
|
||||
) =>
|
||||
effect.pipe(
|
||||
Effect.catchTags({
|
||||
ResponseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: err.response.status, body: err.message }),
|
||||
RequestError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: err.message }),
|
||||
ParseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: `Decode: ${err.message}` }),
|
||||
}),
|
||||
)
|
||||
|
||||
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 waitForHealthy = Effect.fn("OpenCodeClient.waitForHealthy")(
|
||||
function* (preview: PreviewAccess, maxWaitMs = 120_000) {
|
||||
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000))
|
||||
const api = scopedClient(preview)
|
||||
|
||||
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000));
|
||||
const poll = api.get("/global/health").pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(HealthResponse)),
|
||||
Effect.scoped,
|
||||
Effect.flatMap((body) =>
|
||||
body.healthy
|
||||
? Effect.succeed(true)
|
||||
: new HealthCheckError({ lastStatus: `200 but healthy=${body.healthy}` }),
|
||||
),
|
||||
Effect.catchAll((cause) => new HealthCheckError({ lastStatus: String(cause) })),
|
||||
)
|
||||
|
||||
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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return yield* poll.pipe(
|
||||
Effect.retry(
|
||||
Schedule.intersect(
|
||||
Schedule.spaced("2 seconds"),
|
||||
Schedule.recurs(maxAttempts - 1),
|
||||
),
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed(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 }),
|
||||
});
|
||||
const createSession = (preview: PreviewAccess, title: string) =>
|
||||
mapErrors(
|
||||
"createSession",
|
||||
scopedClient(preview)
|
||||
.post("/session", { body: HttpBody.unsafeJson({ title }) })
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(CreateSessionResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((body) => body.id),
|
||||
),
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to create session (${res.status}): ${body}`);
|
||||
}
|
||||
const sessionExists = (preview: PreviewAccess, sessionId: SessionId) =>
|
||||
scopedClient(preview)
|
||||
.get(`/session/${sessionId}`)
|
||||
.pipe(
|
||||
Effect.scoped,
|
||||
Effect.as(true),
|
||||
Effect.catchTag("ResponseError", (err) =>
|
||||
err.response.status === 404
|
||||
? Effect.succeed(false)
|
||||
: new OpenCodeClientError({ operation: "sessionExists", statusCode: err.response.status, body: err.message }),
|
||||
),
|
||||
Effect.catchTag("RequestError", (err) =>
|
||||
new OpenCodeClientError({ operation: "sessionExists", statusCode: 0, body: err.message }),
|
||||
),
|
||||
)
|
||||
|
||||
const session = await res.json() as { id: string };
|
||||
return session.id;
|
||||
}
|
||||
const listSessions = (preview: PreviewAccess, limit = 50) =>
|
||||
mapErrors(
|
||||
"listSessions",
|
||||
scopedClient(preview)
|
||||
.get(`/session${limit > 0 ? `?limit=${limit}` : ""}`)
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ListSessionsResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((sessions) =>
|
||||
sessions.map((s) =>
|
||||
OpenCodeSessionSummary.make({
|
||||
id: s.id,
|
||||
title: s.title ?? "",
|
||||
...(s.time?.updated != null ? { updatedAt: s.time.updated } : {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export async function sessionExists(preview: string | PreviewAccess, sessionId: string): Promise<boolean> {
|
||||
const res = await previewFetch(preview, `/session/${sessionId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
const sendPrompt = (preview: PreviewAccess, sessionId: SessionId, text: string) =>
|
||||
mapErrors(
|
||||
"sendPrompt",
|
||||
scopedClient(preview)
|
||||
.post(`/session/${sessionId}/message`, {
|
||||
body: HttpBody.unsafeJson({ parts: [{ type: "text", text }] }),
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(SendPromptResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((result) => {
|
||||
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)"
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (res.ok) return true;
|
||||
if (res.status === 404) return false;
|
||||
const abortSession = (preview: PreviewAccess, sessionId: SessionId) =>
|
||||
scopedClient(preview)
|
||||
.post(`/session/${sessionId}/abort`)
|
||||
.pipe(
|
||||
Effect.scoped,
|
||||
Effect.asVoid,
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
|
||||
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 }],
|
||||
return OpenCodeClient.of({
|
||||
waitForHealthy,
|
||||
createSession,
|
||||
sessionExists,
|
||||
listSessions,
|
||||
sendPrompt,
|
||||
abortSession,
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
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(() => {});
|
||||
)
|
||||
}
|
||||
|
|
|
|||
124
packages/discord/src/sandbox/pool.test.ts
Normal file
124
packages/discord/src/sandbox/pool.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { OpenCodeClientError } from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { effectTest, testConfigLayer } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { ResumeFailed, SandboxProvisioner } from "./provisioner"
|
||||
import { ThreadAgentPool } from "./pool"
|
||||
|
||||
const threadId = ThreadId.make("t1")
|
||||
const channelId = ChannelId.make("c1")
|
||||
const guildId = GuildId.make("g1")
|
||||
|
||||
const session = SessionInfo.make({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make("s1"),
|
||||
previewUrl: "https://preview.example",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const store = () => {
|
||||
let row = Option.some(session)
|
||||
return SessionStore.of({
|
||||
upsert: (next) =>
|
||||
Effect.sync(() => {
|
||||
row = Option.some(next)
|
||||
}),
|
||||
getByThread: () => Effect.succeed(row),
|
||||
hasTrackedThread: () => Effect.succeed(Option.isSome(row)),
|
||||
getActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed(row)
|
||||
: Effect.succeed(Option.none()),
|
||||
markActivity: () => Effect.void,
|
||||
markHealthOk: () => Effect.void,
|
||||
updateStatus: (_threadId, status) =>
|
||||
Effect.sync(() => {
|
||||
if (Option.isNone(row)) return
|
||||
row = Option.some(row.value.withStatus(status))
|
||||
}),
|
||||
incrementResumeFailure: () => Effect.void,
|
||||
listActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed([row.value] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listTrackedThreads: () =>
|
||||
Option.isSome(row) && row.value.status !== "destroyed"
|
||||
? Effect.succeed([row.value.threadId] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listStaleActive: () => Effect.succeed([] as const),
|
||||
listExpiredPaused: () => Effect.succeed([] as const),
|
||||
})
|
||||
}
|
||||
|
||||
const provisioner = SandboxProvisioner.of({
|
||||
provision: () => Effect.succeed(session),
|
||||
resume: () => Effect.succeed(ResumeFailed.make({ allowRecreate: true })),
|
||||
ensureActive: ({ current }) => Effect.succeed(Option.isSome(current) ? current.value : session),
|
||||
ensureHealthy: () => Effect.succeed(true),
|
||||
recoverSendFailure: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
pause: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
destroy: (_threadId, next) => Effect.succeed(next.withStatus("destroyed")),
|
||||
})
|
||||
|
||||
const client = (statusCode: number, body: string) =>
|
||||
OpenCodeClient.of({
|
||||
waitForHealthy: () => Effect.succeed(true),
|
||||
createSession: () => Effect.succeed(SessionId.make("s2")),
|
||||
sessionExists: () => Effect.succeed(true),
|
||||
listSessions: () => Effect.succeed([]),
|
||||
sendPrompt: () => Effect.fail(new OpenCodeClientError({ operation: "sendPrompt", statusCode, body })),
|
||||
abortSession: () => Effect.void,
|
||||
})
|
||||
|
||||
const withPool = <A, E, R>(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
run: Effect.Effect<A, E, R>,
|
||||
) => {
|
||||
const deps = Layer.mergeAll(
|
||||
testConfigLayer,
|
||||
Layer.succeed(SessionStore, store()),
|
||||
Layer.succeed(SandboxProvisioner, provisioner),
|
||||
Layer.succeed(OpenCodeClient, client(statusCode, body)),
|
||||
)
|
||||
return run.pipe(
|
||||
Effect.provide(ThreadAgentPool.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ThreadAgentPool", () => {
|
||||
effectTest("maps recoverable send failures to SandboxDeadError", () =>
|
||||
withPool(
|
||||
502,
|
||||
"bad gateway",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("SandboxDeadError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("keeps non-recoverable send failures as OpenCodeClientError", () =>
|
||||
withPool(
|
||||
400,
|
||||
"bad request",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("OpenCodeClientError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
305
packages/discord/src/sandbox/pool.ts
Normal file
305
packages/discord/src/sandbox/pool.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { Context, Duration, Effect, Layer, Option, Ref, Schedule } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import {
|
||||
type ConfigEncodeError,
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
type OpenCodeClientError,
|
||||
type SandboxCreateError,
|
||||
type SandboxExecError,
|
||||
SandboxDeadError,
|
||||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { ActorMap } from "../lib/actors/keyed"
|
||||
import { logIgnore } from "../lib/log"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { SandboxProvisioner } from "./provisioner"
|
||||
|
||||
/** Per-thread handle returned by ThreadAgentPool.resolve. */
|
||||
export interface ThreadAgent {
|
||||
readonly threadId: ThreadId
|
||||
/** Snapshot from when this agent handle was created. Prefer `current()` for live state. */
|
||||
readonly session: SessionInfo
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: () => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly ensure: (
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
export declare namespace ThreadAgentPool {
|
||||
export interface Service {
|
||||
/** Get an existing healthy ThreadAgent or create one. */
|
||||
readonly getOrCreate: (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
ThreadAgent,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly getActiveSessionCount: () => Effect.Effect<number, DatabaseError>
|
||||
readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroySession: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadAgentPool extends Context.Tag("@discord/ThreadAgentPool")<
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
ThreadAgentPool,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const provisioner = yield* SandboxProvisioner
|
||||
const oc = yield* OpenCodeClient
|
||||
const store = yield* SessionStore
|
||||
|
||||
const runtime = (
|
||||
threadId: ThreadId,
|
||||
state: Ref.Ref<Option.Option<SessionInfo>>,
|
||||
): Runtime => {
|
||||
const dead = (error: OpenCodeClientError) => {
|
||||
if (error.statusCode === 404) return true
|
||||
if (error.statusCode === 0 || error.statusCode >= 500) return true
|
||||
const body = error.body.toLowerCase()
|
||||
if (body.includes("sandbox not found")) return true
|
||||
if (body.includes("is the sandbox started")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const lookup = Effect.fnUntraced(function* () {
|
||||
const loaded = yield* Ref.get(state)
|
||||
if (Option.isSome(loaded)) return loaded
|
||||
return Option.none<SessionInfo>()
|
||||
})
|
||||
|
||||
const current = Effect.fnUntraced(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isSome(session)) return session.value
|
||||
return yield* new DatabaseError({
|
||||
cause: new Error(`missing session for thread ${threadId}`),
|
||||
})
|
||||
})
|
||||
|
||||
const ensure = (channelId: ChannelId, guildId: GuildId) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* provisioner.ensureActive({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
current: yield* Ref.get(state),
|
||||
})
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
return next
|
||||
})
|
||||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* store.markActivity(threadId)
|
||||
const session = yield* current()
|
||||
return yield* oc.sendPrompt(PreviewAccess.from(session), session.sessionId, text).pipe(
|
||||
Effect.catchTag("OpenCodeClientError", (error) =>
|
||||
provisioner.recoverSendFailure(threadId, session, error).pipe(
|
||||
Effect.flatMap((next) =>
|
||||
Ref.set(state, Option.some(next)),
|
||||
),
|
||||
Effect.flatMap(() => {
|
||||
const failure: OpenCodeClientError | SandboxDeadError = dead(error)
|
||||
? new SandboxDeadError({
|
||||
threadId,
|
||||
reason: `OpenCode send failed (${error.statusCode})`,
|
||||
})
|
||||
: error
|
||||
return Effect.fail(failure)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pause = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.pause(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
const destroy = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.destroy(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
return { current, ensure, send, pause, destroy }
|
||||
}
|
||||
|
||||
const actors: ActorMap.ActorMap<ThreadId, SessionInfo> = yield* ActorMap.make<ThreadId, SessionInfo>({
|
||||
idleTimeout: config.sandboxTimeout,
|
||||
onIdle: (threadId) =>
|
||||
logIgnore(
|
||||
runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.pause("inactivity-timeout"),
|
||||
{ touch: false },
|
||||
).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
),
|
||||
"idle-pause",
|
||||
),
|
||||
load: (threadId) => store.getByThread(threadId).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))),
|
||||
save: (_threadId, session) => logIgnore(store.upsert(session), "save-session").pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const runRuntime = <A, E>(
|
||||
threadId: ThreadId,
|
||||
f: (rt: Runtime) => Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
) =>
|
||||
actors.run(
|
||||
threadId,
|
||||
(state) => f(runtime(threadId, state)),
|
||||
options,
|
||||
)
|
||||
|
||||
const pauseNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.pause(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const destroyNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.destroy(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const makeAgent = (threadId: ThreadId, session: SessionInfo): ThreadAgent => ({
|
||||
threadId,
|
||||
session,
|
||||
current: () =>
|
||||
runRuntime(threadId, (rt) => rt.current(), { touch: false }),
|
||||
send: (text: string) =>
|
||||
runRuntime(threadId, (rt) => rt.send(text)),
|
||||
pause: (reason = "manual") => pauseNow(threadId, reason),
|
||||
destroy: () => destroyNow(threadId, "agent-destroy"),
|
||||
})
|
||||
|
||||
const getOrCreate = Effect.fn("ThreadAgentPool.getOrCreate")(function* (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) {
|
||||
const session = yield* runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.ensure(channelId, guildId),
|
||||
)
|
||||
return makeAgent(threadId, session)
|
||||
})
|
||||
|
||||
const hasTrackedThread = Effect.fn("ThreadAgentPool.hasTrackedThread")(function* (threadId: ThreadId) {
|
||||
return yield* store.hasTrackedThread(threadId)
|
||||
})
|
||||
|
||||
const getTrackedSession = Effect.fn("ThreadAgentPool.getTrackedSession")(function* (threadId: ThreadId) {
|
||||
return yield* store.getByThread(threadId)
|
||||
})
|
||||
|
||||
const getActiveSessionCount = Effect.fn("ThreadAgentPool.getActiveSessionCount")(function* () {
|
||||
return (yield* store.listActive()).length
|
||||
})
|
||||
|
||||
const pauseSession = Effect.fn("ThreadAgentPool.pauseSession")(function* (
|
||||
threadId: ThreadId,
|
||||
reason = "manual",
|
||||
) {
|
||||
yield* pauseNow(threadId, reason)
|
||||
})
|
||||
|
||||
const destroySession = Effect.fn("ThreadAgentPool.destroySession")(function* (threadId: ThreadId) {
|
||||
yield* destroyNow(threadId, "manual-destroy")
|
||||
})
|
||||
|
||||
const cleanupPass = Effect.fnUntraced(function* () {
|
||||
const stale = yield* store.listStaleActive(
|
||||
Math.ceil(Duration.toMinutes(config.sandboxTimeout)) + config.staleActiveGraceMinutes,
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
stale,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
pauseNow(row.threadId, "cleanup-stale-active"),
|
||||
"cleanup-pause",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
const expired = yield* store.listExpiredPaused(config.pausedTtlMinutes)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
destroyNow(row.threadId, "cleanup-expired-paused"),
|
||||
"cleanup-destroy",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanupPass().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Cleanup loop failed").pipe(
|
||||
Effect.annotateLogs({ event: "cleanup.loop.failed", error: String(error) }),
|
||||
),
|
||||
),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ThreadAgentPool.of({
|
||||
getOrCreate,
|
||||
hasTrackedThread,
|
||||
getTrackedSession,
|
||||
getActiveSessionCount,
|
||||
pauseSession,
|
||||
destroySession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
478
packages/discord/src/sandbox/provisioner.ts
Normal file
478
packages/discord/src/sandbox/provisioner.ts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
import agentPrompt from "../agent-prompt.md" with { type: "text" }
|
||||
import { Context, Effect, Exit, Layer, Option, Redacted, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import {
|
||||
ConfigEncodeError,
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
type OpenCodeClientError,
|
||||
SandboxCreateError,
|
||||
SandboxDeadError,
|
||||
type SandboxExecError,
|
||||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../types"
|
||||
import { DaytonaService, type SandboxHandle } from "./daytona"
|
||||
import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode-client"
|
||||
|
||||
import { logIgnore } from "../lib/log"
|
||||
|
||||
const OpenCodeAuth = Schema.parseJson(
|
||||
Schema.Struct({
|
||||
opencode: Schema.Struct({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const OpenCodeConfig = Schema.parseJson(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
share: Schema.String,
|
||||
permission: Schema.String,
|
||||
agent: Schema.Struct({
|
||||
build: Schema.Struct({
|
||||
mode: Schema.Literal("primary"),
|
||||
prompt: Schema.String,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export class Resumed extends Schema.Class<Resumed>("Resumed")({
|
||||
session: SessionInfo,
|
||||
}) {}
|
||||
|
||||
export class ResumeFailed extends Schema.Class<ResumeFailed>("ResumeFailed")({
|
||||
allowRecreate: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export type ResumeResult = Resumed | ResumeFailed
|
||||
type SendFailure = "session-missing" | "sandbox-down" | "non-recoverable"
|
||||
|
||||
export declare namespace SandboxProvisioner {
|
||||
export interface Service {
|
||||
/** Creates a brand new sandbox + OpenCode session. */
|
||||
readonly provision: (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| DatabaseError
|
||||
>
|
||||
/** Attempts to resume an existing sandbox/session. Returns Resumed or Failed. */
|
||||
readonly resume: (
|
||||
session: SessionInfo,
|
||||
) => Effect.Effect<ResumeResult>
|
||||
/** Ensures a thread has an active healthy session, resuming or recreating if needed. */
|
||||
readonly ensureActive: (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId
|
||||
guildId: GuildId
|
||||
current: Option.Option<SessionInfo>
|
||||
}) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
/** Verifies active session health and attachment before reusing it. */
|
||||
readonly ensureHealthy: (
|
||||
session: SessionInfo,
|
||||
maxWaitMs: number,
|
||||
) => Effect.Effect<boolean, HealthCheckError>
|
||||
/** Applies session-state recovery policy after a send failure. */
|
||||
readonly recoverSendFailure: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
error: OpenCodeClientError,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
/** Pauses a session by stopping its sandbox. */
|
||||
readonly pause: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason: string,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
/** Destroys a session by destroying its sandbox. */
|
||||
readonly destroy: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason?: string,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner")<
|
||||
SandboxProvisioner,
|
||||
SandboxProvisioner.Service
|
||||
>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SandboxProvisioner,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const daytonaService = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const store = yield* SessionStore
|
||||
|
||||
/** Best-effort read of the OpenCode startup log from inside a sandbox. */
|
||||
const readStartupLog = (sandboxId: SandboxId, lines = 100) =>
|
||||
daytonaService.exec(sandboxId, "read-opencode-log", `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`).pipe(
|
||||
Effect.map((r) => r.output),
|
||||
Effect.catchAll(() => Effect.succeed("(unable to read log)")),
|
||||
)
|
||||
|
||||
/** Locate the existing OpenCode session or create a fresh one for a thread. */
|
||||
const findOrCreateSessionId = Effect.fnUntraced(function* (preview: PreviewAccess, session: SessionInfo) {
|
||||
const exists = yield* oc
|
||||
.sessionExists(preview, session.sessionId)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(false)))
|
||||
if (exists) return session.sessionId
|
||||
|
||||
const title = `Discord thread ${session.threadId}`
|
||||
const sessions = yield* oc
|
||||
.listSessions(preview, 50)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed([] as ReadonlyArray<OpenCodeSessionSummary>)))
|
||||
const match = [...sessions]
|
||||
.filter((c) => c.title === title)
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
|
||||
|
||||
return match ? match.id : yield* oc.createSession(preview, title)
|
||||
})
|
||||
|
||||
const buildRuntimeEnv = (input?: Record<string, string>): Record<string, string> => {
|
||||
const runtimeEnv: Record<string, string> = {}
|
||||
const githubToken = config.githubToken.trim()
|
||||
if (githubToken.length > 0) {
|
||||
runtimeEnv.GH_TOKEN = githubToken
|
||||
runtimeEnv.GITHUB_TOKEN = githubToken
|
||||
}
|
||||
if (!input) return runtimeEnv
|
||||
return { ...runtimeEnv, ...input }
|
||||
}
|
||||
|
||||
/** Best-effort: record a resume failure reason and mark the session as errored. */
|
||||
const recordFailure = (threadId: ThreadId, reason: string) =>
|
||||
Effect.all(
|
||||
[
|
||||
logIgnore(store.incrementResumeFailure(threadId, reason), "incrementResumeFailure"),
|
||||
logIgnore(store.updateStatus(threadId, "error", reason), "updateStatus"),
|
||||
],
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const restartOpenCodeServe =
|
||||
'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'
|
||||
const classifySendError = (error: OpenCodeClientError): SendFailure => {
|
||||
if (error.statusCode === 404) return "session-missing"
|
||||
if (error.statusCode === 0 || error.statusCode >= 500) return "sandbox-down"
|
||||
const body = error.body.toLowerCase()
|
||||
if (body.includes("sandbox not found") || body.includes("is the sandbox started")) return "sandbox-down"
|
||||
return "non-recoverable"
|
||||
}
|
||||
|
||||
const provision = Effect.fn("SandboxProvisioner.provision")(function* (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) {
|
||||
yield* logIgnore(store.updateStatus(threadId, "creating"), "updateStatus")
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
daytonaService.create({
|
||||
threadId,
|
||||
guildId,
|
||||
timeout: config.sandboxCreationTimeout,
|
||||
}),
|
||||
(handle) =>
|
||||
Effect.gen(function* () {
|
||||
const sandboxId = handle.id
|
||||
|
||||
yield* Effect.logInfo("Created sandbox").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.started", threadId, channelId, guildId, sandboxId }),
|
||||
)
|
||||
|
||||
const opencodeConfig: string = yield* Schema.encode(OpenCodeConfig)({
|
||||
model: config.openCodeModel,
|
||||
share: "disabled",
|
||||
permission: "allow",
|
||||
agent: { build: { mode: "primary", prompt: agentPrompt } },
|
||||
}).pipe(Effect.mapError((cause) => new ConfigEncodeError({ config: "OpenCodeConfig", cause })))
|
||||
|
||||
const authJson: string = yield* Schema.encode(OpenCodeAuth)({
|
||||
opencode: { type: "api", key: Redacted.value(config.openCodeZenApiKey) },
|
||||
}).pipe(Effect.mapError((cause) => new ConfigEncodeError({ config: "OpenCodeAuth", cause })))
|
||||
|
||||
yield* daytonaService.exec(
|
||||
sandboxId,
|
||||
"setup-opencode",
|
||||
[
|
||||
`set -e`,
|
||||
`git clone --depth=1 https://github.com/anomalyco/opencode.git $HOME/opencode`,
|
||||
`mkdir -p $HOME/.local/share/opencode`,
|
||||
`printf '%s' "$OPENCODE_AUTH_JSON" > $HOME/.local/share/opencode/auth.json`,
|
||||
`printf '%s' "$OPENCODE_CONFIG_JSON" > $HOME/opencode/opencode.json`,
|
||||
`cd $HOME/opencode`,
|
||||
`setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 &`,
|
||||
].join("\n"),
|
||||
{
|
||||
env: buildRuntimeEnv({
|
||||
OPENCODE_AUTH_JSON: authJson,
|
||||
OPENCODE_CONFIG_JSON: opencodeConfig,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const healthy = yield* oc.waitForHealthy(
|
||||
PreviewAccess.from(handle),
|
||||
config.startupHealthTimeoutMs,
|
||||
)
|
||||
if (!healthy) {
|
||||
const startupLog = yield* readStartupLog(sandboxId)
|
||||
return yield* new SandboxCreateError({
|
||||
sandboxId: handle.id,
|
||||
cause: new Error(`OpenCode server did not become healthy: ${startupLog.slice(0, 400)}`),
|
||||
})
|
||||
}
|
||||
|
||||
const sessionId = yield* oc.createSession(
|
||||
PreviewAccess.from(handle),
|
||||
`Discord thread ${threadId}`,
|
||||
)
|
||||
|
||||
const session: SessionInfo = SessionInfo.make({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId: handle.id,
|
||||
sessionId,
|
||||
previewUrl: handle.previewUrl,
|
||||
previewToken: handle.previewToken,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
yield* store.markHealthOk(threadId)
|
||||
|
||||
yield* Effect.logInfo("Session is ready").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.ready", threadId, sandboxId, sessionId }),
|
||||
)
|
||||
|
||||
return session
|
||||
}),
|
||||
(handle, exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.logError("Failed to create session").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.failed", threadId, sandboxId: handle.id }),
|
||||
)
|
||||
yield* logIgnore(store.updateStatus(threadId, "error", "creation-failed"), "updateStatus")
|
||||
yield* daytonaService.destroy(handle.id)
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
})
|
||||
|
||||
const failed = (allowRecreate: boolean) => ResumeFailed.make({ allowRecreate })
|
||||
|
||||
const resume = (session: SessionInfo): Effect.Effect<ResumeResult> =>
|
||||
Effect.gen(function* () {
|
||||
if (!["paused", "destroyed", "error", "pausing", "resuming"].includes(session.status)) {
|
||||
return failed(true)
|
||||
}
|
||||
|
||||
yield* store.updateStatus(session.threadId, "resuming")
|
||||
|
||||
const startResult = yield* daytonaService.start(session.sandboxId, config.sandboxCreationTimeout).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catchTag("SandboxNotFoundError", (err) =>
|
||||
Effect.gen(function* () {
|
||||
yield* logIgnore(store.incrementResumeFailure(session.threadId, err.message), "incrementResumeFailure")
|
||||
yield* logIgnore(store.updateStatus(session.threadId, "destroyed", err.message), "updateStatus")
|
||||
return Option.none<SandboxHandle>()
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("SandboxStartError", (err) =>
|
||||
recordFailure(session.threadId, String(err.cause)).pipe(Effect.as(Option.none<SandboxHandle>())),
|
||||
),
|
||||
)
|
||||
|
||||
if (Option.isNone(startResult)) return failed(true)
|
||||
|
||||
const handle = startResult.value
|
||||
|
||||
yield* logIgnore(
|
||||
daytonaService.exec(session.sandboxId, "restart-opencode-serve", restartOpenCodeServe, {
|
||||
env: buildRuntimeEnv(),
|
||||
}),
|
||||
"restart-opencode-serve",
|
||||
)
|
||||
|
||||
const preview = PreviewAccess.from(handle)
|
||||
|
||||
const healthy = yield* oc.waitForHealthy(preview, config.resumeHealthTimeoutMs)
|
||||
if (!healthy) {
|
||||
const startupLog = yield* readStartupLog(session.sandboxId, 120)
|
||||
yield* recordFailure(
|
||||
session.threadId,
|
||||
`OpenCode health check failed after resume. Log: ${startupLog.slice(0, 500)}`,
|
||||
)
|
||||
return failed(false)
|
||||
}
|
||||
|
||||
const sessionId = yield* findOrCreateSessionId(preview, session)
|
||||
|
||||
const resumed = SessionInfo.make({
|
||||
...session,
|
||||
sessionId,
|
||||
previewUrl: handle.previewUrl,
|
||||
previewToken: handle.previewToken,
|
||||
status: "active",
|
||||
})
|
||||
|
||||
yield* store.markHealthOk(session.threadId)
|
||||
|
||||
yield* Effect.logInfo("Resumed existing sandbox").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.resumed", threadId: session.threadId, sandboxId: session.sandboxId }),
|
||||
)
|
||||
|
||||
return Resumed.make({ session: resumed })
|
||||
}).pipe(
|
||||
Effect.catchAll((err) =>
|
||||
recordFailure(session.threadId, String(err)).pipe(Effect.as(failed(false))),
|
||||
),
|
||||
)
|
||||
|
||||
const ensureHealthy = Effect.fn("SandboxProvisioner.ensureHealthy")(function* (
|
||||
session: SessionInfo,
|
||||
maxWaitMs: number,
|
||||
) {
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), maxWaitMs)
|
||||
if (!healthy) {
|
||||
yield* recordFailure(session.threadId, "active-session-healthcheck-failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const attached = yield* oc
|
||||
.sessionExists(PreviewAccess.from(session), session.sessionId)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(false)))
|
||||
if (!attached) {
|
||||
yield* recordFailure(session.threadId, "active-session-missing")
|
||||
return false
|
||||
}
|
||||
|
||||
yield* logIgnore(store.markHealthOk(session.threadId), "markHealthOk")
|
||||
return true
|
||||
})
|
||||
|
||||
const pause = Effect.fn("SandboxProvisioner.pause")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason: string,
|
||||
) {
|
||||
if (session.status === "paused") return session
|
||||
|
||||
yield* store.updateStatus(threadId, "pausing", reason)
|
||||
|
||||
const stopped = Exit.isSuccess(yield* Effect.exit(daytonaService.stop(session.sandboxId)))
|
||||
|
||||
if (stopped) {
|
||||
yield* store.updateStatus(threadId, "paused", null)
|
||||
return session.withStatus("paused")
|
||||
}
|
||||
|
||||
yield* store.updateStatus(threadId, "destroyed", "sandbox-unavailable-during-pause")
|
||||
return session.withStatus("destroyed")
|
||||
})
|
||||
|
||||
const destroy = Effect.fn("SandboxProvisioner.destroy")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason?: string,
|
||||
) {
|
||||
if (session.status === "destroyed") return session
|
||||
yield* store.updateStatus(threadId, "destroying", reason ?? null)
|
||||
yield* daytonaService.destroy(session.sandboxId)
|
||||
yield* store.updateStatus(threadId, "destroyed", reason ?? null)
|
||||
return session.withStatus("destroyed")
|
||||
})
|
||||
|
||||
const ensureActive = Effect.fn("SandboxProvisioner.ensureActive")(function* (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId
|
||||
guildId: GuildId
|
||||
current: Option.Option<SessionInfo>
|
||||
}) {
|
||||
if (Option.isNone(input.current)) {
|
||||
return yield* provision(input.threadId, input.channelId, input.guildId)
|
||||
}
|
||||
|
||||
let candidate = input.current.value
|
||||
if (candidate.status === "active") {
|
||||
const healthy = yield* ensureHealthy(candidate, config.activeHealthCheckTimeoutMs)
|
||||
if (healthy) return candidate
|
||||
const refreshed = yield* store.getByThread(input.threadId)
|
||||
candidate = Option.isSome(refreshed) ? refreshed.value : candidate.withStatus("error")
|
||||
}
|
||||
|
||||
if (config.sandboxReusePolicy === "resume_preferred") {
|
||||
const resumed = yield* resume(candidate)
|
||||
if (resumed instanceof Resumed) return resumed.session
|
||||
if (!resumed.allowRecreate) {
|
||||
return yield* new SandboxDeadError({
|
||||
threadId: input.threadId,
|
||||
reason: "Unable to reattach to existing sandbox session. Try again shortly.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
yield* destroy(input.threadId, candidate, "recreate-after-resume-failure")
|
||||
return yield* provision(input.threadId, input.channelId, input.guildId)
|
||||
})
|
||||
|
||||
const recoverSendFailure = Effect.fn("SandboxProvisioner.recoverSendFailure")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
error: OpenCodeClientError,
|
||||
) {
|
||||
const kind = classifySendError(error)
|
||||
if (kind === "non-recoverable") return session
|
||||
|
||||
yield* store.incrementResumeFailure(threadId, String(error))
|
||||
if (kind === "session-missing") {
|
||||
yield* store.updateStatus(threadId, "error", "opencode-session-missing")
|
||||
return session.withStatus("error")
|
||||
}
|
||||
|
||||
return yield* pause(threadId, session, "recoverable send failure")
|
||||
})
|
||||
|
||||
return SandboxProvisioner.of({
|
||||
provision,
|
||||
resume,
|
||||
ensureActive,
|
||||
ensureHealthy,
|
||||
recoverSendFailure,
|
||||
pause,
|
||||
destroy,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
198
packages/discord/src/sessions/store.test.ts
Normal file
198
packages/discord/src/sessions/store.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Redacted } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { effectTest, withTempSqliteFile } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("token"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
openCodeZenApiKey: Redacted.make("zen"),
|
||||
githubToken: "",
|
||||
logLevel: "info",
|
||||
healthHost: "127.0.0.1",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off",
|
||||
turnRoutingModel: "claude-haiku-4-5",
|
||||
sandboxReusePolicy: "resume_preferred",
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
|
||||
pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
|
||||
activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
|
||||
startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
|
||||
resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
|
||||
sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
})
|
||||
|
||||
const makeSession = (threadId: string, status: "creating" | "active" | "paused" = "active") =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make(threadId),
|
||||
channelId: ChannelId.make("c1"),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(`s-${threadId}`),
|
||||
previewUrl: `https://preview/${threadId}`,
|
||||
previewToken: null,
|
||||
status,
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const withStore = <A, E, R>(run: (store: SessionStore.Service, sql: Client.SqlClient) => Effect.Effect<A, E, R>) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(
|
||||
Layer.provide(config),
|
||||
)
|
||||
const live = Layer.merge(
|
||||
SessionStore.layer.pipe(
|
||||
Layer.provide(sqlite),
|
||||
),
|
||||
sqlite,
|
||||
)
|
||||
|
||||
const program = Effect.all([SessionStore, SqliteDb]).pipe(
|
||||
Effect.flatMap(([store, sql]) => run(store, sql)),
|
||||
)
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-store-",
|
||||
)
|
||||
|
||||
const getTransitions = (sql: Client.SqlClient, threadId: ThreadId) =>
|
||||
sql<{
|
||||
pause_requested_at: string | null
|
||||
paused_at: string | null
|
||||
resume_attempted_at: string | null
|
||||
resumed_at: string | null
|
||||
destroyed_at: string | null
|
||||
}>`SELECT pause_requested_at, paused_at, resume_attempted_at, resumed_at, destroyed_at
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ${threadId}
|
||||
LIMIT 1`.pipe(
|
||||
Effect.map((rows) => rows[0] ?? null),
|
||||
)
|
||||
|
||||
describe("SessionStore", () => {
|
||||
effectTest("runs typed CRUD flow against sqlite", () =>
|
||||
withStore((store, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const t1 = ThreadId.make("t1")
|
||||
const t2 = ThreadId.make("t2")
|
||||
|
||||
yield* store.upsert(makeSession("t1", "active"))
|
||||
expect(yield* store.hasTrackedThread(t1)).toBe(true)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.threadId)).toEqual(Option.some(t1))
|
||||
expect(Option.map(yield* store.getActive(t1), (s) => s.status)).toEqual(Option.some("active"))
|
||||
|
||||
yield* store.updateStatus(t1, "paused", "pause")
|
||||
yield* store.incrementResumeFailure(t1, "resume-fail")
|
||||
expect(Option.isNone(yield* store.getActive(t1))).toBe(true)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.resumeFailCount)).toEqual(Option.some(1))
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.lastError)).toEqual(Option.some("resume-fail"))
|
||||
|
||||
yield* store.updateStatus(t1, "active")
|
||||
yield* sql`UPDATE discord_sessions
|
||||
SET last_activity = datetime('now', '-40 minutes')
|
||||
WHERE thread_id = ${t1}`
|
||||
expect((yield* store.listStaleActive(30)).map((row) => row.threadId)).toEqual([t1])
|
||||
expect((yield* store.listStaleActive(120)).map((row) => row.threadId)).toEqual([])
|
||||
|
||||
yield* store.upsert(makeSession("t2", "creating"))
|
||||
yield* store.updateStatus(t2, "paused")
|
||||
yield* sql`UPDATE discord_sessions
|
||||
SET paused_at = datetime('now', '-40 minutes')
|
||||
WHERE thread_id = ${t2}`
|
||||
expect((yield* store.listExpiredPaused(30)).map((row) => row.threadId)).toEqual([t2])
|
||||
expect((yield* store.listExpiredPaused(120)).map((row) => row.threadId)).toEqual([])
|
||||
expect(new Set(yield* store.listTrackedThreads())).toEqual(new Set([t1, t2]))
|
||||
|
||||
yield* store.updateStatus(t2, "destroyed")
|
||||
expect(new Set(yield* store.listTrackedThreads())).toEqual(new Set([t1]))
|
||||
|
||||
const next = SessionInfo.make({
|
||||
...makeSession("t1", "active"),
|
||||
sessionId: SessionId.make("s-t1-new"),
|
||||
previewToken: "ptok",
|
||||
})
|
||||
yield* store.upsert(next)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.sessionId)).toEqual(Option.some(SessionId.make("s-t1-new")))
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.previewToken)).toEqual(Option.some("ptok"))
|
||||
expect((yield* store.listActive()).map((row) => row.threadId)).toContain(t1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("tracks lifecycle transition timestamps by status", () =>
|
||||
withStore((store, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const t = ThreadId.make("tx")
|
||||
|
||||
yield* store.upsert(makeSession("tx", "creating"))
|
||||
|
||||
yield* store.updateStatus(t, "pausing", "queued")
|
||||
const pausing = yield* getTransitions(sql, t)
|
||||
expect(pausing).not.toBeNull()
|
||||
if (pausing === null) return
|
||||
expect(pausing.pause_requested_at).not.toBeNull()
|
||||
expect(pausing.paused_at).toBeNull()
|
||||
expect(pausing.resume_attempted_at).toBeNull()
|
||||
expect(pausing.resumed_at).toBeNull()
|
||||
expect(pausing.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "paused")
|
||||
const paused = yield* getTransitions(sql, t)
|
||||
expect(paused).not.toBeNull()
|
||||
if (paused === null) return
|
||||
expect(paused.pause_requested_at).not.toBeNull()
|
||||
expect(paused.paused_at).not.toBeNull()
|
||||
expect(paused.resume_attempted_at).toBeNull()
|
||||
expect(paused.resumed_at).toBeNull()
|
||||
expect(paused.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "resuming")
|
||||
const resuming = yield* getTransitions(sql, t)
|
||||
expect(resuming).not.toBeNull()
|
||||
if (resuming === null) return
|
||||
expect(resuming.pause_requested_at).not.toBeNull()
|
||||
expect(resuming.paused_at).not.toBeNull()
|
||||
expect(resuming.resume_attempted_at).not.toBeNull()
|
||||
expect(resuming.resumed_at).toBeNull()
|
||||
expect(resuming.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "active")
|
||||
const active = yield* getTransitions(sql, t)
|
||||
expect(active).not.toBeNull()
|
||||
if (active === null) return
|
||||
expect(active.pause_requested_at).not.toBeNull()
|
||||
expect(active.paused_at).not.toBeNull()
|
||||
expect(active.resume_attempted_at).not.toBeNull()
|
||||
expect(active.resumed_at).not.toBeNull()
|
||||
expect(active.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "destroyed")
|
||||
const destroyed = yield* getTransitions(sql, t)
|
||||
expect(destroyed).not.toBeNull()
|
||||
if (destroyed === null) return
|
||||
expect(destroyed.pause_requested_at).not.toBeNull()
|
||||
expect(destroyed.paused_at).not.toBeNull()
|
||||
expect(destroyed.resume_attempted_at).not.toBeNull()
|
||||
expect(destroyed.resumed_at).not.toBeNull()
|
||||
expect(destroyed.destroyed_at).not.toBeNull()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,321 +1,268 @@
|
|||
import { getDb } from "../db/client"
|
||||
import type { SessionInfo, SessionStatus } from "../types"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import * as SqlSchema from "@effect/sql/SqlSchema"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { initializeSchema } from "../db/init"
|
||||
import { DatabaseError } from "../errors"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, SessionStatus, ThreadId } 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
|
||||
}
|
||||
const ROW = `thread_id AS threadId, channel_id AS channelId, guild_id AS guildId, sandbox_id AS sandboxId, session_id AS sessionId,
|
||||
preview_url AS previewUrl, preview_token AS previewToken, status, last_error AS lastError, resume_fail_count AS resumeFailCount`
|
||||
|
||||
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 Write extends Schema.Class<Write>("Write")({
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
guild_id: GuildId,
|
||||
sandbox_id: SandboxId,
|
||||
session_id: SessionId,
|
||||
preview_url: Schema.String,
|
||||
preview_token: Schema.Union(Schema.Null, Schema.String),
|
||||
status: SessionStatus,
|
||||
last_error: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {}
|
||||
|
||||
class SqliteSessionStore implements SessionStore {
|
||||
private readonly db = getDb()
|
||||
const Thread = Schema.Struct({ thread_id: ThreadId })
|
||||
const Status = Schema.Struct({
|
||||
thread_id: ThreadId,
|
||||
status: SessionStatus,
|
||||
last_error: Schema.Union(Schema.Null, Schema.String),
|
||||
})
|
||||
const Resume = Schema.Struct({ thread_id: ThreadId, last_error: Schema.String })
|
||||
const Minutes = Schema.Struct({ minutes: Schema.Number })
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
const STATUS_COLUMNS = [
|
||||
["pausing", "pause_requested_at"],
|
||||
["paused", "paused_at"],
|
||||
["resuming", "resume_attempted_at"],
|
||||
["active", "resumed_at"],
|
||||
["destroyed", "destroyed_at"],
|
||||
] as const
|
||||
|
||||
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
|
||||
const toWrite = (session: SessionInfo) =>
|
||||
Write.make({
|
||||
thread_id: session.threadId,
|
||||
channel_id: session.channelId,
|
||||
guild_id: session.guildId,
|
||||
sandbox_id: session.sandboxId,
|
||||
session_id: session.sessionId,
|
||||
preview_url: session.previewUrl,
|
||||
preview_token: session.previewToken,
|
||||
status: session.status,
|
||||
last_error: session.lastError,
|
||||
})
|
||||
|
||||
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)
|
||||
export declare namespace SessionStore {
|
||||
export interface Service {
|
||||
readonly upsert: (session: SessionInfo) => Effect.Effect<void, DatabaseError>
|
||||
readonly getByThread: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly getActive: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly markActivity: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
readonly markHealthOk: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
readonly updateStatus: (threadId: ThreadId, status: SessionStatus, lastError?: string | null) => Effect.Effect<void, DatabaseError>
|
||||
readonly incrementResumeFailure: (threadId: ThreadId, lastError: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly listActive: () => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
readonly listTrackedThreads: () => Effect.Effect<ReadonlyArray<ThreadId>, DatabaseError>
|
||||
readonly listStaleActive: (cutoffMinutes: number) => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
readonly listExpiredPaused: (pausedTtlMinutes: number) => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
export class SessionStore extends Context.Tag("@discord/SessionStore")<SessionStore, SessionStore.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SessionStore,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const sessionStore: SessionStore = new SqliteSessionStore()
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
export function getSessionStore(): SessionStore {
|
||||
return sessionStore
|
||||
const statusSet = (status: SessionStatus) =>
|
||||
sql.join(",\n", false)(STATUS_COLUMNS.map(([value, column]) =>
|
||||
sql`${sql(column)} = CASE WHEN ${status} = ${value} THEN CURRENT_TIMESTAMP ELSE ${sql(column)} END`
|
||||
))
|
||||
|
||||
const touch = (column: "last_activity" | "last_health_ok_at") =>
|
||||
SqlSchema.void({
|
||||
Request: Thread,
|
||||
execute: ({ thread_id }) =>
|
||||
sql`UPDATE discord_sessions
|
||||
SET ${sql(column)} = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const upsertQ = SqlSchema.void({
|
||||
Request: Write,
|
||||
execute: (session) =>
|
||||
sql`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 (
|
||||
${session.thread_id}, ${session.channel_id}, ${session.guild_id}, ${session.sandbox_id}, ${session.session_id},
|
||||
${session.preview_url}, ${session.preview_token}, ${session.status}, ${session.last_error},
|
||||
CURRENT_TIMESTAMP,
|
||||
CASE WHEN ${session.status} = '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`,
|
||||
})
|
||||
|
||||
const byThreadQ = SqlSchema.findOne({
|
||||
Request: Thread,
|
||||
Result: SessionInfo,
|
||||
execute: ({ thread_id }) => sql`SELECT ${sql.literal(ROW)} FROM discord_sessions WHERE thread_id = ${thread_id} LIMIT 1`,
|
||||
})
|
||||
|
||||
const activeQ = SqlSchema.findOne({
|
||||
Request: Thread,
|
||||
Result: SessionInfo,
|
||||
execute: ({ thread_id }) =>
|
||||
sql`SELECT ${sql.literal(ROW)} FROM discord_sessions WHERE thread_id = ${thread_id} AND status = 'active' LIMIT 1`,
|
||||
})
|
||||
|
||||
const markActivityQ = touch("last_activity")
|
||||
|
||||
const markHealthOkQ = touch("last_health_ok_at")
|
||||
|
||||
const updateStatusQ = SqlSchema.void({
|
||||
Request: Status,
|
||||
execute: ({ thread_id, status, last_error }) =>
|
||||
sql`UPDATE discord_sessions SET
|
||||
status = ${status}, last_error = ${last_error},
|
||||
${statusSet(status)},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const incrementResumeFailureQ = SqlSchema.void({
|
||||
Request: Resume,
|
||||
execute: ({ thread_id, last_error }) =>
|
||||
sql`UPDATE discord_sessions SET
|
||||
resume_fail_count = resume_fail_count + 1, last_error = ${last_error}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const listActiveQ = SqlSchema.findAll({
|
||||
Request: Schema.Void,
|
||||
Result: SessionInfo,
|
||||
execute: () =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_activity DESC`,
|
||||
})
|
||||
|
||||
const listTrackedThreadsQ = SqlSchema.findAll({
|
||||
Request: Schema.Void,
|
||||
Result: Schema.Struct({ threadId: ThreadId }),
|
||||
execute: () =>
|
||||
sql`SELECT thread_id AS threadId
|
||||
FROM discord_sessions
|
||||
WHERE status != 'destroyed'
|
||||
ORDER BY updated_at DESC`,
|
||||
})
|
||||
|
||||
const listStaleActiveQ = SqlSchema.findAll({
|
||||
Request: Minutes,
|
||||
Result: SessionInfo,
|
||||
execute: ({ minutes }) =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active' AND last_activity < datetime('now', '-' || ${minutes} || ' minutes')
|
||||
ORDER BY last_activity ASC`,
|
||||
})
|
||||
|
||||
const listExpiredPausedQ = SqlSchema.findAll({
|
||||
Request: Minutes,
|
||||
Result: SessionInfo,
|
||||
execute: ({ minutes }) =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'paused' AND paused_at IS NOT NULL
|
||||
AND paused_at < datetime('now', '-' || ${minutes} || ' minutes')
|
||||
ORDER BY paused_at ASC`,
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("SessionStore.upsert")(function* (session: SessionInfo) {
|
||||
yield* db(upsertQ(toWrite(session)))
|
||||
})
|
||||
|
||||
const getByThread = Effect.fn("SessionStore.getByThread")(function* (threadId: ThreadId) {
|
||||
return yield* db(byThreadQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const hasTrackedThread = Effect.fn("SessionStore.hasTrackedThread")(function* (threadId: ThreadId) {
|
||||
const row = yield* db(byThreadQ({ thread_id: threadId }))
|
||||
return Option.isSome(row)
|
||||
})
|
||||
|
||||
const getActive = Effect.fn("SessionStore.getActive")(function* (threadId: ThreadId) {
|
||||
return yield* db(activeQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const markActivity = Effect.fn("SessionStore.markActivity")(function* (threadId: ThreadId) {
|
||||
yield* db(markActivityQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const markHealthOk = Effect.fn("SessionStore.markHealthOk")(function* (threadId: ThreadId) {
|
||||
yield* db(markHealthOkQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const updateStatus = Effect.fn("SessionStore.updateStatus")(function* (threadId: ThreadId, status: SessionStatus, lastError?: string | null) {
|
||||
yield* db(updateStatusQ({ thread_id: threadId, status, last_error: lastError ?? null }))
|
||||
})
|
||||
|
||||
const incrementResumeFailure = Effect.fn("SessionStore.incrementResumeFailure")(function* (threadId: ThreadId, lastError: string) {
|
||||
yield* db(incrementResumeFailureQ({ thread_id: threadId, last_error: lastError }))
|
||||
})
|
||||
|
||||
const listActive = Effect.fn("SessionStore.listActive")(function* () {
|
||||
return yield* db(listActiveQ(undefined))
|
||||
})
|
||||
|
||||
const listTrackedThreads = Effect.fn("SessionStore.listTrackedThreads")(function* () {
|
||||
return (yield* db(listTrackedThreadsQ(undefined))).map((row) => row.threadId)
|
||||
})
|
||||
|
||||
const listStaleActive = Effect.fn("SessionStore.listStaleActive")(function* (cutoffMinutes: number) {
|
||||
return yield* db(listStaleActiveQ({ minutes: cutoffMinutes }))
|
||||
})
|
||||
|
||||
const listExpiredPaused = Effect.fn("SessionStore.listExpiredPaused")(function* (pausedTtlMinutes: number) {
|
||||
return yield* db(listExpiredPausedQ({ minutes: pausedTtlMinutes }))
|
||||
})
|
||||
|
||||
return SessionStore.of({
|
||||
upsert,
|
||||
getByThread,
|
||||
hasTrackedThread,
|
||||
getActive,
|
||||
markActivity,
|
||||
markHealthOk,
|
||||
updateStatus,
|
||||
incrementResumeFailure,
|
||||
listActive,
|
||||
listTrackedThreads,
|
||||
listStaleActive,
|
||||
listExpiredPaused,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = SessionStore.layer.pipe(
|
||||
Layer.provide(SqliteDb.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
65
packages/discord/src/test/effect.ts
Normal file
65
packages/discord/src/test/effect.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Reactivity } from "@effect/experimental"
|
||||
import * as FileSystem from "@effect/platform/FileSystem"
|
||||
import { BunFileSystem } from "@effect/platform-bun"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import type * as Client from "@effect/sql/SqlClient"
|
||||
import { test } from "bun:test"
|
||||
import { Duration, Effect, Layer, Redacted } from "effect"
|
||||
import type { Scope } from "effect/Scope"
|
||||
import type { TestOptions } from "bun:test"
|
||||
import { AppConfig, Milliseconds, Minutes, Seconds } from "../config"
|
||||
|
||||
export const effectTest = (
|
||||
name: string,
|
||||
run: () => Effect.Effect<unknown, unknown, Scope | never>,
|
||||
options?: number | TestOptions,
|
||||
) =>
|
||||
test(name, () => Effect.runPromise(run().pipe(Effect.scoped)), options)
|
||||
|
||||
export const withSqlite = <A, E, R>(filename: string, run: (db: Client.SqlClient) => Effect.Effect<A, E, R>) =>
|
||||
SqliteClient.make({ filename }).pipe(
|
||||
Effect.provide(Reactivity.layer),
|
||||
Effect.flatMap(run),
|
||||
Effect.scoped,
|
||||
)
|
||||
|
||||
export const withTempSqliteFile = <A, E, R>(
|
||||
run: (filename: string) => Effect.Effect<A, E, R>,
|
||||
prefix = "discord-test-",
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const filename = yield* fs.makeTempFileScoped({ prefix, suffix: ".sqlite" })
|
||||
return yield* run(filename)
|
||||
}).pipe(Effect.provide(BunFileSystem.layer))
|
||||
|
||||
export const testConfigLayer = Layer.succeed(
|
||||
AppConfig,
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("test"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath: ":memory:",
|
||||
daytonaApiKey: Redacted.make("test"),
|
||||
openCodeZenApiKey: Redacted.make("test"),
|
||||
githubToken: "",
|
||||
logLevel: "info" as const,
|
||||
healthHost: "0.0.0.0",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off" as const,
|
||||
turnRoutingModel: "test",
|
||||
sandboxReusePolicy: "resume_preferred" as const,
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: Minutes.make(5),
|
||||
pausedTtlMinutes: Minutes.make(180),
|
||||
activeHealthCheckTimeoutMs: Milliseconds.make(15000),
|
||||
startupHealthTimeoutMs: Milliseconds.make(120000),
|
||||
resumeHealthTimeoutMs: Milliseconds.make(120000),
|
||||
sandboxCreationTimeout: Seconds.make(180),
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,22 +1,63 @@
|
|||
export type SessionStatus =
|
||||
| "creating"
|
||||
| "active"
|
||||
| "pausing"
|
||||
| "paused"
|
||||
| "resuming"
|
||||
| "destroying"
|
||||
| "destroyed"
|
||||
| "error";
|
||||
import { Schema } from "effect"
|
||||
|
||||
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;
|
||||
export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"))
|
||||
export type ThreadId = typeof ThreadId.Type
|
||||
|
||||
export const ChannelId = Schema.String.pipe(Schema.brand("ChannelId"))
|
||||
export type ChannelId = typeof ChannelId.Type
|
||||
|
||||
export const GuildId = Schema.String.pipe(Schema.brand("GuildId"))
|
||||
export type GuildId = typeof GuildId.Type
|
||||
|
||||
export const SandboxId = Schema.String.pipe(Schema.brand("SandboxId"))
|
||||
export type SandboxId = typeof SandboxId.Type
|
||||
|
||||
export const SessionId = Schema.String.pipe(Schema.brand("SessionId"))
|
||||
export type SessionId = typeof SessionId.Type
|
||||
|
||||
export const SessionStatus = Schema.Literal(
|
||||
"creating",
|
||||
"active",
|
||||
"pausing",
|
||||
"paused",
|
||||
"resuming",
|
||||
"destroying",
|
||||
"destroyed",
|
||||
"error",
|
||||
)
|
||||
export type SessionStatus = typeof SessionStatus.Type
|
||||
|
||||
/**
|
||||
* Daytona preview link — the URL + token from `sandbox.getPreviewLink()`.
|
||||
*
|
||||
* This is Daytona's canonical way to reach a port inside a sandbox over HTTP.
|
||||
* Used by {@link OpenCodeClient} to talk to the OpenCode server on port 4096.
|
||||
*/
|
||||
export class PreviewAccess extends Schema.Class<PreviewAccess>("PreviewAccess")({
|
||||
/** Daytona preview URL (HTTP tunnel into the sandbox). */
|
||||
previewUrl: Schema.String,
|
||||
/** Auth token for the preview link. May be embedded in the URL as `?tkn=`. */
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {
|
||||
/** Derive from anything carrying `previewUrl` + `previewToken` (e.g. SandboxHandle, SessionInfo). */
|
||||
static from(source: { previewUrl: string; previewToken: string | null }) {
|
||||
return PreviewAccess.make({ previewUrl: source.previewUrl, previewToken: source.previewToken })
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionInfo extends Schema.Class<SessionInfo>("SessionInfo")({
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
sandboxId: SandboxId,
|
||||
sessionId: SessionId,
|
||||
previewUrl: Schema.String,
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
status: SessionStatus,
|
||||
lastError: Schema.Union(Schema.Null, Schema.String),
|
||||
resumeFailCount: Schema.Number,
|
||||
}) {
|
||||
withStatus(status: SessionStatus) {
|
||||
return SessionInfo.make({ ...this, status })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"types": ["bun-types", "node"]
|
||||
"types": ["bun"],
|
||||
"plugins": [{ "name": "@effect/language-service" }]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue