Files
PaperClipAI/server/src/services/projects.ts
T
fef86e32e9 feat(execution-admission): add project concurrency policy and admission storage (#18746)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects group tasks. Agents run tasks as heartbeat runs.
> - Today, workspace mode and shared-workspace flags decide whether
tasks run at the same time. A project cannot set its own limit on
concurrent task executions.
> - A correct project limit must count every project that a task uses,
including @-mentioned projects. It must also keep the count when a
project is archived or deleted, and when a run is admitted more than one
time.
> - This change cannot go into one pull request with claims and
settlement. Thus this pull request adds only the policy semantics and
the durable accounting storage that later claims will use.
> - This pull request adds a new `execution-admission` module, three
tables and the deletion guards. No route writes a cap and no claim path
enforces it.
> - The benefit is a tested storage contract (constraints, idempotency,
rollback, indexed counts, lifecycle preservation) before any behavior
changes.

## Linked Issues or Issue Description

Refs #2831, Refs #14333 (related agent-level and adapter-level limits;
not duplicates).

**Subsystem affected**

Server execution admission (new module
`server/src/modules/execution-admission/`) and the `packages/db` schema.

**Problem or motivation**

A project cannot limit how many of its task executions are active. The
current shared-workspace flags mix resource safety with capacity. A
future project limit needs storage that records each admission attempt
and every project that the attempt charges. Run IDs cannot be the
identity, because a run can be released before dispatch and admitted
again.

**Proposed solution**

Add a separate policy table that keeps "never configured" apart from
"explicit no limit". Add admission epochs with one charge row per
distinct project. Settlement deletes the charge set in one transaction.
Later pull requests integrate claims, settlement, waits and the public
control.

**Alternatives considered**

Columns on `projects` would leak an unenforced cap through every project
reader. A project column or a `uuid[]` on `heartbeat_runs` cannot
enforce per-project uniqueness or company scope. `budget_reservations`
has no project set. A counter would be a second source of truth.

## What Changed

- Added `project_task_execution_policies`. No row means never
configured. A null maximum means an explicit "no limit". A check accepts
only positive maximums. A composite foreign key keeps the project and
company together. The row is deleted with its project.
- Added `execution_admissions`. Each row is one admission attempt of a
run: `(run_id, attempt)` is unique, and a partial unique index allows
one unsettled attempt per run. It keeps the context revision, the
nullable primary project (provenance, no foreign key) and the settlement
identity. A check sets `settlement_id` and `settled_at` together.
- Added `execution_admission_project_charges`. It has one row per
distinct admitted project, unlimited projects included. Composite
foreign keys keep the admission and the project in one company. The
project foreign key has no cascade, so a charge cannot disappear with
its project. `(company_id, project_id)` is indexed for active counts.
- Added migration `0426_absent_guardian.sql` (renumbered from `0423`,
then `0425`, as master added `0423`–`0425`). The generated SQL was
reordered so the unique indexes exist before the composite foreign keys
that need them. Snapshots `0419` and `0420` were pruned to keep five
snapshots.
- Added the `execution-admission` module (domain → application →
adapters):
- Domain: policy patch parsing and application (omitted keeps, null
clears, partial updates keep other fields, malformed values are
rejected), canonical project-set preparation (primary ∪ references,
deduplicated, ascending, bounded with an explicit `too_many_projects`
result), and the settlement replay decision.
- Application: internal `readTaskExecutionPolicy` and
`updateTaskExecutionPolicy` commands. No route, tool, CLI or import
calls them.
- Adapter: the project-set lock (`FOR NO KEY UPDATE`, ascending, one
statement), the policy store, and the storage transitions
`recordAdmission`, `settleAdmission` and `countActiveProjectCharges`.
These transitions are not exported from the module entry point. Claims
(later) must add the capacity check and the lifecycle evidence.
- Project deletion returns `409 project_execution_admission_pending`
while the project has a charge. Agent deletion returns `409
agent_execution_admission_pending` while its runs have an unsettled
admission, and removes settled history. Company deletion calls a new
module participant before it deletes runs and projects.
- Documented the tables, the lifecycle rules and the proposed global
lock order in `doc/DATABASE.md`. Added module ownership to
`server/src/modules/README.md`.

## Verification

