Merge qualified workspace storage and external owner integration into PR 18419

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* commit '6d54fb819d1ac35ea2eec96b99564a27ea2f54b7':
  fix(server): schedule only outstanding merge confirmations (#18382)
  fix(workspaces): inspect storage only when needed (#18410)
This commit is contained in:
DottaandPaperclip committed 2026-10-31 13:50:25 -05:00
commit 376dc050a8
14 files changed
+317 -183

No files matched your search

+74 -68
View File
@@ -1,6 +1,6 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js";
import { inspectPluginWorkspace, removePluginWorkspace, restorePluginWorkspace, type WorkspaceWorkers } from "./plugin-workspaces.js";
import { inspectPluginWorkspace, removePluginWorkspace, restorePluginWorkspace, pluginWorkspaceSupportsMergeAncestry, type WorkspaceWorkers } from "./plugin-workspaces.js";
import { acquireExecutionWorkspaceLifecycleLock, withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.js";
import { measureRunTransaction } from "./execution-transaction-timing.js";
import { measureRunPhase } from "./execution-preparation-timing.js";
@@ -31,7 +31,6 @@ import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import type { SQL } from "drizzle-orm";
import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js";
import { nativeSessionProviderEvidence } from "./native-runtime/native-session-resume.js";
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
import { notifyTerminalWorkspaceWork, TERMINAL_WORKSPACE_RETRY_MS } from "./terminal-workspace-work.js";
@@ -340,10 +339,7 @@ export type ExecutionWorkspaceServiceOptions = {
// Test hook. Runs after compaction moves directories out of the worktree and
// before it re-checks the guard under the lifecycle lock.
afterWorkspaceCompactionMove?: (workspace: ExecutionWorkspaceRow) => Promise<void>;
inspectGitCloseReadiness?: (workspace: ExecutionWorkspace) => Promise<{
git: ExecutionWorkspaceCloseGitReadiness | null;
warnings: string[];
}>;
inspectGitCloseReadiness?: typeof inspectGitCloseReadiness;
};
function parseGitHubRepository(repoUrl: string | null) {
@@ -1370,8 +1366,6 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & {
executionWorkspaceId: string;
};
const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness);
async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: {
companyId: string; issueId: string; excludeRunId?: string;
}) {
@@ -1431,7 +1425,7 @@ const WORKSPACE_COMPACTION_TRASH_PURGE_INTERVAL_MS = 30 * 60 * 1000;
export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) {
async function inspectWorkspace(workspace: ExecutionWorkspace) {
if (workspace.providerType !== "plugin") return inspectGitCloseReadiness(workspace);
if (workspace.providerType !== "plugin") return (opts.inspectGitCloseReadiness ?? inspectGitCloseReadiness)(workspace);
const [row] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspace.id), eq(executionWorkspaces.companyId, workspace.companyId)));
if (!row) throw notFound("Workspace is unavailable");
let state;
@@ -1729,13 +1723,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
...(selectedWorkspace ? { projectWorkspaceId: selectedWorkspace.projectWorkspaceId } : {}) };
}
const inspectLocalDisplay = opts.inspectGitCloseReadiness
? createWorkspaceGitInspectionCache(opts.inspectGitCloseReadiness)
: inspectGitForDisplay;
const inspectProviderDisplay = createWorkspaceGitInspectionCache(inspectWorkspace);
const inspectDisplay = (workspace: ExecutionWorkspace) => workspace.providerType === "plugin"
? inspectProviderDisplay(workspace)
: inspectLocalDisplay(workspace);
const recoveryActionsSvc = issueRecoveryActionService(db);
const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db);
const now = opts.now ?? (() => new Date());
@@ -1915,14 +1902,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
)));
}
async function listDeliveryPullRequestProducts(
workspace: Pick<ExecutionWorkspaceRow, "companyId" | "sourceIssueId">,
async function listDeliveryPullRequestReferences(
workspace: ExecutionWorkspaceRow,
) {
const sourceIssueId = workspace.sourceIssueId;
if (!sourceIssueId) return [];
// A read-only lookup: rebuild it after a transient pooled disconnect.
return retryIdempotentDatabaseOperation(() => db
const products = await retryIdempotentDatabaseOperation(() => db
.select({
id: issueWorkProducts.id,
url: issueWorkProducts.url,
@@ -1939,6 +1926,27 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
))
.orderBy(desc(issueWorkProducts.updatedAt))
.limit(100));
return products.flatMap(product => extractGitHubPullRequestReferences([
product.url, product.externalId, product.title, product.summary,
product.metadata ? JSON.stringify(product.metadata) : null,
])).filter(reference => pullRequestMatchesWorkspaceRepository(reference, workspace));
}
async function cachedDeliveryPullRequestDetails(workspace: ExecutionWorkspaceRow, reference: GitHubPullRequestReference) {
const key = `${workspace.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
const cached = pullRequestStateCache.get(key);
if (cached && now().getTime() - cached.checkedAtMs < pullRequestStateCacheTtlMs) return cached.details;
const details = await resolvePullRequestDetails(workspace.companyId, reference);
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, { details, checkedAtMs: now().getTime() });
return details;
}
async function hasMergedDeliveryPullRequest(workspace: ExecutionWorkspaceRow, references: GitHubPullRequestReference[]) {
for (const reference of references) {
const details = await cachedDeliveryPullRequestDetails(workspace, reference);
if (details.state === "merged" && details.headRef === workspace.branchName && details.headSha) return true;
}
return false;
}
async function assessDelivery(
@@ -1970,48 +1978,23 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
: null;
if (sourceIssueTerminal) {
const products = await listDeliveryPullRequestProducts(workspace);
for (const product of products) {
const references = extractGitHubPullRequestReferences([
product.url,
product.externalId,
product.title,
product.summary,
product.metadata ? JSON.stringify(product.metadata) : null,
]);
if (references.length === 0) continue;
for (const reference of references) {
if (!pullRequestMatchesWorkspaceRepository(reference, workspace)) continue;
const key = `${workspace.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
const cached = pullRequestStateCache.get(key);
let details;
if (cached && now().getTime() - cached.checkedAtMs < pullRequestStateCacheTtlMs) {
details = cached.details;
} else {
details = await resolvePullRequestDetails(workspace.companyId, reference);
setBoundedPullRequestCacheEntry(
pullRequestStateCache,
key,
{ details, checkedAtMs: now().getTime() },
);
}
if (
details.state === "merged"
&& details.headRef === workspace.branchName
&& details.headSha === workspaceHeadSha
&& workspaceHeadSha !== null
) {
mergedPullRequest = true;
break;
}
if (
details.state === "unknown"
|| (details.state === "merged" && (!details.headRef || !details.headSha || !workspaceHeadSha))
) {
pullRequestStateUnknown = true;
}
for (const reference of await listDeliveryPullRequestReferences(workspace)) {
const details = await cachedDeliveryPullRequestDetails(workspace, reference);
if (
details.state === "merged"
&& details.headRef === workspace.branchName
&& details.headSha === workspaceHeadSha
&& workspaceHeadSha !== null
) {
mergedPullRequest = true;
break;
}
if (
details.state === "unknown"
|| (details.state === "merged" && (!details.headRef || !details.headSha || !workspaceHeadSha))
) {
pullRequestStateUnknown = true;
}
if (mergedPullRequest) break;
}
}
@@ -2059,13 +2042,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
}
}
async function hydrateWorkspace(row: ExecutionWorkspaceRow, runtimeServices: WorkspaceRuntimeService[] = []) {
const workspace = toExecutionWorkspace(row, runtimeServices);
const { git } = await inspectDisplay(workspace);
const assessment = await assessDelivery(row, git);
return toExecutionWorkspace(row, runtimeServices, assessment.deliveryState);
}
async function workspaceHasActiveRun(workspace: Pick<ExecutionWorkspaceRow, "id" | "companyId" | "sourceIssueId">) {
const linkedIssues = await db
.select({
@@ -3059,7 +3035,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
projectWorkspaceId: row.projectWorkspaceId,
});
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
return hydrateWorkspace(
return toExecutionWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
);
@@ -3691,6 +3667,25 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
else result.skippedActiveRun += 1;
continue;
}
let deliveryReferences: GitHubPullRequestReference[] | null = null;
if (workspace.providerType === "plugin" && !isClosedExecutionWorkspaceStatus(workspace.status)
&& !reopenPending && !workspace.metadata?.workspaceProviderRemoval && !workspace.metadata?.workspaceProviderRestoration) {
try {
if (!(await pluginWorkspaceSupportsMergeAncestry(db, opts.pluginWorkerManager, workspace))) {
deliveryReferences = await listDeliveryPullRequestReferences(workspace);
if (deliveryReferences.length === 0) {
result.skippedUndelivered += 1;
if (compactionPending && compactedTerminalAt.get(workspace.id) !== latestTerminalAt)
terminalWorkspaceDeadlines.set(workspace.id, compactionDueAt);
else terminalWorkspaceDeadlines.delete(workspace.id);
continue;
}
}
} catch {
result.skippedUndelivered += 1;
continue;
}
}
if (opts.terminalWorkspaceInspectionBackoffMs) {
const revision = createHash("sha256").update(JSON.stringify([
workspace.updatedAt, workspace.metadata,
@@ -3707,6 +3702,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
nextAt: now().getTime() + opts.terminalWorkspaceInspectionBackoffMs,
});
}
if (deliveryReferences !== null) {
try {
if (!(await hasMergedDeliveryPullRequest(workspace, deliveryReferences))) {
result.skippedUndelivered += 1;
continue;
}
} catch {
result.skippedUndelivered += 1;
continue;
}
}
const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd);
const createdByRuntime = workspace.providerType === "git_worktree"
? isRuntimeOwnedGitBranch(workspace.metadata)
+32 -22
View File
@@ -89,6 +89,14 @@ export function pluginRegistryService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function writeWorkspaceProviderChange<T>(reconsiderWorkspaces: boolean, write: (database: Db) => Promise<T>): Promise<T> {
return db.transaction(async tx => {
await notifyExternalObjectRefresh(tx);
if (reconsiderWorkspaces) await notifyDeliveryWork(tx, DELIVERY_QUEUES.terminalWorkspaces);
return write(tx as unknown as Db);
});
}
async function nextInstallOrder(): Promise<number> {
const result = await db
.select({ maxOrder: sql<number>`coalesce(max(${plugins.installOrder}), 0)` })
@@ -240,15 +248,14 @@ export function pluginRegistryService(db: Db) {
setClause.categories = data.manifest.categories;
}
return db.transaction(async tx => {
await notifyExternalObjectRefresh(tx);
return tx
.update(plugins)
.set(setClause)
.where(eq(plugins.id, id))
.returning()
.then((rows) => rows[0] ?? null);
});
const workspaceProviderChanged = data.manifest !== undefined
&& !isDeepStrictEqual(plugin.manifestJson.workspaceProvider, data.manifest.workspaceProvider);
return writeWorkspaceProviderChange(workspaceProviderChanged, database => database
.update(plugins)
.set(setClause)
.where(eq(plugins.id, id))
.returning()
.then((rows) => rows[0] ?? null));
},
// ----- Status ---------------------------------------------------------
@@ -258,19 +265,18 @@ export function pluginRegistryService(db: Db) {
const plugin = await getById(id);
if (!plugin) throw notFound("Plugin not found");
return db.transaction(async tx => {
await notifyExternalObjectRefresh(tx);
return tx
.update(plugins)
.set({
status: input.status,
lastError: input.lastError ?? null,
updatedAt: new Date(),
})
.where(eq(plugins.id, id))
.returning()
.then((rows) => rows[0] ?? null);
});
const workspaceProviderActivated = Boolean(plugin.manifestJson.workspaceProvider)
&& plugin.status !== "ready" && input.status === "ready";
return writeWorkspaceProviderChange(workspaceProviderActivated, database => database
.update(plugins)
.set({
status: input.status,
lastError: input.lastError ?? null,
updatedAt: new Date(),
})
.where(eq(plugins.id, id))
.returning()
.then((rows) => rows[0] ?? null));
},
// ----- Uninstall / Remove --------------------------------------------
@@ -490,6 +496,10 @@ export function pluginRegistryService(db: Db) {
))
.then((rows) => rows[0] ?? null);
if (plugin.manifestJson.workspaceProvider && existing?.enabled === false && input.enabled === true) {
await notifyDeliveryWork(db, DELIVERY_QUEUES.terminalWorkspaces);
}
if (existing) {
return db
.update(pluginCompanySettings)
+7
View File
@@ -70,6 +70,13 @@ async function provider(
return plugin;
}
export async function pluginWorkspaceSupportsMergeAncestry(db: Db, workers: WorkspaceWorkers | undefined, workspace: Workspace) {
const pluginId = readWorkspaceProviderBinding(workspace.metadata)?.pluginId;
if (!pluginId) return true;
const selected = await provider(db, workers, workspace.companyId, pluginId);
return selected.manifestJson.workspaceProvider?.supportsMergeAncestry !== false;
}
/** No paths or provider credentials are accepted from task callers. */
export async function callWorkspaceProvider(
db: Db,
@@ -1,30 +0,0 @@
import type { ExecutionWorkspace } from "@paperclipai/shared";
/** Short-lived display cache only. Destructive operations must inspect afresh. */
export function createWorkspaceGitInspectionCache<T>(inspect: (workspace: ExecutionWorkspace) => Promise<T>) {
const entries = new Map<string, { expiresAt: number; promise: Promise<T> }>();
return (workspace: ExecutionWorkspace): Promise<T> => {
const key = JSON.stringify([
workspace.companyId, workspace.id, workspace.updatedAt, workspace.providerType,
workspace.providerRef, workspace.cwd, workspace.repoUrl, workspace.baseRef,
workspace.branchName, workspace.metadata,
]);
const now = Date.now();
const existing = entries.get(key);
if (existing && existing.expiresAt > now) return existing.promise;
for (const [candidate, entry] of entries) {
if (entry.expiresAt <= now) entries.delete(candidate);
}
if (entries.size >= 256) entries.delete(entries.keys().next().value!);
const entry = { expiresAt: Number.POSITIVE_INFINITY, promise: Promise.resolve().then(() => inspect(workspace)) };
entries.set(key, entry);
entry.promise = entry.promise.then((result) => {
entry.expiresAt = Date.now() + 30_000;
return result;
}, (error) => {
if (entries.get(key) === entry) entries.delete(key);
throw error;
});
return entry.promise;
};
}