mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
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:
commit
376dc050a8
14 files changed
+317
-183
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user