v2.0.0-alpha: BooCoder foundation — container, schema, DB rename
Phase 1 of v2.0. BooCoder is live at port 9502 with a health endpoint. - Database renamed: ALTER DATABASE boocode RENAME TO boochat (one-time). All services updated to connect to /boochat. Docker service name stays boocode_db (rename is internal to Postgres, not Docker). - New apps/coder/ app skeleton: Fastify server with health endpoint, postgres connection, schema apply on boot. Mirrors apps/server pattern but minimal (no inference loop yet — Phase 2). - Schema: pending_changes (operation queue before /apply), tasks (dispatch DAG with state machine), available_agents (startup-probed agent registry), human_inbox view (tasks WHERE state IN blocked/failed). All IF NOT EXISTS, idempotent on re-run. Same boochat database, different tables. - Dockerfile: Node 20 bookworm-slim (glibc for future node-pty in Phase 5). Multi-stage build matching the existing boocode image pattern. - docker-compose.yml: boocoder service on 100.114.205.53:9502, /opt:/opt:rw mount (write-capable, policy-gated at tool layer), depends on boocode_db. - BOOCODER.md: container guidance declaring write-tool capability + pending-changes discipline. All 4 services boot and pass health checks. 9 tables in the shared DB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
BOOCODER.md
41
BOOCODER.md
@@ -1,27 +1,32 @@
|
|||||||
# BooCoder
|
# BooCoder — Container Guidance
|
||||||
|
|
||||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||||
|
|
||||||
## Capabilities
|
## You can
|
||||||
|
|
||||||
- Everything in `BOOCHAT.md`
|
- Read files (view_file, list_dir, grep, find_files)
|
||||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
|
||||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
- Apply pending changes to disk (apply_pending)
|
||||||
|
- Revert applied changes (rewind)
|
||||||
|
- Dispatch tasks to external agents (dispatch_external_agent)
|
||||||
|
- Use MCP tools from configured servers
|
||||||
|
|
||||||
## Constraints
|
## You cannot
|
||||||
|
|
||||||
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
|
- Write outside the project root (path-guard enforced)
|
||||||
- `run_command` executes inside the session sandbox, not the host
|
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
|
||||||
- No git commits, pushes, or pulls — Sam owns those
|
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
|
||||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
- Push to git remotes
|
||||||
|
- Access the internet except via configured MCP servers
|
||||||
|
|
||||||
|
## Pending changes discipline
|
||||||
|
|
||||||
|
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Show a diff preview before any write
|
- Show diffs clearly. Explain what you're changing and why.
|
||||||
- Group related edits into a single `/apply` batch
|
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||||
- If a tool fails, surface the error verbatim — don't paper over it
|
- If uncertain about scope, use smaller edits and verify between steps.
|
||||||
|
- Cite file paths + line numbers for context.
|
||||||
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
||||||
|
|
||||||
## Convention: rules vs recipes
|
|
||||||
|
|
||||||
Always-true rules live here, in `BOOCHAT.md`, and in `CLAUDE.md` (100% present each turn). On-demand recipes live in `/data/skills/` (roughly 6% invoke rate in multi-turn per Codeminer42, 2026). Don't file workflow rules as skills — they misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
|
|
||||||
|
|||||||
28
apps/coder/Dockerfile
Normal file
28
apps/coder/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/coder/package.json ./apps/coder/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/coder ./apps/coder
|
||||||
|
|
||||||
|
RUN pnpm -C apps/coder build
|
||||||
|
|
||||||
|
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /out/coder ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
25
apps/coder/package.json
Normal file
25
apps/coder/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/coder",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/coder/src/config.ts
Normal file
28
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
LLAMA_SWAP_URL: z.string().url(),
|
||||||
|
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||||
|
LOG_LEVEL: z.string().default('info'),
|
||||||
|
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
if (cached) return cached;
|
||||||
|
const parsed = ConfigSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment configuration:');
|
||||||
|
console.error(parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
cached = parsed.data;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
45
apps/coder/src/db.ts
Normal file
45
apps/coder/src/db.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
export type Sql = ReturnType<typeof postgres>;
|
||||||
|
|
||||||
|
let sqlInstance: Sql | null = null;
|
||||||
|
|
||||||
|
export function getSql(config: Config): Sql {
|
||||||
|
if (sqlInstance) return sqlInstance;
|
||||||
|
sqlInstance = postgres(config.DATABASE_URL, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
onnotice: () => {},
|
||||||
|
});
|
||||||
|
return sqlInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySchema(sql: Sql): Promise<void> {
|
||||||
|
const schemaPath = resolve(__dirname, 'schema.sql');
|
||||||
|
const ddl = await readFile(schemaPath, 'utf8');
|
||||||
|
await sql.unsafe(ddl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingDb(sql: Sql): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (sqlInstance) {
|
||||||
|
await sqlInstance.end({ timeout: 5 });
|
||||||
|
sqlInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/coder/src/index.ts
Normal file
55
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.LOG_LEVEL },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow empty JSON bodies (same pattern as apps/server).
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) {
|
||||||
|
done(null, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = getSql(config);
|
||||||
|
await applySchema(sql);
|
||||||
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
// Health endpoint
|
||||||
|
app.get('/api/health', async (_req, reply) => {
|
||||||
|
const dbOk = await pingDb(sql);
|
||||||
|
const status = dbOk ? 200 : 503;
|
||||||
|
return reply.status(status).send({ ok: dbOk, db: dbOk });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
app.log.info('shutting down');
|
||||||
|
await app.close();
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
48
apps/coder/src/schema.sql
Normal file
48
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
|
||||||
|
-- Applied on startup by apps/coder/src/db.ts:applySchema().
|
||||||
|
-- Lives in the same 'boochat' database as BooChat's tables.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL,
|
||||||
|
task_id UUID,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
|
||||||
|
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
execution_path TEXT,
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||||
|
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS available_agents (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
install_path TEXT,
|
||||||
|
version TEXT,
|
||||||
|
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_probed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Human inbox: tasks needing attention
|
||||||
|
CREATE OR REPLACE VIEW human_inbox AS
|
||||||
|
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||||
15
apps/coder/tsconfig.json
Normal file
15
apps/coder/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CODECONTEXT_URL: http://codecontext:8080
|
CODECONTEXT_URL: http://codecontext:8080
|
||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt:rw
|
- /opt:/opt:rw
|
||||||
- /home/samkintop:/home/samkintop:rw
|
- /home/samkintop:/home/samkintop:rw
|
||||||
@@ -50,6 +50,28 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- boocode_net
|
||||||
|
|
||||||
|
boocoder:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/coder/Dockerfile
|
||||||
|
container_name: boocoder
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "100.114.205.53:9502:3000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
|
||||||
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
|
volumes:
|
||||||
|
- /opt:/opt:rw
|
||||||
|
- /opt/projects:/opt/projects:rw
|
||||||
|
- ./data:/data
|
||||||
|
- /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
|
||||||
|
depends_on:
|
||||||
|
- boocode_db
|
||||||
|
networks:
|
||||||
|
- boocode_net
|
||||||
|
|
||||||
boocode_db:
|
boocode_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: boocode_db
|
container_name: boocode_db
|
||||||
@@ -57,7 +79,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: boocode
|
POSTGRES_USER: boocode
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: boocode
|
POSTGRES_DB: boochat
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5500:5432"
|
- "127.0.0.1:5500:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -46,6 +46,34 @@ importers:
|
|||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/coder:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/static':
|
||||||
|
specifier: ^7.0.4
|
||||||
|
version: 7.0.4
|
||||||
|
'@fastify/websocket':
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1
|
||||||
|
fastify:
|
||||||
|
specifier: ^4.28.1
|
||||||
|
version: 4.29.1
|
||||||
|
postgres:
|
||||||
|
specifier: ^3.4.4
|
||||||
|
version: 3.4.9
|
||||||
|
zod:
|
||||||
|
specifier: ^3.23.8
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.14.10
|
||||||
|
version: 20.19.41
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.16.2
|
||||||
|
version: 4.22.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/openai-compatible':
|
'@ai-sdk/openai-compatible':
|
||||||
|
|||||||
Reference in New Issue
Block a user