Own agent hiring and termination in a lifecycle module (#15631)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - Agent creation, approval, pause, and termination change whether an
agent can work.
> - These changes currently use separate service and database paths.
> - Some agents need setup after their record exists and before their
harness can run.
> - This PR adds one module that owns those state changes and their
transactions.
> - The module records progress and retries incomplete work after a
server restart.

## Linked Issues or Issue Description

**Problem or motivation**

A hire can become active before its execution environment is ready.
Direct agent state writes also bypass one common lifecycle boundary.
Termination does not have a durable step for resource cleanup.

**Proposed solution**

Use agent lifecycle commands for hiring, approval, pause, resume, and
termination. Keep proposed hires pending until approval. Prepare each
approved hire, test its saved harness configuration, and then permit
work. Wait for required plugin work before completing a step.

**Alternatives considered**

A general database commit hook would expose transaction details to
callers. A timer alone would delay each new hire. This module owns each
command transaction and starts its worker after commit. A periodic scan
recovers work after a process stops.

**Roadmap alignment**

This change adds the lifecycle boundary for the existing agent and
plugin systems. It does not add a user interface or a provider
implementation. It replaces the lifecycle delivery approach in #15391.
Related proposals include #11475 and #13099; this change does not add
their external executor protocols.

## What Changed

- Add durable approval, preparation, verification, pause, resume, stop,
and cleanup states.
- Keep agent creation, lifecycle state, configuration writes, and agent
record deletion behind `modules/agent-lifecycle`. Keep validation,
credential rules, reads, onboarding, invitations, general approvals, and
company data deletion in their services.
- Commit a hire before the remaining onboarding or invitation work.
Serialize requests with the existing dedicated database connection
helper. Reuse the hire after a failed attempt.
- Keep accepted work in the run queue during setup. Require execution
and cleanup evidence before completing pause or termination.
- Retain the original credential owner when approving migrated hires.
- Start lifecycle work after each command commits. Use database leases
and revision checks for retries and late results.
- Add a generic required plugin method and capability. Store the
required plugin IDs for each agent. Track host and plugin completion
separately.
- Keep harness verification and credential rules in services. Supply
their results to lifecycle through explicit ports. Reject failed logins
and incomplete probes.
- Remove reverse imports from lifecycle adapters into services. Share
record queries and validation below both layers.
- Use one hire decision path for agent IDs and approval IDs. Resolve the
approval and agent change in one transaction. Expose policy
reconciliation on the command object.
- Save configuration and verification invalidation through one module
operation. Ordinary commands own their transaction. Restrict the shared
transaction integration to its listed callers. Reject identity, company,
accounting, timestamp, and unknown fields.
- Require completed termination before deleting agent records. The
company deletion service calls the module through a separate deletion
interface.
- Save an approved secret before applying its agent binding. A failed
binding leaves the secret approved. A retry reuses it.
- Remove old permission and metadata export files. Move hire
configuration rules out of lifecycle persistence.
- Report failed worker steps and scans with operation context and known
error codes. Omit provider messages, stacks, and configuration values.
Document ordinary application use and extension rules.
- Recover expired native owners only after execution stops. Retry
cleanup of failed retained leases. Wait for setup in the live smoke
script.
- Keep manual, budget, and company pause reasons separate. Guard
execution status writes with the ready state.
- Add a migration, boundary checks, regression tests, and a lifecycle
guide. Keep the current UI.
- Limit periodic policy checks to 100 eligible agents per minute.
Continue from the last agent ID. Exclude pending hires and agents in
termination or a final state. Keep explicit company and budget updates
immediate.

## Verification

- Latest head `ccc3c86b0` aligns the shared status update schema with
the PATCH route. All 15 shared tests, 104 server route/OpenAPI tests, 2
CLI tests, and shared/server/CLI TypeScript checks pass. The Greptile
comment is fixed. CI is running; no new Apex review was requested for
this small fix.

- Rebase `75708ac94` rebases onto master at `2eb5187e4`. The backup test
uses the shared 90-second timeout from master; the older timeout commit
was removed. The affected backup test passes.
- Agent budget edits now pass the target agent ID through enforcement.
All 50 affected tests, server TypeScript, and 11 boundary tests pass.
Both configuration update paths have regression coverage. CI is running
on the latest head. No new Apex review was requested for these small
fixes.

- Rebase `9b568666a` preserves the run-control extraction and
delivery-work startup changes from master. All 559 affected tests,
server TypeScript, and 11 boundary tests pass. CI and Apex review are
running on the final head.
- The policy scan fix passes 56 lifecycle and diagnostic tests, 24
policy and caller tests, and 11 boundary tests. A regression test checks
the page limit, interval, excluded states, and cursor wraparound.

- Rebase `fd4698750` passes all 611 tests in eight affected lifecycle,
route, and heartbeat suites. Server TypeScript and all 11 module
boundary tests pass. The company deletion rollback and concurrent hire
tests are included.

- Follow-up `00aac7646` fixes the failed decision deletion test and the
public update schema. All 126 affected server tests, 8 shared tests, 2
CLI tests, and 11 boundary tests pass. Shared, server, CLI, and UI
TypeScript checks pass.

- Server TypeScript passes: `pnpm --filter @paperclipai/server exec tsc
--noEmit`.
- All 11 boundary tests pass: `node --test
scripts/check-module-boundaries.test.mjs`. The repository boundary scan
also passes.
- All 68 lifecycle, failure diagnostic, and harness verification tests
pass. Tests reject forbidden fields through the root command, combined
transition command, and restricted transaction integration. They also
check invalid fields returned by configuration preparation.
- Failure tests cover host work, plugin work, claims, renewals,
deferrals, and scans. Log tests check known error codes and exclusion of
private error contents.
- All 355 tests in 10 existing caller suites pass. These tests cover
secret approval, pairing, pending hires, credentials, company import,
built-in agents, managed agents, instructions, and skills.
- The branch is rebased onto `origin/master` at `2eb5187e4`. The queue
guards and cancellation lock fix use the extracted heartbeat queue.
Agent cancellation uses the extracted run-control file. The source diff
scan found no secrets or private URLs.
- Earlier checks cover approval retries, rollback, late verification
results, policy holds, deletion rollback, concurrent company deletion,
and queue admission during setup.
- Before the rebase, CI passed on `00aac7646` and Greptile Apex rated it
5/5 with no open comments. CI and a new Apex review are pending for the
rebased commit. The branch has no merge conflicts.
- The full local build and native Dot suite were not run. The host has
no Cargo. No live provider test was run.

## Risks

- Background policy recovery takes more than one minute when more than
100 agents are eligible. Explicit company and budget updates still
reconcile immediately.

- The restricted configuration integration keeps an agent reference and
its dependent records in one transaction. It cannot change a budget or
invoke a lifecycle transition. A periodic scan starts pending setup
after that transaction commits.
- Secret approval and binding use separate transactions. A failed
binding can leave an approved secret with no agent binding.
- New approved hires remain unavailable until setup and verification
complete. Existing clients see `paused` during these steps. This PR does
not change the UI.
- Migration `0320_daily_onslaught.sql` maps existing statuses and pause
reasons. It does not create external resources or claim that an existing
harness passed verification.
- Use a coordinated server update for this migration. Older server
versions can write legacy status without the new lifecycle checks.
- A failed onboarding or invitation request can leave a committed hire.
A retry reuses that hire and completes the remaining records.
- Deletion requires completed termination. Company deletion can require
another request while cleanup is pending.
- Required plugins must make repeated calls safe and reject older
revisions. A disabled or removed required plugin blocks completion until
it is restored.
- A new hire can run a second harness test after the existing UI test.
Some adapters can charge for this test.
- No live provider test was run. Provider implementation and resource
backfill remain separate work.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code tools, and local test
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in this PR with the feature request
field labels
- [x] I have not referenced internal or instance-local issues or links
- [x] My branch name describes the change
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] Targeted local tests pass
- [x] All Paperclip CI gates are green on the new commit
- [x] Greptile Apex is 5/5 with no open comments on the new commit
- [x] I will address review comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-09 09:05:12 -07:00
1 parent ab8dcab809
commit 2dd9811e8b
152 files changed
+6790 -2758

No files matched your search

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