fix(cli): require explicit heartbeat scope
This commit is contained in:
parent
932c8bec56
commit
eb63af2348
|
|
@ -5,6 +5,7 @@ import { registerAgentCommands } from "../commands/client/agent.js";
|
|||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const REVISION_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
|
|
@ -52,7 +53,7 @@ describe("agent lifecycle commands", () => {
|
|||
await run(["agent", "resume", AGENT_ID]);
|
||||
await run(["agent", "approve", AGENT_ID]);
|
||||
await run(["agent", "terminate", AGENT_ID]);
|
||||
await run(["agent", "heartbeat:invoke", AGENT_ID]);
|
||||
await run(["agent", "heartbeat:invoke", AGENT_ID, "--allow-unscoped"]);
|
||||
await run(["agent", "claude-login", AGENT_ID]);
|
||||
await run(["agent", "delete", AGENT_ID, "--yes"]);
|
||||
|
||||
|
|
@ -70,6 +71,30 @@ describe("agent lifecycle commands", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("binds a manual heartbeat invoke to the assigned issue", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({
|
||||
id: ISSUE_ID,
|
||||
identifier: "PC-42",
|
||||
assigneeAgentId: AGENT_ID,
|
||||
})));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/issues/PC-42"],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`],
|
||||
]);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
|
||||
reason: "manual_issue_wake",
|
||||
payload: {
|
||||
issueId: ISSUE_ID,
|
||||
taskId: ISSUE_ID,
|
||||
taskKey: ISSUE_ID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ interface AgentWakeOptions extends BaseClientOptions {
|
|||
forceFreshSession?: boolean;
|
||||
}
|
||||
|
||||
interface AgentHeartbeatInvokeOptions extends BaseClientOptions {
|
||||
issueId?: string;
|
||||
allowUnscoped?: boolean;
|
||||
}
|
||||
|
||||
interface AgentJsonPayloadOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
payloadJson: string;
|
||||
|
|
@ -420,7 +425,6 @@ export function registerAgentCommands(program: Command): void {
|
|||
["resume", "resume", "Resume an agent"],
|
||||
["approve", "approve", "Approve a pending agent"],
|
||||
["terminate", "terminate", "Terminate an agent"],
|
||||
["heartbeat:invoke", "heartbeat/invoke", "Invoke an agent heartbeat"],
|
||||
["claude-login", "claude-login", "Trigger Claude login for an agent"],
|
||||
] as const) {
|
||||
addCommonClientOptions(
|
||||
|
|
@ -440,6 +444,53 @@ export function registerAgentCommands(program: Command): void {
|
|||
);
|
||||
}
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("heartbeat:invoke")
|
||||
.description("Invoke an issue-scoped agent heartbeat")
|
||||
.argument("<agentId>", "Agent ID")
|
||||
.option("--issue-id <idOrIdentifier>", "Issue UUID or identifier to bind to the run")
|
||||
.option("--allow-unscoped", "Explicitly allow a run with no issue binding")
|
||||
.action(async (agentId: string, opts: AgentHeartbeatInvokeOptions) => {
|
||||
try {
|
||||
if (opts.issueId && opts.allowUnscoped) {
|
||||
throw new Error("Use either --issue-id or --allow-unscoped, not both");
|
||||
}
|
||||
if (!opts.issueId && !opts.allowUnscoped) {
|
||||
throw new Error("Specify --issue-id for task work or --allow-unscoped for a generic heartbeat");
|
||||
}
|
||||
|
||||
const ctx = resolveCommandContext(opts);
|
||||
let body: Record<string, unknown> = {};
|
||||
if (opts.issueId) {
|
||||
const issue = await ctx.api.get<Issue>(`/api/issues/${encodeURIComponent(opts.issueId)}`);
|
||||
if (!issue) {
|
||||
throw new Error(`Issue not found: ${opts.issueId}`);
|
||||
}
|
||||
if (issue.assigneeAgentId !== agentId) {
|
||||
throw new Error(`Issue ${issue.identifier} is not assigned to agent ${agentId}`);
|
||||
}
|
||||
body = {
|
||||
reason: "manual_issue_wake",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await ctx.api.post<AgentWakeupResponse>(
|
||||
`${apiPath`/api/agents/${agentId}`}/heartbeat/invoke`,
|
||||
body,
|
||||
);
|
||||
printOutput(result, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("permissions:update")
|
||||
|
|
|
|||
|
|
@ -541,7 +541,8 @@ npx paperclipai agent pause <agent-id>
|
|||
npx paperclipai agent resume <agent-id>
|
||||
npx paperclipai agent approve <agent-id>
|
||||
npx paperclipai agent terminate <agent-id>
|
||||
npx paperclipai agent heartbeat:invoke <agent-id>
|
||||
npx paperclipai agent heartbeat:invoke <agent-id> --issue-id <issue-id-or-identifier>
|
||||
npx paperclipai agent heartbeat:invoke <agent-id> --allow-unscoped
|
||||
npx paperclipai agent claude-login <agent-id>
|
||||
npx paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6638,8 +6638,24 @@ registry.registerPath({
|
|||
path: "/api/agents/{id}/heartbeat/invoke",
|
||||
tags: ["agents"],
|
||||
summary: "Invoke agent heartbeat",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
description:
|
||||
"Legacy manual invoke endpoint. Bind task work by supplying payload.issueId, payload.taskId, and payload.taskKey with the same issue UUID. An omitted body creates an intentionally unscoped run.",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: wakeAgentSchema.omit({ failedRunId: true }) },
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
202: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
|
|
|
|||
Loading…
Reference in New Issue