mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +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,
|
||||
};
|
||||
|
||||
@@ -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<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}),
|
||||
runRoutine: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<unknown>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}),
|
||||
enableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}),
|
||||
disableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}),
|
||||
};
|
||||
@@ -20,6 +20,7 @@ vi.mock("@/lib/router", () => ({
|
||||
function resource(
|
||||
resourceKind: BuiltInManagedResourceState["resourceKind"],
|
||||
stockStatus: BuiltInManagedResourceStockStatus,
|
||||
overrides: Partial<BuiltInManagedResourceState> = {},
|
||||
): 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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={pending}>
|
||||
{pending ? "Working…" : triggerLabel}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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={
|
||||
<>
|
||||
<ResourceStatusChip variant="schedule_off" />
|
||||
<ResourceStatusChip
|
||||
variant={scheduleEnabled ? "schedule_on" : "schedule_off"}
|
||||
label={scheduleEnabled ? scheduleLabel : undefined}
|
||||
/>
|
||||
{routine && driftVariant(routine) && (
|
||||
<ResourceStatusChip variant={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) ? (
|
||||
<ResourceActionButton
|
||||
resource={routine}
|
||||
label="routine"
|
||||
onConfirm={() => onResetResource("routine")}
|
||||
pending={resettingResource === "routine"}
|
||||
/>
|
||||
routine ? (
|
||||
<>
|
||||
{onRunRoutine && (
|
||||
<ConfirmActionButton
|
||||
title="Run Reflection Coach once?"
|
||||
body="Paperclip will create one routine task now. This does not enable the weekly schedule or turn on background work."
|
||||
triggerLabel="Run once"
|
||||
confirmLabel="Run once"
|
||||
pending={routineActionPending === "run"}
|
||||
onConfirm={() => onRunRoutine(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{scheduleEnabled
|
||||
? onDisableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Disable the weekly schedule?"
|
||||
body="Paperclip will stop future scheduled Reflection Coach runs. Manual Run once remains available."
|
||||
triggerLabel="Disable schedule"
|
||||
confirmLabel="Disable schedule"
|
||||
pending={routineActionPending === "disable"}
|
||||
onConfirm={() => onDisableSchedule(routineKey)}
|
||||
/>
|
||||
)
|
||||
: onEnableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Enable the weekly schedule?"
|
||||
body="Paperclip will allow Reflection Coach to create routine tasks on the weekly schedule. It can spend tokens when those tasks run."
|
||||
triggerLabel="Enable weekly"
|
||||
confirmLabel="Enable weekly"
|
||||
pending={routineActionPending === "enable"}
|
||||
onConfirm={() => onEnableSchedule(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{driftVariant(routine) && (
|
||||
<ResourceActionButton
|
||||
resource={routine}
|
||||
label="routine"
|
||||
onConfirm={() => onResetResource("routine")}
|
||||
pending={resettingResource === "routine"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{proposalHref && (
|
||||
<BundleRow
|
||||
label="Proposal"
|
||||
chips={<ResourceStatusChip variant="proposal_pending" />}
|
||||
detail="A proposed Reflection Coach update is waiting for review."
|
||||
actions={
|
||||
<Button asChild variant="link" size="sm">
|
||||
<Link to={proposalHref}>Review proposal</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -354,6 +354,9 @@ function BundleCase({ title, state }: { title: string; state: BuiltInAgentState
|
||||
agentRef="reflectioncoach"
|
||||
onConfigure={() => {}}
|
||||
onResetResource={() => {}}
|
||||
onRunRoutine={() => {}}
|
||||
onEnableSchedule={() => {}}
|
||||
onDisableSchedule={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user