Files
PaperClipAI/server/src/services/grok-bot/workspace-inspection.ts
T
2026-10-30 16:44:21 -05:00

90 lines
6.3 KiB
TypeScript

import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
import { dotRunnerAssignments, environmentLeases, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { authorizeExternalBotOperator } from "../external-bot-authority.js";
import { logActivity } from "../activity-log.js";
import { McpOAuthError } from "../public-mcp/oauth.js";
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
import { hasRequiredWorkspaceRecovery, nativeRunHasNoWorkspaceAccess } from "../workspace-restore-recovery-state.js";
/** Old Runner synchronization descriptors could incorrectly require a file
* export for a Bot explicitly admitted without workspace access. Keep the old
* execution fenced and its no-replay hold; inspection only retires that false
* file obligation. Native Runner cleanup never certifies global Grok exit. */
export async function grokWorkspaceInspectionCandidates(db: Db, companyId: string, agentId: string) {
const runs = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId),
eq(heartbeatRuns.driverKind, "grok_bot_mcp"), isNotNull(heartbeatRuns.finishedAt),
inArray(heartbeatRuns.status, ["cancelled", "failed", "timed_out", "interrupted"]),
sql`${heartbeatRuns.resultJson}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'`,
));
return runs.filter(run => nativeRunHasNoWorkspaceAccess(run) && hasRequiredWorkspaceRecovery(run.resultJson));
}
export async function inspectGrokWorkspaceObligations(db: Db, companyId: string, agentId: string, operatorId: string) {
for (const selected of await grokWorkspaceInspectionCandidates(db, companyId, agentId)) {
// Independent task transactions keep the native task/coordinator/run lock
// order out of the routine writer's binding/routine lock order. A partial
// inspection grants no authority and never schedules work.
await db.transaction(async tx => {
const database = tx as unknown as Db;
await authorizeExternalBotOperator(database, companyId, operatorId);
const issueId = selected.nativeIssueId ?? selected.issueId;
if (!issueId) return;
const [task] = await tx.select().from(issues).where(and(
eq(issues.companyId, companyId), eq(issues.id, issueId),
)).for("update");
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, companyId), eq(nativeRunFinalizations.runId, selected.id),
eq(nativeRunFinalizations.issueId, issueId),
)).for("update");
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.id, selected.id),
)).for("update");
if (!run || !hasRequiredWorkspaceRecovery(run.resultJson)) return;
const fail = () => new McpOAuthError("access_denied", "The stopped task still needs verified Runner cleanup before inspection can clear its file obligation.", 409);
if (!task || !nativeRunHasNoWorkspaceAccess(run) || run.driverKind !== "grok_bot_mcp"
|| !run.finishedAt || !["cancelled", "failed", "timed_out", "interrupted"].includes(run.status)
|| !coordinator || coordinator.phase !== "terminal_failure" || coordinator.leaseOwner
|| coordinator.failureDetail?.successorRunId) throw fail();
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ["running", "queued", "scheduled_retry"]),
or(eq(heartbeatRuns.nativeIssueId, issueId), eq(heartbeatRuns.issueId, issueId)),
)).limit(1);
const work = await tx.select().from(dotRunnerAssignments).where(and(
eq(dotRunnerAssignments.companyId, companyId), eq(dotRunnerAssignments.agentId, agentId),
eq(dotRunnerAssignments.runId, run.id),
));
const leases = await tx.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id),
));
if (active || !work.length || work.some(row => row.status !== "fenced") || !leases.length
|| !leases.every(lease => lease.issueId === issueId && hasRemoteTerminationReceipt(lease))) throw fail();
const actions = await tx.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId),
or(sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
sql`${issueRecoveryActions.evidence}->>'sourceRunId' = ${run.id}`),
)).for("update");
const inspection = { schema: "paperclip.grok-workspace-inspection.v1", runId: run.id,
inspectedBy: operatorId, inspectedAt: new Date().toISOString(), workspaceAccess: "none",
remoteTerminationConfirmed: false, runnerLeaseIds: leases.map(lease => lease.id),
previousWorkspaceRestoreFailure: run.resultJson?.workspaceRestoreFailure,
previousWorkspaceRestoreRecovery: run.resultJson?.workspaceRestoreRecovery };
const retireFileObligation = (value: Record<string, unknown>) => {
const { workspaceRestoreFailure: _failure, workspaceRestoreRecovery: _recovery, ...rest } = value;
return { ...rest, externalWorkspaceInspection: inspection };
};
await tx.update(heartbeatRuns).set({ resultJson: retireFileObligation(run.resultJson!) }).where(eq(heartbeatRuns.id, run.id));
if (actions.length) await notifyAttentionRetentionWork(tx, { companyId, sourceKind: "recovery_action" });
for (const action of actions) {
await tx.update(issueRecoveryActions).set({ evidence: retireFileObligation(action.evidence), updatedAt: new Date(),
nextAction: "Inspect Grok and verify its connection before sending a new task message. The old work will not be replayed.",
}).where(eq(issueRecoveryActions.id, action.id));
}
await logActivity(database, { companyId, actorType: "user", actorId: operatorId,
action: "grok.workspace_obligation_inspected", entityType: "issue", entityId: issueId,
agentId, runId: run.id, details: inspection });
});
}
}