feat(cloud): sync primary-company archive state with the Cloud control plane (#13837)

## 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
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-22 17:49:15 -07:00
1 parent be6f49a425
commit 8c6cc7dccf
12 files changed
+585 -18

No files matched your search

@@ -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)
@@ -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);
});
});
+42 -1
View File
@@ -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();
@@ -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<unknown>)
// 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();
});
});
});
+2 -5
View File
@@ -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 {
+18 -10
View File
@@ -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<string, CloudControlAction> = {
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<string, Record<string, CloudControlAction>> = {
"/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;
}
+80 -2
View File
@@ -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;
}
+18
View File
@@ -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({
+16
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
export type CloudInstanceEnv = Record<string, string | undefined>;
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)}`;
}
+103
View File
@@ -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<void>;
};
/**
* 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<void> {
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<void>((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]!);
}
}
@@ -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];
+14
View File
@@ -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);
}