merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,859 @@
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import {
|
||||
PROJECT_COLORS,
|
||||
deriveProjectUrlKey,
|
||||
isUuidLike,
|
||||
normalizeProjectUrlKey,
|
||||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
type CreateWorkspaceInput = {
|
||||
name?: string | null;
|
||||
sourceType?: string | null;
|
||||
cwd?: string | null;
|
||||
repoUrl?: string | null;
|
||||
repoRef?: string | null;
|
||||
defaultRef?: string | null;
|
||||
visibility?: string | null;
|
||||
setupCommand?: string | null;
|
||||
cleanupCommand?: string | null;
|
||||
remoteProvider?: string | null;
|
||||
remoteWorkspaceRef?: string | null;
|
||||
sharedWorkspaceKey?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
}
|
||||
|
||||
interface ProjectShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResolveProjectNameOptions {
|
||||
excludeProjectId?: string | null;
|
||||
}
|
||||
|
||||
/** Batch-load goal refs for a set of projects. */
|
||||
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const projectIds = rows.map((r) => r.id);
|
||||
|
||||
// Fetch join rows + goal titles in one query
|
||||
const links = await db
|
||||
.select({
|
||||
projectId: projectGoals.projectId,
|
||||
goalId: projectGoals.goalId,
|
||||
goalTitle: goals.title,
|
||||
})
|
||||
.from(projectGoals)
|
||||
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
||||
.where(inArray(projectGoals.projectId, projectIds));
|
||||
|
||||
const map = new Map<string, ProjectGoalRef[]>();
|
||||
for (const link of links) {
|
||||
let arr = map.get(link.projectId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(link.projectId, arr);
|
||||
}
|
||||
arr.push({ id: link.goalId, title: link.goalTitle });
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const g = map.get(r.id) ?? [];
|
||||
return {
|
||||
...r,
|
||||
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||
goalIds: g.map((x) => x.id),
|
||||
goals: g,
|
||||
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
|
||||
} as ProjectWithGoals;
|
||||
});
|
||||
}
|
||||
|
||||
function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
serviceName: row.serviceName,
|
||||
status: row.status as WorkspaceRuntimeService["status"],
|
||||
lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"],
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: row.port ?? null,
|
||||
url: row.url ?? null,
|
||||
provider: row.provider as WorkspaceRuntimeService["provider"],
|
||||
providerRef: row.providerRef ?? null,
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
startedAt: row.startedAt,
|
||||
stoppedAt: row.stoppedAt ?? null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toWorkspace(
|
||||
row: ProjectWorkspaceRow,
|
||||
runtimeServices: WorkspaceRuntimeService[] = [],
|
||||
): ProjectWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
name: row.name,
|
||||
sourceType: row.sourceType as ProjectWorkspace["sourceType"],
|
||||
cwd: normalizeWorkspaceCwd(row.cwd),
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
repoRef: row.repoRef ?? null,
|
||||
defaultRef: row.defaultRef ?? row.repoRef ?? null,
|
||||
visibility: row.visibility as ProjectWorkspace["visibility"],
|
||||
setupCommand: row.setupCommand ?? null,
|
||||
cleanupCommand: row.cleanupCommand ?? null,
|
||||
remoteProvider: row.remoteProvider ?? null,
|
||||
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
isPrimary: row.isPrimary,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
const raw = readNonEmptyString(repoUrl);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const cleanedPath = parsed.pathname.replace(/\/+$/, "");
|
||||
const repoName = cleanedPath.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? "";
|
||||
return repoName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveProjectCodebase(input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
fallbackWorkspaces: ProjectWorkspace[];
|
||||
}): ProjectCodebase {
|
||||
const primaryWorkspace = input.primaryWorkspace ?? input.fallbackWorkspaces[0] ?? null;
|
||||
const repoUrl = primaryWorkspace?.repoUrl ?? null;
|
||||
const repoName = deriveRepoNameFromRepoUrl(repoUrl);
|
||||
const localFolder = primaryWorkspace?.cwd ?? null;
|
||||
const managedFolder = resolveManagedProjectWorkspaceDir({
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId,
|
||||
repoName,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId: primaryWorkspace?.id ?? null,
|
||||
repoUrl,
|
||||
repoRef: primaryWorkspace?.repoRef ?? null,
|
||||
defaultRef: primaryWorkspace?.defaultRef ?? null,
|
||||
repoName,
|
||||
localFolder,
|
||||
managedFolder,
|
||||
effectiveLocalFolder: localFolder ?? managedFolder,
|
||||
origin: localFolder ? "local_folder" : "managed_checkout",
|
||||
};
|
||||
}
|
||||
|
||||
function pickPrimaryWorkspace(
|
||||
rows: ProjectWorkspaceRow[],
|
||||
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
|
||||
): ProjectWorkspace | null {
|
||||
if (rows.length === 0) return null;
|
||||
const explicitPrimary = rows.find((row) => row.isPrimary);
|
||||
const primary = explicitPrimary ?? rows[0];
|
||||
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
|
||||
}
|
||||
|
||||
/** Batch-load workspace refs for a set of projects. */
|
||||
async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<ProjectWithGoals[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const projectIds = rows.map((r) => r.id);
|
||||
const workspaceRows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
workspaceRows.map((workspace) => workspace.id),
|
||||
);
|
||||
const sharedRuntimeServicesByWorkspaceId = new Map(
|
||||
Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [
|
||||
workspaceId,
|
||||
services.map(toRuntimeService),
|
||||
]),
|
||||
);
|
||||
|
||||
const map = new Map<string, ProjectWorkspaceRow[]>();
|
||||
for (const row of workspaceRows) {
|
||||
let arr = map.get(row.projectId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(row.projectId, arr);
|
||||
}
|
||||
arr.push(row);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||
toWorkspace(
|
||||
workspace,
|
||||
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
|
||||
),
|
||||
);
|
||||
const primaryWorkspace = pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId);
|
||||
return {
|
||||
...row,
|
||||
codebase: deriveProjectCodebase({
|
||||
companyId: row.companyId,
|
||||
projectId: row.id,
|
||||
primaryWorkspace,
|
||||
fallbackWorkspaces: workspaces,
|
||||
}),
|
||||
workspaces,
|
||||
primaryWorkspace,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sync the project_goals join table for a single project. */
|
||||
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||
// Delete existing links
|
||||
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
|
||||
|
||||
// Insert new links
|
||||
if (goalIds.length > 0) {
|
||||
await db.insert(projectGoals).values(
|
||||
goalIds.map((goalId) => ({ projectId, goalId, companyId })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve goalIds from input, handling the legacy goalId field. */
|
||||
function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined {
|
||||
if (data.goalIds !== undefined) return data.goalIds;
|
||||
if (data.goalId !== undefined) {
|
||||
return data.goalId ? [data.goalId] : [];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceCwd(value: unknown): string | null {
|
||||
const cwd = readNonEmptyString(value);
|
||||
if (!cwd) return null;
|
||||
return cwd === REPO_ONLY_CWD_SENTINEL ? null : cwd;
|
||||
}
|
||||
|
||||
function deriveNameFromCwd(cwd: string): string {
|
||||
const normalized = cwd.replace(/[\\/]+$/, "");
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? "Local folder";
|
||||
}
|
||||
|
||||
function deriveNameFromRepoUrl(repoUrl: string): string {
|
||||
try {
|
||||
const url = new URL(repoUrl);
|
||||
const cleanedPath = url.pathname.replace(/\/+$/, "");
|
||||
const lastSegment = cleanedPath.split("/").filter(Boolean).pop() ?? "";
|
||||
const noGitSuffix = lastSegment.replace(/\.git$/i, "");
|
||||
return noGitSuffix || repoUrl;
|
||||
} catch {
|
||||
return repoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveWorkspaceName(input: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
repoUrl?: string | null;
|
||||
}) {
|
||||
const explicit = readNonEmptyString(input.name);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const cwd = readNonEmptyString(input.cwd);
|
||||
if (cwd) return deriveNameFromCwd(cwd);
|
||||
|
||||
const repoUrl = readNonEmptyString(input.repoUrl);
|
||||
if (repoUrl) return deriveNameFromRepoUrl(repoUrl);
|
||||
|
||||
return "Workspace";
|
||||
}
|
||||
|
||||
export function resolveProjectNameForUniqueShortname(
|
||||
requestedName: string,
|
||||
existingProjects: ProjectShortnameRow[],
|
||||
options?: ResolveProjectNameOptions,
|
||||
): string {
|
||||
const requestedShortname = normalizeProjectUrlKey(requestedName);
|
||||
if (!requestedShortname) return requestedName;
|
||||
|
||||
const usedShortnames = new Set(
|
||||
existingProjects
|
||||
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
|
||||
.map((project) => normalizeProjectUrlKey(project.name))
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
if (!usedShortnames.has(requestedShortname)) return requestedName;
|
||||
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const candidateName = `${requestedName} ${suffix}`;
|
||||
const candidateShortname = normalizeProjectUrlKey(candidateName);
|
||||
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
|
||||
return candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback guard for pathological naming collisions.
|
||||
return `${requestedName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
async function ensureSinglePrimaryWorkspace(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
keepWorkspaceId: string;
|
||||
},
|
||||
) {
|
||||
await dbOrTx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId),
|
||||
),
|
||||
);
|
||||
|
||||
await dbOrTx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId),
|
||||
eq(projectWorkspaces.id, input.keepWorkspaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function projectService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
const withGoals = await attachGoals(db, rows);
|
||||
return attachWorkspaces(db, withGoals);
|
||||
},
|
||||
|
||||
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
||||
const dedupedIds = [...new Set(ids)];
|
||||
if (dedupedIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds)));
|
||||
const withGoals = await attachGoals(db, rows);
|
||||
const withWorkspaces = await attachWorkspaces(db, withGoals);
|
||||
const byId = new Map(withWorkspaces.map((project) => [project.id, project]));
|
||||
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
if (!withGoals) return null;
|
||||
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
||||
return enriched ?? null;
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
|
||||
// Auto-assign a color from the palette if none provided
|
||||
if (!projectData.color) {
|
||||
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
||||
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
||||
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
||||
projectData.color = nextColor;
|
||||
}
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
await syncGoalLinks(db, row.id, companyId, ids);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched!;
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals | null> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
|
||||
if (projectData.name !== undefined) {
|
||||
const existingShortname = normalizeProjectUrlKey(existingProject.name);
|
||||
const nextShortname = normalizeProjectUrlKey(projectData.name);
|
||||
if (existingShortname !== nextShortname) {
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, existingProject.companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
|
||||
excludeProjectId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep legacy goalId column in sync
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
...projectData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (ids !== undefined) {
|
||||
updates.goalId = ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.update(projects)
|
||||
.set(updates)
|
||||
.where(eq(projects.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (ids !== undefined) {
|
||||
await syncGoalLinks(db, id, row.companyId, ids);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched ?? null;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) };
|
||||
}),
|
||||
|
||||
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
if (rows.length === 0) return [];
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
rows.map((workspace) => workspace.id),
|
||||
);
|
||||
return rows.map((row) =>
|
||||
toWorkspace(
|
||||
row,
|
||||
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
createWorkspace: async (
|
||||
projectId: string,
|
||||
data: CreateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
const project = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!project) return null;
|
||||
|
||||
const cwd = normalizeWorkspaceCwd(data.cwd);
|
||||
const repoUrl = readNonEmptyString(data.repoUrl);
|
||||
const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed");
|
||||
const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef);
|
||||
if (sourceType === "remote_managed") {
|
||||
if (!remoteWorkspaceRef && !repoUrl) return null;
|
||||
} else if (!cwd && !repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const name = deriveWorkspaceName({
|
||||
name: data.name,
|
||||
cwd,
|
||||
repoUrl,
|
||||
});
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(asc(projectWorkspaces.createdAt))
|
||||
.then((rows) => rows);
|
||||
|
||||
const shouldBePrimary = data.isPrimary === true || existing.length === 0;
|
||||
const created = await db.transaction(async (tx) => {
|
||||
if (shouldBePrimary) {
|
||||
await tx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, project.companyId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const row = await tx
|
||||
.insert(projectWorkspaces)
|
||||
.values({
|
||||
companyId: project.companyId,
|
||||
projectId,
|
||||
name,
|
||||
sourceType,
|
||||
cwd: cwd ?? null,
|
||||
repoUrl: repoUrl ?? null,
|
||||
repoRef: readNonEmptyString(data.repoRef),
|
||||
defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef),
|
||||
visibility: readNonEmptyString(data.visibility) ?? "default",
|
||||
setupCommand: readNonEmptyString(data.setupCommand),
|
||||
cleanupCommand: readNonEmptyString(data.cleanupCommand),
|
||||
remoteProvider: readNonEmptyString(data.remoteProvider),
|
||||
remoteWorkspaceRef,
|
||||
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
|
||||
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
isPrimary: shouldBePrimary,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row;
|
||||
});
|
||||
|
||||
return created ? toWorkspace(created) : null;
|
||||
},
|
||||
|
||||
updateWorkspace: async (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
data: UpdateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.id, workspaceId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextCwd =
|
||||
data.cwd !== undefined
|
||||
? normalizeWorkspaceCwd(data.cwd)
|
||||
: normalizeWorkspaceCwd(existing.cwd);
|
||||
const nextRepoUrl =
|
||||
data.repoUrl !== undefined
|
||||
? readNonEmptyString(data.repoUrl)
|
||||
: readNonEmptyString(existing.repoUrl);
|
||||
const nextSourceType =
|
||||
data.sourceType !== undefined
|
||||
? readNonEmptyString(data.sourceType)
|
||||
: readNonEmptyString(existing.sourceType);
|
||||
const nextRemoteWorkspaceRef =
|
||||
data.remoteWorkspaceRef !== undefined
|
||||
? readNonEmptyString(data.remoteWorkspaceRef)
|
||||
: readNonEmptyString(existing.remoteWorkspaceRef);
|
||||
if (nextSourceType === "remote_managed") {
|
||||
if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null;
|
||||
} else if (!nextCwd && !nextRepoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.name !== undefined) patch.name = deriveWorkspaceName({ name: data.name, cwd: nextCwd, repoUrl: nextRepoUrl });
|
||||
if (data.name === undefined && (data.cwd !== undefined || data.repoUrl !== undefined)) {
|
||||
patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl });
|
||||
}
|
||||
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
|
||||
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
|
||||
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef);
|
||||
if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType;
|
||||
if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef);
|
||||
if (data.visibility !== undefined && readNonEmptyString(data.visibility)) {
|
||||
patch.visibility = readNonEmptyString(data.visibility)!;
|
||||
}
|
||||
if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand);
|
||||
if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand);
|
||||
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
|
||||
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
|
||||
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
|
||||
if (data.metadata !== undefined) patch.metadata = data.metadata;
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary === true) {
|
||||
await tx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, existing.companyId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
);
|
||||
patch.isPrimary = true;
|
||||
} else if (data.isPrimary === false) {
|
||||
patch.isPrimary = false;
|
||||
}
|
||||
|
||||
const row = await tx
|
||||
.update(projectWorkspaces)
|
||||
.set(patch)
|
||||
.where(eq(projectWorkspaces.id, workspaceId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (row.isPrimary) return row;
|
||||
|
||||
const hasPrimary = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
eq(projectWorkspaces.isPrimary, true),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!hasPrimary) {
|
||||
const nextPrimaryCandidate = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
eq(projectWorkspaces.id, row.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const alternateCandidate = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.then((rows) => rows.find((candidate) => candidate.id !== row.id) ?? null);
|
||||
|
||||
await ensureSinglePrimaryWorkspace(tx, {
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
keepWorkspaceId: alternateCandidate?.id ?? nextPrimaryCandidate?.id ?? row.id,
|
||||
});
|
||||
const refreshed = await tx
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.id, row.id))
|
||||
.then((rows) => rows[0] ?? row);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return updated ? toWorkspace(updated) : null;
|
||||
},
|
||||
|
||||
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.id, workspaceId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
const row = await tx
|
||||
.delete(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.id, workspaceId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (!row.isPrimary) return row;
|
||||
|
||||
const next = await tx
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (next) {
|
||||
await ensureSinglePrimaryWorkspace(tx, {
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
keepWorkspaceId: next.id,
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return removed ? toWorkspace(removed) : null;
|
||||
},
|
||||
|
||||
resolveByReference: async (companyId: string, reference: string) => {
|
||||
const raw = reference.trim();
|
||||
if (raw.length === 0) {
|
||||
return { project: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
if (isUuidLike(raw)) {
|
||||
const row = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, raw), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return { project: null, ambiguous: false } as const;
|
||||
return {
|
||||
project: { id: row.id, companyId: row.companyId, urlKey: deriveProjectUrlKey(row.name, row.id) },
|
||||
ambiguous: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const urlKey = normalizeProjectUrlKey(raw);
|
||||
if (!urlKey) {
|
||||
return { project: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
const matches = rows.filter((row) => deriveProjectUrlKey(row.name, row.id) === urlKey);
|
||||
if (matches.length === 1) {
|
||||
const match = matches[0]!;
|
||||
return {
|
||||
project: { id: match.id, companyId: match.companyId, urlKey: deriveProjectUrlKey(match.name, match.id) },
|
||||
ambiguous: false,
|
||||
} as const;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { project: null, ambiguous: true } as const;
|
||||
}
|
||||
return { project: null, ambiguous: false } as const;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user