- New services/truncate.ts. Tmpfs storage at /tmp/boocode-truncations/ (BOOCODE_TRUNCATION_DIR env var overrides for tests). 12-char base32 opaque ids (~60 bits entropy, "tr_<id>"). Three exports: storeTruncation, readTruncation, truncateIfNeeded (wrap-or-passthrough helper). cleanupTruncations does TTL-pass (7 days) + orphan-reap (parts query on payload->'output'->>'outputPath') in one shot. - Wired four tools through truncateIfNeeded: view_file (raw full file), list_dir (full filtered+secret-filtered entries serialized one-per-line), web_fetch (textRaw pre-slice), codecontext_client (body.result pre-slice). Each returns the existing sliced view plus an optional outputPath field when truncation fires. - New view_truncated_output ToolDef. Resolves opaque id → on-disk content internally; model never sees the truncation dir. Same start_line / end_line slicing semantics as view_file. Registered in ALL_TOOLS (alpha sort places it after view_file automatically) and READ_ONLY_TOOL_NAMES. - cleanupTruncations piggybacks on the v1.13.3 stuck-row sweeper's 60s setInterval. No-op when truncation dir is empty. Not wired (TODO follow-up): grep and find_files. file_ops returns post-cap results to the tool execute path, so the "full content" isn't recoverable without a refactor of fileOps.grep / fileOps.findFiles to expose the uncapped result. web_search is silent-slice (no truncated flag); outside scope. Five sites of seven covered; the remaining two are the only ones needing a file_ops change. Tests: 7 new in truncate.test.ts (roundtrip, unknown id, malformed id, truncateIfNeeded false/true/over-cap/storage-failure paths). 186 total (was 179). cleanupTruncations file-system half implicitly via TTL pass; orphan-reap branch covered by the live container smoke. Smoke verified end-to-end against the live container: - view_file with start_line=1, end_line=3 on CLAUDE.md → tool_result part carried outputPath "tr_cdpn1o04k6ma" + truncated=true. - /tmp/boocode-truncations/tr_cdpn1o04k6ma exists, 15876 bytes, mode 0o600, parent dir mode 0o700. - Follow-up view_truncated_output(id, start_line=50, end_line=55) returned the actual lines 50-55 of CLAUDE.md (the 808notes/BooCode bullets). - ALL_TOOLS count=20 (was 19); alpha sort places view_truncated_output between view_file and watch_changes. Closes a v1.12 catalog row that was scoped but deferred. The v1.13 parts table made outputPath ride on the existing tool_result payload with no schema change beyond the storage helper itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
9.7 KiB
TypeScript
277 lines
9.7 KiB
TypeScript
import Fastify from 'fastify';
|
|
import fastifyStatic from '@fastify/static';
|
|
import fastifyWebsocket from '@fastify/websocket';
|
|
import { existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { loadConfig } from './config.js';
|
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
|
|
|
import { registerProjectRoutes } from './routes/projects.js';
|
|
import { registerSessionRoutes } from './routes/sessions.js';
|
|
import { registerSettingsRoutes } from './routes/settings.js';
|
|
import { registerMessageRoutes } from './routes/messages.js';
|
|
import { registerChatRoutes } from './routes/chats.js';
|
|
import { registerSidebarRoutes } from './routes/sidebar.js';
|
|
import { registerWebSocket } from './routes/ws.js';
|
|
import { registerModelRoutes } from './routes/models.js';
|
|
import { registerAgentRoutes } from './routes/agents.js';
|
|
import { registerSkillsRoutes } from './routes/skills.js';
|
|
import { createInferenceRunner } from './services/inference/index.js';
|
|
import { createBroker } from './services/broker.js';
|
|
import { listSkills } from './services/skills.js';
|
|
import * as compaction from './services/compaction.js';
|
|
import { configureModelContext } from './services/model-context.js';
|
|
import { cleanupTruncations } from './services/truncate.js';
|
|
|
|
async function main() {
|
|
const config = loadConfig();
|
|
|
|
const app = Fastify({
|
|
logger: { level: config.LOG_LEVEL },
|
|
});
|
|
|
|
// Allow empty JSON bodies on POSTs that don't take a body (archive, unarchive, stop, etc.).
|
|
// Default Fastify parser throws FST_ERR_CTP_EMPTY_JSON_BODY on empty string.
|
|
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');
|
|
|
|
const swept = await sql<{ count: string }[]>`
|
|
WITH swept AS (
|
|
UPDATE messages SET status = 'failed'
|
|
WHERE status = 'streaming' AND created_at < NOW() - INTERVAL '5 minutes'
|
|
RETURNING id
|
|
) SELECT count(*)::text AS count FROM swept
|
|
`;
|
|
const sweptCount = Number(swept[0]?.count ?? 0);
|
|
if (sweptCount > 0) {
|
|
app.log.info({ sweptCount }, 'swept stale streaming messages to failed');
|
|
}
|
|
|
|
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
|
|
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
|
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
|
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
|
|
|
await app.register(fastifyWebsocket);
|
|
|
|
app.get('/api/health', async () => {
|
|
const dbOk = await pingDb(sql);
|
|
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
|
|
});
|
|
|
|
const broker = createBroker();
|
|
|
|
registerProjectRoutes(app, sql, config, broker);
|
|
registerSessionRoutes(app, sql, config, broker);
|
|
registerSettingsRoutes(app, sql);
|
|
registerModelRoutes(app, config);
|
|
registerAgentRoutes(app, sql);
|
|
registerSidebarRoutes(app, sql);
|
|
registerChatRoutes(app, sql, broker);
|
|
|
|
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
|
// missing /data/skills is non-fatal — the skill tools just return empty.
|
|
try {
|
|
const skills = await listSkills();
|
|
app.log.info(`skills loaded: ${skills.length}`);
|
|
} catch (err) {
|
|
app.log.warn({ err }, 'skills boot walk failed');
|
|
}
|
|
|
|
const inference = createInferenceRunner(
|
|
{
|
|
sql,
|
|
config,
|
|
log: app.log,
|
|
publish: (sessionId, frame) => {
|
|
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
|
|
},
|
|
// v1.11: broker handle for compaction.process to publish 'compacted'
|
|
// frames on the per-session channel. Inference's regular publish path
|
|
// is bound to (sessionId, InferenceFrame); compaction publishes a
|
|
// different frame shape, so it goes through the raw broker.
|
|
broker,
|
|
},
|
|
(user, frame) => {
|
|
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
|
|
}
|
|
);
|
|
registerMessageRoutes(app, sql, {
|
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
|
},
|
|
// v1.11: synchronous compaction. Awaits the LLM call inside the route's
|
|
// request lifecycle; the new summary row arrives via the WS 'compacted'
|
|
// frame published from inside compaction.process. We let the error
|
|
// bubble up so the route can reply 500 — manual /compact failures
|
|
// should be loud (the user just clicked a button).
|
|
runCompaction: (chatId) =>
|
|
compaction.process({ sql, config, log: app.log, broker, chatId }),
|
|
cancelInference: async (sessionId, chatId) => {
|
|
return inference.cancel(sessionId, chatId);
|
|
},
|
|
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
|
broker.publish(sessionId, {
|
|
type: 'message_started',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
role: 'user',
|
|
});
|
|
broker.publish(sessionId, {
|
|
type: 'delta',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
content,
|
|
});
|
|
broker.publish(sessionId, {
|
|
type: 'message_complete',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
});
|
|
},
|
|
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
|
|
broker.publish(sessionId, {
|
|
type: 'messages_deleted',
|
|
message_ids: messageIds,
|
|
chat_id: chatId,
|
|
});
|
|
},
|
|
publishSessionFrame: (sessionId, frame) => {
|
|
broker.publish(sessionId, frame);
|
|
},
|
|
});
|
|
registerSkillsRoutes(app, sql, {
|
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
|
},
|
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
|
broker.publish(sessionId, {
|
|
type: 'message_started',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
role: 'user',
|
|
});
|
|
broker.publish(sessionId, {
|
|
type: 'delta',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
content,
|
|
});
|
|
broker.publish(sessionId, {
|
|
type: 'message_complete',
|
|
message_id: userMessageId,
|
|
chat_id: chatId,
|
|
});
|
|
},
|
|
publishSessionFrame: (sessionId, frame) => {
|
|
broker.publish(sessionId, frame);
|
|
},
|
|
});
|
|
registerWebSocket(app, sql, broker);
|
|
|
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
|
if (existsSync(webDist)) {
|
|
await app.register(fastifyStatic, {
|
|
root: webDist,
|
|
prefix: '/',
|
|
wildcard: false,
|
|
});
|
|
app.setNotFoundHandler((req, reply) => {
|
|
if (req.url.startsWith('/api')) {
|
|
reply.code(404).send({ error: 'not found' });
|
|
return;
|
|
}
|
|
reply.sendFile('index.html');
|
|
});
|
|
app.log.info(`serving static frontend from ${webDist}`);
|
|
}
|
|
|
|
// v1.13.3: periodic in-process sweeper for streaming rows orphaned by a
|
|
// mid-session crash. The boot sweep (above) only fires once at startup;
|
|
// this loop catches the in-flight case. 60s cadence + 5-min threshold
|
|
// matches the boot sweep so behavior is consistent. Publishes
|
|
// chat_status='idle' on the user channel so the UI dot drops without a
|
|
// refresh — same pattern as handleAbortOrError.
|
|
const SWEEP_INTERVAL_MS = 60_000;
|
|
const sweepStaleStreaming = async (): Promise<void> => {
|
|
try {
|
|
const rows = await sql<{ id: string; chat_id: string }[]>`
|
|
UPDATE messages
|
|
SET status = 'failed', finished_at = clock_timestamp()
|
|
WHERE status = 'streaming'
|
|
AND created_at < NOW() - INTERVAL '5 minutes'
|
|
RETURNING id, chat_id
|
|
`;
|
|
if (rows.length === 0) return;
|
|
app.log.warn(
|
|
{ swept: rows.length, ids: rows.map((r) => r.id) },
|
|
'swept stale streaming rows',
|
|
);
|
|
const seenChats = new Set<string>();
|
|
const now = new Date().toISOString();
|
|
for (const row of rows) {
|
|
if (seenChats.has(row.chat_id)) continue;
|
|
seenChats.add(row.chat_id);
|
|
broker.publishUser('default', {
|
|
type: 'chat_status',
|
|
chat_id: row.chat_id,
|
|
status: 'idle',
|
|
at: now,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
app.log.error({ err }, 'stuck-row sweeper failed');
|
|
}
|
|
};
|
|
// v1.13.5: truncation cleanup rides the same cadence — 60s tick reaps
|
|
// tmpfs files past the 7-day TTL plus any orphans whose owning part has
|
|
// been pruned (v1.13.4) or deleted. No-op when the dir is empty.
|
|
const sweepTimer = setInterval(() => {
|
|
void sweepStaleStreaming();
|
|
void cleanupTruncations({ sql, log: app.log });
|
|
}, SWEEP_INTERVAL_MS);
|
|
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
|
|
|
|
const shutdown = async (signal: string) => {
|
|
app.log.info(`received ${signal}, shutting down`);
|
|
try {
|
|
await app.close();
|
|
await closeDb();
|
|
process.exit(0);
|
|
} catch (err) {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
|
|
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
|
|
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
|
|
// the threat model treats Tailnet membership as the trust boundary.
|
|
await app.listen({ port: config.PORT, host: config.HOST });
|
|
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('Fatal startup error:', err);
|
|
process.exit(1);
|
|
});
|