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

+31
View File
@@ -452,6 +452,37 @@ Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVID
Migration `0274_agent_chat.sql` adds conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row.
## Resource lifecycle events
`resource_lifecycle_events` records content-free lifecycle hooks in the same
transaction as the resource change. Hired agents and new projects emit `create`.
Pending hires emit creation only when `activatePendingApproval` succeeds.
Rejected hires emit termination without creation. Agents created as terminated
emit no creation event. Capture is generic and works on self-hosted and managed
instances; recording an event does not authorize a provider operation.
A partial unique `(company_id, resource_type, resource_id)` index deduplicates
creation. Each actual agent pause, resume, or termination appends another event,
including budget actions and generic status updates. The agent row stays locked
until status and event commit, so concurrent repeat requests emit one hook.
Termination commits API-key revocation in that same transaction.
Hire approval and rejection commit with agent activation or termination, so a
failed event write leaves the decision pending and retryable.
The numeric event ID orders transitions for a resource. Future plugin delivery
must enforce company scope, preserve resource order, and track acknowledgments
per plugin. A global high-water mark can skip transactions that have not yet
committed; it is not a safe delivery cursor. The journal stores only identity,
action, and timestamps, not repository snapshots, credentials, provider config,
or resource health. Company deletion cascades to its events. Resource deletion
retains events, so consumers must revalidate existence and eligibility and load
current authorized repository data. A termination hook does not authorize
removing persistent VM or project data.
The migration creates an empty table. It does not scan or backfill existing
installs. Plugin delivery, retention, retries, and provider integration are
separate work. This change makes no provider calls and adds no plugin read API.
## Legacy controller ownership
Legacy run claims atomically record `controller_boot_id`, a database-clock
@@ -0,0 +1,14 @@
CREATE TABLE "resource_lifecycle_events" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "resource_lifecycle_events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"company_id" uuid NOT NULL,
"resource_type" text NOT NULL,
"resource_id" uuid NOT NULL,
"action" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "resource_lifecycle_events_resource_type_check" CHECK ("resource_lifecycle_events"."resource_type" IN ('agent', 'project')),
CONSTRAINT "resource_lifecycle_events_action_check" CHECK ("resource_lifecycle_events"."action" = 'create' OR ("resource_lifecycle_events"."resource_type" = 'agent' AND "resource_lifecycle_events"."action" IN ('pause', 'resume', 'terminate')))
);
--> statement-breakpoint
ALTER TABLE "resource_lifecycle_events" ADD CONSTRAINT "resource_lifecycle_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "resource_lifecycle_events_creation_idx" ON "resource_lifecycle_events" USING btree ("company_id","resource_type","resource_id") WHERE "resource_lifecycle_events"."action" = 'create';--> statement-breakpoint
CREATE INDEX "resource_lifecycle_events_company_idx" ON "resource_lifecycle_events" USING btree ("company_id","id");
@@ -1,6 +1,6 @@
{
"id": "239dfc30-3531-48b5-a06f-72608de479da",
"prevId": "a02dd1cf-75c9-4c4d-b13d-cf5dcd729ad3",
"id": "ed75c75c-748b-4420-8a68-ec5b34394e75",
"prevId": "a6c67322-9605-4234-b05d-b3585e943e42",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -31465,6 +31465,13 @@
"primaryKey": false,
"notNull": true
},
"title_needs_generation": {
"name": "title_needs_generation",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"description": {
"name": "description",
"type": "text",
@@ -38911,6 +38918,140 @@
},
"isRLSEnabled": false
},
"public.resource_lifecycle_events": {
"name": "resource_lifecycle_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "resource_lifecycle_events_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "9223372036854775807",
"cache": "1",
"cycle": false
}
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"resource_lifecycle_events_creation_idx": {
"name": "resource_lifecycle_events_creation_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "resource_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "resource_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"resource_lifecycle_events\".\"action\" = 'create'",
"concurrently": false,
"method": "btree",
"with": {}
},
"resource_lifecycle_events_company_idx": {
"name": "resource_lifecycle_events_company_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"resource_lifecycle_events_company_id_companies_id_fk": {
"name": "resource_lifecycle_events_company_id_companies_id_fk",
"tableFrom": "resource_lifecycle_events",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"resource_lifecycle_events_resource_type_check": {
"name": "resource_lifecycle_events_resource_type_check",
"value": "\"resource_lifecycle_events\".\"resource_type\" IN ('agent', 'project')"
},
"resource_lifecycle_events_action_check": {
"name": "resource_lifecycle_events_action_check",
"value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))"
}
},
"isRLSEnabled": false
},
"public.routine_documents": {
"name": "routine_documents",
"schema": "",
@@ -44490,6 +44631,298 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tool_connection_app_snapshots": {
"name": "tool_connection_app_snapshots",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"connection_id": {
"name": "connection_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credential_key": {
"name": "credential_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toolkit": {
"name": "toolkit",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"accounts": {
"name": "accounts",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"checked_at": {
"name": "checked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"error_at": {
"name": "error_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"tool_connection_app_snapshots_owner_toolkit_uq": {
"name": "tool_connection_app_snapshots_owner_toolkit_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "connection_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "toolkit",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tool_connection_app_snapshots_company_id_companies_id_fk": {
"name": "tool_connection_app_snapshots_company_id_companies_id_fk",
"tableFrom": "tool_connection_app_snapshots",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tool_connection_app_snapshots_company_connection_fk": {
"name": "tool_connection_app_snapshots_company_connection_fk",
"tableFrom": "tool_connection_app_snapshots",
"tableTo": "tool_connections",
"columnsFrom": [
"company_id",
"connection_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tool_connection_app_syncs": {
"name": "tool_connection_app_syncs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"connection_id": {
"name": "connection_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credential_key": {
"name": "credential_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lease_id": {
"name": "lease_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checked": {
"name": "checked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"total": {
"name": "total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_completed_at": {
"name": "last_completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"tool_connection_app_syncs_owner_key_uq": {
"name": "tool_connection_app_syncs_owner_key_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "connection_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "credential_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tool_connection_app_syncs_company_id_companies_id_fk": {
"name": "tool_connection_app_syncs_company_id_companies_id_fk",
"tableFrom": "tool_connection_app_syncs",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tool_connection_app_syncs_company_connection_fk": {
"name": "tool_connection_app_syncs_company_connection_fk",
"tableFrom": "tool_connection_app_syncs",
"tableTo": "tool_connections",
"columnsFrom": [
"company_id",
"connection_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tool_connection_installs": {
"name": "tool_connection_installs",
"schema": "",
@@ -46202,6 +46635,27 @@
"method": "btree",
"with": {}
},
"tool_mcp_gateway_tokens_expiry_idx": {
"name": "tool_mcp_gateway_tokens_expiry_idx",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"tool_mcp_gateway_tokens_gateway_idx": {
"name": "tool_mcp_gateway_tokens_gateway_idx",
"columns": [
+7
View File
@@ -2066,6 +2066,13 @@
"when": 1791028244412,
"tag": "0296_stiff_thaddeus_ross",
"breakpoints": true
},
{
"idx": 297,
"version": "7",
"when": 1791233758328,
"tag": "0297_foamy_swordsman",
"breakpoints": true
}
]
}
+1
View File
@@ -200,6 +200,7 @@ export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
export { pluginLogs } from "./plugin_logs.js";
export { runIdentityContexts } from "./run_identity_contexts.js";
export { connectionIntentDeliveries } from "./connection_intent_deliveries.js";
export { resourceLifecycleEvents } from "./resource_lifecycle_events.js";
export { toolActionDeliveries } from "./tool_action_deliveries.js";
export { chatTeamsFileTransfers } from "./chat_teams_file_transfers.js";
@@ -0,0 +1,18 @@
import { bigint, check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { companies } from "./companies.js";
/** Content-free lifecycle journal. Consumers must revalidate the resource before provisioning. */
export const resourceLifecycleEvents = pgTable("resource_lifecycle_events", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
resourceType: text("resource_type").$type<"agent" | "project">().notNull(),
resourceId: uuid("resource_id").notNull(),
action: text("action").$type<"create" | "pause" | "resume" | "terminate">().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
resourceIdx: uniqueIndex("resource_lifecycle_events_creation_idx").on(table.companyId, table.resourceType, table.resourceId).where(sql`${table.action} = 'create'`),
companyIdx: index("resource_lifecycle_events_company_idx").on(table.companyId, table.id),
resourceTypeCheck: check("resource_lifecycle_events_resource_type_check", sql`${table.resourceType} IN ('agent', 'project')`),
actionCheck: check("resource_lifecycle_events_action_check", sql`${table.action} = 'create' OR (${table.resourceType} = 'agent' AND ${table.action} IN ('pause', 'resume', 'terminate'))`),
}));
+12 -1
View File
@@ -49,7 +49,7 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval
const update = vi.fn(() => ({ set }));
return {
db: { select, update },
db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => callback({ select, update })) },
selectWhere,
returning,
};
@@ -105,6 +105,17 @@ describe("approvalService resolution idempotency", () => {
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
});
it("does not notify the adapter when the approval transaction fails to commit", async () => {
const approved = createApproval("approved");
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
dbStub.db.transaction.mockImplementationOnce(async callback => {
await callback(dbStub.db);
throw new Error("commit failed");
});
await expect(approvalService(dbStub.db as any).approve("approval-1", "board")).rejects.toThrow("commit failed");
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
});
it("creates the agent from payload when approval does not reference a pending agent", async () => {
const approved = {
...createApproval("approved"),
+6 -1
View File
@@ -26,7 +26,10 @@ type SelectResult = unknown[];
function createDbStub(selectResults: SelectResult[]) {
const pendingSelects = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
const selectWhere = vi.fn(() => {
const query = Promise.resolve(pendingSelects.shift() ?? []);
return Object.assign(query, { for: () => query });
});
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
const selectFrom = vi.fn(() => ({
@@ -62,6 +65,7 @@ function createDbStub(selectResults: SelectResult[]) {
select,
insert,
update,
transaction: async (callback: (db: unknown) => Promise<unknown>) => callback({ select, insert, update }),
},
queueInsert: (rows: unknown[]) => {
pendingInserts.push(rows);
@@ -105,6 +109,7 @@ describe("budgetService", () => {
status: "running",
pauseReason: null,
}],
[{ id: "agent-1", companyId: "company-1", status: "running" }],
]);
dbStub.queueInsert([{
@@ -0,0 +1,221 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { agentApiKeys, agents, budgetPolicies, companies, costEvents, createDb, projects, resourceLifecycleEvents, type Db } from "@paperclipai/db";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
import { approvalService } from "../services/approvals.js";
import { budgetService } from "../services/budgets.js";
import { projectService } from "../services/projects.js";
import { recordResourceCreationEvent } from "../services/resource-lifecycle-events.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping lifecycle event database tests: ${support.reason}`);
describePostgres("Resource lifecycle events", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-resource-events-");
db = createDb(database.connectionString);
}, 90_000);
afterAll(async () => { await database?.cleanup(); });
beforeEach(async () => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Lifecycle fixture", issuePrefix: `L${companyId.replaceAll("-", "").slice(0, 6)}` });
});
afterEach(() => { vi.unstubAllEnvs(); });
const events = () => db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId));
const createAgent = (status: "idle" | "pending_approval" | "terminated" = "idle", database: Db = db) =>
agentService(database).create(companyId, { name: "Lifecycle agent", adapterType: "process", adapterConfig: {}, status });
it("records direct and approval-free hires once, including concurrent duplicate submissions", async () => {
const agent = await createAgent();
await Promise.all(Array.from({ length: 5 }, () => recordResourceCreationEvent(db, companyId, "agent", agent.id)));
expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id, action: "create" })]);
});
it("records pending hires only after approval and ignores repeated approval", async () => {
const agent = await createAgent("pending_approval");
expect(await events()).toEqual([]);
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } });
await approvalService(db).approve(approval.id, "fixture-board");
await approvalService(db).approve(approval.id, "fixture-board");
await agentService(db).activatePendingApproval(agent.id);
expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id })]);
});
it("records legacy approvals that create the agent at approval time", async () => {
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { name: "Legacy hire", adapterType: "process" } });
await approvalService(db).approve(approval.id, "fixture-board");
const [intent] = await events();
expect(intent?.resourceType).toBe("agent");
expect(await agentService(db).getById(intent!.resourceId)).toMatchObject({ status: "idle", companyId });
});
it("does not allocate for rejected or terminated hires", async () => {
const agent = await createAgent("pending_approval");
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } });
await approvalService(db).reject(approval.id, "fixture-board");
await createAgent("terminated");
expect(await events()).toEqual([expect.objectContaining({ action: "terminate", resourceId: agent.id })]);
});
it("rolls back hire rejection if termination cannot record its hook, allowing a retry", async () => {
const agent = await createAgent("pending_approval");
const service = approvalService(db);
const approval = await service.create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } });
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_hook CHECK (false) NOT VALID`);
try {
await expect(service.reject(approval.id, "fixture-board")).rejects.toThrow();
expect(await service.getById(approval.id)).toMatchObject({ status: "pending" });
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "pending_approval" });
expect(await events()).toEqual([]);
} finally {
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_hook`);
}
expect(await service.reject(approval.id, "fixture-board")).toMatchObject({ applied: true, approval: { status: "rejected" } });
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "terminated" });
expect(await service.reject(approval.id, "fixture-board")).toMatchObject({ applied: false });
expect(await events()).toEqual([expect.objectContaining({ action: "terminate", resourceId: agent.id })]);
});
it("records projects with zero or multiple repository workspaces without storing repository data", async () => {
const empty = await projectService(db).create(companyId, { name: "Empty project" });
const project = await projectService(db).createWithRepositories(companyId, { name: "Repository project" }, [
{ id: "1", fullName: "fixture/one", url: "https://github.com/fixture/one", connections: [] },
{ id: "2", fullName: "fixture/two", url: "https://github.com/fixture/two", connections: [] },
]);
expect(project.workspaces).toHaveLength(2);
const rows = await events();
expect(rows.map(row => row.resourceId).sort()).toEqual([empty.id, project.id].sort());
expect(rows.every(row => row.resourceType === "project")).toBe(true);
expect(Object.keys(rows[0]).sort()).toEqual(["action", "companyId", "createdAt", "id", "resourceId", "resourceType"]);
});
it("records every pause/resume cycle and termination in resource order, without duplicate hooks", async () => {
const agent = await createAgent();
const service = agentService(db);
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Lifecycle key", keyHash: "fixture-hash" });
await Promise.all(Array.from({ length: 4 }, () => service.pause(agent.id)));
await Promise.all(Array.from({ length: 4 }, () => service.resume(agent.id)));
await service.update(agent.id, { status: "paused" });
await service.update(agent.id, { status: "idle" });
await Promise.all(Array.from({ length: 4 }, () => service.terminate(agent.id)));
const rows = (await events()).sort((a, b) => a.id - b.id);
expect(rows.map(row => row.action)).toEqual(["create", "pause", "resume", "pause", "resume", "terminate"]);
expect(rows.every(row => row.resourceId === agent.id)).toBe(true);
const [key] = await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id));
expect(key.revokedAt).not.toBeNull();
await expect(service.pause(agent.id)).rejects.toMatchObject({ status: 409 });
await expect(service.resume(agent.id)).rejects.toMatchObject({ status: 409 });
});
it("does not bypass pending hire approval with pause or resume", async () => {
const agent = await createAgent("pending_approval");
await expect(agentService(db).pause(agent.id)).rejects.toMatchObject({ status: 409 });
await expect(agentService(db).resume(agent.id)).rejects.toMatchObject({ status: 409 });
expect(await events()).toEqual([]);
});
it("records budget pause and resume hooks without replaying repeated budget evaluation", async () => {
const agent = await createAgent();
const service = budgetService(db);
await db.insert(budgetPolicies).values({ companyId, scopeType: "agent", scopeId: agent.id, metric: "billed_cents", windowKind: "calendar_month_utc", amount: 100, notifyEnabled: false });
const [event] = await db.insert(costEvents).values({ companyId, agentId: agent.id, provider: "fixture", model: "fixture", costCents: 150, occurredAt: new Date() }).returning();
await service.evaluateCostEvent(event);
await service.evaluateCostEvent(event);
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "paused", pauseReason: "budget" });
await service.upsertPolicy(companyId, { scopeType: "agent", scopeId: agent.id, amount: 200 }, "fixture-board");
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "idle", pauseReason: null });
expect((await events()).sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "pause", "resume"]);
});
it("rolls back pause, resume, termination, and key revocation if a hook write fails", async () => {
const agent = await createAgent();
const paused = await createAgent();
await agentService(db).pause(paused.id);
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Retained key", keyHash: "fixture-retained-hash" });
const before = await events();
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_hook CHECK (false) NOT VALID`);
try {
await expect(agentService(db).pause(agent.id)).rejects.toThrow();
await expect(agentService(db).resume(paused.id)).rejects.toThrow();
await expect(agentService(db).terminate(agent.id)).rejects.toThrow();
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "idle" });
expect(await agentService(db).getById(paused.id)).toMatchObject({ status: "paused" });
const [key] = await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id));
expect(key.revokedAt).toBeNull();
expect(await events()).toEqual(before);
} finally {
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_hook`);
}
});
it("rolls back agent and project events with the outer creation transaction", async () => {
const agentId = randomUUID();
const projectId = randomUUID();
await expect(db.transaction(async tx => {
const txDb = tx as unknown as Db;
await agentService(txDb).create(companyId, { id: agentId, name: "Rolled back agent" });
await projectService(txDb).createWithRepositories(companyId, { id: projectId, name: "Rolled back project" }, []);
throw new Error("rollback fixture");
})).rejects.toThrow("rollback fixture");
expect(await events()).toEqual([]);
expect(await agentService(db).getById(agentId)).toBeNull();
expect(await projectService(db).getById(projectId)).toBeNull();
});
it("fails creation and activation atomically if the intent cannot be persisted", async () => {
const pending = await createAgent("pending_approval");
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: pending.id } });
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_intent CHECK (false) NOT VALID`);
try {
await expect(createAgent()).rejects.toThrow();
await expect(projectService(db).create(companyId, { name: "Rejected project" })).rejects.toThrow();
await expect(approvalService(db).approve(approval.id, "fixture-board")).rejects.toThrow();
expect(await approvalService(db).getById(approval.id)).toMatchObject({ status: "pending" });
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toEqual([expect.objectContaining({ id: pending.id, status: "pending_approval" })]);
expect(await db.select().from(projects).where(eq(projects.companyId, companyId))).toEqual([]);
expect(await events()).toEqual([]);
} finally {
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_intent`);
}
});
it("captures self-hosted lifecycle events without backfilling older resources", async () => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
const oldAgentId = randomUUID();
const oldProjectId = randomUUID();
await db.insert(agents).values({ id: oldAgentId, companyId, name: "Existing agent" });
await db.insert(projects).values({ id: oldProjectId, companyId, name: "Existing project" });
await agentService(db).update(oldAgentId, { name: "Renamed agent" });
await projectService(db).update(oldProjectId, { name: "Renamed project" });
expect(await events()).toEqual([]);
const agent = await createAgent();
const project = await projectService(db).create(companyId, { name: "New project" });
expect((await events()).map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort());
});
it("scopes resource identities by company and type", async () => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-token");
const id = randomUUID();
await recordResourceCreationEvent(db, companyId, "agent", id);
await recordResourceCreationEvent(db, companyId, "project", id);
const otherCompanyId = randomUUID();
await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" });
await recordResourceCreationEvent(db, otherCompanyId, "agent", id);
expect(await events()).toHaveLength(2);
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toHaveLength(1);
await db.delete(companies).where(eq(companies.id, otherCompanyId));
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toEqual([]);
});
});
+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 });
}