Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 52 additions & 20 deletions apps/server/src/db/personalities.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,26 +86,32 @@ export async function deletePersonality(
pool: Pool,
id: string
): Promise<boolean> {
// Atomic delete + active-id clear in one round-trip. The first CTE deletes
// the row; the second clears the active-id setting iff the row existed and
// was the active one. The trailing SELECT always returns one row, so we
// can read the deletion outcome without depending on either DELETE's
// RETURNING set. Closes the read-then-delete-then-clear race that an
// out-of-band activate would otherwise hit.
const result = await pool.query<{ deleted_count: number }>(
`WITH deleted AS (
DELETE FROM personalities WHERE id = $1 RETURNING id
),
cleared AS (
DELETE FROM settings
WHERE key = 'active_personality_id'
AND value = $1
AND EXISTS (SELECT 1 FROM deleted)
)
SELECT COUNT(*)::int AS deleted_count FROM deleted`,
[id]
);
return (result.rows[0]?.deleted_count ?? 0) > 0;
const client = await pool.connect();
try {
await client.query("BEGIN");
// This statement waits for activatePersonality's row lock when needed.
// The separate settings DELETE gets a fresh READ COMMITTED snapshot after
// that wait, ensuring it sees an activation that committed meanwhile.
const deleted = await client.query<{ id: string }>(
"DELETE FROM personalities WHERE id = $1 RETURNING id",
[id]
);
if (deleted.rowCount === 0) {
await client.query("COMMIT");
return false;
}
await client.query("DELETE FROM settings WHERE key = $1 AND value = $2", [
ACTIVE_PERSONALITY_KEY,
id,
]);
await client.query("COMMIT");
return true;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}

export async function getActivePersonalityId(
Expand All@@ -126,6 +132,32 @@ export async function setActivePersonalityId(
await setSetting(pool, ACTIVE_PERSONALITY_KEY, id);
}

/**
* Set the active personality only while holding a row lock on it. Deletion
* acquires that same row lock before it clears the active setting, so a delete
* cannot interleave and leave a dangling active_personality_id behind.
*/
export async function activatePersonality(
pool: Pool,
id: string
): Promise<boolean> {
const result = await pool.query<{ activated: boolean }>(
`WITH locked AS (
SELECT id FROM personalities WHERE id = $1 FOR UPDATE
),
activated AS (
INSERT INTO settings (key, value, updated_at)
SELECT '${ACTIVE_PERSONALITY_KEY}', id, NOW() FROM locked
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = NOW()
RETURNING 1
)
SELECT EXISTS (SELECT 1 FROM activated) AS activated`,
[id]
);
return result.rows[0]?.activated ?? false;
}

export async function getActivePersonality(
pool: Pool
): Promise<Personality | null> {
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/routes/mcp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,12 @@ type McpRouteDeps = {
mcpClearWhiteboard: unknown;
mcpListPersonas: unknown;
mcpLaunchPersona: unknown;
mcpListPersonalities: unknown;
mcpCreatePersonality: unknown;
mcpUpdatePersonality: unknown;
mcpDeletePersonality: unknown;
mcpSetActivePersonality: unknown;
mcpClearActivePersonality: unknown;
mcpLaunchAgent: unknown;
mcpResolveReviewFeedback: unknown;
mcpReopenReviewFeedback: unknown;
Expand DownExpand Up@@ -212,6 +218,12 @@ export async function registerMcpRoutes(
listPins: deps.mcpListPins,
listPersonas: deps.mcpListPersonas,
launchPersona: deps.mcpLaunchPersona,
listPersonalities: deps.mcpListPersonalities,
createPersonality: deps.mcpCreatePersonality,
updatePersonality: deps.mcpUpdatePersonality,
deletePersonality: deps.mcpDeletePersonality,
setActivePersonality: deps.mcpSetActivePersonality,
clearActivePersonality: deps.mcpClearActivePersonality,
launchAgent: deps.mcpLaunchAgent,
resolveReviewFeedback: deps.mcpResolveReviewFeedback,
reopenReviewFeedback: deps.mcpReopenReviewFeedback,
Expand DownExpand Up@@ -297,6 +309,12 @@ export async function registerMcpRoutes(
clearWhiteboard: deps.mcpClearWhiteboard,
listPersonas: deps.mcpListPersonas,
launchPersona: deps.mcpLaunchPersona,
listPersonalities: deps.mcpListPersonalities,
createPersonality: deps.mcpCreatePersonality,
updatePersonality: deps.mcpUpdatePersonality,
deletePersonality: deps.mcpDeletePersonality,
setActivePersonality: deps.mcpSetActivePersonality,
clearActivePersonality: deps.mcpClearActivePersonality,
launchAgent: deps.mcpLaunchAgent,
resolveReviewFeedback: deps.mcpResolveReviewFeedback,
reopenReviewFeedback: deps.mcpReopenReviewFeedback,
Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/routes/personalities.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import type { Pool } from "pg";

import {
activatePersonality,
createPersonality,
deletePersonality,
getActivePersonalityId,
Expand DownExpand Up@@ -149,12 +150,9 @@ export async function registerPersonalityRoutes(
return reply.code(400).send({ error: "id must be a string or null." });
}

const personality = await getPersonality(pool, id);
if (!personality) {
if (!(await activatePersonality(pool, id))) {
return reply.code(404).send({ error: "Personality not found." });
}

await setActivePersonalityId(pool, id);
return { activeId: id };
});
}
6 changes: 6 additions & 0 deletions apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -599,6 +599,12 @@ async function registerRoutes() {
mcpClearWhiteboard: mcpHandlers.clearWhiteboard,
mcpListPersonas: mcpHandlers.listPersonas,
mcpLaunchPersona: mcpHandlers.launchPersona,
mcpListPersonalities: mcpHandlers.listPersonalities,
mcpCreatePersonality: mcpHandlers.createPersonality,
mcpUpdatePersonality: mcpHandlers.updatePersonality,
mcpDeletePersonality: mcpHandlers.deletePersonality,
mcpSetActivePersonality: mcpHandlers.setActivePersonality,
mcpClearActivePersonality: mcpHandlers.clearActivePersonality,
mcpLaunchAgent: mcpHandlers.launchAgent,
mcpResolveReviewFeedback: mcpHandlers.resolveReviewFeedback,
mcpReopenReviewFeedback: mcpHandlers.reopenReviewFeedback,
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/server/mcp-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,16 @@ import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js";
import { createReviewHandlers } from "./mcp-review-handlers.js";
import { MessageStore } from "../messages/store.js";
import { createWhiteboardHandlers } from "./mcp-whiteboard-handlers.js";
import {
activatePersonality,
createPersonality,
deletePersonality,
getActivePersonalityId,
listPersonalities,
setActivePersonalityId,
updatePersonality,
} from "../db/personalities.js";
import { errorMessage } from "../shared/lib/error-message.js";

function buildChildAgentInitialPrompt(
parentAgentId: string,
Expand DownExpand Up@@ -55,6 +65,13 @@ type CreateMcpHandlersDeps = {
appLog: FastifyBaseLogger;
};

function normalizePersonalityDuplicateName(error: unknown): never {
if (errorMessage(error).includes("personalities_name_key")) {
throw new Error("A personality with that name already exists.");
}
throw error;
}

export function mcpMethodNotAllowed(): {
jsonrpc: "2.0";
error: { code: number; message: string };
Expand DownExpand Up@@ -720,6 +737,46 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) {
...reviewHandlers,
...whiteboardHandlers,

listPersonalities: async () => {
const [personalities, activeId] = await Promise.all([
listPersonalities(deps.pool),
getActivePersonalityId(deps.pool),
]);
return { personalities, activeId };
},

createPersonality: async (input: { name: string; prompt: string }) => {
try {
return await createPersonality(deps.pool, input);
} catch (error) {
return normalizePersonalityDuplicateName(error);
}
},

updatePersonality: (
id: string,
input: { name?: string; prompt?: string }
) =>
updatePersonality(deps.pool, id, input)
.catch(normalizePersonalityDuplicateName)
.then((personality) => {
if (!personality) throw new Error("Personality not found.");
return personality;
}),

deletePersonality: (id: string) =>
deletePersonality(deps.pool, id).then((deleted) => {
if (!deleted) throw new Error("Personality not found.");
}),

setActivePersonality: async (id: string) => {
if (!(await activatePersonality(deps.pool, id))) {
throw new Error("Personality not found.");
}
},

clearActivePersonality: () => setActivePersonalityId(deps.pool, null),

upsertEvent: (
agentId: string,
event: {
Expand Down
Loading
Loading