diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 6d36a820d32..c2f7dd65289 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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 = boardActor()) { const [{ agentRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/agents.js"), vi.importActual("../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 = {}) { + 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") diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts index c56de9ebbdc..acae6704dc3 100644 --- a/server/src/__tests__/built-in-agent-routes.test.ts +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -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", diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 82d4016eec7..a3e97681baa 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -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"); }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index beb6c712e60..9530996b2b0 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 { diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts index 9299ab5232a..f8b7ebecb55 100644 --- a/server/src/routes/built-in-agents.ts +++ b/server/src/routes/built-in-agents.ts @@ -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; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 1e01468e899..f8e73d13322 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 9837118f3c4..ddd53a976cf 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -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>; + }, + ) { + 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 { 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, }; diff --git a/ui/src/api/builtInAgents.ts b/ui/src/api/builtInAgents.ts index 5e6bf1b4814..3210402f80e 100644 --- a/ui/src/api/builtInAgents.ts +++ b/ui/src/api/builtInAgents.ts @@ -39,6 +39,7 @@ export interface BuiltInAgentBundleMeta { title: string; status: "active" | "paused"; triggerCount: number; + scheduleLabel?: string; }; } @@ -83,6 +84,13 @@ export interface BuiltInManagedResourceState { /** True when the resource has drifted and can be reset to the default. */ resetAvailable: boolean; changedFiles?: string[]; + /** True when the managed weekly schedule is active and can create background work. */ + scheduleEnabled?: boolean; + /** Pending request_confirmation for a Reflection Coach update proposal, when one exists. */ + pendingUpdateInteractionId?: string | null; + /** Issue containing the pending proposal interaction. */ + pendingUpdateIssueId?: string | null; + pendingUpdateIssueIdentifier?: string | null; } export interface BuiltInAgentState { @@ -148,4 +156,10 @@ export const builtInAgentsApi = { */ reconcile: (companyId: string, key: string) => api.post(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}), + runRoutine: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}), + enableRoutineSchedule: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}), + disableRoutineSchedule: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}), }; diff --git a/ui/src/components/BuiltInBundlePanel.test.tsx b/ui/src/components/BuiltInBundlePanel.test.tsx index f37fee67e70..8a6db316fea 100644 --- a/ui/src/components/BuiltInBundlePanel.test.tsx +++ b/ui/src/components/BuiltInBundlePanel.test.tsx @@ -20,6 +20,7 @@ vi.mock("@/lib/router", () => ({ function resource( resourceKind: BuiltInManagedResourceState["resourceKind"], stockStatus: BuiltInManagedResourceStockStatus, + overrides: Partial = {}, ): BuiltInManagedResourceState { return { resourceKind, @@ -31,6 +32,7 @@ function resource( stockStatus, updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified", resetAvailable: stockStatus !== "stock_current", + ...overrides, }; } @@ -63,6 +65,7 @@ function makeState( title: "Recent agent reflection", status: "paused", triggerCount: 1, + scheduleLabel: "Weekly · Mon 09:00 UTC", }, }, }, @@ -96,6 +99,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => { function render(state: BuiltInAgentState, handlers: Partial<{ onConfigure: () => void; onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void; + onRunRoutine: (routineKey: string) => void; + onEnableSchedule: (routineKey: string) => void; + onDisableSchedule: (routineKey: string) => void; }> = {}) { root = createRoot(container); flushSync(() => { @@ -105,6 +111,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => { agentRef="reflectioncoach" onConfigure={handlers.onConfigure ?? (() => {})} onResetResource={handlers.onResetResource ?? (() => {})} + onRunRoutine={handlers.onRunRoutine ?? (() => {})} + onEnableSchedule={handlers.onEnableSchedule ?? (() => {})} + onDisableSchedule={handlers.onDisableSchedule ?? (() => {})} />, ); }); @@ -135,6 +144,36 @@ describe("BuiltInBundlePanel (PAP-13099)", () => { expect(text).toContain("costs zero tokens by default"); expect(text).toContain("Schedule off"); expect(text).toContain("Ready"); + expect(text).toContain("Run once"); + expect(text).toContain("Enable weekly"); + }); + + it("shows the active weekly schedule and disable action when enabled", () => { + render(makeState("ready", [ + resource("skill", "stock_current"), + resource("instructions", "stock_current"), + resource("routine", "stock_current", { scheduleEnabled: true }), + ])); + const text = container.textContent ?? ""; + expect(text).toContain("Weekly · Mon 09:00 UTC"); + expect(text).toContain("can create background work"); + expect(text).toContain("Disable schedule"); + expect(text).not.toContain("Enable weekly"); + }); + + it("links to a pending proposal interaction when the routine resource reports one", () => { + render(makeState("ready", [ + resource("skill", "stock_current"), + resource("instructions", "stock_current"), + resource("routine", "stock_current", { + pendingUpdateInteractionId: "interaction-1", + pendingUpdateIssueId: "issue-1", + pendingUpdateIssueIdentifier: "PAP-42", + }), + ])); + const link = Array.from(container.querySelectorAll("a")).find((anchor) => anchor.textContent === "Review proposal"); + expect(container.textContent).toContain("Proposal pending"); + expect(link?.getAttribute("href")).toBe("/issues/PAP-42#interaction-interaction-1"); }); it("shows Needs setup for the adapter when the agent is not configured yet", () => { diff --git a/ui/src/components/BuiltInBundlePanel.tsx b/ui/src/components/BuiltInBundlePanel.tsx index 4d2c5e97992..a65c4d4c2f9 100644 --- a/ui/src/components/BuiltInBundlePanel.tsx +++ b/ui/src/components/BuiltInBundlePanel.tsx @@ -134,6 +134,42 @@ function ResourceActionButton({ ); } +function ConfirmActionButton({ + title, + body, + triggerLabel, + confirmLabel, + pending, + onConfirm, +}: { + title: string; + body: string; + triggerLabel: string; + confirmLabel: string; + pending: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + {title} + {body} + + + Cancel + {confirmLabel} + + + + ); +} + interface BundleRowProps { label: string; secondary?: string; @@ -191,8 +227,15 @@ export interface BuiltInBundlePanelProps { onConfigure: () => void; /** Scoped reset for one resource (apply update / reset drift / recreate). */ onResetResource: (kind: BuiltInManagedResourceKind) => void; + /** Trigger the managed routine once without enabling its weekly schedule. */ + onRunRoutine?: (routineKey: string) => void; + /** Enable the managed routine's weekly schedule. */ + onEnableSchedule?: (routineKey: string) => void; + /** Disable the managed routine's weekly schedule. */ + onDisableSchedule?: (routineKey: string) => void; /** The resource kind whose reset is currently in flight, if any. */ resettingResource?: BuiltInManagedResourceKind | null; + routineActionPending?: "run" | "enable" | "disable" | null; className?: string; } @@ -201,7 +244,11 @@ export function BuiltInBundlePanel({ agentRef, onConfigure, onResetResource, + onRunRoutine, + onEnableSchedule, + onDisableSchedule, resettingResource = null, + routineActionPending = null, className, }: BuiltInBundlePanelProps) { const { status, definition, resources } = state; @@ -224,6 +271,13 @@ export function BuiltInBundlePanel({ const skill = findResource(resources, "skill"); const instructions = findResource(resources, "instructions"); const routine = findResource(resources, "routine"); + const scheduleEnabled = routine?.scheduleEnabled === true; + const routineKey = bundle.routine.routineKey; + const scheduleLabel = bundle.routine.scheduleLabel ?? "Weekly schedule"; + const proposalIssueRef = routine?.pendingUpdateIssueIdentifier ?? routine?.pendingUpdateIssueId ?? null; + const proposalHref = proposalIssueRef && routine?.pendingUpdateInteractionId + ? `/issues/${proposalIssueRef}#interaction-${routine.pendingUpdateInteractionId}` + : null; const renderResourceRow = ( kind: BuiltInManagedResourceKind, @@ -303,24 +357,78 @@ export function BuiltInBundlePanel({ secondary={bundle.routine.title} chips={ <> - + {routine && driftVariant(routine) && ( )} } - detail="Nothing runs until you enable the weekly schedule — it costs zero tokens by default." + detail={ + scheduleEnabled + ? "The weekly schedule is enabled and can create background work." + : "Nothing runs until you enable the weekly schedule — it costs zero tokens by default." + } actions={ - routine && driftVariant(routine) ? ( - onResetResource("routine")} - pending={resettingResource === "routine"} - /> + routine ? ( + <> + {onRunRoutine && ( + onRunRoutine(routineKey)} + /> + )} + {scheduleEnabled + ? onDisableSchedule && ( + onDisableSchedule(routineKey)} + /> + ) + : onEnableSchedule && ( + onEnableSchedule(routineKey)} + /> + )} + {driftVariant(routine) && ( + onResetResource("routine")} + pending={resettingResource === "routine"} + /> + )} + ) : undefined } /> + {proposalHref && ( + } + detail="A proposed Reflection Coach update is waiting for review." + actions={ + + } + /> + )} ); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 0ecbca7262b..981f3ffe0bc 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -754,6 +754,38 @@ export function AgentDetail() { setActionError(error instanceof Error ? error.message : "Failed to update bundle resource"); }, }); + const runBuiltInRoutine = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to run built-in routine"); + }, + }); + const enableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule"); + }, + }); + const disableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule"); + }, + }); + const builtInRoutineActionPending = + runBuiltInRoutine.isPending + ? "run" + : enableBuiltInSchedule.isPending + ? "enable" + : disableBuiltInSchedule.isPending + ? "disable" + : null; const { data: runtimeState } = useQuery({ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), @@ -1161,7 +1193,11 @@ export function AgentDetail() { agentRef={canonicalAgentRef} onConfigure={() => setShowBuiltInConfigure(true)} onResetResource={(kind) => resetBuiltInResource.mutate(kind)} + onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)} + onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)} + onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)} resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null} + routineActionPending={builtInRoutineActionPending} /> )} diff --git a/ui/storybook/stories/built-in-agents.stories.tsx b/ui/storybook/stories/built-in-agents.stories.tsx index 31ade49cc0e..1925e34025b 100644 --- a/ui/storybook/stories/built-in-agents.stories.tsx +++ b/ui/storybook/stories/built-in-agents.stories.tsx @@ -354,6 +354,9 @@ function BundleCase({ title, state }: { title: string; state: BuiltInAgentState agentRef="reflectioncoach" onConfigure={() => {}} onResetResource={() => {}} + onRunRoutine={() => {}} + onEnableSchedule={() => {}} + onDisableSchedule={() => {}} /> );