Integrate current master before Copilot profile 35 freeze

Preserve upstream agent lifecycle and dependency updates without changing the Copilot runtime attestation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 11:45:45 -05:00
commit 2a704c2cf8
198 files changed
+14211 -6906

No files matched your search

+2 -5
View File
@@ -1,3 +1,5 @@
import type { ActivityPublication } from "../types/activity-publication.js";
export type { ActivityPublication } from "../types/activity-publication.js";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -66,11 +68,6 @@ export interface LogActivityInput {
responsibleUserIdOverride?: string | null;
}
export interface ActivityPublication {
companyId: string;
payload: Record<string, unknown>;
pluginEvent: PluginEvent | null;
}
export async function createActivityDetailsRedactor(db: Db) {
const currentUserRedactionOptions = {
@@ -0,0 +1,12 @@
import type { Db } from "@paperclipai/db";
import type { AgentConfigurationPatch, UpdateAgentOptions } from "../lib/agent-records.js";
import { conflict } from "../errors.js";
import { updateAgentConfiguration } from "../modules/agent-lifecycle/configuration.js";
import { createAgentLifecycleEffects } from "./agent-lifecycle.js";
export function updateAgentConfigurationInTransaction(tx: Db, id: string, data: Omit<AgentConfigurationPatch, "budgetMonthlyCents">, options?: UpdateAgentOptions) {
if (Object.prototype.hasOwnProperty.call(data, "budgetMonthlyCents")) {
throw conflict("Budget changes require a root lifecycle command");
}
return updateAgentConfiguration(tx, createAgentLifecycleEffects(), id, data, options);
}
+150
View File
@@ -0,0 +1,150 @@
import { and, eq } from "drizzle-orm";
import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db";
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
import { conflict, notFound, unprocessable } from "../errors.js";
import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot,
hasConfigPatchFields, changedPendingApprovalConfigFields, diffConfigSnapshot } from "../lib/agent-records.js";
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
import type { ActivityPublication } from "../types/activity-publication.js";
import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
import { agentCredentialService } from "./agent-credentials.js";
import { clearPrimaryAgent } from "./primary-agent.js";
import { budgetServiceInTransaction } from "./budgets.js";
export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch> {
const id = existing.id;
const { ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
if (existing.status === "pending_approval" && !options?.allowPendingApprovalConfigUpdate) {
const changedFields = changedPendingApprovalConfigFields(agentRecordQueries(db).normalizeAgentRow(existing), data);
if (changedFields.length > 0) {
throw conflict("Pending approval agent configuration cannot be changed before board approval", {
code: "pending_approval_agent_config_frozen",
agentId: id,
fields: changedFields,
});
}
}
if (data.reportsTo !== undefined) {
if (data.reportsTo) {
await ensureManager(existing.companyId, data.reportsTo);
}
await assertNoCycle(id, data.reportsTo);
}
if (data.name !== undefined) {
const previousShortname = normalizeAgentUrlKey(existing.name);
const nextShortname = normalizeAgentUrlKey(data.name);
if (previousShortname !== nextShortname) {
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
}
}
if (Object.prototype.hasOwnProperty.call(data, "metadata")) {
assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options);
}
if (data.appearance?.customAvatarAssetId) {
const [asset] = await db.select().from(assets).where(and(
eq(assets.id, data.appearance.customAvatarAssetId), eq(assets.companyId, existing.companyId),
eq(assets.createdByAgentId, id),
));
if (!asset || asset.contentType !== "image/png" || !asset.objectKey.startsWith(`${existing.companyId}/agent-avatars/${id}/`)) {
throw unprocessable("Use the avatar upload endpoint to set this agent's image");
}
}
const normalizedPatch = { ...data } as AgentConfigurationPatch;
if (data.permissions !== undefined) {
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
}
if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
isPlainRecord(normalizedPatch.adapterConfig)
) {
const normalizedAdapterConfig = await secretService(db).normalizeAdapterConfigForPersistence(
existing.companyId,
normalizedPatch.adapterConfig,
{ adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string },
);
normalizedPatch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
(normalizedPatch.adapterType ?? existing.adapterType) as string,
normalizedAdapterConfig,
);
} else if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterType")
&& isPlainRecord(existing.adapterConfig)
) {
normalizedPatch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
normalizedPatch.adapterType as string,
existing.adapterConfig,
);
}
return normalizedPatch;
}
export async function completeAgentConfiguration(txDb: Db, existing: AgentConfigurationRecord, updated: AgentConfigurationRecord, normalizedPatch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]) {
const id = updated.id;
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
const beforeConfig = shouldRecordRevision
? buildConfigSnapshot(agentRecordQueries(txDb).normalizeAgentRow(existing))
: null;
if (updated.status === "terminated") {
await clearPrimaryAgent(txDb, updated.companyId, id);
}
const priorAdapterConfig = isPlainRecord(existing.adapterConfig) ? existing.adapterConfig : {};
const afterConfig = isPlainRecord(updated.adapterConfig) ? updated.adapterConfig : {};
const changedExecution = updated.adapterType !== existing.adapterType
|| (updated.adapterType === "paperclip_runner" && ["provider", "acpxAgent", "model"].some(
(key) => priorAdapterConfig[key] !== afterConfig[key],
));
if (changedExecution) {
await txDb.delete(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, existing.companyId), eq(agentTaskSessions.agentId, id)));
await txDb.update(agentRuntimeState).set({ adapterType: updated.adapterType, sessionId: null, stateJson: {}, updatedAt: new Date() })
.where(and(eq(agentRuntimeState.companyId, existing.companyId), eq(agentRuntimeState.agentId, id)));
}
if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) {
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
companyId: existing.companyId, consume: false, environmentId: null,
childAdapterConfig: updated.adapterConfig, claudeLogin: options?.claudeLogin,
decision: assertClaudeOAuthBindingInvariant({ adapterType: updated.adapterType,
nextConfig: updated.adapterConfig, priorConfig: existing.adapterConfig }),
});
await agentCredentialService(txDb).syncAgentSecretBindings(
updated, txDb,
existing.adapterConfig,
options?.recordRevision,
);
}
if (normalizedPatch.budgetMonthlyCents !== undefined) {
await budgetServiceInTransaction(txDb, publications).upsertPolicy(existing.companyId, {
scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents,
isActive: normalizedPatch.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
}, options?.recordRevision?.createdByUserId ?? null);
}
const normalizedUpdated = await agentRecordQueries(txDb).getById(updated.id);
if (!normalizedUpdated) {
throw notFound("Agent not found");
}
if (shouldRecordRevision && beforeConfig) {
const afterConfig = buildConfigSnapshot(normalizedUpdated);
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
if (changedKeys.length > 0) {
await txDb.insert(agentConfigRevisions).values({
companyId: normalizedUpdated.companyId,
agentId: normalizedUpdated.id,
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
source: options?.recordRevision?.source ?? "patch",
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
changedKeys,
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
afterConfig: afterConfig as unknown as Record<string, unknown>,
});
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import { eq } from "drizzle-orm";
import { agents, type Db } from "@paperclipai/db";
import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
import { logActivity } from "./activity-log.js";
import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError,
CLAUDE_LOCAL_ADAPTER_TYPE, readClaudeOAuthBinding, secretService, type ClaudeOAuthBindingInvariantDecision } from "./secrets.js";
import { createDbSetupTokenCleanupStore } from "./setup-token-session.js";
import type { ClaudeLoginContext, RevisionMetadata } from "../lib/agent-records.js";
export function agentCredentialService(db: Db) {
const secretsSvc = secretService(db);
async function syncAgentSecretBindings(
agent: { id: string; companyId: string; adapterConfig: unknown },
dbClient: Db = db,
previousAdapterConfig: unknown = null,
actor: RevisionMetadata = {},
) {
const scopedSecretsSvc = dbClient === db ? secretsSvc : secretService(dbClient);
await syncAgentAdapterEnvBindings({
secretsSvc: scopedSecretsSvc,
companyId: agent.companyId,
agentId: agent.id,
adapterConfig: agent.adapterConfig,
});
const previousRefs = new Set([
...collectSecretRefs(previousAdapterConfig).map((ref) => `secret:${ref.secretId}:${ref.configPath}`),
...collectUserSecretRefs(previousAdapterConfig).map((ref) => `user:${ref.definitionKey}:${ref.configPath}`),
]);
const createdRefs = [
...collectSecretRefs(agent.adapterConfig).map((ref) => ({
key: `secret:${ref.secretId}:${ref.configPath}`,
configPath: ref.configPath,
bindingType: "secret_ref",
secretId: ref.secretId,
definitionKey: null,
})),
...collectUserSecretRefs(agent.adapterConfig).map((ref) => ({
key: `user:${ref.definitionKey}:${ref.configPath}`,
configPath: ref.configPath,
bindingType: "user_secret_ref",
secretId: null,
definitionKey: ref.definitionKey,
})),
].filter((ref) => !previousRefs.has(ref.key));
const actorType = actor.createdByUserId ? "user" as const : actor.createdByAgentId ? "agent" as const : "system" as const;
const actorId = actor.createdByUserId ?? actor.createdByAgentId ?? "system";
for (const ref of createdRefs) {
await logActivity(dbClient, {
companyId: agent.companyId,
actorType,
actorId,
agentId: actor.createdByAgentId ?? null,
action: "secret.binding.created",
entityType: "agent",
entityId: agent.id,
details: {
targetType: "agent",
targetId: agent.id,
configPath: ref.configPath,
bindingType: ref.bindingType,
secretId: ref.secretId,
definitionKey: ref.definitionKey,
},
});
}
}
/**
* Enforces the Claude OAuth binding claim inside a write transaction. It runs
* after {@link assertClaudeOAuthBindingInvariant} decided that the write
* introduces or keeps the fixed binding.
*
* When the write introduces the fixed binding:
* * A create or hire path (`consume: true`) consumes a stored-session claim
* with one conditional write. It builds the claim scope from the company,
* the owner user, the fixed adapter, the environment, and the
* `storedSessionId`. It inserts the binding only when the write returns one
* row; otherwise it raises the fixed claim error, which rolls back the
* whole transaction and inserts no binding.
* * An update, approval, or rollback path (`consume: false`) carries no
* claim, so it raises the same fixed claim error at once.
*
* The user-actor apply-existing path (`applyExistingWithoutClaim`) binds the
* fixed reference with no login round trip. The route sets the flag only for
* an authenticated user actor and derives the owner from that actor. The gate
* permits the no-claim bind only when the owner already has a stored value for
* the company. It reads the owner value status; it reads no token. A missing
* owner or a missing stored value raises the same fixed claim error, so the
* caller cannot tell the reasons apart.
*
* The hire-inheritance path (`inheritedFromAgentId`) binds the fixed
* reference with no login round trip and no stored owner value, because the
* owning user resolves per run, not from a value stored against this agent.
* The route copies the parent's reference onto the child before this
* transaction starts, so a concurrent version change on the parent can
* leave the child holding a stale version. The gate re-reads the named
* parent agent inside this transaction and permits the bind only when the
* parent exists, is in the same company, is a `claude_local` agent, and its
* current reference matches the child's copied reference exactly, including
* the version selector. The gate locks the parent row with `SELECT ...
* FOR UPDATE` before it reads the reference. The lock blocks a concurrent
* credential rotation on the same parent row until this transaction
* commits or rolls back, so the compare-and-bind check stays atomic with
* the parent's current state. The route derives the parent identifier
* from the authenticated agent actor, never from the request body, so the
* gate treats it as a claim to verify, not a trusted value.
*
* A controlled internal override skips the claim for a migration or an
* administrator repair. The function creates the fixed user-secret definition
* before the caller runs the declaration synchronization, so the synchronized
* declaration always references an existing definition.
*/
async function enforceClaudeOAuthBindingClaim(
txDb: Db,
input: {
companyId: string;
decision: ClaudeOAuthBindingInvariantDecision;
consume: boolean;
environmentId: string | null;
claudeLogin?: ClaudeLoginContext;
/**
* The adapter config the write is about to persist. The
* `inheritedFromAgentId` path reads the child's copied
* `CLAUDE_CODE_OAUTH_TOKEN` reference from it, to compare against the
* parent's current reference.
*/
childAdapterConfig?: unknown;
},
): Promise<void> {
const ownerUserId = input.claudeLogin?.ownerUserId ?? null;
if (input.decision.introducesBinding && !input.claudeLogin?.allowInternalBindingOverride) {
if (input.claudeLogin?.applyExistingWithoutClaim) {
// The user-actor apply-existing path. The route derived the owner from
// the authenticated user actor. The gate binds the fixed reference only
// when that owner already has a stored value. It reads no token.
if (!ownerUserId) {
throw claudeOAuthClaimRejectedError();
}
const stored = await secretService(txDb).readClaudeOAuthUserSecretStatus(
input.companyId,
ownerUserId,
);
if (!stored) {
throw claudeOAuthClaimRejectedError();
}
} else if (input.claudeLogin?.inheritedFromAgentId) {
// The hire-inheritance path. Re-read the named parent inside this
// transaction; a caller-supplied identifier never binds on its own.
// Compare the parent's current reference against the reference
// already copied onto the child, including the version selector, so
// a concurrent version change on the parent cannot leave the child
// bound to a stale version.
const parentId = input.claudeLogin.inheritedFromAgentId;
const parent = await txDb
.select({
companyId: agents.companyId,
adapterType: agents.adapterType,
adapterConfig: agents.adapterConfig,
})
.from(agents)
.where(eq(agents.id, parentId))
.for("update")
.then((rows) => rows[0] ?? null);
const parentBinding = readClaudeOAuthBinding(parent?.adapterConfig ?? null);
const childBinding = readClaudeOAuthBinding(input.childAdapterConfig ?? null);
if (
!parent ||
parent.companyId !== input.companyId ||
parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE ||
!claudeOAuthBindingsMatchExactly(parentBinding, childBinding)
) {
throw claudeOAuthClaimRejectedError();
}
} else if (!input.consume) {
throw claudeOAuthClaimRejectedError();
} else {
const consumed = await createDbSetupTokenCleanupStore(txDb).consumeStoredClaim({
sessionId: input.claudeLogin?.storedSessionId ?? "",
companyId: input.companyId,
ownerUserId: ownerUserId ?? "",
adapterType: CLAUDE_LOCAL_ADAPTER_TYPE,
});
if (!consumed) {
throw claudeOAuthClaimRejectedError();
}
}
}
if (input.decision.introducesBinding || input.decision.keepsBinding) {
// Create the fixed definition before declaration synchronization.
await secretService(txDb).ensureClaudeOAuthUserSecretDefinition(input.companyId, {
userId: ownerUserId,
});
}
}
return { syncAgentSecretBindings, enforceClaudeOAuthBindingClaim };
}
+41
View File
@@ -0,0 +1,41 @@
import { and, eq, or, sql } from "drizzle-orm";
import { agents, agentApiKeys, agentRuntimeState, agentTaskSessions, agentWakeupRequests, activityLog,
budgetReservations, heartbeatRunEvents, heartbeatRuns, issueExecutionDecisions, issues, issueComments,
principalPermissionGrants, type Db } from "@paperclipai/db";
import { conflict } from "../errors.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
export async function deleteAgentDependencies(tx: Db, companyId: string, id: string) {
const [decisionHold] = await tx.select({ id: budgetReservations.id }).from(budgetReservations).where(and(
eq(budgetReservations.companyId, companyId), eq(budgetReservations.agentId, id),
eq(budgetReservations.state, "held"), sql`${budgetReservations.decisionInvocationId} is not null`,
)).limit(1);
if (decisionHold) throw conflict("Wait for active decisions or resolve their unknown charges in Costs before deleting this agent", {
code: "agent_decision_accounting_pending",
});
await issueThreadInteractionService(tx).cancelPendingForDeletedAddressee(companyId, id);
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
await tx
.update(issues)
.set({ assigneeAgentId: null, createdByAgentId: null })
.where(or(eq(issues.assigneeAgentId, id), eq(issues.createdByAgentId, id)));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
await tx.delete(activityLog).where(
or(
eq(activityLog.agentId, id),
sql`${activityLog.runId} in (select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${id})`,
),
);
await tx.delete(issueExecutionDecisions).where(eq(issueExecutionDecisions.actorAgentId, id));
await tx.delete(issueComments).where(eq(issueComments.authorAgentId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
await tx.delete(principalPermissionGrants).where(and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, "agent"),
eq(principalPermissionGrants.principalId, id),
));
}
@@ -0,0 +1,533 @@
import type { Db } from "@paperclipai/db";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import { parseObject } from "@paperclipai/adapter-utils/server-utils";
import { aiRoutingHarness, ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, type AiConnectionBinding } from "@paperclipai/shared";
import { requireServerAdapter } from "../adapters/index.js";
import { forbidden, HttpError, unprocessable } from "../errors.js";
import { instanceSettingsService } from "./instance-settings.js";
import { environmentService } from "./environments.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { resolveEnvironmentExecutionTarget } from "./environment-execution-target.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "./ai-connection-runtime.js";
import { aiConnectionService } from "./ai-connections.js";
import { validateAiApiKey } from "./ai-api-key-test.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
const asRecord = (value: unknown): Record<string, unknown> | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: PluginWorkerManager) {
const instanceSettings = instanceSettingsService(db);
const environmentsSvc = environmentService(db);
const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager });
// A null agent override inherits the instance default, just like dispatch.
// Resolve this before secrets or probes so a default remote environment can
// never accidentally validate the account on the control-plane host.
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
if (environmentId) return environmentId;
const settings = await instanceSettings.get();
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) {
const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId);
if (!managed) {
throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
code: "managed_sandbox_unavailable",
});
}
return managed.id;
}
return null;
}
/**
* Resolve the execution target the adapter should run its test probes against.
*
* - No environmentId / local environment → returns a local target so the
* adapter probes the Paperclip host (legacy behavior).
* - SSH environment → builds an SSH execution target from the environment
* config so the adapter probes the remote box. No lease is required:
* the SSH spec is fully derived from the saved environment config.
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
* workspace, and resolves a sandbox execution target wired to the runtime
* so the adapter probe runs inside the sandbox the same way a heartbeat
* would. The returned `release` callback rolls the lease back when the
* route is done.
*
* The caller MUST always invoke `release()` (typically in a `finally` block).
*/
async function resolveAdapterTestExecutionContext(input: {
agentId?: string;
companyId: string;
adapterType: string;
environmentId: string | null;
}): Promise<{
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
release: (status?: "released" | "failed") => Promise<void>;
}> {
const noopRelease = async () => {};
if (!input.environmentId) {
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [],
release: noopRelease,
};
}
const requestedEnvironment = await environmentsSvc.getById(input.environmentId);
if (!requestedEnvironment) {
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. The test did not run.",
},
],
release: noopRelease,
};
}
// Managed-sandbox-only policy: redirect a Test that would run on the local
// host onto the platform-managed sandbox, the same as a real run does
// (resolveExecutionWorkspaceEnvironmentId in heartbeat). Without this
// redirect the Test probes the local host while the run executes in the
// managed sandbox, so a passing Test validates the wrong execution target.
// With no active managed sandbox the Test fails closed — never local.
let environment = requestedEnvironment;
if (requestedEnvironment.driver === "local") {
const managedSandboxOnly =
(await instanceSettings.getExperimental()).enableManagedSandboxOnly === true;
if (managedSandboxOnly) {
const managedSandboxEnvironment = await environmentsSvc.findManagedSandboxEnvironment(
input.companyId,
);
if (!managedSandboxEnvironment) {
return {
executionTarget: null,
environmentName: requestedEnvironment.name,
fallbackChecks: [
{
code: "managed_sandbox_unavailable",
level: "error",
message:
"This instance runs agents only in its platform-managed sandbox, but no active managed sandbox environment exists. The test did not run.",
hint: "Restore the managed sandbox environment, then test again.",
},
],
release: noopRelease,
};
}
environment = managedSandboxEnvironment;
}
}
if (environment.driver === "local") {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
if (environment.driver === "ssh") {
try {
const target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
},
leaseMetadata: null,
});
if (target) {
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unavailable",
level: "warn",
message:
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
},
],
release: noopRelease,
};
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "warn",
message:
`Could not connect to environment "${environment.name}" to run the test.`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
}
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
// the workspace inside the box, and run the same probe SSH uses against
// a sandbox execution target wired to the environment runtime.
//
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
// operator-initiated `Test` invocation — the leases table FKs heartbeat
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
// run row. Cleanup goes through the driver's `releaseRunLease` directly
// (by lease record), since the batch helper queries by heartbeatRunId.
//
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
// agent lease) and archive it on release instead of deleting it, so the
// operator can inspect the exact sandbox from the provider dashboard while
// provider-side expiry reaps it later.
const testEnvironment = environment.driver === "sandbox"
? {
...environment,
config: {
...(environment.config ?? {}),
reuseLease: false,
archiveOnRelease: true,
},
}
: environment;
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
try {
leaseRecord = await environmentRuntime.acquireRunLease({
companyId: input.companyId,
agentId: input.agentId,
environment: testEnvironment,
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
// Re-check the company binding atomically at lease time. The route
// guard already rejected a foreign environment, but the binding could
// change between the guard check and the lease acquire. This closes
// that check-to-lease race so a foreign sandbox never gets a lease.
assertCompanyBinding: true,
// Apply the active custom-image template so the Test boots with the
// operator's captured sandbox customizations and prepared image state,
// matching what real agent runs use. Without this the test would
// silently fall back to the base image.
applyCustomImageTemplate: true,
});
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_lease_acquire_failed",
level: "error",
message: `Could not acquire a lease for environment "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
hint: "Check the environment's provider credentials and quota.",
},
],
release: noopRelease,
};
}
const driver = environmentRuntime.getDriver(environment.driver);
const releaseLease = async (status: "released" | "failed" = "released") => {
try {
if (driver) {
await driver.releaseRunLease({
environment: testEnvironment,
lease: leaseRecord.lease,
status,
});
} else {
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
}
} catch (err) {
// Cleanup failures must not mask the test result.
// eslint-disable-next-line no-console
console.warn(
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
let realizedCwd: string | null = null;
try {
const realized = await environmentRuntime.realizeWorkspace({
environment: testEnvironment,
lease: leaseRecord.lease,
// No host workspace to copy for a Test invocation; sandbox/plugin
// realize implementations use the lease metadata's remoteCwd to
// create the working directory inside the box.
workspace: {},
});
realizedCwd =
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
? realized.cwd.trim()
: null;
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_workspace_realize_failed",
level: "error",
message: `Could not realize a workspace inside "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
let target: AdapterExecutionTarget | null;
try {
// Prefer the cwd the realize step returned; fall back to lease metadata.
const leaseMetadataForTarget: Record<string, unknown> | null =
realizedCwd
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: testEnvironment.id,
driver: testEnvironment.driver,
config: testEnvironment.config ?? null,
},
leaseId: leaseRecord.lease.id,
leaseMetadata: leaseMetadataForTarget,
lease: leaseRecord.lease,
environmentRuntime,
});
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "error",
message: `Could not resolve an execution target for "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
if (!target) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unsupported",
level: "warn",
message:
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
},
],
release: noopRelease,
};
}
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
sandboxIdentityCheck: buildSandboxIdentityCheck({
environmentName: environment.name,
lease: leaseRecord.lease,
}),
release: releaseLease,
};
}
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = metadata[key];
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return null;
}
function buildSandboxIdentityCheck(input: {
environmentName: string;
lease: {
id: string;
provider?: string | null;
providerLeaseId?: string | null;
metadata?: Record<string, unknown> | null;
};
}): AdapterEnvironmentCheck {
const metadata = input.lease.metadata ?? {};
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
const snapshotRef = readMetadataString(metadata, [
"snapshot",
"snapshotId",
"snapshotID",
"snapshotRef",
"snapshot_ref",
"templateRef",
"template_ref",
"templateId",
"templateID",
"image",
"imageId",
"imageID",
"imageRef",
"image_ref",
]);
const templateKind = readMetadataString(metadata, [
"templateKind",
"template_kind",
"templateRefKind",
"template_ref_kind",
]);
const detailParts = [
`paperclipLeaseId=${input.lease.id}`,
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
provider ? `provider=${provider}` : null,
sandboxId ? `sandboxId=${sandboxId}` : null,
sandboxName ? `sandboxName=${sandboxName}` : null,
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
].filter((part): part is string => Boolean(part));
return {
code: "sandbox_test_identity",
level: "info",
message: `Environment test identity for "${input.environmentName}".`,
detail: detailParts.join("; "),
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed environment snapshots.",
};
}
// The environment drivers the adapter Test route accepts. A local, SSH, or
// sandbox environment can host a probe; a plugin environment cannot.
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
// The fail-closed tenant-binding guard for the adapter Test route. A caller
// may name any instance environment by id, so the route must reject an
// environment that binds to another company before it resolves secrets,
// merges env, resolves the target, leases a sandbox, or runs the adapter
// test. The guard checks the company binding BEFORE it validates the status
// or the driver, so it never reveals the status or the driver of a foreign
// environment. A same-company or an instance-global environment then gets the
// shared driver and status validation.
async function assertAdapterTestEnvironmentForCompany(
companyId: string,
environmentId: string,
): Promise<void> {
const environment = await environmentsSvc.getById(environmentId);
if (!environment) {
// A missing environment leaks no tenant state. The execution-context
// resolver surfaces the existing environment_not_found check.
return;
}
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
throw forbidden("The selected environment belongs to another company.", {
code: "environment_company_mismatch",
});
}
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS,
});
}
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
const startedAt = new Date();
const result = await probeManagedEnvironment(adapterType, context, binding);
// A provider rejection invalidates the tested credential generation. A
// missing CLI, unavailable environment, or other runtime error does not.
if (result.status === "fail" && result.checks.some(check =>
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|| check.code === "ai_connection_api_key_rejected",
)) {
await aiConnectionService(db).markAuthenticationFailed({
companyId: context.companyId, agentId, runStartedAt: startedAt,
attribution: { ...managed.attribution, identity: managed.identity },
});
}
return result;
}
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
const result = await requireServerAdapter(adapterType).testEnvironment(context);
if (result.status === "fail") return result;
// The resolved method, not binding.method — on a responsible_user binding
// that field is wire-compat only and the default connection decides.
const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method;
// An api_key account does not take the CLI hello probe below: a key
// travels as an env var any engine understands, and the engine's own test
// above judged whether this runtime can execute with it — the ACP lane
// deliberately runs no hello probe when a key is configured. Demanding one
// anyway forced the CLI lane, whose probe needs a provider CLI on PATH,
// and a clean install has none: that walled off onboarding's API-key path
// on exactly the machines the release smoke exists to guard. The account
// is still verified live here — the same provider-endpoint check the save
// performed — so a key revoked since its save fails adoption rather than
// producing an agent that cannot authenticate at runtime. The hello-probe
// requirement stays for subscriptions: a stored login is a file layout
// only a provider CLI reads, so proving the runtime lane can consume it
// takes a real hello turn.
if (resolvedMethod === "api_key" && !context.config.managedAiRouting) {
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
try {
if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify.");
await validateAiApiKey(binding.provider, key);
result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." });
} catch (error) {
result.status = "fail";
const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected";
result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." });
}
return result;
}
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider];
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
result.checks.push(...probe.checks);
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
}
if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) {
result.status = "fail";
result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." });
}
return result;
}
return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment };
}
@@ -0,0 +1,74 @@
import { readProcessStartedAt } from "./hot-restart.js";
import { runUsedConversationAdapter } from "./conversation-continuation.js";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
import { isCancelledNativeStartup } from "./cancelled-native-startup.js";
/** Cancellation changes admission. Process and lease evidence controls cleanup. */
export async function agentExecutionsHaveStopped(db: Db, agentIds: string[]) {
if (!agentIds.length) return true;
const runs = await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.agentId, agentIds));
const runIds = db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(inArray(heartbeatRuns.agentId, agentIds));
const leases = await db.select().from(environmentLeases).where(or(
inArray(environmentLeases.heartbeatRunId, runIds),
inArray(sql`${environmentLeases.metadata}->>'agentId'`, agentIds),
));
if (leases.some(lease => (!lease.releasedAt && !(lease.status === "retained" && lease.cleanupStatus === "success")) || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed")) return false;
const coordinators = await db.select().from(nativeRunFinalizations)
.where(inArray(nativeRunFinalizations.runId, runIds));
const now = new Date();
const abandoned = coordinators.filter(row => row.leaseOwner);
if (abandoned.some(row => !row.leaseExpiresAt || row.leaseExpiresAt > now || row.resultId ||
runs.find(run => run.id === row.runId)?.status !== "cancelled")) return false;
const absent = (pid: number) => {
try { process.kill(pid, 0); return false; }
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH"; }
};
for (const coordinator of abandoned) {
if (!coordinator.controllerPid || absent(coordinator.controllerPid)) continue;
const observed = await readProcessStartedAt(coordinator.controllerPid).catch(() => null);
if (!observed || !coordinator.controllerProcessStartedAt ||
new Date(observed).getTime() === coordinator.controllerProcessStartedAt.getTime()) return false;
}
for (const run of runs) {
if (!["succeeded", "failed", "cancelled", "timed_out", "interrupted"].includes(run.status)) return false;
if (run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()) return false;
const runLeases = leases.filter(lease => lease.heartbeatRunId === run.id);
if (runLeases.some(lease => lease.provider && lease.provider !== "local")) {
if (run.status === "cancelled" && !runLeases.every(hasRemoteTerminationReceipt)) return false;
continue;
}
let processAlive = Boolean(run.processPid && !absent(run.processPid));
let groupAlive = Boolean(run.processGroupId && !absent(-run.processGroupId));
if (processAlive && run.processStartedAt) {
const observed = await readProcessStartedAt(run.processPid!).catch(() => null);
if (observed && new Date(observed).getTime() !== run.processStartedAt.getTime()) {
processAlive = false;
if (run.processGroupId === run.processPid) groupAlive = false;
}
}
if (processAlive || groupAlive) return false;
if (run.status !== "cancelled" || !run.startedAt) continue;
if (run.runtimeMode === "native" && !run.processPid && !run.processGroupId &&
!await hasNativeLocalProcessStop(db, run.companyId, run.id) &&
!await isCancelledNativeStartup(db, run, coordinators.find(row => row.runId === run.id))) return false;
if (run.runtimeMode === "legacy" && !run.processPid && !run.processGroupId &&
(run.resultJson?.executionCancellation as Record<string, unknown> | undefined)?.state !== "acknowledged" &&
!(run.executionStage === "settled" && !await runUsedConversationAdapter(db, run)) &&
!await isCancelledNativeStartup(db, run, coordinators.find(row => row.runId === run.id))) return false;
}
// Release an expired owner only after its cancelled execution has stop proof.
for (const coordinator of abandoned) {
const [cleared] = await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null, updatedAt: now })
.where(and(eq(nativeRunFinalizations.runId, coordinator.runId),
eq(nativeRunFinalizations.leaseOwner, coordinator.leaseOwner!),
eq(nativeRunFinalizations.leaseExpiresAt, coordinator.leaseExpiresAt!),
eq(nativeRunFinalizations.controllerGeneration, coordinator.controllerGeneration),
eq(nativeRunFinalizations.phase, coordinator.phase), isNull(nativeRunFinalizations.resultId)))
.returning({ runId: nativeRunFinalizations.runId });
if (!cleared) return false;
}
return true;
}
@@ -0,0 +1,76 @@
import { and, eq } from "drizzle-orm";
import { agents, companies, type Db } from "@paperclipai/db";
import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
import { isForbiddenConfigEnvKey, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { requireServerAdapter } from "../adapters/index.js";
import { agentEnvironmentTestService } from "./agent-environment-test.js";
import { aiConnectionRouterService } from "./ai-connection-router.js";
import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js";
import { environmentService } from "./environments.js";
import { secretService } from "./secrets.js";
import { dotRunnerBroker } from "./dot-runner-broker.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import type { LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
const verified = result.checks.some(check => check.level === "info" &&
(/hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified"));
const authenticationFailed = result.checks.some(check =>
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code));
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
if (result.status === "fail" || authenticationFailed || incomplete) throw Object.assign(new Error("The harness test failed"), { code: "harness_test_failed" });
}
export function agentHarnessVerificationService(db: Db, manager: PluginWorkerManager) {
const tests = agentEnvironmentTestService(db, manager);
const secrets = secretService(db);
const environments = environmentService(db);
async function verify(snapshot: LifecycleAgent): Promise<"complete" | "pending"> {
const [agent] = await db.select().from(agents).where(and(eq(agents.id, snapshot.id), eq(agents.lifecycleVersion, snapshot.lifecycleVersion)));
if (!agent) return "pending";
if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot") {
const binding = await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id);
return binding?.status === "ready" && binding.subscriptionVerified && binding.id === agent.adapterConfig.dotBindingId ? "complete" : "pending";
}
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
const context = { consumerType: "agent" as const, consumerId: agent.id, actorType: "system" as const,
actorId: "agent-lifecycle", responsibleUserId };
let binding = agent.runtimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
let config = agent.adapterConfig;
if (binding?.mode === "router") {
const selected = await aiConnectionRouterService(db, manager).resolve({ companyId: agent.companyId,
poolId: binding.connectionId, agentId: agent.id, userId: responsibleUserId,
adapterType: agent.adapterType, taskKey: `lifecycle:${agent.lifecycleOperation!.id}` });
binding = selected.binding;
config = { ...config, ...selected.runtimeConfig };
}
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId);
if (environmentId) {
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId);
const environment = await environments.getById(environmentId);
const env = Object.fromEntries(Object.entries(binding ? stripAiAuthBindings(environment?.envVars) : parseObject(environment?.envVars))
.filter(([key]) => !isForbiddenConfigEnvKey(key)));
const resolved = await secrets.resolveEnvBindings(agent.companyId, env,
{ ...context, consumerType: "environment", consumerId: environmentId });
config = { ...config, env: { ...resolved.env, ...parseObject(config.env) } };
}
const target = await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
let status: "released" | "failed" = "failed";
try {
if (target.fallbackChecks.length) throw Object.assign(new Error("The test environment is unavailable"), { code: "test_environment_unavailable" });
const input = { companyId: agent.companyId, adapterType: agent.adapterType, config,
executionTarget: target.executionTarget, environmentName: target.environmentName };
const result = binding ? await withManagedAiProbe(db, { ...input, agentId: agent.id, responsibleUserId, binding },
managed => tests.testManagedEnvironment(agent.adapterType, { ...input, config: managed.config }, binding!, managed, agent.id))
: await requireServerAdapter(agent.adapterType).testEnvironment(input);
assertHarnessTestPassed(result);
status = "released";
return "complete";
} finally { await target.release(status); }
}
return { verify };
}
+135
View File
@@ -0,0 +1,135 @@
import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
import { agents, toolConnectionInstalls, principalPermissionGrants, type Db } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { unprocessable } from "../errors.js";
import { agentRecordQueries, isPlainRecord, configPatchFromApprovalPayload, normalizeRuntimeConfigForNewAgent,
deduplicateAgentName, type CreateAgentData, type CreateAgentOptions, type AgentConfigurationPatch, type AgentHireRecord } from "../lib/agent-records.js";
import { normalizeAgentPermissions, permissionsImplyLowTrust, NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS,
newStandardAgentGrantScope } from "../lib/agent-permissions.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
import { agentCredentialService } from "./agent-credentials.js";
import { agentIdentityService } from "./agent-identity.js";
import { initializePrimaryAgent } from "./primary-agent.js";
export async function prepareAgentHire(db: Db, companyId: string, data: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
const { ensureManager, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar");
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
if (data.reportsTo) {
await ensureManager(companyId, data.reportsTo);
}
const existingAgents = await db
.select({ id: agents.id, name: agents.name, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
const adapterType = data.adapterType ?? "process";
const rawAdapterConfig = isPlainRecord(data.adapterConfig)
? await secretService(db).normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
: {};
const adapterConfig = normalizePaperclipRunnerAdapterConfig(adapterType, rawAdapterConfig);
await agentCredentialService(db).enforceClaudeOAuthBindingClaim(db, {
companyId, consume: true, environmentId: data.defaultEnvironmentId ?? null,
childAdapterConfig: adapterConfig, claudeLogin: options?.claudeLogin,
decision: assertClaudeOAuthBindingInvariant({ adapterType, nextConfig: adapterConfig }),
});
return { ...data, name: uniqueName,
appearance: data.appearance == null ? randomAgentAppearance() : agentAppearanceSchema.parse(data.appearance),
role, adapterType, adapterConfig, permissions: normalizedPermissions, runtimeConfig };
}
export async function initializeAgentHire(txDb: Db, created: AgentHireRecord, options?: CreateAgentOptions) {
const tx = txDb;
const companyId = created.companyId;
await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id);
// New standard agents receive the standard direct grants at activation.
// Low-trust and bundled agents keep their explicit, narrower grants.
if (created.status !== "pending_approval" && !permissionsImplyLowTrust(created.permissions) &&
!readBuiltInAgentMarker(created.metadata)) {
await tx.insert(principalPermissionGrants).values(
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
companyId,
principalType: "agent" as const,
principalId: created.id,
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, created.id),
})),
).onConflictDoNothing();
}
if (options?.aiConnectionInstall) {
const install = options.aiConnectionInstall;
const connectionIds = [...new Set([install.connectionId, ...(install.memberConnectionIds ?? [])])];
await tx.insert(toolConnectionInstalls).values(connectionIds.map(connectionId => ({
companyId, connectionId,
targetType: "agent" as const, targetId: created.id,
createdByUserId: install.createdByUserId,
}))).onConflictDoNothing();
}
await agentCredentialService(txDb).syncAgentSecretBindings(created, txDb);
if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) {
await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id);
}
}
export async function prepareAgentHireApproval(txDb: Db, existing: AgentHireRecord, approvedPayload?: Record<string, unknown> | null): Promise<AgentConfigurationPatch> {
const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {};
const patch = { ...approvedPatch } as AgentConfigurationPatch;
const hasApprovedAdapterConfig = Object.prototype.hasOwnProperty.call(patch, "adapterConfig") && isPlainRecord(patch.adapterConfig);
if (
Object.prototype.hasOwnProperty.call(patch, "adapterConfig") &&
isPlainRecord(patch.adapterConfig)
) {
const normalizedAdapterConfig = await secretService(txDb).normalizeAdapterConfigForPersistence(
existing.companyId, patch.adapterConfig, { adapterType: (patch.adapterType ?? existing.adapterType) as string },
);
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
(patch.adapterType ?? existing.adapterType) as string,
normalizedAdapterConfig,
);
} else if (
Object.prototype.hasOwnProperty.call(patch, "adapterType")
&& isPlainRecord(existing.adapterConfig)
) {
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
patch.adapterType as string,
existing.adapterConfig,
);
}
if (patch.permissions !== undefined) {
// The pending-approval activation replays the original hire
// request, so the new-agent creation default applies.
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
}
if (hasApprovedAdapterConfig) {
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
companyId: existing.companyId, consume: false, environmentId: null,
childAdapterConfig: patch.adapterConfig,
decision: assertClaudeOAuthBindingInvariant({ adapterType: patch.adapterType ?? existing.adapterType,
nextConfig: patch.adapterConfig, priorConfig: existing.adapterConfig }),
});
}
return patch;
}
export async function completeAgentHireApproval(txDb: Db, updated: AgentHireRecord, existing: AgentHireRecord) {
const tx = txDb;
await agentCredentialService(txDb).syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
if (!permissionsImplyLowTrust(updated.permissions) && !readBuiltInAgentMarker(updated.metadata)) {
await tx.insert(principalPermissionGrants).values(
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
companyId: updated.companyId,
principalType: "agent" as const,
principalId: updated.id,
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, updated.id),
})),
).onConflictDoNothing();
}
}
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { createHash } from "node:crypto";
import { adoptAgentFiles, agentFileToken } from "./agent-file-store.js";
import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
@@ -252,19 +253,15 @@ export function agentInstructionRevisionService(db: Db) {
!state.agent.adapterConfig.instructionsBundleMode ||
(bound.type === "plugin" && configuredEntryFile !== input.entryFile)
) {
await tx
.update(agents)
.set({
adapterConfig: {
...state.agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: state.root,
instructionsEntryFile: state.entryFile,
instructionsFilePath: `${state.root}/${state.entryFile}`,
},
updatedAt: new Date(),
})
.where(eq(agents.id, state.agent.id));
await updateAgentConfigurationInTransaction(tx as unknown as Db, state.agent.id, {
adapterConfig: {
...state.agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: state.root,
instructionsEntryFile: state.entryFile,
instructionsFilePath: `${state.root}/${state.entryFile}`,
},
}, { allowPendingApprovalConfigUpdate: true });
}
};
// An exact replay can safely return the durable receipt even after its base
@@ -0,0 +1,47 @@
import { and, eq, inArray } from "drizzle-orm";
import { agents, plugins, pluginCompanySettings, type Db } from "@paperclipai/db";
import { heartbeatService } from "./heartbeat.js";
import { listInvalidOrgChainDescendantIds } from "./agent-invokability.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import type { LifecycleDriver } from "../modules/agent-lifecycle/index.js";
import { agentHarnessVerificationService } from "./agent-harness-verification.js";
export function createLifecycleDriver(db: Db, manager: PluginWorkerManager): LifecycleDriver {
const { verify } = agentHarnessVerificationService(db, manager);
const heartbeat = heartbeatService(db, { pluginWorkerManager: manager });
return {
async requiredPluginIds(agent) {
if (agent.lifecycleRequiredPluginIds !== null) return agent.lifecycleRequiredPluginIds;
const installed = await db.select().from(plugins).where(inArray(plugins.status, ["ready", "error", "installed", "upgrade_pending"]));
const disabled = await db.select({ pluginId: pluginCompanySettings.pluginId }).from(pluginCompanySettings)
.where(and(eq(pluginCompanySettings.companyId, agent.companyId), eq(pluginCompanySettings.enabled, false)));
return installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) &&
plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")).map(plugin => plugin.id);
},
async runPlugin(agent, pluginId) {
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, pluginId));
const [settings] = await db.select().from(pluginCompanySettings).where(and(
eq(pluginCompanySettings.pluginId, pluginId), eq(pluginCompanySettings.companyId, agent.companyId)));
if (settings?.enabled === false || !plugin || plugin.status !== "ready" || !plugin.manifestJson.agentLifecycle || !plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")) {
throw Object.assign(new Error("A required lifecycle plugin is unavailable"), { code: "required_plugin_unavailable" });
}
const operationId = agent.lifecycleOperation!.id;
const result = await manager.call(plugin.id, "agentLifecycle", { companyId: agent.companyId,
agentId: agent.id, operationId, version: agent.lifecycleVersion, phase: agent.lifecycleState }, 30_000);
if (!result || result.operationId !== operationId || result.version !== agent.lifecycleVersion || !["complete", "pending"].includes(result.status)) {
throw Object.assign(new Error("Invalid lifecycle result"), { code: "invalid_lifecycle_result" });
}
return result.status;
},
async runHost(agent) {
if (agent.lifecycleState === "verifying") return verify(agent);
if (["pausing", "terminating", "cleaning_up"].includes(agent.lifecycleState)) {
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, agent.companyId));
const ids = agent.lifecycleState === "pausing" ? [agent.id]
: [agent.id, ...listInvalidOrgChainDescendantIds(agent.id, companyAgents)];
if (!await heartbeat.stopInvocationsForAgents(ids, "Agent lifecycle stop requested")) return "pending";
}
return "complete";
},
};
}
+61
View File
@@ -0,0 +1,61 @@
import { logger } from "../middleware/logger.js";
import type { Db } from "@paperclipai/db";
import * as lifecycle from "../modules/agent-lifecycle/index.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { budgetServiceInTransaction, deliverBudgetEnforcement, policyBlocks, type BudgetServiceHooks } from "./budgets.js";
import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
import { clearPrimaryAgent } from "./primary-agent.js";
import { deleteAgentDependencies } from "./agent-deletion.js";
import { prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js";
import { prepareAgentHire, initializeAgentHire, prepareAgentHireApproval, completeAgentHireApproval } from "./agent-hiring.js";
import { trackIdleWork } from "./task-admission.js";
export { scheduleAgentLifecycle } from "../modules/agent-lifecycle/index.js";
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function createAgentLifecycleEffects(hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
return {
reportFailure(context, error) {
// Only known codes are safe; provider errors can contain credentials or private data.
const allowedCodes = new Set(["required_plugin_unavailable", "invalid_lifecycle_result", "harness_test_failed",
"test_environment_unavailable", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
const code = [candidate?.code, candidate?.cause?.code].find(value => typeof value === "string" && allowedCodes.has(value));
logger.warn({ ...context, failureCode: code ?? "unclassified" }, "Agent lifecycle operation failed");
},
transaction: withAccountingTransaction,
deleteDependencies: deleteAgentDependencies,
policyBlocks,
setAgentBudget: (db, publications, companyId, agentId, amount, userId, isActive) =>
budgetServiceInTransaction(db, publications).upsertPolicy(companyId, {
scopeType: "agent", scopeId: agentId, amount, isActive, windowKind: "calendar_month_utc",
}, userId),
enforceBudget: (db, companyId, agentId) => deliverBudgetEnforcement(db, hooks, companyId, agentId),
recordCreation: (db, companyId, agentId) => recordResourceCreationEvent(db, companyId, "agent", agentId),
recordStatus: recordAgentStatusEvent,
clearPrimary: clearPrimaryAgent,
prepareConfiguration: prepareAgentConfiguration,
completeConfiguration: completeAgentConfiguration,
prepareHire: prepareAgentHire,
initializeHire: initializeAgentHire,
prepareHireApproval: prepareAgentHireApproval,
completeHireApproval: completeAgentHireApproval,
};
}
export function createAgentLifecycle(db: Db, hooks: BudgetServiceHooks = {}) {
return lifecycle.createAgentLifecycle(db, createAgentLifecycleEffects(hooks));
}
function trackedDriver(driver: lifecycle.LifecycleDriver): lifecycle.LifecycleDriver {
return { ...driver, runHost: agent => trackIdleWork(driver.runHost(agent)),
runPlugin: (agent, pluginId) => trackIdleWork(driver.runPlugin(agent, pluginId)) };
}
export function configureAgentLifecycle(db: Db, driver: lifecycle.LifecycleDriver, canRun = () => true) {
return lifecycle.configureAgentLifecycle(db, createAgentLifecycleEffects(), trackedDriver(driver), canRun);
}
export function startAgentLifecycle(db: Db, driver: lifecycle.LifecycleDriver, canRun: () => boolean) {
return lifecycle.startAgentLifecycle(db, createAgentLifecycleEffects(), trackedDriver(driver), canRun);
}
-102
View File
@@ -1,102 +0,0 @@
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
/** Direct grants for newly activated standard agents. Existing agents are not backfilled. */
export const NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS = [
"agents:configure",
"agents:suggest-changes",
"skills:create",
"skills:suggest-changes",
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"audit:view_agent_actions",
"tools:use",
"tools:manage_runtime",
"inbox:manage",
"tasks:assign",
"tasks:assign_scope",
"tasks:manage_active_checkouts",
] as const satisfies readonly PermissionKey[];
export function newStandardAgentGrantScope(
permissionKey: PermissionKey,
agentId: string,
): Record<string, unknown> | null {
if (permissionKey === "tasks:assign_scope") return { subtreeRootAgentId: agentId };
if (permissionKey === "inbox:manage") return { responsibleUserOnly: true };
return null;
}
export type NormalizedAgentPermissions = Record<string, unknown> & {
canCreateAgents: boolean;
canCreateSkills: boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Mirrors the agent-source low-trust markers consumed by
* resolveCoreTrustPreset: the low-trust review preset (top-level or inside
* authorizationPolicy) or a low-trust boundary. Defaults must never grant
* agent-creation authority to a low-trust agent.
*/
export function permissionsImplyLowTrust(permissions: unknown): boolean {
const record = asRecord(permissions);
if (!record) return false;
const authorizationPolicy = asRecord(record.authorizationPolicy);
return (
record.trustPreset === LOW_TRUST_REVIEW_PRESET ||
authorizationPolicy?.trustPreset === LOW_TRUST_REVIEW_PRESET ||
asRecord(record.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.trustBoundary) !== null
);
}
/**
* "create" is the context for permissions arriving on a new-agent write: the
* hire/create default applies and the resolved value is persisted. "stored"
* is the context for rows read back from the database: a row without an
* explicit value stays fail-closed, so the default is never granted
* retroactively to legacy or malformed records at read or enforcement time.
*/
export type AgentPermissionsContext = "create" | "stored";
export function defaultAgentPermissions(
options?: { lowTrust?: boolean; context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
return {
canCreateAgents: options?.context === "create" && options?.lowTrust !== true,
canCreateSkills: true,
};
}
export function normalizeAgentPermissions(
permissions: unknown,
options?: { context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
const defaults = defaultAgentPermissions({
lowTrust: permissionsImplyLowTrust(permissions),
context: options?.context ?? "stored",
});
const record = asRecord(permissions);
if (!record) {
return defaults;
}
return {
...record,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents
: defaults.canCreateAgents,
canCreateSkills:
typeof record.canCreateSkills === "boolean"
? record.canCreateSkills
: defaults.canCreateSkills,
};
}
+2 -2
View File
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { createHash } from "node:crypto";
import { and, eq } from "drizzle-orm";
import sharp from "sharp";
@@ -5,7 +6,6 @@ import { agents, assets, companies, type Db } from "@paperclipai/db";
import { MAX_AGENT_AVATAR_BYTES, resolveAgentAppearance, agentAvatarUrl, setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import type { StorageService } from "../storage/types.js";
import { agentService } from "./agents.js";
import { logActivity, publishActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
/** Decode and re-encode: exclude SVG, animation, metadata and unbounded pixel counts. No URL fetching. */
@@ -60,7 +60,7 @@ export async function setAgentProfileAvatar(db: Db, storage: StorageService, com
createdByUserId: actor.actorType === "user" ? actor.actorId : null }).returning();
appearance.customAvatarAssetId = asset!.id;
}
await agentService(txDb).update(agentId, { appearance }, { recordRevision: {
await updateAgentConfigurationInTransaction(txDb, agentId, { appearance }, { recordRevision: {
createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "avatar-upload",
} });
await logActivity(txDb, { ...actor, companyId, action: "agent.avatar_updated", entityType: "agent", entityId: agentId,
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import type { AiProvider } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
/** Fixed provider endpoints; credentials are never sent to a caller-supplied URL or through a redirect. */
export async function validateAiApiKey(
provider: AiProvider,
key: string,
request: typeof fetch = fetch,
) {
const endpoints = {
anthropic: "https://api.anthropic.com/v1/models?limit=1",
openai: "https://api.openai.com/v1/models",
openrouter: "https://openrouter.ai/api/v1/key",
xai: "https://api.x.ai/v1/models",
google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
};
let response: Response;
try {
response = await request(endpoints[provider], {
redirect: "error",
signal: AbortSignal.timeout(15000),
headers:
provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic"
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
: { Authorization: `Bearer ${key}` },
});
} catch {
throw unprocessable("Could not verify the account. Try again.", { code: "ai_connection_verification_failed" });
}
await response.body?.cancel();
if (!response.ok)
throw unprocessable(
response.status === 401 || response.status === 403
? "The provider rejected this API key."
: "The provider could not verify this account. Try again.",
{ code: response.status === 401 || response.status === 403 ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed" },
);
}
+47 -180
View File
@@ -1,21 +1,19 @@
import { agentAppearanceSchema } from "@paperclipai/shared";
import { approvalResolutionRecords, resolvableApprovalStatuses as resolvableStatuses } from "../lib/approval-records.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { unprocessable } from "../errors.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService, budgetServiceInTransaction } from "./budgets.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { notifyHireApproved } from "./hire-hook.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
export function approvalRecords(db: Db) {
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
const { getExistingApproval, resolveApproval } = approvalResolutionRecords(db);
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
@@ -24,69 +22,8 @@ export function approvalService(db: Db) {
};
}
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>, database: Db) {
const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null;
if (!sourceBuiltInAgentKey) return;
const { builtInAgentService } = await import("./built-in-agents.js");
await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey);
}
async function getExistingApproval(id: string, database: Db = db) {
const existing = await database
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
return existing;
}
async function resolveApproval(
id: string,
targetStatus: "approved" | "rejected",
decidedByUserId: string,
decisionNote: string | null | undefined,
database: Db = db,
): Promise<ResolutionResult> {
const existing = await getExistingApproval(id, database);
if (!canResolveStatuses.has(existing.status)) {
if (existing.status === targetStatus) {
return { approval: existing, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
const now = new Date();
const updated = await database
.update(approvals)
.set({
status: targetStatus,
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
return { approval: updated, applied: true };
}
const latest = await getExistingApproval(id, database);
if (latest.status === targetStatus) {
return { approval: latest, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
return {
getExistingApproval, resolveApproval,
list: (companyId: string, status?: string, readCondition?: SQL<boolean>) => {
const conditions = [eq(approvals.companyId, companyId)];
if (status) conditions.push(eq(approvals.status, status));
@@ -142,113 +79,6 @@ export function approvalService(db: Db) {
return updated;
},
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const now = new Date();
const existing = await getExistingApproval(id);
// Receipt writers lock company before agent. Hire approval must use that
// order too, including activation of an existing pending agent.
const result = await withAccountingTransaction(db, existing.companyId, async (txDb, publications) => {
const agentsSvc = agentService(txDb);
const budgets = budgetServiceInTransaction(txDb, publications);
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote,
txDb,
);
let hireApprovedAgentId: string | null = null;
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
await agentsSvc.activatePendingApproval(payloadAgentId, payload);
await reconcileApprovedBuiltInAgent(updated.companyId, payload, txDb);
hireApprovedAgentId = payloadAgentId;
} else {
const created = await agentsSvc.create(updated.companyId, {
name: String(payload.name ?? "New Agent"),
appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance),
role: String(payload.role ?? "general"),
title: typeof payload.title === "string" ? payload.title : null,
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
adapterType: String(payload.adapterType ?? "process"),
adapterConfig:
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
? (payload.adapterConfig as Record<string, unknown>)
: {},
budgetMonthlyCents:
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
metadata:
typeof payload.metadata === "object" && payload.metadata !== null
? (payload.metadata as Record<string, unknown>)
: null,
status: "idle",
spentMonthlyCents: 0,
permissions: undefined,
lastHeartbeatAt: null,
}, { createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId });
hireApprovedAgentId = created?.id ?? null;
}
if (hireApprovedAgentId) {
const budgetMonthlyCents =
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0;
if (budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
updated.companyId,
{
scopeType: "agent",
scopeId: hireApprovedAgentId,
amount: budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
decidedByUserId,
);
}
}
}
return { approval: updated, applied, hireApprovedAgentId };
});
if (result.hireApprovedAgentId) {
await budgetService(db).deliverPendingEnforcement(result.approval.companyId);
void notifyHireApproved(db, {
companyId: result.approval.companyId,
agentId: result.hireApprovedAgentId,
source: "approval",
sourceId: id,
approvedAt: now,
}).catch(() => {});
}
return { approval: result.approval, applied: result.applied };
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
return db.transaction(async tx => {
const txDb = tx as unknown as Db;
const { approval: updated, applied } = await resolveApproval(
id,
"rejected",
decidedByUserId,
decisionNote,
txDb,
);
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
await agentService(txDb).terminate(payloadAgentId);
}
}
return { approval: updated, applied };
});
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (existing.status !== "pending") {
@@ -332,3 +162,40 @@ export function approvalService(db: Db) {
},
};
}
export function approvalService(db: Db) {
const { getExistingApproval, resolveApproval, ...records } = approvalRecords(db);
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>, database: Db) {
const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null;
if (!sourceBuiltInAgentKey) return;
const { builtInAgentService } = await import("./built-in-agents.js");
await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey);
}
async function decideHire(target: string | { approvalId: string }, status: "approved" | "rejected", userId: string, note?: string | null) {
const lifecycle = createAgentLifecycle(db);
const result = await (status === "approved" ? lifecycle.approveHire : lifecycle.rejectHire)(target, userId, note);
if (result?.hireApprovedAgentId && result.approval) {
await reconcileApprovedBuiltInAgent(result.approval.companyId, result.approval.payload, db);
await budgetService(db).deliverPendingEnforcement(result.approval.companyId);
void notifyHireApproved(db, { companyId: result.approval.companyId, agentId: result.hireApprovedAgentId,
source: "approval", sourceId: result.approval.id, approvedAt: result.approval.decidedAt ?? new Date() }).catch(() => {});
}
return result;
}
async function decide(id: string, status: "approved" | "rejected", userId: string, note?: string | null) {
const approval = await getExistingApproval(id);
if (approval.type === "hire_agent") {
const result = await decideHire({ approvalId: id }, status, userId, note);
return { approval: result!.approval!, applied: result!.applied };
}
return resolveApproval(id, status, userId, note);
}
return { ...records,
approveHire: (agentId: string, userId: string, note?: string | null) => decideHire(agentId, "approved", userId, note),
rejectHire: (agentId: string, userId: string, note?: string | null) => decideHire(agentId, "rejected", userId, note),
approve: (id: string, userId: string, note?: string | null) => decide(id, "approved", userId, note),
reject: (id: string, userId: string, note?: string | null) => decide(id, "rejected", userId, note),
};
}
+1 -1
View File
@@ -45,7 +45,7 @@ import {
type TrustPresetResolution,
} from "./trust-preset-resolver.js";
import { logger } from "../middleware/logger.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
export type AuthorizationActor =
+11 -30
View File
@@ -1,7 +1,7 @@
import { compareCents, normalizeCents } from "@paperclipai/shared";
import { and, desc, eq, gte, inArray, lt, ne, sql, or, isNull, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { recordAgentStatusEvent } from "./resource-lifecycle-events.js";
import {
agents,
approvals,
@@ -225,7 +225,7 @@ function observedBlocks(policy: PolicyRow, observed: Awaited<ReturnType<typeof c
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
return compareCents(observed.totalExact, policy.amount) >= 0 || (observed.pendingRunCount - observed.recoveringRunCount) > 0 || (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0);
}
async function policyBlocks(db: Db, policy: PolicyRow) {
export async function policyBlocks(db: Db, policy: PolicyRow) {
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
return observedBlocks(policy, await computeObservedSpend(db, policy));
}
@@ -282,18 +282,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
const recordActivity = (input: LogActivityInput) => logActivity(db, input, publications);
async function pauseScopeForBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db.transaction(async tx => {
const [agent] = await tx.select().from(agents)
.where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update");
if (agent && ["active", "idle", "running", "error"].includes(agent.status)) {
await tx.update(agents).set({ status: "paused", pauseReason: "budget", pausedAt: now, updatedAt: now })
.where(eq(agents.id, agent.id));
await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "paused");
}
});
return;
}
if (policy.scopeType === "agent") return;
if (policy.scopeType === "project") {
await db
@@ -334,18 +323,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
if (await policyBlocks(db, candidate)) return;
}
const now = new Date();
if (policy.scopeType === "agent") {
await db.transaction(async tx => {
const [agent] = await tx.select().from(agents)
.where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update");
if (agent?.status === "paused" && agent.pauseReason === "budget") {
await tx.update(agents).set({ status: "idle", pauseReason: null, pausedAt: null, updatedAt: now })
.where(eq(agents.id, agent.id));
await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "idle");
}
});
return;
}
if (policy.scopeType === "agent") return;
if (policy.scopeType === "project") {
await db
@@ -993,7 +971,10 @@ export async function withCurrentBudgetEnforcement<T>(db: Db, scope: BudgetEnfor
/** Cancellation is an at-least-once external effect. A failed delivery never
* rolls back committed spend, and its version remains pending for recovery. */
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string) {
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string, agentId?: string | null) {
const { createAgentLifecycle } = await import("./agent-lifecycle.js");
// Global recovery uses the bounded lifecycle sweep for policy holds.
if (companyId) await createAgentLifecycle(db).reconcilePolicyHolds(companyId, agentId);
if (!hooks.cancelWorkForScope) return;
const pending = await db.select().from(budgetPolicies).where(and(
sql`${budgetPolicies.enforcementVersion} > ${budgetPolicies.enforcementDeliveredVersion}`,
@@ -1017,9 +998,9 @@ export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
const reads = budgetServiceInTransaction(db);
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>) {
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>, agentId?: string | null) {
const result = await withAccountingTransaction(db, companyId, (tx, publications) => work(budgetServiceInTransaction(tx, publications)));
await deliverBudgetEnforcement(db, hooks, companyId);
await deliverBudgetEnforcement(db, hooks, companyId, agentId);
return result;
}
return {
@@ -1044,7 +1025,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
evaluateCostEvent: (event: typeof costEvents.$inferSelect) =>
mutate(event.companyId, (service) => service.evaluateCostEvent(event)),
getInvocationBlock: (companyId: string, agentId: string | null, context?: { issueId?: string | null; projectId?: string | null }) =>
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context)),
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context), agentId),
resolveIncident: (companyId: string, incidentId: string, input: BudgetIncidentResolutionInput, actorUserId: string) =>
mutate(companyId, (service) => service.resolveIncident(companyId, incidentId, input, actorUserId)),
};
@@ -1,45 +0,0 @@
export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent";
export interface BuiltInAgentMarker {
key: string;
featureKeys: string[];
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeFeatureKeys(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
return featureKeys.length === value.length ? featureKeys : null;
}
export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null {
if (!isPlainRecord(metadata)) return null;
const marker = metadata[BUILT_IN_AGENT_METADATA_KEY];
if (!isPlainRecord(marker)) return null;
const key = marker.key;
const featureKeys = normalizeFeatureKeys(marker.featureKeys);
if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null;
return { key, featureKeys };
}
export function withBuiltInAgentMarker(
metadata: Record<string, unknown> | null | undefined,
marker: BuiltInAgentMarker,
): Record<string, unknown> {
return {
...(metadata ?? {}),
[BUILT_IN_AGENT_METADATA_KEY]: {
key: marker.key,
featureKeys: [...marker.featureKeys],
},
};
}
export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) {
if (!left && !right) return true;
if (!left || !right) return false;
return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys);
}
+8 -6
View File
@@ -1,3 +1,4 @@
import { createAgentLifecycle } from "./agent-lifecycle.js";
import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
@@ -21,7 +22,7 @@ import { approvalService } from "./approvals.js";
import {
readBuiltInAgentMarker,
withBuiltInAgentMarker,
} from "./built-in-agent-metadata.js";
} from "../lib/built-in-agent-metadata.js";
import { companySkillService } from "./company-skills.js";
import { routineService } from "./routines.js";
import { accessService } from "./access.js";
@@ -805,6 +806,7 @@ function isBuiltInAgentMarkerConflict(error: unknown): boolean {
export function builtInAgentService(db: Db) {
const agentSvc = agentService(db);
const agentSvcLifecycle = createAgentLifecycle(db);
const accessSvc = accessService(db);
const approvalSvc = approvalService(db);
const instructionsSvc = agentInstructionsService(db);
@@ -1403,8 +1405,8 @@ export function builtInAgentService(db: Db) {
}
async function ensureBuiltInAgentAssignable(agent: Agent) {
if (agent.status !== "paused") return agent;
const resumed = await agentSvc.resume(agent.id);
if (agent.status !== "paused" || ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return agent;
const resumed = await agentSvcLifecycle.resumeAgent(agent.id);
if (!resumed) throw notFound("Built-in agent not found");
return resumed as Agent;
}
@@ -1664,7 +1666,7 @@ export function builtInAgentService(db: Db) {
const [keep, ...duplicates] = markedRows;
for (const duplicate of duplicates) {
const openApproval = await approvalSvc.findOpenHireApprovalForAgent(companyId, duplicate.id);
await agentSvc.terminate(duplicate.id);
await agentSvcLifecycle.terminateAgent(duplicate.id);
if (openApproval) {
await approvalSvc.cancel(
openApproval.id,
@@ -1785,7 +1787,7 @@ export function builtInAgentService(db: Db) {
: null;
let created: Agent;
try {
created = await agentSvc.create(companyId, {
created = await agentSvcLifecycle.requestHire(companyId, {
...definitionPatch(definition, resolvedInput),
status: definition.defaultStatus ?? "idle",
pauseReason: definition.defaultStatus === "paused"
@@ -1890,7 +1892,7 @@ export function builtInAgentService(db: Db) {
: null;
let pending: Agent;
try {
pending = await agentSvc.create(companyId, {
pending = await agentSvcLifecycle.requestHire(companyId, {
...definitionPatch(definition, input),
status: "pending_approval",
reportsTo,
@@ -1,3 +1,4 @@
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { and, asc, eq, inArray, lte, sql } from "drizzle-orm";
import { agents, agentWakeupRequests, chatCompletionDeliveries as deliveries, chatTaskHandoffs as handoffs,
heartbeatRuns, issueComments, issueDocuments, issues, type Db } from "@paperclipai/db";
@@ -31,13 +32,16 @@ export async function recordChatHandoff(tx: Connection, task: Issue, actorRunId:
}
/** Must run on the same transaction as status projection (including native arbitration). */
export async function recordChatCompletion(tx: Connection, before: Issue, after: Issue) {
if (before.status === after.status) return;
export async function recordChatCompletion(tx: Connection, before: Issue, after: Issue): Promise<boolean> {
if (before.status === after.status) return false;
await tx.update(deliveries).set({ status: "superseded" }).where(and(eq(deliveries.taskId, after.id),
eq(deliveries.companyId, after.companyId), inArray(deliveries.status, [...pending])));
if (after.status !== "done") return;
if (after.status !== "done") return false;
const [handoff] = await tx.select().from(handoffs).where(and(eq(handoffs.taskId, after.id), eq(handoffs.companyId, after.companyId)));
if (handoff) await tx.insert(deliveries).values({ companyId: after.companyId, taskId: after.id, statusVersion: after.statusVersion }).onConflictDoNothing();
if (!handoff) return false;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatCompletion);
await tx.insert(deliveries).values({ companyId: after.companyId, taskId: after.id, statusVersion: after.statusVersion }).onConflictDoNothing();
return true;
}
async function loadAudience(tx: Connection, deliveryId: string) {
@@ -243,5 +247,9 @@ export function chatCompletionDeliveryService(db: Db, heartbeat: { wakeup(agentI
.orderBy(asc(deliveries.nextAttemptAt)).limit(100);
for (const row of due) await deliver(row.id);
}
return { deliver, sweepPending };
async function hasPending() {
return (await db.select({ id: deliveries.id }).from(deliveries)
.where(inArray(deliveries.status, [...pending])).limit(1)).length > 0;
}
return { deliver, sweepPending, hasPending };
}
@@ -220,18 +220,18 @@ describe("createCoalescedAsyncTrigger", () => {
describe("chat publication commit signals", () => {
it("emits the accepted signals only after their durable source writes", () => {
const heartbeatSource = readFileSync(
new URL("./heartbeat.ts", import.meta.url),
const lifecycleSource = readFileSync(
new URL("./heartbeat/run-lifecycle.ts", import.meta.url),
"utf8",
);
const appendRunEventStart = heartbeatSource.indexOf(
const appendRunEventStart = lifecycleSource.indexOf(
"async function appendRunEvent(",
);
const persistedEvent = heartbeatSource.indexOf(
const persistedEvent = lifecycleSource.indexOf(
"await appendHeartbeatRunEvent",
appendRunEventStart,
);
const emittedEvent = heartbeatSource.indexOf(
const emittedEvent = lifecycleSource.indexOf(
"publishLiveEvent({",
persistedEvent,
);
@@ -239,6 +239,10 @@ describe("chat publication commit signals", () => {
expect(persistedEvent).toBeGreaterThan(appendRunEventStart);
expect(emittedEvent).toBeGreaterThan(persistedEvent);
const heartbeatSource = readFileSync(
new URL("./heartbeat.ts", import.meta.url),
"utf8",
);
const presentationMarker = heartbeatSource.indexOf(
'eventType: "run.presentation.resolved"',
);
+7 -115
View File
@@ -1,3 +1,5 @@
import { deleteCompany } from "./company-deletion.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { publishAccountingActivities } from "./accounting-transaction.js";
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
@@ -7,37 +9,10 @@ import {
companyLogos,
assets,
agents,
agentApiKeys,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
issues,
issueComments,
projects,
goals,
heartbeatRuns,
runIdentityContexts,
heartbeatRunEvents,
costEvents,
decisionInvocations,
financeEvents,
budgetPolicies,
budgetIncidents,
issueReadStates,
approvalComments,
approvals,
activityLog,
companySecrets,
joinRequests,
invites,
principalPermissionGrants,
companyMemberships,
companySkills,
documents,
routineRuns,
routineTriggers,
routineRevisions,
routines,
} from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
@@ -79,19 +54,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
const pausedAgentRows = await tx
.update(agents)
.set({
status: "paused",
pauseReason: "company_archived",
pausedAt: new Date(),
updatedAt: new Date(),
})
.where(and(
eq(agents.companyId, id),
notInArray(agents.status, ["paused", "terminated", "pending_approval"]),
))
.returning({ id: agents.id });
const pausedAgentRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), notInArray(agents.status, ["paused", "terminated", "pending_approval"])));
const activeRunIds = await tx
.select({ id: heartbeatRuns.id })
@@ -399,20 +362,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
let agentsRestored = 0;
if (willReactivate) {
const restoredRows = await tx
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(and(
eq(agents.companyId, id),
eq(agents.status, "paused"),
eq(agents.pauseReason, "company_archived"),
))
.returning({ id: agents.id });
const restoredRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), eq(agents.pauseReason, "company_archived")));
agentsRestored = restoredRows.length;
}
@@ -464,6 +414,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
};
});
if (!result) return null;
await createAgentLifecycle(db).reconcilePolicyHolds(id);
publishAccountingActivities(id, budgetPublications);
if (data.budgetMonthlyCents !== undefined) await deliverBudgetEnforcement(db, budgetHooks, id);
// Post-commit, fire-and-forget, and BEFORE any finalization that
@@ -542,6 +493,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
};
});
if (!result) return null;
await createAgentLifecycle(db).reconcilePolicyHolds(id);
// Same doorbell rule as update(): the archive is committed, so ring
// before finalization, which can throw without undoing it.
@@ -553,67 +505,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
return result.company;
},
remove: (id: string) =>
db.transaction(async (tx) => {
// Exclude accounting writers before taking child locks. KEY SHARE must
// remain compatible: native writers can already hold a child row while
// saving a company-scoped result. FOR UPDATE would deadlock that save.
const [existing] = await tx.select({ id: companies.id }).from(companies)
.where(eq(companies.id, id)).for("no key update");
if (!existing) return null;
// Finance can reference costs and both can reference runs. Incidents
// reference policies and approvals; delete these dependents first.
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(decisionInvocations).where(eq(decisionInvocations.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(budgetIncidents).where(eq(budgetIncidents.companyId, id));
await tx.delete(budgetPolicies).where(eq(budgetPolicies.companyId, id));
// Delete from child tables in dependency order
const companyRunIds = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, id));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
if (companyRunIds.length > 0) {
await tx
.delete(heartbeatRunEvents)
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
}
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
await tx.delete(invites).where(eq(invites.companyId, id));
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
await tx.delete(routineRuns).where(eq(routineRuns.companyId, id));
await tx.delete(routineTriggers).where(eq(routineTriggers.companyId, id));
await tx.delete(routineRevisions).where(eq(routineRevisions.companyId, id));
await tx.delete(routines).where(eq(routines.companyId, id));
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
await tx.delete(documents).where(eq(documents.companyId, id));
await tx.delete(issues).where(eq(issues.companyId, id));
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
await tx.delete(assets).where(eq(assets.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id));
await tx.delete(projects).where(eq(projects.companyId, id));
await tx.delete(agents).where(eq(agents.companyId, id));
const rows = await tx
.delete(companies)
.where(eq(companies.id, id))
.returning();
return rows[0] ?? null;
}),
remove: (id: string) => deleteCompany(db, id),
stats: () =>
Promise.all([
+110
View File
@@ -0,0 +1,110 @@
import type { Db } from "@paperclipai/db";
import { eq, inArray } from "drizzle-orm";
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
import {
companies,
agents,
companyLogos,
assets,
agentApiKeys,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
issues,
issueComments,
projects,
goals,
heartbeatRuns,
runIdentityContexts,
heartbeatRunEvents,
costEvents,
decisionInvocations,
financeEvents,
budgetPolicies,
budgetIncidents,
issueReadStates,
approvalComments,
approvals,
activityLog,
companySecrets,
joinRequests,
invites,
principalPermissionGrants,
companyMemberships,
companySkills,
documents,
routineRuns,
routineTriggers,
routineRevisions,
routines,
} from "@paperclipai/db";
export async function deleteCompany(db: Db, id: string) {
const lifecycle = createAgentLifecycle(db);
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, id));
for (const agent of companyAgents) {
await lifecycle.terminateAgent(agent.id);
await scheduleAgentLifecycle(db, agent.id);
}
return db.transaction(async (tx) => {
// Exclude accounting writers before taking child locks. KEY SHARE must
// remain compatible: native writers can already hold a child row while
// saving a company-scoped result. FOR UPDATE would deadlock that save.
const [existing] = await tx.select({ id: companies.id }).from(companies)
.where(eq(companies.id, id)).for("no key update");
if (!existing) return null;
// Finance can reference costs and both can reference runs. Incidents
// reference policies and approvals; delete these dependents first.
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(decisionInvocations).where(eq(decisionInvocations.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(budgetIncidents).where(eq(budgetIncidents.companyId, id));
await tx.delete(budgetPolicies).where(eq(budgetPolicies.companyId, id));
// Delete from child tables in dependency order
const companyRunIds = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, id));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
if (companyRunIds.length > 0) {
await tx
.delete(heartbeatRunEvents)
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
}
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
await tx.delete(invites).where(eq(invites.companyId, id));
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
await tx.delete(routineRuns).where(eq(routineRuns.companyId, id));
await tx.delete(routineTriggers).where(eq(routineTriggers.companyId, id));
await tx.delete(routineRevisions).where(eq(routineRevisions.companyId, id));
await tx.delete(routines).where(eq(routines.companyId, id));
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
await tx.delete(documents).where(eq(documents.companyId, id));
await tx.delete(issues).where(eq(issues.companyId, id));
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
await tx.delete(assets).where(eq(assets.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id));
await tx.delete(projects).where(eq(projects.companyId, id));
await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
const rows = await tx
.delete(companies)
.where(eq(companies.id, id))
.returning();
return rows[0] ?? null;
});
}
@@ -1,5 +1,5 @@
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
interface ExportAgentCandidate {
id: string;
+4 -2
View File
@@ -1,3 +1,4 @@
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { agentAppearanceSchema } from "@paperclipai/shared";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
@@ -3559,6 +3560,7 @@ export function parseGitHubSourceUrl(rawUrl: string) {
export function companyPortabilityService(db: Db, storage?: StorageService) {
const companies = companyService(db);
const agents = agentService(db);
const agentsLifecycle = createAgentLifecycle(db);
const assetRecords = assetService(db);
const instructions = agentInstructionsService(db);
const access = accessService(db);
@@ -5642,9 +5644,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
: {};
if (planAgent.action === "update" && planAgent.existingAgentId) {
if (pauseAutomations) await agentsLifecycle.pauseAgent(planAgent.existingAgentId, "import");
let updated = await agents.update(planAgent.existingAgentId, {
...patch,
...automationPausePatch,
});
if (!updated) {
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
@@ -5690,7 +5692,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
let created = await agents.create(targetCompany.id, {
let created = await agentsLifecycle.requestHire(targetCompany.id, {
...patch,
...automationPausePatch,
status: pauseAutomations ? "paused" : "idle",
+7 -16
View File
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { logger } from "../middleware/logger.js";
import { removeRuntimeSkillCache, resolveRuntimeSkillCache, runtimeSkillCacheSpec } from "./runtime-skill-cache.js";
import { createHash, randomUUID } from "node:crypto";
@@ -3925,15 +3926,9 @@ export function companySkillService(db: Db) {
? { key: forkKey, versionId: null }
: entry
);
const updated = await tx
.update(agentsTable)
.set({
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
updatedAt: new Date(),
})
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)))
.returning({ id: agentsTable.id })
.then((rows) => rows[0] ?? null);
const updated = await updateAgentConfigurationInTransaction(tx as unknown as Db, item.agentId, {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
}, { allowPendingApprovalConfigUpdate: true });
if (!updated) throw notFound(`Agent not found for skill reassignment: ${item.agentId}`);
}
});
@@ -4196,13 +4191,9 @@ export function companySkillService(db: Db) {
entry.key === previousKey
? { key: newKey, versionId: entry.versionId ?? null }
: entry);
await tx
.update(agentsTable)
.set({
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
updatedAt: new Date(),
})
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)));
await updateAgentConfigurationInTransaction(tx as unknown as Db, item.agentId, {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
}, { allowPendingApprovalConfigUpdate: true });
}
} catch (error) {
// Restore while the name locks are still held.
@@ -167,7 +167,11 @@ export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbea
await db.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(eq(connectionIntentDeliveries.interactionId, interactionId));
}
}
return { deliver, tryDeliver: async (id: string) => { try { await deliver(id); } catch { /* Persisted delivery remains due after its lease. */ } }, sweepPending: async () => {
async function hasPending() {
return (await db.select({ id: connectionIntentDeliveries.interactionId }).from(connectionIntentDeliveries)
.where(isNull(connectionIntentDeliveries.deliveredAt)).limit(1)).length > 0;
}
return { deliver, hasPending, tryDeliver: async (id: string) => { try { await deliver(id); } catch { /* Persisted delivery remains due after its lease. */ } }, sweepPending: async () => {
const rows = await db.select().from(connectionIntentDeliveries).where(and(isNull(connectionIntentDeliveries.deliveredAt), lte(connectionIntentDeliveries.nextAttemptAt, new Date())))
.orderBy(asc(connectionIntentDeliveries.nextAttemptAt)).limit(50);
let failed = 0;
+2 -1
View File
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { emailChannelService } from "./email-channels.js";
import { emailConnectionService } from "./email-connections.js";
import { grantConnectionAgentTools } from "./connection-agent-access.js";
@@ -1035,7 +1036,7 @@ export function connectionIntentService(db: Db) {
}
const binding = options.validatedAdoption.binding;
if (binding.provider !== payload.serviceSlug || binding.mode !== "responsible_user") throw conflict("Invalid legacy adoption binding");
const updated = await agentService(txDb).update(agent.id, {
const updated = await updateAgentConfigurationInTransaction(txDb, agent.id, {
runtimeConfig: { ...agent.runtimeConfig, aiConnection: binding },
}, { recordRevision: { createdByUserId: userId, source: "patch" } });
if (!updated) throw notFound("Agent not found");
@@ -0,0 +1,100 @@
import { subscribeDatabaseWork, databaseWorkPending, reconcileDatabaseWork } from "@paperclipai/db";
import { createWorkScheduler } from "./work-scheduler.js";
import { type DeliveryQueue } from "./delivery-work-notifications.js";
import { beginIdleTrackedWork } from "./task-admission.js";
export function createDeliveryWorkCoordinator(input: {
owner: object;
canRun: () => boolean;
// Idle drain may reconcile admitted writes without dispatching new work.
// Warm standby can disable both operations.
canReconcile?: () => boolean;
onError: (error: unknown, queue: DeliveryQueue) => void;
}) {
const scheduler = createWorkScheduler();
const workers = new Map<DeliveryQueue, { wake: () => void; stop: () => Promise<void> }>();
let stopped = false;
return {
// Only these registered tasks are represented; not yet the whole instance.
nextWakeAt: scheduler.nextWakeAt,
register(queue: DeliveryQueue, task: {
retryMs: number;
run: (signal: AbortSignal) => Promise<unknown>;
hasPending: () => Promise<boolean>;
}) {
if (stopped) throw new Error("Delivery coordinator is stopped");
if (workers.has(queue)) throw new Error(`Delivery worker already registered: ${queue}`);
let dirty = false;
let scheduledAt: number | null = null;
let running: Promise<void> | null = null;
let workerStopped = false;
let controller: AbortController | null = null;
let finishIntent: (() => void) | undefined;
function schedule(delay: number) {
if (workerStopped) return;
const at = Date.now() + delay;
if (scheduledAt !== null && scheduledAt <= at) return;
scheduledAt = at;
scheduler.schedule(queue, at, start);
}
function start() {
if (workerStopped || running) return;
scheduler.cancel(queue);
scheduledAt = null;
if (!input.canRun() && !(input.canReconcile?.() ?? false)) { schedule(task.retryMs); return; }
dirty = false;
const finish = beginIdleTrackedWork();
const attempt = new AbortController();
controller = attempt;
running = Promise.resolve().then(() => reconcileDatabaseWork(input.owner, queue))
.then(() => workerStopped || !input.canRun() ? undefined : task.run(attempt.signal)).then(() => workerStopped ? false : task.hasPending()).then(pending => {
if (pending || databaseWorkPending(input.owner, queue)) schedule(task.retryMs);
else { finishIntent?.(); finishIntent = undefined; }
}).catch(error => {
schedule(task.retryMs);
// Logging must never turn a recoverable sweep failure into an
// unhandled rejection or prevent other queues from running.
try { if (!workerStopped) input.onError(error, queue); } catch { /* Recovery remains scheduled. */ }
}).finally(() => {
running = null;
controller = null;
finish();
if (dirty) schedule(0);
});
}
function wake() {
if (workerStopped) return;
dirty = true;
if (!running) schedule(0);
}
const unsubscribe = subscribeDatabaseWork(input.owner, queue, event => {
if (workerStopped) return;
if (databaseWorkPending(input.owner, queue)) {
finishIntent ??= beginIdleTrackedWork();
// Give the transaction time to settle. Its settlement notification
// replaces this deadline with an immediate wake on the normal path.
if (!running) schedule(task.retryMs);
}
if (event === "settled") wake();
});
const worker = { wake, async stop() {
workerStopped = true;
controller?.abort(new Error("Delivery worker stopped"));
unsubscribe();
scheduler.cancel(queue);
await running;
finishIntent?.(); finishIntent = undefined;
} };
workers.set(queue, worker);
const ready = Promise.resolve().then(() => { start(); return running; });
return { ready, wake };
},
async stop() {
stopped = true;
scheduler.stop();
await Promise.all(Array.from(workers.values(), worker => worker.stop()));
workers.clear();
},
};
}
@@ -0,0 +1,22 @@
import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
/** Topics for existing durable queues; register intent before their writes. */
export const DELIVERY_QUEUES = {
feedback: "feedback-exports",
chatCompletion: "chat-completions",
connection: "connection-continuations",
question: "question-responses",
toolAction: "tool-action-receipts",
} as const;
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
export async function notifyDeliveryWork(transaction: object, queue: DeliveryQueue): Promise<void> {
await signalDatabaseWork(transaction, queue);
}
// Public delivery notification observers only see settled work. The coordinator
// uses the lower-level lifecycle subscription to also fence in-flight writes.
export function subscribeDeliveryWork(owner: object, queue: DeliveryQueue, wake: () => void): () => void {
return subscribeDatabaseWork(owner, queue, event => {
if (event === "settled") wake();
});
}
+45 -31
View File
@@ -1,6 +1,8 @@
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
import { activityLog, agents, companies, dotAgentBindings, type Db } from "@paperclipai/db";
import { agentService } from "./agents.js";
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
import { withDedicatedDbConnection } from "@paperclipai/db";
import { approvalService } from "./approvals.js";
import { accessService } from "./access.js";
import { agentInstructionsService } from "./agent-instructions.js";
@@ -11,21 +13,22 @@ import { notFound, unprocessable } from "../errors.js";
/** One unfinished invitation per operator/company. Only non-secret references are persisted. */
export function dotInvitationService(db: Db) {
async function pending(database: Db, companyId: string, operatorId: string) {
async function pending(database: Db, companyId: string, operatorId: string, recoverIncomplete = false) {
const rows = await database.select({ agent: agents, binding: dotAgentBindings })
.from(agents).leftJoin(dotAgentBindings, and(eq(dotAgentBindings.agentId, agents.id), isNull(dotAgentBindings.revokedAt)))
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"),
eq(agents.adapterType, "paperclip_runner"), sql`${agents.adapterConfig}->>'provider' = 'openai_dot'`,
sql`exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId}
sql`((${recoverIncomplete} and ${agents.metadata}->'dotInvitation'->>'operatorId' = ${operatorId}
and ${agents.lifecycleOperation}->>'responsibleUserId' = ${operatorId}) or exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId}
and ${activityLog.entityId} = ${agents.id}::text and ${activityLog.entityType} = 'agent'
and ${activityLog.actorType} = 'user' and ${activityLog.actorId} = ${operatorId}
and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation')`)).orderBy(desc(agents.createdAt));
and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation'))`)).orderBy(desc(agents.createdAt));
return rows.find(row => row.binding?.status !== "ready")?.agent ?? null;
}
async function describe(agent: typeof agents.$inferSelect) {
const approval = await approvalService(db).findOpenHireApprovalForAgent(agent.companyId, agent.id);
async function describe(agent: Pick<typeof agents.$inferSelect, "id" | "name" | "companyId" | "status">, database: Db = db) {
const approval = await approvalService(database).findOpenHireApprovalForAgent(agent.companyId, agent.id);
return { agent: { id: agent.id, name: agent.name, status: agent.status }, approvalId: approval?.id ?? null,
binding: await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id) };
binding: await dotRunnerBroker(database).bindingForAgent(agent.companyId, agent.id) };
}
return {
async resume(companyId: string, operatorId: string) {
@@ -34,16 +37,24 @@ export function dotInvitationService(db: Db) {
},
async create(companyId: string, operatorId: string) {
if (!await dotRunnerBroker(db).enabled()) throw unprocessable("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings.");
const agent = await db.transaction(async tx => {
const database = tx as unknown as Db;
const database = db;
const lockKey = `paperclip:dot-invitation:${companyId}:${operatorId}`;
return withDedicatedDbConnection(db, lockDb => lockDb.transaction(async lock => {
await lock.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
// SQL locking also serializes retries on different control-plane replicas.
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for("update");
const [company] = await database.select().from(companies).where(eq(companies.id, companyId));
if (!company) throw notFound("Company not found");
if (company.status !== "active") throw unprocessable("Activate this company before inviting Dot.");
const existing = await pending(database, companyId, operatorId);
if (existing) return existing;
const existing = await pending(database, companyId, operatorId, true);
if (existing) {
const [completed] = await database.select({ id: activityLog.id }).from(activityLog).where(and(
eq(activityLog.companyId, companyId), eq(activityLog.entityId, existing.id),
eq(activityLog.action, "agent.hire_created"), eq(activityLog.actorId, operatorId),
sql`${activityLog.details}->>'source' = 'dot-invitation'`)).limit(1);
if (completed) return describe(existing, database);
}
const svc = agentService(database);
let created = await svc.create(companyId, {
let created = existing ?? await createAgentLifecycle(database).requestHire(companyId, {
name: "Dot", role: "general", adapterType: "paperclip_runner",
adapterConfig: { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true,
dotWorkspaceAccess: false, dotAttachmentAccess: false },
@@ -54,25 +65,28 @@ export function dotInvitationService(db: Db) {
const bundle = await agentInstructionsService(database).materializeManagedBundle(created,
await loadDefaultAgentInstructionsBundle("default"), { entryFile: "AGENTS.md", replaceExisting: false });
created = (await svc.update(created.id, { adapterConfig: bundle.adapterConfig }, { allowPendingApprovalConfigUpdate: true }))!;
const access = accessService(database);
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId);
const approval = company.requireBoardApprovalForNewAgents ? await approvalService(database).create(companyId, {
type: "hire_agent", status: "pending", requestedByUserId: operatorId,
payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType,
adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata,
budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: {
adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig,
} },
}) : null;
await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created",
entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role,
requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } });
if (approval) await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "approval.created",
entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } });
return created;
});
return describe(agent);
await database.transaction(async tx => {
const txDb = tx as unknown as Db;
const access = accessService(txDb);
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId);
const approval = await approvalService(txDb).findOpenHireApprovalForAgent(companyId, created.id) ?? (company.requireBoardApprovalForNewAgents ? await approvalService(txDb).create(companyId, {
type: "hire_agent", status: "pending", requestedByUserId: operatorId,
payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType,
adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata,
budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: {
adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig,
} },
}) : null);
await logActivity(txDb, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created",
entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role,
requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } });
if (approval) await logActivity(txDb, { companyId, actorType: "user", actorId: operatorId, action: "approval.created",
entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } });
});
scheduleAgentLifecycle(db, created.id);
return describe(created, database);
}));
},
};
}
+13 -9
View File
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod";
@@ -13,7 +14,7 @@ import type { PublicMcpToolExtension } from "./public-mcp/dot-runner.js";
import { boardAuthService } from "./board-auth.js";
import { logActivity } from "./activity-log.js";
import { authorizationService } from "./authorization.js";
import { agentService } from "./agents.js";
import { canConfigureAgentConnection } from "../modules/agent-lifecycle/index.js";
import { issueService } from "./issues.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js";
@@ -43,7 +44,9 @@ function createBroker(db: Db) {
const [agent] = await db.select().from(agents).where(and(eq(agents.id, binding.agentId), eq(agents.companyId, binding.companyId)));
const access = grant ? await boardAuthService(db).resolveBoardAccess(grant.userId) : null;
const membership = access?.memberships.find(m => m.companyId === binding.companyId && m.status === "active");
if (!access?.user || !membership || membership.membershipRole === "viewer" || !grant || grant.agentId !== binding.agentId || !agent || ["terminated", "pending_approval", ...(allowPaused ? [] : ["paused"])].includes(agent.status)) throw fail("Agent connection authority is unavailable.");
if (!access?.user || !membership || membership.membershipRole === "viewer" || !grant || grant.agentId !== binding.agentId || !agent
|| ["terminated", "pending_approval"].includes(agent.status)
|| (!allowPaused && (requireReady ? agent.status === "paused" : !canConfigureAgentConnection(agent)))) throw fail("Agent connection authority is unavailable.");
return binding;
}
@@ -115,7 +118,7 @@ function createBroker(db: Db) {
const code = randomBytes(24).toString("base64url");
const binding = await db.transaction(async tx => {
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))).for("update");
if (!agent || agent.adapterType !== "paperclip_runner" || agent.adapterConfig.provider !== "openai_dot" || ["pending_approval", "terminated", "paused"].includes(agent.status)) throw fail("Choose an approved Paperclip Runner agent.");
if (!agent || agent.adapterType !== "paperclip_runner" || agent.adapterConfig.provider !== "openai_dot" || !canConfigureAgentConnection(agent)) throw fail("Choose an approved Paperclip Runner agent.");
const [existing] = await tx.select().from(bindings).where(and(eq(bindings.companyId, input.companyId), eq(bindings.agentId, input.agentId), isNull(bindings.revokedAt)));
if (existing) {
if (existing.id !== input.replaceBindingId || existing.status !== "pairing" || existing.operatorId !== input.operatorId) {
@@ -143,10 +146,10 @@ function createBroker(db: Db) {
if (!b || b.status !== "pairing" || !b.pairingExpiresAt || b.pairingExpiresAt <= new Date()) throw fail("Pairing code expired or was consumed.");
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, b.agentId), eq(agents.companyId, b.companyId))).for("update");
const [grant] = await tx.select().from(mcpOauthGrants).where(and(eq(mcpOauthGrants.id, principal.grant.id), isNull(mcpOauthGrants.revokedAt))).for("update");
if (!grant || grant.agentId || !agent || ["paused", "terminated", "pending_approval"].includes(agent.status)) throw fail("Pairing authority is unavailable.");
if (!grant || grant.agentId || !agent || !canConfigureAgentConnection(agent)) throw fail("Pairing authority is unavailable.");
await tx.update(bindings).set({ grantId: grant.id, status: "connected", pairingCodeHash: null, pairingExpiresAt: null, updatedAt: new Date() }).where(eq(bindings.id, b.id));
await tx.update(mcpOauthGrants).set({ agentId: b.agentId }).where(eq(mcpOauthGrants.id, grant.id));
await agentService(tx as unknown as Db).update(agent.id, { adapterConfig: { ...agent.adapterConfig, dotBindingId: b.id } },
await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, { adapterConfig: { ...agent.adapterConfig, dotBindingId: b.id } },
{ recordRevision: { createdByUserId: b.operatorId, source: "dot-pairing" } });
await logActivity(tx as unknown as Db, { companyId: b.companyId, actorType: "user", actorId: b.operatorId,
action: "dot.paired", entityType: "agent", entityId: b.agentId, details: { bindingId: b.id, generation: b.generation } });
@@ -197,9 +200,10 @@ function createBroker(db: Db) {
const [agent] = await tx.select().from(agents).where(eq(agents.id, b.agentId));
if (!agent || ["terminated", "pending_approval"].includes(agent.status)) throw fail("Agent connection authority is unavailable.");
const paused = agent.status === "paused";
const visibleKinds: Array<typeof mailbox.$inferSelect.kind> = canConfigureAgentConnection(agent) ? ["authority_revoked", "readiness_challenge"] : ["authority_revoked"];
const items = await tx.select().from(mailbox).where(and(eq(mailbox.companyId, b.companyId), eq(mailbox.bindingId, b.id),
eq(mailbox.bindingGeneration, b.generation), gt(mailbox.id, after), paused ? eq(mailbox.kind, "authority_revoked") : undefined)).orderBy(asc(mailbox.id)).limit(50);
// Paused reads expose fences only and never consume hidden task items.
eq(mailbox.bindingGeneration, b.generation), gt(mailbox.id, after), paused ? inArray(mailbox.kind, visibleKinds) : undefined)).orderBy(asc(mailbox.id)).limit(50);
// Setup reads expose challenges and fences without consuming hidden work.
return { bindingId: b.id, generation: b.generation, items, nextCursor: paused ? after : items.at(-1)?.id ?? after };
});
},
@@ -256,7 +260,7 @@ function createBroker(db: Db) {
const access = await boardAuthService(db).resolveBoardAccess(b.operatorId);
return { companyId: b.companyId, agentId: b.agentId, agentName: agent!.name,
responsibleUser: access.user ? { id: access.user.id, name: access.user.name } : null,
permissions: agent!.permissions, ready: state?.status === "ready" && state.subscriptionVerified,
permissions: agent!.permissions, ready: agent!.lifecycleState === "ready" && state?.status === "ready" && state.subscriptionVerified,
assignment: state?.assignment ?? null,
idle: { read: ["paperclip_dot_capabilities", "paperclip_dot_tasks", "paperclip_dot_inbox"],
profile: "paperclip_dot_set_avatar",
@@ -429,7 +433,7 @@ function createBroker(db: Db) {
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).for("update");
if (agent?.adapterConfig.dotBindingId === b.id) {
const { dotBindingId: _removed, ...adapterConfig } = agent.adapterConfig;
await agentService(tx as unknown as Db).update(agentId, { adapterConfig },
await updateAgentConfigurationInTransaction(tx as unknown as Db, agentId, { adapterConfig },
{ recordRevision: { createdByUserId: operatorId, source: "dot-revoke" } });
}
await tx.update(assignments).set({ status: "fenced" }).where(and(eq(assignments.bindingId, b.id), inArray(assignments.status, ["offered", "accepted"])));
+4 -1
View File
@@ -4043,7 +4043,10 @@ export function environmentRuntimeService(
and(
eq(environmentLeases.heartbeatRunId, heartbeatRunId),
or(eq(environmentLeases.status, "active"), and(eq(environmentLeases.status, "pending_cleanup"),
sql`(${environmentLeases.metadata} ? 'nativeWorkspaceExportResume' or ${environmentLeases.metadata} ? 'sandboxStopAndRetain')`)),
sql`(${environmentLeases.metadata} ? 'nativeWorkspaceExportResume' or ${environmentLeases.metadata} ? 'sandboxStopAndRetain')`),
providerResourceDisposition === "destroy"
? and(eq(environmentLeases.status, "retained"), eq(environmentLeases.cleanupStatus, "failed"))
: undefined),
),
);
if (leaseRows.length === 0) {
@@ -0,0 +1,52 @@
import { unprocessable } from "../errors.js";
export async function assertEnvironmentSelectionForCompany(
environmentsSvc: {
getById(environmentId: string): Promise<{
id: string;
driver: string;
status?: string | null;
config: Record<string, unknown> | null;
} | null>;
},
_companyId: string,
environmentId: string | null | undefined,
options?: {
allowedDrivers?: string[];
allowedSandboxProviders?: string[];
},
) {
if (environmentId === undefined || environmentId === null) return;
const environment = await environmentsSvc.getById(environmentId);
if (!environment) {
throw unprocessable("Environment not found.");
}
if (environment.status === "archived") {
throw unprocessable("Environment is archived.");
}
if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) {
throw unprocessable(
`Environment driver "${environment.driver}" is not allowed here. Allowed drivers: ${options.allowedDrivers.join(", ")}`,
);
}
if (environment.driver === "sandbox") {
const config = environment.config && typeof environment.config === "object"
? environment.config as Record<string, unknown>
: {};
const provider = typeof config.provider === "string" ? config.provider : "";
if (provider === "fake") {
throw unprocessable(
`Environment sandbox provider "${provider}" is not allowed here. The built-in fake provider is probe-only and cannot execute runs.`,
);
}
if (
options?.allowedSandboxProviders
&& options.allowedSandboxProviders.length > 0
&& !options.allowedSandboxProviders.includes(provider)
) {
throw unprocessable(
`Environment sandbox provider "${provider || "unknown"}" is not allowed here. Allowed providers: ${options.allowedSandboxProviders.join(", ")}`,
);
}
}
}
@@ -200,6 +200,7 @@ export async function reconcileAbandonedExecutionControl(
eq(agents.id, run.agentId),
eq(agents.companyId, run.companyId),
eq(agents.status, "running"),
eq(agents.lifecycleState, "ready"),
sql`not exists (select 1 from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${run.agentId} and ${heartbeatRuns.status} = 'running')`,
),
);
+10 -2
View File
@@ -2,6 +2,8 @@ import { gzipSync } from "node:zlib";
import type { FeedbackTraceBundle } from "@paperclipai/shared";
import type { Config } from "../config.js";
export const FEEDBACK_UPLOAD_TIMEOUT_MS = 30_000;
const DEFAULT_FEEDBACK_EXPORT_BACKEND_URL = "https://telemetry.paperclip.ing";
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
@@ -12,7 +14,7 @@ function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Da
}
export interface FeedbackTraceShareClient {
uploadTraceBundle(bundle: FeedbackTraceBundle): Promise<{ objectKey: string }>;
uploadTraceBundle(bundle: FeedbackTraceBundle, signal?: AbortSignal): Promise<{ objectKey: string }>;
}
export function createFeedbackTraceShareClientFromConfig(
@@ -23,7 +25,10 @@ export function createFeedbackTraceShareClientFromConfig(
const endpoint = new URL("/feedback-traces", baseUrl).toString();
return {
async uploadTraceBundle(bundle) {
async uploadTraceBundle(bundle, shutdownSignal) {
const deadline = AbortSignal.timeout(FEEDBACK_UPLOAD_TIMEOUT_MS);
const signal = shutdownSignal ? AbortSignal.any([shutdownSignal, deadline]) : deadline;
signal.throwIfAborted();
const exportedAt = new Date();
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
const requestBody = JSON.stringify({
@@ -33,6 +38,7 @@ export function createFeedbackTraceShareClientFromConfig(
});
const response = await fetch(endpoint, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
@@ -49,6 +55,8 @@ export function createFeedbackTraceShareClientFromConfig(
}
const payload = await response.json().catch(() => null) as { objectKey?: unknown } | null;
// A timed-out response body is not a successful upload acknowledgement.
signal.throwIfAborted();
return {
objectKey: typeof payload?.objectKey === "string" && payload.objectKey.trim().length > 0
? payload.objectKey
+26 -4
View File
@@ -1,3 +1,4 @@
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, getTableColumns, gte, isNull, lte, ne, or } from "drizzle-orm";
@@ -111,7 +112,7 @@ const feedbackExportColumns = getTableColumns(feedbackExports);
const instructionsSvc = agentInstructionsService();
type FeedbackTraceShareClient = {
uploadTraceBundle(bundle: FeedbackTraceBundle): Promise<{ objectKey: string }>;
uploadTraceBundle(bundle: FeedbackTraceBundle, signal?: AbortSignal): Promise<{ objectKey: string }>;
};
type FeedbackServiceOptions = {
@@ -1715,6 +1716,15 @@ async function buildFeedbackTraceBundleFromRow(
}
export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
// Serialize explicit flushes and the worker's batches so callers cannot
// upload the same trace concurrently. HTTP vote handlers only save and notify.
let exportFlush: Promise<unknown> = Promise.resolve();
function serializeExportFlush<T>(operation: () => Promise<T>): Promise<T> {
const result = exportFlush.then(operation, operation);
exportFlush = result.catch(() => undefined);
return result;
}
return {
listIssueVotesForUser: async (issueId: string, authorUserId: string) =>
db
@@ -1786,13 +1796,23 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
return row ? buildFeedbackTraceBundleFromRow(db, row) : null;
},
hasPendingFeedbackTraces: async () => {
const pending = options.shareClient
? or(eq(feedbackExports.status, "pending"), eq(feedbackExports.status, "failed"))
: eq(feedbackExports.status, "pending");
return (await db.select({ id: feedbackExports.id }).from(feedbackExports)
.where(pending).limit(1)).length > 0;
},
flushPendingFeedbackTraces: async (input?: {
companyId?: string;
traceId?: string;
limit?: number;
now?: Date;
}) => {
signal?: AbortSignal;
}) => serializeExportFlush(async () => {
const shareClient = options.shareClient;
input?.signal?.throwIfAborted();
if (!shareClient) {
const filters = [eq(feedbackExports.status, "pending")];
if (input?.companyId) {
@@ -1861,12 +1881,13 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
let failed = 0;
for (const row of rows) {
if (input?.signal?.aborted) break;
const attemptAt = input?.now ?? new Date();
attempted += 1;
try {
const bundle = await buildFeedbackTraceBundleFromRow(db, row);
await shareClient.uploadTraceBundle(bundle);
await shareClient.uploadTraceBundle(bundle, input?.signal);
await db
.update(feedbackExports)
@@ -1900,7 +1921,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
sent,
failed,
};
},
}),
saveIssueVote: async (input: {
issueId: string;
@@ -2071,6 +2092,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
})
.where(eq(feedbackVotes.id, savedVote.id));
if (sharedWithLabs) await notifyDeliveryWork(tx, DELIVERY_QUEUES.feedback);
const [savedTrace] = await tx
.insert(feedbackExports)
.values({
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -1,3 +1,4 @@
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
import { normalizeAgentNameKey } from "./retries.js";
import {
type WakeupOptions,
@@ -941,6 +942,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
);
return null;
}
// Keep accepted work in the durable queue until setup completes.
if (isAgentAwaitingSetup(agent)) return null;
const invokability = companyAgents
? evaluateAgentInvokability(toAgentOrgRow(agent), companyAgents)
: await getAgentInvokability(agent);
@@ -1862,6 +1865,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
// Cancelled after the start lock is released: cancelRunInternal promotes the
// agent's next queued run, which takes this same lock.
const rejectedClaims: Array<{ run: typeof heartbeatRuns.$inferSelect; err: HttpError }> = [];
let cancellationReason: string | undefined;
return withAgentStartLock(agentId, async () => {
const agent = await getAgent(agentId);
@@ -1869,10 +1873,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
const invokability = await getAgentInvokability(agent);
if (!invokability.invokable) {
if (shouldCancelRunsForNonInvokableAgent(invokability)) {
await cancelActiveForAgentInternal(
agentId,
`Cancelled because the agent is not invokable: ${invokability.reason}`,
);
cancellationReason = `Cancelled because the agent is not invokable: ${invokability.reason}`;
}
return [];
}
@@ -2011,7 +2012,10 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
});
}
return claimedRuns;
}).finally(() => cancelRejectedQueuedRuns(rejectedClaims));
}).finally(async () => {
if (cancellationReason) await cancelActiveForAgentInternal(agentId, cancellationReason);
await cancelRejectedQueuedRuns(rejectedClaims);
});
}
// Public wakeup entry point. Callers dispatch it fire-and-forget, so register
@@ -2501,7 +2505,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
});
}
const invokability = await getAgentInvokability(agent);
// Setup delays execution, but must not discard work accepted by a caller.
const invokability = await getAgentInvokability(isAgentAwaitingSetup(agent) ? { ...agent, status: "idle" } : agent);
if (!invokability.invokable) {
if (opts.requestedByActorType !== "user" || executionWaitRequestId) {
await writeSkippedRequest("agent.not_invokable", {
@@ -0,0 +1,309 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { runningProcesses } from "../../adapters/index.js";
import { adapterExecutionControls } from "../adapter-execution-control.js";
import { terminateLocalService } from "../local-service-supervisor.js";
import { createHeartbeatRunState } from "./run-state.js";
import { createHeartbeatRunPreparation } from "./run-preparation.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js";
import { createHeartbeatRunControl, type HeartbeatRunControlDependencies } from "./run-control.js";
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
vi.mock("../local-service-supervisor.js", async (importOriginal) => ({
...await importOriginal<typeof import("../local-service-supervisor.js")>(),
terminateLocalService: vi.fn(async () => undefined),
}));
type Run = typeof heartbeatRuns.$inferSelect;
function callbacks(db: Db) {
const state = createHeartbeatRunState(db);
const lifecycleDependencies = {
...state,
...createHeartbeatRunPreparation(db),
issuesSvc: {
addComment: vi.fn<HeartbeatLifecycleDependencies["issuesSvc"]["addComment"]>(),
listReviewAttention: vi.fn<HeartbeatLifecycleDependencies["issuesSvc"]["listReviewAttention"]>(),
},
recovery: { escalateStrandedAssignedIssue: vi.fn<HeartbeatLifecycleDependencies["recovery"]["escalateStrandedAssignedIssue"]>() },
companySkills: { completeTestRunForIssue: vi.fn<HeartbeatLifecycleDependencies["companySkills"]["completeTestRunForIssue"]>() },
budgets: { getInvocationBlock: vi.fn(async () => null) },
treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
enqueueWakeup: vi.fn<HeartbeatLifecycleDependencies["enqueueWakeup"]>(),
getCurrentUserRedactionOptions: vi.fn(async () => ({})),
getAgentInvokability: vi.fn<HeartbeatLifecycleDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
emitTerminalAgentTaskRun: vi.fn<HeartbeatLifecycleDependencies["emitTerminalAgentTaskRun"]>(),
budgetHooks: {},
} satisfies HeartbeatLifecycleDependencies;
const lifecycle = createHeartbeatLifecycle(db, lifecycleDependencies);
return {
...state,
...lifecycle,
getRun: vi.fn(state.getRun),
getAgent: vi.fn(state.getAgent),
setRunStatusFromLive: vi.fn(lifecycle.setRunStatusFromLive),
setRunStatus: vi.fn(lifecycle.setRunStatus),
setWakeupStatus: vi.fn(lifecycle.setWakeupStatus),
appendRunEvent: vi.fn(lifecycle.appendRunEvent),
finalizeAgentStatus: vi.fn<HeartbeatRunControlDependencies["finalizeAgentStatus"]>(),
options: { closeWarmNativeSessionsForRun: vi.fn(async () => ({ closed: 1, busy: 0, failed: 0 })) },
envOrchestrator: { releaseForRun: vi.fn<HeartbeatRunControlDependencies["envOrchestrator"]["releaseForRun"]>(async () => ({ released: [], errors: [] })) },
enqueueWakeup: lifecycleDependencies.enqueueWakeup,
startNextQueuedRunForAgent: vi.fn<HeartbeatRunControlDependencies["startNextQueuedRunForAgent"]>(),
sweepPendingCleanupLeases: vi.fn<HeartbeatRunControlDependencies["sweepPendingCleanupLeases"]>(),
timerClaimWasFirstHeartbeat: vi.fn<HeartbeatRunControlDependencies["timerClaimWasFirstHeartbeat"]>(() => undefined),
wakeQueue: { releaseIssueExecution: vi.fn<HeartbeatRunControlDependencies["wakeQueue"]["releaseIssueExecution"]>(async () => ({ outcome: { kind: "released" }, postCommitEffects: [] })) },
applyWakeQueuePostCommitEffects: vi.fn(async () => undefined),
getSchedulingSuppression: vi.fn(async () => ({ suppressed: false })),
activeRunExecutions: new Set<string>(),
processRunCancellationSettlements: new Map<string, { settled: Promise<void>; failed: boolean; error?: unknown }>(),
failedProcessRunCancellations: new Map<string, { settled: Promise<void>; failed: boolean; error?: unknown }>(),
} satisfies HeartbeatRunControlDependencies;
}
function guardedDatabase() {
const access = vi.fn(() => { throw new Error("Unexpected run-control database access"); });
return { db: new Proxy({}, { get: access }) as Db, access };
}
describe("heartbeat run-control module boundary", () => {
afterEach(() => adapterExecutionControls.clear());
it("constructs without database access, cancellation, or cleanup", () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
createHeartbeatRunControl(database.db, deps);
expect(database.access).not.toHaveBeenCalled();
for (const callback of Object.values(deps)) if (vi.isMockFunction(callback)) expect(callback).not.toHaveBeenCalled();
expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
});
it("keeps an already completed run out of cancellation effects", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
const run = { id: "finished", status: "succeeded", runtimeMode: "legacy" } as Run;
deps.getRun.mockResolvedValue(run);
expect(await createHeartbeatRunControl(database.db, deps).cancelRunInternal(run.id)).toBe(run);
expect(database.access).not.toHaveBeenCalled();
expect(deps.getAgent).not.toHaveBeenCalled();
expect(deps.appendRunEvent).not.toHaveBeenCalled();
});
it("keeps a suppressed saved-input scan out of the database and wake admission", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
deps.getSchedulingSuppression.mockResolvedValue({ suppressed: true });
await createHeartbeatRunControl(database.db, deps).resumeExecutionWaitComments();
expect(database.access).not.toHaveBeenCalled();
expect(deps.enqueueWakeup).not.toHaveBeenCalled();
});
it("ignores an empty cancellation batch", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
expect(await createHeartbeatRunControl(database.db, deps).cancelInvocationsForAgentsInternal(["", ""], "pause")).toEqual({ agentIds: [], runsCancelled: 0, wakeupsCancelled: 0 });
expect(database.access).not.toHaveBeenCalled();
expect(deps.getAgent).not.toHaveBeenCalled();
});
it("waits for an owned adapter before admitting saved comments", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
const run = { id: "owned", status: "cancelled", runtimeMode: "legacy" } as Run;
adapterExecutionControls.set(run.id, {} as never);
await createHeartbeatRunControl(database.db, deps).resumeRemoteStopComments(run);
expect(database.access).not.toHaveBeenCalled();
expect(deps.enqueueWakeup).not.toHaveBeenCalled();
});
it("retains leases while native execution ownership is unverified", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
deps.getRun.mockResolvedValue({ runtimeMode: "native", status: "running", nativePhase: "terminal_failure", errorCode: "native_execution_ownership_unverified" } as Run);
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "held", companyId: "company", agentId: "agent", status: "failed", providerResourceDisposition: "destroy" });
expect(database.access).not.toHaveBeenCalled();
expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
});
it("uses the persisted terminal outcome when choosing warm resource retention", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
deps.getRun.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "failed", companyId: "company", agentId: "agent", status: "succeeded", providerResourceDisposition: "keep_running" });
expect(deps.envOrchestrator.releaseForRun).toHaveBeenCalledWith(expect.objectContaining({ heartbeatRunId: "failed", status: "failed", providerResourceDisposition: "stop_and_retain" }));
expect(database.access).not.toHaveBeenCalled();
});
it("defers destruction when a warm session remains busy", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
deps.getRun.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
deps.options.closeWarmNativeSessionsForRun.mockResolvedValue({ closed: 0, busy: 1, failed: 0 });
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "busy", companyId: "company", agentId: "agent", status: "failed", providerResourceDisposition: "destroy" });
expect(deps.options.closeWarmNativeSessionsForRun).toHaveBeenCalledWith({ runId: "busy", reason: "terminal heartbeat run destroyed its environment lease" });
expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
expect(database.access).not.toHaveBeenCalled();
});
});
const support = await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping run-control database tests: ${support.reason}`);
describe.skipIf(!support.supported)("heartbeat run-control database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-run-control-module");
db = createDb(database.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
beforeEach(async () => {
vi.mocked(terminateLocalService).mockReset().mockResolvedValue(undefined);
await db.execute(sql`truncate table companies restart identity cascade`);
});
afterEach(() => runningProcesses.clear());
async function fixture() {
const [company] = await db.insert(companies).values({ name: "Run control", issuePrefix: "RC" }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Controlled agent", adapterType: "codex_local" }).returning();
return { company, agent };
}
async function liveRun(companyId: string, agentId: string) {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", runtimeMode: "legacy", runtimeModeResolvedAt: new Date() }).returning();
runningProcesses.set(run.id, { child: { pid: 424242 } as never, graceSec: 4, processGroupId: 424242 });
return run;
}
it("reports a missing run without entering cancellation", async () => {
const deps = callbacks(db);
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(randomUUID())).rejects.toThrow("Heartbeat run not found");
expect(deps.getAgent).not.toHaveBeenCalled();
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
expect(deps.appendRunEvent).not.toHaveBeenCalled();
});
it("joins concurrent Stop requests across factories through the shared process barrier", async () => {
const { company, agent } = await fixture();
const run = await liveRun(company.id, agent.id);
const deps = callbacks(db);
const cancellationLookup = vi.spyOn(deps.processRunCancellationSettlements, "get");
let releaseTermination!: () => void;
const terminating = new Promise<void>(resolve => { releaseTermination = resolve; });
vi.mocked(terminateLocalService).mockReturnValue(terminating);
const first = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Stop", { terminationGraceMs: 1 });
await vi.waitFor(() => expect(deps.processRunCancellationSettlements.has(run.id)).toBe(true));
const second = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Stop again");
await vi.waitFor(() => expect(cancellationLookup).toHaveBeenCalledTimes(2));
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
expect(terminateLocalService).toHaveBeenCalledTimes(1);
releaseTermination();
const results = await Promise.all([first, second]);
expect(results.map(result => result?.status)).toEqual(["cancelled", "cancelled"]);
expect(deps.setRunStatusFromLive).toHaveBeenCalledTimes(1);
expect(deps.appendRunEvent).toHaveBeenCalledTimes(1);
expect(deps.finalizeAgentStatus).toHaveBeenCalledTimes(1);
expect(deps.startNextQueuedRunForAgent).toHaveBeenCalledTimes(1);
expect(deps.processRunCancellationSettlements.size).toBe(0);
expect(runningProcesses.has(run.id)).toBe(false);
expect(terminateLocalService).toHaveBeenCalledWith({ pid: 424242, processGroupId: 424242 }, { forceAfterMs: 100, signal: "SIGINT" });
});
it("settles every joined Stop caller with the same termination failure", async () => {
const { company, agent } = await fixture();
const run = await liveRun(company.id, agent.id);
const deps = callbacks(db);
const cancellationLookup = vi.spyOn(deps.processRunCancellationSettlements, "get");
deps.activeRunExecutions.add(run.id);
let rejectTermination!: (error: Error) => void;
vi.mocked(terminateLocalService).mockReturnValue(new Promise<void>((_resolve, reject) => { rejectTermination = reject; }));
const first = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id);
await vi.waitFor(() => expect(deps.processRunCancellationSettlements.has(run.id)).toBe(true));
const second = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id);
const results = Promise.allSettled([first, second]);
await vi.waitFor(() => expect(cancellationLookup).toHaveBeenCalledTimes(2));
const failure = new Error("provider did not stop");
rejectTermination(failure);
expect(await results).toEqual([{ status: "rejected", reason: failure }, { status: "rejected", reason: failure }]);
expect(terminateLocalService).toHaveBeenCalledTimes(1);
expect(deps.processRunCancellationSettlements.size).toBe(0);
expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure);
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
expect((await deps.getRun(run.id))?.status).toBe("running");
});
it("retains failed Stop evidence and the owned process until a later request succeeds", async () => {
const { company, agent } = await fixture();
const run = await liveRun(company.id, agent.id);
const deps = callbacks(db);
deps.activeRunExecutions.add(run.id);
const failure = new Error("termination failed");
vi.mocked(terminateLocalService).mockRejectedValueOnce(failure);
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).rejects.toBe(failure);
expect(deps.failedProcessRunCancellations.get(run.id)).toMatchObject({ failed: true, error: failure });
expect(deps.processRunCancellationSettlements.size).toBe(0);
expect(runningProcesses.has(run.id)).toBe(true);
expect((await deps.getRun(run.id))?.status).toBe("running");
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
expect(await createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).toMatchObject({ status: "cancelled" });
expect(terminateLocalService).toHaveBeenCalledTimes(2);
// The executor still owns removal of this evidence after it observes Stop.
expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure);
});
it("deduplicates agent batches and cancels only unclaimed pending wakes", async () => {
const { company, agent } = await fixture();
const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent" }).returning();
const [finished] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "succeeded" }).returning();
const wakes = await db.insert(agentWakeupRequests).values([
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued" },
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "deferred_issue_execution" },
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "claimed" },
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued", runId: finished.id },
{ companyId: company.id, agentId: otherAgent.id, source: "assignment", status: "queued" },
]).returning();
const deps = callbacks(db);
expect(await createHeartbeatRunControl(db, deps).cancelInvocationsForAgentsInternal([agent.id, "", agent.id], "pause")).toEqual({ agentIds: [agent.id], runsCancelled: 0, wakeupsCancelled: 2 });
const persisted = await db.select().from(agentWakeupRequests);
expect(wakes.map(wake => persisted.find(row => row.id === wake.id)?.status)).toEqual(["cancelled", "cancelled", "claimed", "queued", "queued"]);
});
it("rechecks stale budget enforcement before cancelling runs or queued wakes", async () => {
const { company, agent } = await fixture();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "queued", runtimeMode: "legacy" }).returning();
const [wake] = await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued" }).returning();
const deps = callbacks(db);
await createHeartbeatRunControl(db, deps).cancelBudgetScopeWork({ companyId: company.id, scopeType: "agent", scopeId: agent.id, enforcement: { policyId: randomUUID(), version: 1 } });
expect((await deps.getRun(run.id))?.status).toBe("queued");
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)))[0].status).toBe("queued");
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
expect(terminateLocalService).not.toHaveBeenCalled();
});
it("limits project budget cancellation by company, effective project, and admission cutoff", async () => {
const { company, agent } = await fixture();
const [otherCompany] = await db.insert(companies).values({ name: "Other company", issuePrefix: "OC" }).returning();
const [otherAgent] = await db.insert(agents).values({ companyId: otherCompany.id, name: "Other company agent" }).returning();
const projectRows = await db.insert(projects).values([
{ companyId: company.id, name: "Target" }, { companyId: company.id, name: "Override" },
]).returning();
const [issue] = await db.insert(issues).values({ companyId: company.id, projectId: projectRows[0].id, title: "Bound task", issueNumber: 1, identifier: "RC-1" }).returning();
const old = new Date("2026-01-01T00:00:00Z");
const cutoff = new Date("2026-01-02T00:00:00Z");
const wakes = await db.insert(agentWakeupRequests).values([
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued", createdAt: old, payload: { issueId: issue.id } },
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued", createdAt: old, payload: { issueId: issue.id, projectId: projectRows[1].id } },
{ companyId: company.id, agentId: agent.id, source: "assignment", status: "queued", createdAt: cutoff, payload: { projectId: projectRows[0].id } },
{ companyId: otherCompany.id, agentId: otherAgent.id, source: "assignment", status: "queued", createdAt: old, payload: { projectId: projectRows[0].id } },
]).returning();
await createHeartbeatRunControl(db, callbacks(db)).cancelBudgetScopeWork({ companyId: company.id, scopeType: "project", scopeId: projectRows[0].id, createdBefore: cutoff });
const persisted = await db.select().from(agentWakeupRequests);
expect(wakes.map(wake => persisted.find(row => row.id === wake.id)?.status)).toEqual(["cancelled", "queued", "queued", "queued"]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,196 @@
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import type { CurrentUserRedactionOptions } from "../../log-redaction.js";
import { publishLiveEvent } from "../live-events.js";
import { getHeartbeatRunRuntimeStatus } from "../heartbeat-run-runtime-status.js";
import { createHeartbeatRunState } from "./run-state.js";
import { createHeartbeatRunPreparation } from "./run-preparation.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js";
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
type Run = typeof heartbeatRuns.$inferSelect;
function callbacks(db: Db) {
const state = createHeartbeatRunState(db);
return {
...state,
...createHeartbeatRunPreparation(db),
getRun: vi.fn(state.getRun),
issuesSvc: {
addComment: vi.fn<HeartbeatLifecycleDependencies["issuesSvc"]["addComment"]>(),
listReviewAttention: vi.fn<HeartbeatLifecycleDependencies["issuesSvc"]["listReviewAttention"]>(),
},
recovery: { escalateStrandedAssignedIssue: vi.fn<HeartbeatLifecycleDependencies["recovery"]["escalateStrandedAssignedIssue"]>() },
companySkills: { completeTestRunForIssue: vi.fn<HeartbeatLifecycleDependencies["companySkills"]["completeTestRunForIssue"]>() },
budgets: { getInvocationBlock: vi.fn(async () => null) },
treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
enqueueWakeup: vi.fn<HeartbeatLifecycleDependencies["enqueueWakeup"]>(),
getCurrentUserRedactionOptions: vi.fn(async (): Promise<CurrentUserRedactionOptions> => ({})),
getAgentInvokability: vi.fn<HeartbeatLifecycleDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
emitTerminalAgentTaskRun: vi.fn<HeartbeatLifecycleDependencies["emitTerminalAgentTaskRun"]>(),
budgetHooks: {},
} satisfies HeartbeatLifecycleDependencies;
}
function guardedDatabase() {
const access = vi.fn(() => { throw new Error("Unexpected lifecycle database access"); });
return { db: new Proxy({}, { get: access }) as Db, access };
}
describe("heartbeat lifecycle module boundary", () => {
it("constructs without database work or reporting effects", () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
createHeartbeatLifecycle(database.db, deps);
expect(database.access).not.toHaveBeenCalled();
for (const callback of Object.values(deps)) if (vi.isMockFunction(callback)) expect(callback).not.toHaveBeenCalled();
});
it("ignores absent wake requests without accessing the database", async () => {
const database = guardedDatabase();
const lifecycle = createHeartbeatLifecycle(database.db, callbacks(database.db));
await lifecycle.setWakeupStatus(null, "completed");
await lifecycle.setWakeupStatus(undefined, "completed");
expect(database.access).not.toHaveBeenCalled();
});
it.each([
{ livenessState: "empty_response", contextSnapshot: { goalControlRequestId: "goal" } },
{ livenessState: "plan_only", contextSnapshot: { resumeSessionGoalHeartbeat: true } },
{ livenessState: "productive", contextSnapshot: { issueId: "issue" } },
{ livenessState: "empty_response", contextSnapshot: {} },
])("keeps ineligible continuations out of wake admission (%j)", async (input) => {
const database = guardedDatabase();
const deps = callbacks(database.db);
await createHeartbeatLifecycle(database.db, deps).handleRunLivenessContinuation(input as unknown as Run);
expect(database.access).not.toHaveBeenCalled();
expect(deps.enqueueWakeup).not.toHaveBeenCalled();
expect(deps.budgets.getInvocationBlock).not.toHaveBeenCalled();
});
it("preserves retained native ownership before lease-release teardown reads or writes", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
const run = { runtimeMode: "native", status: "running", nativePhase: "terminal_failure", errorCode: "native_execution_ownership_unverified" } as Run;
expect(await createHeartbeatLifecycle(database.db, deps).terminalizeRunOnLeaseRelease(run)).toBe(run);
expect(database.access).not.toHaveBeenCalled();
expect(deps.emitTerminalAgentTaskRun).not.toHaveBeenCalled();
});
it("rejects late progress after re-reading a terminal run through the service callback", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
const run = { id: "late-progress", companyId: "company", agentId: "agent", status: "running", contextSnapshot: {} } as Run;
deps.getRun.mockResolvedValue({ ...run, status: "succeeded" });
expect(await createHeartbeatLifecycle(database.db, deps).recordCurrentHeartbeatRunRuntimeProgress(run, { phase: "finalize", message: "late update" }, null)).toBeNull();
expect(deps.getRun).toHaveBeenCalledWith(run.id);
expect(getHeartbeatRunRuntimeStatus(run.id)).toBeNull();
expect(database.access).not.toHaveBeenCalled();
});
});
const support = await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping lifecycle module database tests: ${support.reason}`);
describe.skipIf(!support.supported)("heartbeat lifecycle database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-lifecycle-module");
db = createDb(database.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
beforeEach(async () => {
vi.mocked(publishLiveEvent).mockClear();
await db.execute(sql`truncate table companies restart identity cascade`);
});
async function fixture(patch: Partial<typeof heartbeatRuns.$inferInsert> = {}) {
const [company] = await db.insert(companies).values({ name: "Lifecycle module", issuePrefix: "LM" }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Lifecycle agent" }).returning();
const [wake] = await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId: agent.id, source: "assignment", status: "claimed" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, wakeupRequestId: wake.id, status: "running", runtimeMode: "native", ...patch }).returning();
return { company, agent, wake, run };
}
it("keeps one terminal outcome and reports only the winner of competing writes", async () => {
const { run } = await fixture();
const deps = callbacks(db);
const lifecycle = createHeartbeatLifecycle(db, deps);
const results = await Promise.all([
lifecycle.setRunStatusIfRunning(run.id, "succeeded", { finishedAt: new Date() }),
lifecycle.setRunStatusIfRunning(run.id, "failed", { errorCode: "late_failure", finishedAt: new Date() }),
]);
const winner = results.find(result => result.updated)!;
expect(results.filter(result => result.updated)).toHaveLength(1);
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(persisted.status).toBe(winner.run?.status);
expect(results.every(result => result.run?.status === persisted.status)).toBe(true);
expect(deps.emitTerminalAgentTaskRun).toHaveBeenCalledTimes(1);
expect(deps.emitTerminalAgentTaskRun).toHaveBeenCalledWith(winner.run, "running", undefined);
expect(vi.mocked(publishLiveEvent).mock.calls.filter(([event]) => event.type === "heartbeat.run.status")).toHaveLength(1);
});
it("rechecks native ownership atomically before a terminal write", async () => {
const { run } = await fixture({ errorCode: "native_execution_ownership_unverified", nativePhase: "terminal_failure" });
const deps = callbacks(db);
const result = await createHeartbeatLifecycle(db, deps).setRunStatusIfRunning(run.id, "failed");
expect(result).toMatchObject({ updated: false, run: { status: "running", errorCode: run.errorCode } });
expect(deps.emitTerminalAgentTaskRun).not.toHaveBeenCalled();
expect(publishLiveEvent).not.toHaveBeenCalled();
});
it.each(["setRunStatus", "setRunStatusIfRunning"] as const)("preserves accounting metadata while %s enriches usage", async (operation) => {
const { run } = await fixture({ usageJson: { accountingReceiptReady: false, usageSource: "per_run", inputTokens: 5 } });
await createHeartbeatLifecycle(db, callbacks(db))[operation](run.id, "succeeded", { usageJson: { inputTokens: 8, outputTokens: 3 } });
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(persisted.usageJson).toEqual({ accountingReceiptReady: false, usageSource: "per_run", inputTokens: 8, outputTokens: 3 });
});
it.each(["claimed", "cancelled"] as const)("settles a %s wake without reviving a revoked request", async (status) => {
const { wake } = await fixture();
await db.update(agentWakeupRequests).set({ status }).where(eq(agentWakeupRequests.id, wake.id));
await createHeartbeatLifecycle(db, callbacks(db)).setWakeupStatus(wake.id, "completed", { reason: "settled" });
const [persisted] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
expect(persisted.status).toBe(status === "cancelled" ? "cancelled" : "completed");
expect(persisted.reason).toBe(status === "cancelled" ? wake.reason : "settled");
});
it("publishes the persisted sequence for concurrent event writes", async () => {
const { run } = await fixture();
const lifecycle = createHeartbeatLifecycle(db, callbacks(db));
await Promise.all(["first", "second"].map(message => lifecycle.appendRunEvent(run, { eventType: "lifecycle", message })));
const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, run.id)).orderBy(heartbeatRunEvents.seq);
expect(rows.map(row => row.seq)).toEqual([1, 2]);
const published = vi.mocked(publishLiveEvent).mock.calls.map(([event]) => event).filter(event => event.type === "heartbeat.run.event");
expect(published).toHaveLength(2);
for (const row of rows) expect(published.find(event => event.payload?.seq === row.seq)?.payload).toMatchObject({ runId: run.id, message: row.message });
});
it("reads current redaction settings for each event and persists the published sanitized payload", async () => {
const { run } = await fixture();
const deps = callbacks(db);
deps.getCurrentUserRedactionOptions.mockResolvedValueOnce({ enabled: true, userNames: ["alice"], homeDirs: ["/Users/alice"] });
deps.getCurrentUserRedactionOptions.mockResolvedValueOnce({ enabled: true, userNames: ["bob"], homeDirs: ["/Users/bob"] });
const lifecycle = createHeartbeatLifecycle(db, deps);
for (const name of ["alice", "bob"]) await lifecycle.appendRunEvent(run, {
eventType: "lifecycle", message: `Opening /Users/${name}/project`, payload: { path: `/Users/${name}/project`, token: "test-credential-material" },
});
const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, run.id)).orderBy(heartbeatRunEvents.seq);
expect(deps.getCurrentUserRedactionOptions).toHaveBeenCalledTimes(2);
expect(rows).toHaveLength(2);
const published = vi.mocked(publishLiveEvent).mock.calls.map(([event]) => event).filter(event => event.type === "heartbeat.run.event");
expect(published).toHaveLength(2);
for (const row of rows) {
const text = JSON.stringify({ message: row.message, payload: row.payload });
expect(text).not.toContain("alice");
expect(text).not.toContain("bob");
expect(text).not.toContain("test-credential-material");
expect(published.find(event => event.payload?.seq === row.seq)?.payload).toMatchObject({ message: row.message, payload: row.payload });
}
});
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import { PERMISSION_KEYS } from "@paperclipai/shared";
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "./agent-permissions.js";
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../lib/agent-permissions.js";
import { grantsForHumanRole } from "./company-member-roles.js";
export function grantsFromDefaults(
@@ -1,4 +1,5 @@
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { activeIssueInteractionCondition, historicalQuestionCondition } from "./issue-question-context.js";
import {
currentContinuationOrigins,
@@ -2829,6 +2830,7 @@ export function issueThreadInteractionService(
.returning();
if (!row) throw interactionAlreadyResolvedError();
if (status === "accepted" || status === "rejected") {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
await tx.insert(connectionIntentDeliveries).values({ interactionId, companyId: issue.companyId }).onConflictDoNothing();
}
return row;
@@ -5026,6 +5028,7 @@ export function issueThreadInteractionService(
// This answer updates conversation history only. It must not resume
// the completed source run or enqueue new work for the closed task.
if (!historicalAnswer) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.question);
await tx
.insert(issueQuestionResponseDeliveries)
.values(questionResponseDeliveryValues(answered));
+77 -114
View File
@@ -5,11 +5,13 @@ import type { ApplyOnboardingSeed } from "@paperclipai/shared";
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { findActiveServerAdapter } from "../adapters/registry.js";
import { agentService } from "./agents.js";
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
import { withDedicatedDbConnection } from "@paperclipai/db";
import { PAPERCLIP_CORE_SKILL_KEYS } from "./company-skills.js";
import { goalService } from "./goals.js";
import { projectService } from "./projects.js";
import { issueService } from "./issues.js";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
/**
@@ -179,19 +181,45 @@ export function onboardingSeedService(db: Db) {
return created.id;
}
/**
* The seed application proper, run inside the per-company transaction the
* public `apply` opens. Every read and write goes through `dbx` — the locked
* transaction — so it is serialized against a concurrent push for the same
* company. Services are reconstructed on `dbx` for the same reason.
*/
async function prepareAgent(dbx: Db, companyId: string, seed: ApplyOnboardingSeed, audit?: OnboardingSeedAuditActor) {
const existing = await readRecord(dbx, companyId);
if (existing?.revision === seed.revision) return existing.agentId;
const agentSvc = agentService(dbx);
const agentName = seed.agent?.name.trim() || null;
const agentRole = seed.agent?.role?.trim() || null;
// 2. Agent → the customer's first hire, the lead the first task is
// assigned to.
let agentId = await resolveTargetAgentId(dbx, companyId, existing?.agentId ?? null);
if (agentName) {
if (agentId) {
await agentSvc.update(agentId, { name: agentName, title: agentRole });
} else {
const adapterType = seededAgentAdapterType();
const created = await createAgentLifecycle(dbx).requestHire(companyId, {
name: agentName,
role: SEEDED_AGENT_ROLE,
title: agentRole,
adapterType,
adapterConfig: seededAgentAdapterConfig(adapterType),
runtimeConfig: {},
permissions: {},
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
}, { createdByUserId: audit?.actorType === "user" ? audit.actorId : null });
agentId = created.id;
}
}
return agentId;
}
async function applyWithin(
dbx: Db,
companyId: string,
seed: ApplyOnboardingSeed,
audit?: OnboardingSeedAuditActor,
preparedAgentId: string | null,
): Promise<OnboardingSeedApplication> {
const agentSvc = agentService(dbx);
const goalSvc = goalService(dbx);
const projectSvc = projectService(dbx);
const issueSvc = issueService(dbx);
@@ -237,48 +265,9 @@ export function onboardingSeedService(db: Db) {
}
}
// 2. Agent → the customer's first hire, the lead the first task is
// assigned to.
let agentId = await resolveTargetAgentId(dbx, companyId, existing?.agentId ?? null);
if (agentName) {
if (agentId) {
await agentSvc.update(agentId, { name: agentName, title: agentRole });
} else {
const adapterType = seededAgentAdapterType();
const created = await agentSvc.create(companyId, {
name: agentName,
role: SEEDED_AGENT_ROLE,
title: agentRole,
adapterType,
adapterConfig: seededAgentAdapterConfig(adapterType),
runtimeConfig: {},
permissions: {},
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
}, { createdByUserId: audit?.actorType === "user" ? audit.actorId : null });
agentId = created.id;
}
}
const agentId = preparedAgentId;
// 3. First task → an issue in the Onboarding project, assigned to the
// lead so the dashboard opens with work on it.
//
// No-first-task contract (PAP-67 r17.4): on the Cloud walk this branch
// never runs. The seed Cloud sends is mission-only — `agent` and
// `firstTask` are unpopulated by the signup wizard and a paperclip-cloud
// `node:test` in `src/onboarding/` pins that — so `firstTaskTitle` is
// null here and the first task stays owned by the tenant's own
// server-owned onboarding path (`POST /issues` with
// `onboardingFirstTask: true`). That path is the only one that stamps
// `ONBOARDING_FIRST_TASK_ORIGIN_KIND` and races safely on the partial
// unique index `issues_onboarding_first_task_uq`. If this receiver ever
// created the first task on the cloud walk it would produce a *second*,
// unstamped one: no agent-authored greeting, the brief rendered as a
// right-aligned user bubble, and two onboarding tasks — silently,
// because the uq index only guards origin-stamped rows. The branch is
// retained for the endpoint's documented body contract, but the
// mission-only seed is what keeps it inert on the cloud path.
// A mission-only seed leaves the first task to the onboarding route.
let issueId = existing?.issueId ?? null;
if (firstTaskTitle) {
if (await issueStillExists(dbx, companyId, issueId)) {
@@ -357,38 +346,8 @@ export function onboardingSeedService(db: Db) {
return { revision: seed.revision, changed: true, goalId, agentId, issueId };
}
/**
* Apply an onboarding seed to a company.
*
* Idempotent per `revision`: a replay of the revision already stored is a
* no-op that still reports success, because Cloud reads any 2xx as "the
* tenant holds this content" and retries otherwise. A *different* revision
* (the customer edited their answers in Cloud) updates the goal, agent and
* task this seed previously created rather than creating a second set.
*
* Every write happens before the caller responds — Cloud records the applied
* revision only on a 2xx, and the redirect into the tenant dashboard is
* gated on it, so a partially-applied seed must surface as a failure rather
* than as an acknowledged one.
*
* Concurrency: Cloud's reconcile runs off portfolio fetches, which can
* overlap, so two pushes for the same company can arrive at once. Both would
* otherwise pass the revision check before either wrote the seed record and
* each create a company goal, a lead agent and an Onboarding project. A
* per-company advisory lock held for the transaction serializes them — the
* same idiom `folders` and `decision-queues` use — so the second push sees
* the first push's writes (the record, the reused goal/agent/project) and
* updates in place instead of duplicating.
*
* Auditing: when `audit` is supplied and the push changed anything, the
* `company.onboarding_seed_applied` entry is written *inside* this same
* transaction. That is the only arrangement in which the entry cannot go
* permanently missing. Logging after the commit forces a choice between two
* broken outcomes — answer 500 and the retry returns `changed: false` and
* never logs, or answer 200 and Cloud stops retrying while the entry stays
* absent. Writing it transactionally removes the choice: either both land, or
* neither does and the retry re-applies from a clean slate.
*/
// Hire first. If the content transaction fails, a retry reuses the committed agent.
// The dedicated transaction holds only the workflow lock, not the hire writes.
async function apply(
companyId: string,
seed: ApplyOnboardingSeed,
@@ -400,42 +359,46 @@ export function onboardingSeedService(db: Db) {
// late.
const publications: ActivityPublication[] = [];
const result = await db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:onboarding-seed:${companyId}`}, 0))`,
);
const dbx = tx as unknown as Db;
const applied = await applyWithin(dbx, companyId, seed, audit);
const database = db;
const lockKey = `paperclip:onboarding-seed:${companyId}`;
return withDedicatedDbConnection(db, lockDb => lockDb.transaction(async lock => {
await lock.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const preparedAgentId = await prepareAgent(database, companyId, seed, audit);
const result = await database.transaction(async (tx) => {
const dbx = tx as unknown as Db;
const applied = await applyWithin(dbx, companyId, seed, preparedAgentId);
if (applied.changed && audit) {
await logActivity(
dbx,
{
companyId,
actorType: audit.actorType,
actorId: audit.actorId,
agentId: audit.agentId,
runId: audit.runId,
agentApiKeyId: audit.agentApiKeyId,
action: "company.onboarding_seed_applied",
entityType: "company",
entityId: companyId,
details: {
revision: applied.revision,
goalId: applied.goalId,
agentId: applied.agentId,
issueId: applied.issueId,
if (applied.changed && audit) {
await logActivity(
dbx,
{
companyId,
actorType: audit.actorType,
actorId: audit.actorId,
agentId: audit.agentId,
runId: audit.runId,
agentApiKeyId: audit.agentApiKeyId,
action: "company.onboarding_seed_applied",
entityType: "company",
entityId: companyId,
details: {
revision: applied.revision,
goalId: applied.goalId,
agentId: applied.agentId,
issueId: applied.issueId,
},
},
},
publications,
);
}
publications,
);
}
return applied;
});
return applied;
});
for (const publication of publications) publishActivity(publication);
return result;
for (const publication of publications) publishActivity(publication);
if (result.agentId) scheduleAgentLifecycle(db, result.agentId);
return result;
}));
}
return { apply, get: (companyId: string) => readRecord(db, companyId) };
+4 -2
View File
@@ -1,3 +1,4 @@
import { createAgentLifecycle } from "./agent-lifecycle.js";
import type { Db } from "@paperclipai/db";
import {
activityLog,
@@ -719,6 +720,7 @@ export function buildHostServices(
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
const companies = companyService(db);
const agents = agentService(db);
const agentsLifecycle = createAgentLifecycle(db);
const managedAgents = pluginManagedAgentService(db, {
pluginId,
pluginKey,
@@ -3013,14 +3015,14 @@ export function buildHostServices(
await ensurePluginAvailableForCompany(companyId);
const agent = await agents.getById(params.agentId);
requireInCompany("Agent", agent, companyId);
return (await agents.pause(params.agentId)) as Agent;
return (await agentsLifecycle.pauseAgent(params.agentId)) as Agent;
},
async resume(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const agent = await agents.getById(params.agentId);
requireInCompany("Agent", agent, companyId);
return (await agents.resume(params.agentId)) as Agent;
return (await agentsLifecycle.resumeAgent(params.agentId)) as Agent;
},
async invoke(params) {
const companyId = ensureCompanyId(params.companyId);
+5 -16
View File
@@ -1,3 +1,4 @@
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { and, eq, isNull, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -183,6 +184,7 @@ export function pluginManagedAgentService(
options: PluginManagedAgentServiceOptions,
) {
const agentSvc = agentService(db);
const agentSvcLifecycle = createAgentLifecycle(db);
const approvalSvc = approvalService(db);
const instructions = agentInstructionsService(db);
@@ -486,7 +488,7 @@ export function pluginManagedAgentService(
const requiresApproval = company.requireBoardApprovalForNewAgents;
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const initialStatus = requiresApproval ? "pending_approval" : declaration.status ?? "idle";
let created = await agentSvc.create(companyId, {
let created = await agentSvcLifecycle.requestHire(companyId, {
...declarationPatch(declaration, { adapterType }),
status: initialStatus,
pauseReason: initialStatus === "paused" ? managedAgentPauseReason(options.pluginKey) : null,
@@ -569,26 +571,13 @@ export function pluginManagedAgentService(
) {
if (
declaration.status !== "paused"
|| agent.status !== "paused"
|| agent.lifecycleState !== "paused"
|| agent.pauseReason !== null
) {
return agent;
}
const updated = await db
.update(agents)
.set({
pauseReason: managedAgentPauseReason(options.pluginKey),
updatedAt: new Date(),
})
.where(and(
eq(agents.id, agent.id),
eq(agents.companyId, companyId),
eq(agents.status, "paused"),
isNull(agents.pauseReason),
))
.returning({ id: agents.id })
.then((rows) => rows[0] ?? null);
const updated = await agentSvcLifecycle.pauseAgent(agent.id, managedAgentPauseReason(options.pluginKey));
if (!updated) {
const current = await agentSvc.getById(agent.id) as Agent | null;
+4 -3
View File
@@ -1,5 +1,7 @@
import { updateAgentConfigurationInTransaction } from "../agent-configuration-transaction.js";
import { instanceSettingsService } from "../instance-settings.js";
import { createHash, randomBytes } from "node:crypto";
import { canConfigureAgentConnection } from "../../modules/agent-lifecycle/index.js";
import { and, eq, gt, inArray, isNull, lt, lte, notExists, sql } from "drizzle-orm";
import { z } from "zod";
import type { Request } from "express";
@@ -7,7 +9,6 @@ import {
type Db, activityLog, agents, authUsers, companies, companyLogos, dotAgentBindings, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions,
} from "@paperclipai/db";
import { DOT_RUNNER_MCP_PATH, DOT_RUNNER_MCP_SCOPES, PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest, type McpDotPairingPreview } from "@paperclipai/shared";
import { agentService } from "../agents.js";
import { boardAuthService } from "../board-auth.js";
import { logActivity } from "../activity-log.js";
import { createClientMetadataResolver, mcpRedirectMatches, validMcpRedirect as validRedirect, type MetadataFetch } from "./client-metadata.js";
@@ -274,7 +275,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
if (!binding || binding.status !== "pairing" || binding.grantId || !binding.pairingExpiresAt || binding.pairingExpiresAt <= new Date()) throw invalidGrant();
if (request.requestedCompanyId && request.requestedCompanyId !== binding.companyId) throw invalidGrant();
const [agent] = await queryDb.select().from(agents).where(and(eq(agents.id, binding.agentId), eq(agents.companyId, binding.companyId))).for("update");
if (!agent || agent.adapterType !== "paperclip_runner" || ["paused", "terminated", "pending_approval"].includes(agent.status)) throw invalidGrant();
if (!agent || agent.adapterType !== "paperclip_runner" || !canConfigureAgentConnection(agent)) throw invalidGrant();
return { request, binding, agent };
}
@@ -459,7 +460,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
await tx.update(mcpOauthGrants).set({ agentId: binding.agentId }).where(eq(mcpOauthGrants.id, grant.id));
await tx.update(dotAgentBindings).set({ grantId: grant.id, status: "connected", pairingCodeHash: null,
pairingExpiresAt: null, updatedAt: new Date() }).where(eq(dotAgentBindings.id, binding.id));
await agentService(tx as unknown as Db).update(agent.id, { adapterConfig: { ...agent.adapterConfig, dotBindingId: binding.id } },
await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, { adapterConfig: { ...agent.adapterConfig, dotBindingId: binding.id } },
{ recordRevision: { createdByUserId: binding.operatorId, source: "dot-pairing" } });
await logActivity(tx as unknown as Db, { companyId: binding.companyId, actorType: "user", actorId: binding.operatorId,
action: "dot.paired", entityType: "agent", entityId: binding.agentId,
@@ -1340,5 +1340,9 @@ export function questionResponseDeliveryService(
return counts;
}
return { deliver, sweepPending };
async function hasPending() {
return (await db.select({ id: issueQuestionResponseDeliveries.id }).from(issueQuestionResponseDeliveries)
.where(inArray(issueQuestionResponseDeliveries.status, ["pending", "delivering"])).limit(1)).length > 0;
}
return { deliver, sweepPending, hasPending };
}
+5
View File
@@ -1,3 +1,4 @@
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
import { isAiAuthenticationBlocked } from "../ai-auth-failure.js";
import { hasCommittedNativePlanWait } from "../native-runtime/native-plan-wait.js";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
@@ -4494,6 +4495,10 @@ export function recoveryService(
}
const agent = await getAgent(agentId);
if (agent?.companyId === issue.companyId && isAgentAwaitingSetup(agent)) {
result.skipped += 1;
continue;
}
const agentInvokable =
agent && agent.companyId === issue.companyId
? await isAgentInvokable(agent)
+19 -6
View File
@@ -1,3 +1,4 @@
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { withAgentAppearance } from "@paperclipai/shared";
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -699,7 +700,7 @@ export function createSecretProposalsService(db: Db) {
}
adapterConfig[proposal.configPath] = binding;
}
const updated = await agentSvc.update(target.id, { adapterConfig }, {
const updated = await updateAgentConfigurationInTransaction(txDb, target.id, { adapterConfig }, {
recordRevision: { createdByUserId: resolvedByUserId, source: "patch" },
});
if (!updated) throw notFound("Target agent not found");
@@ -720,18 +721,22 @@ export function createSecretProposalsService(db: Db) {
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
assertCanResolve?: (proposal: Proposal, txDb: Db) => Promise<void>;
}) {
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const loadForApproval = async (txDb: Db) => {
const proposal = await requirePending(companyId, proposalId, txDb, true);
assertNotExpired(proposal);
await input.assertCanResolve?.(proposal, txDb);
await assertBindingSnapshotCurrent(proposal, txDb, true);
return proposal;
};
const saved = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const proposal = await loadForApproval(txDb);
if (proposal.kind === "secret") {
const created = await applySecretApproval(txDb, proposal, input);
return markApproved(txDb, proposal, {
return { kind: "secret" as const, proposal: await markApproved(txDb, proposal, {
resolvedByUserId: input.resolvedByUserId,
createdSecretId: created.id,
});
}) };
}
let secretId = proposal.secretId;
@@ -757,7 +762,15 @@ export function createSecretProposalsService(db: Db) {
}
}
if (!secretId) throw conflict("Binding proposal has no approved secret");
const liveSecret = await secretService(txDb).getById(secretId);
return { kind: "binding" as const, secretId };
});
if (saved.kind === "secret") return saved.proposal;
// Keep the approved secret if applying its reference fails.
return db.transaction(async tx => {
const txDb = tx as unknown as Db;
const proposal = await loadForApproval(txDb);
const liveSecret = await secretService(txDb).getById(saved.secretId);
if (!liveSecret || liveSecret.companyId !== companyId || liveSecret.status !== "active") {
throw conflict("Binding proposal secret is not active");
}
+1 -1
View File
@@ -21,7 +21,7 @@ import type {
import { companySearchQuerySchema, STATUS_CARD_AGENT_MAX_CARDS } from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { builtInAgentService } from "./built-in-agents.js";
import { companySearchService } from "./company-search.js";
import { issueService } from "./issues.js";
+1 -1
View File
@@ -24,7 +24,7 @@ import {
type WriteSummarySlotResponse,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { builtInAgentService } from "./built-in-agents.js";
import { agentService } from "./agents.js";
import { issueService } from "./issues.js";
@@ -345,6 +345,12 @@ export function toolActionDeliveryService(
}
return {
deliver,
async hasPending() {
// Include receipts awaiting review/execution: their later terminal write
// must remain recoverable even if its optional fast-path callback fails.
return (await db.select({ id: toolActionDeliveries.actionRequestId }).from(toolActionDeliveries)
.where(isNull(toolActionDeliveries.deliveredAt)).limit(1)).length > 0;
},
async deliverForRun(input: { companyId: string; runId: string }) {
// A review can settle before its source run yields. Retry that run's
// receipts as soon as execution cleanup finishes; the periodic sweep
@@ -1,3 +1,4 @@
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { and, eq } from "drizzle-orm";
import {
issues,
@@ -206,6 +207,7 @@ export async function commitToolActionReview(
: { version: 1, outcome: "rejected", reason: input.reason },
})
.where(eq(issueThreadInteractions.id, interaction.id));
await notifyDeliveryWork(tx, DELIVERY_QUEUES.toolAction);
await tx
.insert(toolActionDeliveries)
.values({
+13 -9
View File
@@ -1,3 +1,4 @@
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { composeConnectionInstructions } from "./connection-instructions.js";
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
import { boundedMcpToolName } from "./mcp-tool-names.js";
@@ -2615,15 +2616,18 @@ export function createToolGatewayService(
);
}
await db
.insert(toolActionDeliveries)
.values({
companyId: input.session.companyId,
actionRequestId: actionRequest.id,
issueId: input.session.issueId,
interactionId: interaction.id,
})
.onConflictDoNothing();
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.toolAction);
await tx
.insert(toolActionDeliveries)
.values({
companyId: input.session.companyId,
actionRequestId: actionRequest.id,
issueId: input.session.issueId!,
interactionId: interaction.id,
})
.onConflictDoNothing();
});
await writeToolCallEvent({
invocationId: input.invocation.id,
+45
View File
@@ -0,0 +1,45 @@
/** In-process deadlines. Owners restore durable tasks at startup. */
export function createWorkScheduler() {
const tasks = new Map<string, { at: number; run: () => void }>();
let timer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
const nextWakeAt = () => tasks.size ? Math.min(...Array.from(tasks.values(), task => task.at)) : null;
function arm() {
if (timer) clearTimeout(timer);
timer = null;
const next = nextWakeAt();
if (stopped || next === null) return;
timer = setTimeout(() => {
timer = null;
const now = Date.now();
const due = Array.from(tasks.entries()).filter(([, task]) => task.at <= now);
for (const [key, task] of due) {
// Another due callback can cancel or replace this task.
if (tasks.get(key) !== task) continue;
tasks.delete(key);
try { task.run(); }
catch (error) { process.emitWarning(`Scheduled work failed: ${String(error)}`); }
}
arm();
}, Math.min(2_147_483_647, Math.max(0, next - Date.now())));
timer.unref?.();
}
return {
nextWakeAt,
schedule(key: string, at: number, run: () => void) {
if (stopped) return;
if (!Number.isFinite(at)) throw new Error("Work deadline must be finite");
tasks.set(key, { at, run });
arm();
},
cancel(key: string) { tasks.delete(key); arm(); },
stop() {
stopped = true;
tasks.clear();
if (timer) clearTimeout(timer);
timer = null;
},
};
}