mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## 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>
646 lines
19 KiB
TypeScript
646 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
approvals,
|
|
budgetIncidents,
|
|
budgetPolicies,
|
|
companies,
|
|
costEvents,
|
|
createDb,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import { budgetService } from "../services/budgets.ts";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../services/activity-log.js", () => ({
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
type SelectResult = unknown[];
|
|
|
|
function createDbStub(selectResults: SelectResult[]) {
|
|
const pendingSelects = [...selectResults];
|
|
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(() => ({
|
|
where: selectWhere,
|
|
then: selectThen,
|
|
orderBy: selectOrderBy,
|
|
}));
|
|
const select = vi.fn(() => ({
|
|
from: selectFrom,
|
|
}));
|
|
|
|
const insertValues = vi.fn();
|
|
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
|
|
const insert = vi.fn(() => ({
|
|
values: insertValues.mockImplementation(() => ({
|
|
returning: insertReturning,
|
|
})),
|
|
}));
|
|
|
|
const updateSet = vi.fn();
|
|
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
|
|
const update = vi.fn(() => ({
|
|
set: updateSet.mockImplementation(() => ({
|
|
where: updateWhere,
|
|
})),
|
|
}));
|
|
|
|
const pendingInserts: unknown[][] = [];
|
|
const pendingUpdates: unknown[][] = [];
|
|
|
|
return {
|
|
db: {
|
|
select,
|
|
insert,
|
|
update,
|
|
transaction: async (callback: (db: unknown) => Promise<unknown>) => callback({ select, insert, update }),
|
|
},
|
|
queueInsert: (rows: unknown[]) => {
|
|
pendingInserts.push(rows);
|
|
},
|
|
queueUpdate: (rows: unknown[] = []) => {
|
|
pendingUpdates.push(rows);
|
|
},
|
|
selectWhere,
|
|
insertValues,
|
|
updateSet,
|
|
};
|
|
}
|
|
|
|
describe("budgetService", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
|
|
const policy = {
|
|
id: "policy-1",
|
|
companyId: "company-1",
|
|
scopeType: "agent",
|
|
scopeId: "agent-1",
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
amount: 100,
|
|
warnPercent: 80,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: false,
|
|
isActive: true,
|
|
};
|
|
|
|
const dbStub = createDbStub([
|
|
[policy],
|
|
[{ total: 150 }],
|
|
[],
|
|
[{
|
|
companyId: "company-1",
|
|
name: "Budget Agent",
|
|
status: "running",
|
|
pauseReason: null,
|
|
}],
|
|
[{ id: "agent-1", companyId: "company-1", status: "running" }],
|
|
]);
|
|
|
|
dbStub.queueInsert([{
|
|
id: "approval-1",
|
|
companyId: "company-1",
|
|
status: "pending",
|
|
}]);
|
|
dbStub.queueInsert([{
|
|
id: "incident-1",
|
|
companyId: "company-1",
|
|
policyId: "policy-1",
|
|
approvalId: "approval-1",
|
|
}]);
|
|
dbStub.queueUpdate([]);
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
|
|
await service.evaluateCostEvent({
|
|
companyId: "company-1",
|
|
agentId: "agent-1",
|
|
projectId: null,
|
|
} as any);
|
|
|
|
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
type: "budget_override_required",
|
|
status: "pending",
|
|
}),
|
|
);
|
|
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
policyId: "policy-1",
|
|
thresholdType: "hard",
|
|
amountLimit: 100,
|
|
amountObserved: 150,
|
|
approvalId: "approval-1",
|
|
}),
|
|
);
|
|
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: "paused",
|
|
pauseReason: "budget",
|
|
pausedAt: expect.any(Date),
|
|
}),
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "budget.hard_threshold_crossed",
|
|
entityId: "incident-1",
|
|
}),
|
|
);
|
|
expect(cancelWorkForScope).toHaveBeenCalledWith({
|
|
companyId: "company-1",
|
|
scopeType: "agent",
|
|
scopeId: "agent-1",
|
|
});
|
|
});
|
|
|
|
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
|
|
const agentPolicy = {
|
|
id: "policy-agent-1",
|
|
companyId: "company-1",
|
|
scopeType: "agent",
|
|
scopeId: "agent-1",
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
amount: 100,
|
|
warnPercent: 80,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: true,
|
|
isActive: true,
|
|
};
|
|
|
|
const dbStub = createDbStub([
|
|
[{
|
|
status: "running",
|
|
pauseReason: null,
|
|
companyId: "company-1",
|
|
name: "Budget Agent",
|
|
}],
|
|
[{
|
|
status: "active",
|
|
name: "Paperclip",
|
|
}],
|
|
[],
|
|
[agentPolicy],
|
|
[{ total: 120 }],
|
|
]);
|
|
|
|
const service = budgetService(dbStub.db as any);
|
|
const block = await service.getInvocationBlock("company-1", "agent-1");
|
|
|
|
expect(block).toEqual({
|
|
scopeType: "agent",
|
|
scopeId: "agent-1",
|
|
scopeName: "Budget Agent",
|
|
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
|
});
|
|
});
|
|
|
|
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
|
|
const dbStub = createDbStub([
|
|
[{
|
|
status: "idle",
|
|
pauseReason: null,
|
|
companyId: "company-1",
|
|
name: "Budget Agent",
|
|
}],
|
|
[{
|
|
status: "paused",
|
|
pauseReason: "budget",
|
|
name: "Paperclip",
|
|
}],
|
|
]);
|
|
|
|
const service = budgetService(dbStub.db as any);
|
|
const block = await service.getInvocationBlock("company-1", "agent-1");
|
|
|
|
expect(block).toEqual({
|
|
scopeType: "company",
|
|
scopeId: "company-1",
|
|
scopeName: "Paperclip",
|
|
reason: "Company is paused because its budget hard-stop was reached.",
|
|
});
|
|
});
|
|
|
|
it("uses live observed spend when raising a budget incident", async () => {
|
|
const dbStub = createDbStub([
|
|
[{
|
|
id: "incident-1",
|
|
companyId: "company-1",
|
|
policyId: "policy-1",
|
|
amountObserved: 120,
|
|
approvalId: "approval-1",
|
|
}],
|
|
[{
|
|
id: "policy-1",
|
|
companyId: "company-1",
|
|
scopeType: "company",
|
|
scopeId: "company-1",
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
}],
|
|
[{ total: 150 }],
|
|
]);
|
|
|
|
const service = budgetService(dbStub.db as any);
|
|
|
|
await expect(
|
|
service.resolveIncident(
|
|
"company-1",
|
|
"incident-1",
|
|
{ action: "raise_budget_and_resume", amount: 140 },
|
|
"board-user",
|
|
),
|
|
).rejects.toThrow("New budget must exceed current observed spend");
|
|
});
|
|
|
|
it("syncs company monthly budget when raising and resuming a company incident", async () => {
|
|
const now = new Date();
|
|
const dbStub = createDbStub([
|
|
[{
|
|
id: "incident-1",
|
|
companyId: "company-1",
|
|
policyId: "policy-1",
|
|
scopeType: "company",
|
|
scopeId: "company-1",
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
windowStart: now,
|
|
windowEnd: now,
|
|
thresholdType: "hard",
|
|
amountLimit: 100,
|
|
amountObserved: 120,
|
|
status: "open",
|
|
approvalId: "approval-1",
|
|
resolvedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}],
|
|
[{
|
|
id: "policy-1",
|
|
companyId: "company-1",
|
|
scopeType: "company",
|
|
scopeId: "company-1",
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
amount: 100,
|
|
}],
|
|
[{ total: 120 }],
|
|
[{ id: "approval-1", status: "approved" }],
|
|
[{
|
|
companyId: "company-1",
|
|
name: "Paperclip",
|
|
status: "paused",
|
|
pauseReason: "budget",
|
|
pausedAt: now,
|
|
}],
|
|
]);
|
|
|
|
const service = budgetService(dbStub.db as any);
|
|
await service.resolveIncident(
|
|
"company-1",
|
|
"incident-1",
|
|
{ action: "raise_budget_and_resume", amount: 175 },
|
|
"board-user",
|
|
);
|
|
|
|
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
budgetMonthlyCents: 175,
|
|
updatedAt: expect.any(Date),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-budgets-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(budgetIncidents);
|
|
await db.delete(approvals);
|
|
await db.delete(budgetPolicies);
|
|
await db.delete(costEvents);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
mockLogActivity.mockClear();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createBudgetFixture() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Budget Project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
return { companyId, agentId, projectId };
|
|
}
|
|
|
|
async function insertCostEvent(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
projectId?: string | null;
|
|
costCents: number;
|
|
occurredAt?: Date;
|
|
}) {
|
|
const [event] = await db
|
|
.insert(costEvents)
|
|
.values({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
projectId: input.projectId ?? null,
|
|
provider: "openai",
|
|
biller: "openai",
|
|
billingType: "metered_api",
|
|
model: "gpt-5-release-gate",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 20,
|
|
costCents: input.costCents,
|
|
occurredAt: input.occurredAt ?? new Date(),
|
|
})
|
|
.returning();
|
|
|
|
return event!;
|
|
}
|
|
|
|
it("raises one soft incident per window before hard-stopping and safely logging agent telemetry", async () => {
|
|
const { companyId, agentId } = await createBudgetFixture();
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
const service = budgetService(db, { cancelWorkForScope });
|
|
const [policy] = await db
|
|
.insert(budgetPolicies)
|
|
.values({
|
|
companyId,
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
metric: "billed_cents",
|
|
windowKind: "calendar_month_utc",
|
|
amount: 100,
|
|
warnPercent: 80,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: true,
|
|
isActive: true,
|
|
})
|
|
.returning();
|
|
|
|
const softEvent = await insertCostEvent({ companyId, agentId, costCents: 80 });
|
|
await service.evaluateCostEvent(softEvent);
|
|
await service.evaluateCostEvent(softEvent);
|
|
|
|
let incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
|
|
expect(incidentRows[0]).toMatchObject({
|
|
companyId,
|
|
policyId: policy!.id,
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
thresholdType: "soft",
|
|
amountLimit: 100,
|
|
amountObserved: 80,
|
|
approvalId: null,
|
|
status: "open",
|
|
});
|
|
|
|
const [agentBeforeHardStop] = await db
|
|
.select({ status: agents.status, pauseReason: agents.pauseReason })
|
|
.from(agents);
|
|
expect(agentBeforeHardStop).toEqual({ status: "active", pauseReason: null });
|
|
|
|
const hardEvent = await insertCostEvent({ companyId, agentId, costCents: 25 });
|
|
await service.evaluateCostEvent(hardEvent);
|
|
await service.evaluateCostEvent(hardEvent);
|
|
|
|
incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
|
|
expect(incidentRows.find((incident) => incident.thresholdType === "soft")).toMatchObject({
|
|
status: "resolved",
|
|
});
|
|
expect(incidentRows.find((incident) => incident.thresholdType === "hard")).toMatchObject({
|
|
amountLimit: 100,
|
|
amountObserved: 105,
|
|
status: "open",
|
|
});
|
|
|
|
const [approval] = await db.select().from(approvals);
|
|
expect(approval).toMatchObject({
|
|
companyId,
|
|
type: "budget_override_required",
|
|
status: "pending",
|
|
});
|
|
|
|
const [agentAfterHardStop] = await db
|
|
.select({ status: agents.status, pauseReason: agents.pauseReason, pausedAt: agents.pausedAt })
|
|
.from(agents);
|
|
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
|
|
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
|
expect(cancelWorkForScope).toHaveBeenCalledTimes(2);
|
|
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId });
|
|
|
|
const block = await service.getInvocationBlock(companyId, agentId);
|
|
expect(block).toEqual({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
scopeName: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
|
|
reason: "Agent is paused because its budget hard-stop was reached.",
|
|
});
|
|
|
|
const telemetryCalls = mockLogActivity.mock.calls.map(([, input]) => input);
|
|
expect(telemetryCalls.filter((call) => call.action === "budget.soft_threshold_crossed")).toHaveLength(1);
|
|
expect(telemetryCalls.filter((call) => call.action === "budget.hard_threshold_crossed")).toHaveLength(1);
|
|
expect(telemetryCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "budget.soft_threshold_crossed",
|
|
entityType: "budget_incident",
|
|
details: expect.objectContaining({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
amountObserved: 80,
|
|
amountLimit: 100,
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
action: "budget.hard_threshold_crossed",
|
|
entityType: "budget_incident",
|
|
details: expect.objectContaining({
|
|
scopeType: "agent",
|
|
scopeId: agentId,
|
|
amountObserved: 105,
|
|
amountLimit: 100,
|
|
approvalId: approval!.id,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
for (const call of telemetryCalls) {
|
|
expect(JSON.stringify(call.details)).not.toContain("SECRET_TOKEN_SHOULD_NOT_LEAK");
|
|
expect(call.details).not.toHaveProperty("prompt");
|
|
expect(call.details).not.toHaveProperty("message");
|
|
}
|
|
});
|
|
|
|
it("hard-stops project work until a valid budget raise resumes it and overview reconciles ledger spend", async () => {
|
|
const { companyId, agentId, projectId } = await createBudgetFixture();
|
|
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
|
const service = budgetService(db, { cancelWorkForScope });
|
|
await db.insert(budgetPolicies).values({
|
|
companyId,
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
metric: "billed_cents",
|
|
windowKind: "lifetime",
|
|
amount: 100,
|
|
warnPercent: 75,
|
|
hardStopEnabled: true,
|
|
notifyEnabled: true,
|
|
isActive: true,
|
|
});
|
|
|
|
const event = await insertCostEvent({ companyId, agentId, projectId, costCents: 125 });
|
|
await service.evaluateCostEvent(event);
|
|
await service.evaluateCostEvent(event);
|
|
|
|
const incidentRows = await db
|
|
.select()
|
|
.from(budgetIncidents);
|
|
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
|
|
const hardIncident = incidentRows.find((incident) => incident.thresholdType === "hard")!;
|
|
expect(hardIncident).toMatchObject({
|
|
companyId,
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amountLimit: 100,
|
|
amountObserved: 125,
|
|
status: "open",
|
|
});
|
|
|
|
const [projectAfterHardStop] = await db
|
|
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
|
|
.from(projects);
|
|
expect(projectAfterHardStop?.pauseReason).toBe("budget");
|
|
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
|
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId });
|
|
|
|
const overviewWhileBlocked = await service.overview(companyId);
|
|
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
|
|
expect(overviewWhileBlocked.pendingApprovalCount).toBe(1);
|
|
expect(overviewWhileBlocked.policies[0]).toMatchObject({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amount: 100,
|
|
observedAmount: 125,
|
|
remainingAmount: 0,
|
|
utilizationPercent: 125,
|
|
status: "hard_stop",
|
|
paused: true,
|
|
pauseReason: "budget",
|
|
});
|
|
expect(overviewWhileBlocked.activeIncidents).toHaveLength(1);
|
|
|
|
await expect(
|
|
service.resolveIncident(
|
|
companyId,
|
|
hardIncident.id,
|
|
{ action: "raise_budget_and_resume", amount: 125 },
|
|
"board-user",
|
|
),
|
|
).rejects.toThrow("New budget must exceed current observed spend");
|
|
|
|
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toEqual({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
scopeName: "Budget Project",
|
|
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
|
|
});
|
|
|
|
const resolved = await service.resolveIncident(
|
|
companyId,
|
|
hardIncident.id,
|
|
{ action: "raise_budget_and_resume", amount: 175, decisionNote: "Approved release-gate budget raise." },
|
|
"board-user",
|
|
);
|
|
expect(resolved).toMatchObject({ status: "resolved", approvalStatus: "approved" });
|
|
|
|
const [projectAfterResume] = await db
|
|
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
|
|
.from(projects);
|
|
expect(projectAfterResume).toEqual({ pauseReason: null, pausedAt: null });
|
|
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toBeNull();
|
|
|
|
const overviewAfterResume = await service.overview(companyId);
|
|
expect(overviewAfterResume.pausedProjectCount).toBe(0);
|
|
expect(overviewAfterResume.pendingApprovalCount).toBe(0);
|
|
expect(overviewAfterResume.policies[0]).toMatchObject({
|
|
scopeType: "project",
|
|
scopeId: projectId,
|
|
amount: 175,
|
|
observedAmount: 125,
|
|
remainingAmount: 50,
|
|
utilizationPercent: expect.closeTo(71.43, 2),
|
|
status: "ok",
|
|
paused: false,
|
|
pauseReason: null,
|
|
});
|
|
expect(overviewAfterResume.activeIncidents).toHaveLength(0);
|
|
});
|
|
});
|