Add project and agent resource lifecycle hooks (#15280)

## Thinking Path

> - Paperclip manages agents and projects for work.
> - Plugins need reliable lifecycle hooks for these resources.
> - In-process notifications can disappear during a restart.
> - A lifecycle record must commit with the resource change.
> - This pull request adds a generic lifecycle journal without provider
calls.
> - A later plugin delivery layer can use these records without losing
lifecycle changes.

## Linked Issues or Issue Description

**Problem or motivation**

Resource provisioning needs durable hooks for agent hiring, pause,
resume, termination, and project creation. Hooks must cover shared
service callers, budget actions, and approval paths. Capture should work
for all plugins and deployment modes.

**Proposed solution**

Record content-free events in the resource transaction. Deduplicate
creation and unchanged status. Preserve each pause/resume cycle. Commit
approval, activation, and creation together. Commit termination and
API-key revocation together.

**Alternatives considered**

Event subscriptions alone cannot survive process failure. Cloud-only
capture would exclude other plugins. Provider calls inside tenant
transactions would couple resource creation to external services.

**Roadmap alignment**

This is lifecycle infrastructure for the existing plugin system. It adds
no provider, adapter, UI, or plugin read API. Repository mutations and
backfill remain separate work. Searches found no duplicate
lifecycle-journal PR.

## What Changed

- Add a company-scoped lifecycle journal and an additive migration.
- Record hired-agent and project creation from shared services.
- Record agent pause, resume, and termination, including generic updates
and budget actions.
- Lock agent state changes to suppress concurrent duplicate hooks.
- Make hire approval, rejection, and termination transactions atomic
with their lifecycle records.
- Document capture, ordering, future per-plugin acknowledgments, and
migration scope.

## Verification

- Full workspace typecheck: `pnpm -r typecheck` passed.
- Production build: `pnpm build` passed.
- Focused agent, project, approval, budget, and built-in regressions:
102 tests passed across 9 suites.
- Final lifecycle journal check: 14 tests passed. It covers
repeated/concurrent transitions, rollback, key revocation, self-hosted
capture, and company boundaries.
- Review regression: 32 lifecycle and approval tests passed, including
rejected-hire rollback and retry; server typecheck passed after the fix.
- The initial local `pnpm test:run` overlapped the rejection fix and
reported the new rollback regression against the earlier service code. A
fresh lifecycle run passed all 14 tests. Fresh full local shards were
stopped once the complete CI test matrix passed on the final commit.
- `git diff --check` and a local secret/PII scan passed.
- Greptile: 5/5 on `3ee3903f8e1a171ea3ba2bea9caa2b5766a3f807`, with the
review thread resolved.
- [Complete CI
passed](https://github.com/paperclipai/paperclip/actions/runs/37374227222)
on `3ee3903f8e1a171ea3ba2bea9caa2b5766a3f807`: general
server/chat/workspace tests, serialized server suites, runner checks,
build, typecheck, canary, and all end-to-end shards. The requester
waived CI during the Actions outage, but the workflow subsequently
completed successfully.

## Risks

- The migration creates an empty table. It does not scan or backfill
existing resources.
- Apply the normal database migration before running this server
version. Event-write failure intentionally rolls back the resource
change.
- Pending hires cannot bypass approval through pause or resume.
- Capture works on all deployments. Plugin delivery, retention, retries,
and provider actions remain separate work. No plugin can read this
journal through a new API in this PR.
- Future delivery must enforce company scope, track acknowledgments per
plugin, and preserve resource order. A global sequence cursor can skip
uncommitted transactions.
- A termination hook does not authorize deleting persistent volumes.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code execution, and tool use. The exact deployment model ID and context
window are not exposed in this session.

## 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-05 15:58:38 -07:00
1 parent ad0c4f0767
commit 984f092ddf
14 files changed
+965 -161

No files matched your search

+38 -38
View File
@@ -38,6 +38,7 @@ import {
syncAgentAdapterEnvBindings,
} from "./agent-secret-bindings.js";
import { logActivity } from "./activity-log.js";
import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
import {
@@ -788,6 +789,16 @@ export function agentService(db: Db) {
type AgentUpdateResult = Awaited<ReturnType<typeof getById>>;
const applyUpdate = async (txDb: Db): Promise<AgentUpdateResult> => {
const current = data.status !== undefined
? await txDb.select().from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0] ?? null)
: existing;
if (!current) return null;
if (current.status === "terminated" && data.status && data.status !== "terminated") {
throw conflict("Terminated agents cannot be resumed");
}
if (current.status === "pending_approval" && data.status && data.status !== "pending_approval" && data.status !== "terminated") {
throw conflict("Pending approval agents cannot be activated directly");
}
const updated = await txDb
.update(agents)
.set({ ...normalizedPatch, updatedAt: new Date() })
@@ -795,6 +806,9 @@ export function agentService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
if (data.status !== undefined) {
await recordAgentStatusEvent(txDb, updated.companyId, id, current.status, updated.status);
}
const priorAdapterConfig = isPlainRecord(existing.adapterConfig) ? existing.adapterConfig : {};
const afterConfig = isPlainRecord(updated.adapterConfig) ? updated.adapterConfig : {};
@@ -938,6 +952,9 @@ export function agentService(db: Db) {
}).onConflictDoNothing();
}
await syncAgentSecretBindings(created, txDb);
if (created.status !== "pending_approval" && created.status !== "terminated") {
await recordResourceCreationEvent(txDb, companyId, "agent", created.id);
}
const normalizedCreated = await agentService(txDb).getById(created.id);
if (!normalizedCreated) {
throw notFound("Agent not found");
@@ -953,19 +970,12 @@ export function agentService(db: Db) {
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
const updated = await db
.update(agents)
.set({
status: "paused",
pauseReason: reason,
pausedAt: new Date(),
errorReason: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? getById(updated.id) : null;
return updateAgent(id, {
status: "paused",
pauseReason: reason,
pausedAt: new Date(),
errorReason: null,
});
},
resume: async (id: string) => {
@@ -976,19 +986,12 @@ export function agentService(db: Db) {
throw conflict("Pending approval agents cannot be resumed");
}
const updated = await db
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
errorReason: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? getById(updated.id) : null;
return updateAgent(id, {
status: "idle",
pauseReason: null,
pausedAt: null,
errorReason: null,
});
},
clearError: async (id: string) => {
@@ -1025,23 +1028,19 @@ export function agentService(db: Db) {
const existing = await getById(id);
if (!existing) return null;
await db
.update(agents)
.set({
return db.transaction(async tx => {
const txDb = tx as unknown as Db;
const updated = await agentService(txDb).update(id, {
status: "terminated",
pauseReason: null,
pausedAt: null,
errorReason: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id));
await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.agentId, id));
return getById(id);
});
if (!updated) return null;
await tx.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.agentId, id));
return updated;
});
},
remove: async (id: string) => {
@@ -1150,6 +1149,7 @@ export function agentService(db: Db) {
});
}
await syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
await recordResourceCreationEvent(txDb, existing.companyId, "agent", updated.id);
const agent = await agentService(txDb).getById(updated.id);
if (!agent) {
throw notFound("Agent not found");
+101 -88
View File
@@ -10,8 +10,6 @@ import { notifyHireApproved } from "./hire-hook.js";
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
@@ -25,15 +23,15 @@ export function approvalService(db: Db) {
};
}
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>) {
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(db).ensure(companyId, sourceBuiltInAgentKey);
await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey);
}
async function getExistingApproval(id: string) {
const existing = await db
async function getExistingApproval(id: string, database: Db = db) {
const existing = await database
.select()
.from(approvals)
.where(eq(approvals.id, id))
@@ -47,8 +45,9 @@ export function approvalService(db: Db) {
targetStatus: "approved" | "rejected",
decidedByUserId: string,
decisionNote: string | null | undefined,
database: Db = db,
): Promise<ResolutionResult> {
const existing = await getExistingApproval(id);
const existing = await getExistingApproval(id, database);
if (!canResolveStatuses.has(existing.status)) {
if (existing.status === targetStatus) {
return { approval: existing, applied: false };
@@ -59,7 +58,7 @@ export function approvalService(db: Db) {
}
const now = new Date();
const updated = await db
const updated = await database
.update(approvals)
.set({
status: targetStatus,
@@ -76,7 +75,7 @@ export function approvalService(db: Db) {
return { approval: updated, applied: true };
}
const latest = await getExistingApproval(id);
const latest = await getExistingApproval(id, database);
if (latest.status === targetStatus) {
return { approval: latest, applied: false };
}
@@ -142,93 +141,107 @@ export function approvalService(db: Db) {
},
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote,
);
let hireApprovedAgentId: string | null = null;
const now = new Date();
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);
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,
});
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,
);
}
void notifyHireApproved(db, {
companyId: updated.companyId,
agentId: hireApprovedAgentId,
source: "approval",
sourceId: id,
approvedAt: now,
}).catch(() => {});
}
}
const result = await db.transaction(async tx => {
const txDb = tx as unknown as Db;
const agentsSvc = agentService(txDb);
const budgets = budgetService(txDb);
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote,
txDb,
);
return { approval: updated, applied };
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,
});
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) {
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) => {
const { approval: updated, applied } = await resolveApproval(
id,
"rejected",
decidedByUserId,
decisionNote,
);
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 agentsSvc.terminate(payloadAgentId);
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 };
return { approval: updated, applied };
});
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
+19 -18
View File
@@ -1,5 +1,6 @@
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { recordAgentStatusEvent } from "./resource-lifecycle-events.js";
import {
agents,
approvals,
@@ -214,15 +215,15 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
async function pauseScopeForBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "paused",
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"])));
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;
}
@@ -261,15 +262,15 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
async function resumeScopeFromBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
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;
}
+16 -13
View File
@@ -34,6 +34,7 @@ import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runt
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { recordResourceCreationEvent } from "./resource-lifecycle-events.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -592,19 +593,21 @@ export function projectService(db: Db) {
// together (goalIds wins resolution, mirroring the update path).
const legacyGoalId = ids?.[0] ?? null;
const row = await db
.insert(projects)
.values({ ...projectData, goalId: legacyGoalId, companyId })
.returning()
.then((rows) => rows[0]);
if (ids && ids.length > 0) {
await syncGoalLinks(db, row.id, companyId, ids);
}
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched!;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const row = await tx
.insert(projects)
.values({ ...projectData, goalId: legacyGoalId, companyId })
.returning()
.then((rows) => rows[0]);
if (ids && ids.length > 0) {
await syncGoalLinks(txDb, row.id, companyId, ids);
}
await recordResourceCreationEvent(txDb, companyId, "project", row.id);
const [withGoals] = await attachGoals(txDb, [row]);
const [enriched] = withGoals ? await attachWorkspaces(txDb, [withGoals]) : [];
return enriched!;
});
};
const getProjectById = async (id: string): Promise<ProjectWithGoals | null> => {
@@ -0,0 +1,25 @@
import { resourceLifecycleEvents, type Db } from "@paperclipai/db";
import { sql } from "drizzle-orm";
/** Call inside the transaction that creates the resource or approves the hire. */
export async function recordResourceCreationEvent(
db: Db,
companyId: string,
resourceType: "agent" | "project",
resourceId: string,
): Promise<void> {
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType, resourceId, action: "create" }).onConflictDoNothing({
target: [resourceLifecycleEvents.companyId, resourceLifecycleEvents.resourceType, resourceLifecycleEvents.resourceId],
where: sql`${resourceLifecycleEvents.action} = 'create'`,
});
}
/** Call with the agent row locked, in the same transaction as the status change. */
export async function recordAgentStatusEvent(db: Db, companyId: string, agentId: string, before: string, after: string): Promise<void> {
if (before === after) return;
const action = after === "terminated" ? "terminate"
: after === "paused" ? "pause"
: before === "paused" && ["active", "idle", "running", "error"].includes(after) ? "resume" : null;
if (!action) return;
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action });
}