mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A project supplies work context and repository storage to task runs. > - Creation selected a storage owner but did not retain the selected environment on the project. > - Configuration stored an environment inside checkout policy, but policy reads and run selection dropped it. > - Projects therefore continued to follow shared agent and instance defaults. > - This pull request saves placement on each project and uses it for new runs. > - The benefit is an independent Project to Environment association with the existing storage, trust, and recovery safeguards. ## Linked Issues or Issue Description Refs #20459 and #11644. Follow-up to #20587. The reported journey is Project > Configuration: changing one project's environment must not change another project's mapping or shared defaults. Related open PR #18176 repairs placement through the legacy workspace-policy field. This follow-up introduces a separate project association, saves creation defaults, and migrates saved legacy references. It overlaps that PR's placement, permission, and recovery safeguards. The changes should be reviewed together. #9588 concerns environment-variable response privacy and does not address placement. ROADMAP.md has no separate project-association work planned. ## What Changed - Add nullable `projects.environment_id` and expose `Project.environmentId`. Save the effective creation default once on the project. Keep environment placement separate from checkout/isolation policy. - Read and write Environment directly in Project Configuration. Keep the control available with isolated workspaces disabled. Each form reads its project's value, even when environment choices share a query cache. - Offer environments compatible with the current storage owner. Change the target and storage together when the existing storage selector attaches a core project to a plugin. Preserve retained-data restrictions and validate the observed storage owner again on save. - Resolve new task runs from their organizational project's target before agent and instance defaults. File sources from another project do not change placement. Explicit low-trust sandbox choices and enforced sandbox policies retain precedence. External provider placement remains separate. - Restore admitted native runs from their owned environment lease. Later project edits apply to new admissions. Require workspace-write permission for environment changes and clears. Check company bindings before runtime admission and during lease creation. - Migrate valid saved policy references into the project column. Normalize legacy API writes. Count and clear matching project references when an environment is deleted. Update API and behavior documentation. - Refresh the maintained SDK source registry for the two changed shared files. Runtime pins are unchanged. ## Verification - [Hosted CI](https://github.com/paperclipai/paperclip/actions/runs/42908045146) is green on `8b3b7d5b5aed51fc4c8e9c2e3506508bbaf9e00c`: **60 successful checks**, three skipped gates, no failures, and `ci / verify` passed. GitHub reports no merge conflicts. Standard Greptile scored the same commit **5/5** with both review threads resolved. - Source checkout on macOS arm64, Node 26.12.0, with fresh isolated PostgreSQL. No production data or live provider calls. - The two-project component test changes the first form with isolation disabled and a shared query cache. The second form keeps its target. Storage changes update only the selected project. A saved target remains selected while the list loads, and an explicit clear remains possible. - API integration creates two projects from the same default, patches one, reads both, and checks that agent and instance defaults remain unchanged. A later instance-default change does not rewrite either saved project target. Tests cover unavailable/incompatible choices, anonymous and cross-company denials, and storage preconditions. - The migration test runs the actual SQL against an existing project row and verifies its environment and disabled checkout policy survive. Deletion accounting and clearing affect only matching project references. - The final guard set passes **53 tests across four files**: `project-environments.test.ts`, `environment-selection-route-guards.test.ts`, `heartbeat/environment-selection.test.ts`, and `ProjectProperties.environment.test.tsx`. All **64 environment-run-orchestrator tests** pass, including direct lease acquisition without an extra caller argument. The broader affected set passed **109 tests across eight files** before the additional admission guards. These sets overlap. - UI typecheck, migration numbering/safety, token gates, module boundaries, query-error rendering, and diff checks pass. `pnpm verify --explain` returns exit 2 and requires broad CI because shared schema/runtime paths and behavior documentation changed. Broad coverage is delegated to current-head CI. - Early iterations failed due to a legacy parser that drops the environment field, a fixture without real company membership, and incorrect expectations for privacy-preserving 404 denials. The service now reads legacy references before normalization, the API fixture has real membership, and denial assertions match the existing privacy contract. The orchestrator mock also needed the reused company-binding guard in its service contract. The focused suites above pass after those corrections. - Local server typecheck remains blocked by installed ACPX, Slack, AI SDK, and Runner type mismatches in unchanged dependency-facing code. It reports no errors in the changed production paths. This is not a full local typecheck or suite pass. Hosted CI supplies full current-head coverage. - SDK source-proof and privacy qualification passes **56 tests across three files**, including the real built SDK. The maintained source registry matches the committed revision; runtime pins are unchanged. - Review reproduced two failures: an incoming policy without a storage owner could be validated before the existing owner was restored, and uppercase legacy UUIDs missed migration matching. Preserve the owner before validation and compare normalized UUID text. Both regressions now pass. The post-fix project/storage/orchestrator batch passes **107 tests**: `node node_modules/vitest/vitest.mjs run server/src/__tests__/project-environments.test.ts server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/environment-run-orchestrator.test.ts --silent`. - The first hosted run failed build, typecheck, and canary at the same `run-completion.test.ts` call because lease acquisition required a new argument. The lease method now enforces binding internally. Its existing caller compiles without a new field, and the orchestrator tests prove the binding flag reaches the runtime. The full server check no longer reports that error; the local dependency errors above remain. ## Risks - Apply the normal database migration before serving the new revision. The migration adds a nullable foreign key and copies valid legacy project selections. It does not move repository data or resize provider storage. The five newest schema snapshots are retained. - New projects retain their creation target when shared defaults change. Existing projects without a saved selection continue to inherit defaults. A null target explicitly opts into inheritance. - Existing saved project selections now affect placement. Missing, archived, incompatible, and foreign-company targets fail validation; they do not silently choose another storage owner. A running admitted process keeps its original owned lease. - Local dependency limitations are listed above. No live-provider, deployed-browser, or production rollout qualification is claimed. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact serving snapshot and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1514 lines
61 KiB
TypeScript
1514 lines
61 KiB
TypeScript
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
|
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
|
import { resolveProjectEnvironment, type ProjectCreationEnvironment } from "./project-environments.js";
|
|
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
|
|
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js";
|
|
import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
projects,
|
|
executionWorkspaces,
|
|
projectAccessMembers,
|
|
projectGoals,
|
|
goals,
|
|
issues,
|
|
budgetPolicies,
|
|
pluginManagedResources,
|
|
plugins,
|
|
projectWorkspaces,
|
|
workspaceRuntimeServices,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
deriveProjectUrlKey,
|
|
hasNonAsciiContent,
|
|
isUuidLike,
|
|
normalizeProjectUrlKey,
|
|
type BudgetWindowKind,
|
|
type ProjectBudgetSummary,
|
|
type ProjectDiscoverySummary,
|
|
type ProjectCodebase,
|
|
type ProjectExecutionWorkspacePolicy,
|
|
type ProjectGoalRef,
|
|
type ProjectManagedByPlugin,
|
|
type ProjectWorkspaceRuntimeConfig,
|
|
type ProjectWorkspace,
|
|
type WorkspaceRuntimeService,
|
|
type PluginManagedProjectDeclaration,
|
|
type PluginManagedProjectResolution,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, unprocessable } from "../errors.js";
|
|
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
|
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
|
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
|
import { hasUnsettledProjectCharges } from "../modules/execution-admission/index.js";
|
|
|
|
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
|
|
export async function lockProjectRootMutation(tx: ProjectTransaction, projectId: string) {
|
|
return tx.select().from(projects).where(eq(projects.id, projectId)).for("update").then(rows => rows[0] ?? null);
|
|
}
|
|
|
|
type ProjectRow = typeof projects.$inferSelect;
|
|
|
|
function legacyProjectEnvironmentId(policy: Record<string, unknown> | null | undefined) {
|
|
const id = policy?.environmentId;
|
|
return typeof id === "string" || id === null ? id : undefined;
|
|
}
|
|
|
|
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;
|
|
runtimeConfig?: Partial<ProjectWorkspaceRuntimeConfig> | null;
|
|
isPrimary?: boolean;
|
|
};
|
|
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
|
|
|
export async function ensureProjectAccessMember(
|
|
dbOrTx: any,
|
|
input: {
|
|
companyId: string;
|
|
projectId: string;
|
|
subjectType: "user" | "agent";
|
|
subjectId: string;
|
|
},
|
|
) {
|
|
return dbOrTx.transaction(async (tx: Db) => {
|
|
await notifyChatInboundWakeNotices(tx, { companyId: input.companyId });
|
|
return tx.insert(projectAccessMembers).values(input).onConflictDoNothing().returning()
|
|
.then(rows => rows[0] ?? null);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Concurrency-safe lazy creation for the per-user private parking project.
|
|
* The partial unique index is the final arbiter; the advisory lock avoids
|
|
* wasting project ids and keeps the membership insert in the same transaction.
|
|
*/
|
|
export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: string, userId: string) {
|
|
const lockKey = `personal-private-project:${companyId}:${userId}`;
|
|
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
let project = await dbOrTx
|
|
.select()
|
|
.from(projects)
|
|
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
|
.limit(1)
|
|
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
|
if (!project) {
|
|
project = await dbOrTx
|
|
.insert(projects)
|
|
.values({
|
|
companyId,
|
|
name: "My private tasks",
|
|
description: "Private tasks visible only to people and agents explicitly granted access.",
|
|
status: "in_progress",
|
|
visibility: "private",
|
|
personalOwnerUserId: userId,
|
|
privacyOwnerUserId: userId,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
|
project ??= await dbOrTx
|
|
.select()
|
|
.from(projects)
|
|
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
|
.limit(1)
|
|
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
|
}
|
|
if (!project) throw new Error("Failed to create personal private project");
|
|
await ensureProjectAccessMember(dbOrTx, {
|
|
companyId,
|
|
projectId: project.id,
|
|
subjectType: "user",
|
|
subjectId: userId,
|
|
});
|
|
return project;
|
|
}
|
|
|
|
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
|
urlKey: string;
|
|
goalIds: string[];
|
|
goals: ProjectGoalRef[];
|
|
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
|
workspaceProvider?: import("@paperclipai/shared").ProjectWorkspaceProviderConfiguration;
|
|
codebase: ProjectCodebase;
|
|
workspaces: ProjectWorkspace[];
|
|
primaryWorkspace: ProjectWorkspace | null;
|
|
managedByPlugin: ProjectManagedByPlugin | null;
|
|
taskCount?: number;
|
|
budget?: ProjectBudgetSummary | 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,
|
|
runtimeConfig: readProjectWorkspaceRuntimeConfig((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 providerCatalog = await listProjectWorkspaceProviders(db, rows[0]!.companyId);
|
|
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 listCurrentRuntimeServicesForProjectWorkspaces(
|
|
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);
|
|
}
|
|
|
|
const managedRows = await db
|
|
.select({
|
|
id: pluginManagedResources.id,
|
|
pluginId: pluginManagedResources.pluginId,
|
|
pluginKey: pluginManagedResources.pluginKey,
|
|
manifestJson: plugins.manifestJson,
|
|
resourceKind: pluginManagedResources.resourceKind,
|
|
resourceKey: pluginManagedResources.resourceKey,
|
|
resourceId: pluginManagedResources.resourceId,
|
|
defaultsJson: pluginManagedResources.defaultsJson,
|
|
createdAt: pluginManagedResources.createdAt,
|
|
updatedAt: pluginManagedResources.updatedAt,
|
|
})
|
|
.from(pluginManagedResources)
|
|
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
|
|
.where(and(
|
|
eq(pluginManagedResources.resourceKind, "project"),
|
|
inArray(pluginManagedResources.resourceId, projectIds),
|
|
));
|
|
const managedByProjectId = new Map<string, ProjectManagedByPlugin>();
|
|
for (const row of managedRows) {
|
|
managedByProjectId.set(row.resourceId, {
|
|
id: row.id,
|
|
pluginId: row.pluginId,
|
|
pluginKey: row.pluginKey,
|
|
pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: row.resourceKey,
|
|
defaultsJson: row.defaultsJson,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
});
|
|
}
|
|
|
|
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,
|
|
managedByPlugin: managedByProjectId.get(row.id) ?? null,
|
|
workspaceProvider: resolveProjectWorkspaceProvider(row.executionWorkspacePolicy, providerCatalog,
|
|
workspaces.some(workspace => Boolean(workspace.repoUrl))),
|
|
};
|
|
});
|
|
}
|
|
|
|
type TaskCountRow = { projectId: string | null; count: number };
|
|
type ProjectBudgetRow = { scopeId: string; amount: number; windowKind: string };
|
|
|
|
/**
|
|
* Build the per-project task-count and budget lookups from the aggregate query
|
|
* rows. Pure (no DB) so the merge logic can be unit-tested in isolation.
|
|
* Only active policies with a positive amount surface as a budget.
|
|
*/
|
|
export function buildProjectListMetricMaps(taskCountRows: TaskCountRow[], budgetRows: ProjectBudgetRow[]) {
|
|
const taskCountByProjectId = new Map<string, number>();
|
|
for (const row of taskCountRows) {
|
|
if (row.projectId) taskCountByProjectId.set(row.projectId, Number(row.count) || 0);
|
|
}
|
|
|
|
const budgetByProjectId = new Map<string, ProjectBudgetSummary>();
|
|
for (const row of budgetRows) {
|
|
if (row.amount > 0) {
|
|
budgetByProjectId.set(row.scopeId, {
|
|
amountCents: row.amount,
|
|
windowKind: row.windowKind as BudgetWindowKind,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { taskCountByProjectId, budgetByProjectId };
|
|
}
|
|
|
|
/**
|
|
* Attach lightweight list-only metrics (task count + budget) to a set of
|
|
* projects using two aggregate queries (no N+1). Used by the projects list
|
|
* view (IA Phase 4 — PAP-60).
|
|
*/
|
|
async function attachListMetrics(
|
|
db: Db,
|
|
companyId: string,
|
|
rows: ProjectWithGoals[],
|
|
): Promise<ProjectWithGoals[]> {
|
|
if (rows.length === 0) return rows;
|
|
|
|
const projectIds = rows.map((r) => r.id);
|
|
|
|
const [taskCountRows, budgetRows] = await Promise.all([
|
|
db
|
|
.select({
|
|
projectId: issues.projectId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds), isNull(issues.conversationAgentId)))
|
|
.groupBy(issues.projectId),
|
|
db
|
|
.select({
|
|
scopeId: budgetPolicies.scopeId,
|
|
amount: budgetPolicies.amount,
|
|
windowKind: budgetPolicies.windowKind,
|
|
})
|
|
.from(budgetPolicies)
|
|
.where(
|
|
and(
|
|
eq(budgetPolicies.companyId, companyId),
|
|
eq(budgetPolicies.scopeType, "project"),
|
|
eq(budgetPolicies.metric, "billed_cents"),
|
|
eq(budgetPolicies.isActive, true),
|
|
inArray(budgetPolicies.scopeId, projectIds),
|
|
),
|
|
),
|
|
]);
|
|
|
|
const { taskCountByProjectId, budgetByProjectId } = buildProjectListMetricMaps(
|
|
taskCountRows,
|
|
budgetRows,
|
|
);
|
|
|
|
return rows.map((row) => ({
|
|
...row,
|
|
taskCount: taskCountByProjectId.get(row.id) ?? 0,
|
|
budget: budgetByProjectId.get(row.id) ?? null,
|
|
}));
|
|
}
|
|
|
|
/** Sync the project_goals join table for a single project. */
|
|
/**
|
|
* Every goal a project links to must exist and belong to the same company.
|
|
* Without this check a nonexistent id only dies at the projects.goal_id
|
|
* foreign key — an opaque 500 the caller retries (observed live
|
|
* 2026-09-03: four identical retries of one bad id) — and a goal from
|
|
* another company would link silently, because the foreign key proves
|
|
* existence, not ownership.
|
|
*/
|
|
async function assertGoalsBelongToCompany(db: Db, companyId: string, goalIds: string[]): Promise<void> {
|
|
if (goalIds.length === 0) return;
|
|
const unique = [...new Set(goalIds)];
|
|
const found = await db
|
|
.select({ id: goals.id })
|
|
.from(goals)
|
|
.where(and(eq(goals.companyId, companyId), inArray(goals.id, unique)));
|
|
const foundIds = new Set(found.map((row) => row.id));
|
|
const unknown = unique.filter((goalId) => !foundIds.has(goalId));
|
|
if (unknown.length > 0) {
|
|
throw unprocessable(
|
|
`Unknown goal id(s) for this company: ${unknown.join(", ")}`,
|
|
{ unknownGoalIds: unknown },
|
|
);
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function buildManagedProjectDefaults(declaration: PluginManagedProjectDeclaration) {
|
|
return {
|
|
projectKey: declaration.projectKey,
|
|
displayName: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
status: declaration.status ?? "in_progress",
|
|
color: declaration.color ?? null,
|
|
settings: declaration.settings ?? {},
|
|
};
|
|
}
|
|
|
|
export function resolveProjectNameForUniqueShortname(
|
|
requestedName: string,
|
|
existingProjects: ProjectShortnameRow[],
|
|
options?: ResolveProjectNameOptions,
|
|
): string {
|
|
const requestedShortname = normalizeProjectUrlKey(requestedName);
|
|
if (!requestedShortname) return requestedName;
|
|
// Non-ASCII names get a UUID suffix in deriveProjectUrlKey, making slugs inherently unique.
|
|
if (hasNonAsciiContent(requestedName)) 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, options: { captureWorkspaceUpdates?: boolean } = {}) {
|
|
const createProject = async (
|
|
companyId: string,
|
|
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
|
environment: ProjectCreationEnvironment = {},
|
|
): Promise<ProjectWithGoals> => {
|
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
|
if (ids && ids.length > 0) await assertGoalsBelongToCompany(db, companyId, ids);
|
|
|
|
// Note: color is intentionally NOT auto-assigned. New projects default to
|
|
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
|
|
|
|
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)
|
|
// The resolved set is canonical for persistence as well as validation:
|
|
// falling back to the raw legacy field here would write an id that
|
|
// skipped validation whenever `goalIds: []` and `goalId` arrive
|
|
// together (goalIds wins resolution, mirroring the update path).
|
|
const legacyGoalId = ids?.[0] ?? null;
|
|
const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
|
|
const workspacePolicy = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
|
|
const selected = await resolveProjectEnvironment(db, companyId,
|
|
legacyEnvironmentId !== undefined ? workspacePolicy : projectData.executionWorkspacePolicy ?? null, {
|
|
...environment, environmentId: environment.environmentId ?? projectData.environmentId ?? legacyEnvironmentId ?? undefined,
|
|
});
|
|
const executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
|
|
projectData.environmentId = selected.environmentId;
|
|
|
|
return db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
const row = await tx
|
|
.insert(projects)
|
|
.values({ ...projectData, executionWorkspacePolicy, goalId: legacyGoalId, companyId })
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
if (ids && ids.length > 0) {
|
|
await syncGoalLinks(txDb, row.id, companyId, ids);
|
|
}
|
|
await recordResourceCreationEvent(txDb, companyId, "project", row.id);
|
|
const [withGoals] = await attachGoals(txDb, [row]);
|
|
const [enriched] = withGoals ? await attachWorkspaces(txDb, [withGoals]) : [];
|
|
return enriched!;
|
|
});
|
|
};
|
|
|
|
const getProjectById = 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;
|
|
};
|
|
|
|
return {
|
|
// Project discovery never reads workspace JSON, goals, metrics, or full descriptions.
|
|
listSummaries: async (companyId: string, opts: { limit: number; cursor?: string; includeArchived: boolean; candidateIds: string[] | null }): Promise<ProjectDiscoverySummary[]> => {
|
|
return db.select({
|
|
id: projects.id,
|
|
name: sql<string>`left(${projects.name}, 500)`,
|
|
status: projects.status,
|
|
description: sql<string | null>`left(${projects.description}, 1000)`,
|
|
descriptionTruncated: sql<boolean>`coalesce(length(${projects.description}) > 1000, false)`,
|
|
}).from(projects).where(and(
|
|
eq(projects.companyId, companyId),
|
|
opts.includeArchived ? undefined : isNull(projects.archivedAt),
|
|
opts.cursor ? gt(projects.id, opts.cursor) : undefined,
|
|
opts.candidateIds === null ? undefined : or(
|
|
inArray(projects.id, opts.candidateIds),
|
|
// Project policy can add a root/project boundary to the actor scope.
|
|
// Keep these candidates for the authoritative per-project decision.
|
|
sql`${projects.executionWorkspacePolicy}->'authorizationPolicy' is not null`,
|
|
),
|
|
)).orderBy(asc(projects.id)).limit(opts.limit);
|
|
},
|
|
|
|
list: async (companyId: string, opts: { includeArchived?: boolean } = {}): Promise<ProjectWithGoals[]> => {
|
|
// NOTE: this service default is intentionally the inverse of the HTTP route default.
|
|
// The route (`GET /companies/:companyId/projects`) defaults `includeArchived` to `false`
|
|
// (active-only) for its callers, but the service defaults to `true` so that existing
|
|
// server-internal callers that pass no opts keep their pre-existing "return everything,
|
|
// including archived" behaviour. Pass `{ includeArchived: false }` explicitly for active-only.
|
|
const includeArchived = opts.includeArchived ?? true;
|
|
const where = includeArchived
|
|
? eq(projects.companyId, companyId)
|
|
: and(eq(projects.companyId, companyId), isNull(projects.archivedAt));
|
|
const rows = await db.select().from(projects).where(where);
|
|
const withGoals = await attachGoals(db, rows);
|
|
const withWorkspaces = await attachWorkspaces(db, withGoals);
|
|
return attachListMetrics(db, companyId, withWorkspaces);
|
|
},
|
|
|
|
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: getProjectById,
|
|
|
|
resolveManagedProject: async (input: {
|
|
companyId: string;
|
|
pluginId: string;
|
|
pluginKey: string;
|
|
projectKey: string;
|
|
reset?: boolean;
|
|
createIfMissing?: boolean;
|
|
}): Promise<PluginManagedProjectResolution> => {
|
|
const plugin = await db
|
|
.select({ id: plugins.id, pluginKey: plugins.pluginKey, manifestJson: plugins.manifestJson })
|
|
.from(plugins)
|
|
.where(eq(plugins.id, input.pluginId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!plugin || plugin.pluginKey !== input.pluginKey) {
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: null,
|
|
project: null,
|
|
status: "missing",
|
|
};
|
|
}
|
|
|
|
const declaration = plugin.manifestJson.projects?.find((project) => project.projectKey === input.projectKey);
|
|
if (!declaration) {
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: null,
|
|
project: null,
|
|
status: "missing",
|
|
};
|
|
}
|
|
|
|
const defaults = buildManagedProjectDefaults(declaration);
|
|
const existingBinding = await db
|
|
.select()
|
|
.from(pluginManagedResources)
|
|
.where(and(
|
|
eq(pluginManagedResources.companyId, input.companyId),
|
|
eq(pluginManagedResources.pluginId, input.pluginId),
|
|
eq(pluginManagedResources.resourceKind, "project"),
|
|
eq(pluginManagedResources.resourceKey, input.projectKey),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (existingBinding) {
|
|
const existingProject = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existingProject) {
|
|
if (input.reset) {
|
|
await db
|
|
.update(projects)
|
|
.set({
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
status: declaration.status ?? "in_progress",
|
|
color: declaration.color ?? null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)));
|
|
}
|
|
if (input.createIfMissing !== false) {
|
|
await db
|
|
.update(pluginManagedResources)
|
|
.set({ defaultsJson: defaults, updatedAt: new Date() })
|
|
.where(eq(pluginManagedResources.id, existingBinding.id));
|
|
}
|
|
const project = await getProjectById(existingBinding.resourceId);
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: project?.id ?? existingBinding.resourceId,
|
|
project: project as import("@paperclipai/shared").Project | null,
|
|
status: input.reset ? "reset" : "resolved",
|
|
};
|
|
}
|
|
|
|
if (input.createIfMissing === false) {
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: null,
|
|
project: null,
|
|
status: "missing",
|
|
};
|
|
}
|
|
|
|
const project = await createProject(input.companyId, {
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
status: declaration.status ?? "in_progress",
|
|
color: declaration.color ?? undefined,
|
|
});
|
|
await db
|
|
.update(pluginManagedResources)
|
|
.set({ resourceId: project.id, defaultsJson: defaults, updatedAt: new Date() })
|
|
.where(eq(pluginManagedResources.id, existingBinding.id));
|
|
const hydrated = await getProjectById(project.id);
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: hydrated?.id ?? project.id,
|
|
project: hydrated as import("@paperclipai/shared").Project | null,
|
|
status: "relinked",
|
|
};
|
|
}
|
|
|
|
if (input.createIfMissing === false) {
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: null,
|
|
project: null,
|
|
status: "missing",
|
|
};
|
|
}
|
|
|
|
const project = await createProject(input.companyId, {
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
status: declaration.status ?? "in_progress",
|
|
color: declaration.color ?? undefined,
|
|
});
|
|
await db.insert(pluginManagedResources).values({
|
|
companyId: input.companyId,
|
|
pluginId: input.pluginId,
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
resourceId: project.id,
|
|
defaultsJson: defaults,
|
|
});
|
|
const hydrated = await getProjectById(project.id);
|
|
return {
|
|
pluginKey: input.pluginKey,
|
|
resourceKind: "project",
|
|
resourceKey: input.projectKey,
|
|
companyId: input.companyId,
|
|
projectId: hydrated?.id ?? project.id,
|
|
project: hydrated as import("@paperclipai/shared").Project | null,
|
|
status: "created",
|
|
};
|
|
},
|
|
|
|
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[], environment: ProjectCreationEnvironment = {}): Promise<ProjectWithGoals> => {
|
|
return db.transaction(async (tx) => {
|
|
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
|
const project = await service.create(companyId, data, environment);
|
|
for (const repo of repositories) {
|
|
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
|
}
|
|
return (await service.getById(project.id))!;
|
|
});
|
|
},
|
|
|
|
replaceRepositories: async (projectId: string, repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals | null> => {
|
|
return db.transaction(async (tx) => {
|
|
const project = await lockProjectRootMutation(tx, projectId);
|
|
if (!project) return null;
|
|
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
|
const existing = await service.listWorkspaces(projectId);
|
|
const ids = new Set(repositories.map((repo) => repo.id));
|
|
for (const workspace of existing) {
|
|
const repoId = workspace.metadata?.githubRepositoryId;
|
|
if (typeof repoId === "string" && !ids.has(repoId)) {
|
|
// Keep local/runtime workspace configuration when detaching source.
|
|
if (workspace.cwd || workspace.remoteWorkspaceRef) {
|
|
const { githubRepositoryId: _id, ...metadata } = workspace.metadata!;
|
|
await service.updateWorkspace(projectId, workspace.id, { repoUrl: null, metadata });
|
|
} else await service.removeWorkspace(projectId, workspace.id);
|
|
}
|
|
}
|
|
for (const repo of repositories) {
|
|
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
|
|
if (retained) {
|
|
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName || retained.metadata?.githubRepositoryPrivate !== repo.private || retained.metadata?.githubRepositoryUrl !== repo.url) {
|
|
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url, metadata: { ...retained.metadata, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
|
}
|
|
continue;
|
|
}
|
|
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
|
|
if (legacy) {
|
|
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
|
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
|
}
|
|
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
|
|
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);
|
|
return service.getById(projectId);
|
|
});
|
|
},
|
|
|
|
create: createProject,
|
|
|
|
update: async (
|
|
id: string,
|
|
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[]; expectedStorageProviderPluginId?: string | null },
|
|
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
|
|
const db = tx as unknown as Db;
|
|
const { goalIds: inputGoalIds, expectedStorageProviderPluginId, ...projectData } = data;
|
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
|
const existingProject = await db
|
|
.select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
|
.from(projects)
|
|
.where(eq(projects.id, id)).for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existingProject) return null;
|
|
const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
|
|
if (projectData.environmentId === undefined && legacyEnvironmentId !== undefined) {
|
|
projectData.environmentId = legacyEnvironmentId;
|
|
}
|
|
if (legacyEnvironmentId !== undefined) {
|
|
const normalized = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
|
|
projectData.executionWorkspacePolicy = normalized ? { ...normalized } : null;
|
|
}
|
|
const currentStorage = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId;
|
|
if (currentStorage && projectData.executionWorkspacePolicy !== undefined) {
|
|
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
|
|
if (requestedStorage !== undefined && requestedStorage !== currentStorage) {
|
|
throw conflict("A plugin-backed project stays attached to its storage plugin");
|
|
}
|
|
if (requestedStorage === undefined) {
|
|
projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: currentStorage };
|
|
}
|
|
}
|
|
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
|
|
const storageChanged = requestedStorage !== undefined && requestedStorage !== currentStorage;
|
|
const selectedEnvironmentId = projectData.environmentId === undefined && storageChanged
|
|
? existingProject.environmentId : projectData.environmentId;
|
|
if (selectedEnvironmentId && (selectedEnvironmentId !== existingProject.environmentId || storageChanged || expectedStorageProviderPluginId !== undefined)) {
|
|
const selected = await resolveProjectEnvironment(db, existingProject.companyId,
|
|
projectData.executionWorkspacePolicy ?? existingProject.executionWorkspacePolicy,
|
|
{ environmentId: selectedEnvironmentId, expectedStorageProviderPluginId });
|
|
projectData.executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
|
|
}
|
|
if (!currentStorage && requestedStorage) {
|
|
await retireRemovedPluginWorkspaceDependents(db, id);
|
|
}
|
|
if (ids && ids.length > 0) {
|
|
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
|
|
}
|
|
|
|
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.transaction(async (tx) => {
|
|
if (updates.visibility !== undefined) {
|
|
await notifyStatusCardIssueChange(tx, existingProject.companyId);
|
|
await notifyChatInboundWakeNotices(tx, { companyId: existingProject.companyId });
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existingProject.companyId}`}, 0))`);
|
|
}
|
|
const [updated] = await tx.update(projects).set(updates).where(eq(projects.id, id)).returning();
|
|
if (updated?.visibility === "private" && updates.visibility === "private") {
|
|
const protectedRows = await tx.execute(sql`with recursive protected_tasks as (
|
|
select i.id from issues i where i.project_id = ${id} and i.company_id = ${updated.companyId}
|
|
union
|
|
select i.id from issues i join protected_tasks p on i.parent_id = p.id or i.privacy_parent_issue_id = p.id
|
|
where i.company_id = ${updated.companyId}
|
|
) update issues i set visibility = 'private', privacy_root_issue_id = coalesce(i.privacy_root_issue_id, i.id),
|
|
privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id), updated_at = now()
|
|
from protected_tasks p where i.id = p.id returning i.id`);
|
|
// Assignment access stays with the task after unassignment.
|
|
if (protectedRows.length) await tx.execute(sql`insert into issue_access_grants (issue_id, subject_type, subject_id, source)
|
|
select i.id, principal.kind, principal.id, 'assignment' from issues i
|
|
cross join lateral (values ('agent', i.assignee_agent_id::text), ('user', i.assignee_user_id)) principal(kind, id)
|
|
where i.id in (${sql.join(protectedRows.map(row => sql`${row.id}`), sql`, `)}) and principal.id is not null
|
|
and not exists (select 1 from issue_access_grants g where g.issue_id = i.id
|
|
and g.subject_type = principal.kind and g.subject_id = principal.id and g.revoked_at is null)`);
|
|
}
|
|
return updated ?? null;
|
|
});
|
|
if (!row) return null;
|
|
|
|
if (ids !== undefined) {
|
|
await syncGoalLinks(db, id, row.companyId, ids);
|
|
}
|
|
|
|
if (Object.entries(projectData).some(([key, value]) => value !== undefined && key !== "archivedAt" && key !== "updatedAt") || ids !== undefined || (existingProject.archivedAt && !row.archivedAt)) {
|
|
await recordProjectLifecycleEvent(db, row.companyId, id);
|
|
}
|
|
if (row.archivedAt && !existingProject.archivedAt) {
|
|
await recordProjectLifecycleEvent(db, row.companyId, id, "archive");
|
|
}
|
|
|
|
const [withGoals] = await attachGoals(db, [row]);
|
|
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
|
return enriched ?? null;
|
|
}),
|
|
|
|
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
|
const rows = await db
|
|
.select({
|
|
id: projects.id,
|
|
environmentId: projects.environmentId,
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(eq(projects.companyId, companyId));
|
|
|
|
let cleared = 0;
|
|
for (const row of rows) {
|
|
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
|
|
if (row.environmentId !== environmentId && legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue;
|
|
|
|
await projectService(db).update(row.id, {
|
|
environmentId: null,
|
|
...(legacyProjectEnvironmentId(row.executionWorkspacePolicy) === environmentId ? { executionWorkspacePolicy: policy ? { ...policy } : null } : {}),
|
|
});
|
|
cleared += 1;
|
|
}
|
|
|
|
return cleared;
|
|
},
|
|
|
|
remove: async (id: string) => db.transaction(async tx => {
|
|
const project = await lockProjectRootMutation(tx, id);
|
|
if (!project) return null;
|
|
const [retained] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.projectId, id)).limit(1);
|
|
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
|
|
if (await hasUnsettledProjectCharges(tx as unknown as Db, { companyId: project.companyId, projectId: id })) {
|
|
throw conflict("Task executions admitted to this project have not finished saving. Wait for them to settle before deleting the project.", {
|
|
code: "project_execution_admission_pending",
|
|
});
|
|
}
|
|
await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
|
|
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
|
await notifyStatusCardIssueChange(tx, project.companyId);
|
|
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
|
|
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
|
|
}),
|
|
|
|
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 listCurrentRuntimeServicesForProjectWorkspaces(
|
|
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> => db.transaction(async tx => {
|
|
const db = tx as unknown as Db;
|
|
const project = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId)).for("update")
|
|
.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.runtimeConfig !== undefined
|
|
? mergeProjectWorkspaceRuntimeConfig(
|
|
(data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
|
data.runtimeConfig ?? null,
|
|
)
|
|
: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
|
isPrimary: shouldBePrimary,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (row) await enqueueProjectSkillSource(tx as unknown as Db, row);
|
|
return row;
|
|
});
|
|
|
|
if (created && options.captureWorkspaceUpdates !== false) {
|
|
await recordProjectLifecycleEvent(db, created.companyId, projectId);
|
|
}
|
|
return created ? toWorkspace(created) : null;
|
|
}),
|
|
|
|
updateWorkspace: async (
|
|
projectId: string,
|
|
workspaceId: string,
|
|
data: UpdateWorkspaceInput,
|
|
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
|
const db = tx as unknown as Db;
|
|
if (!await lockProjectRootMutation(tx, projectId)) return null;
|
|
const existing = await db
|
|
.select()
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.id, workspaceId),
|
|
eq(projectWorkspaces.projectId, projectId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.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 || data.runtimeConfig !== undefined) {
|
|
patch.metadata =
|
|
data.runtimeConfig !== undefined
|
|
? mergeProjectWorkspaceRuntimeConfig(
|
|
data.metadata !== undefined
|
|
? (data.metadata as Record<string, unknown> | null | undefined)
|
|
: ((existing.metadata as Record<string, unknown> | null | undefined) ?? null),
|
|
data.runtimeConfig ?? null,
|
|
)
|
|
: data.metadata;
|
|
}
|
|
|
|
if (["repoUrl", "repoRef", "defaultRef", "sourceType", "remoteProvider", "remoteWorkspaceRef"].some(key =>
|
|
key in patch && patch[key as keyof typeof patch] !== existing[key as keyof typeof existing])) {
|
|
await retireRemovedPluginWorkspaceDependents(db, projectId, workspaceId);
|
|
}
|
|
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.repoUrl !== existing.repoUrl || row.defaultRef !== existing.defaultRef || row.repoRef !== existing.repoRef) {
|
|
await enqueueProjectSkillSource(tx as unknown as Db, row);
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
if (updated && options.captureWorkspaceUpdates !== false) {
|
|
await recordProjectLifecycleEvent(db, updated.companyId, projectId);
|
|
}
|
|
return updated ? toWorkspace(updated) : null;
|
|
}),
|
|
|
|
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
|
const db = tx as unknown as Db;
|
|
if (!await lockProjectRootMutation(tx, projectId)) return null;
|
|
const existing = await db
|
|
.select()
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.id, workspaceId),
|
|
eq(projectWorkspaces.projectId, projectId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
await retireRemovedPluginWorkspaceDependents(db, projectId, workspaceId);
|
|
|
|
const removed = await db.transaction(async (tx) => {
|
|
await detachProjectSkillSource(tx as unknown as Db, existing.companyId, workspaceId);
|
|
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;
|
|
});
|
|
|
|
if (removed && options.captureWorkspaceUpdates !== false) {
|
|
await recordProjectLifecycleEvent(db, removed.companyId, projectId);
|
|
}
|
|
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;
|
|
},
|
|
};
|
|
}
|