feat: workspace improvements - nullable cwd, repo-only workspaces, and resolution refactor

Make workspace cwd optional to support repo-only workspaces that don't require
a local directory. Refactor workspace resolution in heartbeat service to pass
all workspace hints to adapters, add fallback logic when project workspaces
have no valid local cwd, and improve workspace name derivation. Also adds limit
param to heartbeat runs list endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 21:35:33 -06:00
parent 30522f3f11
commit 20a4ca08a5
10 changed files with 5822 additions and 67 deletions

View File

@@ -5,6 +5,16 @@ import { PROJECT_COLORS, type ProjectGoalRef, type ProjectWorkspace } from "@pap
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
type CreateWorkspaceInput = {
name?: string | null;
cwd?: string | null;
repoUrl?: string | null;
repoRef?: string | null;
metadata?: Record<string, unknown> | null;
isPrimary?: boolean;
};
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
interface ProjectWithGoals extends ProjectRow {
goalIds: string[];
@@ -122,6 +132,53 @@ function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): s
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";
}
async function ensureSinglePrimaryWorkspace(
dbOrTx: any,
input: {
@@ -257,7 +314,7 @@ export function projectService(db: Db) {
createWorkspace: async (
projectId: string,
data: Omit<typeof projectWorkspaces.$inferInsert, "projectId" | "companyId">,
data: CreateWorkspaceInput,
): Promise<ProjectWorkspace | null> => {
const project = await db
.select()
@@ -266,6 +323,15 @@ export function projectService(db: Db) {
.then((rows) => rows[0] ?? null);
if (!project) return null;
const cwd = normalizeWorkspaceCwd(data.cwd);
const repoUrl = readNonEmptyString(data.repoUrl);
if (!cwd && !repoUrl) return null;
const name = deriveWorkspaceName({
name: data.name,
cwd,
repoUrl,
});
const existing = await db
.select()
.from(projectWorkspaces)
@@ -292,10 +358,10 @@ export function projectService(db: Db) {
.values({
companyId: project.companyId,
projectId,
name: data.name,
cwd: data.cwd,
repoUrl: data.repoUrl ?? null,
repoRef: data.repoRef ?? null,
name,
cwd: cwd ?? null,
repoUrl: repoUrl ?? null,
repoRef: readNonEmptyString(data.repoRef),
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: shouldBePrimary,
})
@@ -310,7 +376,7 @@ export function projectService(db: Db) {
updateWorkspace: async (
projectId: string,
workspaceId: string,
data: Partial<typeof projectWorkspaces.$inferInsert>,
data: UpdateWorkspaceInput,
): Promise<ProjectWorkspace | null> => {
const existing = await db
.select()
@@ -324,13 +390,26 @@ export function projectService(db: Db) {
.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);
if (!nextCwd && !nextRepoUrl) return null;
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
updatedAt: new Date(),
};
if (data.name !== undefined) patch.name = data.name;
if (data.cwd !== undefined) patch.cwd = data.cwd;
if (data.repoUrl !== undefined) patch.repoUrl = data.repoUrl;
if (data.repoRef !== undefined) patch.repoRef = data.repoRef;
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.metadata !== undefined) patch.metadata = data.metadata;
const updated = await db.transaction(async (tx) => {