From 8c6cc7dccf91523e0720bd86f95487e66b4b0e63 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 17:49:15 -0700 Subject: [PATCH] feat(cloud): sync primary-company archive state with the Cloud control plane (#13837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Cloud runs managed instances and controls their lifecycle from a control plane. > - Inside the app, a person can archive a company. On a managed instance, the Cloud-pinned primary company is the whole organization. > - Today that archive stays local. The control plane does not learn about it, so it keeps the instance running and shows the organization as live. > - This pull request notifies the control plane when the primary company crosses the archived boundary, and adds two control endpoints so the control plane can verify the state and undo the archive during a restore. > - The benefit is that an archived organization stops running and shows as archived, and a restore brings the company back without manual steps. ## Linked Issues or Issue Description No public issue exists. Description follows the enhancement template: **What existing behavior does this improve?** Company archive on Cloud-managed instances. Archiving the primary company pauses agents and cancels runs, but the hosting control plane never learns about it. **Subsystem affected** Server: company service, cloud-control middleware, instance routes. **Current behavior** A person archives the primary company. The instance keeps running. The Cloud portfolio still shows the organization as live. Unarchive after a Cloud restore requires manual steps inside the product. **Proposed behavior** The company service rings a Cloud lifecycle doorbell when the primary company is archived or unarchived. The control plane verifies the state through `GET /api/instance/lifecycle` before it acts. During a restore, the control plane calls `POST /api/instance/lifecycle/unarchive-primary` to bring the company back. Self-hosted instances are not affected. **Reason and benefit** An archived organization should not keep running, and its hosting console should show it as archived. The verified read-back keeps the doorbell a hint: a forged or duplicated ring cannot change state. ## What Changed - New `services/cloud-lifecycle-sync.ts`: fire-and-forget doorbell `POST {cloudOrigin}/v1/tenant/lifecycle-changed` with bounded retries. It runs only on cloud-managed instances, and only for the derived primary company id. It never blocks or fails the company mutation. - `services/companies.ts`: ring the doorbell after a committed archive or unarchive transition, in both the `update()` status-patch path and `archive()`. - New `GET /api/instance/lifecycle`: reports the primary company id, its status, and how many other companies are not archived. Bound to a `lifecycle:read` Cloud control assertion; board members can also read it. - New `POST /api/instance/lifecycle/unarchive-primary`: idempotent unarchive of the primary company as a system actor. Bound to `lifecycle:unarchive-primary`; requires instance admin otherwise. - `middleware/cloud-control.ts`: a closed endpoint→method→action table replaces the single hardcoded endpoint. Each assertion still authorizes exactly one action on one endpoint. - `services/cloud-instance.ts`: the primary-company id derivation moves here as the single definition; `middleware/auth.ts` delegates to it. The derivation itself is unchanged. ## Verification - `pnpm exec tsc --noEmit` in `server/` is clean. - `pnpm exec vitest run src/__tests__/cloud-lifecycle-sync.test.ts src/__tests__/cloud-control-task-drain.test.ts src/__tests__/instance-settings-routes.test.ts src/__tests__/companies-service.test.ts src/__tests__/cloud-tenant-company-provisioning.test.ts` — all pass. - New tests cover: doorbell env-gating, retry bounds, no-throw contract; the read-back status and sibling count; idempotent unarchive and the admin gate; non-cloud 404s; control-assertion action binding for the new endpoints, including cross-action and wrong-method rejection; and a companies-service test that proves the doorbell rings exactly on archived-boundary transitions. ## Risks - Self-hosted instances see no behavior change: without a Cloud signal the doorbell is a no-op and the endpoints answer 404. - The doorbell is advisory by design. The control plane verifies through the read-back before it acts, so a lost or duplicated ring cannot corrupt state. - The unarchive endpoint reuses the existing `companyService.update` path, so agent reactivation and activity logging behave exactly like an in-product unarchive. ## Model Used - Claude Fable 5 (`claude-fable-5`), via Claude Code CLI, extended thinking and tool use enabled. ## 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 --- .../cloud-control-task-drain.test.ts | 38 ++++ .../__tests__/cloud-lifecycle-sync.test.ts | 90 ++++++++++ .../src/__tests__/companies-service.test.ts | 43 ++++- .../instance-settings-routes.test.ts | 162 ++++++++++++++++++ server/src/middleware/auth.ts | 7 +- server/src/middleware/cloud-control.ts | 28 +-- server/src/routes/instance-settings.ts | 82 ++++++++- server/src/routes/openapi.ts | 18 ++ server/src/services/cloud-instance.ts | 16 ++ server/src/services/cloud-lifecycle-sync.ts | 103 +++++++++++ server/src/services/cloud-runtime-identity.ts | 2 + server/src/services/companies.ts | 14 ++ 12 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/cloud-lifecycle-sync.test.ts create mode 100644 server/src/services/cloud-lifecycle-sync.ts diff --git a/server/src/__tests__/cloud-control-task-drain.test.ts b/server/src/__tests__/cloud-control-task-drain.test.ts index 0e500157bb..3f0ab8e195 100644 --- a/server/src/__tests__/cloud-control-task-drain.test.ts +++ b/server/src/__tests__/cloud-control-task-drain.test.ts @@ -211,6 +211,12 @@ describe("cloudControlMiddleware", () => { app.all("/api/instance/task-drain", (req, res) => { res.json({ actor: req.actor }); }); + app.all("/api/instance/lifecycle", (req, res) => { + res.json({ actor: req.actor }); + }); + app.all("/api/instance/lifecycle/unarchive-primary", (req, res) => { + res.json({ actor: req.actor }); + }); app.get("/api/instance/settings", (req, res) => { res.json({ actor: req.actor }); }); @@ -263,6 +269,38 @@ describe("cloudControlMiddleware", () => { expect(res.body.error).toBe("invalid_cloud_control_assertion"); }); + it("authorizes the lifecycle endpoints, each bound to its own action", async () => { + const app = createApp(); + const read = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(read.status).toBe(200); + expect(read.body.actor).toMatchObject({ type: "board", isInstanceAdmin: true, source: "cloud_control" }); + + const unarchive = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:unarchive-primary")); + expect(unarchive.status).toBe(200); + expect(unarchive.body.actor).toMatchObject({ source: "cloud_control" }); + + // Cross-binding: a task-drain assertion opens no lifecycle door, a + // read assertion cannot unarchive, and lifecycle endpoints accept no + // extra methods. + const crossAction = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read")); + expect(crossAction.status).toBe(401); + const readAsWrite = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(readAsWrite.status).toBe(401); + const wrongMethod = await request(app) + .post("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(wrongMethod.status).toBe(400); + expect(wrongMethod.body.error).toBe("cloud_control_wrong_endpoint"); + }); + it("accepts the conventional trailing-slash form of the endpoint", async () => { const app = createApp(); const res = await request(app) diff --git a/server/src/__tests__/cloud-lifecycle-sync.test.ts b/server/src/__tests__/cloud-lifecycle-sync.test.ts new file mode 100644 index 0000000000..9a9897f086 --- /dev/null +++ b/server/src/__tests__/cloud-lifecycle-sync.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isCloudPinnedPrimaryCompany, + notifyCloudOfPrimaryCompanyLifecycleChange, +} from "../services/cloud-lifecycle-sync.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; + +const STACK_ID = "stack-lifecycle-sync"; +const PRIMARY_ID = cloudTenantPrimaryCompanyId(STACK_ID); + +const CLOUD_ENV = { + PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-token-test", + PAPERCLIP_CLOUD_STACK_ID: STACK_ID, + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.test", +} as NodeJS.ProcessEnv; + +describe("isCloudPinnedPrimaryCompany", () => { + it("matches only the derived primary company of a cloud-managed instance", () => { + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, CLOUD_ENV)).toBe(true); + expect(isCloudPinnedPrimaryCompany("some-other-company", CLOUD_ENV)).toBe(false); + // Self-hosted: no cloud signal, never primary. + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, {} as NodeJS.ProcessEnv)).toBe(false); + }); +}); + +describe("notifyCloudOfPrimaryCompanyLifecycleChange", () => { + it("rings the harness doorbell with the tenant token and stack id", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ outcome: "archived" }), { status: 200 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit]; + expect(url).toBe("https://cloud.example.test/v1/tenant/lifecycle-changed"); + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers); + expect(headers.get("authorization")).toBe("Bearer tenant-token-test"); + expect(headers.get("x-paperclip-cloud-stack-id")).toBe(STACK_ID); + }); + + it("is a silent no-op for non-primary companies and incomplete cloud metadata", async () => { + const fetchImpl = vi.fn(); + await notifyCloudOfPrimaryCompanyLifecycleChange("some-other-company", { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: { ...CLOUD_ENV, PAPERCLIP_CLOUD_API_ORIGIN: undefined } as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: {} as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("retries transient failures a bounded number of times and never throws", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("connection refused"); + }); + const sleeps: number[] = []; + await expect( + notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async (ms) => { + sleeps.push(ms); + }, + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(sleeps).toEqual([2_000, 10_000]); + }); + + it("does not retry once the harness answered, even non-2xx", async () => { + // Any answer means the harness heard the ring; it does its own + // verified read-back, so a 4xx outcome is final. + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: "stack_not_found" }), { status: 404 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async () => { + throw new Error("must not sleep"); + }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index 40ccfc6d18..aa79a5b93a 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, @@ -24,6 +24,15 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +// Observe the Cloud lifecycle doorbell without any network: the real +// implementation is env-gated (a no-op off Cloud), so these tests assert +// WHEN the service rings it, not what the ring does. +const notifyCloudSpy = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../services/cloud-lifecycle-sync.js", () => ({ + isCloudPinnedPrimaryCompany: () => false, + notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy, +})); + import { companyService } from "../services/companies.js"; import { deriveIssuePrefixBase } from "../services/issue-prefix.js"; import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; @@ -809,6 +818,38 @@ describeEmbeddedPostgres("companyService", () => { expect(archiveActivity[0]).toMatchObject({ details: { agentsPaused: 1, runsCancelled: 0 } }); }); + it("rings the Cloud lifecycle doorbell exactly on archived-boundary transitions", async () => { + notifyCloudSpy.mockClear(); + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Doorbell Test Co", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + const svc = companyService(db); + const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null }; + + // A plain edit never rings. + await svc.update(companyId, { name: "Doorbell Test Co (renamed)" }, actor); + expect(notifyCloudSpy).not.toHaveBeenCalled(); + + // archive() rings once; re-archiving (no transition) does not. + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + expect(notifyCloudSpy).toHaveBeenLastCalledWith(companyId); + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + + // Unarchiving through update() rings again. + await svc.update(companyId, { status: "active" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(2); + + // update() into archived rings too (the status-patch archive path). + await svc.update(companyId, { status: "archived" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(3); + }); + it("runs the archive cascade when update() transitions a paused company to archived", async () => { const companyId = randomUUID(); const idleAgentId = randomUUID(); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 7d58c1ac64..2eb7a0e96a 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -23,11 +23,16 @@ const mockEnvironmentService = vi.hoisted(() => ({ findManagedSandboxEnvironment: vi.fn(), update: vi.fn(), })); +const mockCompanyService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockPublishActivity = vi.hoisted(() => vi.fn()); function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ + companyService: () => mockCompanyService, heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, logActivity: mockLogActivity, @@ -52,8 +57,12 @@ function defaultTransactionImplementation(fn: (tx: unknown) => Promise) // Module-scoped (not rebuilt per createApp call) so a test can assert how // many times a request opened a transaction — the task-drain audit writes // for every company must share ONE transaction, not one each. +// Rows the lifecycle read-back's plain `db.select().from(companies)` sees; +// tests assign per case. +let mockCompanyRows: Array<{ id: string; status: string }> = []; const mockDb = { transaction: vi.fn(defaultTransactionImplementation), + select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })), }; describe("instance settings routes", () => { @@ -1289,4 +1298,157 @@ describe("instance settings routes", () => { expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled(); }); }); + + describe("cloud lifecycle read-back and unarchive", () => { + const STACK_ID = "stack-lifecycle-routes"; + const adminActor = { + type: "board", + userId: "paperclip-cloud", + source: "cloud_control", + isInstanceAdmin: true, + companyIds: [], + }; + const memberActor = { + type: "board", + userId: "member-1", + source: "cloud_tenant", + isInstanceAdmin: false, + companyIds: ["company-1"], + }; + // The pinned primary company id the routes derive from the stack id. + let primaryId: string; + + beforeEach(async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID; + const { cloudTenantPrimaryCompanyId } = await vi.importActual< + typeof import("../services/cloud-instance.js") + >("../services/cloud-instance.js"); + primaryId = cloudTenantPrimaryCompanyId(STACK_ID); + mockCompanyRows = []; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + }); + + it("reports the primary company status and how many other companies are not archived", async () => { + mockCompanyRows = [ + { id: primaryId, status: "archived" }, + { id: "company-sibling-live", status: "active" }, + { id: "company-sibling-paused", status: "paused" }, + { id: "company-sibling-archived", status: "archived" }, + ]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + primaryCompanyId: primaryId, + primaryCompanyStatus: "archived", + // paused counts: any non-archived sibling keeps the stack alive. + otherUnarchivedCompanyCount: 2, + }); + }); + + it("reports a missing primary company without failing", async () => { + mockCompanyRows = [{ id: "company-other", status: "active" }]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body.primaryCompanyStatus).toBe("missing"); + }); + + it("hides the cross-company lifecycle summary from non-admin board members", async () => { + mockCompanyRows = [{ id: primaryId, status: "archived" }]; + const app = await createApp(memberActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(403); + }); + + it("answers 404 when the instance is not cloud-managed", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + const readRes = await request(await createApp(adminActor)).get("/api/instance/lifecycle"); + expect(readRes.status).toBe(404); + + // The admin gate runs first, so prove the 404 with an admin actor. + const unarchiveRes = await request(await createApp(adminActor)) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + expect(unarchiveRes.status).toBe(404); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("unarchives an archived primary company as a system actor", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: true }); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + { actorType: "system", actorId: "paperclip-cloud", agentId: null, runId: null }, + ); + }); + + it("attributes a human admin's unarchive to that admin, not to Cloud", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const humanAdmin = { + type: "board", + userId: "human-admin", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + }; + const app = await createApp(humanAdmin); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + expect.objectContaining({ actorType: "user", actorId: "human-admin" }), + ); + }); + + it("is idempotent: an unarchived primary company reports changed:false", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "active" }); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: false }); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("requires instance admin rights to unarchive", async () => { + const app = await createApp(memberActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(403); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 22286b7b3f..60d1afe073 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -58,6 +58,7 @@ import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compa import { forbidden, unauthorized, unprocessable } from "../errors.js"; export { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -797,11 +798,7 @@ function constantTimeStringEqual(left: string, right: string): boolean { } function cloudTenantCompanyId(stackId: string): string { - const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); - bytes[6] = (bytes[6] & 0x0f) | 0x50; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.subarray(0, 16).toString("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; + return cloudTenantPrimaryCompanyId(stackId); } export function humanizeCloudStackSlug(stackId: string): string { diff --git a/server/src/middleware/cloud-control.ts b/server/src/middleware/cloud-control.ts index e4e490dfb8..f0e44503e8 100644 --- a/server/src/middleware/cloud-control.ts +++ b/server/src/middleware/cloud-control.ts @@ -6,17 +6,25 @@ import { type CloudControlAction, } from "../services/cloud-runtime-identity.js"; -/** Method → the one action a control assertion must name to take it. */ -const ACTION_BY_METHOD: Record = { - GET: "task-drain:read", - POST: "task-drain:start", - DELETE: "task-drain:stop", +/** Endpoint → method → the one action a control assertion must name to take it. */ +const ACTIONS_BY_ENDPOINT: Record> = { + "/api/instance/task-drain": { + GET: "task-drain:read", + POST: "task-drain:start", + DELETE: "task-drain:stop", + }, + "/api/instance/lifecycle": { + GET: "lifecycle:read", + }, + "/api/instance/lifecycle/unarchive-primary": { + POST: "lifecycle:unarchive-primary", + }, }; /** - * Accepts Cloud's signed control assertion only on the task-drain endpoint, - * so the Cloud control plane can hold new agent work and wait for quiescence - * before restarting the container for a deploy. The JWS is the entire + * Accepts Cloud's signed control assertion only on the closed endpoint set + * above — the task-drain hold the deploy path uses, and the lifecycle + * read-back/unarchive pair behind the Cloud archive sync. The JWS is the entire * authorization: a valid assertion installs a synthetic instance-admin board * actor (replacing whatever weaker actor the request carried), each assertion * is bound to exactly one method's action, and the header is rejected loudly @@ -32,11 +40,11 @@ export function cloudControlMiddleware(): RequestHandler { next(); return; } - const expectedAction = ACTION_BY_METHOD[req.method]; // Express's non-strict routing treats a trailing slash as the same // route; the endpoint check must agree with it. const normalizedPath = req.path.length > 1 && req.path.endsWith("/") ? req.path.slice(0, -1) : req.path; - if (normalizedPath !== "/api/instance/task-drain" || !expectedAction) { + const expectedAction = ACTIONS_BY_ENDPOINT[normalizedPath]?.[req.method]; + if (!expectedAction) { res.status(400).json({ error: "cloud_control_wrong_endpoint" }); return; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 1784d848c6..9612555278 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; import { patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, @@ -7,11 +7,16 @@ import { startTaskDrainRequestSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; -import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + isCloudManagedInstance, +} from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { validate } from "../middleware/validate.js"; import { logger } from "../middleware/logger.js"; import { + companyService, heartbeatService, instanceSettingsService, logActivity, @@ -403,5 +408,78 @@ export function instanceSettingsRoutes(db: Db) { res.json({ wasActive }); }); + // Cloud lifecycle read-back: the harness answers a tenant's archive + // doorbell by asking this instance what the Cloud-pinned primary + // company's status actually is, so the shared-token doorbell can stay a + // hint (see cloud-lifecycle-sync.ts). Reached by the harness with a + // `lifecycle:read` Cloud control assertion. Instance-admin only for + // humans: the response summarizes lifecycle state across EVERY company + // on the instance, which a board member scoped to one company must not + // be able to infer. + router.get("/instance/lifecycle", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const rows = await db + .select({ id: companies.id, status: companies.status }) + .from(companies); + const primary = rows.find((row) => row.id === primaryCompanyId); + res.json({ + primaryCompanyId, + primaryCompanyStatus: primary?.status ?? "missing", + otherUnarchivedCompanyCount: rows.filter( + (row) => row.id !== primaryCompanyId && row.status !== "archived", + ).length, + }); + }); + + // Cloud restore counterpart: "Resume organization" on a stack that was + // archived because its primary company was archived must bring the + // company back too, or the tenant's next doorbell would re-archive the + // stack the customer just resumed. Idempotent: a primary company that is + // not archived reports changed:false. Reached by the harness with a + // `lifecycle:unarchive-primary` control assertion; instance admins hold + // the same power through PATCH /api/companies/:id already. + router.post("/instance/lifecycle/unarchive-primary", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const companySvc = companyService(db); + const existing = await companySvc.getById(primaryCompanyId); + if (!existing) { + res.status(404).json({ error: "primary_company_not_found" }); + return; + } + if (existing.status !== "archived") { + res.json({ status: existing.status, changed: false }); + return; + } + // Attribution: only the harness's synthetic cloud_control actor logs + // as the Cloud system identity; a human instance admin calling this + // endpoint is recorded as themselves, exactly like an in-product + // unarchive. + const actor = req.actor.source === "cloud_control" + ? { actorType: "system" as const, actorId: "paperclip-cloud", agentId: null, runId: null } + : (() => { + const info = getActorInfo(req); + return { + actorType: info.actorType, + actorId: info.actorId, + agentId: info.agentId, + runId: info.runId, + }; + })(); + const updated = await companySvc.update(primaryCompanyId, { status: "active" }, actor); + res.json({ status: updated?.status ?? "active", changed: true }); + }); + return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 54e72f1d95..c7d44d0aa5 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6215,6 +6215,24 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); +registry.registerPath({ + method: "get", + path: "/api/instance/lifecycle", + tags: ["instance"], + summary: + "Read the Cloud-pinned primary company's lifecycle status and how many other companies are not archived; 404 when the instance is not Cloud-managed", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/instance/lifecycle/unarchive-primary", + tags: ["instance"], + summary: + "Unarchive the Cloud-pinned primary company (idempotent); used by the Cloud control plane while restoring an archived stack", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + // ─── Board chat (Conference Room Chat, experimental) ────────────────────────── registry.registerPath({ diff --git a/server/src/services/cloud-instance.ts b/server/src/services/cloud-instance.ts index e6825dda77..cdd3636969 100644 --- a/server/src/services/cloud-instance.ts +++ b/server/src/services/cloud-instance.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export type CloudInstanceEnv = Record; export type CloudStackContext = { @@ -50,3 +52,17 @@ export function getCloudStackContext( cloudOrigin: normalizeOptionalEnvValue(env.PAPERCLIP_CLOUD_API_ORIGIN), }; } + +/** + * The Cloud-pinned primary company id for a stack: the deterministic + * v5-style UUID the trusted-header lane seeds and every Cloud surface pins. + * The derivation is frozen — seeded companies fleet-wide already carry + * these ids. middleware/auth.ts delegates here; this is the one definition. + */ +export function cloudTenantPrimaryCompanyId(stackId: string): string { + const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} diff --git a/server/src/services/cloud-lifecycle-sync.ts b/server/src/services/cloud-lifecycle-sync.ts new file mode 100644 index 0000000000..8eb3e6e24c --- /dev/null +++ b/server/src/services/cloud-lifecycle-sync.ts @@ -0,0 +1,103 @@ +import { logger } from "../middleware/logger.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + type CloudInstanceEnv, +} from "./cloud-instance.js"; + +/** + * Cloud lifecycle doorbell: when the Cloud-pinned primary company is + * archived or unarchived on a managed instance, ring the harness + * (`POST /v1/tenant/lifecycle-changed`) so Paperclip Cloud can converge the + * stack — an org whose only company is archived should not keep running, + * and should read "Archived" on /orgs rather than "Live". + * + * The ring is a HINT by contract: the harness never trusts it. It reads + * the primary company's status back through `GET /api/instance/lifecycle` + * (Cloud control assertion) before changing anything, so the worst a lost, + * duplicated, or forged ring can do is trigger one verified read. + * + * Fire-and-forget with a bounded retry: archiving a company must never + * block or fail on Cloud availability, and a missed ring only delays the + * badge/suspend convergence, it cannot corrupt state. + */ + +const REQUEST_TIMEOUT_MS = 10_000; +const RETRY_DELAYS_MS = [2_000, 10_000]; + +export type CloudLifecycleSyncOptions = { + env?: CloudInstanceEnv; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise; +}; + +/** + * True when this company is the Cloud-pinned primary company of a managed + * instance — the only company whose archive state the harness mirrors. + */ +export function isCloudPinnedPrimaryCompany( + companyId: string, + env: CloudInstanceEnv = process.env, +): boolean { + const stackId = getCloudStackContext(env)?.stackId; + return Boolean(stackId) && cloudTenantPrimaryCompanyId(stackId!) === companyId; +} + +/** + * Ring the harness about a lifecycle change of the pinned primary company. + * No-op (resolved promise) on self-hosted instances, non-primary companies, + * or incomplete Cloud metadata. Never throws. + */ +export async function notifyCloudOfPrimaryCompanyLifecycleChange( + companyId: string, + options: CloudLifecycleSyncOptions = {}, +): Promise { + const env = options.env ?? process.env; + if (!isCloudPinnedPrimaryCompany(companyId, env)) return; + const context = getCloudStackContext(env); + const token = env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); + if (!context?.stackId || !context.cloudOrigin || !token) return; + + const fetchImpl = options.fetchImpl ?? fetch; + // Unreferenced timers: this detached retry loop must never hold the + // process open — a shutdown mid-retry just drops the ring, which the + // harness's verified read-back model tolerates by design. + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + })); + const url = `${context.cloudOrigin}/v1/tenant/lifecycle-changed`; + + for (let attempt = 0; ; attempt += 1) { + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-paperclip-cloud-stack-id": context.stackId, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + // Any response means the harness heard the ring — it does its own + // verified read-back, so even a non-2xx outcome is not retried + // beyond transient server errors. + if (response.ok || response.status < 500) return; + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn( + { status: response.status, url }, + "Cloud lifecycle doorbell got a server error; giving up", + ); + return; + } + } catch (err) { + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn({ err, url }, "Cloud lifecycle doorbell unreachable; giving up"); + return; + } + } + await sleep(RETRY_DELAYS_MS[attempt]!); + } +} diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index db8f5b77bd..e6567f2cf9 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -449,6 +449,8 @@ export const CLOUD_CONTROL_ACTIONS = [ "task-drain:read", "task-drain:start", "task-drain:stop", + "lifecycle:read", + "lifecycle:unarchive-primary", ] as const; export type CloudControlAction = (typeof CLOUD_CONTROL_ACTIONS)[number]; diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index c2b27e0ebc..b0838f14ac 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -36,6 +36,7 @@ import { } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { isCloudManagedInstance } from "./cloud-instance.js"; +import { notifyCloudOfPrimaryCompanyLifecycleChange } from "./cloud-lifecycle-sync.js"; import { MAX_ISSUE_PREFIX_ATTEMPTS, deriveIssuePrefixBase, @@ -442,10 +443,20 @@ export function companyService(db: Db) { company: enrichCompany(hydrated), reactivated: shouldLogReactivation ? { agentsRestored } : null, archiveCascade, + unarchived: willReactivate && existing.status === "archived", issuePrefixRederived, }; }); if (!result) return null; + // Post-commit, fire-and-forget, and BEFORE any finalization that + // could throw: a Cloud-pinned primary company that crossed the + // archived boundary (either direction) rings the harness so the + // stack itself can converge. The status transaction has already + // committed, so a later cascade or activity-log failure must not + // leave Cloud unaware of a company that is in fact archived. + if (result.archiveCascade || result.unarchived) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); + } if (result.issuePrefixRederived) { await logActivity(db, { companyId: id, @@ -514,7 +525,10 @@ export function companyService(db: Db) { }); if (!result) return null; + // Same doorbell rule as update(): the archive is committed, so ring + // before finalization, which can throw without undoing it. if (result.cascade) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); await finalizeArchive(id, actor, result.cascade); }