Files
PaperClipAI/server/src/__tests__/plugin-managed-routines.test.ts
T
DottaandPaperclip 30ff3d7c58 feat(routines): expose activity gate API (#9438)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Scheduled routines provide recurring control-plane work without
manual intervention
> - The new activity gate can suppress scheduled runs when no external
work occurred
> - The core scheduler and database support landed without a public
create/update contract
> - Agents, operators, and managed plugins need validated fields plus
discoverable semantics to opt in safely
> - This pull request exposes the activity gate through routine APIs,
revisions, plugin contracts, tests, and skill documentation
> - The benefit is backward-compatible control over idle scheduled work
without losing activity-triggered follow-up

## Linked Issues or Issue Description

- Refs #8534

## What Changed

- Added shared activity-gate policy and scope enums with create/PATCH
validation.
- Persisted activity-gate fields through routine creation, updates,
revision snapshots, pipeline snapshots, and revision restores.
- Defaulted legacy revision snapshots during restore and added
regression coverage for pre-field snapshots.
- Extended managed-plugin routine declarations, production
reconciliation, and the SDK test harness to preserve non-default gate
settings.
- Added end-to-end API coverage for create/PATCH/list/detail
round-trips, defaults, and invalid enum rejection.
- Documented schedule-only semantics, activity windows,
own-run/read-action exclusions, scopes, and an hourly quiet-night
watcher example.

## Verification

- `pnpm exec vitest run packages/shared/src/validators/routine.test.ts
server/src/__tests__/routines-service.test.ts
server/src/__tests__/routines-e2e.test.ts`
- `pnpm exec vitest run packages/shared/src/validators/plugin.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/routines-service.test.ts -t 'activity
gate|preserves declared activity gate settings|resolves routine agent
and project refs'`
- `pnpm exec vitest run ui/src/lib/workspace-routines.test.ts
ui/src/pages/Routines.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/plugin-sdk typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- GitHub CI: all final-head checks green; Storybook visual regression
skipped by path rules.
- Greptile: 5/5 with no unresolved review threads.

## Risks

- Low risk: defaults remain `always` and `company`, preserving existing
routine behavior and old revision snapshots.
- Managed plugin manifests can now declare the same validated gate
settings as the public routine API; omitted values retain core defaults.
- Revision snapshots now include the new fields so policy changes are
not lost or treated as no-ops during restore.

> For core feature work, checked `ROADMAP.md`: this extends the existing
Scheduled Routines roadmap item and does not duplicate a separate
planned capability.

## Model Used

- OpenAI GPT-5.5 via Codex CLI, with repository tool use and code
execution; context-window size was not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-24 16:47:24 -05:00

261 lines
8.9 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
companies,
createDb,
documentRevisions,
documents,
issues,
pluginManagedResources,
plugins,
projects,
routineDocuments,
routineRuns,
routineTriggers,
routines,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { routineService } from "../services/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-routines-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Routines Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
}],
projects: [{
projectKey: "operations",
displayName: "Plugin Operations",
description: "Plugin operation inspection",
status: "in_progress",
}],
routines: [{
routineKey: "nightly-lint",
title: "Nightly lint",
description: "Lint plugin state",
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
projectRef: { resourceKind: "project", resourceKey: "operations" },
status: "active",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
activityGatePolicy: "require_external_activity",
activityGateScope: "project",
triggers: [{
kind: "schedule",
label: "Nightly",
cronExpression: "0 3 * * *",
timezone: "UTC",
}],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
},
}],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed routines", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routineDocuments);
await db.delete(routines);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(agents);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
const companyId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
defaultResponsibleUserId: "responsible-user",
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-routines-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("resolves routine agent and project refs by stable managed keys", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(created.status).toBe("created");
expect(created.routine).toMatchObject({
title: "Nightly lint",
assigneeAgentId: agent.agentId,
projectId: project.projectId,
activityGatePolicy: "require_external_activity",
activityGateScope: "project",
managedByPlugin: expect.objectContaining({
pluginKey: "paperclip.managed-routines-test",
resourceKind: "routine",
resourceKey: "nightly-lint",
}),
});
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
expect(trigger).toMatchObject({
kind: "schedule",
cronExpression: "0 3 * * *",
timezone: "UTC",
});
});
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(missing.status).toBe("missing_refs");
expect(missing.missingRefs).toEqual([
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
]);
const [agent] = await db.insert(agents).values({
companyId,
name: "Operator-selected maintainer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning();
const [project] = await db.insert(projects).values({
companyId,
name: "Operator-selected project",
status: "in_progress",
}).returning();
const repaired = await services.routines.managedReconcile({
companyId,
routineKey: "nightly-lint",
assigneeAgentId: agent.id,
projectId: project.id,
});
expect(repaired.status).toBe("created");
expect(repaired.routine).toMatchObject({
assigneeAgentId: agent.id,
projectId: project.id,
});
await db
.update(routines)
.set({ title: "Operator renamed lint", updatedAt: new Date() })
.where(eq(routines.id, repaired.routineId!));
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.routine?.title).toBe("Operator renamed lint");
});
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
expect(run.status).toBe("issue_created");
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
expect(issue).toMatchObject({
originKind: "plugin:paperclip.managed-routines-test:operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
projectId: project.projectId,
assigneeAgentId: agent.agentId,
});
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
reason: "issue_assigned",
}));
});
});