mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat users receive a notice when accepted input waits for execution. > - Periodic publication maintenance scans those receipts even when nothing changes. > - Admission, cancellation, source removal, and authority changes already define when a notice can change. > - Existing action, receipt, publication, and provider-link rows preserve that work across restart. > - This PR gives ordinary inbound queue notices a commit-driven owner using the shared scheduler. > - Settled notices stop querying while unfinished batches and contention retain recovery. ## Linked Issues or Issue Description This ordinary inbound-notice slice follows merged #17895 and now targets master. Related extraction: #16187. Failed-run retry notice projection remains a separate following slice (#17649); its two shared receipt-writer notifications move forward here because ordinary input can attach to those receipt owners. **What happened?** Publication maintenance scans accepted input to project queued, not-started, and removed-source notices. Empty scans keep touching the database. Its cursor also loses PostgreSQL timestamp precision, so a full page of suppressed candidates can repeat. **Expected behavior** Committed changes should wake the notice owner. The worker should reconstruct saved candidates after restart and stop scanning when settled. It must retain a wake across drain, failed selection, contention, and a commit during reconciliation. **Steps to reproduce** Start an otherwise idle instance with chat history. Observe the ordinary inbound notice scan in periodic publication maintenance. For the cursor defect, retain a full page of denied candidates with microsecond timestamps before an eligible notice. ## What Changed - Register an ordinary inbound-notice owner with the existing delivery coordinator and scheduler. Bound each pass to 100 candidates, preserve precise continuation cursors and earlier-page contention, and reset scoped discovery after committed changes. - Notify from real admission, failed action settlement, source removal, task/tree/agent/budget/company cancellation, signed Slack Stop, and deferred queue cancellation/failure. Preserve pending-status and absent-run guards so concurrent promotion wins. - Cover task privacy/grants, project membership/visibility and current authority through their persistence owners. Keep receipt ownership, current authorization, provider policy and closed notice text in the existing chat service. - Cover shared retry-owned receipts through the existing durable authorization callback and a notifying transaction around conditional denial retirement. These small producer fixes already exist in #17649; no per-caller recovery wrappers are added. Retain its matching budget-refusal fixture correction. - Remove only ordinary notices from periodic maintenance. Preserve the separate failed-retry pass at 50 and keep the explicit combined manual API. - Preserve timestamp precision, fairness, restart, drain/resume, rollback and quiet behavior. Add two real producer transaction regressions and update the audit. Add no storage or scheduler infrastructure. ## Verification Current publication head: `41f7e2c9e351efa6cfa5ef699c0e69df7425012b`, after normal merge of master `d30c8f42b842327ee1c18c41a2efd6ba623da5cd` (#17960). The integration preserves both complete added/removed patches, all non-overlap upstream blobs/deletions, and task-before-comment lock order before the notice source lock/intent. Full binary identity is not claimed for the overlapping files. The final follow-up changes acceptance documentation only. - **202 distinct combined-master cases pass on `1720b685d9d5fb9cb4377223fdaa0a9fae2aff9c`:** 148 across the real PostgreSQL receipt concurrency and two comment route suites, plus 54 notice/FIFO/producer cases (1,598 skipped). Current server typechecking/build and module boundaries pass; all 21 owned source/test hashes are unchanged after the documentation follow-up. - The earlier CI voice-reconstruction quiet assertion failed once after 105 fetches. All 15 voice lifecycle cases pass unchanged locally; no assertions or production code were relaxed and no CI retry was used. The original failure remains in the audit, and current-head full CI must pass. - **Earlier restoration repair (head `28c898e93504318056d7a6ab2bbc5eafa6b639ae`): 126 distinct selected cases pass (54 ordinary-notice/producer cases with 1,598 skipped, plus 72 separate Retry/budget cases with 1,580 skipped).** Fix the blocking [restored input stays silent](https://github.com/paperclipai/paperclip/pull/17647#discussion_r4404007181) finding. A failed Retry's earlier receipt wake can drain before `releaseUnadmittedTaskRetryInput` restores ordinary receipts in a later transaction. The restoring transaction now awaits the existing company/task notification before its guarded write. - The new causal regression builds a durable unadmitted failed-Retry owner, uses the real input-claim owner, consumes an earlier committed wake, and proves two minutes of worker quiet with no notice. The real release transaction then restores the ordinary row, emits intent/commit settlement, and reactivates the coordinator to create its queued notice. It fails before the hook with the restored row visible but no intent. This is real PostgreSQL producer/consumer boundary proof with seeded Retry-refusal state; the separate compatibility selection exercises existing supported GitHub Retry flows. It is not a claimed complete public-request failure walkthrough. - **Earlier discovery-repair acceptance (revision `4a713edfa06b9d3dcc0e81e900006fbdb973b2d9`): 267 distinct selected cases pass:** 142 complete cancellation/app/admission consumer cases, 53 ordinary-notice cases, and 72 separate retry/budget cases. The selections skip 1,598 and 1,579 other cases. The final master integration leaves this complete patch byte-identical; the new master paths concern provider workspace/session reuse and E2E reports. - Fix the valid P2 [discovery rereads denied history](https://github.com/paperclipai/paperclip/pull/17647#discussion_r4386889825), despite the previous head's 5/5 summary. Task hints now need zero discovery SQL; company/startup discovery returns DISTINCT company/task identifiers with one query. Bounded projection still checks current authority and candidates. Three real database regressions with 250 denied actions and a second company fail before the fix and pass afterward, checking actual query and returned-row counts. Broad SQL may still inspect candidate history; no constant database-cost claim is made. - Both new producer tests fail before their fixes and pass afterward. One observes intent before a callback-owned receipt write and settlement after outer commit. The other uses real heartbeat admission/recovery and a PostgreSQL row lock to prove exact company/task intent exists before conditional denial retirement; the outside connection still sees deferred status until commit. - The reader can select a retry-owned deferred receipt for later ordinary input, and receipt resolution follows its owner. Disabling coalescing on the retry's own request does not exclude that later path. The two transferred notifications close the actual writer gaps; current authorization remains enforced by existing consumers. This is producer/transaction proof, not a claimed complete mixed-ingress walkthrough. - The moved callback exposes an old test mock that authorized against the root DB. The existing #17649 fixture correction authorizes and writes in one transaction; original requester, comment, skipped-status and budget assertions remain. Both the initial invalid lock fixture and the corrected missing-intent failures are preserved in the audit. - Updated shared dependency build, server typechecking/build and module-boundary checks pass. Because master also changed agent configuration, upgrade admission and runner contracts, the same 264 cases were rerun on combined revision `875a6ee8d211cba7f0499aace7fcea7f6880c4a2` with new isolated databases. Repeated checks are not 528 different tests. Tests under `src/__tests__` were executed separately from production typechecking. - [Current acceptance and preserved failure ledger](https://github.com/paperclipai/paperclip/blob/41f7e2c9e351efa6cfa5ef699c0e69df7425012b/doc/plans/2026-10-25-inbound-wake-notices-sleep-audit.md#shared-retry-receipt-producer-gap-resolved-october-28) records current scope, exact commands, actual fixtures, source hashes and remaining limits. Earlier baseline/full-suite counts retain their original revision scope. - Server typechecking/build and module boundaries pass after the current restoration hook. The earlier combined-master compilation at `661f2f6da8c6c22cdcc25edccd57502ee6fa8c4b` retains its original scope. - **Current exact-head CI is green and Greptile is 5/5.** The old head's review error is not a pass. No full local repository matrix, live provider tenant, external alarm, physical suspension or savings claim is made. Following notice duties retain their separate owners. ### Final merge verification - Reviewed PR head remains `41f7e2c9e351efa6cfa5ef699c0e69df7425012b`: exact-head Greptile 5/5, all 55 check contexts clean, no unresolved review threads, and no Ponytail verdict present. - One Runner shard initially failed descriptor suspension and a five-second OpenCode fixture deadline. The same package passed on the preceding head. The first local attempt was invalid because required Rust fixture binaries were absent; after building them, all eight relevant cases pass with unchanged timeouts. One failed-job retry then passes 1,956 cases (four skipped). No source/assertion/time-budget change or second CI retry was used. - Current master `0e365768f032a472807b5720da8431f92d2f07ce` adds manual monitor and task-drain fixes. Its routes file overlaps but merges cleanly. Local normal-merge validation commit `2720b79c769cb609ad4d392512331e67b910ff3f` preserves both complete added/removed patches and every unrelated master blob/deletion. This is not raw binary identity for overlapping files. - **156 distinct cases pass on that exact combined tree:** all 102 monitor/drain/policy cases plus 54 notice cases (1,598 skipped). Types and build pass. An initial competing-cold-check run failed setup hooks (38 passed, two hook failures, 62 skipped); the same four suites pass alone with all original hook/test timeouts and assertions intact. The failed run remains recorded, not counted as full acceptance. - This merge proof changes no published source or review head. The protected pinned-head merge guard recomputes both patches and requires the exact tested master/tree/head, successful validation receipts and unchanged live merge gates. Previous acceptance counts retain their original revision scope; no local full-repository or deployed-sleep claim is made. Master then advanced to `75754ffb7af2b7ee7dce1e915a145faa82a8fb2a` with #17976 (five UI/e2e/map files only). The entire `server` and `packages` trees are identical to the validated composition. Both complete patches and all newly landed UI files are preserved in the final clean merge tree; the protected gate checks these exact source-root hashes and changed paths. No additional UI-test result is claimed. ## Risks - Missing a supported producer could delay a notice until restart. Tests cover real service and API producers; durable rows remain authoritative. Notifications cover supported application writers in the owning process, not arbitrary external SQL writers. - Privacy invalidation can affect descendants, so those changes use company scope. Ordinary admission and publication changes use task scope. Settled history causes no recurring scan. - Provider output remains subject to current authority and receipt ownership. A cancelled request without a visible queued notice does not acquire a new failure message. - This establishes worker quiet, not host or database suspension. Cloud alarms and conservative sleep gates do not change. Other maintenance remains in place. - Remaining stacked slices still need their own integration, review, and CI. This slice merges only under the user’s explicit campaign authorization after current-head gates pass. ## Model Used OpenAI Codex, GPT-6 family, with repository inspection and code execution tools. The exact serving variant 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>
1463 lines
57 KiB
TypeScript
1463 lines
57 KiB
TypeScript
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";
|
|
|
|
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 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.");
|
|
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);
|
|
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;
|
|
},
|
|
};
|
|
}
|