Compare commits
1 Commits
v2.0.0-alp
...
v2.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| ce31577d1e |
@@ -5,12 +5,16 @@ RUN corepack enable
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/server/package.json ./apps/server/
|
||||||
COPY apps/coder/package.json ./apps/coder/
|
COPY apps/coder/package.json ./apps/coder/
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY apps/coder ./apps/coder
|
# Build server first (coder depends on it via workspace dep for types + inference)
|
||||||
|
COPY apps/server ./apps/server
|
||||||
|
RUN pnpm -C apps/server build
|
||||||
|
|
||||||
|
COPY apps/coder ./apps/coder
|
||||||
RUN pnpm -C apps/coder build
|
RUN pnpm -C apps/coder build
|
||||||
|
|
||||||
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boocode/server": "workspace:*",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// BooCoder's config is a superset of the server's Config type so it can be
|
||||||
|
// passed directly into the inference runner's InferenceContext. Fields the
|
||||||
|
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
|
||||||
|
// default to values that satisfy the server's Zod schema without BooCoder
|
||||||
|
// needing to supply them in its environment.
|
||||||
const ConfigSchema = z.object({
|
const ConfigSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
PORT: z.coerce.number().int().positive().default(3000),
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
@@ -7,8 +12,17 @@ const ConfigSchema = z.object({
|
|||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
LLAMA_SWAP_URL: z.string().url(),
|
LLAMA_SWAP_URL: z.string().url(),
|
||||||
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||||
|
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||||
|
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||||
|
// Fields needed to satisfy the server's Config type but unused by BooCoder:
|
||||||
|
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
|
||||||
|
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||||
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
|
GITEA_TOKEN: z.string().optional(),
|
||||||
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||||
|
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||||
|
// inference loop, broker, and tool registry without duplication.
|
||||||
|
import { createInferenceRunner } from '@boocode/server/inference';
|
||||||
|
import { createBroker } from '@boocode/server/broker';
|
||||||
|
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||||
|
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||||
|
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||||
|
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||||
|
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||||
|
// Routes
|
||||||
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerPendingRoutes } from './routes/pending.js';
|
||||||
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -28,13 +44,73 @@ async function main() {
|
|||||||
await applySchema(sql);
|
await applySchema(sql);
|
||||||
app.log.info('database schema applied');
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
|
const broker = createBroker(app.log);
|
||||||
|
|
||||||
|
// --- Tool registry extension ---
|
||||||
|
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||||
|
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||||
|
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
||||||
|
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
||||||
|
appendMcpTools(adaptedWriteTools);
|
||||||
|
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
||||||
|
|
||||||
|
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
||||||
|
// the appended write tools) for tool dispatch.
|
||||||
|
const inference = createInferenceRunner(
|
||||||
|
{
|
||||||
|
sql,
|
||||||
|
config: config as unknown as ServerConfig,
|
||||||
|
log: app.log,
|
||||||
|
publish: (sessionId, frame) => {
|
||||||
|
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
},
|
||||||
|
(user, frame) => {
|
||||||
|
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||||
|
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||||
|
// into the enqueue to set context before the run starts.
|
||||||
|
const inferenceApi = {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||||
|
// Set the inference context so write tools can access sql + sessionId.
|
||||||
|
// The context persists for the duration of the inference run. Since
|
||||||
|
// BooCoder is single-user and runs one inference at a time per session,
|
||||||
|
// this module-level state is safe.
|
||||||
|
setInferenceContext({ sql, sessionId, taskId: null });
|
||||||
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
|
},
|
||||||
|
cancel: async (sessionId: string, chatId: string) => {
|
||||||
|
const result = await inference.cancel(sessionId, chatId);
|
||||||
|
clearInferenceContext();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register WebSocket support
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
// Health endpoint
|
// Health endpoint
|
||||||
app.get('/api/health', async (_req, reply) => {
|
app.get('/api/health', async (_req, reply) => {
|
||||||
const dbOk = await pingDb(sql);
|
const dbOk = await pingDb(sql);
|
||||||
const status = dbOk ? 200 : 503;
|
const status = dbOk ? 200 : 503;
|
||||||
return reply.status(status).send({ ok: dbOk, db: dbOk });
|
return reply.status(status).send({
|
||||||
|
ok: dbOk,
|
||||||
|
db: dbOk,
|
||||||
|
tools: ALL_TOOLS.length,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
registerPendingRoutes(app, sql);
|
||||||
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
app.log.info('shutting down');
|
app.log.info('shutting down');
|
||||||
|
|||||||
126
apps/coder/src/routes/messages.ts
Normal file
126
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
|
||||||
|
const SendBody = z.object({
|
||||||
|
content: z.string().min(1).max(64_000),
|
||||||
|
chat_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface InferenceApi {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActive: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMessageRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
inference: InferenceApi,
|
||||||
|
): void {
|
||||||
|
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/messages',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = SendBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const { content, chat_id: chatId } = parsed.data;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const sessionRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate chat belongs to session and is open
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found or not open in this session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if inference is already running on this chat
|
||||||
|
if (inference.hasActive(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'inference already running on this chat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message + streaming assistant row in a transaction
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish user message frames so WS subscribers see it immediately
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
content,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
|
||||||
|
// Enqueue inference — the runner will stream assistant deltas via broker
|
||||||
|
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/stop',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Find active chats in this session
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
let cancelled = false;
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (inference.hasActive(chat.id)) {
|
||||||
|
cancelled = await inference.cancel(sessionId, chat.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancelled };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/coder/src/routes/pending.ts
Normal file
121
apps/coder/src/routes/pending.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import {
|
||||||
|
listPending,
|
||||||
|
applyOne,
|
||||||
|
applyAll,
|
||||||
|
rejectOne,
|
||||||
|
rewindOne,
|
||||||
|
} from '../services/pending_changes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a session's project path.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM sessions s
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = ${sessionId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a pending change's session.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM pending_changes pc
|
||||||
|
JOIN sessions s ON pc.session_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE pc.id = ${changeId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/sessions/:sessionId/pending — list pending changes for a session
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await listPending(sql, sessionId);
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await applyAll(sql, sessionId, projectRoot);
|
||||||
|
return { results };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/apply — apply a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await applyOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/reject — reject a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/reject',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
await rejectOne(sql, changeId);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/rewind — rewind (undo) an applied change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/rewind',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await rewindOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/coder/src/routes/ws.ts
Normal file
51
apps/coder/src/routes/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
|
||||||
|
export function registerWebSocket(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
): void {
|
||||||
|
// Per-session streaming WebSocket. Clients connect here to receive live
|
||||||
|
// inference frames (deltas, tool_calls, tool_results, message_complete).
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/ws/sessions/:sessionId',
|
||||||
|
{ websocket: true },
|
||||||
|
async (socket, req) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
||||||
|
socket.close(1008, 'session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send snapshot of existing messages so client can hydrate
|
||||||
|
const messages = await sql<Record<string, unknown>[]>`
|
||||||
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||||
|
|
||||||
|
// Subscribe to broker for live frames
|
||||||
|
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||||
|
if (socket.readyState !== socket.OPEN) return;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err, sessionId }, 'ws send failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => unsubscribe());
|
||||||
|
socket.on('error', () => unsubscribe());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
|
||||||
|
|
||||||
|
const PROJECT_ROOT = '/opt/projects/my-app';
|
||||||
|
|
||||||
|
describe('resolveWritePath', () => {
|
||||||
|
it('resolves a relative path correctly', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/index.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves nested relative path', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on ../ escape', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on absolute path outside project root', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows absolute path inside project root', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.local', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.production', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.pem files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.key files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_rsa', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_ed25519', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies credentials.json', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a normal file inside project', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a non-existent nested file (no realpath)', () => {
|
||||||
|
// This is the key difference from BooChat's pathGuard: no realpath means
|
||||||
|
// files that don't exist yet still pass validation
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on null/empty path', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ../ within project root and still allows', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path that looks inside root but normalizes outside', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSecretPath', () => {
|
||||||
|
it('detects .env', () => {
|
||||||
|
expect(isSecretPath('.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects nested .env', () => {
|
||||||
|
expect(isSecretPath('config/.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects *.pfx', () => {
|
||||||
|
expect(isSecretPath('certs/client.pfx')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag normal source files', () => {
|
||||||
|
expect(isSecretPath('src/index.ts')).toBe(false);
|
||||||
|
expect(isSecretPath('README.md')).toBe(false);
|
||||||
|
expect(isSecretPath('package.json')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isSecretPath('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
apps/coder/src/services/pending_changes.ts
Normal file
224
apps/coder/src/services/pending_changes.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import { resolveWritePath } from './write_guard.js';
|
||||||
|
|
||||||
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'edit' | 'delete';
|
||||||
|
diff: string;
|
||||||
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
id: string;
|
||||||
|
file_path: string;
|
||||||
|
operation: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function queueEdit(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
oldString: string,
|
||||||
|
newString: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueCreate(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueDelete(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Apply functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function applyOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-validate path in case projectRoot has shifted
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(oldStr)) {
|
||||||
|
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
||||||
|
}
|
||||||
|
const updated = content.replace(oldStr, newStr);
|
||||||
|
await writeFile(change.file_path, updated, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Stash current content in diff for potential rewind
|
||||||
|
try {
|
||||||
|
const existing = await readFile(change.file_path, 'utf8');
|
||||||
|
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||||
|
} catch {
|
||||||
|
// File may already be gone — proceed with status update
|
||||||
|
}
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAll(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult[]> {
|
||||||
|
const pending = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
const results: ApplyResult[] = [];
|
||||||
|
for (const change of pending) {
|
||||||
|
results.push(await applyOne(sql, change.id, projectRoot));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reject functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rewind functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rewindOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
// Reverse a create: delete the file
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
// Reverse an edit: swap old and new
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(newStr)) {
|
||||||
|
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
||||||
|
}
|
||||||
|
const reverted = content.replace(newStr, oldStr);
|
||||||
|
await writeFile(change.file_path, reverted, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Query functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
||||||
|
return sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
30
apps/coder/src/services/tools/adapter.ts
Normal file
30
apps/coder/src/services/tools/adapter.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
|
||||||
|
* interface (which takes `projectRoot, extraRoots?`).
|
||||||
|
*
|
||||||
|
* The adapter reads the module-level inference context at execute time, so the
|
||||||
|
* wrapping happens at boot (static) — no per-inference re-wrap needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { getInferenceContext } from './inference_context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
|
||||||
|
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
|
||||||
|
* builds the ToolContext from the module-level inference context at call time.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
jsonSchema: tool.jsonSchema,
|
||||||
|
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
|
||||||
|
const ctx: ToolContext = getInferenceContext();
|
||||||
|
return tool.execute(input, projectRoot, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { applyAll } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const ApplyPendingInput = z.object({});
|
||||||
|
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
||||||
|
|
||||||
|
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
inputSchema: ApplyPendingInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No pending changes to apply.'
|
||||||
|
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
51
apps/coder/src/services/tools/create_file.ts
Normal file
51
apps/coder/src/services/tools/create_file.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueCreate } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const CreateFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
type CreateFileInputT = z.infer<typeof CreateFileInput>;
|
||||||
|
|
||||||
|
export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: CreateFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
|
||||||
|
content: { type: 'string', description: 'Full content of the file to create' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueCreate(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.content,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'create',
|
||||||
|
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/delete_file.ts
Normal file
48
apps/coder/src/services/tools/delete_file.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueDelete } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const DeleteFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
});
|
||||||
|
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
|
||||||
|
|
||||||
|
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: DeleteFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueDelete(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'delete',
|
||||||
|
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
54
apps/coder/src/services/tools/edit_file.ts
Normal file
54
apps/coder/src/services/tools/edit_file.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueEdit } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const EditFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
old_string: z.string().min(1),
|
||||||
|
new_string: z.string(),
|
||||||
|
});
|
||||||
|
type EditFileInputT = z.infer<typeof EditFileInput>;
|
||||||
|
|
||||||
|
export const editFileTool: ToolDef<EditFileInputT> = {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: EditFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
|
||||||
|
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
|
||||||
|
new_string: { type: 'string', description: 'The replacement string' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'old_string', 'new_string'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueEdit(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.old_string,
|
||||||
|
input.new_string,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'edit',
|
||||||
|
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
26
apps/coder/src/services/tools/index.ts
Normal file
26
apps/coder/src/services/tools/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ToolDef } from './types.js';
|
||||||
|
import { editFileTool } from './edit_file.js';
|
||||||
|
import { createFileTool } from './create_file.js';
|
||||||
|
import { deleteFileTool } from './delete_file.js';
|
||||||
|
import { applyPendingTool } from './apply_pending.js';
|
||||||
|
import { rewindTool } from './rewind.js';
|
||||||
|
|
||||||
|
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||||
|
|
||||||
|
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
|
||||||
|
// with BooChat's read-only tools to form the full tool set available to agents.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||||
|
applyPendingTool,
|
||||||
|
createFileTool,
|
||||||
|
deleteFileTool,
|
||||||
|
editFileTool,
|
||||||
|
rewindTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
|
||||||
|
WRITE_TOOLS.map((t) => [t.name, t]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };
|
||||||
36
apps/coder/src/services/tools/inference_context.ts
Normal file
36
apps/coder/src/services/tools/inference_context.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level inference context for write tools.
|
||||||
|
*
|
||||||
|
* Set via `setInferenceContext()` before each inference run starts.
|
||||||
|
* Write tools read it via `getInferenceContext()` during execute.
|
||||||
|
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||||
|
* ambient state that can't be threaded through the tool-phase execute
|
||||||
|
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InferenceContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: InferenceContext | null = null;
|
||||||
|
|
||||||
|
export function setInferenceContext(ctx: InferenceContext): void {
|
||||||
|
current = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearInferenceContext(): void {
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInferenceContext(): InferenceContext {
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
71
apps/coder/src/services/tools/rewind.ts
Normal file
71
apps/coder/src/services/tools/rewind.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { rewindOne } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const RewindInput = z.object({
|
||||||
|
change_id: z.string().uuid().optional(),
|
||||||
|
all: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
type RewindInputT = z.infer<typeof RewindInput>;
|
||||||
|
|
||||||
|
export const rewindTool: ToolDef<RewindInputT> = {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
inputSchema: RewindInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
|
||||||
|
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
if (input.change_id) {
|
||||||
|
const result = await rewindOne(context.sql, input.change_id, projectRoot);
|
||||||
|
return {
|
||||||
|
results: [result],
|
||||||
|
message: result.success
|
||||||
|
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
|
||||||
|
: `Failed to revert: ${result.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.all) {
|
||||||
|
// Rewind all applied changes for this session in reverse order
|
||||||
|
const applied = await context.sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM pending_changes
|
||||||
|
WHERE session_id = ${context.sessionId} AND status = 'applied'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const results = [];
|
||||||
|
for (const row of applied) {
|
||||||
|
results.push(await rewindOne(context.sql, row.id, projectRoot));
|
||||||
|
}
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed: results.length - succeeded,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No applied changes to revert.'
|
||||||
|
: `Reverted ${succeeded}/${results.length} changes.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'Provide either change_id or all=true.' };
|
||||||
|
},
|
||||||
|
};
|
||||||
32
apps/coder/src/services/tools/types.ts
Normal file
32
apps/coder/src/services/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
export interface ToolJsonSchema {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to BooCoder tool execute functions.
|
||||||
|
*
|
||||||
|
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
|
||||||
|
* interact with the database (pending_changes table) and need session/task
|
||||||
|
* context for proper attribution.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDef<TInput> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: z.ZodType<TInput>;
|
||||||
|
jsonSchema: ToolJsonSchema;
|
||||||
|
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
|
||||||
|
}
|
||||||
73
apps/coder/src/services/write_guard.ts
Normal file
73
apps/coder/src/services/write_guard.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
export class WriteGuardError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'WriteGuardError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny list: files that should never be written regardless of path-guard.
|
||||||
|
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
||||||
|
// Full parity with BooChat's deny list is not needed for write-guard because
|
||||||
|
// the write tools are intentional (model chose to create/edit); we block only
|
||||||
|
// files that are unambiguously secrets.
|
||||||
|
const SECRET_PATTERNS: readonly string[] = [
|
||||||
|
'.env',
|
||||||
|
'.env.local',
|
||||||
|
'.env.production',
|
||||||
|
'.env.development',
|
||||||
|
'.env.staging',
|
||||||
|
'id_rsa',
|
||||||
|
'id_dsa',
|
||||||
|
'id_ecdsa',
|
||||||
|
'id_ed25519',
|
||||||
|
'*.pem',
|
||||||
|
'*.key',
|
||||||
|
'*.p12',
|
||||||
|
'*.pfx',
|
||||||
|
'*.crt',
|
||||||
|
'credentials.json',
|
||||||
|
'*.kdbx',
|
||||||
|
'.netrc',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSecretPath(filePath: string): boolean {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) return false;
|
||||||
|
const basename = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
return SECRET_PATTERNS.some((pattern) => {
|
||||||
|
if (pattern.startsWith('*')) {
|
||||||
|
return basename.endsWith(pattern.slice(1));
|
||||||
|
}
|
||||||
|
return basename === pattern;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and validate a write target path.
|
||||||
|
*
|
||||||
|
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
||||||
|
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
||||||
|
* checks the result stays within projectRoot.
|
||||||
|
*/
|
||||||
|
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||||
|
if (!filePath || filePath.length === 0) {
|
||||||
|
throw new WriteGuardError('file path is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||||
|
const normalized = resolve(candidate); // normalizes ../ segments
|
||||||
|
|
||||||
|
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
||||||
|
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecretPath(normalized)) {
|
||||||
|
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
9
apps/coder/vitest.config.ts
Normal file
9
apps/coder/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,23 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||||
|
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||||
|
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||||
|
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||||
|
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||||
|
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||||
|
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||||
|
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||||
|
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||||
|
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||||
|
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||||
|
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||||
|
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||||
|
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||||
|
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"declaration": false,
|
"declaration": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -48,6 +48,9 @@ importers:
|
|||||||
|
|
||||||
apps/coder:
|
apps/coder:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@boocode/server':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../server
|
||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^7.0.4
|
specifier: ^7.0.4
|
||||||
version: 7.0.4
|
version: 7.0.4
|
||||||
@@ -73,6 +76,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user