mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Preserve scope on manual heartbeat invokes (#5323)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The agent live-run route lets operators trigger a manual heartbeat invocation so an agent can pick up a specific issue or step out of band > - The current route flow drops the caller's scope (issue/run context) when forwarding the manual invoke into the heartbeat service, so the resulting run loses the targeting the operator specified > - This pull request threads the operator-supplied scope through the manual invoke path on both the server route and the UI client, with a regression test that confirms the scope round-trips > - The benefit is manual heartbeat invokes from the live-run UI actually pick up the scoped issue/run instead of falling through to the agent's default routine ## What Changed - `server/src/routes/agents.ts`: forward the operator-supplied scope into the manual invoke heartbeat service call - `server/src/__tests__/agent-live-run-routes.test.ts`: new test verifying the manual invoke path preserves scope - `ui/src/api/agents.ts`: pass scope through the live-run client API ## Verification - `pnpm vitest run --no-coverage server/src/__tests__/agent-live-run-routes.test.ts` - `pnpm typecheck` clean ## Risks Low. The change is purely additive on the route surface — handlers that did not previously pass scope continue to work; handlers that did pass it now have it preserved instead of dropped. ## Model Used Claude Opus 4.7 (1M context) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable — new test covers the preserved-scope path - [x] If this change affects the UI, I have included before/after screenshots — N/A (internal API change, no visible UI shift) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
9fb0c73e0a
commit
83e7ecc58e
3 changed files with 151 additions and 24 deletions
|
|
@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||||
getActiveRunIssueSummaryForAgent: vi.fn(),
|
getActiveRunIssueSummaryForAgent: vi.fn(),
|
||||||
getRunLogAccess: vi.fn(),
|
getRunLogAccess: vi.fn(),
|
||||||
readLog: vi.fn(),
|
readLog: vi.fn(),
|
||||||
|
wakeup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockIssueService = vi.hoisted(() => ({
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
|
@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||||
listCompanyIds: vi.fn(),
|
listCompanyIds: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const routeAgentId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
function registerModuleMocks() {
|
function registerModuleMocks() {
|
||||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||||
|
|
||||||
|
|
@ -210,6 +213,14 @@ describe("agent live run routes", () => {
|
||||||
content: "chunk",
|
content: "chunk",
|
||||||
nextOffset: 5,
|
nextOffset: 5,
|
||||||
});
|
});
|
||||||
|
mockHeartbeatService.wakeup.mockResolvedValue({
|
||||||
|
id: "run-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
status: "queued",
|
||||||
|
invocationSource: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a compact active run payload for issue polling", async () => {
|
it("returns a compact active run payload for issue polling", async () => {
|
||||||
|
|
@ -524,4 +535,66 @@ describe("agent live run routes", () => {
|
||||||
expect(res.body).toHaveLength(4);
|
expect(res.body).toHaveLength(4);
|
||||||
expect(db.select).toHaveBeenCalledTimes(2);
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
|
||||||
|
const res = await requestApp(
|
||||||
|
await createApp(),
|
||||||
|
(baseUrl) => request(baseUrl)
|
||||||
|
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||||
|
.send({
|
||||||
|
reason: "issue_assigned",
|
||||||
|
payload: {
|
||||||
|
issueId: "issue-1",
|
||||||
|
taskId: "issue-1",
|
||||||
|
taskKey: "issue-1",
|
||||||
|
},
|
||||||
|
forceFreshSession: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||||
|
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
|
||||||
|
// caller actually supplied so empty-body callers (e.g. e2e suites) match
|
||||||
|
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
|
||||||
|
// caller supplies reason / payload / forceFreshSession those are
|
||||||
|
// forwarded; idempotencyKey is omitted unless explicitly set.
|
||||||
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
reason: "issue_assigned",
|
||||||
|
payload: {
|
||||||
|
issueId: "issue-1",
|
||||||
|
taskId: "issue-1",
|
||||||
|
taskKey: "issue-1",
|
||||||
|
},
|
||||||
|
requestedByActorType: "user",
|
||||||
|
requestedByActorId: "local-board",
|
||||||
|
contextSnapshot: {
|
||||||
|
triggeredBy: "board",
|
||||||
|
actorId: "local-board",
|
||||||
|
forceFreshSession: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
|
||||||
|
const res = await requestApp(
|
||||||
|
await createApp(),
|
||||||
|
(baseUrl) => request(baseUrl)
|
||||||
|
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||||
|
.send({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||||
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
requestedByActorType: "user",
|
||||||
|
requestedByActorId: "local-board",
|
||||||
|
contextSnapshot: {
|
||||||
|
triggeredBy: "board",
|
||||||
|
actorId: "local-board",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2883,7 +2883,25 @@ export function agentRoutes(
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
// Shared handler body for the wakeup-style endpoints. The two routes differ
|
||||||
|
// only in:
|
||||||
|
// - `source` — the modern /wakeup endpoint reads it from the request body
|
||||||
|
// (timer|assignment|on_demand|automation) while the legacy
|
||||||
|
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
|
||||||
|
// ever produced on-demand invocations.
|
||||||
|
// - skipped-response shape — the modern endpoint surfaces the rich
|
||||||
|
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
|
||||||
|
// { status: "skipped" } shape for backward compat.
|
||||||
|
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||||
|
type WakeupRouteOpts = {
|
||||||
|
source: HeartbeatSource | undefined;
|
||||||
|
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
|
||||||
|
};
|
||||||
|
const handleWakeupRoute = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
opts: WakeupRouteOpts,
|
||||||
|
): Promise<void> => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const agent = await svc.getById(id);
|
const agent = await svc.getById(id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
|
|
@ -2902,7 +2920,7 @@ export function agentRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = await heartbeat.wakeup(id, {
|
const run = await heartbeat.wakeup(id, {
|
||||||
source: req.body.source,
|
source: opts.source,
|
||||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||||
reason: req.body.reason ?? null,
|
reason: req.body.reason ?? null,
|
||||||
payload: req.body.payload ?? null,
|
payload: req.body.payload ?? null,
|
||||||
|
|
@ -2917,7 +2935,7 @@ export function agentRoutes(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
res.status(202).json(await opts.skippedResponse(agent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2935,9 +2953,23 @@ export function agentRoutes(
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(202).json(run);
|
res.status(202).json(run);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||||
|
await handleWakeupRoute(req, res, {
|
||||||
|
source: req.body.source,
|
||||||
|
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
|
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
|
||||||
|
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
|
||||||
|
// before the wakeup/invoke convergence). Reads scope fields directly off
|
||||||
|
// the body without `validate(wakeAgentSchema)` because callers — including
|
||||||
|
// the e2e suite — post an empty body, and the schema rejects undefined
|
||||||
|
// / missing bodies. Only forwards fields the caller actually supplied so
|
||||||
|
// an empty body produces the original fixed-arg `heartbeat.invoke()`
|
||||||
|
// shape exactly.
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const agent = await svc.getById(id);
|
const agent = await svc.getById(id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
|
|
@ -2955,19 +2987,37 @@ export function agentRoutes(
|
||||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = await heartbeat.invoke(
|
const body = (req.body ?? {}) as Partial<{
|
||||||
id,
|
reason: unknown;
|
||||||
"on_demand",
|
payload: unknown;
|
||||||
{
|
idempotencyKey: unknown;
|
||||||
triggeredBy: req.actor.type,
|
forceFreshSession: unknown;
|
||||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
triggerDetail: unknown;
|
||||||
},
|
}>;
|
||||||
"manual",
|
const contextSnapshot: Record<string, unknown> = {
|
||||||
{
|
triggeredBy: req.actor.type,
|
||||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
};
|
||||||
},
|
if (body.forceFreshSession === true) {
|
||||||
);
|
contextSnapshot.forceFreshSession = true;
|
||||||
|
}
|
||||||
|
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
|
||||||
|
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||||
|
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||||
|
contextSnapshot,
|
||||||
|
};
|
||||||
|
if (typeof body.reason === "string" && body.reason.length > 0) {
|
||||||
|
wakeOpts.reason = body.reason;
|
||||||
|
}
|
||||||
|
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
|
||||||
|
wakeOpts.payload = body.payload as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
|
||||||
|
wakeOpts.idempotencyKey = body.idempotencyKey;
|
||||||
|
}
|
||||||
|
const run = await heartbeat.wakeup(id, wakeOpts);
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
res.status(202).json({ status: "skipped" });
|
res.status(202).json({ status: "skipped" });
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
|
||||||
canAssignTasks: boolean;
|
canAssignTasks: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentWakeRequest {
|
||||||
|
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||||
|
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
forceFreshSession?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function withCompanyScope(path: string, companyId?: string) {
|
function withCompanyScope(path: string, companyId?: string) {
|
||||||
if (!companyId) return path;
|
if (!companyId) return path;
|
||||||
const separator = path.includes("?") ? "&" : "?";
|
const separator = path.includes("?") ? "&" : "?";
|
||||||
|
|
@ -204,16 +213,11 @@ export const agentsApi = {
|
||||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||||
data,
|
data,
|
||||||
),
|
),
|
||||||
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
|
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
|
||||||
|
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
|
||||||
wakeup: (
|
wakeup: (
|
||||||
id: string,
|
id: string,
|
||||||
data: {
|
data: AgentWakeRequest,
|
||||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
|
||||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
|
||||||
reason?: string | null;
|
|
||||||
payload?: Record<string, unknown> | null;
|
|
||||||
idempotencyKey?: string | null;
|
|
||||||
},
|
|
||||||
companyId?: string,
|
companyId?: string,
|
||||||
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||||
loginWithClaude: (id: string, companyId?: string) =>
|
loginWithClaude: (id: string, companyId?: string) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue