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:
@@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getActiveRunIssueSummaryForAgent: vi.fn(),
|
||||
getRunLogAccess: vi.fn(),
|
||||
readLog: vi.fn(),
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
listCompanyIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const routeAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
@@ -210,6 +213,14 @@ describe("agent live run routes", () => {
|
||||
content: "chunk",
|
||||
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 () => {
|
||||
@@ -524,4 +535,66 @@ describe("agent live run routes", () => {
|
||||
expect(res.body).toHaveLength(4);
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+66
-16
@@ -2883,7 +2883,25 @@ export function agentRoutes(
|
||||
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 agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2902,7 +2920,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const run = await heartbeat.wakeup(id, {
|
||||
source: req.body.source,
|
||||
source: opts.source,
|
||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||
reason: req.body.reason ?? null,
|
||||
payload: req.body.payload ?? null,
|
||||
@@ -2917,7 +2935,7 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
||||
res.status(202).json(await opts.skippedResponse(agent));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2935,9 +2953,23 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
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) => {
|
||||
// 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 agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2955,19 +2987,37 @@ export function agentRoutes(
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.invoke(
|
||||
id,
|
||||
"on_demand",
|
||||
{
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
},
|
||||
"manual",
|
||||
{
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
},
|
||||
);
|
||||
const body = (req.body ?? {}) as Partial<{
|
||||
reason: unknown;
|
||||
payload: unknown;
|
||||
idempotencyKey: unknown;
|
||||
forceFreshSession: unknown;
|
||||
triggerDetail: unknown;
|
||||
}>;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
};
|
||||
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) {
|
||||
res.status(202).json({ status: "skipped" });
|
||||
|
||||
+12
-8
@@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
|
||||
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) {
|
||||
if (!companyId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
@@ -204,16 +213,11 @@ export const agentsApi = {
|
||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||
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: (
|
||||
id: string,
|
||||
data: {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
},
|
||||
data: AgentWakeRequest,
|
||||
companyId?: string,
|
||||
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
|
||||
Reference in New Issue
Block a user