mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Add Reflection Coach routine controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f2b71590cc
commit
67db22407a
12 files changed
+862
-32
No files matched your search
@@ -98,7 +98,17 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
function boardActor() {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown> = boardActor()) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
@@ -106,13 +116,7 @@ async function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
@@ -166,6 +170,21 @@ function makeAgent() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeReflectionCoachAgent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...makeAgent(),
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Reflection Coach",
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -259,6 +278,79 @@ describe("agent instructions bundle routes", () => {
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies non-privileged agents from reading peer instructions bundles", async () => {
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "agent-reader") {
|
||||
return {
|
||||
...makeAgent(),
|
||||
id: "agent-reader",
|
||||
name: "Reader",
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
return makeAgent();
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-reader",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("Missing permission");
|
||||
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows agents to read their own instructions bundles", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the built-in Reflection Coach to read peer instructions bundles", async () => {
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "coach-agent") {
|
||||
return makeReflectionCoachAgent({ id: "coach-agent" });
|
||||
}
|
||||
return makeAgent();
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "coach-agent",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
|
||||
.query({ path: "AGENTS.md" }),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
"AGENTS.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a bundle file and persists compatibility config", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
|
||||
@@ -7,6 +7,7 @@ const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
@@ -15,9 +16,13 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
provision: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
enableRoutineSchedule: vi.fn(),
|
||||
disableRoutineSchedule: vi.fn(),
|
||||
runRoutine: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -100,11 +105,16 @@ describe("built-in agent routes", () => {
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.decide.mockResolvedValue(allowDecision());
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: true });
|
||||
mockBuiltInAgentService.list.mockResolvedValue([builtInState()]);
|
||||
mockBuiltInAgentService.get.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.ensure.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.provision.mockResolvedValue({ state: builtInState(), approval: null });
|
||||
mockBuiltInAgentService.reset.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.enableRoutineSchedule.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.disableRoutineSchedule.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.runRoutine.mockResolvedValue({ id: "routine-run-1", source: "manual", status: "queued" });
|
||||
});
|
||||
|
||||
it("lists built-in agent state for actors with company access", async () => {
|
||||
@@ -232,6 +242,120 @@ describe("built-in agent routes", () => {
|
||||
expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables a built-in routine schedule through the board tasks:assign gate", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/enable`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith(companyId, "board-user", "tasks:assign");
|
||||
expect(mockBuiltInAgentService.enableRoutineSchedule).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "built_in_agent.routine_schedule_enabled",
|
||||
entityId: agentId,
|
||||
details: expect.objectContaining({ routineKey: "recent-agent-reflection" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("disables a built-in routine schedule", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/disable`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockBuiltInAgentService.disableRoutineSchedule).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers a built-in routine manual run", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(res.body).toMatchObject({ id: "routine-run-1", source: "manual" });
|
||||
expect(mockBuiltInAgentService.runRoutine).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "built_in_agent.routine_run_triggered",
|
||||
details: expect.objectContaining({ routineRunId: "routine-run-1" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("denies built-in routine controls when tasks:assign is not allowed", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies agent actors from controlling built-in routines", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "manager-agent",
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("Only board operators can control built-in routines.");
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns pending hire approvals instead of provisioning immediately when company policy requires it", async () => {
|
||||
const approval = {
|
||||
id: "approval-1",
|
||||
@@ -288,7 +412,7 @@ describe("built-in agent routes", () => {
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs");
|
||||
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
companySkillVersions,
|
||||
companySkills,
|
||||
createDb,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
routines,
|
||||
routineTriggers,
|
||||
@@ -59,6 +61,8 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issues);
|
||||
await db.delete(builtInManagedResources);
|
||||
await db.delete(companySkillVersions);
|
||||
await db.delete(companySkills);
|
||||
@@ -444,11 +448,14 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
});
|
||||
expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
|
||||
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
|
||||
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus]).slice(0, 2)).toEqual([
|
||||
["instructions", "stock_current"],
|
||||
["skill", "stock_current"],
|
||||
["routine", "stock_current"],
|
||||
]);
|
||||
expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
resourceId: expect.any(String),
|
||||
scheduleEnabled: false,
|
||||
});
|
||||
|
||||
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
|
||||
@@ -708,6 +715,106 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
expect(grants.map((grant) => grant.permissionKey)).not.toContain("tasks:assign");
|
||||
});
|
||||
|
||||
it("controls the Reflection Coach routine schedule without enabling it by default", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await agentService(db).create(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const builtIns = builtInAgentService(db);
|
||||
const created = await builtIns.ensure(companyId, "reflection-coach");
|
||||
expect(created.status).toBe("paused");
|
||||
expect(created.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
scheduleEnabled: false,
|
||||
});
|
||||
|
||||
const enabled = await builtIns.enableRoutineSchedule(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ userId: "board-user" },
|
||||
);
|
||||
expect(enabled.status).toBe("needs_setup");
|
||||
expect(enabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
scheduleEnabled: true,
|
||||
});
|
||||
const [enabledRoutine] = await db.select().from(routines).where(eq(routines.companyId, companyId));
|
||||
const [enabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, enabledRoutine!.id));
|
||||
expect(enabledRoutine).toMatchObject({ status: "active" });
|
||||
expect(enabledTrigger).toMatchObject({ enabled: true });
|
||||
|
||||
const disabled = await builtIns.disableRoutineSchedule(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ userId: "board-user" },
|
||||
);
|
||||
expect(disabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
scheduleEnabled: false,
|
||||
});
|
||||
const [disabledRoutine] = await db.select().from(routines).where(eq(routines.id, enabledRoutine!.id));
|
||||
const [disabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.id, enabledTrigger!.id));
|
||||
expect(disabledRoutine).toMatchObject({ status: "paused" });
|
||||
expect(disabledTrigger).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it("surfaces pending Reflection Coach proposal interactions on the routine resource", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await agentService(db).create(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const created = await builtInAgentService(db).ensure(companyId, "reflection-coach");
|
||||
const proposalIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: proposalIssueId,
|
||||
companyId,
|
||||
title: "Review Reflection Coach proposal",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
identifier: `${issuePrefix(companyId)}-42`,
|
||||
issueNumber: 42,
|
||||
assigneeAgentId: created.agentId,
|
||||
createdByAgentId: created.agentId,
|
||||
});
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Review proposed coaching change",
|
||||
summary: "Accept or reject the proposed update.",
|
||||
createdByAgentId: created.agentId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Accept the proposed coaching change?",
|
||||
acceptLabel: "Accept",
|
||||
rejectLabel: "Reject",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
|
||||
|
||||
expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
pendingUpdateInteractionId: interactionId,
|
||||
pendingUpdateIssueId: proposalIssueId,
|
||||
pendingUpdateIssueIdentifier: `${issuePrefix(companyId)}-42`,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Reflection Coach stock drift until explicit reset", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const created = await builtInAgentService(db).ensure(companyId, "reflection-coach");
|
||||
@@ -742,6 +849,6 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
stockStatus: "operator_modified",
|
||||
resetAvailable: true,
|
||||
});
|
||||
expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("built-in Reflection Coach");
|
||||
expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("You are Reflection Coach");
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,7 @@ import { recoveryService } from "../services/recovery/service.js";
|
||||
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -832,7 +833,7 @@ export function agentRoutes(
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") {
|
||||
await assertCanReadConfigurations(req, targetAgent.companyId);
|
||||
@@ -844,6 +845,22 @@ export function agentRoutes(
|
||||
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (actorAgent.id === targetAgent.id) return;
|
||||
|
||||
const builtInMarker = readBuiltInAgentMarker(actorAgent.metadata);
|
||||
if (builtInMarker?.key === "reflection-coach") {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
targetAgent.companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
if (allowedByGrant || canCreateAgents(actorAgent)) return;
|
||||
|
||||
throw forbidden("Missing permission: can create agents");
|
||||
}
|
||||
|
||||
function assertKnownAdapterType(type: string | null | undefined): string {
|
||||
|
||||
@@ -9,6 +9,26 @@ import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import type { BuiltInAgentState } from "../services/built-in-agents.js";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function formatScheduleLabel(trigger: { cronExpression: string; timezone: string } | undefined) {
|
||||
if (!trigger) return "Weekly schedule";
|
||||
const parts = trigger.cronExpression.trim().split(/\s+/);
|
||||
const [minute, hour, , , dayOfWeek] = parts;
|
||||
const weekdayIndex = dayOfWeek ? Number(dayOfWeek) : Number.NaN;
|
||||
if (
|
||||
parts.length === 5
|
||||
&& /^\d+$/.test(minute ?? "")
|
||||
&& /^\d+$/.test(hour ?? "")
|
||||
&& Number.isInteger(weekdayIndex)
|
||||
&& weekdayIndex >= 0
|
||||
&& weekdayIndex < WEEKDAY_LABELS.length
|
||||
) {
|
||||
return `Weekly · ${WEEKDAY_LABELS[weekdayIndex]} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${trigger.timezone}`;
|
||||
}
|
||||
return `Weekly · ${trigger.timezone}`;
|
||||
}
|
||||
|
||||
function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentState {
|
||||
const definition = {
|
||||
...state.definition,
|
||||
@@ -32,6 +52,7 @@ function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentStat
|
||||
title: state.definition.bundle.routine.title,
|
||||
status: state.definition.bundle.routine.status,
|
||||
triggerCount: state.definition.bundle.routine.triggers.length,
|
||||
scheduleLabel: formatScheduleLabel(state.definition.bundle.routine.triggers[0]),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@@ -72,15 +93,36 @@ export function builtInAgentRoutes(db: Db) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanControlBuiltInRoutine(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Only board operators can control built-in routines.");
|
||||
}
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: tasks:assign");
|
||||
}
|
||||
}
|
||||
|
||||
async function logBuiltInAgentMutation(
|
||||
req: Request,
|
||||
input: {
|
||||
companyId: string;
|
||||
action: "built_in_agent.provision_requested" | "built_in_agent.reconcile" | "built_in_agent.reset" | "approval.created";
|
||||
input: {
|
||||
companyId: string;
|
||||
action:
|
||||
| "built_in_agent.provision_requested"
|
||||
| "built_in_agent.reconcile"
|
||||
| "built_in_agent.reset"
|
||||
| "built_in_agent.routine_schedule_enabled"
|
||||
| "built_in_agent.routine_schedule_disabled"
|
||||
| "built_in_agent.routine_run_triggered"
|
||||
| "approval.created";
|
||||
key: string;
|
||||
agentId: string | null;
|
||||
status: string;
|
||||
approvalId?: string | null;
|
||||
routineKey?: string | null;
|
||||
routineRunId?: string | null;
|
||||
},
|
||||
) {
|
||||
const actor = getActorInfo(req);
|
||||
@@ -97,6 +139,8 @@ export function builtInAgentRoutes(db: Db) {
|
||||
key: input.key,
|
||||
status: input.status,
|
||||
approvalId: input.approvalId ?? null,
|
||||
routineKey: input.routineKey ?? null,
|
||||
routineRunId: input.routineRunId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -184,5 +228,93 @@ export function builtInAgentRoutes(db: Db) {
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/enable",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
const current = await svc.get(companyId, key);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const state = await svc.enableRoutineSchedule(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_schedule_enabled",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
routineKey,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/disable",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
const current = await svc.get(companyId, key);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const state = await svc.disableRoutineSchedule(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_schedule_disabled",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
routineKey,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/run",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
const current = await svc.get(companyId, key);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const run = await svc.runRoutine(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_run_triggered",
|
||||
key,
|
||||
agentId: current.agentId,
|
||||
status: current.status,
|
||||
routineKey,
|
||||
routineRunId: run.id,
|
||||
});
|
||||
res.status(202).json(run);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
updateAgentInstructionsBundleSchema,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
createAgentKeySchema,
|
||||
builtInAgentEmptyMutationSchema,
|
||||
builtInAgentProvisionSchema,
|
||||
wakeAgentSchema,
|
||||
resetAgentSessionSchema,
|
||||
@@ -1189,6 +1190,32 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
for (const route of [
|
||||
["enable", "Enable a built-in routine schedule", 200],
|
||||
["disable", "Disable a built-in routine schedule", 200],
|
||||
["run", "Run a built-in routine once", 202],
|
||||
] as const) {
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: `/api/companies/{companyId}/built-in-agents/{key}/routines/{routineKey}/${route[0]}`,
|
||||
tags: ["agents"],
|
||||
summary: route[1],
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), key: z.string(), routineKey: z.string() }),
|
||||
body: jsonBody(builtInAgentEmptyMutationSchema),
|
||||
},
|
||||
responses: {
|
||||
[route[2]]: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/agents",
|
||||
|
||||
@@ -3,9 +3,9 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { and, desc, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, builtInManagedResources, companies, routines, routineTriggers } from "@paperclipai/db";
|
||||
import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
|
||||
import type { Agent, Approval, CompanySkill, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
@@ -82,6 +82,10 @@ export interface BuiltInManagedResourceState {
|
||||
updateAvailable: boolean;
|
||||
resetAvailable: boolean;
|
||||
changedFiles?: string[];
|
||||
scheduleEnabled?: boolean;
|
||||
pendingUpdateInteractionId?: string | null;
|
||||
pendingUpdateIssueId?: string | null;
|
||||
pendingUpdateIssueIdentifier?: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentBundleDefinition {
|
||||
@@ -313,6 +317,10 @@ function stockState(input: {
|
||||
currentHash: string | null;
|
||||
bindingStockHash: string | null;
|
||||
changedFiles?: string[];
|
||||
scheduleEnabled?: boolean;
|
||||
pendingUpdateInteractionId?: string | null;
|
||||
pendingUpdateIssueId?: string | null;
|
||||
pendingUpdateIssueIdentifier?: string | null;
|
||||
}): BuiltInManagedResourceState {
|
||||
const status = resourceStatus({
|
||||
resourceId: input.resourceId,
|
||||
@@ -331,6 +339,14 @@ function stockState(input: {
|
||||
updateAvailable: status === "stock_update_available" || status === "operator_modified",
|
||||
resetAvailable: status !== "stock_current",
|
||||
...(input.changedFiles && input.changedFiles.length > 0 ? { changedFiles: input.changedFiles } : {}),
|
||||
...(input.scheduleEnabled !== undefined ? { scheduleEnabled: input.scheduleEnabled } : {}),
|
||||
...(input.pendingUpdateInteractionId !== undefined
|
||||
? { pendingUpdateInteractionId: input.pendingUpdateInteractionId }
|
||||
: {}),
|
||||
...(input.pendingUpdateIssueId !== undefined ? { pendingUpdateIssueId: input.pendingUpdateIssueId } : {}),
|
||||
...(input.pendingUpdateIssueIdentifier !== undefined
|
||||
? { pendingUpdateIssueIdentifier: input.pendingUpdateIssueIdentifier }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -812,7 +828,6 @@ export function builtInAgentService(db: Db) {
|
||||
return stockHash({
|
||||
title: routine.title,
|
||||
description: routine.description,
|
||||
status: routine.status,
|
||||
priority: routine.priority,
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
@@ -820,7 +835,6 @@ export function builtInAgentService(db: Db) {
|
||||
triggers: triggers.map((trigger) => ({
|
||||
kind: trigger.kind,
|
||||
label: trigger.label,
|
||||
enabled: trigger.enabled,
|
||||
cronExpression: trigger.cronExpression,
|
||||
timezone: trigger.timezone,
|
||||
})),
|
||||
@@ -854,6 +868,73 @@ export function builtInAgentService(db: Db) {
|
||||
return { binding, routine, triggers };
|
||||
}
|
||||
|
||||
function routineScheduleEnabled(routine: Routine | null, triggers: RoutineTrigger[]) {
|
||||
return Boolean(
|
||||
routine?.status === "active"
|
||||
&& triggers.some((trigger) => trigger.kind === "schedule" && trigger.enabled),
|
||||
);
|
||||
}
|
||||
|
||||
async function pendingUpdateProposal(agent: Agent | null) {
|
||||
if (!agent) return null;
|
||||
return db
|
||||
.select({
|
||||
interactionId: issueThreadInteractions.id,
|
||||
issueId: issues.id,
|
||||
issueIdentifier: issues.identifier,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, agent.companyId),
|
||||
eq(issueThreadInteractions.createdByAgentId, agent.id),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function withRoutineControls(
|
||||
state: BuiltInManagedResourceState,
|
||||
input: {
|
||||
routine: Routine | null;
|
||||
triggers: RoutineTrigger[];
|
||||
proposal: Awaited<ReturnType<typeof pendingUpdateProposal>>;
|
||||
},
|
||||
) {
|
||||
if (state.resourceKind !== "routine") return state;
|
||||
return {
|
||||
...state,
|
||||
scheduleEnabled: routineScheduleEnabled(input.routine, input.triggers),
|
||||
pendingUpdateInteractionId: input.proposal?.interactionId ?? null,
|
||||
pendingUpdateIssueId: input.proposal?.issueId ?? null,
|
||||
pendingUpdateIssueIdentifier: input.proposal?.issueIdentifier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireBundleRoutine(companyId: string, key: string, routineKey: string) {
|
||||
const definition = requireBuiltInAgentDefinition(key);
|
||||
if (!definition.bundle || definition.bundle.routine.routineKey !== routineKey) {
|
||||
throw notFound("Built-in routine not found");
|
||||
}
|
||||
await ensureCompany(companyId);
|
||||
const agent = await findSingleAgent(companyId, definition);
|
||||
if (!agent) throw notFound("Built-in agent is not provisioned");
|
||||
const current = await getRoutineByBinding(companyId, definition);
|
||||
if (!current.routine) throw notFound("Built-in routine not found");
|
||||
const schedule = current.triggers.find((trigger) => trigger.kind === "schedule") ?? null;
|
||||
return { definition, agent, routine: current.routine, triggers: current.triggers, schedule };
|
||||
}
|
||||
|
||||
async function ensureBuiltInAgentAssignable(agent: Agent) {
|
||||
if (agent.status !== "paused") return agent;
|
||||
const resumed = await agentSvc.resume(agent.id);
|
||||
if (!resumed) throw notFound("Built-in agent not found");
|
||||
return resumed as Agent;
|
||||
}
|
||||
|
||||
async function createOrResetRoutine(agent: Agent, definition: BuiltInAgentDefinition, existing: Routine | null, mode: "reconcile" | "reset") {
|
||||
const routine = definition.bundle!.routine;
|
||||
const actor = { agentId: null, userId: "built-in-bundles" };
|
||||
@@ -977,7 +1058,8 @@ export function builtInAgentService(db: Db) {
|
||||
triggerCount: bundle.routine.triggers.length,
|
||||
},
|
||||
});
|
||||
return stockState({
|
||||
const next = await getRoutineByBinding(agent.companyId, definition);
|
||||
return withRoutineControls(stockState({
|
||||
resourceKind: "routine",
|
||||
resourceKey: bundle.routine.routineKey,
|
||||
resourceId: nextRoutine.id,
|
||||
@@ -985,6 +1067,10 @@ export function builtInAgentService(db: Db) {
|
||||
latestStockHash: stock,
|
||||
currentHash: shouldWrite ? stock : currentHash,
|
||||
bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
|
||||
}), {
|
||||
routine: next.routine,
|
||||
triggers: next.triggers,
|
||||
proposal: await pendingUpdateProposal(agent),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1002,6 +1088,7 @@ export function builtInAgentService(db: Db) {
|
||||
: await skillSvc.getByKey(companyId, bundle.skill.canonicalKey);
|
||||
const skillFiles = await getCurrentSkillFiles(companyId, skill, bundle);
|
||||
const { routine, triggers } = await getRoutineByBinding(companyId, definition);
|
||||
const proposal = await pendingUpdateProposal(agent);
|
||||
const instructionHash = Object.values(instructionFiles).every((value) => value !== null)
|
||||
? stockHash(instructionFiles)
|
||||
: null;
|
||||
@@ -1045,7 +1132,7 @@ export function builtInAgentService(db: Db) {
|
||||
bindingStockHash: skillBinding?.stockHash ?? null,
|
||||
changedFiles: changedFileList(skillFiles, bundle.skill.files),
|
||||
}),
|
||||
stockState({
|
||||
withRoutineControls(stockState({
|
||||
resourceKind: "routine",
|
||||
resourceKey: bundle.routine.routineKey,
|
||||
resourceId: routine?.id ?? null,
|
||||
@@ -1053,7 +1140,7 @@ export function builtInAgentService(db: Db) {
|
||||
latestStockHash: routineDefaultsHash(bundle.routine, bundle.routine.triggers),
|
||||
currentHash: routineHash,
|
||||
bindingStockHash: routineBinding?.stockHash ?? null,
|
||||
}),
|
||||
}), { routine, triggers, proposal }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1327,6 +1414,37 @@ export function builtInAgentService(db: Db) {
|
||||
return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources);
|
||||
}
|
||||
|
||||
async function setRoutineSchedule(
|
||||
companyId: string,
|
||||
key: string,
|
||||
routineKey: string,
|
||||
enabled: boolean,
|
||||
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
|
||||
) {
|
||||
const { definition, agent, routine, schedule } = await requireBundleRoutine(companyId, key, routineKey);
|
||||
if (!schedule) throw notFound("Built-in routine schedule not found");
|
||||
if (enabled) {
|
||||
await ensureBuiltInAgentAssignable(agent);
|
||||
await routineSvc.update(routine.id, { status: "active" }, actor);
|
||||
await routineSvc.updateTrigger(schedule.id, { enabled: true }, actor);
|
||||
} else {
|
||||
await routineSvc.updateTrigger(schedule.id, { enabled: false }, actor);
|
||||
await routineSvc.update(routine.id, { status: "paused" }, actor);
|
||||
}
|
||||
return state(definition, await agentSvc.getById(agent.id) as Agent);
|
||||
}
|
||||
|
||||
async function runRoutine(
|
||||
companyId: string,
|
||||
key: string,
|
||||
routineKey: string,
|
||||
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
|
||||
) {
|
||||
const { agent, routine } = await requireBundleRoutine(companyId, key, routineKey);
|
||||
await ensureBuiltInAgentAssignable(agent);
|
||||
return routineSvc.runRoutine(routine.id, { source: "manual" }, actor);
|
||||
}
|
||||
|
||||
async function requireBuiltInAgent(companyId: string, key: string): Promise<RequiredBuiltInAgent> {
|
||||
const current = await get(companyId, key);
|
||||
if (!current.agent) throw builtInAgentNotConfiguredError(current);
|
||||
@@ -1356,6 +1474,19 @@ export function builtInAgentService(db: Db) {
|
||||
provision,
|
||||
list,
|
||||
reset,
|
||||
enableRoutineSchedule: (
|
||||
companyId: string,
|
||||
key: string,
|
||||
routineKey: string,
|
||||
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
|
||||
) => setRoutineSchedule(companyId, key, routineKey, true, actor),
|
||||
disableRoutineSchedule: (
|
||||
companyId: string,
|
||||
key: string,
|
||||
routineKey: string,
|
||||
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
|
||||
) => setRoutineSchedule(companyId, key, routineKey, false, actor),
|
||||
runRoutine,
|
||||
requireBuiltInAgent,
|
||||
reconcileDefinitionDefaults,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user