batch3 T2: panes CRUD route + default chat pane on session POST
Adds /api/sessions/:id/panes (GET, POST), /api/panes/:id (PATCH, DELETE) with transactional position-shift logic (negate-and-restore pattern to avoid UNIQUE collisions). Max 5 panes per session enforced. Sessions.POST now creates the session and a default Chat pane at position 0 atomically via sql.begin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { registerMessageRoutes } from './routes/messages.js';
|
|||||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
import { registerModelRoutes } from './routes/models.js';
|
import { registerModelRoutes } from './routes/models.js';
|
||||||
|
import { registerPaneRoutes } from './routes/panes.js';
|
||||||
import { createInferenceRunner } from './services/inference.js';
|
import { createInferenceRunner } from './services/inference.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ async function main() {
|
|||||||
registerSettingsRoutes(app, sql);
|
registerSettingsRoutes(app, sql);
|
||||||
registerModelRoutes(app, config);
|
registerModelRoutes(app, config);
|
||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
|
registerPaneRoutes(app, sql);
|
||||||
|
|
||||||
const broker = createBroker();
|
const broker = createBroker();
|
||||||
const inference = createInferenceRunner({
|
const inference = createInferenceRunner({
|
||||||
|
|||||||
198
apps/server/src/routes/panes.ts
Normal file
198
apps/server/src/routes/panes.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Pane, PaneCreateRequest, PaneUpdateRequest } from '../types/api.js';
|
||||||
|
|
||||||
|
const VALID_KINDS = new Set(['chat', 'file_browser']);
|
||||||
|
const MAX_PANES = 5;
|
||||||
|
|
||||||
|
async function movePane(
|
||||||
|
sql: Sql,
|
||||||
|
paneId: string,
|
||||||
|
sid: string,
|
||||||
|
oldPos: number,
|
||||||
|
newPos: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (oldPos === newPos) return;
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
// Move target pane to temporary negative slot to avoid collision
|
||||||
|
await tx`UPDATE session_panes SET position = -1 WHERE id = ${paneId}`;
|
||||||
|
if (newPos > oldPos) {
|
||||||
|
await tx`UPDATE session_panes SET position = -position
|
||||||
|
WHERE session_id = ${sid} AND position > ${oldPos} AND position <= ${newPos}`;
|
||||||
|
await tx`UPDATE session_panes SET position = -position - 1
|
||||||
|
WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`;
|
||||||
|
} else {
|
||||||
|
await tx`UPDATE session_panes SET position = -position - 2
|
||||||
|
WHERE session_id = ${sid} AND position >= ${newPos} AND position < ${oldPos}`;
|
||||||
|
await tx`UPDATE session_panes SET position = -position - 1
|
||||||
|
WHERE session_id = ${sid} AND position < -1`;
|
||||||
|
}
|
||||||
|
await tx`UPDATE session_panes SET position = ${newPos} WHERE id = ${paneId}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPaneRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/sessions/:id/panes — list panes ordered by position ASC
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/sessions/:id/panes',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
const panes = await sql<Pane[]>`
|
||||||
|
SELECT id, session_id, position, kind, state, created_at
|
||||||
|
FROM session_panes
|
||||||
|
WHERE session_id = ${req.params.id}
|
||||||
|
ORDER BY position ASC
|
||||||
|
`;
|
||||||
|
return { panes };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:id/panes — create a new pane
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/sessions/:id/panes',
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = (req.body ?? {}) as PaneCreateRequest;
|
||||||
|
const { kind, position } = body;
|
||||||
|
|
||||||
|
if (!kind || !VALID_KINDS.has(kind)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'kind must be "chat" or "file_browser"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = req.params.id;
|
||||||
|
|
||||||
|
const countRows = await sql<{ n: number }[]>`
|
||||||
|
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
|
||||||
|
`;
|
||||||
|
const count = countRows[0]?.n ?? 0;
|
||||||
|
|
||||||
|
if (count >= MAX_PANES) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: `session already has ${MAX_PANES} panes (maximum)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine insert position
|
||||||
|
let insertPos: number;
|
||||||
|
if (position === undefined || position === null) {
|
||||||
|
insertPos = count;
|
||||||
|
} else {
|
||||||
|
if (position < 0 || position > count) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: `position must be between 0 and ${count} (existing count)` };
|
||||||
|
}
|
||||||
|
insertPos = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {};
|
||||||
|
|
||||||
|
const inserted = await sql.begin(async (tx) => {
|
||||||
|
await tx`UPDATE session_panes SET position = -position - 1
|
||||||
|
WHERE session_id = ${sid} AND position >= ${insertPos}`;
|
||||||
|
const [row] = await tx<Pane[]>`
|
||||||
|
INSERT INTO session_panes (session_id, position, kind, state)
|
||||||
|
VALUES (${sid}, ${insertPos}, ${kind}, ${JSON.stringify(state)}::jsonb)
|
||||||
|
RETURNING id, session_id, position, kind, state, created_at
|
||||||
|
`;
|
||||||
|
await tx`UPDATE session_panes SET position = -position
|
||||||
|
WHERE session_id = ${sid} AND position < 0`;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return inserted as Pane;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /api/panes/:id — update state and/or position
|
||||||
|
app.patch<{ Params: { id: string } }>(
|
||||||
|
'/api/panes/:id',
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = (req.body ?? {}) as PaneUpdateRequest;
|
||||||
|
const { state, position } = body;
|
||||||
|
|
||||||
|
if (state === undefined && position === undefined) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'must provide at least one of: state, position' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneRows = await sql<Pane[]>`
|
||||||
|
SELECT id, session_id, position, kind, state, created_at
|
||||||
|
FROM session_panes WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
if (paneRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pane not found' };
|
||||||
|
}
|
||||||
|
const pane = paneRows[0]!;
|
||||||
|
const sid = pane.session_id;
|
||||||
|
const oldPos = pane.position;
|
||||||
|
|
||||||
|
// Validate position if provided
|
||||||
|
if (position !== undefined) {
|
||||||
|
const countRows = await sql<{ n: number }[]>`
|
||||||
|
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
|
||||||
|
`;
|
||||||
|
const count = countRows[0]?.n ?? 0;
|
||||||
|
if (position < 0 || position >= count) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: `position must be between 0 and ${count - 1}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply position change if needed
|
||||||
|
if (position !== undefined && position !== oldPos) {
|
||||||
|
await movePane(sql, req.params.id, sid, oldPos, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state change if provided
|
||||||
|
if (state !== undefined) {
|
||||||
|
await sql`
|
||||||
|
UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb
|
||||||
|
WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await sql<Pane[]>`
|
||||||
|
SELECT id, session_id, position, kind, state, created_at
|
||||||
|
FROM session_panes WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
return updated as Pane;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/panes/:id — delete a pane, shift remaining down
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/panes/:id',
|
||||||
|
async (req, reply) => {
|
||||||
|
const paneRows = await sql<{ id: string; session_id: string; position: number }[]>`
|
||||||
|
SELECT id, session_id, position FROM session_panes WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
if (paneRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pane not found' };
|
||||||
|
}
|
||||||
|
const { session_id: sid, position: P } = paneRows[0]!;
|
||||||
|
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
await tx`DELETE FROM session_panes WHERE id = ${req.params.id}`;
|
||||||
|
await tx`UPDATE session_panes SET position = -position
|
||||||
|
WHERE session_id = ${sid} AND position > ${P}`;
|
||||||
|
await tx`UPDATE session_panes SET position = -position - 1
|
||||||
|
WHERE session_id = ${sid} AND position < 0`;
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,11 +74,18 @@ export function registerSessionRoutes(
|
|||||||
const name = parsed.data.name ?? 'New session';
|
const name = parsed.data.name ?? 'New session';
|
||||||
const systemPrompt = parsed.data.system_prompt ?? '';
|
const systemPrompt = parsed.data.system_prompt ?? '';
|
||||||
|
|
||||||
const [row] = await sql<Session[]>`
|
const row = await sql.begin(async (tx) => {
|
||||||
|
const [session] = await tx<Session[]>`
|
||||||
INSERT INTO sessions (project_id, name, model, system_prompt)
|
INSERT INTO sessions (project_id, name, model, system_prompt)
|
||||||
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
|
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
|
||||||
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
|
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO session_panes (session_id, position, kind, state)
|
||||||
|
VALUES (${session!.id}, 0, 'chat', '{}'::jsonb)
|
||||||
|
`;
|
||||||
|
return session!;
|
||||||
|
});
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user