- `cd server && npx vitest run src/modules/execution-admission` → 2
files, 26 tests pass (the Greptile finding on an empty primary ID added
an assertion to an existing test). The adapter tests use embedded
PostgreSQL and cover:
  - policy semantics, the maximum check and the company foreign key;
- charges for every distinct project, a primaryless set and an empty set
(no rows);
- replay of one admission ID, conflict on a changed record, refusal of a
second live attempt and the partial unique index;
- settlement of the whole set once, replay, a different settlement, then
attempt 2 for the same run;
- company isolation of projects, runs and admission IDs, the
duplicate-charge key, and rollback of an aborted transaction;
- active counts per project, and an `EXPLAIN` that uses
`execution_admission_project_charges_project_idx`;
- archive keeps charges, `projectService.remove` returns 409, a direct
project delete fails on the foreign key, and delete succeeds after
settlement;
- `deleteAgentDependencies` returns 409 while unsettled, and
`deleteCompany` removes only that company's admissions.
- `cd packages/db && npx vitest run
src/execution-admission-migration.test.ts` → pass. It removes the new
tables and receipt, seeds a legacy project and run, applies pending
migrations, and compares the existing rows.
- `pnpm --filter @paperclipai/db check:migrations` → pass. Five
snapshots remain (`0421`, `0422`, `0424`, `0425`, `0426`; `0423` is
SQL-only on master).
- `node scripts/check-module-boundaries.mjs` → pass.
- `packages/db` `tsc --noEmit` → pass. A scoped `tsc` of the module
entry points and domain tests → pass.
- Existing tests for the changed deletion services → 6 files, 161 tests
pass (`resource-lifecycle-events`, `prompt-injection-prevention`,
`agent-lifecycle/lifecycle`, `case-deletion`,
`project-workspace-root-provenance`, `companies-service`).
- Not run locally: the full `server` typecheck. `tsc` and `tsgo` were
stopped by the out-of-memory killer on the 7 GB machine (exit 137, also
single-threaded). The full `pnpm -r typecheck`, `pnpm test:run` and
`pnpm build` were not run locally; CI ran them on head `520b69d553`: all
gates pass, including Typecheck, Build and the 12 server test shards.
After merging master (`1c9ca5829f`) into head `c088729361`, the
migration check, migration upgrade test, module tests, module-boundary
check and `packages/db` typecheck were rerun locally and pass; CI is
rerunning the full suite.

## Risks

- Migration risk is low. It creates three empty tables and indexes. It
does not change existing tables or rows.
- Behavior changes only when admission rows exist. Nothing writes them
yet, so project, agent and company deletion behave as before.
- Later claim work must take the project-set lock in the documented
order. A claim that takes project locks after issue locks could deadlock
with project deletion.
- Settlement deletes charge rows, so a settled admission keeps its
primary project and context revision but not its full project set.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file edits, embedded PostgreSQL tests). Default context
window and reasoning mode.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: nickyleach <331803+nickyleach@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 11:35:28 -08:00

1472 lines
58 KiB
TypeScript

import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.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;
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[] },
): 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;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const row = await tx
.insert(projects)
.values({ ...projectData, 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[]): Promise<ProjectWithGoals> => {
return db.transaction(async (tx) => {
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const project = await service.create(companyId, data);
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[] },
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
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, archivedAt: projects.archivedAt, executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(eq(projects.id, id)).for("update")
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
if (projectData.executionWorkspacePolicy !== undefined &&
projectData.executionWorkspacePolicy?.storageProviderPluginId !== existingProject.executionWorkspacePolicy?.storageProviderPluginId) {
const providers = await listProjectWorkspaceProviders(db, existingProject.companyId);
const previousOwner = resolveProjectWorkspaceProvider(existingProject.executionWorkspacePolicy, providers, false).pluginId;
const nextOwner = resolveProjectWorkspaceProvider(projectData.executionWorkspacePolicy, providers, false).pluginId;
if (previousOwner !== nextOwner) 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,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(eq(projects.companyId, companyId));
let cleared = 0;
for (const row of rows) {
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
if (policy?.environmentId !== environmentId) continue;
await projectService(db).update(row.id, {
executionWorkspacePolicy: { ...policy, environmentId: 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;
},
};
}