Compare commits
5 Commits
v1.13.8-to
...
v1.13.13-w
| Author | SHA1 | Date | |
|---|---|---|---|
| bc376c878d | |||
| 8b568b36d3 | |||
| 34cbecf975 | |||
| 5a3f357ce9 | |||
| fc11e8dc91 |
@@ -10,3 +10,12 @@ POSTGRES_PASSWORD=CHANGE_ME
|
||||
# Internal Tailscale address that bypasses Authelia. Override if you
|
||||
# point BooCode at a different SearXNG instance.
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
|
||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||
# sessions where the model only needs read-only filesystem access.
|
||||
#
|
||||
# core → view_file, list_dir, grep, find_files (~2k)
|
||||
# standard → core + web_*, git_status, all 8 codecontext_* tools (~10k)
|
||||
# all → every tool in ALL_TOOLS (~21k)
|
||||
# BOOCODE_TOOLS=all
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
CLAUDE.local.md
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# BooChat
|
||||
|
||||
You are the assistant running inside BooChat — a self-hosted developer chat app.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Read-only file tools: `view_file`, `list_dir`, `grep`, `find_files`
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
||||
|
||||
You are the assistant running inside BooCoder — the write-capable companion to BooChat.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Everything in `BOOCHAT.md`
|
||||
|
||||
@@ -112,11 +112,12 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
|
||||
|
||||
## Environment
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context).
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist).
|
||||
|
||||
## Workflow
|
||||
|
||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
||||
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
|
||||
@@ -75,7 +75,7 @@ async function main() {
|
||||
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
|
||||
});
|
||||
|
||||
const broker = createBroker();
|
||||
const broker = createBroker(app.log);
|
||||
|
||||
registerProjectRoutes(app, sql, config, broker);
|
||||
registerSessionRoutes(app, sql, config, broker);
|
||||
@@ -101,7 +101,9 @@ async function main() {
|
||||
config,
|
||||
log: app.log,
|
||||
publish: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
|
||||
// v1.13.11-b: route through the typed publishFrame so the broker's
|
||||
// Zod gate validates every inference frame before delivery.
|
||||
broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||
// frames on the per-session channel. Inference's regular publish path
|
||||
@@ -110,7 +112,7 @@ async function main() {
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
|
||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
}
|
||||
);
|
||||
registerMessageRoutes(app, sql, {
|
||||
@@ -129,33 +131,33 @@ async function main() {
|
||||
},
|
||||
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'messages_deleted',
|
||||
message_ids: messageIds,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
});
|
||||
registerSkillsRoutes(app, sql, {
|
||||
@@ -163,26 +165,26 @@ async function main() {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
@@ -230,7 +232,7 @@ async function main() {
|
||||
for (const row of rows) {
|
||||
if (seenChats.has(row.chat_id)) continue;
|
||||
seenChats.add(row.chat_id);
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: row.chat_id,
|
||||
status: 'idle',
|
||||
|
||||
@@ -102,7 +102,7 @@ export function registerChatRoutes(
|
||||
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
|
||||
RETURNING id, session_id, name, status, created_at, updated_at
|
||||
`;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_created',
|
||||
chat: chat!,
|
||||
session_id: req.params.id,
|
||||
@@ -132,7 +132,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_updated',
|
||||
chat_id: chat.id,
|
||||
session_id: chat.session_id,
|
||||
@@ -162,7 +162,7 @@ export function registerChatRoutes(
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_archived',
|
||||
chat_id: id,
|
||||
session_id: req.params.id,
|
||||
@@ -203,7 +203,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found or already archived' };
|
||||
}
|
||||
const row = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_archived',
|
||||
chat_id: row.id,
|
||||
session_id: row.session_id,
|
||||
@@ -226,7 +226,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found or not archived' };
|
||||
}
|
||||
const chat = rows[0]!;
|
||||
broker.publishUser('default', { type: 'chat_unarchived', chat });
|
||||
broker.publishUserFrame('default', { type: 'chat_unarchived', chat });
|
||||
return chat;
|
||||
}
|
||||
);
|
||||
@@ -243,7 +243,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const row = result[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_deleted',
|
||||
chat_id: row.id,
|
||||
session_id: row.session_id,
|
||||
@@ -338,7 +338,7 @@ export function registerChatRoutes(
|
||||
return chat!;
|
||||
});
|
||||
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_created',
|
||||
chat: newChat,
|
||||
session_id: source.session_id,
|
||||
@@ -400,13 +400,13 @@ export function registerChatRoutes(
|
||||
reply.code(409);
|
||||
return { error: 'message status changed mid-request' };
|
||||
}
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: msg.chat_id,
|
||||
status: 'idle',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
broker.publish(msg.session_id, {
|
||||
broker.publishFrame(msg.session_id, {
|
||||
type: 'message_complete',
|
||||
message_id: msg.id,
|
||||
chat_id: msg.chat_id,
|
||||
|
||||
@@ -129,7 +129,7 @@ export function registerProjectRoutes(
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
|
||||
reply.code(201);
|
||||
return {
|
||||
project: row,
|
||||
@@ -186,11 +186,11 @@ export function registerProjectRoutes(
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
|
||||
reply.code(201);
|
||||
} else {
|
||||
// existing.status was 'archived' — row has been restored.
|
||||
broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_unarchived', project: row as unknown as Project });
|
||||
reply.code(200);
|
||||
}
|
||||
return row;
|
||||
@@ -243,7 +243,7 @@ export function registerProjectRoutes(
|
||||
// v1.9: the project_updated frame still only carries id + name. Clients
|
||||
// that need the new fields refetch via api.projects.list() — keeps the
|
||||
// frame payload lean, per the locked recon decision (d).
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'project_updated',
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
@@ -260,7 +260,7 @@ export function registerProjectRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'not found or already archived' };
|
||||
}
|
||||
broker.publishUser('default', { type: 'project_archived', project_id: req.params.id });
|
||||
broker.publishUserFrame('default', { type: 'project_archived', project_id: req.params.id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
@@ -277,7 +277,7 @@ export function registerProjectRoutes(
|
||||
return { error: 'not found or not archived' };
|
||||
}
|
||||
const project = rows[0]!;
|
||||
broker.publishUser('default', { type: 'project_unarchived', project });
|
||||
broker.publishUserFrame('default', { type: 'project_unarchived', project });
|
||||
return project;
|
||||
});
|
||||
|
||||
@@ -288,7 +288,7 @@ export function registerProjectRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
broker.publishUser('default', { type: 'project_deleted', project_id: id });
|
||||
broker.publishUserFrame('default', { type: 'project_deleted', project_id: id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export function registerSessionRoutes(
|
||||
`;
|
||||
return session!;
|
||||
});
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_created',
|
||||
session: row,
|
||||
project_id: row.project_id,
|
||||
@@ -178,7 +178,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const session = rows[0]!;
|
||||
if (name !== undefined && session.name !== priorName) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_renamed',
|
||||
session_id: session.id,
|
||||
name: session.name,
|
||||
@@ -188,7 +188,7 @@ export function registerSessionRoutes(
|
||||
// (notably the SettingsPane open in another tab) can refetch and pick
|
||||
// up the new fields. Frame stays lean (decision d) — payload is just
|
||||
// ids + name + updated_at, the client refetches via api.sessions.get.
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_updated',
|
||||
session_id: session.id,
|
||||
project_id: session.project_id,
|
||||
@@ -220,7 +220,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const session = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_workspace_updated',
|
||||
session_id: session.id,
|
||||
workspace_panes: session.workspace_panes,
|
||||
@@ -248,7 +248,7 @@ export function registerSessionRoutes(
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_archived',
|
||||
session_id: id,
|
||||
project_id: req.params.id,
|
||||
@@ -289,7 +289,7 @@ export function registerSessionRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'session not found or already archived' };
|
||||
}
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_archived',
|
||||
session_id: rows[0]!.id,
|
||||
project_id: rows[0]!.project_id,
|
||||
@@ -312,7 +312,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'session not found or not archived' };
|
||||
}
|
||||
const session = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_created',
|
||||
session: session,
|
||||
project_id: session.project_id,
|
||||
@@ -334,7 +334,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'not found' };
|
||||
}
|
||||
const project_id = deleted[0]!.project_id;
|
||||
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
|
||||
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ALL_TOOLS } from '../tools.js';
|
||||
import {
|
||||
ALL_TOOLS,
|
||||
CORE_TOOL_NAMES,
|
||||
STANDARD_TOOL_NAMES,
|
||||
TOOLS_BY_NAME,
|
||||
resolveToolTier,
|
||||
} from '../tools.js';
|
||||
|
||||
describe('ALL_TOOLS registry', () => {
|
||||
// v1.13.3: tools must be alpha-sorted at module load. llama.cpp's prompt
|
||||
@@ -12,3 +18,59 @@ describe('ALL_TOOLS registry', () => {
|
||||
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolTier (v1.13.15-tools)', () => {
|
||||
it('returns CORE tools for tier=core', () => {
|
||||
expect(resolveToolTier('core')).toEqual(CORE_TOOL_NAMES);
|
||||
});
|
||||
|
||||
it('returns STANDARD tools for tier=standard', () => {
|
||||
const result = resolveToolTier('standard');
|
||||
expect(result.length).toBe(STANDARD_TOOL_NAMES.length);
|
||||
expect(result.length).toBeGreaterThan(CORE_TOOL_NAMES.length);
|
||||
// STANDARD is a strict superset of CORE.
|
||||
expect(result).toEqual(expect.arrayContaining([...CORE_TOOL_NAMES]));
|
||||
});
|
||||
|
||||
it('returns ALL tool names for tier=all', () => {
|
||||
expect(resolveToolTier('all').length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
|
||||
it('defaults to all when env var is undefined', () => {
|
||||
expect(resolveToolTier(undefined).length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(resolveToolTier('CORE')).toEqual(CORE_TOOL_NAMES);
|
||||
expect(resolveToolTier('Standard').length).toBe(STANDARD_TOOL_NAMES.length);
|
||||
});
|
||||
|
||||
it('falls back to all for unknown tier strings', () => {
|
||||
expect(resolveToolTier('bogus').length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORE_TOOL_NAMES + STANDARD_TOOL_NAMES validation', () => {
|
||||
// The module-load validation in tools.ts throws if a tier references a
|
||||
// tool that doesn't exist in TOOLS_BY_NAME. These tests double-check that
|
||||
// invariant from the consumer side so a future tier-list edit can't smuggle
|
||||
// in a typo without a test failure.
|
||||
it('every CORE name exists in TOOLS_BY_NAME', () => {
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
expect(TOOLS_BY_NAME[name], `CORE references unknown tool '${name}'`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('every STANDARD name exists in TOOLS_BY_NAME', () => {
|
||||
for (const name of STANDARD_TOOL_NAMES) {
|
||||
expect(TOOLS_BY_NAME[name], `STANDARD references unknown tool '${name}'`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('CORE is a subset of STANDARD', () => {
|
||||
const standardSet = new Set<string>(STANDARD_TOOL_NAMES);
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
expect(standardSet.has(name), `'${name}' is in CORE but not STANDARD`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
218
apps/server/src/services/__tests__/ws-frames.test.ts
Normal file
218
apps/server/src/services/__tests__/ws-frames.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
WsFrameSchema,
|
||||
KNOWN_FRAME_TYPES,
|
||||
type WsFrame,
|
||||
} from '../../types/ws-frames.js';
|
||||
import { createBroker } from '../broker.js';
|
||||
|
||||
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
|
||||
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
|
||||
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
|
||||
|
||||
describe('WsFrameSchema (v1.13.11-a)', () => {
|
||||
it('accepts a well-formed chat_status frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'streaming',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown frame type', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'cosmic_ray_strike',
|
||||
chat_id: VALID_UUID_A,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a chat_status frame with invalid status enum', () => {
|
||||
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
|
||||
// should fail validation — that's a drift catcher.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'working',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a UUID field with a non-UUID string', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: 'not-a-uuid',
|
||||
status: 'idle',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative token counts in usage frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: -1,
|
||||
ctx_used: 100,
|
||||
ctx_max: 1000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
|
||||
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'tool_result',
|
||||
tool_message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
tool_call_id: 'call_abc123',
|
||||
output: { whatever: true },
|
||||
truncated: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a compacted frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'compacted',
|
||||
session_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
summary_message_id: VALID_UUID_C,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a session_workspace_updated frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'session_workspace_updated',
|
||||
session_id: VALID_UUID_A,
|
||||
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
|
||||
// Probe each known type by attempting a minimal valid construction.
|
||||
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
|
||||
for (const type of KNOWN_FRAME_TYPES) {
|
||||
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
|
||||
// We expect FAILURE on every type because we're missing required fields,
|
||||
// but the failure must be ABOUT the missing fields, not about an unknown
|
||||
// type. A "Invalid discriminator value" error means the type isn't in
|
||||
// the union — that's a drift.
|
||||
if (probe.success) continue;
|
||||
const issues = probe.error.issues;
|
||||
const hasInvalidDiscriminator = issues.some(
|
||||
(i) => i.code === 'invalid_union_discriminator',
|
||||
);
|
||||
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ws-frames.ts file mirror parity', () => {
|
||||
it('apps/server and apps/web copies are byte-identical', () => {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const serverPath = resolve(here, '../../../types/ws-frames.ts');
|
||||
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
|
||||
const serverContent = readFileSync(serverPath, 'utf8');
|
||||
const webContent = readFileSync(webPath, 'utf8');
|
||||
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
|
||||
let logErrors: Array<{ obj: unknown; msg: string }>;
|
||||
let mockLog: Parameters<typeof createBroker>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
logErrors = [];
|
||||
mockLog = {
|
||||
error: (obj: unknown, msg: string) => {
|
||||
logErrors.push({ obj, msg });
|
||||
},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
trace: () => {},
|
||||
fatal: () => {},
|
||||
child: () => mockLog as never,
|
||||
level: 'info',
|
||||
silent: () => {},
|
||||
} as unknown as Parameters<typeof createBroker>[0];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('publishFrame delivers a valid frame to subscribers', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
||||
broker.publishFrame('sess-1', {
|
||||
type: 'delta',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
content: 'hello',
|
||||
});
|
||||
expect(received).toHaveLength(1);
|
||||
expect((received[0] as { type: string }).type).toBe('delta');
|
||||
expect(logErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishFrame drops + logs an invalid frame instead of delivering it', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
||||
broker.publishFrame('sess-1', {
|
||||
type: 'delta',
|
||||
message_id: 'not-a-uuid',
|
||||
content: 'hello',
|
||||
} as never);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(1);
|
||||
expect(logErrors[0]!.msg).toMatch(/ws-frame-validation-failed/);
|
||||
});
|
||||
|
||||
it('publishUserFrame drops + logs an invalid user-channel frame', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribeUser('default', (f) => received.push(f as WsFrame));
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'working', // v1.12.1 dropped this enum value
|
||||
at: VALID_TIMESTAMP,
|
||||
} as never);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishFrame validation failure does not throw (no cascade into stream-phase)', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
expect(() =>
|
||||
broker.publishFrame('sess-1', { type: 'unknown_type' } as never),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||
import { ALL_TOOLS } from './tools.js';
|
||||
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
||||
|
||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||
@@ -186,11 +186,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
throw new Error(fmErrors.join('; '));
|
||||
}
|
||||
|
||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||
const filteredTools = Array.isArray(fm.tools)
|
||||
? fm.tools.filter((t): t is string =>
|
||||
(ALL_TOOL_NAMES as readonly string[]).includes(t),
|
||||
(ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t),
|
||||
)
|
||||
: DEFAULT_TOOLS;
|
||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||
|
||||
return {
|
||||
id: slugify(section.name),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js';
|
||||
|
||||
export type Frame = Record<string, unknown> & { type: string };
|
||||
export type Listener = (frame: Frame) => void;
|
||||
|
||||
@@ -6,9 +9,15 @@ export interface Broker {
|
||||
subscribe(sessionId: string, listener: Listener): () => void;
|
||||
publishUser(user: string, frame: Frame): void;
|
||||
subscribeUser(user: string, listener: Listener): () => void;
|
||||
// v1.13.11-a: typed publish wrappers. Validate against WsFrameSchema and
|
||||
// delegate to publish / publishUser on success; log + drop on failure
|
||||
// (fail-closed). Existing publish / publishUser callers stay legal — they
|
||||
// get converted to the typed variant in v1.13.11-b.
|
||||
publishFrame(sessionId: string, frame: WsFrame): void;
|
||||
publishUserFrame(user: string, frame: WsFrame): void;
|
||||
}
|
||||
|
||||
export function createBroker(): Broker {
|
||||
export function createBroker(log?: FastifyBaseLogger): Broker {
|
||||
const topics = new Map<string, Set<Listener>>();
|
||||
const userTopics = new Map<string, Set<Listener>>();
|
||||
|
||||
@@ -39,6 +48,28 @@ export function createBroker(): Broker {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.13.11-a: shared validation guard. Returns the parsed/typed frame on
|
||||
// success, or null on failure (after logging). Brief mandates fail-closed
|
||||
// semantics: invalid frames don't reach subscribers; throwing here could
|
||||
// cascade into stream-phase aborts which v1.13.7 already had to defend
|
||||
// against, so log + drop is the right shape.
|
||||
function validate(channel: 'session' | 'user', key: string, frame: WsFrame): WsFrame | null {
|
||||
const parsed = WsFrameSchema.safeParse(frame);
|
||||
if (parsed.success) return parsed.data;
|
||||
const frameType = (frame as { type?: unknown })?.type;
|
||||
const errors = parsed.error.flatten();
|
||||
if (log) {
|
||||
log.error(
|
||||
{ channel, key, frame_type: frameType, errors },
|
||||
'ws-frame-validation-failed: dropping invalid frame',
|
||||
);
|
||||
} else {
|
||||
// Fallback for callers that didn't pass a logger (e.g. unit tests).
|
||||
console.error('ws-frame-validation-failed', { channel, key, frame_type: frameType, errors });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
publish(sessionId, frame) {
|
||||
publishTo(topics, sessionId, frame);
|
||||
@@ -52,5 +83,15 @@ export function createBroker(): Broker {
|
||||
subscribeUser(user, listener) {
|
||||
return subscribeTo(userTopics, user, listener);
|
||||
},
|
||||
publishFrame(sessionId, frame) {
|
||||
const valid = validate('session', sessionId, frame);
|
||||
if (!valid) return;
|
||||
publishTo(topics, sessionId, valid as Frame);
|
||||
},
|
||||
publishUserFrame(user, frame) {
|
||||
const valid = validate('user', user, frame);
|
||||
if (!valid) return;
|
||||
publishTo(userTopics, user, valid as Frame);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -431,15 +431,16 @@ export async function process(input: ProcessInput): Promise<void> {
|
||||
'compaction: invoking model',
|
||||
);
|
||||
|
||||
// 6a. Flip the chat dot amber for the duration of the LLM call + DB writes.
|
||||
// Same { type: 'chat_status', status: 'working', at } shape inference.ts
|
||||
// emits at runner enqueue. publishUser → broadcasts on the per-user channel
|
||||
// (all devices / tabs see it) since chat_status is a user-channel frame in
|
||||
// BooCode (see useChatStatus.ts, which is the consumer).
|
||||
broker.publishUser('default', {
|
||||
// 6a. Flip the chat dot for the duration of the LLM call + DB writes.
|
||||
// v1.13.11-b: publish status='streaming' (the v1.12.1-widened replacement
|
||||
// for the dropped 'working' value). Compaction's LLM call has the same
|
||||
// semantic as an inference turn for dot-state purposes. The v1.12.1
|
||||
// chat_status widening missed this site; v1.13.11's WsFrame Zod schema
|
||||
// surfaced the drift via the unknown-enum-value check.
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: chatId,
|
||||
status: 'working',
|
||||
status: 'streaming',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -508,7 +509,7 @@ export async function process(input: ProcessInput): Promise<void> {
|
||||
// Always restore the dot. Status='idle' (not 'error') even on failure —
|
||||
// the caller logs/re-surfaces the error separately; the dot doesn't
|
||||
// need to stay red across reloads for a transient compaction blip.
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: chatId,
|
||||
status: 'idle',
|
||||
@@ -522,7 +523,7 @@ export async function process(input: ProcessInput): Promise<void> {
|
||||
// toast. Order matters: idle must precede 'compacted' so the dot is
|
||||
// already green by the time the refetch toast appears.
|
||||
if (succeeded) {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'compacted',
|
||||
session_id: sessionId,
|
||||
chat_id: chatId,
|
||||
|
||||
@@ -700,6 +700,64 @@ export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntrie
|
||||
ALL_TOOLS.map((t) => [t.name, t])
|
||||
);
|
||||
|
||||
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
||||
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
||||
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
||||
// any prompt-cache stability win (fewer tools = shorter, more stable tool
|
||||
// schemas in the system prompt). Pattern lift from eyaltoledano/claude-task-
|
||||
// master (MIT + Commons Clause — pattern only, no code lift).
|
||||
//
|
||||
// The env var is a CEILING. It only narrows; never expands an agent's
|
||||
// declared whitelist. Default behavior (var unset) is unchanged: all tools.
|
||||
export const CORE_TOOL_NAMES = [
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
] as const;
|
||||
|
||||
export const STANDARD_TOOL_NAMES = [
|
||||
...CORE_TOOL_NAMES,
|
||||
'web_search',
|
||||
'web_fetch',
|
||||
'git_status',
|
||||
'get_codebase_overview',
|
||||
'get_file_analysis',
|
||||
'get_symbol_info',
|
||||
'search_symbols',
|
||||
'get_dependencies',
|
||||
'watch_changes',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_framework_analysis',
|
||||
] as const;
|
||||
|
||||
// Module-load validation: every name in CORE / STANDARD must exist in
|
||||
// TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach
|
||||
// production; server boot fails loudly rather than silently filtering valid
|
||||
// tools out of agent whitelists.
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
if (!TOOLS_BY_NAME[name]) {
|
||||
throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`);
|
||||
}
|
||||
}
|
||||
for (const name of STANDARD_TOOL_NAMES) {
|
||||
if (!TOOLS_BY_NAME[name]) {
|
||||
throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveToolTier(tier: string | undefined): readonly string[] {
|
||||
switch ((tier ?? 'all').toLowerCase()) {
|
||||
case 'core':
|
||||
return CORE_TOOL_NAMES;
|
||||
case 'standard':
|
||||
return STANDARD_TOOL_NAMES;
|
||||
case 'all':
|
||||
default:
|
||||
return ALL_TOOLS.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
export function toolJsonSchemas(): ToolJsonSchema[] {
|
||||
return ALL_TOOLS.map((t) => t.jsonSchema);
|
||||
}
|
||||
|
||||
318
apps/server/src/types/ws-frames.ts
Normal file
318
apps/server/src/types/ws-frames.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
||||
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
||||
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
||||
// silent protocol drift between publisher and consumer.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
||||
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
||||
// files match. If you change one, change the other.
|
||||
//
|
||||
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
||||
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
||||
// deep payload validation is follow-up work.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- shared primitives -----------------------------------------------------
|
||||
|
||||
const Uuid = z.string().uuid();
|
||||
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
||||
const ToolCallId = z.string().min(1);
|
||||
const IsoTimestamp = z.string().min(1);
|
||||
|
||||
const ChatStatusValue = z.enum([
|
||||
'streaming',
|
||||
'tool_running',
|
||||
'waiting_for_input',
|
||||
'idle',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
'doom_loop_summary_failed',
|
||||
'cap_hit',
|
||||
'cap_hit_summary_failed',
|
||||
]);
|
||||
|
||||
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
||||
|
||||
const ToolCallShape = z.object({
|
||||
id: ToolCallId,
|
||||
name: z.string().min(1),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
// Free-form bags: opaque to the frame schema; deep validation is out of
|
||||
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
||||
// payload narrowing is follow-up work). z.unknown() means the consumer
|
||||
// must narrow before reading — TypeScript-side this is fine because every
|
||||
// consumer already operates on the hand-maintained Project / Chat / Session
|
||||
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
||||
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
||||
const OpaqueObject = z.unknown();
|
||||
|
||||
// ---- per-session channel frames --------------------------------------------
|
||||
|
||||
export const SnapshotFrame = z.object({
|
||||
type: z.literal('snapshot'),
|
||||
messages: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const MessageStartedFrame = z.object({
|
||||
type: z.literal('message_started'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
role: MessageRoleValue,
|
||||
});
|
||||
|
||||
export const DeltaFrame = z.object({
|
||||
type: z.literal('delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallFrame = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call: ToolCallShape,
|
||||
});
|
||||
|
||||
export const ToolResultFrame = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call_id: ToolCallId,
|
||||
output: z.unknown(),
|
||||
truncated: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MessageCompleteFrame = z.object({
|
||||
type: z.literal('message_complete'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
model: z.string().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UsageFrame = z.object({
|
||||
type: z.literal('usage'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
completion_tokens: z.number().int().nonnegative().nullable(),
|
||||
ctx_used: z.number().int().nonnegative().nullable(),
|
||||
ctx_max: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const MessagesDeletedFrame = z.object({
|
||||
type: z.literal('messages_deleted'),
|
||||
message_ids: z.array(Uuid),
|
||||
chat_id: Uuid.optional(),
|
||||
});
|
||||
|
||||
export const ChatRenamedFrame = z.object({
|
||||
type: z.literal('chat_renamed'),
|
||||
chat_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const CompactedFrame = z.object({
|
||||
type: z.literal('compacted'),
|
||||
session_id: Uuid,
|
||||
chat_id: Uuid,
|
||||
summary_message_id: Uuid,
|
||||
});
|
||||
|
||||
export const ErrorFrame = z.object({
|
||||
type: z.literal('error'),
|
||||
message_id: Uuid.optional(),
|
||||
chat_id: Uuid.optional(),
|
||||
error: z.string(),
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
||||
|
||||
export const ChatStatusFrame = z.object({
|
||||
type: z.literal('chat_status'),
|
||||
chat_id: Uuid,
|
||||
status: ChatStatusValue,
|
||||
at: IsoTimestamp,
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
export const SessionUpdatedFrame = z.object({
|
||||
type: z.literal('session_updated'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const SessionRenamedFrame = z.object({
|
||||
type: z.literal('session_renamed'),
|
||||
session_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SessionCreatedFrame = z.object({
|
||||
type: z.literal('session_created'),
|
||||
session: OpaqueObject,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionArchivedFrame = z.object({
|
||||
type: z.literal('session_archived'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionDeletedFrame = z.object({
|
||||
type: z.literal('session_deleted'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
workspace_panes: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
type: z.literal('chat_created'),
|
||||
chat: OpaqueObject,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUpdatedFrame = z.object({
|
||||
type: z.literal('chat_updated'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
name: z.string().nullable(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const ChatArchivedFrame = z.object({
|
||||
type: z.literal('chat_archived'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUnarchivedFrame = z.object({
|
||||
type: z.literal('chat_unarchived'),
|
||||
chat: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ChatDeletedFrame = z.object({
|
||||
type: z.literal('chat_deleted'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectCreatedFrame = z.object({
|
||||
type: z.literal('project_created'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectArchivedFrame = z.object({
|
||||
type: z.literal('project_archived'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectUnarchivedFrame = z.object({
|
||||
type: z.literal('project_unarchived'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectUpdatedFrame = z.object({
|
||||
type: z.literal('project_updated'),
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectDeletedFrame = z.object({
|
||||
type: z.literal('project_deleted'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
// per-session
|
||||
SnapshotFrame,
|
||||
MessageStartedFrame,
|
||||
DeltaFrame,
|
||||
ToolCallFrame,
|
||||
ToolResultFrame,
|
||||
MessageCompleteFrame,
|
||||
UsageFrame,
|
||||
MessagesDeletedFrame,
|
||||
ChatRenamedFrame,
|
||||
CompactedFrame,
|
||||
ErrorFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
SessionRenamedFrame,
|
||||
SessionCreatedFrame,
|
||||
SessionArchivedFrame,
|
||||
SessionDeletedFrame,
|
||||
SessionWorkspaceUpdatedFrame,
|
||||
ChatCreatedFrame,
|
||||
ChatUpdatedFrame,
|
||||
ChatArchivedFrame,
|
||||
ChatUnarchivedFrame,
|
||||
ChatDeletedFrame,
|
||||
ProjectCreatedFrame,
|
||||
ProjectArchivedFrame,
|
||||
ProjectUnarchivedFrame,
|
||||
ProjectUpdatedFrame,
|
||||
ProjectDeletedFrame,
|
||||
]);
|
||||
|
||||
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
||||
|
||||
// Convenience: the set of known frame types. Useful for the publishFrame
|
||||
// helper to log the offending type name when validation fails. Kept in sync
|
||||
// by hand with the discriminated union above.
|
||||
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'snapshot',
|
||||
'message_started',
|
||||
'delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'message_complete',
|
||||
'usage',
|
||||
'messages_deleted',
|
||||
'chat_renamed',
|
||||
'compacted',
|
||||
'error',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
'session_created',
|
||||
'session_archived',
|
||||
'session_deleted',
|
||||
'session_workspace_updated',
|
||||
'chat_created',
|
||||
'chat_updated',
|
||||
'chat_archived',
|
||||
'chat_unarchived',
|
||||
'chat_deleted',
|
||||
'project_created',
|
||||
'project_archived',
|
||||
'project_unarchived',
|
||||
'project_updated',
|
||||
'project_deleted',
|
||||
] as const;
|
||||
@@ -31,7 +31,8 @@
|
||||
"shiki": "^1.29.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
|
||||
318
apps/web/src/api/ws-frames.ts
Normal file
318
apps/web/src/api/ws-frames.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
||||
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
||||
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
||||
// silent protocol drift between publisher and consumer.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
||||
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
||||
// files match. If you change one, change the other.
|
||||
//
|
||||
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
||||
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
||||
// deep payload validation is follow-up work.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- shared primitives -----------------------------------------------------
|
||||
|
||||
const Uuid = z.string().uuid();
|
||||
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
||||
const ToolCallId = z.string().min(1);
|
||||
const IsoTimestamp = z.string().min(1);
|
||||
|
||||
const ChatStatusValue = z.enum([
|
||||
'streaming',
|
||||
'tool_running',
|
||||
'waiting_for_input',
|
||||
'idle',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
'doom_loop_summary_failed',
|
||||
'cap_hit',
|
||||
'cap_hit_summary_failed',
|
||||
]);
|
||||
|
||||
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
||||
|
||||
const ToolCallShape = z.object({
|
||||
id: ToolCallId,
|
||||
name: z.string().min(1),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
// Free-form bags: opaque to the frame schema; deep validation is out of
|
||||
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
||||
// payload narrowing is follow-up work). z.unknown() means the consumer
|
||||
// must narrow before reading — TypeScript-side this is fine because every
|
||||
// consumer already operates on the hand-maintained Project / Chat / Session
|
||||
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
||||
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
||||
const OpaqueObject = z.unknown();
|
||||
|
||||
// ---- per-session channel frames --------------------------------------------
|
||||
|
||||
export const SnapshotFrame = z.object({
|
||||
type: z.literal('snapshot'),
|
||||
messages: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const MessageStartedFrame = z.object({
|
||||
type: z.literal('message_started'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
role: MessageRoleValue,
|
||||
});
|
||||
|
||||
export const DeltaFrame = z.object({
|
||||
type: z.literal('delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallFrame = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call: ToolCallShape,
|
||||
});
|
||||
|
||||
export const ToolResultFrame = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call_id: ToolCallId,
|
||||
output: z.unknown(),
|
||||
truncated: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MessageCompleteFrame = z.object({
|
||||
type: z.literal('message_complete'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
model: z.string().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UsageFrame = z.object({
|
||||
type: z.literal('usage'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
completion_tokens: z.number().int().nonnegative().nullable(),
|
||||
ctx_used: z.number().int().nonnegative().nullable(),
|
||||
ctx_max: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const MessagesDeletedFrame = z.object({
|
||||
type: z.literal('messages_deleted'),
|
||||
message_ids: z.array(Uuid),
|
||||
chat_id: Uuid.optional(),
|
||||
});
|
||||
|
||||
export const ChatRenamedFrame = z.object({
|
||||
type: z.literal('chat_renamed'),
|
||||
chat_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const CompactedFrame = z.object({
|
||||
type: z.literal('compacted'),
|
||||
session_id: Uuid,
|
||||
chat_id: Uuid,
|
||||
summary_message_id: Uuid,
|
||||
});
|
||||
|
||||
export const ErrorFrame = z.object({
|
||||
type: z.literal('error'),
|
||||
message_id: Uuid.optional(),
|
||||
chat_id: Uuid.optional(),
|
||||
error: z.string(),
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
||||
|
||||
export const ChatStatusFrame = z.object({
|
||||
type: z.literal('chat_status'),
|
||||
chat_id: Uuid,
|
||||
status: ChatStatusValue,
|
||||
at: IsoTimestamp,
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
export const SessionUpdatedFrame = z.object({
|
||||
type: z.literal('session_updated'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const SessionRenamedFrame = z.object({
|
||||
type: z.literal('session_renamed'),
|
||||
session_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SessionCreatedFrame = z.object({
|
||||
type: z.literal('session_created'),
|
||||
session: OpaqueObject,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionArchivedFrame = z.object({
|
||||
type: z.literal('session_archived'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionDeletedFrame = z.object({
|
||||
type: z.literal('session_deleted'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
workspace_panes: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
type: z.literal('chat_created'),
|
||||
chat: OpaqueObject,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUpdatedFrame = z.object({
|
||||
type: z.literal('chat_updated'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
name: z.string().nullable(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const ChatArchivedFrame = z.object({
|
||||
type: z.literal('chat_archived'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUnarchivedFrame = z.object({
|
||||
type: z.literal('chat_unarchived'),
|
||||
chat: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ChatDeletedFrame = z.object({
|
||||
type: z.literal('chat_deleted'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectCreatedFrame = z.object({
|
||||
type: z.literal('project_created'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectArchivedFrame = z.object({
|
||||
type: z.literal('project_archived'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectUnarchivedFrame = z.object({
|
||||
type: z.literal('project_unarchived'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectUpdatedFrame = z.object({
|
||||
type: z.literal('project_updated'),
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectDeletedFrame = z.object({
|
||||
type: z.literal('project_deleted'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
// per-session
|
||||
SnapshotFrame,
|
||||
MessageStartedFrame,
|
||||
DeltaFrame,
|
||||
ToolCallFrame,
|
||||
ToolResultFrame,
|
||||
MessageCompleteFrame,
|
||||
UsageFrame,
|
||||
MessagesDeletedFrame,
|
||||
ChatRenamedFrame,
|
||||
CompactedFrame,
|
||||
ErrorFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
SessionRenamedFrame,
|
||||
SessionCreatedFrame,
|
||||
SessionArchivedFrame,
|
||||
SessionDeletedFrame,
|
||||
SessionWorkspaceUpdatedFrame,
|
||||
ChatCreatedFrame,
|
||||
ChatUpdatedFrame,
|
||||
ChatArchivedFrame,
|
||||
ChatUnarchivedFrame,
|
||||
ChatDeletedFrame,
|
||||
ProjectCreatedFrame,
|
||||
ProjectArchivedFrame,
|
||||
ProjectUnarchivedFrame,
|
||||
ProjectUpdatedFrame,
|
||||
ProjectDeletedFrame,
|
||||
]);
|
||||
|
||||
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
||||
|
||||
// Convenience: the set of known frame types. Useful for the publishFrame
|
||||
// helper to log the offending type name when validation fails. Kept in sync
|
||||
// by hand with the discriminated union above.
|
||||
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'snapshot',
|
||||
'message_started',
|
||||
'delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'message_complete',
|
||||
'usage',
|
||||
'messages_deleted',
|
||||
'chat_renamed',
|
||||
'compacted',
|
||||
'error',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
'session_created',
|
||||
'session_archived',
|
||||
'session_deleted',
|
||||
'session_workspace_updated',
|
||||
'chat_created',
|
||||
'chat_updated',
|
||||
'chat_archived',
|
||||
'chat_unarchived',
|
||||
'chat_deleted',
|
||||
'project_created',
|
||||
'project_archived',
|
||||
'project_unarchived',
|
||||
'project_updated',
|
||||
'project_deleted',
|
||||
] as const;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { recordUsage } from './useChatThroughput';
|
||||
@@ -216,8 +217,28 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
||||
// frames are logged and dropped. WsFrameSchema is the runtime guard;
|
||||
// the hand-maintained WsFrame type stays as the narrowed dev-time
|
||||
// shape (Zod uses OpaqueObject for nested types like Message[]). One
|
||||
// cast bridges the two.
|
||||
let raw: unknown;
|
||||
try {
|
||||
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame (parse)', err);
|
||||
return;
|
||||
}
|
||||
const validated = WsFrameSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
console.error('ws-frame-validation-failed (session channel)', {
|
||||
frame_type: (raw as { type?: unknown })?.type,
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const frame = validated.data as unknown as WsFrame;
|
||||
// v1.11: on a compaction completion, re-fetch the message list so
|
||||
// the new summary row + the cohort of compacted_at-stamped older
|
||||
// rows render correctly. We dispatch the fresh list as a synthetic
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { createWsReconnectToast } from './wsReconnectToast';
|
||||
|
||||
@@ -38,14 +39,33 @@ export function useUserEvents(): void {
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
||||
// frames are logged and dropped instead of dispatched onto the
|
||||
// sessionEvents bus where a stale or wrong shape would silently
|
||||
// corrupt sidebar / chat state.
|
||||
let raw: unknown;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(ev.data);
|
||||
if (parsed && typeof (parsed as { type?: unknown }).type === 'string') {
|
||||
sessionEvents.emit(parsed as import('./sessionEvents').SessionEvent);
|
||||
}
|
||||
raw = JSON.parse(ev.data);
|
||||
} catch (err) {
|
||||
console.warn('useUserEvents: failed to parse frame', err);
|
||||
return;
|
||||
}
|
||||
const validated = WsFrameSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
console.error('ws-frame-validation-failed (user channel)', {
|
||||
frame_type: (raw as { type?: unknown })?.type,
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Bridge cast: Zod's union is broader than SessionEvent (it includes
|
||||
// per-session-channel frames too, which never arrive on the user
|
||||
// channel). sessionEvents.emit only dispatches frames whose type
|
||||
// appears in SessionEvent; the narrowing happens via the existing
|
||||
// useSidebar.ts applyEvent switch.
|
||||
sessionEvents.emit(
|
||||
validated.data as unknown as import('./sessionEvents').SessionEvent,
|
||||
);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
38
openspec/README.md
Normal file
38
openspec/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# openspec
|
||||
|
||||
Per-batch documentation convention adopted v1.13.15-openspec.
|
||||
|
||||
Lift source: Fission-AI/OpenSpec directory layout. **No CLI dependency** — just
|
||||
the folder shape. Full OpenSpec lifecycle adoption is a future v1.14+ batch.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
openspec/
|
||||
changes/
|
||||
<slug>/ # one folder per shipped or planned batch
|
||||
proposal.md # Why + scope summary
|
||||
tasks.md # implementation step list
|
||||
design.md # architecture / data-model decisions (optional)
|
||||
specs/ # reserved for future OpenSpec CLI adoption
|
||||
archived/ # snapshots of pre-v1.13.15 batch docs
|
||||
<original-filename>.md
|
||||
specs/ # global specs, future v1.14+ use
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Slugs are lowercase-hyphenated derived from the batch title
|
||||
(e.g. `v1-13-10-per-tool-cost`, `file-attachments-v3-5`).
|
||||
- Already-shipped pre-v1.13.15 batches live in `changes/archived/` as
|
||||
single-file snapshots. They were not split into proposal/tasks because
|
||||
the work was already complete; archiving preserves git history.
|
||||
- New v1.13.15+ batches should land directly in
|
||||
`changes/<slug>/proposal.md` (+ tasks.md, + design.md when applicable).
|
||||
- `proposal.md` carries the "Why" and scope. `tasks.md` is the action list
|
||||
(numbered or checkbox). `design.md` is for non-trivial architectural
|
||||
decisions worth recording separately.
|
||||
- A canonical dispatch brief (matching the v1.13.9 / v1.13.10 format)
|
||||
is most naturally split as proposal.md (Where we are, Why this matters,
|
||||
rationale sections) + tasks.md (Scope items, Build + smoke) + design.md
|
||||
(Attribution model, Filtering, Canonical mapping).
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -157,6 +157,9 @@ importers:
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.3.0
|
||||
|
||||
Reference in New Issue
Block a user