Prevent duplicate agent shortnames per company

This commit is contained in:
Dotta
2026-03-06 09:54:27 -06:00
parent e670324334
commit 192d76678e
2 changed files with 97 additions and 0 deletions

View 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);
});
});

View File

@@ -51,6 +51,16 @@ interface UpdateAgentOptions {
recordRevision?: RevisionMetadata;
}
interface AgentShortnameRow {
id: string;
name: string;
status: string;
}
interface AgentShortnameCollisionOptions {
excludeAgentId?: string | null;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
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) {
function withUrlKey<T extends { id: string; name: string }>(row: T) {
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(
id: string,
data: Partial<typeof agents.$inferInsert>,
@@ -212,6 +262,14 @@ export function agentService(db: Db) {
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>;
if (data.permissions !== undefined) {
const role = (data.role ?? existing.role) as string;
@@ -267,6 +325,8 @@ export function agentService(db: Db) {
await ensureManager(companyId, data.reportsTo);
}
await assertCompanyShortnameAvailable(companyId, data.name);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
const created = await db