Prevent duplicate agent shortnames per company
This commit is contained in:
37
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
37
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hasAgentShortnameCollision } from "../services/agents.ts";
|
||||||
|
|
||||||
|
describe("hasAgentShortnameCollision", () => {
|
||||||
|
it("detects collisions by normalized shortname", () => {
|
||||||
|
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||||
|
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||||
|
]);
|
||||||
|
expect(collision).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores terminated agents", () => {
|
||||||
|
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||||
|
{ id: "a1", name: "codex-coder", status: "terminated" },
|
||||||
|
]);
|
||||||
|
expect(collision).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the excluded agent id", () => {
|
||||||
|
const collision = hasAgentShortnameCollision(
|
||||||
|
"Codex Coder",
|
||||||
|
[
|
||||||
|
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||||
|
{ id: "a2", name: "other-agent", status: "idle" },
|
||||||
|
],
|
||||||
|
{ excludeAgentId: "a1" },
|
||||||
|
);
|
||||||
|
expect(collision).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collide when candidate has no shortname", () => {
|
||||||
|
const collision = hasAgentShortnameCollision("!!!", [
|
||||||
|
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||||
|
]);
|
||||||
|
expect(collision).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,16 @@ interface UpdateAgentOptions {
|
|||||||
recordRevision?: RevisionMetadata;
|
recordRevision?: RevisionMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentShortnameRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentShortnameCollisionOptions {
|
||||||
|
excludeAgentId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,21 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAgentShortnameCollision(
|
||||||
|
candidateName: string,
|
||||||
|
existingAgents: AgentShortnameRow[],
|
||||||
|
options?: AgentShortnameCollisionOptions,
|
||||||
|
): boolean {
|
||||||
|
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||||
|
if (!candidateShortname) return false;
|
||||||
|
|
||||||
|
return existingAgents.some((agent) => {
|
||||||
|
if (agent.status === "terminated") return false;
|
||||||
|
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||||
|
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function agentService(db: Db) {
|
export function agentService(db: Db) {
|
||||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||||
return {
|
return {
|
||||||
@@ -185,6 +210,31 @@ export function agentService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertCompanyShortnameAvailable(
|
||||||
|
companyId: string,
|
||||||
|
candidateName: string,
|
||||||
|
options?: AgentShortnameCollisionOptions,
|
||||||
|
) {
|
||||||
|
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||||
|
if (!candidateShortname) return;
|
||||||
|
|
||||||
|
const existingAgents = await db
|
||||||
|
.select({
|
||||||
|
id: agents.id,
|
||||||
|
name: agents.name,
|
||||||
|
status: agents.status,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.companyId, companyId));
|
||||||
|
|
||||||
|
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||||
|
if (hasCollision) {
|
||||||
|
throw conflict(
|
||||||
|
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateAgent(
|
async function updateAgent(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<typeof agents.$inferInsert>,
|
data: Partial<typeof agents.$inferInsert>,
|
||||||
@@ -212,6 +262,14 @@ export function agentService(db: Db) {
|
|||||||
await assertNoCycle(id, data.reportsTo);
|
await assertNoCycle(id, data.reportsTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
const previousShortname = normalizeAgentUrlKey(existing.name);
|
||||||
|
const nextShortname = normalizeAgentUrlKey(data.name);
|
||||||
|
if (previousShortname !== nextShortname) {
|
||||||
|
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||||
if (data.permissions !== undefined) {
|
if (data.permissions !== undefined) {
|
||||||
const role = (data.role ?? existing.role) as string;
|
const role = (data.role ?? existing.role) as string;
|
||||||
@@ -267,6 +325,8 @@ export function agentService(db: Db) {
|
|||||||
await ensureManager(companyId, data.reportsTo);
|
await ensureManager(companyId, data.reportsTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertCompanyShortnameAvailable(companyId, data.name);
|
||||||
|
|
||||||
const role = data.role ?? "general";
|
const role = data.role ?? "general";
|
||||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||||
const created = await db
|
const created = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user