mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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:
commit
2a704c2cf8
198 files changed
+14211
-6906
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+28
-1222
File diff suppressed because it is too large.
Load diff
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"',
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"])));
|
||||
|
||||
@@ -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')`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
+232
-3996
File diff suppressed because it is too large.
Load diff
@@ -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,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));
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user