2026-08-25 17:20:53 -04:00
|
|
|
import { and, asc, eq } from "drizzle-orm";
|
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
|
import { z } from "zod";
|
|
|
|
|
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
|
|
|
|
import { harnessTypeSchema } from "@tasks/shared/worker";
|
2026-08-25 17:56:02 -04:00
|
|
|
import type { HarnessType } from "@tasks/shared/worker";
|
2026-08-25 17:20:53 -04:00
|
|
|
import { rateLimit } from "@/server/lib/rate-limit";
|
|
|
|
|
import { router, workspaceProcedure } from "@/server/trpc";
|
|
|
|
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
|
|
|
|
|
2026-08-25 17:56:02 -04:00
|
|
|
export const HARNESS_PRESETS: ReadonlyArray<{
|
|
|
|
|
alias: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
harnessType: HarnessType;
|
|
|
|
|
}> = [
|
|
|
|
|
{ alias: "doc-writer", displayName: "Doc writer", harnessType: "minimal" },
|
|
|
|
|
{ alias: "minimal", displayName: "Minimal", harnessType: "minimal" },
|
|
|
|
|
] as const;
|
|
|
|
|
|
2026-08-25 17:20:53 -04:00
|
|
|
type HarnessRow = typeof harnessProviders.$inferSelect;
|
|
|
|
|
type RouteRow = typeof roleHarnessRoutes.$inferSelect;
|
|
|
|
|
|
|
|
|
|
function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
|
|
|
|
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
|
|
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Only workspace owners and admins can manage harnesses." });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
|
|
|
|
|
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapHarnessRow(row: HarnessRow) {
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
workspaceId: row.workspaceId,
|
|
|
|
|
alias: row.alias,
|
|
|
|
|
displayName: row.displayName,
|
|
|
|
|
harnessType: harnessTypeSchema.parse(row.harnessType),
|
|
|
|
|
endpointUrl: row.endpointUrl,
|
|
|
|
|
authEnvVar: row.authEnvVar,
|
|
|
|
|
config: row.config,
|
|
|
|
|
enabled: row.enabled,
|
|
|
|
|
createdAt: row.createdAt,
|
|
|
|
|
updatedAt: row.updatedAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapRouteRow(row: RouteRow) {
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
workspaceId: row.workspaceId,
|
|
|
|
|
roleId: row.roleId,
|
|
|
|
|
harnessProviderId: row.harnessProviderId,
|
|
|
|
|
enabled: row.enabled,
|
|
|
|
|
sortOrder: row.sortOrder,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const harnessFieldsSchema = z.object({
|
|
|
|
|
alias: z.string().min(1).max(120),
|
|
|
|
|
displayName: z.string().min(1).max(200),
|
|
|
|
|
harnessType: harnessTypeSchema,
|
|
|
|
|
endpointUrl: z.string().url().nullable().optional(),
|
|
|
|
|
authEnvVar: z.string().min(1).max(120).nullable().optional(),
|
|
|
|
|
config: z.record(z.unknown()).nullable().optional(),
|
|
|
|
|
enabled: z.boolean().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const harnessesRouter = router({
|
|
|
|
|
list: workspaceProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const rows = await ctx.db
|
|
|
|
|
.select()
|
|
|
|
|
.from(harnessProviders)
|
|
|
|
|
.where(eq(harnessProviders.workspaceId, ctx.workspace.id))
|
|
|
|
|
.orderBy(asc(harnessProviders.alias));
|
|
|
|
|
return { harnesses: rows.map(mapHarnessRow), canManage: canManageWorkspace(ctx) };
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
create: workspaceProcedure.input(harnessFieldsSchema).mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
try {
|
|
|
|
|
const [created] = await ctx.db
|
|
|
|
|
.insert(harnessProviders)
|
|
|
|
|
.values({
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
alias: input.alias.trim(),
|
|
|
|
|
displayName: input.displayName.trim(),
|
|
|
|
|
harnessType: input.harnessType,
|
|
|
|
|
endpointUrl: input.endpointUrl?.trim() || null,
|
|
|
|
|
authEnvVar: input.authEnvVar?.trim() || null,
|
|
|
|
|
config: input.config ?? null,
|
|
|
|
|
enabled: input.enabled ?? true,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness provider." });
|
|
|
|
|
return mapHarnessRow(created);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: `Alias "${input.alias}" already exists in this workspace.` });
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
update: workspaceProcedure.input(harnessFieldsSchema.partial().extend({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
const [existing] = await ctx.db
|
|
|
|
|
.select()
|
|
|
|
|
.from(harnessProviders)
|
|
|
|
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
|
|
|
|
try {
|
|
|
|
|
const [updated] = await ctx.db
|
|
|
|
|
.update(harnessProviders)
|
|
|
|
|
.set({
|
|
|
|
|
...(input.alias !== undefined ? { alias: input.alias.trim() } : {}),
|
|
|
|
|
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
|
|
|
|
|
...(input.harnessType !== undefined ? { harnessType: input.harnessType } : {}),
|
|
|
|
|
...(input.endpointUrl !== undefined ? { endpointUrl: input.endpointUrl?.trim() || null } : {}),
|
|
|
|
|
...(input.authEnvVar !== undefined ? { authEnvVar: input.authEnvVar?.trim() || null } : {}),
|
|
|
|
|
...(input.config !== undefined ? { config: input.config } : {}),
|
|
|
|
|
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.returning();
|
|
|
|
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
|
|
|
|
return mapHarnessRow(updated);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: "Another harness already uses that alias." });
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
delete: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
const [deleted] = await ctx.db
|
|
|
|
|
.delete(harnessProviders)
|
|
|
|
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.returning({ id: harnessProviders.id });
|
|
|
|
|
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
|
|
|
|
return { id: deleted.id };
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
testHarness: workspaceProcedure.input(z.object({ harnessId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
const limit = rateLimit({ key: `harness:test:${ctx.workspace.id}`, limit: 10, windowMs: 60_000 });
|
|
|
|
|
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Test rate limit exceeded. Try again shortly." });
|
|
|
|
|
const [row] = await ctx.db
|
|
|
|
|
.select()
|
|
|
|
|
.from(harnessProviders)
|
|
|
|
|
.where(and(eq(harnessProviders.id, input.harnessId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
|
|
|
|
if (!row.endpointUrl) {
|
|
|
|
|
return { ok: false as const, latencyMs: 0, error: "No endpoint_url configured for this harness." };
|
|
|
|
|
}
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
try {
|
|
|
|
|
const headers: Record<string, string> = {};
|
|
|
|
|
if (row.authEnvVar) {
|
|
|
|
|
const token = process.env[row.authEnvVar]?.trim();
|
|
|
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
|
}
|
|
|
|
|
const base = row.endpointUrl.replace(/\/+$/, "");
|
|
|
|
|
// Try /health then base itself as fallback
|
|
|
|
|
let res: Response | null = null;
|
|
|
|
|
let lastError = "";
|
|
|
|
|
for (const suffix of ["/health", ""]) {
|
|
|
|
|
try {
|
|
|
|
|
res = await fetch(`${base}${suffix}`, { headers, signal: AbortSignal.timeout(8000) });
|
|
|
|
|
if (res.ok) break;
|
|
|
|
|
lastError = `HTTP ${res.status} ${res.statusText}`;
|
|
|
|
|
// don't break on non-ok if we have another suffix
|
|
|
|
|
if (suffix === "") lastError = `HTTP ${res.status} ${res.statusText}`;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
lastError = e instanceof Error ? e.message : String(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const latencyMs = Date.now() - started;
|
|
|
|
|
if (res && res.ok) return { ok: true as const, latencyMs };
|
|
|
|
|
return { ok: false as const, latencyMs, error: lastError || "Health check failed" };
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false as const, latencyMs: Date.now() - started, error: e instanceof Error ? e.message : String(e) };
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
listRoutes: workspaceProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const routes = await ctx.db
|
|
|
|
|
.select({
|
|
|
|
|
route: roleHarnessRoutes,
|
|
|
|
|
harnessAlias: harnessProviders.alias,
|
|
|
|
|
harnessType: harnessProviders.harnessType,
|
|
|
|
|
harnessEnabled: harnessProviders.enabled,
|
|
|
|
|
})
|
|
|
|
|
.from(roleHarnessRoutes)
|
|
|
|
|
.innerJoin(harnessProviders, eq(roleHarnessRoutes.harnessProviderId, harnessProviders.id))
|
|
|
|
|
.where(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))
|
|
|
|
|
.orderBy(asc(roleHarnessRoutes.sortOrder), asc(roleHarnessRoutes.roleId));
|
|
|
|
|
return {
|
|
|
|
|
routes: routes.map((row) => ({
|
|
|
|
|
...mapRouteRow(row.route),
|
|
|
|
|
harnessAlias: row.harnessAlias,
|
|
|
|
|
harnessType: row.harnessType,
|
|
|
|
|
harnessEnabled: row.harnessEnabled,
|
|
|
|
|
})),
|
|
|
|
|
canManage: canManageWorkspace(ctx),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
upsertRoute: workspaceProcedure
|
|
|
|
|
.input(z.object({ roleId: z.string().min(1).max(120), harnessProviderId: z.string().uuid(), enabled: z.boolean().optional(), sortOrder: z.number().int().optional() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
const [provider] = await ctx.db
|
|
|
|
|
.select({ id: harnessProviders.id })
|
|
|
|
|
.from(harnessProviders)
|
|
|
|
|
.where(and(eq(harnessProviders.id, input.harnessProviderId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (!provider) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
|
|
|
|
const [existing] = await ctx.db
|
|
|
|
|
.select({ id: roleHarnessRoutes.id })
|
|
|
|
|
.from(roleHarnessRoutes)
|
|
|
|
|
.where(and(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id), eq(roleHarnessRoutes.roleId, input.roleId)))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (existing) {
|
|
|
|
|
const [updated] = await ctx.db
|
|
|
|
|
.update(roleHarnessRoutes)
|
|
|
|
|
.set({
|
|
|
|
|
harnessProviderId: input.harnessProviderId,
|
|
|
|
|
enabled: input.enabled ?? true,
|
|
|
|
|
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(and(eq(roleHarnessRoutes.id, existing.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id)))
|
|
|
|
|
.returning();
|
|
|
|
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
|
|
|
|
return mapRouteRow(updated);
|
|
|
|
|
}
|
|
|
|
|
const [created] = await ctx.db
|
|
|
|
|
.insert(roleHarnessRoutes)
|
|
|
|
|
.values({
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
roleId: input.roleId,
|
|
|
|
|
harnessProviderId: input.harnessProviderId,
|
|
|
|
|
enabled: input.enabled ?? true,
|
|
|
|
|
sortOrder: input.sortOrder ?? 0,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness route." });
|
|
|
|
|
return mapRouteRow(created);
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
deleteRoute: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
const [deleted] = await ctx.db.delete(roleHarnessRoutes).where(and(eq(roleHarnessRoutes.id, input.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))).returning({ id: roleHarnessRoutes.id });
|
|
|
|
|
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
|
|
|
|
return { id: deleted.id };
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function isUniqueViolation(error: unknown): boolean {
|
|
|
|
|
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "23505";
|
|
|
|
|
}
|