mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## 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>
261 lines
8.9 KiB
TypeScript
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",
|
|
}));
|
|
});
|
|
});
|