mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - On a managed instance under `enableManagedSandboxOnly`, boot points the instance default at the managed sandbox when nothing deliberate is set > - That write read the default and wrote it later without a lock. Two overlapping boots, or a boot and an admin write, could overwrite a value the other had just committed > - A revert path also reset that stamped default whenever the mode was turned off, so a platform-filled default could change again later > - A planned plugin-environment default should fill only an unset default and never change a set one, which needs every instance's default to stay set > - This pull request makes the fill one locked transaction and removes the revert, so a filled default is an ordinary set value > - The benefit is a single, race-free rule: boot fills an unset default once, and nothing changes a set default afterwards ## Linked Issues or Issue Description Refs #19551 (the plugin-environment default that builds on this rule) **Subsystem affected** Server environments: the managed environment bootstrap and the instance settings route. **Problem or motivation** `applyManagedEnvironments` read the instance default, decided, and wrote it in separate steps with no lock, so a concurrent boot or admin write could be overwritten. When `enableManagedSandboxOnly` was not declared, a revert reset a default the platform had filled. That needed a `managedDefaultStamped` marker, plus a marker clear on every admin default write. A later boot could therefore change a default that had already been set. **Proposed solution** Fill the default in one transaction that locks the instance default row and rechecks it. Fill only an unset, local or deleted default. Remove the revert and its marker, so a filled default behaves like any other set value. **Alternatives considered** Keeping the revert and adding locks to it. A revert on a mode change would still move an existing instance's default later, which a fill-only plugin default cannot tolerate. **Roadmap alignment** This narrows existing managed-environment behaviour. It adds no product surface. ## What Changed - New `server/src/services/managed-instance-default.ts`: `fillUnsetInstanceDefault(db, environmentId)`. In one transaction it creates the settings row if needed, locks the instance default row (`FOR UPDATE`), and rechecks the current value. It writes only when the default is unset, names the local environment, or names a deleted environment. Any other set default is kept. - `server/src/services/managed-environments.ts`: - Under `enableManagedSandboxOnly`, the managed sandbox fill now uses that helper, through a new `fillInstanceDefault` test seam. - The mode-off revert and the `managedDefaultStamped` marker are removed. - The unused `instanceSettings` seam option is removed, along with the `getById`, `update` and `findManagedSandboxEnvironment` methods of the environments seam. - `server/src/routes/instance-settings.ts`: an explicit default write is a plain `update` again. The marker clear and its transaction are gone. - `doc/DEVELOPING.md`: the "Managed environments" section states the fill rule. Existing rows may still carry `managedDefaultStamped` in their metadata. Nothing reads it any more, and it is not part of the managed stock hash. ## Verification - `cd server && npx vitest run src/__tests__/managed-instance-default.test.ts` (embedded Postgres): - The fill writes an unset default, including on a fresh instance with no settings row. It also writes a default that names the local environment, or one whose environment was deleted. - It never changes a default set to an ssh, plugin or other sandbox environment, and leaves a default that already names the target alone. - Controlled overlap: an uncommitted admin default write makes the fill wait, and the fill then keeps the admin's value. This test fails without the row lock. - Two overlapping fills: exactly one writes. - Real boot pass: the default is filled under managed-sandbox-only and stays the same after a boot with the mode off. A set default stays on every boot. - `npx vitest run src/services/managed-environments.test.ts src/services/managed-environments-plugin-entries.test.ts`: the fill runs only under managed-sandbox-only, with the managed sandbox row. It never runs for a plugin environment. Nothing is written when the mode is off. A failed fill does not fail the ensure. - `npx vitest run src/__tests__/instance-settings-routes.test.ts`: an explicit default write is a plain update that touches no environment row. - `npx vitest run src/__tests__/managed-plugin-environments.test.ts src/__tests__/environment-routes.test.ts src/__tests__/server-startup-feedback-export.test.ts src/__tests__/instance-settings-service.test.ts`: pass. - `pnpm --filter @paperclipai/server typecheck` passes. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. ## Risks - Behaviour change: when `enableManagedSandboxOnly` is turned off for an instance, a default the platform filled with the managed sandbox now stays. Local execution is still allowed then, but it is no longer the default unless an admin picks it. - No schema change. Stale `managedDefaultStamped` keys stay in some row metadata and are ignored. - The fill holds the instance default row lock for one short transaction at boot. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (file edits, shell, test runs). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
23 KiB
TypeScript
543 lines
23 KiB
TypeScript
import type { IdleDeadlineOwners } from "../services/idle-deadline-types.js";
|
|
import { verifiedIdleCapabilities } from "../middleware/cloud-control.js";
|
|
import { trackIdleWork } from "../services/task-admission.js";
|
|
import { Router, type Request } from "express";
|
|
import { companies, type Db } from "@paperclipai/db";
|
|
import {
|
|
patchInstanceSettingsSchema,
|
|
patchInstanceExperimentalSettingsSchema,
|
|
patchInstanceGeneralSettingsSchema,
|
|
startTaskDrainRequestSchema,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, forbidden } from "../errors.js";
|
|
import { assertAgentsReadyForEnvironmentChange } from "../services/plugin-environment-driver.js";
|
|
import {
|
|
cloudTenantPrimaryCompanyId,
|
|
getCloudStackContext,
|
|
isCloudManagedInstance,
|
|
} from "../services/cloud-instance.js";
|
|
import { getHiddenSettings } from "../services/settings-visibility.js";
|
|
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
|
|
import { readIdleLocalWork } from "../services/idle-local-work.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import {
|
|
companyService,
|
|
heartbeatService,
|
|
instanceSettingsService,
|
|
logActivity,
|
|
publishActivity,
|
|
type ActivityPublication,
|
|
} from "../services/index.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
|
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
|
|
|
|
function sameJsonValue(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
return (
|
|
Array.isArray(a)
|
|
&& Array.isArray(b)
|
|
&& a.length === b.length
|
|
&& a.every((value, i) => sameJsonValue(value, b[i]))
|
|
);
|
|
}
|
|
const aKeys = Object.keys(a);
|
|
const bKeys = new Set(Object.keys(b));
|
|
return aKeys.length === bKeys.size && aKeys.every((key) =>
|
|
bKeys.has(key) && sameJsonValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Floor writes to operator-hidden settings. Same-value writes pass so clients
|
|
* that echo a full GET response keep working (the executionMode precedent);
|
|
* only a write that would actually change a hidden setting is rejected.
|
|
*/
|
|
async function assertNoHiddenSettingChanges(
|
|
body: Record<string, unknown>,
|
|
getCurrent: () => Promise<object>,
|
|
isHiddenField: (field: string) => boolean,
|
|
) {
|
|
const hiddenKeys = Object.keys(body).filter(isHiddenField);
|
|
if (hiddenKeys.length === 0) return;
|
|
const current = (await getCurrent()) as Record<string, unknown>;
|
|
for (const key of hiddenKeys) {
|
|
if (sameJsonValue(body[key], current[key])) continue;
|
|
throw forbidden(`${key} is managed by the hosting operator on this instance`, {
|
|
code: "settings_operator_managed",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish activity events for an already-committed mutation. The audit row
|
|
* exists in the database no matter what happens here, so a publish failure
|
|
* must not turn into a route error: that would report the mutation as
|
|
* failed to the caller when it in fact succeeded. Log and swallow instead.
|
|
*/
|
|
function publishActivitiesBestEffort(publications: ActivityPublication[], action: string) {
|
|
for (const publication of publications) {
|
|
try {
|
|
publishActivity(publication);
|
|
} catch (err) {
|
|
logger.error({ err, action, companyId: publication.companyId }, "failed to publish activity event");
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertCanManageInstanceSettings(req: Request) {
|
|
if (req.actor.type !== "board") {
|
|
throw forbidden("Board access required");
|
|
}
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
|
return;
|
|
}
|
|
throw forbidden("Instance admin access required");
|
|
}
|
|
|
|
function assertCanManageExperimentalSettings(req: Request) {
|
|
if (
|
|
req.actor.type === "board"
|
|
&& req.actor.source === "cloud_tenant"
|
|
&& req.actor.canManageExperimentalSettings
|
|
) {
|
|
return;
|
|
}
|
|
assertCanManageInstanceSettings(req);
|
|
}
|
|
|
|
// A task-drain start or stop reads the live drain state, writes an audit
|
|
// transaction, and only then mutates the process-local drain state. The
|
|
// audit write is an async gap: two overlapping requests can commit their
|
|
// transactions in one order but reach the in-memory mutation in the other
|
|
// order, so a stale transition would win, the audit log would not match the
|
|
// live state, and the response for the newer request would not match what
|
|
// actually ended up live. Run each request's whole read-audit-apply
|
|
// sequence through this queue so overlapping requests execute one at a
|
|
// time, in the order they enter it: audit order and apply order then always
|
|
// agree, and each response reports exactly the state its own request
|
|
// produced.
|
|
let taskDrainTransitionQueue: Promise<void> = Promise.resolve();
|
|
|
|
function withTaskDrainTransition<T>(run: () => Promise<T>): Promise<T> {
|
|
const turn = trackIdleWork(taskDrainTransitionQueue.then(run));
|
|
// Normalize to a settled void promise for the next caller in line, so a
|
|
// rejected transition (a failed audit write, for example) cannot wedge
|
|
// every later transition behind it.
|
|
taskDrainTransitionQueue = turn.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return turn;
|
|
}
|
|
|
|
export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManager, prepareIdleDatabaseBackup?: () => Promise<boolean>, deadlineOwners?: IdleDeadlineOwners) {
|
|
const router = Router();
|
|
const svc = instanceSettingsService(db);
|
|
const environments = environmentService(db);
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: pluginWorkers });
|
|
|
|
router.get("/instance/settings", async (req, res) => {
|
|
assertBoardOrgAccess(req);
|
|
res.json(await svc.get());
|
|
});
|
|
|
|
router.patch(
|
|
"/instance/settings",
|
|
validate(patchInstanceSettingsSchema),
|
|
async (req, res) => {
|
|
assertCanManageInstanceSettings(req);
|
|
if (Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId")) {
|
|
await assertEnvironmentSelectionForCompany(
|
|
environments,
|
|
"instance",
|
|
typeof req.body.defaultEnvironmentId === "string" ? req.body.defaultEnvironmentId : null,
|
|
);
|
|
const previousId = (await svc.get()).defaultEnvironmentId;
|
|
const environmentById = async (id: unknown) => typeof id === "string" ? environments.getById(id) : null;
|
|
await assertAgentsReadyForEnvironmentChange(db, await environmentById(previousId), await environmentById(req.body.defaultEnvironmentId),
|
|
{ inheritsDefault: true });
|
|
}
|
|
// An explicit default write is an ordinary set value: boot only fills an
|
|
// unset, local or dangling default (`fillUnsetInstanceDefault`), and the
|
|
// lock it takes on this row means it never overwrites a concurrent write.
|
|
const updated = await svc.update(req.body);
|
|
const actor = getActorInfo(req);
|
|
const companyIds = await svc.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "instance.settings.updated",
|
|
entityType: "instance_settings",
|
|
entityId: updated.id,
|
|
details: {
|
|
defaultEnvironmentId: updated.defaultEnvironmentId,
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
res.json(updated);
|
|
},
|
|
);
|
|
|
|
router.get("/instance/settings/general", async (req, res) => {
|
|
// General settings (e.g. feedbackDataSharingPreference) are readable by any
|
|
// authenticated org member or instance admin. Only PATCH requires instance-admin.
|
|
assertBoardOrgAccess(req);
|
|
res.json(await svc.getGeneral());
|
|
});
|
|
|
|
router.patch(
|
|
"/instance/settings/general",
|
|
validate(patchInstanceGeneralSettingsSchema),
|
|
async (req, res) => {
|
|
assertCanManageInstanceSettings(req);
|
|
// Floor: on cloud-managed instances the execution mode is pinned by the
|
|
// platform (the execution-policy bootstrap writes it at boot). No
|
|
// instance admin — including a computed owner-admin — may change it: a
|
|
// forced provider switch would strand runs on a provider the platform
|
|
// never provisioned. Same-value writes pass so settings forms that echo
|
|
// the full general-settings object keep working. Absent and "any" both
|
|
// mean unrestricted, so they compare equal.
|
|
if (
|
|
isCloudManagedInstance() &&
|
|
Object.prototype.hasOwnProperty.call(req.body, "executionMode")
|
|
) {
|
|
const current = await svc.getGeneral();
|
|
if ((req.body.executionMode ?? "any") !== (current.executionMode ?? "any")) {
|
|
throw forbidden("executionMode is platform-managed on cloud-managed instances", {
|
|
code: "execution_mode_platform_managed",
|
|
});
|
|
}
|
|
}
|
|
const hidden = getHiddenSettings();
|
|
await assertNoHiddenSettingChanges(
|
|
req.body,
|
|
() => svc.getGeneral(),
|
|
(field) => hidden.has(`instance.general.${field}`),
|
|
);
|
|
const updated = await svc.updateGeneral(req.body);
|
|
const actor = getActorInfo(req);
|
|
const companyIds = await svc.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "instance.settings.general_updated",
|
|
entityType: "instance_settings",
|
|
entityId: updated.id,
|
|
details: {
|
|
general: updated.general,
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
res.json(updated.general);
|
|
},
|
|
);
|
|
|
|
router.get("/instance/settings/experimental", async (req, res) => {
|
|
assertBoardOrgAccess(req);
|
|
res.json(await svc.getExperimental());
|
|
});
|
|
|
|
router.patch(
|
|
"/instance/settings/experimental",
|
|
validate(patchInstanceExperimentalSettingsSchema),
|
|
async (req, res) => {
|
|
assertCanManageExperimentalSettings(req);
|
|
// Hiding the whole Experimental page floors every toggle; otherwise
|
|
// only individually hidden keys are floored.
|
|
const hidden = getHiddenSettings();
|
|
await assertNoHiddenSettingChanges(
|
|
req.body,
|
|
() => svc.getExperimental(),
|
|
(field) =>
|
|
hidden.has("instance.experimental") || hidden.has(`instance.experimental.${field}`),
|
|
);
|
|
const updated = await svc.updateExperimental(req.body);
|
|
const actor = getActorInfo(req);
|
|
const companyIds = await svc.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "instance.settings.experimental_updated",
|
|
entityType: "instance_settings",
|
|
entityId: updated.id,
|
|
details: {
|
|
experimental: updated.experimental,
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
res.json(updated.experimental);
|
|
},
|
|
);
|
|
|
|
router.get("/instance/task-drain", async (req, res) => {
|
|
assertBoardOrgAccess(req);
|
|
if (req.query.upgradeReadiness === "1") {
|
|
// This covers all companies and must not expose instance-wide work to a member.
|
|
assertCanManageInstanceSettings(req);
|
|
res.json({ ...heartbeat.getTaskDrainStatus(), upgradeReadiness: await heartbeat.readUpgradeReadiness() });
|
|
return;
|
|
}
|
|
if (req.query.idleSleepSafety === "1") {
|
|
// The report covers every company in this process. Ordinary company
|
|
// members may read process counters, but not instance-wide work state.
|
|
assertCanManageInstanceSettings(req);
|
|
const domainDeadlines = req.query.scheduledWake === "domain-deadlines-v1";
|
|
const capabilities = verifiedIdleCapabilities(req);
|
|
if (domainDeadlines && capabilities?.deadlineCatalog !== "domain_deadlines_v1") {
|
|
throw forbidden("Domain deadline reports require the signed Cloud deadline catalog");
|
|
}
|
|
const scheduledIssueMonitors = req.query.scheduledWake === "2" || domainDeadlines;
|
|
const scheduledRoutines = req.query.scheduledWake === "1" || scheduledIssueMonitors;
|
|
if (scheduledRoutines && req.actor.source !== "cloud_control") {
|
|
throw forbidden("Scheduled wake reports require Cloud control authority");
|
|
}
|
|
// A checkpoint performs platform-owned backup work. Tenant owners can
|
|
// have instance-admin elevation, but that must not grant the manual
|
|
// backup authority which managed instances deliberately withhold.
|
|
if (prepareIdleDatabaseBackup && isCloudManagedInstance() && req.actor.source !== "cloud_control") {
|
|
throw forbidden("Backup checkpoints are platform-managed on cloud-managed instances", {
|
|
code: "database_backups_platform_managed",
|
|
});
|
|
}
|
|
const idleSleepSafety = await readIdleSleepSafety(db, () => heartbeat.getTaskDrainStatus(), Date.now,
|
|
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined,
|
|
diagnostic => readIdleLocalWork({ backupCheckpoint: Boolean(prepareIdleDatabaseBackup), diagnostic,
|
|
deadlineCapability: domainDeadlines ? capabilities : undefined }),
|
|
pluginWorkers?.inspectIdleSleep?.bind(pluginWorkers), prepareIdleDatabaseBackup, scheduledRoutines, scheduledIssueMonitors,
|
|
{ deadlineOwners, incomingWake: capabilities, deadlineCapability: domainDeadlines ? capabilities : undefined,
|
|
...(req.query.idleSleepDiagnostics === "1" ? { collectDiagnostics: true } : {}) });
|
|
res.json({ ...heartbeat.getTaskDrainStatus(), idleSleepSafety });
|
|
return;
|
|
}
|
|
res.json(heartbeat.getTaskDrainStatus());
|
|
});
|
|
|
|
router.post(
|
|
"/instance/task-drain",
|
|
validate(startTaskDrainRequestSchema),
|
|
async (req, res) => {
|
|
assertCanManageInstanceSettings(req);
|
|
const actor = getActorInfo(req);
|
|
const companyIds = await svc.listCompanyIds();
|
|
const ttlMs = req.body.ttlMs ?? null;
|
|
// The whole read-audit-apply sequence runs as one queued transition
|
|
// (see withTaskDrainTransition above), so an overlapping start or
|
|
// stop cannot commit its audit row, or apply its live state, out of
|
|
// order against this one. computeTaskDrain runs inside the turn so
|
|
// startedAt reflects the moment this request actually took effect,
|
|
// not the moment it arrived and was queued behind another transition.
|
|
const drain = await withTaskDrainTransition(async () => {
|
|
const prior = heartbeat.getTaskDrainStatus();
|
|
if (prior?.ownerId || (req.body.purpose === "idle" && prior?.draining)) {
|
|
throw conflict("Another task drain is already active");
|
|
}
|
|
const computed = heartbeat.computeTaskDrain({ ttlMs,
|
|
...(req.body.purpose === "idle" ? { purpose: "idle" as const } : {}) });
|
|
// One transaction for every company's audit row, so a write that
|
|
// succeeds for one company and fails for another never leaves a
|
|
// partial activity history behind — either every company gets the
|
|
// record, or none does. The drain mutation below runs only after
|
|
// this transaction commits, so a failed write leaves the live
|
|
// drain untouched and there is no partial state to roll back.
|
|
const postCommitActivityPublications: ActivityPublication[] = [];
|
|
await db.transaction((tx) =>
|
|
Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(tx as unknown as Db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "instance.task_drain.started",
|
|
entityType: "instance_settings",
|
|
entityId: "default",
|
|
details: {
|
|
startedAt: computed.startedAt,
|
|
expiresAt: computed.expiresAt,
|
|
...(computed.ownerId ? { purpose: "idle", ownerId: computed.ownerId } : {}),
|
|
},
|
|
}, postCommitActivityPublications),
|
|
),
|
|
),
|
|
);
|
|
heartbeat.applyTaskDrain(computed);
|
|
// The audit record already committed, so a failure to publish it
|
|
// here is not a reason to undo the drain: reverting the in-memory
|
|
// state at this point would desync it from the committed row.
|
|
// Swallow a publish failure so it cannot turn a committed mutation
|
|
// into a false 500.
|
|
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.started");
|
|
return computed;
|
|
});
|
|
res.json(drain);
|
|
},
|
|
);
|
|
|
|
router.delete("/instance/task-drain", async (req, res) => {
|
|
assertCanManageInstanceSettings(req);
|
|
const actor = getActorInfo(req);
|
|
const companyIds = await svc.listCompanyIds();
|
|
// See the POST handler above for why the whole read-audit-apply
|
|
// sequence runs inside withTaskDrainTransition: it queues this stop
|
|
// behind any transition already in flight, so it cannot read a status
|
|
// an overlapping request is about to make stale, and its audit row and
|
|
// its live-state mutation always land in the same order as every other
|
|
// queued transition.
|
|
const wasActive = await withTaskDrainTransition(async () => {
|
|
const priorStatus = heartbeat.getTaskDrainStatus();
|
|
if ((priorStatus.ownerId || req.query.ownerId !== undefined) &&
|
|
(typeof req.query.ownerId !== "string" || req.query.ownerId !== priorStatus.ownerId)) {
|
|
throw conflict("Task drain ownership changed");
|
|
}
|
|
// Read wasActive once, here, and use this same value for the audit
|
|
// detail and the response body below. A TTL that expires between two
|
|
// separate reads would otherwise make the two values disagree.
|
|
const wasActive = priorStatus.draining;
|
|
// See the POST handler above for why this is one transaction, and why
|
|
// the drain mutation runs only after it commits.
|
|
const postCommitActivityPublications: ActivityPublication[] = [];
|
|
await db.transaction((tx) =>
|
|
Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(tx as unknown as Db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "instance.task_drain.stopped",
|
|
entityType: "instance_settings",
|
|
entityId: "default",
|
|
details: {
|
|
wasActive,
|
|
},
|
|
}, postCommitActivityPublications),
|
|
),
|
|
),
|
|
);
|
|
heartbeat.stopTaskDrain();
|
|
pluginWorkers?.releaseIdleSleep?.();
|
|
// See the POST handler above for why a publish failure here is
|
|
// swallowed instead of failing the route: the audit record already
|
|
// committed, so a publish failure here must not undo a drain-stop
|
|
// that is already correct in the database, and must not report the
|
|
// stop as failed when it succeeded.
|
|
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.stopped");
|
|
return wasActive;
|
|
});
|
|
if (wasActive) {
|
|
void heartbeat.resumeQueuedRunsAfterDrain().catch(err => {
|
|
logger.error({ err }, "failed to resume queued work after task drain; durable work remains queued");
|
|
});
|
|
}
|
|
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;
|
|
}
|