mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Merge branch 'codex/work-folders-pi-refresh' into codex/work-folders-storybook-refresh
* codex/work-folders-pi-refresh: fix(runner): keep healthy native sessions alive (#13261) feat(connections): add AgentMail inboxes and email tasks (#13256) fix: stop completion reviews caused by policy upgrades (#13266) fix(ci): avoid empty pnpm caches from lockfile refresh (#13267) feat: accept a base64-encoded Cloud UI snippet (#13245) ci: use reserved AWS capacity for post-merge cloud verification (#13257) Fix Codex API key authentication in tests and runs (#13260) refactor: remove automatic productivity reviews (#13263) fix: continue conversations after confirmed remote runner stop (#13254) ci: cache Rust dependencies used by post-merge typecheck (#13259) fix(ui): restore task page archive shortcut (#13253) feat(ui): show one rolling runner activity per commentary group (#13255) fix(ui): hide retry countdown after execution starts (#13258) test: isolate native controller takeover fixture (#13212)
This commit is contained in:
commit
24a7ab22f6
201 files changed
+62608
-3880
No files matched your search
@@ -536,6 +536,52 @@ describe("agent routes adapter validation", () => {
|
||||
expect(String(env.CODEX_HOME)).toContain(`/companies/company-1/agents/${agentId}/codex-home`);
|
||||
});
|
||||
|
||||
it("restores a saved agent's redacted CODEX_HOME before testing its adapter", async () => {
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const storedHome = "/paperclip/companies/company-1/agents/agent-1/codex-home";
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...(await mockAgentService.getById()),
|
||||
id: agentId,
|
||||
adapterType: "external_test",
|
||||
adapterConfig: { env: { CODEX_HOME: storedHome } },
|
||||
});
|
||||
const { registerServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter(externalAdapter);
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
agentId,
|
||||
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{ env: { CODEX_HOME: storedHome } },
|
||||
expect.objectContaining({ adapterType: "external_test" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects redacted-value restoration from an incompatible saved agent", async () => {
|
||||
const { registerServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter(externalAdapter);
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Webhook } from "svix";
|
||||
import {
|
||||
agentmailApi,
|
||||
agentmailMessageSchema,
|
||||
emailText,
|
||||
emailReplyRecipients,
|
||||
isAutomaticEmail,
|
||||
isFilteredEmail,
|
||||
normalizeAgentmailEvent,
|
||||
verifyAgentmailWebhook,
|
||||
} from "../services/agentmail-api.js";
|
||||
import { emailSendSchema } from "@paperclipai/shared";
|
||||
import { buildRunnerApiCatalog } from "../services/native-runtime/runner-api-catalog.js";
|
||||
|
||||
const message = (extra = {}) =>
|
||||
agentmailMessageSchema.parse({
|
||||
inbox_id: "agent@agentmail.to",
|
||||
thread_id: "thread",
|
||||
message_id: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
describe("AgentMail protocol boundary", () => {
|
||||
it("verifies the exact raw body and rejects forged or stale Svix signatures", () => {
|
||||
const secret = `whsec_${Buffer.from("a-test-secret-only").toString("base64")}`;
|
||||
const body = JSON.stringify({
|
||||
event_type: "message.received",
|
||||
message: message(),
|
||||
});
|
||||
const timestamp = new Date();
|
||||
const id = randomUUID();
|
||||
const headers = {
|
||||
"svix-id": id,
|
||||
"svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)),
|
||||
"svix-signature": new Webhook(secret).sign(id, timestamp, body),
|
||||
};
|
||||
expect(verifyAgentmailWebhook(Buffer.from(body), headers, secret)).toEqual(
|
||||
JSON.parse(body),
|
||||
);
|
||||
expect(() =>
|
||||
verifyAgentmailWebhook(Buffer.from(body + " "), headers, secret),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
verifyAgentmailWebhook(
|
||||
Buffer.from(body),
|
||||
{ ...headers, "svix-timestamp": "1" },
|
||||
secret,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
it("normalizes both transports and keeps delivery receipts separate from incoming mail", () => {
|
||||
const m = { inbox_id: "inbox", message_id: "message" };
|
||||
expect(
|
||||
normalizeAgentmailEvent({ event_type: "message.received", message: m })
|
||||
?.kind,
|
||||
).toBe("message.received");
|
||||
expect(
|
||||
normalizeAgentmailEvent({ type: "message_received", message: m })?.kind,
|
||||
).toBe("message.received");
|
||||
expect(
|
||||
normalizeAgentmailEvent({ type: "message_delivered", message: m })?.kind,
|
||||
).toBe("message.delivered");
|
||||
expect(normalizeAgentmailEvent({ type: "subscribed" })).toBeNull();
|
||||
expect(() =>
|
||||
normalizeAgentmailEvent({ event_type: "message.received", message: {} }),
|
||||
).toThrow();
|
||||
});
|
||||
it.each([
|
||||
["message.sent", "send"],
|
||||
["message.delivered", "delivery"],
|
||||
["message.bounced", "bounce"],
|
||||
["message.complained", "complaint"],
|
||||
["message.rejected", "reject"],
|
||||
])("admits the documented %s receipt envelope through either transport", (kind, field) => {
|
||||
for (const transport of [{ type: "event", event_type: kind }, { type: kind.replace(".", "_") }]) {
|
||||
expect(normalizeAgentmailEvent({
|
||||
...transport,
|
||||
event_id: "provider-event",
|
||||
[field]: { inbox_id: "inbox", thread_id: "thread", message_id: "sent-message" },
|
||||
})).toEqual({ kind, inbox_id: "inbox", message_id: "sent-message", eventId: "provider-event" });
|
||||
}
|
||||
});
|
||||
it("prefers extracted text, strips HTML and recognizes provider filtering and auto-replies", () => {
|
||||
expect(
|
||||
emailText(
|
||||
message({ extracted_text: "New reply", text: "Quoted history" }),
|
||||
),
|
||||
).toBe("New reply");
|
||||
expect(
|
||||
emailText(
|
||||
message({
|
||||
html: '<script>alert(1)</script><img src="https://tracking.test"><p>Hello</p>',
|
||||
}),
|
||||
),
|
||||
).not.toContain("tracking.test");
|
||||
expect(
|
||||
isAutomaticEmail(
|
||||
message({ headers: { "Auto-Submitted": "auto-replied" } }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAutomaticEmail(message({ headers: { "Auto-Submitted": "no" } })),
|
||||
).toBe(false);
|
||||
for (const label of ["spam", "blocked", "unauthenticated"])
|
||||
expect(isFilteredEmail(message({ labels: [label] }))).toBe(true);
|
||||
});
|
||||
it("pins the API host, encodes message IDs and preserves the provider idempotency key", async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ message_id: "sent", thread_id: "thread" }),
|
||||
),
|
||||
);
|
||||
await agentmailApi("private-key", fetcher).send(
|
||||
"agent@agentmail.to",
|
||||
{ text: "Reply", reply_all: false },
|
||||
"stable-key",
|
||||
"<message@domain>",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"https://api.agentmail.to/v0/inboxes/agent%40agentmail.to/messages/%3Cmessage%40domain%3E/reply",
|
||||
expect.objectContaining({
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({ "Idempotency-Key": "stable-key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("redacts provider error bodies", async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response("private email and credentials", { status: 403 }),
|
||||
);
|
||||
await expect(agentmailApi("private-key", fetcher).whoami()).rejects.toThrow(
|
||||
"AgentMail request failed (403)",
|
||||
);
|
||||
});
|
||||
it("constructs deliberate reply-all from visible recipients, excluding self and Bcc", () => {
|
||||
const envelope = {
|
||||
from: "Sender <sender@example.test>",
|
||||
to: ["agent@agentmail.to", "visible@example.test"],
|
||||
cc: ["visible@example.test", "cc@example.test"],
|
||||
bcc: ["private@example.test"],
|
||||
subject: "Hello",
|
||||
};
|
||||
expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual(
|
||||
{ to: ["sender@example.test"], cc: [], bcc: [], reply_all: false },
|
||||
);
|
||||
expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({
|
||||
to: ["sender@example.test", "visible@example.test"],
|
||||
cc: ["cc@example.test"],
|
||||
bcc: [],
|
||||
reply_all: false,
|
||||
});
|
||||
});
|
||||
it("honors Reply-To for reply and reply-all without adding the forwarding sender or Bcc", () => {
|
||||
const envelope = {
|
||||
from: "Forwarder <forwarder@example.test>",
|
||||
replyTo: ["Reply desk <reply@example.test>", "agent@agentmail.to"],
|
||||
to: ["agent@agentmail.to", "visible@example.test"],
|
||||
cc: ["reply@example.test", "cc@example.test"],
|
||||
bcc: ["private@example.test"],
|
||||
subject: "Forwarded request",
|
||||
};
|
||||
expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual({
|
||||
to: ["reply@example.test"], cc: [], bcc: [], reply_all: false,
|
||||
});
|
||||
expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({
|
||||
to: ["reply@example.test", "visible@example.test"], cc: ["cc@example.test"], bcc: [], reply_all: false,
|
||||
});
|
||||
expect(emailReplyRecipients({ ...envelope, replyTo: [] }, "agent@agentmail.to", false).to)
|
||||
.toEqual(["forwarder@example.test"]);
|
||||
});
|
||||
it("validates explicit new-message and reply envelopes, rejecting header injection and Bcc reuse", () => {
|
||||
const base = {
|
||||
endpointId: randomUUID(),
|
||||
idempotencyKey: randomUUID(),
|
||||
text: "Hello",
|
||||
};
|
||||
expect(
|
||||
emailSendSchema.safeParse({
|
||||
...base,
|
||||
parentIssueId: randomUUID(),
|
||||
to: ["person@example.test"],
|
||||
subject: "Hi\r\nBcc: hidden@example.test",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
emailSendSchema.safeParse({
|
||||
...base,
|
||||
conversationId: randomUUID(),
|
||||
replyToMessageId: "message",
|
||||
bcc: ["hidden@example.test"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
emailSendSchema.parse({
|
||||
...base,
|
||||
conversationId: randomUUID(),
|
||||
replyToMessageId: "message",
|
||||
}).replyAll,
|
||||
).toBe(false);
|
||||
});
|
||||
it("exposes explicit email actions in runtime API discovery and keeps credential setup board-only", () => {
|
||||
const operations = buildRunnerApiCatalog();
|
||||
const send = operations.find(o => o.path === "/api/companies/{companyId}/email/send");
|
||||
expect(send?.method).toBe("POST"); expect(send?.requestBody).toBeDefined();
|
||||
expect(send?.responses).toHaveProperty("202");
|
||||
const setup = operations.find(o => o.path === "/api/companies/{companyId}/email/inspect");
|
||||
expect(JSON.stringify(setup?.authorization)).toContain("board");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -616,13 +616,13 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
|
||||
expect(feed.totalCount).toBe(12);
|
||||
expect(feed.totalCount).toBe(11);
|
||||
expect(feed.countsBySourceKind).toMatchObject({
|
||||
approval: 1,
|
||||
issue_thread_interaction: 1,
|
||||
join_request: 1,
|
||||
recovery_action: 1,
|
||||
productivity_review: 1,
|
||||
productivity_review: 0,
|
||||
blocker_attention: 1,
|
||||
review: 2,
|
||||
failed_run: 1,
|
||||
@@ -634,7 +634,6 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
"productivity_review",
|
||||
"blocker_attention",
|
||||
"review",
|
||||
"failed_run",
|
||||
@@ -651,7 +650,14 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
expect(item.rank).toBeGreaterThan(0);
|
||||
}
|
||||
expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
|
||||
expect(feed.items.some((item) => item.sourceKind === "productivity_review")).toBe(false);
|
||||
expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
|
||||
const legacyReviews = await db.select().from(issues).where(eq(issues.originKind, "issue_productivity_review"));
|
||||
expect(legacyReviews).toHaveLength(2);
|
||||
expect(legacyReviews).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Human productivity review", status: "todo", assigneeUserId: "board-user", parentId: productivitySourceIssueId }),
|
||||
expect.objectContaining({ title: "Agent productivity review excluded", status: "todo", assigneeAgentId: workerId, parentId: agentProductivitySourceIssueId }),
|
||||
]));
|
||||
expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
|
||||
expect(feed.items.some((item) =>
|
||||
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
|
||||
|
||||
@@ -3,10 +3,12 @@ import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
|
||||
|
||||
const html = '<html><body><div id="root"></div></body></html>';
|
||||
const snippet = '<script src="https://example.com/widget.js"></script>';
|
||||
const encoded = Buffer.from(snippet, "utf-8").toString("base64");
|
||||
|
||||
describe("Cloud UI snippet", () => {
|
||||
it("leaves self-hosted HTML unchanged even when a snippet is configured", () => {
|
||||
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html);
|
||||
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded })).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -27,4 +29,55 @@ describe("Cloud UI snippet", () => {
|
||||
expect(result).toContain(script);
|
||||
expect(result).not.toContain("test-token");
|
||||
});
|
||||
|
||||
it("decodes a base64 snippet on a Cloud instance", () => {
|
||||
expect(injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
|
||||
})).toBe(html.replace("</body>", `${snippet}\n</body>`));
|
||||
});
|
||||
|
||||
it("tolerates whitespace and line wrapping in the base64 value", () => {
|
||||
const wrapped = ` ${encoded.slice(0, 20)}\n${encoded.slice(20)}\n`;
|
||||
expect(injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: wrapped,
|
||||
})).toBe(html.replace("</body>", `${snippet}\n</body>`));
|
||||
});
|
||||
|
||||
it("prefers the plain snippet when both variables are set", () => {
|
||||
const other = Buffer.from("<script>other()</script>", "utf-8").toString("base64");
|
||||
const result = injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}",
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET: snippet,
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET_B64: other,
|
||||
});
|
||||
expect(result).toContain(snippet);
|
||||
expect(result).not.toContain("other()");
|
||||
});
|
||||
|
||||
it("treats a blank plain variable as disabled even when a base64 value is set", () => {
|
||||
expect(injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}",
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET: " ",
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
|
||||
})).toBe(html);
|
||||
expect(injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}",
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET: "",
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
|
||||
})).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "invalid characters", value: "!!!not-base64!!!" },
|
||||
{ label: "wrong length", value: "abcde" },
|
||||
{ label: "unpadded", value: Buffer.from("<b>x</b>", "utf-8").toString("base64").replace(/=+$/, "") },
|
||||
{ label: "carrying nonzero padding bits", value: "PB==" },
|
||||
{ label: "not valid UTF-8 once decoded", value: "/w==" },
|
||||
{ label: "blank once decoded", value: Buffer.from(" \n ", "utf-8").toString("base64") },
|
||||
{ label: "blank", value: " " },
|
||||
])("ignores a base64 value that is $label", ({ value }) => {
|
||||
expect(injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: value,
|
||||
})).toBe(html);
|
||||
});
|
||||
});
|
||||
@@ -1440,6 +1440,68 @@ process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates connector skills by agent and revision without changing the selected model identity", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-connector-codex-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const command = path.join(root, "codex");
|
||||
const capture = path.join(root, "capture.json");
|
||||
const sourceHome = path.join(root, "selected-account");
|
||||
const skillSource = path.join(root, "skill-v1");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sourceHome, { recursive: true });
|
||||
await fs.mkdir(skillSource, { recursive: true });
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), fakeCodexAuthJson);
|
||||
await fs.writeFile(path.join(skillSource, "SKILL.md"), "# AgentMail\nAssigned inbox one.");
|
||||
await writeFakeCodexCommand(command);
|
||||
const keys = ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"] as const;
|
||||
const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "connectors";
|
||||
process.env.CODEX_HOME = sourceHome;
|
||||
const invoke = async (agentId: string, digest: string | null, source = skillSource, connectorSkillInstructions = "") => {
|
||||
const config = {
|
||||
engine: "cli", command, cwd: workspace,
|
||||
env: { CODEX_HOME: sourceHome, PAPERCLIP_TEST_CAPTURE_PATH: capture },
|
||||
paperclipConnectorSkillDigest: digest,
|
||||
paperclipSkillSync: { desiredSkills: digest ? ["paperclipai/paperclip/agentmail"] : [] },
|
||||
paperclipRuntimeSkills: digest ? [{ key: "paperclipai/paperclip/agentmail", runtimeName: "agentmail", source }] : [],
|
||||
};
|
||||
const result = await execute({ runId: `run-${agentId}-${digest?.slice(0, 1) ?? "none"}`,
|
||||
agent: { id: agentId, companyId: "company-1", name: "Email agent", adapterType: "codex_local", adapterConfig: config },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config, context: { paperclipWake: { connectorSkillInstructions } }, authToken: "test-token", onLog: async () => {},
|
||||
});
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
return JSON.parse(await fs.readFile(capture, "utf8")) as CapturePayload;
|
||||
};
|
||||
try {
|
||||
const first = await invoke("agent-1", "a".repeat(64));
|
||||
expect(first.codexHome).toContain("connector-runtimes/agent-1/");
|
||||
expect(await fs.realpath(path.join(first.codexHome!, "auth.json"))).toBe(await fs.realpath(path.join(sourceHome, "auth.json")));
|
||||
expect(await fs.readFile(path.join(first.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox one");
|
||||
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const other = await invoke("agent-2", "a".repeat(64));
|
||||
expect(other.codexHome).not.toBe(first.codexHome);
|
||||
const nextSource = path.join(root, "skill-v2");
|
||||
await fs.mkdir(nextSource);
|
||||
await fs.writeFile(path.join(nextSource, "SKILL.md"), "# AgentMail\nAssigned inbox two.");
|
||||
const next = await invoke("agent-1", "b".repeat(64), nextSource);
|
||||
expect(next.codexHome).not.toBe(first.codexHome);
|
||||
expect(await fs.readFile(path.join(next.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox two");
|
||||
const inline = await invoke("agent-1", null, skillSource, "# AgentMail\nAssigned inbox inline@example.test");
|
||||
expect(inline.prompt).toContain("Assigned inbox inline@example.test");
|
||||
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const removed = await invoke("agent-1", null);
|
||||
expect(removed.prompt).not.toContain("inline@example.test");
|
||||
expect(removed.codexHome).toBe(sourceHome);
|
||||
await expect(fs.stat(path.join(removed.codexHome!, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; }
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -418,8 +418,8 @@ describeEmbeddedPostgres("company import batches inserts", () => {
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
expect(imported?.status).toBe("in_progress");
|
||||
// A fabricated import-time startedAt made carried-over work look hours
|
||||
// stale to duration-based sweeps (e.g. the productivity review).
|
||||
// An import-time startedAt would misrepresent carried-over work
|
||||
// as a newly started active episode.
|
||||
expect(imported?.startedAt).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1244,7 +1244,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
};
|
||||
}
|
||||
if (method === "environmentDestroyLease") {
|
||||
return undefined;
|
||||
return { providerLeaseId: "plugin-lease-1", state: "destroyed" };
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
@@ -1276,6 +1276,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(leaseRows).toHaveLength(1);
|
||||
expect(leaseRows[0]?.status).toBe("expired");
|
||||
expect(leaseRows[0]?.cleanupStatus).toBe("success");
|
||||
expect(leaseRows[0]?.metadata?.remoteExecutionTermination).toMatchObject({
|
||||
companyId, runId, leaseId: leaseRows[0]!.id, providerLeaseId: "plugin-lease-1", state: "destroyed",
|
||||
});
|
||||
|
||||
// The acquire provisioned the remote plugin sandbox, so it destroys the
|
||||
// sandbox on the rejection. Without this teardown the rejected insert leaks a
|
||||
@@ -3125,7 +3128,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const lease = await environmentService(db).getLeaseById(orphan.id);
|
||||
await runtimeWithPlugin.retryPendingSandboxTeardown({ environment: null, lease: lease! });
|
||||
vi.mocked(workerManager.call).mockImplementationOnce(async (_pluginId, _method, args: any) => {
|
||||
destroyConfigs.push(args.config);
|
||||
return { providerLeaseId: args.providerLeaseId, state: "destroyed" };
|
||||
});
|
||||
await expect(runtimeWithPlugin.retryPendingSandboxTeardown({ environment: null, lease: lease! }))
|
||||
.resolves.toEqual({ providerLeaseId: orphan.providerLeaseId, state: "destroyed" });
|
||||
expect(destroyConfigs).toHaveLength(1);
|
||||
// The recorded secret ref resolved to the old credential, and the resolved
|
||||
// value reached the provider teardown instead of the secret ref.
|
||||
@@ -6339,7 +6347,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
if (method === "environmentDestroyLease") {
|
||||
return undefined;
|
||||
return { providerLeaseId: reusableLease.providerLeaseId, state: "destroyed" };
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
@@ -6366,6 +6374,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
status: "expired",
|
||||
failureReason: "environment_deleted",
|
||||
cleanupStatus: "success",
|
||||
metadata: { remoteExecutionTermination: { schema: "paperclip.remote-termination.v1",
|
||||
leaseId: reusableLease.id, runId, providerLeaseId: reusableLease.providerLeaseId, state: "destroyed" } },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
// misdelivering it. `omitHostRouteId: true` sends the notification with no
|
||||
// `hostRouteId` field at all, so a test proves the host warns about a plugin
|
||||
// build old enough to omit the field, instead of silently dropping it.
|
||||
// - `emitOnInput`: when true, wait for the first input before sending scripted
|
||||
// notifications, so legacy routing tests know the worker session is bound.
|
||||
// - `exitCode`: when set, the fixture emits an exit notification after the outputs.
|
||||
// - `omitHostRouteIdOnExit`: when true, the main `exitCode` exit notification
|
||||
// carries no `hostRouteId` field, so a test proves the host still resolves
|
||||
@@ -184,7 +186,13 @@ rl.on("line", (line) => {
|
||||
const mode = directive.mode ?? "normal";
|
||||
const workerSessionId = directive.workerSessionId ?? "ws-1";
|
||||
const closeMode = directive.closeMode ?? "ack";
|
||||
routes.set(params.hostRouteId, { workerSessionId, closeMode });
|
||||
routes.set(params.hostRouteId, {
|
||||
workerSessionId,
|
||||
closeMode,
|
||||
pendingOutput: directive.emitOnInput === true
|
||||
? scriptedOutputLines(directive, params.hostRouteId, workerSessionId)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (mode === "no-open-reply") {
|
||||
// Never reply, so the host open call times out.
|
||||
@@ -230,6 +238,8 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (directive.emitOnInput === true) return;
|
||||
|
||||
// Emit the scripted output and the exit after the open reply, so the host
|
||||
// binds the route first.
|
||||
setImmediate(() => {
|
||||
@@ -243,6 +253,11 @@ rl.on("line", (line) => {
|
||||
// test proves the input reaches the worker and the output routes back.
|
||||
for (const [hostRouteId, entry] of routes.entries()) {
|
||||
if (entry.workerSessionId === params.workerSessionId) {
|
||||
if (entry.pendingOutput !== null) {
|
||||
process.stdout.write(entry.pendingOutput);
|
||||
entry.pendingOutput = null;
|
||||
continue;
|
||||
}
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "loginPty.output",
|
||||
|
||||
@@ -230,7 +230,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
expect(manager?.status).toBe("idle");
|
||||
}
|
||||
|
||||
it("keeps blocked and recovery-origin sources artifact-free", async () => {
|
||||
it.each(["stale_active_run_evaluation", "issue_productivity_review"])("keeps blocked and %s sources artifact-free", async (originKind) => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const blocked = await seedRunningRun({
|
||||
now,
|
||||
@@ -240,7 +240,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
const recursive = await seedRunningRun({
|
||||
now,
|
||||
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceOriginKind: "stale_active_run_evaluation",
|
||||
sourceOriginKind: originKind,
|
||||
});
|
||||
const { enqueueWakeup, recovery } = createRecovery();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
@@ -10139,20 +10140,48 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => {
|
||||
mockAdapterExecute.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "I will inspect the repo next and then implement the fix.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
});
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
it.each([false, true])("enqueues one bounded plan-only continuation with legacy productivity review present: %s", async (withLegacyReview) => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
});
|
||||
const legacyReviewId = randomUUID();
|
||||
if (withLegacyReview) {
|
||||
await db.insert(issues).values({
|
||||
id: legacyReviewId,
|
||||
companyId,
|
||||
title: "Historical productivity review",
|
||||
description: "Keep this review and its existing ownership unchanged.",
|
||||
status: "todo",
|
||||
assigneeUserId: "responsible-user",
|
||||
parentId: issueId,
|
||||
originKind: "issue_productivity_review",
|
||||
originId: issueId,
|
||||
originFingerprint: `productivity-review:${issueId}`,
|
||||
});
|
||||
}
|
||||
const legacyReviewBefore = withLegacyReview
|
||||
? await db.select().from(issues).where(eq(issues.id, legacyReviewId))
|
||||
: [];
|
||||
mockAdapterExecute.mockImplementationOnce(async () => {
|
||||
if (withLegacyReview) {
|
||||
// These pre-dispatch cancellations used to satisfy both the no-comment
|
||||
// and churn thresholds and suppress an otherwise valid continuation.
|
||||
await db.insert(heartbeatRuns).values(Array.from({ length: 10 }, (_, index) => ({
|
||||
id: randomUUID(), companyId, agentId,
|
||||
invocationSource: "automation", triggerDetail: "system", status: "cancelled",
|
||||
errorCode: "execution_reconciliation_required",
|
||||
contextSnapshot: { issueId, taskId: issueId },
|
||||
createdAt: new Date(Date.now() - (index + 1) * 60_000),
|
||||
finishedAt: new Date(),
|
||||
})));
|
||||
}
|
||||
return {
|
||||
exitCode: 0, signal: null, timedOut: false, errorMessage: null,
|
||||
summary: "I will inspect the repo next and then implement the fix.",
|
||||
provider: "test", model: "test-model",
|
||||
};
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.reconcileStrandedAssignedIssues();
|
||||
@@ -10189,6 +10218,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
}
|
||||
expect(sourceRun?.id).not.toBe(runId);
|
||||
expect(sourceRun?.livenessState).toBe("plan_only");
|
||||
if (withLegacyReview) {
|
||||
expect(await db.select().from(issues).where(eq(issues.id, legacyReviewId))).toEqual(legacyReviewBefore);
|
||||
const source = (await issueService(db).list(companyId)).find((issue) => issue.id === issueId);
|
||||
expect(source).toBeDefined();
|
||||
expect(source).not.toHaveProperty("productivityReview");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a plan document update as progress and does not enqueue liveness continuation", async () => {
|
||||
|
||||
@@ -267,10 +267,10 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "productivity_review",
|
||||
reason: "scheduled_maintenance",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: { wakeReason: "productivity_review" },
|
||||
contextSnapshot: { wakeReason: "scheduled_maintenance" },
|
||||
});
|
||||
|
||||
expect(run).not.toBeNull();
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
environmentLeases,
|
||||
environments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -299,6 +301,23 @@ describeEmbeddedPostgres("heartbeat teardown terminalizes the run before releasi
|
||||
expect(releaseRunLeases).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["daytona", "local"])("destroy after failed checkpoint requires a terminal remote owner: %s", async provider => {
|
||||
const { companyId, agentId, runId } = await seed({ issueStatus: "blocked", runStatus: "running" });
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
||||
const [environment] = await db.insert(environments).values({ name: `cleanup-${runId}`, driver: "sandbox" }).returning();
|
||||
await db.insert(environmentLeases).values({ companyId, environmentId: environment.id,
|
||||
heartbeatRunId: runId, agentId, provider, providerLeaseId: "sandbox-owned",
|
||||
status: "active", leasePolicy: "ephemeral" });
|
||||
const releaseRunLeases = vi.fn(async () => []);
|
||||
const heartbeat = heartbeatService(db, {
|
||||
environmentRuntime: { releaseRunLeases } as unknown as HeartbeatEnvironmentRuntime,
|
||||
closeWarmNativeSessionsForRun: async () => ({ closed: 0, busy: 0, failed: 1 }),
|
||||
});
|
||||
await heartbeat.releaseEnvironmentLeasesForRun({ runId, companyId, agentId,
|
||||
status: "cancelled", providerResourceDisposition: "destroy" });
|
||||
expect(releaseRunLeases).toHaveBeenCalledTimes(provider === "daytona" ? 1 : 0);
|
||||
});
|
||||
|
||||
it("terminalizes a running run to succeeded before release when the issue reached done", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seed({ issueStatus: "done", runStatus: "running" });
|
||||
|
||||
|
||||
@@ -2026,11 +2026,12 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect.soft(mockLogActivity).not.toHaveBeenCalled();
|
||||
// This is a route-boundary test, not a fake SQL engine: inspect the
|
||||
// actual compiled predicate so a global or active-only query cannot pass.
|
||||
// Only restricted chat bindings use this recovery gate; email uses normal agent work.
|
||||
expect(db.chatBindingQueries).toHaveLength(1);
|
||||
expect(db.chatBindingQueries[0].sql).toBe(
|
||||
'("chat_conversations"."company_id" = $1 and "chat_conversations"."issue_id" = $2)',
|
||||
'("chat_endpoints"."external_execution_policy" = $1 and "chat_conversations"."company_id" = $2 and "chat_conversations"."issue_id" = $3)',
|
||||
);
|
||||
expect(db.chatBindingQueries[0].params).toEqual([companyId, issueId]);
|
||||
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2157,7 +2158,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
).toHaveBeenCalledExactlyOnceWith(chatRetryActionId);
|
||||
expect(order).toEqual(["begin", "stage", "commit", "dispatch"]);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(db.chatBindingQueries[0].params).toEqual([companyId, issueId]);
|
||||
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
|
||||
});
|
||||
|
||||
it("keeps committed recovery resolution successful when immediate dispatch rejects", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getComment: vi.fn(),
|
||||
listBlockerAttention: vi.fn(),
|
||||
listReviewAttention: vi.fn(),
|
||||
listProductivityReviews: vi.fn(),
|
||||
getCurrentScheduledRetry: vi.fn(),
|
||||
getActiveInboxArchiveFields: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
@@ -212,7 +211,6 @@ describe.sequential("issue goal context routes", () => {
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
|
||||
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
|
||||
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
|
||||
mockIssueService.getActiveInboxArchiveFields.mockResolvedValue({});
|
||||
mockIssueService.listAttachments.mockResolvedValue([]);
|
||||
@@ -270,6 +268,24 @@ describe.sequential("issue goal context routes", () => {
|
||||
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it.each(["", "/heartbeat-context"])("reads historical review tasks without computed productivity fields: %s", async (suffix) => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
...legacyProjectLinkedIssue,
|
||||
originKind: "issue_productivity_review",
|
||||
originId: "historical-source",
|
||||
});
|
||||
const res = await request(createApp()).get(`/api/issues/${legacyProjectLinkedIssue.id}${suffix}`);
|
||||
expect(res.status).toBe(200);
|
||||
const issue = suffix ? res.body.issue : res.body;
|
||||
expect(issue).toMatchObject({
|
||||
originKind: "issue_productivity_review",
|
||||
originId: "historical-source",
|
||||
assigneeAgentId: legacyProjectLinkedIssue.assigneeAgentId,
|
||||
status: legacyProjectLinkedIssue.status,
|
||||
});
|
||||
expect(issue).not.toHaveProperty("productivityReview");
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
reconcileNativeFinalizations,
|
||||
resolveNativeReconciliationStatus,
|
||||
} from "../services/native-runtime/native-finalization-reconciler.js";
|
||||
import * as activityLog from "../services/activity-log.js";
|
||||
import { dismissObsoleteNativePolicyReviews } from "../services/native-runtime/obsolete-policy-reviews.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
@@ -242,11 +244,11 @@ const nativeStatusEffectKinds = new Set<NativeStatusEffect["kind"]>([
|
||||
|
||||
const supersedingDecisionStates = new Set([
|
||||
"new_evidence_satisfies_contract", "dependency_now_done", "explicit_resume_capability",
|
||||
"board_cancelled_before_cas", "new_policy_requires_review", "authorized_writer_incremented_version",
|
||||
"board_cancelled_before_cas", "authorized_writer_incremented_version",
|
||||
]);
|
||||
|
||||
const liveReconciliationStates = new Set([
|
||||
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "new_policy_requires_review",
|
||||
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "policy_version_changed",
|
||||
]);
|
||||
|
||||
function initialRunStatus(fixture: Fixture) {
|
||||
@@ -319,7 +321,6 @@ function reconciliationFactsFor(completionState: string) {
|
||||
case "new_evidence_satisfies_contract": return { newEvidenceSatisfiesContract: true };
|
||||
case "dependency_now_done": return { dependencyResolved: true };
|
||||
case "explicit_resume_capability": return { authorizedResume: true };
|
||||
case "new_policy_requires_review": return { policyVersionChanged: true };
|
||||
case "authorized_writer_incremented_version": return { statusVersionAdvanced: true };
|
||||
default: return null;
|
||||
}
|
||||
@@ -529,7 +530,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
triggerActorCompanyId: companyId,
|
||||
priorIssueStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorStatus,
|
||||
priorStatusVersion: 0,
|
||||
policyVersion: completionState === "new_policy_requires_review"
|
||||
policyVersion: completionState === "policy_version_changed"
|
||||
? "phase6-v1"
|
||||
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
assessmentJson: {
|
||||
@@ -955,7 +956,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
issueId: seeded.issueId,
|
||||
assessmentId: seeded.assessmentId,
|
||||
decisionVersion: 1,
|
||||
policyVersion: completionState === "new_policy_requires_review"
|
||||
policyVersion: completionState === "policy_version_changed"
|
||||
? "phase6-v1"
|
||||
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
fromStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorIssueStatus,
|
||||
@@ -980,20 +981,43 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
updatedAt: new Date(Date.now() + 1_000),
|
||||
}).where(eq(issueWorkProducts.id, seeded.workProductId));
|
||||
}
|
||||
if (completionState === "policy_version_changed") {
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
|
||||
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
|
||||
});
|
||||
}
|
||||
const [reconciled] = await reconcileNativeFinalizations(db, [seeded.runId]);
|
||||
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
|
||||
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
|
||||
if (completionState === "policy_version_changed") {
|
||||
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
|
||||
const assessments = await db.select().from(workAssessments).where(eq(workAssessments.issueId, seeded.issueId));
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]!.id).toBe(priorDecision!.id);
|
||||
expect(assessments).toHaveLength(1);
|
||||
expect(assessments[0]!.policyVersion).toBe("phase6-v1");
|
||||
semanticConsumer = "native-reconciliation-consumer";
|
||||
consumerDecision = pushDecisionConsumer(semanticConsumer, {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve", toStatus: decisions[0]!.toStatus as NativeStatusDecision["toStatus"],
|
||||
reasonCode: decisions[0]!.reasonCode, unblockDescriptor: null, effects: [],
|
||||
});
|
||||
liveEntrypointCommitted = true;
|
||||
consumerExecutions.push({ consumer: "native-reconciliation-entrypoint", observed: { decisionId: priorDecision!.id } });
|
||||
} else {
|
||||
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
|
||||
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
|
||||
}
|
||||
semanticConsumer = "native-reconciliation-consumer";
|
||||
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
|
||||
liveEntrypointCommitted = true;
|
||||
consumerExecutions.push({
|
||||
consumer: "native-reconciliation-entrypoint",
|
||||
observed: {
|
||||
action: reconciled.reconciliationAction,
|
||||
decisionId: reconciled.decisionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
semanticConsumer = "native-reconciliation-consumer";
|
||||
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
|
||||
liveEntrypointCommitted = true;
|
||||
consumerExecutions.push({
|
||||
consumer: "native-reconciliation-entrypoint",
|
||||
observed: {
|
||||
action: reconciled.reconciliationAction,
|
||||
decisionId: reconciled.decisionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
seeded.nativeRecords
|
||||
@@ -1858,7 +1882,10 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
];
|
||||
for (const [field, expected] of mutations) {
|
||||
const mutated = { ...fixture, expected };
|
||||
expect(comparisonFailures(mutated, observed), `${fixture.id}:${field}`).not.toEqual([]);
|
||||
const mutationObserved = field === "forbiddenEffects" && observed.effects.length === 0
|
||||
? { ...observed, effects: [observedEffect] }
|
||||
: observed;
|
||||
expect(comparisonFailures(mutated, mutationObserved), `${fixture.id}:${field}`).not.toEqual([]);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1973,24 +2000,177 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
.rejects.toThrow("native_pending_effect_target_missing:enqueue_continuation");
|
||||
}, 30_000);
|
||||
|
||||
it("preserves terminal issues when a newer reconciliation policy is available", () => {
|
||||
expect(resolveNativeReconciliationStatus({
|
||||
facts: { policyVersionChanged: true },
|
||||
priorIssueStatus: "done",
|
||||
agentId,
|
||||
})).toMatchObject({
|
||||
statusAction: "preserve",
|
||||
toStatus: "done",
|
||||
reasonCode: "prior_status_terminal_preserved",
|
||||
effects: [{ kind: "append_superseding_assessment" }],
|
||||
async function seedPolicyReview(options: { genuine?: boolean; priorStatus?: "in_progress" | "blocked" | "in_review" } = {}) {
|
||||
const template = corpus.fixtures.find((candidate) => candidate.mode === "native")!;
|
||||
const priorStatus = options.priorStatus ?? "in_progress";
|
||||
const seeded = await seedFixture({
|
||||
...template, id: `policy-review-${randomUUID()}`,
|
||||
given: { ...template.given, priorIssueStatus: priorStatus, completionState: "policy_review_cleanup" },
|
||||
});
|
||||
const [assessment] = await db.select().from(workAssessments).where(eq(workAssessments.id, seeded.assessmentId));
|
||||
const previousAssessmentId = randomUUID();
|
||||
await db.insert(workAssessments).values({
|
||||
...assessment!, id: previousAssessmentId, policyVersion: "previous-policy",
|
||||
inputDigest: `previous-assessment:${seeded.issueId}`,
|
||||
});
|
||||
await db.update(workAssessments).set({ supersedesAssessmentId: previousAssessmentId })
|
||||
.where(eq(workAssessments.id, seeded.assessmentId));
|
||||
const committed = await commitNativeStatusDecision({
|
||||
db, companyId, issueId: seeded.issueId, runId: seeded.runId,
|
||||
assessmentId: seeded.assessmentId, priorStatus, priorStatusVersion: 0, priorDecisionId: null,
|
||||
decision: {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "in_review", toStatus: "in_review", reasonCode: "completion_review_required", unblockDescriptor: null,
|
||||
effects: options.genuine
|
||||
? [{ kind: "bind_reviewer", prompt: "Review the release before publishing.", ownerUserId: null }]
|
||||
: [
|
||||
{ kind: "bind_reviewer", prompt: "Review the superseding native policy assessment.", ownerUserId: null },
|
||||
{ kind: "append_superseding_assessment" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, seeded.issueId));
|
||||
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, committed.decision.id));
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
|
||||
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
|
||||
});
|
||||
return { ...seeded, decision: decision!, interaction: interaction! };
|
||||
}
|
||||
|
||||
it("withdraws obsolete policy reviews, restores the prior status, and is idempotent", async () => {
|
||||
const seeded = await seedPolicyReview();
|
||||
await reconcileNativeFinalizations(db, [seeded.runId]);
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(interaction).toMatchObject({ status: "cancelled", result: { outcome: "withdrawn" } });
|
||||
expect(issue).toMatchObject({ status: "in_progress", statusVersion: 2, lastStatusDecisionId: null });
|
||||
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
|
||||
expect(decisions.find((row) => row.id === seeded.decision.id)).toEqual(seeded.decision);
|
||||
await reconcileNativeFinalizations(db, [seeded.runId]);
|
||||
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual([issue]);
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).toEqual(decisions);
|
||||
}, 30_000);
|
||||
|
||||
it.each(["accepted", "rejected"])("leaves an already %s review untouched", async (status) => {
|
||||
const seeded = await seedPolicyReview();
|
||||
await db.update(issueThreadInteractions).set({ status }).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ status: "in_review", lastStatusDecisionId: seeded.decision.id });
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
expect(interaction!.status).toBe(status);
|
||||
});
|
||||
|
||||
it("preserves real completion reviews and limits cleanup to the requested runs", async () => {
|
||||
const genuine = await seedPolicyReview({ genuine: true });
|
||||
const other = await seedPolicyReview();
|
||||
await dismissObsoleteNativePolicyReviews(db, [genuine.runId]);
|
||||
for (const seeded of [genuine, other]) {
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
expect(interaction!.status).toBe("pending");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["blocked", "done", "cancelled"])("dismisses the obsolete card without undoing a later %s status", async (status) => {
|
||||
const seeded = await seedPolicyReview();
|
||||
await issueService(db).update(seeded.issueId, { status });
|
||||
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
|
||||
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
expect(interaction!.status).toBe(status === "blocked" ? "cancelled" : "expired");
|
||||
});
|
||||
|
||||
it("does not restore status after a newer decision or a status change back to review", async () => {
|
||||
for (const changedPointer of [false, true]) {
|
||||
const seeded = await seedPolicyReview();
|
||||
if (changedPointer) {
|
||||
await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, seeded.issueId));
|
||||
} else {
|
||||
await issueService(db).update(seeded.issueId, { status: "in_progress" });
|
||||
await issueService(db).update(seeded.issueId, { status: "in_review" });
|
||||
}
|
||||
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
|
||||
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps review status when a separate request still needs a response", async () => {
|
||||
const seeded = await seedPolicyReview();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId: seeded.issueId, kind: "request_confirmation", status: "pending",
|
||||
payload: { version: 1, prompt: "Approve publishing the release." },
|
||||
});
|
||||
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue!.status).toBe("in_review");
|
||||
});
|
||||
|
||||
it("isolates a failed cleanup candidate and retries it on the next pass", async () => {
|
||||
const first = await seedPolicyReview();
|
||||
const second = await seedPolicyReview();
|
||||
const runIds = [first.runId, second.runId];
|
||||
const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("injected cleanup failure"));
|
||||
try {
|
||||
await expect(dismissObsoleteNativePolicyReviews(db, runIds)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
transaction.mockRestore();
|
||||
}
|
||||
const statuses = await Promise.all([first, second].map(async (seeded) => {
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
return interaction!.status;
|
||||
}));
|
||||
expect(statuses.sort()).toEqual(["cancelled", "pending"]);
|
||||
await dismissObsoleteNativePolicyReviews(db, runIds);
|
||||
for (const seeded of [first, second]) {
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue!.status).toBe("in_progress");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps committed cleanup and continues publication after a live event fails", async () => {
|
||||
const first = await seedPolicyReview();
|
||||
const second = await seedPolicyReview();
|
||||
const publish = vi.spyOn(activityLog, "publishActivity").mockImplementationOnce(() => {
|
||||
throw new Error("injected live publication failure");
|
||||
});
|
||||
try {
|
||||
await dismissObsoleteNativePolicyReviews(db, [first.runId, second.runId]);
|
||||
expect(publish.mock.calls.length).toBeGreaterThan(1);
|
||||
for (const seeded of [first, second]) {
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
|
||||
expect(issue!.status).toBe("in_progress");
|
||||
expect(interaction!.status).toBe("cancelled");
|
||||
}
|
||||
} finally {
|
||||
publish.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues native finalization when the obsolete-card lookup fails", async () => {
|
||||
const seeded = await seedPolicyReview({ genuine: true });
|
||||
const select = vi.spyOn(db, "select").mockImplementationOnce(() => {
|
||||
throw new Error("injected cleanup lookup failure");
|
||||
});
|
||||
try {
|
||||
const reconciled = await reconcileNativeFinalizations(db, [seeded.runId]);
|
||||
expect(reconciled).toHaveLength(1);
|
||||
expect(reconciled[0]!.phase).toBe("committed");
|
||||
} finally {
|
||||
select.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a later authoritative status during reconciliation", () => {
|
||||
expect(resolveNativeReconciliationStatus({
|
||||
facts: { authoritativeStatusChanged: true, policyVersionChanged: true },
|
||||
facts: { authoritativeStatusChanged: true },
|
||||
priorIssueStatus: "blocked",
|
||||
agentId,
|
||||
})).toMatchObject({
|
||||
statusAction: "preserve",
|
||||
toStatus: "blocked",
|
||||
statusAction: "preserve", toStatus: "blocked",
|
||||
reasonCode: "prior_status_terminal_preserved",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"board-chat.ts": "/api",
|
||||
"built-in-agents.ts": "/api",
|
||||
"chat-channels.ts": "/api",
|
||||
"email.ts": "/api",
|
||||
"cloud.ts": "/api/cloud",
|
||||
"companies.ts": "/api/companies",
|
||||
"company-skills.ts": "/api",
|
||||
@@ -90,6 +91,7 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
|
||||
// This endpoint is authenticated by the provider signature rather than by a
|
||||
// Paperclip board/agent credential. It intentionally stays out of the public
|
||||
// board API document, while this exact exclusion keeps route coverage honest.
|
||||
"POST /api/chat-webhooks/agentmail/{publicId}",
|
||||
"POST /api/chat-webhooks/{publicId}/{provider}",
|
||||
]);
|
||||
|
||||
@@ -128,7 +130,7 @@ function normalizeExpressPath(routePath: string) {
|
||||
|
||||
function resolveMountedPath(file: string, prefix: string, routePath: string) {
|
||||
if (
|
||||
file === "chat-channels.ts" &&
|
||||
(file === "chat-channels.ts" || file === "email.ts") &&
|
||||
routePath.startsWith("/api/chat-webhooks/")
|
||||
) {
|
||||
return routePath;
|
||||
|
||||
@@ -1636,10 +1636,13 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
emitOnInput: true,
|
||||
outputs: [{ chunk: "legacy-output", omitHostRouteId: true }],
|
||||
}),
|
||||
);
|
||||
route.onData((chunk) => chunks.push(chunk));
|
||||
// Legacy notifications require the open reply to bind the worker ID.
|
||||
route.write("emit-scripted-output");
|
||||
await vi.waitFor(() => expect(chunks).toContain("legacy-output"));
|
||||
|
||||
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
|
||||
@@ -1661,8 +1664,9 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
|
||||
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true, emitOnInput: true }),
|
||||
);
|
||||
route.write("emit-scripted-exit");
|
||||
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
|
||||
@@ -1,762 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
|
||||
PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX,
|
||||
PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
productivityReviewService,
|
||||
} from "../services/productivity-review.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres productivity review tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("productivity review service", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-productivity-review-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
}, 30_000);
|
||||
|
||||
async function seedAssignedIssue(opts?: {
|
||||
status?: "todo" | "in_progress";
|
||||
startedAt?: Date;
|
||||
parentId?: string | null;
|
||||
originKind?: string;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `PR${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const createdAt = new Date("2026-04-28T10:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Productivity Review Co",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: managerId,
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "cto",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: coderId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Implement data import",
|
||||
status: opts?.status ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: coderId,
|
||||
parentId: opts?.parentId ?? null,
|
||||
originKind: opts?.originKind ?? "manual",
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
startedAt: opts?.startedAt ?? createdAt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
return { companyId, managerId, coderId, issueId, issuePrefix, createdAt };
|
||||
}
|
||||
|
||||
async function insertRuns(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
count: number;
|
||||
now: Date;
|
||||
withRunComments?: boolean;
|
||||
}) {
|
||||
const runs: Array<typeof heartbeatRuns.$inferInsert> = [];
|
||||
for (let index = 0; index < input.count; index += 1) {
|
||||
const runId = randomUUID();
|
||||
const createdAt = new Date(input.now.getTime() - index * 60_000);
|
||||
runs.push({
|
||||
id: runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: "succeeded",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
startedAt: createdAt,
|
||||
finishedAt: new Date(createdAt.getTime() + 30_000),
|
||||
contextSnapshot: { issueId: input.issueId, taskId: input.issueId },
|
||||
livenessState: "advanced",
|
||||
nextAction: "Continue processing the next batch.",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
}
|
||||
await db.insert(heartbeatRuns).values(runs);
|
||||
|
||||
if (input.withRunComments) {
|
||||
await db.insert(issueComments).values(
|
||||
runs.map((run, index) => ({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
authorAgentId: input.agentId,
|
||||
createdByRunId: run.id,
|
||||
body: `Progress update ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
async function listProductivityReviews(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND)))
|
||||
.orderBy(issues.createdAt);
|
||||
}
|
||||
|
||||
async function listRefreshComments(reviewIssueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.issueId, reviewIssueId),
|
||||
sql`${issueComments.body} like ${`${PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX}%`}`,
|
||||
))
|
||||
.orderBy(issueComments.createdAt);
|
||||
}
|
||||
|
||||
it("creates exactly one manager-assigned review for a no-comment run streak and rate-limits immediate refresh", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const service = productivityReviewService(db);
|
||||
const first = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const second = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
|
||||
expect(first.created).toBe(1);
|
||||
expect(second.updated).toBe(0);
|
||||
expect(second.existing).toBe(1);
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
expect(reviews).toHaveLength(1);
|
||||
expect(reviews[0]?.parentId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
|
||||
expect(reviews[0]?.assigneeAdapterOverrides).toBeNull();
|
||||
expect(reviews[0]?.originId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
|
||||
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
|
||||
expect(reviews[0]?.description).toContain("No-comment completed-run streak: 10");
|
||||
|
||||
expect(await listRefreshComments(reviews[0]!.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refreshes open productivity reviews only once per interval and caps refresh comments", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
const firstRefreshAt = new Date(now.getTime() + DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS);
|
||||
const firstRefresh = await service.reconcileProductivityReviews({
|
||||
now: firstRefreshAt,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const tooSoonRefresh = await service.reconcileProductivityReviews({
|
||||
now: new Date(firstRefreshAt.getTime() + 30 * 60 * 1000),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
await service.reconcileProductivityReviews({
|
||||
now: new Date(firstRefreshAt.getTime() + DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
await service.reconcileProductivityReviews({
|
||||
now: new Date(firstRefreshAt.getTime() + 2 * DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const cappedRefresh = await service.reconcileProductivityReviews({
|
||||
now: new Date(firstRefreshAt.getTime() + 3 * DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(firstRefresh.updated).toBe(1);
|
||||
expect(tooSoonRefresh.updated).toBe(0);
|
||||
expect(tooSoonRefresh.existing).toBe(1);
|
||||
expect(cappedRefresh.updated).toBe(0);
|
||||
expect(cappedRefresh.existing).toBe(1);
|
||||
expect(await listRefreshComments(review!.id)).toHaveLength(DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS);
|
||||
});
|
||||
|
||||
it("allows only one productivity review per source issue in 24 hours", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
const createdAt = new Date(now.getTime() - 8 * 60 * 60 * 1000);
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId: seeded.companyId,
|
||||
title: "Completed productivity review",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: 2,
|
||||
identifier: `${seeded.issuePrefix}-2`,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.creationCapped).toBe(1);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("suppresses creation after three consecutive completed reviews with no source action", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
await db.insert(issues).values(
|
||||
[96, 72, 48].map((hoursAgo, index) => {
|
||||
const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: seeded.companyId,
|
||||
title: `No-action productivity review ${index + 1}`,
|
||||
status: "done",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: index + 2,
|
||||
identifier: `${seeded.issuePrefix}-${index + 2}`,
|
||||
createdAt,
|
||||
updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.noActionSuppressed).toBe(1);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("resets no-action suppression for source action after a zero-duration review", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
const reviewWindows = [96, 72, 48].map((hoursAgo, index) => {
|
||||
const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: seeded.companyId,
|
||||
title: `Productivity review ${index + 1}`,
|
||||
status: "done" as const,
|
||||
priority: "high" as const,
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: index + 2,
|
||||
identifier: `${seeded.issuePrefix}-${index + 2}`,
|
||||
createdAt,
|
||||
updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
|
||||
};
|
||||
});
|
||||
const actedReview = reviewWindows[1]!;
|
||||
actedReview.updatedAt = actedReview.createdAt;
|
||||
await db.insert(issues).values(reviewWindows);
|
||||
await db.insert(activityLog).values({
|
||||
companyId: seeded.companyId,
|
||||
actorType: "agent",
|
||||
actorId: seeded.coderId,
|
||||
agentId: seeded.coderId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: seeded.issueId,
|
||||
createdAt: new Date(actedReview.createdAt.getTime() + 2 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.noActionSuppressed).toBe(0);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("uses review creation order for no-action streak windows", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
const reviewWindows = [
|
||||
{ hoursAgo: 96, updatedAt: new Date(now.getTime() - 95 * 60 * 60 * 1000) },
|
||||
{ hoursAgo: 72, updatedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000) },
|
||||
{ hoursAgo: 48, updatedAt: new Date(now.getTime() - 47 * 60 * 60 * 1000) },
|
||||
].map((window, index) => {
|
||||
const createdAt = new Date(now.getTime() - window.hoursAgo * 60 * 60 * 1000);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: seeded.companyId,
|
||||
title: `Productivity review ordered window ${index + 1}`,
|
||||
status: "done" as const,
|
||||
priority: "high" as const,
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: index + 2,
|
||||
identifier: `${seeded.issuePrefix}-${index + 2}`,
|
||||
createdAt,
|
||||
updatedAt: window.updatedAt,
|
||||
};
|
||||
});
|
||||
const middleReviewCreatedAt = reviewWindows[1]!.createdAt;
|
||||
await db.insert(issues).values(reviewWindows);
|
||||
await db.insert(activityLog).values({
|
||||
companyId: seeded.companyId,
|
||||
actorType: "agent",
|
||||
actorId: seeded.coderId,
|
||||
agentId: seeded.coderId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: seeded.issueId,
|
||||
createdAt: new Date(middleReviewCreatedAt.getTime() + 60_000),
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
thresholds: { maxConsecutiveNoActionReviews: 1 },
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.noActionSuppressed).toBe(1);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not count cancelled productivity reviews toward the creation cap", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
await db.insert(issues).values(
|
||||
[8, 9, 10].map((hoursAgo, index) => {
|
||||
const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: seeded.companyId,
|
||||
title: `Cancelled productivity review ${index + 1}`,
|
||||
status: "cancelled",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: index + 2,
|
||||
identifier: `${seeded.issuePrefix}-${index + 2}`,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.creationCapped).toBe(0);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("creates a long-active review without enabling a continuation hold", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
|
||||
const result = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `long_active_duration`");
|
||||
expect(review?.priority).toBe("medium");
|
||||
expect(hold.held).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a long-active candidate while its assignee is paused and reviews it once unpaused", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, seeded.coderId));
|
||||
const service = productivityReviewService(db);
|
||||
|
||||
const pausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
|
||||
expect(pausedResult.created).toBe(0);
|
||||
expect(pausedResult.skipped).toBe(1);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
|
||||
|
||||
await db.update(agents).set({ status: "idle" }).where(eq(agents.id, seeded.coderId));
|
||||
const unpausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
|
||||
expect(unpausedResult.created).toBe(1);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("creates a high-churn review even when every sampled run has a progress comment", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
withRunComments: true,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `high_churn`");
|
||||
expect(review?.description).toContain("Runs in rolling windows: 10/1h");
|
||||
});
|
||||
|
||||
it("ignores non-assignee comments when evaluating high-churn productivity reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 9,
|
||||
now,
|
||||
});
|
||||
const managerRuns = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.managerId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
await db.insert(issueComments).values(
|
||||
managerRuns.map((run, index) => ({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
authorAgentId: seeded.managerId,
|
||||
createdByRunId: run.id,
|
||||
body: `Manager note ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips productivity-review descendants so reviews cannot recursively spawn reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const reviewId = randomUUID();
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: reviewId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Existing productivity review",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: 2,
|
||||
identifier: `${seeded.issuePrefix}-2`,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: childId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Review follow-up child",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: seeded.coderId,
|
||||
parentId: reviewId,
|
||||
issueNumber: 3,
|
||||
identifier: `${seeded.issuePrefix}-3`,
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: childId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a recently completed review as a snooze window", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "done", updatedAt: now })
|
||||
.where(eq(issues.id, review!.id));
|
||||
|
||||
const result = await service.reconcileProductivityReviews({
|
||||
now: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.snoozed).toBe(1);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a recently cancelled review as a snooze window", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "cancelled", updatedAt: now })
|
||||
.where(eq(issues.id, review!.id));
|
||||
|
||||
const result = await service.reconcileProductivityReviews({
|
||||
now: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.snoozed).toBe(1);
|
||||
expect(result.created).toBe(0);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports and logs soft-stop holds for open no-comment reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const [latestRun] = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
expect(hold.held).toBe(true);
|
||||
if (!hold.held) return;
|
||||
|
||||
await service.recordContinuationHold({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
runId: latestRun!.id as string,
|
||||
agentId: seeded.coderId,
|
||||
reviewIssueId: review!.id,
|
||||
trigger: hold.trigger,
|
||||
reason: hold.reason,
|
||||
});
|
||||
const activities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.productivity_review_continuation_held"));
|
||||
expect(activities).toHaveLength(1);
|
||||
expect(activities[0]?.entityId).toBe(seeded.issueId);
|
||||
});
|
||||
|
||||
it("clamps poisoned requestDepth metadata instead of aborting productivity reconciliation", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ requestDepth: 2_147_483_647 })
|
||||
.where(eq(issues.id, seeded.issueId));
|
||||
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,6 @@ const {
|
||||
scanSilentActiveRuns: vi.fn(async () => ({ created: 0, escalated: 0 })),
|
||||
sweepStaleIssueLocks: vi.fn(async () => ({ cleared: 0 })),
|
||||
sweepPendingCleanupLeases: vi.fn(async () => ({ swept: 0, destroyed: 0, capped: 0 })),
|
||||
reconcileProductivityReviews: vi.fn(async () => ({ created: 0, updated: 0, failed: 0 })),
|
||||
sweepExpiredRuntimeStatuses: vi.fn(() => 0),
|
||||
tickTimers: vi.fn(async () => ({ checked: 0, enqueued: 0, skipped: 0 })),
|
||||
};
|
||||
@@ -519,6 +518,32 @@ describe("startServer feedback export wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("never invokes the retired review detector at startup or on periodic recovery", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
heartbeatSchedulerEnabled: true,
|
||||
heartbeatSchedulerIntervalMs: 30000,
|
||||
}));
|
||||
const retiredDetector = vi.fn(async () => ({ created: 1, updated: 1, failed: 0 }));
|
||||
const runtime = Object.assign(heartbeatServiceMock, { reconcileProductivityReviews: retiredDetector });
|
||||
let intervalCallback: (() => void) | null = null;
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void) => {
|
||||
intervalCallback = callback;
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval);
|
||||
try {
|
||||
await startServer();
|
||||
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(1);
|
||||
expect(intervalCallback).not.toBeNull();
|
||||
intervalCallback?.();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
|
||||
expect(retiredDetector).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete (runtime as Partial<typeof runtime>).reconcileProductivityReviews;
|
||||
setIntervalSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps routine ticks and setup cleanup active when heartbeat scheduling is suppressed", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
heartbeatSchedulerEnabled: true,
|
||||
|
||||
@@ -4972,6 +4972,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
});
|
||||
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"agentmail",
|
||||
"jira",
|
||||
"airtable",
|
||||
"asana",
|
||||
@@ -4992,7 +4993,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
"github",
|
||||
]),
|
||||
);
|
||||
expect(res.body.apps).toHaveLength(40);
|
||||
expect(res.body.apps).toHaveLength(41);
|
||||
expect(
|
||||
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
|
||||
.ownershipAvailability,
|
||||
|
||||
@@ -4612,7 +4612,9 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
command: serviceCommand,
|
||||
cwd: ".",
|
||||
port: { type: "auto" as const },
|
||||
readiness: { type: "http" as const, urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 3, intervalMs: 100 },
|
||||
// This checks replacement, not startup latency. Allow the same startup
|
||||
// budget as other real-process fixtures on busy CI hosts.
|
||||
readiness: { type: "http" as const, urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10, intervalMs: 100 },
|
||||
expose: { type: "url" as const, urlTemplate: "http://127.0.0.1:{{port}}" },
|
||||
lifecycle: "shared" as const,
|
||||
stopPolicy: { type: "manual" as const },
|
||||
@@ -4638,7 +4640,7 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
});
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("reuses a shared Paperclip dev runtime after one transient unhealthy response", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-transient-health-"));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emailChannelService } from "./services/email-channels.js";
|
||||
import { emailRoutes, emailWebhookRoutes } from "./routes/email.js";
|
||||
import { toolActionDeliveryService } from "./services/tool-action-delivery.js";
|
||||
import express, { Router, type Request as ExpressRequest } from "express";
|
||||
import {
|
||||
@@ -583,6 +585,8 @@ export async function createApp(
|
||||
// Provider-authenticated ingress is intentionally outside the board
|
||||
// mutation guard. The Chat SDK adapter verifies the provider signature
|
||||
// before Paperclip persists or acts on any event.
|
||||
const emailChannels = emailChannelService(db, { heartbeat: connectionIntentHeartbeat, storage: opts.storageService, publicBaseUrl: opts.chatWebhookPublicBaseUrl ?? opts.authPublicBaseUrl });
|
||||
app.use(emailWebhookRoutes(emailChannels));
|
||||
app.use(chatWebhookRoutes(chatChannels));
|
||||
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
|
||||
const bundledCatalogRoot =
|
||||
@@ -741,6 +745,7 @@ export async function createApp(
|
||||
}),
|
||||
);
|
||||
api.use(executionWorkspaceRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(emailRoutes(db, emailChannels));
|
||||
api.use(goalRoutes(db));
|
||||
api.use(onboardingSeedRoutes(db));
|
||||
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
|
||||
@@ -1126,6 +1131,7 @@ export async function createApp(
|
||||
if (opts.feedbackExportService) {
|
||||
void flushPendingFeedbackExports();
|
||||
}
|
||||
emailChannels.start();
|
||||
const flushChatPublications = async () => {
|
||||
await chatChannels.schedulePendingPublications();
|
||||
};
|
||||
@@ -1297,6 +1303,7 @@ export async function createApp(
|
||||
viteHmrServer?.close();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
await emailChannels.shutdown();
|
||||
await chatChannels.shutdown();
|
||||
// Cancel every live setup-token login session and AWAIT the cancellation,
|
||||
// so each direct child stops and the server releases each lease before the
|
||||
|
||||
@@ -2,7 +2,40 @@ import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-
|
||||
|
||||
/** Trusted operator HTML only. This content is public and runs in the app origin. */
|
||||
export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string {
|
||||
const snippet = env.PAPERCLIP_CLOUD_UI_SNIPPET;
|
||||
if (!isCloudManagedInstance(env) || !snippet?.trim()) return html;
|
||||
const snippet = resolveCloudUiSnippet(env);
|
||||
if (!isCloudManagedInstance(env) || !snippet) return html;
|
||||
return html.replace(/<\/body>/i, () => `${snippet}\n</body>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A present plain variable always wins — blank included, so clearing it to
|
||||
* blank disables injection even when a base64 value is still deployed. The
|
||||
* base64 variant exists because delivery pipelines that write env vars
|
||||
* through provider APIs can sit behind web application firewalls that
|
||||
* reject values containing raw script markup; base64 carries the same
|
||||
* snippet through them unchanged.
|
||||
*/
|
||||
function resolveCloudUiSnippet(env: CloudInstanceEnv): string | null {
|
||||
const plain = env.PAPERCLIP_CLOUD_UI_SNIPPET;
|
||||
if (plain !== undefined) return plain.trim() ? plain : null;
|
||||
const encoded = env.PAPERCLIP_CLOUD_UI_SNIPPET_B64?.replace(/\s+/g, "");
|
||||
if (!encoded) return null;
|
||||
const decoded = decodeBase64(encoded);
|
||||
return decoded?.trim() ? decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value that is not canonical, padded base64 of valid UTF-8 is ignored
|
||||
* rather than injected as garbage: the round trip rejects stray padding
|
||||
* bits, and the fatal decoder rejects byte sequences that are not UTF-8.
|
||||
*/
|
||||
function decodeBase64(encoded: string): string | null {
|
||||
if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null;
|
||||
const bytes = Buffer.from(encoded, "base64");
|
||||
if (bytes.toString("base64") !== encoded) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1560,11 +1560,6 @@ async function startServerWithDatabaseTeardown(
|
||||
if (swept.cleared > 0) {
|
||||
logger.warn({ ...swept }, "startup stale-lock sweeper cleared issue locks");
|
||||
}
|
||||
|
||||
const reviewed = await heartbeat.reconcileProductivityReviews();
|
||||
if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
|
||||
logger.warn({ ...reviewed }, "startup productivity reconciliation created or updated review work");
|
||||
}
|
||||
})().catch((err) => {
|
||||
logger.error({ err }, "startup heartbeat recovery failed");
|
||||
throw err;
|
||||
@@ -1805,12 +1800,6 @@ async function startServerWithDatabaseTeardown(
|
||||
logger.warn({ ...swept }, "periodic stale-lock sweeper cleared issue locks");
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
const reviewed = await heartbeat.reconcileProductivityReviews();
|
||||
if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
|
||||
logger.warn({ ...reviewed }, "periodic productivity reconciliation created or updated review work");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "periodic heartbeat recovery failed");
|
||||
}));
|
||||
|
||||
+42
-13
@@ -1,3 +1,4 @@
|
||||
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
|
||||
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
|
||||
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
@@ -2900,8 +2901,8 @@ export function agentRoutes(
|
||||
requestedSkillEntries,
|
||||
mode,
|
||||
).filter(
|
||||
(entry) => adapterType !== "paperclip_runner"
|
||||
|| entry.key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
(entry) => !isConnectorSkill(entry.key) && (adapterType !== "paperclip_runner"
|
||||
|| entry.key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY),
|
||||
);
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
const resolvedKeys = new Set([
|
||||
@@ -3139,9 +3140,32 @@ export function agentRoutes(
|
||||
if (requestedEnvironmentId) {
|
||||
await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironmentId);
|
||||
}
|
||||
// Agent reads redact every plain environment value. When this is a saved
|
||||
// agent test, restore those display-only placeholders from the
|
||||
// server-side config before validating or resolving secrets; otherwise
|
||||
// the probe treats "***REDACTED***" as a value to persist.
|
||||
const savedAgentId = typeof req.body.agentId === "string" ? req.body.agentId : null;
|
||||
let adapterConfigForTest = inputAdapterConfig;
|
||||
if (savedAgentId) {
|
||||
const savedAgent = await getAccessibleResource(req, res, svc.getById(savedAgentId), "Agent not found");
|
||||
if (!savedAgent) return;
|
||||
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
const providerAdapter = savedAgent.adapterType === "paperclip_runner"
|
||||
? inputAdapterConfig.provider === "codex"
|
||||
? "codex_local"
|
||||
: inputAdapterConfig.provider === "acpx" && inputAdapterConfig.acpxAgent === "claude"
|
||||
? "claude_local"
|
||||
: null
|
||||
: null;
|
||||
if (savedAgent.adapterType !== type && providerAdapter !== type) {
|
||||
throw unprocessable("Saved agent is not compatible with the adapter being tested");
|
||||
}
|
||||
await assertCanUpdateAgent(req, savedAgent);
|
||||
adapterConfigForTest = restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig);
|
||||
}
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
inputAdapterConfig,
|
||||
adapterConfigForTest,
|
||||
{ strictMode: strictSecretsMode, adapterType: type },
|
||||
);
|
||||
// Prospective, non-persisted config: resolve the acting user's own user
|
||||
@@ -3596,13 +3620,15 @@ export function agentRoutes(
|
||||
runtimeConfig,
|
||||
{ materializeMissing: false },
|
||||
);
|
||||
const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id });
|
||||
const connectorConfig = await applyConnectorSkills(runtimeSkillConfig, runtimeSkillConfig.paperclipRuntimeSkills, connectorAssignments);
|
||||
const snapshot = await adapter.listSkills({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
adapterType: agent.adapterType,
|
||||
config: runtimeSkillConfig,
|
||||
config: connectorConfig,
|
||||
});
|
||||
res.json(snapshot);
|
||||
res.json(annotateConnectorSkills(snapshot, connectorAssignments));
|
||||
});
|
||||
|
||||
router.post(
|
||||
@@ -3657,17 +3683,16 @@ export function agentRoutes(
|
||||
buildActorSecretContext(req, { consumerType: "agent", consumerId: updated.id }),
|
||||
{ adapterType: updated.adapterType, skipUserSecrets: true },
|
||||
);
|
||||
const runtimeSkillConfig = {
|
||||
...runtimeConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const snapshot = adapter?.syncSkills
|
||||
const connectorAssignments = await resolveConnectorAssignments(db, { companyId: updated.companyId, agentId: updated.id });
|
||||
const runtimeSkillConfig = await applyConnectorSkills(runtimeConfig, runtimeSkillEntries, connectorAssignments);
|
||||
const manualSkillConfig = await applyConnectorSkills(runtimeConfig, runtimeSkillEntries, []);
|
||||
let snapshot = adapter?.syncSkills
|
||||
? await adapter.syncSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeSkillConfig,
|
||||
}, desiredSkills)
|
||||
config: manualSkillConfig,
|
||||
}, readPaperclipSkillSyncPreference(manualSkillConfig).desiredSkills)
|
||||
: adapter?.listSkills
|
||||
? await adapter.listSkills({
|
||||
agentId: updated.id,
|
||||
@@ -3677,6 +3702,10 @@ export function agentRoutes(
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries);
|
||||
|
||||
if (connectorAssignments.length && adapter?.listSkills) {
|
||||
snapshot = await adapter.listSkills({ agentId: updated.id, companyId: updated.companyId,
|
||||
adapterType: updated.adapterType, config: runtimeSkillConfig });
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -3699,7 +3728,7 @@ export function agentRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
res.json(snapshot);
|
||||
res.json(annotateConnectorSkills(snapshot, connectorAssignments));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ export function chatWebhookRoutes(
|
||||
});
|
||||
}
|
||||
const provider = req.params.provider as ChatProvider;
|
||||
if (!CHAT_PROVIDERS.includes(provider))
|
||||
if (!CHAT_PROVIDERS.includes(provider) || provider === "agentmail")
|
||||
throw badRequest("Unsupported chat provider");
|
||||
const response = await service.handleWebhook(
|
||||
req.params.publicId as string,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
emailConnectionSchema,
|
||||
emailEndpointSetupSchema,
|
||||
emailSendSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
|
||||
import { emailConnectionService } from "../services/email-connections.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { forbidden, notFound } from "../errors.js";
|
||||
import type {
|
||||
EmailChannelService,
|
||||
EmailActor,
|
||||
} from "../services/email-channels.js";
|
||||
|
||||
function actor(req: Request): EmailActor {
|
||||
return req.actor.type === "agent"
|
||||
? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined }
|
||||
: {
|
||||
userId: req.actor.userId ?? "board",
|
||||
localImplicit: req.actor.source === "local_implicit",
|
||||
};
|
||||
}
|
||||
export function emailRoutes(db: Db, service: EmailChannelService) {
|
||||
const router = Router();
|
||||
async function manager(req: Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
if (!hasCompanyAccess(req, companyId))
|
||||
throw notFound("Email inbox not found");
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
|
||||
return;
|
||||
if (
|
||||
!req.actor.userId ||
|
||||
!(await accessService(db).hasPermission(
|
||||
companyId,
|
||||
"user",
|
||||
req.actor.userId,
|
||||
"tools:manage_connections",
|
||||
))
|
||||
)
|
||||
throw forbidden("Missing permission: tools:manage_connections");
|
||||
}
|
||||
router.post(
|
||||
"/companies/:companyId/email/connections",
|
||||
validate(emailConnectionSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await manager(req, companyId);
|
||||
await service.requireEnabled();
|
||||
res
|
||||
.status(201)
|
||||
.json(
|
||||
await emailConnectionService(db).connect(
|
||||
companyId,
|
||||
req.body,
|
||||
actor(req),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/companies/:companyId/email/connections/:connectionId/inspect",
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await manager(req, companyId);
|
||||
await service.requireEnabled();
|
||||
const saved = await emailConnectionService(db).credential(
|
||||
companyId,
|
||||
req.params.connectionId as string,
|
||||
actor(req),
|
||||
);
|
||||
res
|
||||
.set("Cache-Control", "no-store")
|
||||
.json(await service.inspect(saved.value));
|
||||
},
|
||||
);
|
||||
router.get("/companies/:companyId/email/inboxes", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const rows = await service.list(companyId);
|
||||
res.json(
|
||||
req.actor.type === "agent"
|
||||
? rows.filter((r) => r.assignedAgentId === req.actor.agentId)
|
||||
: rows,
|
||||
);
|
||||
});
|
||||
router.post(
|
||||
"/companies/:companyId/email/inspect",
|
||||
validate(z.object({ apiKey: z.string().min(1).max(4096) }).strict()),
|
||||
async (req, res) => {
|
||||
await manager(req, req.params.companyId as string);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.inspect(req.body.apiKey));
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/companies/:companyId/email/inboxes",
|
||||
validate(emailEndpointSetupSchema),
|
||||
async (req, res) => {
|
||||
await manager(req, req.params.companyId as string);
|
||||
res
|
||||
.status(201)
|
||||
.json(
|
||||
await service.setup(
|
||||
req.params.companyId as string,
|
||||
req.body,
|
||||
actor(req),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/email/inboxes/:endpointId/control",
|
||||
validate(
|
||||
z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(),
|
||||
),
|
||||
async (req, res) => {
|
||||
const endpoint = await service.getEndpoint(
|
||||
req.params.endpointId as string,
|
||||
);
|
||||
await manager(req, endpoint.companyId);
|
||||
res.json(await service.control(endpoint.id, req.body.action, actor(req)));
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/email/inboxes/:endpointId/reconnect",
|
||||
validate(
|
||||
z
|
||||
.object({
|
||||
apiKey: z.string().min(1).max(4096),
|
||||
receiveMode: z.enum(["websocket", "webhook"]),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
async (req, res) => {
|
||||
const endpoint = await service.getEndpoint(
|
||||
req.params.endpointId as string,
|
||||
);
|
||||
await manager(req, endpoint.companyId);
|
||||
res.json(
|
||||
await service.reconnect(
|
||||
endpoint.id,
|
||||
req.body.apiKey,
|
||||
req.body.receiveMode,
|
||||
actor(req),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/companies/:companyId/email/deliveries/:publicationId/resolve",
|
||||
validate(
|
||||
z
|
||||
.object({
|
||||
outcome: z.enum(["sent", "failed"]),
|
||||
providerMessageId: z.string().min(1).max(998).optional(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await manager(req, companyId);
|
||||
res.json(
|
||||
await service.resolveUncertain(
|
||||
companyId,
|
||||
req.params.publicationId as string,
|
||||
req.body,
|
||||
actor(req),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
router.post(
|
||||
"/companies/:companyId/email/send",
|
||||
validate(emailSendSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res
|
||||
.status(202)
|
||||
.json(await service.queueSend(companyId, req.body, actor(req)));
|
||||
},
|
||||
);
|
||||
router.get("/companies/:companyId/email/tasks/:issueId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
await service.authorizeRead(
|
||||
companyId,
|
||||
req.params.issueId as string,
|
||||
actor(req),
|
||||
);
|
||||
const thread = await service.thread(
|
||||
companyId,
|
||||
req.params.issueId as string,
|
||||
);
|
||||
if (
|
||||
thread &&
|
||||
req.actor.type === "agent" &&
|
||||
thread.endpoint.assignedAgentId !== req.actor.agentId
|
||||
)
|
||||
throw notFound("Email task not found");
|
||||
res.json(thread);
|
||||
});
|
||||
router.get(
|
||||
"/companies/:companyId/email/deliveries/:publicationId",
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const delivery = await service.publication(
|
||||
req.params.publicationId as string,
|
||||
companyId,
|
||||
);
|
||||
await service.authorizeRead(companyId, delivery.issueId, actor(req));
|
||||
const thread = await service.thread(companyId, delivery.issueId);
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
thread?.endpoint.assignedAgentId !== req.actor.agentId
|
||||
)
|
||||
throw notFound("Email delivery not found");
|
||||
res.json(delivery);
|
||||
},
|
||||
);
|
||||
return router;
|
||||
}
|
||||
export function emailWebhookRoutes(service: EmailChannelService) {
|
||||
const router = Router();
|
||||
router.post("/api/chat-webhooks/agentmail/:publicId", async (req, res) => {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const key of ["svix-id", "svix-timestamp", "svix-signature"])
|
||||
if (typeof req.headers[key] === "string") headers[key] = req.headers[key];
|
||||
if (!Buffer.isBuffer(req.body))
|
||||
throw forbidden("Raw webhook body required");
|
||||
await service.webhook(req.params.publicId, req.body, headers);
|
||||
res.sendStatus(204);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
agents,
|
||||
approvals,
|
||||
chatConversations,
|
||||
chatEndpoints,
|
||||
chatPublications,
|
||||
companyMemberships,
|
||||
documents,
|
||||
@@ -3013,9 +3014,6 @@ function toCompactIssue(issue: any): CompactIssue {
|
||||
...(issue.blockedInboxAttention !== undefined
|
||||
? { blockedInboxAttention: issue.blockedInboxAttention }
|
||||
: {}),
|
||||
...(issue.productivityReview
|
||||
? { productivityReview: issue.productivityReview }
|
||||
: {}),
|
||||
...(issue.scheduledRetry ? { scheduledRetry: issue.scheduledRetry } : {}),
|
||||
...(issue.liveDescendantCount !== undefined
|
||||
? { liveDescendantCount: issue.liveDescendantCount }
|
||||
@@ -8381,7 +8379,6 @@ export function issueRoutes(
|
||||
relations,
|
||||
blockerAttention,
|
||||
reviewAttention,
|
||||
productivityReview,
|
||||
scheduledRetry,
|
||||
attachments,
|
||||
continuationSummary,
|
||||
@@ -8399,9 +8396,6 @@ export function issueRoutes(
|
||||
svc
|
||||
.listReviewAttention(issue.companyId, [issue])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
svc
|
||||
.listProductivityReviews(issue.companyId, [issue.id])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
svc.getCurrentScheduledRetry(issue.id),
|
||||
svc.listAttachments(issue.id),
|
||||
documentsSvc.getIssueDocumentByKey(
|
||||
@@ -8465,7 +8459,6 @@ export function issueRoutes(
|
||||
workMode: issue.workMode,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
...(reviewAttention ? { reviewAttention } : {}),
|
||||
productivityReview,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
priority: issue.priority,
|
||||
@@ -8708,7 +8701,6 @@ export function issueRoutes(
|
||||
relations,
|
||||
blockerAttention,
|
||||
reviewAttention,
|
||||
productivityReview,
|
||||
referenceSummary,
|
||||
successfulRunHandoffStates,
|
||||
scheduledRetry,
|
||||
@@ -8728,9 +8720,6 @@ export function issueRoutes(
|
||||
svc
|
||||
.listReviewAttention(issue.companyId, [issue])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
svc
|
||||
.listProductivityReviews(issue.companyId, [issue.id])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
|
||||
svc.getCurrentScheduledRetry(issue.id),
|
||||
@@ -8774,7 +8763,6 @@ export function issueRoutes(
|
||||
ancestors,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
...(reviewAttention ? { reviewAttention } : {}),
|
||||
productivityReview,
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
executionBlocker: await getExecutionBlocker(db, issue.companyId, issue.id),
|
||||
scheduledRetry,
|
||||
@@ -9119,8 +9107,10 @@ export function issueRoutes(
|
||||
const [chatBinding] = await tx
|
||||
.select({ id: chatConversations.id })
|
||||
.from(chatConversations)
|
||||
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId))
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.externalExecutionPolicy, "restricted"),
|
||||
eq(chatConversations.companyId, lockedIssue.companyId),
|
||||
eq(chatConversations.issueId, lockedIssue.id),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
emailEndpointSetupSchema,
|
||||
emailConnectionSchema,
|
||||
emailSendSchema,
|
||||
// Agent
|
||||
createAgentSchema,
|
||||
createAgentHireSchema,
|
||||
@@ -779,6 +782,8 @@ const chatEndpointResponseSchema = z
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
connectionId: z.string().uuid(),
|
||||
publicationMode: z.enum(["automatic", "explicit"]),
|
||||
externalExecutionPolicy: z.enum(["restricted", "agent"]),
|
||||
provider: chatProviderSchema,
|
||||
publicId: z.string(),
|
||||
status: chatEndpointStatusSchema,
|
||||
@@ -1427,6 +1432,13 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
||||
"POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke",
|
||||
"POST /api/tool-gateway/action-requests/{id}/approve",
|
||||
"POST /api/tool-gateway/action-requests/{id}/decline",
|
||||
"POST /api/companies/{companyId}/email/inspect",
|
||||
"POST /api/companies/{companyId}/email/inboxes",
|
||||
"POST /api/companies/{companyId}/email/connections",
|
||||
"POST /api/companies/{companyId}/email/connections/{connectionId}/inspect",
|
||||
"POST /api/email/inboxes/{endpointId}/control",
|
||||
"POST /api/email/inboxes/{endpointId}/reconnect",
|
||||
"POST /api/companies/{companyId}/email/deliveries/{publicationId}/resolve",
|
||||
// Chat endpoints expose provider credentials, identity mappings, access
|
||||
// policy, and replay controls. Every mounted handler asserts a board actor;
|
||||
// keep the generated security contract equally restrictive.
|
||||
@@ -1523,6 +1535,7 @@ const CREATED_OPERATIONS = new Set([
|
||||
]);
|
||||
|
||||
const ACCEPTED_OPERATIONS = new Set([
|
||||
"POST /api/companies/{companyId}/email/send",
|
||||
"POST /api/companies/import",
|
||||
"POST /api/health/dev-server/restart",
|
||||
"POST /api/invites/{token}/accept",
|
||||
@@ -1999,6 +2012,28 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
// Explicit task-bound email. Board setup and agent actions share the same vaulted
|
||||
// connection, while automatic chat publication never applies to these endpoints.
|
||||
for (const [method, path, summary, body, success] of [
|
||||
["post", "/api/companies/{companyId}/email/connections", "Save AgentMail credential and access", emailConnectionSchema, 201],
|
||||
["post", "/api/companies/{companyId}/email/connections/{connectionId}/inspect", "Inspect inboxes using a saved AgentMail credential", undefined, 200],
|
||||
["get", "/api/companies/{companyId}/email/inboxes", "List authorized AgentMail inboxes", undefined, 200],
|
||||
["post", "/api/companies/{companyId}/email/inspect", "Inspect AgentMail inboxes and verified domains for setup", z.object({ apiKey: z.string().min(1).max(4096) }).strict(), 200],
|
||||
["post", "/api/companies/{companyId}/email/inboxes", "Create or attach an agent email inbox", emailEndpointSetupSchema, 201],
|
||||
["post", "/api/email/inboxes/{endpointId}/control", "Pause, resume or disconnect an email inbox", z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(), 200],
|
||||
["post", "/api/email/inboxes/{endpointId}/reconnect", "Reconnect the same email inbox", z.object({ apiKey: z.string().min(1).max(4096), receiveMode: z.enum(["websocket", "webhook"]) }).strict(), 200],
|
||||
["post", "/api/companies/{companyId}/email/send", "Explicitly send email: start a child task or reply to a bound conversation", emailSendSchema, 202],
|
||||
["get", "/api/companies/{companyId}/email/tasks/{issueId}", "Read a task's email thread, full text context, recipients and delivery outcomes", undefined, 200],
|
||||
["get", "/api/companies/{companyId}/email/deliveries/{publicationId}", "Check queued, sent, delivered, failed or uncertain email delivery", undefined, 200],
|
||||
["post", "/api/companies/{companyId}/email/deliveries/{publicationId}/resolve", "Resolve uncertain email after checking the provider", z.object({ outcome: z.enum(["sent", "failed"]), providerMessageId: z.string().min(1).max(998).optional() }).strict(), 200],
|
||||
] as const) {
|
||||
registry.registerPath({ method, path, tags: ["Email"], summary,
|
||||
description: "Experimental AgentMail channel. Internal comments never send email. Agent sends require assigned inbox and task ownership, active run authority, and configured action policies. Preserve the same idempotencyKey and payload across retries. New conversations create an email child task; replies require conversationId and replyToMessageId. Reply-all is deliberate and never includes Bcc.",
|
||||
request: { params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map(match => [match[1], z.string().uuid()]))), ...(body ? { body: jsonBody(body) } : {}) },
|
||||
responses: { [success]: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chat Channels ─────────────────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { z } from "zod";
|
||||
import { Webhook } from "svix";
|
||||
import type { EmailEnvelope } from "@paperclipai/shared";
|
||||
|
||||
const strings = z.array(z.string());
|
||||
export const agentmailMessageSchema = z.object({
|
||||
inbox_id: z.string().min(1),
|
||||
thread_id: z.string().min(1),
|
||||
message_id: z.string().min(1),
|
||||
from: z.string().default(""),
|
||||
to: strings.default([]),
|
||||
cc: strings.optional(),
|
||||
bcc: strings.optional(),
|
||||
reply_to: strings.optional(),
|
||||
subject: z.string().default("(No subject)"),
|
||||
text: z.string().optional(),
|
||||
html: z.string().optional(),
|
||||
extracted_text: z.string().optional(),
|
||||
timestamp: z.string().datetime({ offset: true }),
|
||||
created_at: z.string().datetime({ offset: true }).optional(),
|
||||
labels: strings.default([]),
|
||||
headers: z.record(z.string(), z.string()).default({}),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
attachment_id: z.string(),
|
||||
filename: z.string().optional(),
|
||||
content_type: z.string().optional(),
|
||||
size: z.number().nonnegative(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
export type AgentmailMessage = z.infer<typeof agentmailMessageSchema>;
|
||||
export interface AgentmailInbox {
|
||||
inbox_id: string;
|
||||
display_name?: string;
|
||||
}
|
||||
export interface AgentmailScope {
|
||||
scope_type: "organization" | "pod" | "inbox";
|
||||
organization_id: string;
|
||||
pod_id?: string;
|
||||
inbox_id?: string;
|
||||
}
|
||||
export class AgentmailApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly retryAfterMs = 1000,
|
||||
) {
|
||||
// Provider bodies may contain credentials or private mail. Never log them.
|
||||
super(`AgentMail request failed (${status})`);
|
||||
}
|
||||
}
|
||||
export const AGENTMAIL_EVENTS = [
|
||||
"message.received",
|
||||
"message.sent",
|
||||
"message.delivered",
|
||||
"message.bounced",
|
||||
"message.complained",
|
||||
"message.rejected",
|
||||
];
|
||||
export function emailText(message: AgentmailMessage): string {
|
||||
return (
|
||||
message.extracted_text ??
|
||||
message.text ??
|
||||
(message.html
|
||||
? message.html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
: "")
|
||||
).slice(0, 100_000);
|
||||
}
|
||||
/** Reconstruct only visible recipients; never let provider reply-all inherit Bcc. */
|
||||
export function emailReplyRecipients(
|
||||
message: EmailEnvelope,
|
||||
ownAddress: string,
|
||||
replyAll: boolean,
|
||||
) {
|
||||
const address = (value: string) =>
|
||||
(value.match(/<([^>]+)>/)?.[1] ?? value).trim();
|
||||
const seen = new Set([address(ownAddress).toLowerCase()]);
|
||||
const unique = (values: string[]) =>
|
||||
values.map(address).filter((value) => {
|
||||
const key = value.toLowerCase();
|
||||
if (!value || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const replyTargets = message.replyTo?.length ? message.replyTo : [message.from];
|
||||
const to = unique([...replyTargets, ...(replyAll ? message.to : [])]);
|
||||
const cc = unique(replyAll ? (message.cc ?? []) : []);
|
||||
return { to, cc, bcc: [], reply_all: false };
|
||||
}
|
||||
export function isAutomaticEmail(message: AgentmailMessage): boolean {
|
||||
const headers = Object.fromEntries(
|
||||
Object.entries(message.headers).map(([k, v]) => [
|
||||
k.toLowerCase(),
|
||||
v.toLowerCase(),
|
||||
]),
|
||||
);
|
||||
return Boolean(
|
||||
(headers["auto-submitted"] && headers["auto-submitted"] !== "no") ||
|
||||
/^(bulk|list|junk)$/.test(headers.precedence ?? "") ||
|
||||
headers["x-autoreply"] ||
|
||||
headers["x-autorespond"],
|
||||
);
|
||||
}
|
||||
export function isFilteredEmail(message: AgentmailMessage): boolean {
|
||||
return message.labels.some((label) =>
|
||||
["spam", "blocked", "unauthenticated", "trash"].includes(label),
|
||||
);
|
||||
}
|
||||
export function verifyAgentmailWebhook(
|
||||
body: Buffer,
|
||||
headers: Record<string, string>,
|
||||
secret: string,
|
||||
): unknown {
|
||||
return new Webhook(secret).verify(body.toString("utf8"), headers);
|
||||
}
|
||||
export function normalizeAgentmailEvent(value: unknown) {
|
||||
const parsed = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
event_type: z.string().optional(),
|
||||
event_id: z.string().optional(),
|
||||
message: z.unknown().optional(),
|
||||
send: z.unknown().optional(),
|
||||
delivery: z.unknown().optional(),
|
||||
bounce: z.unknown().optional(),
|
||||
complaint: z.unknown().optional(),
|
||||
reject: z.unknown().optional(),
|
||||
})
|
||||
.parse(value);
|
||||
const kind =
|
||||
parsed.event_type ?? parsed.type?.replace(/^message_/, "message.");
|
||||
if (!kind || !AGENTMAIL_EVENTS.includes(kind)) return null;
|
||||
// Provider receipts use event-specific envelopes, shared by both transports.
|
||||
// Internal reconciliation events may supply the fetched message directly.
|
||||
const receipts: Record<string, unknown> = {
|
||||
"message.sent": parsed.send,
|
||||
"message.delivered": parsed.delivery,
|
||||
"message.bounced": parsed.bounce,
|
||||
"message.complained": parsed.complaint,
|
||||
"message.rejected": parsed.reject,
|
||||
};
|
||||
// Fetch the authoritative message before intake; delivery events have reduced payloads.
|
||||
const message = z
|
||||
.object({ inbox_id: z.string(), message_id: z.string() })
|
||||
.parse(receipts[kind] ?? parsed.message);
|
||||
return {
|
||||
kind,
|
||||
...message,
|
||||
eventId: parsed.event_id ?? `${kind}:${message.message_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** REST is the email protocol boundary; credentials never enter an agent runtime. */
|
||||
export function agentmailApi(apiKey: string, fetchImpl: typeof fetch = fetch) {
|
||||
async function request<T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
idempotencyKey?: string,
|
||||
): Promise<T> {
|
||||
const response = await fetchImpl(`https://api.agentmail.to/v0${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
redirect: "error",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const seconds = Number(retryAfter ?? 1);
|
||||
const delay = Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: Date.parse(retryAfter ?? "") - Date.now();
|
||||
throw new AgentmailApiError(
|
||||
response.status,
|
||||
Math.max(1000, Math.min(300_000, Number.isFinite(delay) ? delay : 1000)),
|
||||
);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
if (!response.body) throw new Error("Empty AgentMail response");
|
||||
const reader = response.body.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
bytes += part.value.length;
|
||||
if (bytes > 16 * 1024 * 1024)
|
||||
throw new Error("AgentMail response exceeds the processing limit");
|
||||
parts.push(part.value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
return JSON.parse(Buffer.concat(parts).toString("utf8")) as T;
|
||||
}
|
||||
const inboxPath = (id: string) => `/inboxes/${encodeURIComponent(id)}`;
|
||||
return {
|
||||
request,
|
||||
whoami: () => request<AgentmailScope>("/auth/me"),
|
||||
getInbox: (id: string) => request<AgentmailInbox>(inboxPath(id)),
|
||||
listInboxes: () =>
|
||||
request<{ inboxes: AgentmailInbox[] }>("/inboxes?limit=100"),
|
||||
listDomains: () =>
|
||||
request<{ domains: { domain_id: string; domain: string }[] }>(
|
||||
"/domains?limit=100",
|
||||
),
|
||||
getDomain: (id: string) =>
|
||||
request<{ domain_id: string; domain: string; status: string }>(
|
||||
`/domains/${encodeURIComponent(id)}`,
|
||||
),
|
||||
createInbox: (body: unknown) =>
|
||||
request<AgentmailInbox>("/inboxes", "POST", body),
|
||||
createInboxKey: (id: string) =>
|
||||
request<{ api_key: string; api_key_id: string }>(
|
||||
`${inboxPath(id)}/api-keys`,
|
||||
"POST",
|
||||
{ name: "Paperclip email runtime" },
|
||||
),
|
||||
deleteInboxKey: (id: string, keyId: string) =>
|
||||
request<void>(
|
||||
`${inboxPath(id)}/api-keys/${encodeURIComponent(keyId)}`,
|
||||
"DELETE",
|
||||
),
|
||||
createWebhook: (id: string, url: string, clientId: string) =>
|
||||
request<{ webhook_id: string; secret: string }>(
|
||||
`${inboxPath(id)}/webhooks`,
|
||||
"POST",
|
||||
{ url, event_types: AGENTMAIL_EVENTS, client_id: clientId },
|
||||
),
|
||||
deleteWebhook: (id: string, webhookId: string) =>
|
||||
request<void>(
|
||||
`${inboxPath(id)}/webhooks/${encodeURIComponent(webhookId)}`,
|
||||
"DELETE",
|
||||
),
|
||||
getMessage: async (id: string, messageId: string) =>
|
||||
agentmailMessageSchema.parse(
|
||||
await request(
|
||||
`${inboxPath(id)}/messages/${encodeURIComponent(messageId)}`,
|
||||
),
|
||||
),
|
||||
getThread: async (id: string, threadId: string) =>
|
||||
z
|
||||
.object({ messages: z.array(agentmailMessageSchema) })
|
||||
.parse(
|
||||
await request(
|
||||
`${inboxPath(id)}/threads/${encodeURIComponent(threadId)}`,
|
||||
),
|
||||
),
|
||||
listMessages: (id: string, after?: string, page?: string) =>
|
||||
request<{
|
||||
messages: {
|
||||
message_id: string;
|
||||
created_at?: string;
|
||||
timestamp?: string;
|
||||
}[];
|
||||
next_page_token?: string;
|
||||
}>(
|
||||
`${inboxPath(id)}/messages?${new URLSearchParams({ ...(after ? { after } : {}), ascending: "true", limit: "100", ...(page ? { page_token: page } : {}) })}`,
|
||||
),
|
||||
send: (id: string, body: unknown, key: string, replyId?: string) =>
|
||||
request<{ message_id: string; thread_id: string }>(
|
||||
`${inboxPath(id)}/messages/${replyId ? `${encodeURIComponent(replyId)}/reply` : "send"}`,
|
||||
"POST",
|
||||
body,
|
||||
key,
|
||||
),
|
||||
getAttachment: (id: string, messageId: string, attachmentId: string) =>
|
||||
request<{ download_url: string; size: number }>(
|
||||
`${inboxPath(id)}/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -49,7 +49,6 @@ import type {
|
||||
IssueReviewPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { badRequest } from "../errors.js";
|
||||
import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import {
|
||||
BLOCKER_ATTENTION_MAX_DEPTH,
|
||||
@@ -105,7 +104,6 @@ const SOURCE_RANK: Record<AttentionSourceKind, number> = {
|
||||
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
||||
const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
|
||||
const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
||||
const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const;
|
||||
const DETAIL_EXCERPT_LENGTH = 160;
|
||||
const DETAIL_IMAGE_LIMIT = 3;
|
||||
@@ -1453,65 +1451,6 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
}));
|
||||
}
|
||||
|
||||
const productivityRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
originId: issues.originId,
|
||||
originFingerprint: issues.originFingerprint,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
isNotNull(issues.assigneeUserId),
|
||||
notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]),
|
||||
))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const [productivitySourceMap, productivityReviewMap, productivityImageMap] = await Promise.all([
|
||||
issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId)),
|
||||
issueSummaryMap(db, companyId, productivityRows.map((row) => row.id)),
|
||||
issueImageMap(db, companyId, productivityRows.map((row) => row.id)),
|
||||
]);
|
||||
|
||||
for (const review of productivityRows) {
|
||||
const reviewIssue = productivityReviewMap.get(review.id);
|
||||
if (!reviewIssue) continue;
|
||||
const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null;
|
||||
const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "productivity_review",
|
||||
subject: issueSubject(prefix, reviewIssue),
|
||||
whyNow: "Productivity review is awaiting a human decision.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "resolve", label: "Resolve", description: "Record a productivity review outcome." },
|
||||
{ id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." },
|
||||
{ id: "reassign", label: "Reassign", description: "Move the review to another owner." },
|
||||
),
|
||||
inlineResolvable: false,
|
||||
entryRule: "Open issue_productivity_review issue assigned to a user.",
|
||||
exitRule: "Review issue is done/cancelled or no longer assigned to a user.",
|
||||
dedupKey,
|
||||
severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium",
|
||||
activityAt: toIso(review.updatedAt),
|
||||
createdAt: toIso(review.createdAt),
|
||||
updatedAt: toIso(review.updatedAt),
|
||||
relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
|
||||
...issueContext(reviewIssue),
|
||||
detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
type BlockedAttentionIssue = IssueSubjectRow & {
|
||||
blockerAttention?: {
|
||||
|
||||
@@ -383,6 +383,7 @@ function publicationSummary(
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<ChatProvider, string> = {
|
||||
agentmail: "AgentMail",
|
||||
slack: "Slack",
|
||||
github: "GitHub",
|
||||
discord: "Discord",
|
||||
@@ -576,6 +577,7 @@ async function inspectSlackCallback(
|
||||
}
|
||||
|
||||
const CAPABILITIES: Record<ChatProvider, ChatAdapterCapabilities> = {
|
||||
agentmail: { threads: true, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: true, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: true },
|
||||
slack: {
|
||||
threads: true,
|
||||
directMessages: true,
|
||||
@@ -668,6 +670,7 @@ const REQUIRED_CREDENTIALS: Record<
|
||||
Exclude<ChatProvider, "github">,
|
||||
readonly string[]
|
||||
> = {
|
||||
agentmail: [],
|
||||
slack: ["botToken", "signingSecret"],
|
||||
discord: ["botToken", "applicationId", "guildId"],
|
||||
"microsoft-teams": ["clientId", "tenantId", "clientSecret"],
|
||||
@@ -725,6 +728,7 @@ const SUPPORTED_GITHUB_WEBHOOK_EVENTS = new Set<string>([
|
||||
]);
|
||||
|
||||
const SUPPLIED_CREDENTIAL_KEYS: Record<ChatProvider, readonly string[]> = {
|
||||
agentmail: [],
|
||||
slack: ["botToken", "signingSecret"],
|
||||
github: ["appId", "privateKey"],
|
||||
discord: ["botToken", "applicationId", "guildId"],
|
||||
@@ -2590,6 +2594,7 @@ function providerSetupState(
|
||||
const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null;
|
||||
const step = endpoint.status === "active" ? "complete" : endpoint.setup.step;
|
||||
switch (endpoint.provider) {
|
||||
case "agentmail": return endpoint.setup;
|
||||
case "slack": {
|
||||
const observations = (endpoint.setup as InternalSetupState)
|
||||
.slackCallbackSurfaces;
|
||||
@@ -5747,6 +5752,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
id: endpoint.id,
|
||||
companyId: endpoint.companyId,
|
||||
connectionId: endpoint.connectionId,
|
||||
publicationMode: endpoint.publicationMode,
|
||||
externalExecutionPolicy: endpoint.externalExecutionPolicy,
|
||||
provider: endpoint.provider,
|
||||
publicId: endpoint.publicId,
|
||||
status: endpoint.status,
|
||||
@@ -5832,6 +5839,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
input: CreateChatEndpointInput,
|
||||
actorUserId?: string | null,
|
||||
) {
|
||||
if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail");
|
||||
const agent = await db
|
||||
.select({ id: agents.id, name: agents.name, status: agents.status })
|
||||
.from(agents)
|
||||
@@ -5968,6 +5976,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
if (initial.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail");
|
||||
await withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
@@ -8048,6 +8057,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
if (record.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail");
|
||||
const suppliedCredentialKeys = Object.keys(input.credentials ?? {});
|
||||
if (suppliedCredentialKeys.length > 0) {
|
||||
const credentialAction =
|
||||
@@ -26104,7 +26114,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined,
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`,
|
||||
onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined,
|
||||
inArray(chatDeliveries.eventKind, [
|
||||
"reaction_added",
|
||||
"reaction_removed",
|
||||
@@ -26195,6 +26206,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`,
|
||||
onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined,
|
||||
notInArray(chatDeliveries.eventKind, [
|
||||
"reaction_added",
|
||||
@@ -28642,6 +28654,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
conversationId: string,
|
||||
commentId: string,
|
||||
) {
|
||||
const emailBoundary = await endpointRecord(endpointId);
|
||||
if (emailBoundary?.endpoint.publicationMode === "explicit") throw badRequest("Use an explicit email send action");
|
||||
const conversation = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
@@ -28714,6 +28728,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
userId: string,
|
||||
attachmentIds: string[] = [],
|
||||
) {
|
||||
const emailBoundary = await endpointRecord(endpointId);
|
||||
if (emailBoundary?.endpoint.publicationMode === "explicit") throw badRequest("Use an explicit email send action");
|
||||
// Browser request IDs are only unique within the conversation that issued
|
||||
// them. Include that durable task boundary so a retried key from another
|
||||
// conversation can neither suppress its send nor return the first task's
|
||||
@@ -35794,6 +35810,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`,
|
||||
eq(chatPublications.state, "streaming"),
|
||||
lte(chatPublications.updatedAt, staleBefore),
|
||||
// Teams owns separate staged I/O intents and a longer attempt lease.
|
||||
@@ -35884,6 +35901,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
and(
|
||||
or(
|
||||
and(
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`,
|
||||
inArray(chatPublications.state, ["pending", "retry"]),
|
||||
notExists(
|
||||
db
|
||||
|
||||
@@ -277,6 +277,7 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
and(
|
||||
eq(chatEndpoints.companyId, chatConversations.companyId),
|
||||
eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.publicationMode, "automatic"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
|
||||
@@ -493,6 +493,7 @@ export function parseChatProviderLifecycle(
|
||||
input: ParseChatProviderLifecycleInput,
|
||||
): ChatProviderLifecycleEffect[] {
|
||||
switch (input.provider) {
|
||||
case "agentmail": return [];
|
||||
case "slack":
|
||||
return parseSlackLifecycle(input);
|
||||
case "github":
|
||||
|
||||
@@ -240,6 +240,7 @@ async function enqueueSafeNativeChatProgress(
|
||||
and(
|
||||
eq(chatEndpoints.companyId, chatConversations.companyId),
|
||||
eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.publicationMode, "automatic"),
|
||||
eq(chatEndpoints.assignedAgentId, heartbeatRuns.agentId),
|
||||
),
|
||||
)
|
||||
@@ -360,6 +361,7 @@ async function enqueueSafeNativeChatProgress(
|
||||
and(
|
||||
eq(chatEndpoints.companyId, chatConversations.companyId),
|
||||
eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.publicationMode, "automatic"),
|
||||
eq(chatEndpoints.assignedAgentId, row.agentId),
|
||||
),
|
||||
)
|
||||
@@ -671,6 +673,7 @@ export async function enqueueChatRunMilestones(
|
||||
and(
|
||||
eq(chatEndpoints.companyId, chatConversations.companyId),
|
||||
eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.publicationMode, "automatic"),
|
||||
eq(chatEndpoints.assignedAgentId, heartbeatRuns.agentId),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { AgentSkillSnapshot } from "@paperclipai/shared";
|
||||
import {
|
||||
resolvePaperclipSkillsDir,
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
type PaperclipSkillEntry,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { emailChannelService } from "./email-channels.js";
|
||||
import {
|
||||
AGENTMAIL_TOOLS,
|
||||
executeAgentmailTool,
|
||||
} from "./connectors/agentmail.js";
|
||||
import { materializeAsset } from "./native-runtime/runtime-context.js";
|
||||
|
||||
type AgentBinding = { companyId: string; agentId: string };
|
||||
type ToolBinding = AgentBinding & {
|
||||
runId: string;
|
||||
issueId: string;
|
||||
workMode?: string;
|
||||
};
|
||||
type Resource = {
|
||||
id: string;
|
||||
label: string;
|
||||
connectionId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
type Tool = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
};
|
||||
interface ConnectorDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
skillName: string;
|
||||
tools: Tool[];
|
||||
resolve: (db: Db, binding: AgentBinding) => Promise<Resource[]>;
|
||||
execute: (
|
||||
db: Db,
|
||||
binding: ToolBinding,
|
||||
name: string,
|
||||
value: unknown,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Trusted connector packages declare their contributions here. Assignments and
|
||||
// current access, not credential availability or agent-authored config, select them.
|
||||
const connectors: ConnectorDefinition[] = [
|
||||
{
|
||||
key: "agentmail",
|
||||
label: "AgentMail",
|
||||
skillName: "agentmail",
|
||||
tools: AGENTMAIL_TOOLS.map(
|
||||
({ action: _action, ...definition }) => definition,
|
||||
),
|
||||
async resolve(db, binding) {
|
||||
const service = emailChannelService(db, {
|
||||
heartbeat: { wakeup: async () => null },
|
||||
});
|
||||
return (
|
||||
await service.assignedInboxes(binding.companyId, binding.agentId)
|
||||
).map(({ id, address, connectionId }) => ({
|
||||
id,
|
||||
label: address ?? id,
|
||||
connectionId,
|
||||
}));
|
||||
},
|
||||
async execute(db, binding, name, value) {
|
||||
const tool = AGENTMAIL_TOOLS.find((entry) => entry.name === name);
|
||||
if (!tool) throw forbidden("Unknown AgentMail tool");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw forbidden("Expected tool arguments");
|
||||
return executeAgentmailTool(db, binding, {
|
||||
...value,
|
||||
action: tool.action,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
const skillKey = (connector: ConnectorDefinition) =>
|
||||
`paperclipai/paperclip/${connector.skillName}`;
|
||||
export type ConnectorAssignment = {
|
||||
key: string;
|
||||
label: string;
|
||||
skillKey: string;
|
||||
resources: Resource[];
|
||||
tools: Tool[];
|
||||
};
|
||||
|
||||
export async function resolveConnectorAssignments(
|
||||
db: Db,
|
||||
binding: AgentBinding,
|
||||
): Promise<ConnectorAssignment[]> {
|
||||
const assignments: ConnectorAssignment[] = [];
|
||||
for (const connector of connectors) {
|
||||
const resources = await connector.resolve(db, binding);
|
||||
if (resources.length)
|
||||
assignments.push({
|
||||
key: connector.key,
|
||||
label: connector.label,
|
||||
skillKey: skillKey(connector),
|
||||
resources,
|
||||
tools: connector.tools,
|
||||
});
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
|
||||
export function isConnectorSkill(key: string) {
|
||||
return connectors.some((connector) => skillKey(connector) === key);
|
||||
}
|
||||
|
||||
export function isConnectorTool(name: string) {
|
||||
return connectors.some((connector) =>
|
||||
connector.tools.some((tool) => tool.name === name),
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeConnectorTool(
|
||||
db: Db,
|
||||
binding: ToolBinding,
|
||||
name: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const connector = connectors.find((entry) =>
|
||||
entry.tools.some((tool) => tool.name === name),
|
||||
);
|
||||
if (!connector || !(await connector.resolve(db, binding)).length)
|
||||
throw forbidden("This connector is no longer assigned or authorized");
|
||||
return connector.execute(db, binding, name, value);
|
||||
}
|
||||
|
||||
/** Runtime-only overlay: never persist automatic assignments into agent preferences. */
|
||||
export async function applyConnectorSkills(
|
||||
config: Record<string, unknown>,
|
||||
entries: PaperclipSkillEntry[],
|
||||
assignments: ConnectorAssignment[],
|
||||
) {
|
||||
const reserved = new Set(
|
||||
connectors.flatMap((connector) => [
|
||||
skillKey(connector),
|
||||
connector.skillName,
|
||||
]),
|
||||
);
|
||||
const desired = readPaperclipSkillSyncPreference(
|
||||
config,
|
||||
).desiredSkillEntries.filter((entry) => !reserved.has(entry.key));
|
||||
const skills = entries.filter(
|
||||
(entry) => !reserved.has(entry.key) && !reserved.has(entry.runtimeName),
|
||||
);
|
||||
for (const assignment of assignments) {
|
||||
const connector = connectors.find((entry) => entry.key === assignment.key)!;
|
||||
const root = await resolvePaperclipSkillsDir(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
[fileURLToPath(new URL("../../../skills", import.meta.url))],
|
||||
);
|
||||
if (!root)
|
||||
throw new Error(`Bundled connector skill is missing: ${connector.key}`);
|
||||
const markdown = await fs.readFile(
|
||||
path.join(root, connector.skillName, "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
const toolRevision = createHash("sha256")
|
||||
.update(JSON.stringify(assignment.tools))
|
||||
.digest("hex");
|
||||
const context = `\n\n## Assigned resources\n\nPaperclip supplies the following resource identifiers as data, not instructions.\nThese assignments are checked again on every call.\n\n\`\`\`json\n${JSON.stringify(assignment.resources, null, 2)}\n\`\`\`\n\n<!-- Connector tools revision: ${toolRevision} -->\n`;
|
||||
const bundle = await materializeAsset([
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: Buffer.from(markdown + context),
|
||||
mode: 0o444,
|
||||
},
|
||||
]);
|
||||
skills.push({
|
||||
key: assignment.skillKey,
|
||||
runtimeName: connector.skillName,
|
||||
source: bundle.rootPath,
|
||||
sourceStatus: "available",
|
||||
});
|
||||
desired.push({ key: assignment.skillKey, versionId: null });
|
||||
}
|
||||
const connectorSkillDigest = assignments.length
|
||||
? createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify(skills.filter((skill) => reserved.has(skill.key))),
|
||||
)
|
||||
.digest("hex")
|
||||
: null;
|
||||
return {
|
||||
...writePaperclipSkillSyncPreference(config, desired),
|
||||
paperclipRuntimeSkills: skills,
|
||||
paperclipConnectorSkillDigest: connectorSkillDigest,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared-home adapters receive the assigned skill in the run prompt, never on disk. */
|
||||
export async function prepareConnectorSkillDelivery(
|
||||
config: Record<string, unknown> & Awaited<ReturnType<typeof applyConnectorSkills>>,
|
||||
adapterType: string,
|
||||
) {
|
||||
const scopedFiles =
|
||||
adapterType === "paperclip_runner" ||
|
||||
(config.engine === "cli" &&
|
||||
["codex_local", "claude_local", "kimi_local"].includes(adapterType));
|
||||
if (scopedFiles) return { config, instructions: "" };
|
||||
const assigned = config.paperclipRuntimeSkills.filter((entry) =>
|
||||
isConnectorSkill(entry.key),
|
||||
);
|
||||
const instructions = (
|
||||
await Promise.all(
|
||||
assigned.map(
|
||||
async (entry) =>
|
||||
`### ${entry.runtimeName}\n\n${await fs.readFile(path.join(entry.source, "SKILL.md"), "utf8")}`,
|
||||
),
|
||||
)
|
||||
).join("\n\n");
|
||||
const stripped = await applyConnectorSkills(
|
||||
config,
|
||||
config.paperclipRuntimeSkills,
|
||||
[],
|
||||
);
|
||||
return {
|
||||
config: {
|
||||
...stripped,
|
||||
paperclipConnectorSkillDigest: config.paperclipConnectorSkillDigest,
|
||||
},
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
export function annotateConnectorSkills(
|
||||
snapshot: AgentSkillSnapshot,
|
||||
assignments: ConnectorAssignment[],
|
||||
): AgentSkillSnapshot {
|
||||
const entries = [...snapshot.entries];
|
||||
for (const connector of connectors) {
|
||||
if (!entries.some((entry) => entry.key === skillKey(connector)))
|
||||
entries.push({
|
||||
key: skillKey(connector),
|
||||
runtimeName: connector.skillName,
|
||||
desired: assignments.some(
|
||||
(entry) => entry.skillKey === skillKey(connector),
|
||||
),
|
||||
managed: true,
|
||||
state: "available",
|
||||
readOnly: true,
|
||||
originLabel: `${connector.label} assignment`,
|
||||
detail:
|
||||
"Provided automatically when this connector assigns a resource to the agent.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
entries: entries.map((entry) => {
|
||||
const assignment = assignments.find(
|
||||
(item) => item.skillKey === entry.key,
|
||||
);
|
||||
return assignment
|
||||
? {
|
||||
...entry,
|
||||
desired: true,
|
||||
state: "configured",
|
||||
readOnly: true,
|
||||
originLabel: `${assignment.label} assignment`,
|
||||
detail: `Provided automatically by ${assignment.label}: ${assignment.resources.map((resource) => resource.label).join(", ")}. Manage this skill through the connector assignment.`,
|
||||
}
|
||||
: connectors.some((connector) => skillKey(connector) === entry.key)
|
||||
? { ...entry, readOnly: true }
|
||||
: entry;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { emailSendSchema } from "@paperclipai/shared";
|
||||
import { emailChannelService } from "../email-channels.js";
|
||||
import { forbidden, notFound } from "../../errors.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
|
||||
const AGENTMAIL_EMAIL_CONTRACT = {
|
||||
description:
|
||||
"Use an assigned AgentMail inbox for this task. Internal comments and final responses never send email. List inboxes, read the current email thread, explicitly send a new email child task or reply, and inspect delivery. Requires experimental email connections. A send needs a UUID idempotencyKey; preserve it and the identical payload on retry. A reply uses conversationId and replyToMessageId from thread; replyAll defaults false and excludes Bcc. Sending does not close the task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["inboxes", "thread", "send", "delivery"],
|
||||
},
|
||||
publicationId: {
|
||||
type: "string",
|
||||
description: "Publication UUID returned by send, for delivery status.",
|
||||
},
|
||||
request: {
|
||||
type: "object",
|
||||
properties: {
|
||||
endpointId: { type: "string" },
|
||||
parentIssueId: {
|
||||
type: "string",
|
||||
description: "Current task UUID for a new email child task.",
|
||||
},
|
||||
conversationId: { type: "string" },
|
||||
replyToMessageId: { type: "string" },
|
||||
replyAll: { type: "boolean" },
|
||||
to: { type: "array", items: { type: "string" } },
|
||||
cc: { type: "array", items: { type: "string" } },
|
||||
bcc: { type: "array", items: { type: "string" } },
|
||||
subject: { type: "string" },
|
||||
text: { type: "string" },
|
||||
attachmentIds: { type: "array", items: { type: "string" } },
|
||||
idempotencyKey: { type: "string" },
|
||||
},
|
||||
required: ["endpointId", "text", "idempotencyKey"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["action"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
} as const;
|
||||
// Connector-owned definitions. They are never part of the universal runner catalog.
|
||||
export const AGENTMAIL_TOOLS = [
|
||||
{
|
||||
name: "agentmail_inboxes",
|
||||
action: "inboxes",
|
||||
description: "List your active assigned AgentMail inboxes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "agentmail_read_thread",
|
||||
action: "thread",
|
||||
description:
|
||||
"Read the current task's AgentMail email thread, recipients, messages and attachments.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "agentmail_send",
|
||||
action: "send",
|
||||
description: AGENTMAIL_EMAIL_CONTRACT.description,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
request: AGENTMAIL_EMAIL_CONTRACT.inputSchema.properties.request,
|
||||
},
|
||||
required: ["request"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "agentmail_delivery",
|
||||
action: "delivery",
|
||||
description:
|
||||
"Check delivery of an AgentMail publication belonging to your task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
publicationId:
|
||||
AGENTMAIL_EMAIL_CONTRACT.inputSchema.properties.publicationId,
|
||||
},
|
||||
required: ["publicationId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
action: z.enum(["inboxes", "thread", "send", "delivery"]),
|
||||
request: emailSendSchema.optional(),
|
||||
publicationId: z.string().uuid().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function executeAgentmailTool(
|
||||
db: Db,
|
||||
binding: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
runId: string;
|
||||
issueId: string;
|
||||
workMode?: string;
|
||||
},
|
||||
value: unknown,
|
||||
) {
|
||||
if (
|
||||
!(await instanceSettingsService(db).getExperimental()).enableChatConnectors
|
||||
)
|
||||
throw forbidden("Experimental email connections are disabled");
|
||||
const input = schema.parse(value);
|
||||
// This facade only persists intents/reads. The app's durable email worker owns execution.
|
||||
const service = emailChannelService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async () => {
|
||||
throw new Error("Task email facade cannot start a receive worker");
|
||||
},
|
||||
},
|
||||
});
|
||||
const inboxes = await service.assignedInboxes(
|
||||
binding.companyId,
|
||||
binding.agentId,
|
||||
);
|
||||
if (!inboxes.length) throw forbidden("No active assigned AgentMail inbox");
|
||||
if (input.action === "inboxes") return inboxes;
|
||||
await service.authorizeRead(binding.companyId, binding.issueId, {
|
||||
agentId: binding.agentId,
|
||||
runId: binding.runId,
|
||||
});
|
||||
const thread = await service.thread(binding.companyId, binding.issueId);
|
||||
if (thread && !inboxes.some((inbox) => inbox.id === thread.endpoint.id))
|
||||
throw notFound("Email task not found");
|
||||
if (input.action === "thread") return thread;
|
||||
if (input.action === "delivery") {
|
||||
if (!input.publicationId) throw forbidden("Publication ID required");
|
||||
const delivery = await service.publication(
|
||||
input.publicationId,
|
||||
binding.companyId,
|
||||
);
|
||||
await service.authorizeRead(binding.companyId, delivery.issueId, {
|
||||
agentId: binding.agentId,
|
||||
runId: binding.runId,
|
||||
});
|
||||
const target = await service.thread(binding.companyId, delivery.issueId);
|
||||
if (!target || !inboxes.some((inbox) => inbox.id === target.endpoint.id))
|
||||
throw notFound("Email delivery not found");
|
||||
return delivery;
|
||||
}
|
||||
if (binding.workMode && binding.workMode !== "standard")
|
||||
throw forbidden("Email sends require standard work mode");
|
||||
if (
|
||||
!input.request ||
|
||||
(input.request.parentIssueId &&
|
||||
input.request.parentIssueId !== binding.issueId) ||
|
||||
(input.request.conversationId &&
|
||||
input.request.conversationId !== thread?.conversationId)
|
||||
)
|
||||
throw forbidden("Email send must belong to the current task");
|
||||
return service.queueSend(binding.companyId, input.request, {
|
||||
agentId: binding.agentId,
|
||||
runId: binding.runId,
|
||||
});
|
||||
}
|
||||
@@ -236,6 +236,7 @@ async function sourceIssueId(
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return { exists: Boolean(row), issueId: row?.issueId ?? null };
|
||||
}
|
||||
// Keep historical decision queue entries accessible after feature retirement.
|
||||
case "productivity_review":
|
||||
case "blocker_attention":
|
||||
case "review": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,319 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
type Db,
|
||||
agents,
|
||||
toolConnections,
|
||||
connectionGrants,
|
||||
companySecrets,
|
||||
toolConnectionInstalls,
|
||||
} from "@paperclipai/db";
|
||||
import type { EmailConnectionInput } from "@paperclipai/shared";
|
||||
import { badRequest, forbidden, notFound } from "../errors.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { toolAccessService } from "./tool-access.js";
|
||||
import { agentmailApi } from "./agentmail-api.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { EmailActor } from "./email-channels.js";
|
||||
|
||||
export function emailConnectionService(
|
||||
db: Db,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
) {
|
||||
const secrets = secretService(db);
|
||||
async function get(companyId: string, id: string, actor?: EmailActor) {
|
||||
const [connection] = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
eq(toolConnections.id, id),
|
||||
eq(toolConnections.status, "active"),
|
||||
eq(toolConnections.enabled, true),
|
||||
),
|
||||
);
|
||||
if (!connection || connection.config.provider !== "agentmail")
|
||||
throw notFound("Active AgentMail connection not found");
|
||||
if (connection.config.emailCredential) {
|
||||
const id = connection.credentialSecretRefs.find((ref) => ref.configPath === "credentials.controlKey")?.secretId;
|
||||
const [secret] = id ? await db.select({ id: companySecrets.id }).from(companySecrets).where(and(
|
||||
eq(companySecrets.id, id), eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.status, "active"), isNull(companySecrets.deletedAt),
|
||||
)) : [];
|
||||
if (!secret) throw forbidden("AgentMail credential is unavailable");
|
||||
}
|
||||
const activeGrants = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.connectionId, id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
),
|
||||
);
|
||||
if (connection.config.emailCredential && !activeGrants.length)
|
||||
throw forbidden("AgentMail credential access has been revoked");
|
||||
if (actor && !actor.localImplicit) {
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.connectionId, id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!grants.some(
|
||||
(g) =>
|
||||
g.kind === "organization" ||
|
||||
(g.kind === "user" && g.subjectUserId === actor.userId),
|
||||
)
|
||||
)
|
||||
throw forbidden("You do not have access to this AgentMail credential");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
async function assertAgentAccess(
|
||||
companyId: string,
|
||||
id: string,
|
||||
agentId: string,
|
||||
) {
|
||||
await get(companyId, id);
|
||||
const installs = await db
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnectionInstalls.companyId, companyId),
|
||||
eq(toolConnectionInstalls.connectionId, id),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!installs.some(
|
||||
(i) =>
|
||||
(i.targetType === "company" && i.targetId === companyId) ||
|
||||
(i.targetType === "agent" && i.targetId === agentId),
|
||||
)
|
||||
)
|
||||
throw forbidden(
|
||||
"This agent no longer has access to the AgentMail connection",
|
||||
);
|
||||
}
|
||||
async function credential(companyId: string, id: string, actor?: EmailActor) {
|
||||
const connection = await get(companyId, id, actor);
|
||||
const ref = connection.credentialSecretRefs.find(
|
||||
(r) => r.configPath === "credentials.controlKey",
|
||||
);
|
||||
if (!ref) throw badRequest("Reconnect AgentMail to restore its API key");
|
||||
const value = await secrets.resolveSecretValue(
|
||||
companyId,
|
||||
ref.secretId,
|
||||
ref.versionSelector ?? "latest",
|
||||
{
|
||||
consumerType: "tool_connection",
|
||||
consumerId: id,
|
||||
configPath: ref.configPath,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
},
|
||||
);
|
||||
return { connection, ref, value };
|
||||
}
|
||||
async function connect(
|
||||
companyId: string,
|
||||
input: EmailConnectionInput,
|
||||
actor: EmailActor,
|
||||
) {
|
||||
await agentmailApi(input.apiKey, fetchImpl).whoami();
|
||||
return db.transaction(async (tx) => {
|
||||
const db = tx as unknown as Db;
|
||||
await db.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`email-account:${companyId}:${input.idempotencyKey}`}, 0))`,
|
||||
);
|
||||
const secrets = secretService(db);
|
||||
const tools = toolAccessService(db);
|
||||
for (const agentId of input.agentIds) {
|
||||
const [agent] = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
eq(agents.id, agentId),
|
||||
eq(agents.companyId, companyId),
|
||||
ne(agents.status, "terminated"),
|
||||
),
|
||||
);
|
||||
if (!agent) throw badRequest("Select an available company agent");
|
||||
}
|
||||
const previous = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
eq(
|
||||
toolConnections.uid,
|
||||
`agentmail-account-${input.idempotencyKey}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (previous[0])
|
||||
return emailConnectionService(db, fetchImpl).get(
|
||||
companyId,
|
||||
previous[0].id,
|
||||
actor,
|
||||
);
|
||||
const secret = await secrets.create(companyId, {
|
||||
name: `AgentMail account ${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: input.apiKey,
|
||||
});
|
||||
const app = await tools.createApplication(companyId, {
|
||||
name: `AgentMail ${input.idempotencyKey.slice(0, 8)}`,
|
||||
applicationKey: `agentmail-account:${input.idempotencyKey}`,
|
||||
type: "chat",
|
||||
status: "active",
|
||||
metadata: { sourceTemplateKey: "agentmail" },
|
||||
});
|
||||
const connection = await tools.createConnection(
|
||||
companyId,
|
||||
{
|
||||
applicationId: app.id,
|
||||
name: "AgentMail",
|
||||
connectionKind: "managed",
|
||||
connectionPurpose: "tool",
|
||||
transport: "rest_api",
|
||||
authKind: "api_key",
|
||||
ownership: "customer",
|
||||
credentialPolicy: "shared",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
config: { provider: "agentmail", emailCredential: true },
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [
|
||||
{
|
||||
secretId: secret.id,
|
||||
configPath: "credentials.controlKey",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actorType: "user",
|
||||
actorId: actor.userId ?? "board",
|
||||
actorSource: actor.localImplicit ? "local_implicit" : "session",
|
||||
},
|
||||
);
|
||||
await db
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
uid: `agentmail-account-${input.idempotencyKey}`,
|
||||
healthStatus: "ok",
|
||||
healthMessage: "Connected",
|
||||
healthCheckedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolConnections.id, connection.id));
|
||||
if (input.grantKind === "user")
|
||||
await db
|
||||
.update(connectionGrants)
|
||||
.set({
|
||||
kind: "user",
|
||||
isDefault: false,
|
||||
subjectUserId: actor.userId ?? "board",
|
||||
})
|
||||
.where(eq(connectionGrants.connectionId, connection.id));
|
||||
await tools.putConnectionInstalls(
|
||||
connection.id,
|
||||
{
|
||||
installs: input.allAgents
|
||||
? [{ targetType: "company", targetId: companyId }]
|
||||
: input.agentIds.map((targetId) => ({
|
||||
targetType: "agent" as const,
|
||||
targetId,
|
||||
})),
|
||||
},
|
||||
{
|
||||
actorType: "user",
|
||||
actorId: actor.userId ?? "board",
|
||||
actorSource: actor.localImplicit ? "local_implicit" : "session",
|
||||
},
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: actor.userId ?? "board",
|
||||
action: "email.connection.created",
|
||||
entityType: "tool_connection",
|
||||
entityId: connection.id,
|
||||
details: {
|
||||
grantKind: input.grantKind,
|
||||
allAgents: input.allAgents,
|
||||
agentIds: input.agentIds,
|
||||
},
|
||||
});
|
||||
return tools.getConnection(connection.id, companyId);
|
||||
});
|
||||
}
|
||||
async function allowAgent(
|
||||
companyId: string,
|
||||
id: string,
|
||||
agentId: string,
|
||||
actor: EmailActor,
|
||||
) {
|
||||
return db.transaction(async (tx) => {
|
||||
const db = tx as unknown as Db;
|
||||
await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.id, id),
|
||||
eq(toolConnections.companyId, companyId),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
await emailConnectionService(db, fetchImpl).get(companyId, id, actor);
|
||||
const tools = toolAccessService(db);
|
||||
const installs = await db
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(eq(toolConnectionInstalls.connectionId, id));
|
||||
if (
|
||||
installs.some(
|
||||
(i) => i.targetType === "company" || i.targetId === agentId,
|
||||
)
|
||||
)
|
||||
return;
|
||||
await tools.putConnectionInstalls(
|
||||
id,
|
||||
{
|
||||
installs: [
|
||||
...installs.map((i) => ({
|
||||
targetType: i.targetType,
|
||||
targetId: i.targetId,
|
||||
})),
|
||||
{ targetType: "agent", targetId: agentId },
|
||||
],
|
||||
},
|
||||
{
|
||||
actorType: "user",
|
||||
actorId: actor.userId ?? "board",
|
||||
actorSource: actor.localImplicit ? "local_implicit" : "session",
|
||||
},
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: actor.userId ?? "board",
|
||||
action: "email.connection.agent_added",
|
||||
entityType: "tool_connection",
|
||||
entityId: id,
|
||||
details: { agentId },
|
||||
});
|
||||
});
|
||||
}
|
||||
return { get, credential, connect, allowAgent, assertAgentAccess };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -634,9 +635,10 @@ export interface EnvironmentRuntimeDriver {
|
||||
* current environment provider, so a provider change or an environment delete
|
||||
* cannot strand the teardown. `environment` is null when a delete already
|
||||
* removed the environment row. The method throws when the teardown fails, so
|
||||
* the cleanup sweep keeps the row for a later retry.
|
||||
* the cleanup sweep keeps the row for a later retry. Any returned provider
|
||||
* receipt must be validated and persisted by the caller at lease release.
|
||||
*/
|
||||
retryPendingSandboxTeardown?(input: { environment: Environment | null; lease: EnvironmentLease }): Promise<void>;
|
||||
retryPendingSandboxTeardown?(input: { environment: Environment | null; lease: EnvironmentLease }): Promise<unknown>;
|
||||
/**
|
||||
* Report whether the provider worker can run an orphan teardown now. A plugin
|
||||
* sandbox provider worker can be briefly down during its own restart window.
|
||||
@@ -1486,9 +1488,12 @@ function createSandboxEnvironmentDriver(
|
||||
const releaseCleanedUpOrphanRow = async (
|
||||
leaseId: string,
|
||||
diagnosticFields: Record<string, unknown>,
|
||||
receipt: unknown,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const lease = await environmentsSvc.getLeaseById(leaseId);
|
||||
await environmentsSvc.releaseLease(leaseId, "expired", {
|
||||
...(lease ? { remoteExecutionTermination: remoteTerminationReceipt(lease, receipt) } : {}),
|
||||
cleanupStatus: "success",
|
||||
failureReason: "acquire_rejected_teardown_succeeded",
|
||||
});
|
||||
@@ -1564,13 +1569,14 @@ function createSandboxEnvironmentDriver(
|
||||
record: DeferredOrphanCleanupRecord;
|
||||
cause: unknown;
|
||||
canTeardown: boolean;
|
||||
teardown: () => Promise<void>;
|
||||
teardown: () => Promise<unknown>;
|
||||
}): Promise<void> => {
|
||||
const durable = await tryWriteDurablePendingCleanup(input.record);
|
||||
let teardownFailed = !input.canTeardown;
|
||||
let receipt: unknown;
|
||||
if (!teardownFailed) {
|
||||
try {
|
||||
await input.teardown();
|
||||
receipt = await input.teardown();
|
||||
} catch {
|
||||
teardownFailed = true;
|
||||
}
|
||||
@@ -1578,7 +1584,7 @@ function createSandboxEnvironmentDriver(
|
||||
if (!teardownFailed) {
|
||||
// The teardown removed the orphan, so drop the durable row if we wrote one.
|
||||
if (durable.leaseId !== null) {
|
||||
await releaseCleanedUpOrphanRow(durable.leaseId, orphanDiagnosticFields(input.record));
|
||||
await releaseCleanedUpOrphanRow(durable.leaseId, orphanDiagnosticFields(input.record), receipt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2159,7 +2165,7 @@ function createSandboxEnvironmentDriver(
|
||||
cause: error,
|
||||
canTeardown: pluginWorkerManager.isRunning(pluginProvider.resolved.plugin.id),
|
||||
teardown: async () => {
|
||||
await pluginWorkerManager.call(
|
||||
return await pluginWorkerManager.call(
|
||||
pluginProvider.resolved.plugin.id,
|
||||
"environmentDestroyLease",
|
||||
{
|
||||
@@ -2528,7 +2534,7 @@ function createSandboxEnvironmentDriver(
|
||||
{ issueId: input.lease.issueId, heartbeatRunId: input.lease.heartbeatRunId },
|
||||
);
|
||||
const workerConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
|
||||
await pluginWorkerManager.call(
|
||||
return await pluginWorkerManager.call(
|
||||
pluginProvider.resolved.plugin.id,
|
||||
"environmentDestroyLease",
|
||||
{
|
||||
@@ -2545,7 +2551,6 @@ function createSandboxEnvironmentDriver(
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in provider path. Resolve the recorded config secrets through the
|
||||
@@ -3045,6 +3050,7 @@ function createSandboxEnvironmentDriver(
|
||||
const providerKey = readString(metadata.provider);
|
||||
|
||||
let cleanupStatus: "success" | "failed" = "success";
|
||||
let termination: ReturnType<typeof remoteTerminationReceipt>;
|
||||
if (
|
||||
pluginId &&
|
||||
providerKey &&
|
||||
@@ -3057,7 +3063,7 @@ function createSandboxEnvironmentDriver(
|
||||
lease: input.lease,
|
||||
provider: providerKey,
|
||||
});
|
||||
await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
||||
const receipt = await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
||||
pluginWorkerManager.call(pluginId, "environmentReleaseLease", {
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
@@ -3068,6 +3074,7 @@ function createSandboxEnvironmentDriver(
|
||||
leaseMetadata: metadata,
|
||||
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig))),
|
||||
);
|
||||
termination = remoteTerminationReceipt(input.lease, receipt);
|
||||
} catch {
|
||||
cleanupStatus = "failed";
|
||||
}
|
||||
@@ -3096,6 +3103,7 @@ function createSandboxEnvironmentDriver(
|
||||
return await environmentsSvc.releaseLease(input.lease.id, releaseStatus, {
|
||||
failureReason,
|
||||
cleanupStatus,
|
||||
...(cleanupStatus === "success" && termination ? { remoteExecutionTermination: termination } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3106,6 +3114,7 @@ function createSandboxEnvironmentDriver(
|
||||
}): Promise<EnvironmentLease | null> {
|
||||
if (await retainUnsavedWorkFolderLease(db, input.lease)) return { ...input.lease, status: "retained", expiresAt: null, failureReason: "work_folder_save_required" };
|
||||
let cleanupStatus: "success" | "failed" = "success";
|
||||
let termination: ReturnType<typeof remoteTerminationReceipt>;
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
|
||||
try {
|
||||
@@ -3125,7 +3134,7 @@ function createSandboxEnvironmentDriver(
|
||||
lease: input.lease,
|
||||
provider: providerKey,
|
||||
});
|
||||
await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
||||
const receipt = await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
||||
pluginWorkerManager.call(pluginId, "environmentDestroyLease", {
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
@@ -3136,6 +3145,7 @@ function createSandboxEnvironmentDriver(
|
||||
leaseMetadata: metadata,
|
||||
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig))),
|
||||
);
|
||||
termination = remoteTerminationReceipt(input.lease, receipt);
|
||||
}
|
||||
} else {
|
||||
const metadataConfig = sandboxConfigFromLeaseMetadata(input.lease);
|
||||
@@ -3165,6 +3175,7 @@ function createSandboxEnvironmentDriver(
|
||||
{
|
||||
failureReason: input.failureReason,
|
||||
cleanupStatus,
|
||||
...(cleanupStatus === "success" && termination ? { remoteExecutionTermination: termination } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3856,14 +3867,14 @@ export function environmentRuntimeService(
|
||||
async retryPendingSandboxTeardown(input: {
|
||||
environment: Environment | null;
|
||||
lease: EnvironmentLease;
|
||||
}): Promise<void> {
|
||||
}): Promise<unknown> {
|
||||
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
||||
if (!driver.retryPendingSandboxTeardown) {
|
||||
throw new Error(
|
||||
`Environment driver "${driver.driver}" does not support orphan sandbox teardown.`,
|
||||
);
|
||||
}
|
||||
await driver.retryPendingSandboxTeardown(input);
|
||||
return await driver.retryPendingSandboxTeardown(input);
|
||||
},
|
||||
|
||||
// Report whether the provider worker can run an orphan teardown now. The
|
||||
|
||||
@@ -1617,6 +1617,7 @@ export function environmentService(db: Db) {
|
||||
options?: {
|
||||
failureReason?: string;
|
||||
cleanupStatus?: EnvironmentLeaseCleanupStatus;
|
||||
remoteExecutionTermination?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
const now = new Date();
|
||||
@@ -1629,6 +1630,11 @@ export function environmentService(db: Db) {
|
||||
updatedAt: now,
|
||||
...(options?.failureReason !== undefined ? { failureReason: options.failureReason } : {}),
|
||||
...(options?.cleanupStatus !== undefined ? { cleanupStatus: options.cleanupStatus } : {}),
|
||||
// A later release without a receipt cannot reuse an earlier stop's
|
||||
// authority (for example after a same-run lease resume).
|
||||
metadata: options?.remoteExecutionTermination
|
||||
? sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ remoteExecutionTermination: options.remoteExecutionTermination })}::jsonb`
|
||||
: sql`${environmentLeases.metadata} - 'remoteExecutionTermination'`,
|
||||
})
|
||||
.where(eq(environmentLeases.id, id))
|
||||
.returning()
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||
import {
|
||||
approvals, issueApprovals, issueThreadInteractions,
|
||||
agentWakeupRequests, agents, companies, createDb, heartbeatRuns, issueComments, issueRecoveryActions,
|
||||
agentWakeupRequests, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issueRecoveryActions,
|
||||
issues, nativeRunFinalizations, environmentLeases, environments, issueRelations, issueTreeHolds, issueTreeHoldMembers,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
|
||||
import { buildExecutionContinuation } from "./execution-continuation.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { heartbeatService, type HeartbeatEnvironmentRuntime } from "./heartbeat.js";
|
||||
import { getExecutionBlocker } from "./execution-blocker.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("explicit native conversation continuation", () => {
|
||||
@@ -46,6 +47,107 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
agentId: f.agentId, status: "queued", contextSnapshot: { issueId: f.issueId, previousRunId: result.previousRunId, forceFreshSession: true } });
|
||||
return result;
|
||||
});
|
||||
it.each([true, false])("acknowledges a legacy remote Stop only after confirmed lease cleanup: %s", async confirmed => {
|
||||
const f = await seed();
|
||||
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "cancelled", processPid: null,
|
||||
resultJson: { executionCancellation: { state: "requested" } },
|
||||
}).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, runId: f.sourceRunId,
|
||||
agentId: f.agentId, seq: 1, eventType: "adapter.invoke", payload: { adapterType: "claude_local" } });
|
||||
await db.update(issueRecoveryActions).set({ cause: "legacy_execution_requires_reconciliation" })
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
const [environment] = await db.insert(environments).values({ name: `Remote ${f.sourceRunId}`, driver: "sandbox" }).returning();
|
||||
const identity = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId,
|
||||
provider: "daytona", providerLeaseId: "sandbox-legacy" };
|
||||
await db.insert(environmentLeases).values({ ...identity, environmentId: environment.id,
|
||||
status: "expired", leasePolicy: "ephemeral", releasedAt: new Date(), cleanupStatus: "success",
|
||||
metadata: confirmed ? { remoteExecutionTermination: remoteTerminationReceipt(identity,
|
||||
{ providerLeaseId: identity.providerLeaseId, state: "destroyed" }) } : {},
|
||||
});
|
||||
await heartbeatService(db).releaseEnvironmentLeasesForRun({ runId: f.sourceRunId,
|
||||
companyId: f.companyId, agentId: f.agentId, status: "cancelled" });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
expect(run.resultJson?.executionCancellation).toMatchObject({ state: confirmed ? "acknowledged" : "requested" });
|
||||
if (confirmed) expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
||||
else expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["stopped", "destroyed", "missing", "wrong_lease", "cleanup_failed", "active"])(
|
||||
"admits a remote native predecessor only with confirmed termination: %s", async kind => {
|
||||
const f = await seed();
|
||||
// This PID exists on the control-plane host. It must never be used to
|
||||
// infer liveness of the identically numbered remote process.
|
||||
await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const [environment] = await db.insert(environments).values({ name: `Remote ${f.sourceRunId}`, driver: "sandbox" }).returning();
|
||||
const identity = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId,
|
||||
provider: "daytona", providerLeaseId: "sandbox-1" };
|
||||
const proof = remoteTerminationReceipt(identity, { providerLeaseId: "sandbox-1",
|
||||
state: kind === "destroyed" ? "destroyed" : "stopped" });
|
||||
await db.insert(environmentLeases).values({ ...identity, environmentId: environment.id,
|
||||
issueId: f.issueId, status: kind === "active" ? "active" : "released", leasePolicy: "ephemeral",
|
||||
releasedAt: kind === "active" ? null : new Date(), cleanupStatus: kind === "cleanup_failed" ? "failed" : "success",
|
||||
metadata: kind === "missing" ? {} : { remoteExecutionTermination:
|
||||
kind === "wrong_lease" ? { ...proof, providerLeaseId: "other-sandbox" } : proof },
|
||||
});
|
||||
const result = await admit(f);
|
||||
if (["stopped", "destroyed"].includes(kind)) expect(result).toMatchObject({ previousRunId: f.sourceRunId });
|
||||
else expect(result).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ runtime: "native", retry: false }, { runtime: "native", retry: true },
|
||||
{ runtime: "legacy", retry: false }, { runtime: "legacy", retry: true },
|
||||
])("resumes a user message after confirmed cleanup: %j", async ({ runtime, retry }) => {
|
||||
const f = await seed();
|
||||
if (runtime === "legacy") {
|
||||
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "cancelled", processPid: null,
|
||||
resultJson: { executionCancellation: { state: "requested" } } }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, runId: f.sourceRunId,
|
||||
agentId: f.agentId, seq: 1, eventType: "adapter.invoke", payload: { adapterType: "claude_local" } });
|
||||
await db.update(issueRecoveryActions).set({ cause: "legacy_execution_requires_reconciliation" })
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
}
|
||||
// Keep the successor queued so this test never starts an actual provider.
|
||||
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
|
||||
const [environment] = await db.insert(environments).values({ name: `pending-${f.sourceRunId}`, driver: "sandbox" }).returning();
|
||||
const identity = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId,
|
||||
provider: "daytona", providerLeaseId: "pending-sandbox" };
|
||||
await db.insert(environmentLeases).values({ ...identity, environmentId: environment.id, status: "active", leasePolicy: "ephemeral" });
|
||||
const heartbeat = heartbeatService(db, retry ? { environmentRuntime: {
|
||||
retryPendingSandboxTeardown: async () => ({ providerLeaseId: identity.providerLeaseId, state: "destroyed" }),
|
||||
} as unknown as HeartbeatEnvironmentRuntime } : {});
|
||||
await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented",
|
||||
requestedByActorType: "user", requestedByActorId: "board", payload: { issueId: f.issueId, commentId: f.commentId },
|
||||
contextSnapshot: { issueId: f.issueId, wakeCommentId: f.commentId } });
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await heartbeat.resumeRemoteStopComments(source);
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
if (retry) {
|
||||
await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed" })
|
||||
.where(eq(environmentLeases.id, identity.id));
|
||||
expect(await heartbeat.sweepPendingCleanupLeases()).toMatchObject({ destroyed: 1 });
|
||||
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, identity.id));
|
||||
expect(lease.metadata?.remoteExecutionTermination).toMatchObject({ runId: f.sourceRunId, state: "destroyed" });
|
||||
} else {
|
||||
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
||||
metadata: { remoteExecutionTermination: remoteTerminationReceipt(identity,
|
||||
{ providerLeaseId: identity.providerLeaseId, state: "stopped" }) } }).where(eq(environmentLeases.id, identity.id));
|
||||
await heartbeat.releaseEnvironmentLeasesForRun({ runId: source.id, companyId: source.companyId,
|
||||
agentId: source.agentId, status: source.status });
|
||||
}
|
||||
await heartbeat.resumeRemoteStopComments(source);
|
||||
await heartbeat.resumeRemoteStopComments(source);
|
||||
const runs = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
|
||||
expect(runs).toHaveLength(1);
|
||||
if (runtime === "native") expect(runs[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.sourceRunId,
|
||||
explicitUserContinuation: { commentId: f.commentId } });
|
||||
else expect(runs[0].contextSnapshot).toMatchObject({ wakeCommentId: f.commentId });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
||||
});
|
||||
|
||||
it("queues the actual user wake with a fresh session and retained source context", async () => {
|
||||
const f = await seed();
|
||||
// Occupy this agent's only slot so this admission test never starts a provider.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
|
||||
import { hasRemoteTerminationReceipt, remoteLeaseCleanupScope } from "./remote-execution-termination.js";
|
||||
import { z } from "zod";
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
@@ -76,24 +78,31 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
run.errorCode === "execution_reconciliation_required" &&
|
||||
!run.processPid && !run.processGroupId && !run.nativeSessionId;
|
||||
if (run.runtimeMode !== "native" && !unusedAdmission) return null;
|
||||
if (!unusedAdmission) {
|
||||
// A missing process identity is not evidence that a provider exited.
|
||||
if (!run.processPid && !run.processGroupId) return null;
|
||||
if (run.processPid && !processStopped(run.processPid)) return null;
|
||||
if (run.processGroupId && !processStopped(-run.processGroupId)) return null;
|
||||
}
|
||||
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
|
||||
eq(nativeRunFinalizations.companyId, companyId), eq(nativeRunFinalizations.runId, run.id),
|
||||
)).for("update");
|
||||
if (coordinator && (coordinator.phase !== "terminal_failure" || coordinator.leaseOwner ||
|
||||
coordinator.resultId || coordinator.failureDetail?.successorRunId)) return null;
|
||||
const leases = await db.select({ provider: environmentLeases.provider, releasedAt: environmentLeases.releasedAt })
|
||||
const leases = await db.select()
|
||||
.from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
||||
));
|
||||
// A PID on another host cannot be checked with this server's process table.
|
||||
// Remote execution retains its hold until a target-aware stop proof exists.
|
||||
if (leases.some(lease => !lease.releasedAt || lease.provider !== "local")) return null;
|
||||
const remote = leases.some(lease => lease.provider !== "local");
|
||||
if (remote) {
|
||||
// Never interpret remote PIDs using the control-plane host's process table.
|
||||
if (!leases.every(hasRemoteTerminationReceipt)) return null;
|
||||
if (!input.dryRun && !leases.every(lease => completeTerminatedRemoteNativeSessionCleanup({
|
||||
companyId, runId: run.id, remoteCleanupScope: remoteLeaseCleanupScope(lease)!,
|
||||
}))) return null;
|
||||
} else {
|
||||
if (leases.some(lease => !lease.releasedAt || lease.cleanupStatus === "failed")) return null;
|
||||
if (!unusedAdmission) {
|
||||
// A missing process identity is not evidence that a provider exited.
|
||||
if (!run.processPid && !run.processGroupId) return null;
|
||||
if (run.processPid && !processStopped(run.processPid)) return null;
|
||||
if (run.processGroupId && !processStopped(-run.processGroupId)) return null;
|
||||
}
|
||||
}
|
||||
sources.push(run);
|
||||
}
|
||||
const nativeSources = sources.filter(run => run.runtimeMode === "native");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
|
||||
import { remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
|
||||
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
|
||||
import { getExecutionBlocker } from "./execution-blocker.js";
|
||||
import { CONVERSATION_CONTINUATION_POLICY, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
|
||||
@@ -504,7 +507,6 @@ import {
|
||||
REVIEW_PATH_RECOVERY_INSTRUCTION,
|
||||
reviewPathConsumedRefFromRun,
|
||||
} from "./recovery/review-path-recovery.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { resolveRequiredSuccessfulRunHandoffOnValidPath } from "./successful-run-handoff-state.js";
|
||||
import { taskWatchdogService } from "./task-watchdogs.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
@@ -9728,7 +9730,6 @@ export function heartbeatService(
|
||||
return interaction.id;
|
||||
}
|
||||
|
||||
const productivityReviews = productivityReviewService(db, { enqueueWakeup });
|
||||
const taskWatchdogs = taskWatchdogService(db, { enqueueWakeup });
|
||||
let unsafeTextProjectionPromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -9874,7 +9875,19 @@ export function heartbeatService(
|
||||
);
|
||||
return { closed: 0, busy: 0, failed: 1 };
|
||||
});
|
||||
if (closeResult.busy > 0 || closeResult.failed > 0) {
|
||||
// A failed remote checkpoint cannot veto destruction of the isolated
|
||||
// sandbox after the run stopped. Provider destruction supplies the exit
|
||||
// proof; it does not turn the interrupted checkpoint into a success.
|
||||
const remoteLeases = closeResult.failed > 0 && leaseOwnerRun &&
|
||||
["cancelled", "failed", "timed_out", "interrupted"].includes(leaseOwnerRun.status)
|
||||
? await db.select({ provider: environmentLeases.provider }).from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, input.companyId),
|
||||
eq(environmentLeases.heartbeatRunId, input.runId),
|
||||
))
|
||||
: [];
|
||||
const canDestroyRemote = remoteLeases.length > 0 &&
|
||||
remoteLeases.every(lease => lease.provider && lease.provider !== "local");
|
||||
if (closeResult.busy > 0 || (closeResult.failed > 0 && !canDestroyRemote)) {
|
||||
logger.warn(
|
||||
{ runId: input.runId, warmNativeSessions: closeResult },
|
||||
"deferred environment lease destruction until warm native sessions close",
|
||||
@@ -9909,6 +9922,76 @@ export function heartbeatService(
|
||||
"failed to release environment lease for heartbeat run",
|
||||
);
|
||||
}
|
||||
await acknowledgeRemoteStop(input.runId, input.companyId);
|
||||
}
|
||||
|
||||
async function acknowledgeRemoteStop(runId: string, companyId: string) {
|
||||
// The provider receipt arrives after adapter settlement. A remote ACP child
|
||||
// has no host PID, so only this target-aware boundary can acknowledge Stop.
|
||||
const stopped = await getRun(runId);
|
||||
if (stopped?.runtimeMode === "native") {
|
||||
const scopes = await stoppedRemoteCleanupScopes(db, companyId, runId);
|
||||
for (const remoteCleanupScope of scopes ?? []) {
|
||||
completeTerminatedRemoteNativeSessionCleanup({ companyId, runId, remoteCleanupScope });
|
||||
}
|
||||
}
|
||||
if (stopped?.runtimeMode === "legacy" && stopped.status === "cancelled" &&
|
||||
parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
|
||||
await runUsedConversationAdapter(db, stopped) &&
|
||||
await remoteExecutionHasStopped(db, companyId, runId)) {
|
||||
await db.update(heartbeatRuns).set({
|
||||
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
|
||||
executionCancellation: { ...parseObject(stopped.resultJson?.executionCancellation),
|
||||
state: "acknowledged", acknowledgedAt: new Date().toISOString(),
|
||||
proof: "provider_termination_receipt" },
|
||||
conversationContinuation: CONVERSATION_CONTINUATION_POLICY,
|
||||
})}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.status, "cancelled")));
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeRemoteStopComments(run: typeof heartbeatRuns.$inferSelect) {
|
||||
if (!isHeartbeatRunTerminalStatus(run.status) || adapterExecutionControls.has(run.id) ||
|
||||
!(await remoteExecutionHasStopped(db, run.companyId, run.id))) return;
|
||||
const issueId = run.nativeIssueId ?? (typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null);
|
||||
if (!issueId) return;
|
||||
const legacyContinuation = run.runtimeMode === "legacy" && run.status === "cancelled" &&
|
||||
hasConversationContinuationPolicy((await getRun(run.id))?.resultJson) &&
|
||||
!(await getExecutionBlocker(db, run.companyId, issueId));
|
||||
if (run.runtimeMode !== "native" && !legacyContinuation) return;
|
||||
const pending = await db.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
eq(agentWakeupRequests.requestedByActorType, "user"),
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
||||
)).orderBy(asc(agentWakeupRequests.requestedAt));
|
||||
for (const wake of pending) {
|
||||
const payload = parseObject(wake.payload);
|
||||
const context = parseObject(payload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||
const commentId = deriveCommentId(context, payload);
|
||||
if (legacyContinuation) {
|
||||
if (!commentId || !run.finishedAt || !wake.requestedByActorId ||
|
||||
!["issue_commented", "issue_reopened_via_comment"].includes(wake.reason ?? "")) continue;
|
||||
const [comment] = await db.select().from(issueComments).where(and(
|
||||
eq(issueComments.companyId, run.companyId), eq(issueComments.issueId, issueId),
|
||||
sql`${issueComments.id}::text = ${commentId}`, eq(issueComments.authorType, "user"),
|
||||
eq(issueComments.authorUserId, wake.requestedByActorId), isNull(issueComments.deletedAt),
|
||||
isNull(issueComments.createdByRunId), gt(issueComments.createdAt, run.finishedAt),
|
||||
));
|
||||
if (!comment?.body.trim()) continue;
|
||||
} else if (!await admitExplicitNativeContinuation({ db, companyId: run.companyId, issueId,
|
||||
agentId: run.agentId, actorType: wake.requestedByActorType, actorId: wake.requestedByActorId,
|
||||
reason: wake.reason, commentId, successorRunId: randomUUID(), dryRun: true })) continue;
|
||||
// Re-enter ordinary admission with the original user's authority. It
|
||||
// atomically adopts the deferred comments and still applies every gate.
|
||||
await enqueueWakeup(run.agentId, { source: wake.source as WakeupOptions["source"], triggerDetail: (wake.triggerDetail ?? undefined) as WakeupOptions["triggerDetail"],
|
||||
reason: wake.reason, payload, contextSnapshot: context,
|
||||
requestedByActorType: "user", requestedByActorId: wake.requestedByActorId,
|
||||
idempotencyKey: `remote-stop-comment:${run.id}:${wake.id}` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasUnsafeTextProjectionDatabase() {
|
||||
@@ -12514,29 +12597,6 @@ export function heartbeatService(
|
||||
projectId: issue.projectId,
|
||||
})
|
||||
: null;
|
||||
if (issue) {
|
||||
const productivityHold =
|
||||
await productivityReviews.isProductivityReviewContinuationHoldActive({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
agentId: run.agentId,
|
||||
});
|
||||
if (productivityHold.held) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason: `${run.livenessReason ?? "Run ended without concrete progress"}; continuation held by productivity review ${productivityHold.reviewIdentifier ?? productivityHold.reviewIssueId}`,
|
||||
});
|
||||
await productivityReviews.recordContinuationHold({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
reviewIssueId: productivityHold.reviewIssueId,
|
||||
trigger: productivityHold.trigger,
|
||||
reason: productivityHold.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
|
||||
const idempotencyKey = issue
|
||||
@@ -17719,15 +17779,16 @@ export function heartbeatService(
|
||||
try {
|
||||
if (useRecordedTeardown) {
|
||||
// Tear the sandbox down from the recorded provider config and the
|
||||
// cleanup-authorized secret versions. The teardown returns no value
|
||||
// and throws on failure, so the sweep releases the lease itself.
|
||||
await environmentRuntime.retryPendingSandboxTeardown({
|
||||
// cleanup-authorized secret versions. Preserve any provider receipt;
|
||||
// a completed retry must grant the same evidence as initial cleanup.
|
||||
const receipt = await environmentRuntime.retryPendingSandboxTeardown({
|
||||
environment,
|
||||
lease,
|
||||
});
|
||||
await environmentsSvc.releaseLease(lease.id, "expired", {
|
||||
cleanupStatus: "success",
|
||||
failureReason: "pending_cleanup_retry",
|
||||
remoteExecutionTermination: remoteTerminationReceipt(lease, receipt),
|
||||
});
|
||||
destroyed += 1;
|
||||
} else if (environment) {
|
||||
@@ -17764,6 +17825,15 @@ export function heartbeatService(
|
||||
"pending_cleanup lease retry failed",
|
||||
);
|
||||
}
|
||||
if (lease.heartbeatRunId) {
|
||||
// Delivery failure must not revert successful provider cleanup. A new
|
||||
// message can still use the persisted receipt on its next admission.
|
||||
await (async () => {
|
||||
await acknowledgeRemoteStop(lease.heartbeatRunId!, lease.companyId);
|
||||
const run = await getRun(lease.heartbeatRunId!);
|
||||
if (run) await resumeRemoteStopComments(run);
|
||||
})().catch(() => logger.warn({ leaseId: lease.id }, "could not reconsider messages after cleanup retry"));
|
||||
}
|
||||
}
|
||||
|
||||
return { swept: rows.length, destroyed, capped };
|
||||
@@ -18625,16 +18695,6 @@ export function heartbeatService(
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcileProductivityReviews(opts?: {
|
||||
now?: Date;
|
||||
companyId?: string;
|
||||
}) {
|
||||
return productivityReviews.reconcileProductivityReviews({
|
||||
...opts,
|
||||
issueCreatedAtGte: await getWorktreeExecutionCutoff(),
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcileTaskWatchdogs(opts?: {
|
||||
companyId?: string | null;
|
||||
runId?: string | null;
|
||||
@@ -20182,10 +20242,14 @@ export function heartbeatService(
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
endedAtMs: Date.now(),
|
||||
});
|
||||
let runtimeConfig: Record<string, unknown> = {
|
||||
...effectiveResolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id });
|
||||
const connectorSkillConfig = await applyConnectorSkills(effectiveResolvedConfig, runtimeSkillEntries, connectorAssignments);
|
||||
// Both CLI adapters and native context materialization use the same resolved set.
|
||||
runtimeSkillEntries.splice(0, runtimeSkillEntries.length, ...connectorSkillConfig.paperclipRuntimeSkills);
|
||||
const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType);
|
||||
// Always replace this runtime-only field; caller wake data cannot supply skills.
|
||||
context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
|
||||
let runtimeConfig: Record<string, unknown> = connectorDelivery.config;
|
||||
const latestAgentConfigRevision = await getLatestAgentConfigRevision(
|
||||
agent.companyId,
|
||||
agent.id,
|
||||
@@ -22900,6 +22964,7 @@ export function heartbeatService(
|
||||
executePaperclipNativeSession({
|
||||
db,
|
||||
execution: nativeExecution,
|
||||
turnTimeoutMs: Math.max(0, asNumber(runtimeConfig.timeoutSec, 0)) * 1_000,
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
leaseOwner: runOptions.nativeLeaseOwner,
|
||||
restartRecovery: runOptions.nativeRestartRecovery,
|
||||
@@ -24815,6 +24880,9 @@ export function heartbeatService(
|
||||
!nativeWorkspaceFinalizeScheduled &&
|
||||
!shutdownInProgress
|
||||
) {
|
||||
if (latestRun) await resumeRemoteStopComments(latestRun).catch(err => {
|
||||
logger.warn({ err, runId: run.id }, "failed to resume user messages after remote Stop");
|
||||
});
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
}
|
||||
}
|
||||
@@ -25451,8 +25519,10 @@ export function heartbeatService(
|
||||
const [chatBinding] = await tx
|
||||
.select({ id: chatConversations.id })
|
||||
.from(chatConversations)
|
||||
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId))
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.externalExecutionPolicy, "restricted"),
|
||||
eq(chatConversations.companyId, agent.companyId),
|
||||
eq(chatConversations.issueId, issueId),
|
||||
),
|
||||
@@ -28019,6 +28089,7 @@ export function heartbeatService(
|
||||
terminalizeRunOnLeaseRelease,
|
||||
|
||||
releaseEnvironmentLeasesForRun,
|
||||
resumeRemoteStopComments,
|
||||
|
||||
sweepStaleIssueLocks,
|
||||
|
||||
@@ -28026,8 +28097,6 @@ export function heartbeatService(
|
||||
|
||||
scanSilentActiveRuns,
|
||||
|
||||
reconcileProductivityReviews,
|
||||
|
||||
reconcileTaskWatchdogs,
|
||||
|
||||
buildRunOutputSilence,
|
||||
|
||||
@@ -114,10 +114,6 @@ export {
|
||||
RunnerGoalActionError,
|
||||
RunnerGoalConflictError,
|
||||
} from "./runner-goals.js";
|
||||
export {
|
||||
productivityReviewService,
|
||||
PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
} from "./productivity-review.js";
|
||||
export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/index.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
|
||||
@@ -79,8 +79,6 @@ import type {
|
||||
IssueReviewAttentionPath,
|
||||
IssueBlockedInboxAttention,
|
||||
IssueBlockedInboxIssueRef,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
IssueWatchdogSummary,
|
||||
LowTrustBoundary,
|
||||
@@ -1114,6 +1112,10 @@ export async function resolveChatOriginPublicationBindings(
|
||||
runId: string | null,
|
||||
): Promise<ChatPublicationBinding[]> {
|
||||
if (!runId) return [];
|
||||
const explicitEmail = await dbOrTx.select({ id: chatEndpoints.id }).from(chatEndpoints)
|
||||
.innerJoin(chatConversations, eq(chatConversations.endpointId, chatEndpoints.id))
|
||||
.where(and(eq(chatConversations.companyId, companyId), eq(chatConversations.issueId, issueId), eq(chatEndpoints.publicationMode, "explicit"))).limit(1);
|
||||
if (explicitEmail.length) return [];
|
||||
|
||||
let originRunId = runId;
|
||||
let contextSnapshot: Record<string, unknown> | null = null;
|
||||
@@ -3238,15 +3240,6 @@ const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = [
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND =
|
||||
"harness_liveness_escalation";
|
||||
const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [
|
||||
"issue.productivity_review_created",
|
||||
"issue.productivity_review_updated",
|
||||
];
|
||||
const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] =
|
||||
["no_comment_streak", "long_active_duration", "high_churn"];
|
||||
|
||||
function lowTrustBoundaryIssueCondition(
|
||||
companyId: string,
|
||||
boundary: (LowTrustBoundary & { companyId: string }) | null | undefined,
|
||||
@@ -3628,132 +3621,6 @@ async function terminalExplicitBlockersByRoot(
|
||||
return terminalByRoot;
|
||||
}
|
||||
|
||||
function readProductivityReviewTrigger(
|
||||
value: unknown,
|
||||
): IssueProductivityReviewTrigger | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return PRODUCTIVITY_REVIEW_TRIGGERS.includes(
|
||||
value as IssueProductivityReviewTrigger,
|
||||
)
|
||||
? (value as IssueProductivityReviewTrigger)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readProductivityReviewStreak(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
||||
return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
async function listIssueProductivityReviewMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
): Promise<Map<string, IssueProductivityReview>> {
|
||||
const map = new Map<string, IssueProductivityReview>();
|
||||
if (sourceIssueIds.length === 0) return map;
|
||||
|
||||
const reviewRows: Array<{
|
||||
sourceIssueId: string | null;
|
||||
reviewIssueId: string;
|
||||
reviewIdentifier: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
for (const chunk of chunkList(
|
||||
[...new Set(sourceIssueIds)],
|
||||
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
||||
)) {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
sourceIssueId: issues.originId,
|
||||
reviewIssueId: issues.id,
|
||||
reviewIdentifier: issues.identifier,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
inArray(issues.originId, chunk),
|
||||
visibleIssueCondition(),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id));
|
||||
reviewRows.push(...rows);
|
||||
}
|
||||
|
||||
if (reviewRows.length === 0) return map;
|
||||
|
||||
const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId);
|
||||
const triggerByReviewIssueId = new Map<
|
||||
string,
|
||||
{
|
||||
trigger: IssueProductivityReviewTrigger | null;
|
||||
noCommentStreak: number | null;
|
||||
}
|
||||
>();
|
||||
for (const chunk of chunkList(
|
||||
reviewIssueIds,
|
||||
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
||||
)) {
|
||||
const detailRows = await dbOrTx
|
||||
.select({
|
||||
entityId: activityLog.entityId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, chunk),
|
||||
inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt));
|
||||
for (const row of detailRows as Array<{
|
||||
entityId: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}>) {
|
||||
if (triggerByReviewIssueId.has(row.entityId)) continue;
|
||||
triggerByReviewIssueId.set(row.entityId, {
|
||||
trigger: readProductivityReviewTrigger(row.details?.trigger),
|
||||
noCommentStreak: readProductivityReviewStreak(
|
||||
row.details?.noCommentStreak,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of reviewRows) {
|
||||
if (!row.sourceIssueId) continue;
|
||||
if (map.has(row.sourceIssueId)) continue;
|
||||
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
||||
map.set(row.sourceIssueId, {
|
||||
reviewIssueId: row.reviewIssueId,
|
||||
reviewIdentifier: row.reviewIdentifier,
|
||||
status: row.status as IssueProductivityReview["status"],
|
||||
priority: row.priority as IssueProductivityReview["priority"],
|
||||
trigger: detail?.trigger ?? null,
|
||||
noCommentStreak: detail?.noCommentStreak ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
@@ -6369,7 +6236,6 @@ async function listBlockedInboxIssues(
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
reviewAttention?: IssueReviewAttention;
|
||||
blockedInboxAttention: IssueBlockedInboxAttention;
|
||||
productivityReview?: IssueProductivityReview | null;
|
||||
liveDescendantCount?: number;
|
||||
lastActivityAt: Date;
|
||||
myLastTouchAt?: Date | null;
|
||||
@@ -6418,7 +6284,6 @@ async function listBlockedInboxIssues(
|
||||
blockedByMap,
|
||||
blockerAttentionByIssueId,
|
||||
reviewAttentionByIssueId,
|
||||
productivityReviewByIssueId,
|
||||
blockedInboxAttentionByIssueId,
|
||||
liveDescendantCountByIssueId,
|
||||
] = await Promise.all([
|
||||
@@ -6432,7 +6297,6 @@ async function listBlockedInboxIssues(
|
||||
blockedByMapForIssues(dbOrTx, companyId, issueIds),
|
||||
listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns),
|
||||
listIssueReviewAttentionMap(dbOrTx, companyId, withRuns),
|
||||
listIssueProductivityReviewMap(dbOrTx, companyId, issueIds),
|
||||
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
|
||||
includeLiveDescendantSummary
|
||||
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
|
||||
@@ -6506,9 +6370,6 @@ async function listBlockedInboxIssues(
|
||||
reviewAttention:
|
||||
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
|
||||
blockedInboxAttention,
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
...(includeLiveDescendantSummary
|
||||
? {
|
||||
liveDescendantCount:
|
||||
@@ -8160,12 +8021,10 @@ export function issueService(db: Db) {
|
||||
const [
|
||||
blockerAttentionByIssueId,
|
||||
reviewAttentionByIssueId,
|
||||
productivityReviewByIssueId,
|
||||
blockedInboxAttentionByIssueId,
|
||||
] = await Promise.all([
|
||||
listIssueBlockerAttentionMap(db, companyId, withRuns),
|
||||
listIssueReviewAttentionMap(db, companyId, withRuns),
|
||||
listIssueProductivityReviewMap(db, companyId, issueIds),
|
||||
includeBlockedInboxAttention
|
||||
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
|
||||
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
|
||||
@@ -8203,9 +8062,6 @@ export function issueService(db: Db) {
|
||||
liveDescendantCountByIssueId.get(row.id) ?? 0,
|
||||
}
|
||||
: {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8249,9 +8105,6 @@ export function issueService(db: Db) {
|
||||
liveDescendantCountByIssueId.get(row.id) ?? 0,
|
||||
}
|
||||
: {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt:
|
||||
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
@@ -9119,14 +8972,6 @@ export function issueService(db: Db) {
|
||||
return listIssueReviewAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listProductivityReviews: async (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
@@ -10474,8 +10319,8 @@ export function issueService(db: Db) {
|
||||
createdAt: row.createdAt ?? new Date(),
|
||||
updatedAt: row.updatedAt ?? new Date(),
|
||||
// Imported in-progress work did not start at import time; fabricating
|
||||
// startedAt here trips duration-based sweeps (e.g. productivity
|
||||
// review). Only a bundle-carried startedAt is written.
|
||||
// startedAt here would misrepresent its active episode.
|
||||
// Only a bundle-carried startedAt is written.
|
||||
startedAt: row.startedAt ?? null,
|
||||
completedAt:
|
||||
row.completedAt ?? (row.status === "done" ? new Date() : null),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lte, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -30,6 +31,7 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
|
||||
import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js";
|
||||
import { dismissObsoleteNativePolicyReviews } from "./obsolete-policy-reviews.js";
|
||||
import {
|
||||
cleanupNativeWorkspaceSync,
|
||||
readNativeWorkspaceSyncReference,
|
||||
@@ -57,7 +59,6 @@ export type NativeReconciliationFacts = {
|
||||
newEvidenceSatisfiesContract?: boolean;
|
||||
dependencyResolved?: boolean;
|
||||
authorizedResume?: boolean;
|
||||
policyVersionChanged?: boolean;
|
||||
statusVersionAdvanced?: boolean;
|
||||
};
|
||||
|
||||
@@ -111,22 +112,6 @@ export function resolveNativeReconciliationStatus(input: {
|
||||
if (input.facts.authoritativeStatusChanged) {
|
||||
return preserve("prior_status_terminal_preserved", [{ kind: "append_superseding_assessment" }]);
|
||||
}
|
||||
if (input.facts.policyVersionChanged) {
|
||||
if (["done", "cancelled"].includes(input.priorIssueStatus)) {
|
||||
return preserve("prior_status_terminal_preserved", [{ kind: "append_superseding_assessment" }]);
|
||||
}
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "in_review",
|
||||
toStatus: "in_review",
|
||||
reasonCode: "completion_review_required",
|
||||
unblockDescriptor: null,
|
||||
effects: [
|
||||
{ kind: "bind_reviewer", prompt: "Review the superseding native policy assessment.", ownerUserId: null },
|
||||
{ kind: "append_superseding_assessment" },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (input.facts.statusVersionAdvanced) {
|
||||
return preserve("arbitration_conflict_reloaded", [
|
||||
{ kind: "increment_status_version" },
|
||||
@@ -553,6 +538,9 @@ export async function reconcileNativeFinalizations(
|
||||
}) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
await dismissObsoleteNativePolicyReviews(db, runIds).catch((err) => {
|
||||
logger.warn({ err }, "Obsolete native policy review lookup failed; continuing native reconciliation");
|
||||
});
|
||||
const rows = await db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
@@ -655,7 +643,6 @@ export async function reconcileNativeFinalizations(
|
||||
),
|
||||
)).limit(1).then((entries) => entries[0] ?? null)
|
||||
: null;
|
||||
const policyVersionChanged = assessment?.policyVersion !== NATIVE_STATUS_ARBITER_POLICY_VERSION;
|
||||
const currentDecision = row.decisionId
|
||||
? await db.select({
|
||||
assessmentId: statusDecisions.assessmentId,
|
||||
@@ -727,7 +714,7 @@ export async function reconcileNativeFinalizations(
|
||||
let reassessment = null;
|
||||
let resultRow = null;
|
||||
let contractRow = null;
|
||||
if (assessment && (policyVersionChanged || authoritativeStatusChanged || changedEvidence)) {
|
||||
if (assessment && (authoritativeStatusChanged || changedEvidence)) {
|
||||
[resultRow, contractRow] = await Promise.all([
|
||||
db.select().from(nativeRunResults).where(and(
|
||||
eq(nativeRunResults.id, assessment.resultId),
|
||||
@@ -758,11 +745,9 @@ export async function reconcileNativeFinalizations(
|
||||
&& reassessment.verificationPassed === true;
|
||||
const facts: NativeReconciliationFacts = authoritativeStatusChanged
|
||||
? { authoritativeStatusChanged: true }
|
||||
: policyVersionChanged
|
||||
? { policyVersionChanged: true }
|
||||
: newEvidenceSatisfiesContract
|
||||
? { newEvidenceSatisfiesContract: true }
|
||||
: {};
|
||||
: newEvidenceSatisfiesContract
|
||||
? { newEvidenceSatisfiesContract: true }
|
||||
: {};
|
||||
if (Object.keys(facts).length > 0) {
|
||||
if (!assessment || !reassessment || !resultRow || !contractRow) {
|
||||
throw new Error("native_reconciliation_reassessment_missing");
|
||||
|
||||
@@ -207,6 +207,7 @@ describe("native controller takeover fencing", () => {
|
||||
}),
|
||||
now,
|
||||
isProcessAlive,
|
||||
readProcessStartedAt: async () => recordedStart,
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false, reason: "controller_still_alive" });
|
||||
expect(isProcessAlive).toHaveBeenCalledWith(123);
|
||||
|
||||
@@ -5802,26 +5802,31 @@ describe("native warm session supervision", () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(base),
|
||||
execution: base,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(lowered),
|
||||
execution: lowered,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
expect(firstClose).toHaveBeenCalledWith({
|
||||
reason: "warm native session configuration changed",
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(secondClose).toHaveBeenCalledWith({
|
||||
reason: "warm native session idle timeout",
|
||||
}),
|
||||
{ timeout: 500 },
|
||||
);
|
||||
// Filesystem work between calls can exceed the idle window on a busy host.
|
||||
// Advance that window only after proving the permission change closed it.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(base),
|
||||
execution: base,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(lowered),
|
||||
execution: lowered,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
expect(firstClose).toHaveBeenCalledWith({
|
||||
reason: "warm native session configuration changed",
|
||||
});
|
||||
expect(secondClose).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(secondClose).toHaveBeenCalledWith({
|
||||
reason: "warm native session idle timeout",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
|
||||
import {
|
||||
boundedExecutionCleanup,
|
||||
EXECUTION_CONTROL_DEADLINE_MS,
|
||||
@@ -6613,6 +6615,8 @@ export async function executePaperclipNativeSession(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
runnerInstanceId: string;
|
||||
/** Configured total turn bound; zero/unset is unlimited. */
|
||||
turnTimeoutMs?: number;
|
||||
leaseOwner?: string;
|
||||
restartRecovery?: NativeRestartRecoveryClaim;
|
||||
onSpawn?: (meta: {
|
||||
@@ -6633,8 +6637,6 @@ export async function executePaperclipNativeSession(input: {
|
||||
onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise<void>;
|
||||
sessionGoalControl?: NativeSessionGoalControl | null;
|
||||
resumeSessionGoalHeartbeat?: boolean;
|
||||
/** Internal test seam; production rolls over five minutes before runnerd's one-hour lease. */
|
||||
goalRolloverAtMs?: number;
|
||||
preparationSpans?: NativeRunHistoricalSpan[];
|
||||
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
@@ -7612,6 +7614,14 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
trace,
|
||||
})
|
||||
: null;
|
||||
const remoteCleanupLease = input.runnerExecutionTarget?.kind === "remote" &&
|
||||
input.runnerExecutionTarget.transport === "sandbox" && input.runnerExecutionTarget.leaseId
|
||||
? await input.db.select({ provider: environmentLeases.provider, providerLeaseId: environmentLeases.providerLeaseId })
|
||||
.from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, input.execution.binding.companyId),
|
||||
eq(environmentLeases.id, input.runnerExecutionTarget.leaseId),
|
||||
)).then(rows => rows[0])
|
||||
: null;
|
||||
nativeSessionExecuteStartedAtMs = Date.now();
|
||||
native = await trace.measure(
|
||||
"native.session.execute",
|
||||
@@ -7624,6 +7634,8 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
const result = await trace.run(runnerSessionStartupScope, () =>
|
||||
executeNativeSession({
|
||||
input: runnerExecution,
|
||||
remoteCleanupScope: remoteCleanupLease ? remoteLeaseCleanupScope(remoteCleanupLease) : undefined,
|
||||
turnTimeoutMs: input.turnTimeoutMs,
|
||||
backend:
|
||||
input.backend ??
|
||||
runnerdBackend ??
|
||||
@@ -9610,7 +9622,11 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
input.db,
|
||||
input.execution.binding,
|
||||
);
|
||||
const pinnedSkills = new Set("runtimeContext" in input.execution ? input.execution.runtimeContext.skills.map((skill) => skill.key) : []);
|
||||
const connectorAssignments = [...pinnedSkills].some(isConnectorSkill)
|
||||
? await resolveConnectorAssignments(input.db, input.execution.binding) : [];
|
||||
const authority = new PaperclipRunnerToolAuthority(input.db, {
|
||||
connectorAssignments: connectorAssignments.filter((assignment) => pinnedSkills.has(assignment.skillKey)),
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
runId: input.execution.binding.runId,
|
||||
|
||||
@@ -23,7 +23,7 @@ export function nativeToolContractFingerprintForTarget(
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
schema: "paperclip.native-tool-contract.v10",
|
||||
schema: "paperclip.native-tool-contract.v12",
|
||||
executionTargetKind,
|
||||
advertisementPolicy: {
|
||||
// Direct provider threads retain declarations from thread/start.
|
||||
@@ -37,6 +37,7 @@ export function nativeToolContractFingerprintForTarget(
|
||||
structuredHumanInput:
|
||||
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
|
||||
semanticCompletion: "finish_response_wake_user_facing_summary.v3",
|
||||
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
|
||||
},
|
||||
tools: [
|
||||
...(executionTargetKind === "local"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import {
|
||||
approvals, issueApprovals, issueThreadInteractions, issues,
|
||||
nativeRunFinalizations, statusDecisionEffects, statusDecisions, workAssessments,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { enqueueTerminalIssueInteractionChatPublications } from "../chat-interaction-publications.js";
|
||||
import { persistActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
|
||||
|
||||
const obsoletePrompt = "Review the superseding native policy assessment.";
|
||||
|
||||
/** Retire only the old version-change gate, never a real completion review. */
|
||||
export async function dismissObsoleteNativePolicyReviews(db: Db, runIds?: string[]) {
|
||||
const candidates = await db.select({
|
||||
interaction: issueThreadInteractions,
|
||||
decision: statusDecisions,
|
||||
priorDecisionId: workAssessments.priorDecisionId,
|
||||
}).from(issueThreadInteractions)
|
||||
.innerJoin(statusDecisionEffects, and(
|
||||
eq(statusDecisionEffects.companyId, issueThreadInteractions.companyId),
|
||||
eq(statusDecisionEffects.issueId, issueThreadInteractions.issueId),
|
||||
sql`${statusDecisionEffects.targetId} = ${issueThreadInteractions.id}::text`,
|
||||
eq(statusDecisionEffects.targetType, "issue_thread_interaction"),
|
||||
eq(statusDecisionEffects.effectKind, "bind_reviewer"),
|
||||
))
|
||||
.innerJoin(statusDecisions, and(
|
||||
eq(statusDecisions.id, statusDecisionEffects.decisionId),
|
||||
eq(statusDecisions.companyId, issueThreadInteractions.companyId),
|
||||
eq(statusDecisions.issueId, issueThreadInteractions.issueId),
|
||||
eq(statusDecisions.runId, issueThreadInteractions.sourceRunId),
|
||||
))
|
||||
.innerJoin(workAssessments, and(
|
||||
eq(workAssessments.id, statusDecisions.assessmentId),
|
||||
eq(workAssessments.companyId, statusDecisions.companyId),
|
||||
eq(workAssessments.issueId, statusDecisions.issueId),
|
||||
))
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
isNull(issueThreadInteractions.createdByAgentId),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
eq(statusDecisions.applicationState, "applied"),
|
||||
eq(statusDecisions.reasonCode, "completion_review_required"),
|
||||
eq(statusDecisions.toStatus, "in_review"),
|
||||
sql`${issueThreadInteractions.idempotencyKey} = 'native-review:' || ${statusDecisions.id}::text`,
|
||||
sql`${issueThreadInteractions.payload}->>'prompt' = ${obsoletePrompt}`,
|
||||
// Match the complete old decision, including its unique assessment-only effect.
|
||||
sql`${statusDecisions.decisionJson}->'effects' = ${JSON.stringify([
|
||||
{ kind: "bind_reviewer", prompt: obsoletePrompt, ownerUserId: null },
|
||||
{ kind: "append_superseding_assessment" },
|
||||
])}::jsonb`,
|
||||
...(runIds?.length ? [inArray(statusDecisions.runId, runIds)] : []),
|
||||
)).limit(100);
|
||||
|
||||
for (const { interaction, decision, priorDecisionId } of candidates) {
|
||||
const publications: ActivityPublication[] = [];
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Same lock order as status commits: coordinator, issue, interaction.
|
||||
await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations)
|
||||
.where(and(eq(nativeRunFinalizations.runId, decision.runId),
|
||||
eq(nativeRunFinalizations.companyId, decision.companyId)))
|
||||
.for("update");
|
||||
const issue = await tx.select().from(issues).where(and(
|
||||
eq(issues.id, decision.issueId), eq(issues.companyId, decision.companyId),
|
||||
)).for("update").then((rows) => rows[0]);
|
||||
if (!issue) return;
|
||||
const now = new Date();
|
||||
const [cancelled] = await tx.update(issueThreadInteractions).set({
|
||||
status: "cancelled",
|
||||
result: { version: 1, outcome: "withdrawn", reason: "A Paperclip upgrade does not require completion review." },
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(issueThreadInteractions.id, interaction.id),
|
||||
eq(issueThreadInteractions.companyId, decision.companyId),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
)).returning({ id: issueThreadInteractions.id });
|
||||
if (!cancelled) return;
|
||||
const terminalInteraction = await issueThreadInteractionService(tx as unknown as Db).getById(cancelled.id);
|
||||
if (terminalInteraction) {
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, terminalInteraction);
|
||||
}
|
||||
|
||||
const pendingInteraction = await tx.select({ id: issueThreadInteractions.id })
|
||||
.from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
)).limit(1);
|
||||
const pendingApproval = await tx.select({ id: approvals.id }).from(issueApprovals)
|
||||
.innerJoin(approvals, and(eq(approvals.id, issueApprovals.approvalId),
|
||||
eq(approvals.companyId, issue.companyId)))
|
||||
.where(and(eq(issueApprovals.companyId, issue.companyId), eq(issueApprovals.issueId, issue.id),
|
||||
inArray(approvals.status, ["pending", "revision_requested"]))).limit(1);
|
||||
const restoreStatus = issue.status === "in_review"
|
||||
&& issue.lastStatusDecisionId === decision.id
|
||||
&& issue.statusVersion === Number(decision.decisionJson.projectedStatusVersion ?? decision.decisionVersion)
|
||||
&& ["backlog", "todo", "in_progress", "blocked"].includes(decision.fromStatus)
|
||||
&& pendingInteraction.length === 0 && pendingApproval.length === 0
|
||||
&& issue.executionState?.status !== "pending";
|
||||
if (restoreStatus) {
|
||||
const priorDecision = priorDecisionId ? await tx.select().from(statusDecisions).where(and(
|
||||
eq(statusDecisions.id, priorDecisionId), eq(statusDecisions.companyId, issue.companyId),
|
||||
eq(statusDecisions.issueId, issue.id),
|
||||
)).then((rows) => rows[0]) : null;
|
||||
await issueService(tx as unknown as Db).update(issue.id, {
|
||||
status: decision.fromStatus,
|
||||
// This is an administrative correction, not a replay of the old decision.
|
||||
lastStatusDecisionId: null,
|
||||
unblockDescriptor: priorDecision?.decisionJson.unblockDescriptor as typeof issue.unblockDescriptor ?? null,
|
||||
}, tx, publications);
|
||||
}
|
||||
const { publication } = await persistActivity(tx as unknown as Db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "native-policy-review-cleanup",
|
||||
action: restoreStatus ? "issue.updated" : "issue.interaction_cancelled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
issueId: issue.id,
|
||||
runId: decision.runId,
|
||||
details: {
|
||||
source: "obsolete_native_policy_review",
|
||||
interactionId: interaction.id,
|
||||
decisionId: decision.id,
|
||||
fromStatus: issue.status,
|
||||
toStatus: restoreStatus ? decision.fromStatus : issue.status,
|
||||
},
|
||||
});
|
||||
publications.push(publication);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, interactionId: interaction.id, issueId: decision.issueId },
|
||||
"Failed to withdraw obsolete native policy review; will retry on the next pass");
|
||||
continue;
|
||||
}
|
||||
for (const publication of publications) {
|
||||
try {
|
||||
publishActivity(publication);
|
||||
} catch (err) {
|
||||
logger.warn({ err, interactionId: interaction.id, issueId: decision.issueId },
|
||||
"Obsolete native policy review cleanup committed; live activity publication failed, history is preserved");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isConnectorTool, executeConnectorTool, type ConnectorAssignment } from "../connector-runtime.js";
|
||||
import { resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
|
||||
import { connectionIntentService } from "../connection-intents.js";
|
||||
import { RUNTIME_CONNECTION_TOOL_DEFINITIONS } from "../connection-tool-definitions.js";
|
||||
@@ -86,6 +87,7 @@ type Binding = {
|
||||
apiUrl?: string;
|
||||
storage?: StorageService;
|
||||
/** Server-owned suppression for baseline evals; true never overrides operator opt-in. */
|
||||
connectorAssignments?: ConnectorAssignment[];
|
||||
apiToolsEnabled?: boolean;
|
||||
workMode?: "standard" | "planning" | "ask";
|
||||
workspaceRoot?: string;
|
||||
@@ -184,7 +186,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
definitions.push(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION);
|
||||
definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION);
|
||||
definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION);
|
||||
return [...RUNTIME_CONNECTION_TOOL_DEFINITIONS, ...definitions];
|
||||
return [...RUNTIME_CONNECTION_TOOL_DEFINITIONS, ...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools), ...definitions];
|
||||
}
|
||||
|
||||
async execute(call: {
|
||||
@@ -192,6 +194,13 @@ export class PaperclipRunnerToolAuthority {
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
}): Promise<unknown> {
|
||||
if (isConnectorTool(call.tool)) {
|
||||
if (!(this.binding.connectorAssignments ?? []).some((assignment) => assignment.tools.some((tool) => tool.name === call.tool))) throw forbidden("Connector tool is not available to this run");
|
||||
const { run } = await this.#boundContext();
|
||||
const snapshot = record(run.contextSnapshot);
|
||||
if (isPaperclipExternalChatContractTurn(snapshot.paperclipWake) || String(snapshot.source ?? "").startsWith("chat:") || snapshot.paperclipExternalChatQuestionResponse) throw forbidden("Restricted chat runs cannot use email actions");
|
||||
return executeConnectorTool(this.db, this.binding, call.tool, call.arguments);
|
||||
}
|
||||
if (RUNTIME_CONNECTION_TOOL_DEFINITIONS.some((tool) => tool.name === call.tool)) {
|
||||
await this.#boundContext();
|
||||
const { run } = await captureRunIdentity(this.db, this.binding);
|
||||
@@ -1081,7 +1090,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
eq(chatEndpoints.assignedAgentId, this.binding.agentId),
|
||||
),
|
||||
);
|
||||
if (!endpoint) {
|
||||
if (!endpoint || endpoint.provider === "agentmail") {
|
||||
throw new Error("paperclip_runner_chat_attachment_binding_denied");
|
||||
}
|
||||
provider = endpoint.provider;
|
||||
|
||||
@@ -87,7 +87,7 @@ async function verifyMaterializedAsset(
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeAsset(files: AssetFile[]): Promise<NativeRuntimeAssetReference> {
|
||||
export async function materializeAsset(files: AssetFile[]): Promise<NativeRuntimeAssetReference> {
|
||||
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const manifestFiles = sorted.map((file) => ({ path: safeRelativePath(file.path, "runtime context path"), sha256: sha256(file.content), mode: file.mode & 0o555, size: file.content.byteLength }));
|
||||
const totalBytes = manifestFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
@@ -1,999 +0,0 @@
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { clampIssueRequestDepth } from "@paperclipai/shared";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
costEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
import { withRecoveryContext } from "./recovery/status-only-context.js";
|
||||
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
|
||||
|
||||
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS = 10;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS = 6;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY = 10;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS = 30;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS = 6 * 60 * 60 * 1000;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS = 3;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW = 1;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS = 3;
|
||||
|
||||
const TERMINAL_RUN_STATUSES = ["succeeded", "interrupted", "failed", "cancelled", "timed_out"] as const;
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const MAX_CANDIDATE_ISSUES = 250;
|
||||
const MAX_RUNS_FOR_STREAK = 100;
|
||||
const MAX_PARENT_WALK_DEPTH = 25;
|
||||
export const PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX = "Productivity review evidence refreshed.";
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
// Evidence only reads these run fields; selecting the full row detoasts
|
||||
// result_json/context_snapshot for up to MAX_RUNS_FOR_STREAK runs per issue.
|
||||
type ProductivityRunSample = Pick<
|
||||
HeartbeatRunRow,
|
||||
"id" | "agentId" | "status" | "livenessState" | "createdAt" | "nextAction" | "usageJson"
|
||||
>;
|
||||
type ProductivityReviewTrigger = "no_comment_streak" | "long_active_duration" | "high_churn";
|
||||
|
||||
type ProductivityReviewThresholds = {
|
||||
noCommentStreakRuns: number;
|
||||
longActiveMs: number;
|
||||
highChurnHourly: number;
|
||||
highChurnSixHours: number;
|
||||
resolvedSnoozeMs: number;
|
||||
refreshIntervalMs: number;
|
||||
maxRefreshComments: number;
|
||||
creationWindowMs: number;
|
||||
maxCreationsPerWindow: number;
|
||||
maxConsecutiveNoActionReviews: number;
|
||||
};
|
||||
|
||||
type ProductivityReviewEvidence = {
|
||||
trigger: ProductivityReviewTrigger;
|
||||
triggerReasons: string[];
|
||||
sourceIssue: IssueRow;
|
||||
sourceAgent: AgentRow;
|
||||
noCommentStreak: number;
|
||||
totalRunCount: number;
|
||||
terminalRunCount: number;
|
||||
activeRunCount: number;
|
||||
runCountLastHour: number;
|
||||
runCountLastSixHours: number;
|
||||
commentCount: number;
|
||||
commentCountLastHour: number;
|
||||
commentCountLastSixHours: number;
|
||||
elapsedMs: number | null;
|
||||
latestRuns: ProductivityRunSample[];
|
||||
latestComments: Array<typeof issueComments.$inferSelect>;
|
||||
costCents: number;
|
||||
usageSamples: Array<{ runId: string; usageJson: Record<string, unknown> | null }>;
|
||||
nextAction: string | null;
|
||||
thresholds: ProductivityReviewThresholds;
|
||||
generatedAt: Date;
|
||||
};
|
||||
|
||||
type EnqueueWakeup = (
|
||||
agentId: string,
|
||||
opts?: {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown | null>;
|
||||
|
||||
function productivityReviewFingerprint(sourceIssueId: string) {
|
||||
return `productivity-review:${sourceIssueId}`;
|
||||
}
|
||||
|
||||
function issueRunScopeSql(issueId: string) {
|
||||
return sql`(
|
||||
${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}
|
||||
or ${heartbeatRuns.contextSnapshot}->>'taskId' = ${issueId}
|
||||
or ${heartbeatRuns.contextSnapshot}->>'taskKey' = ${issueId}
|
||||
)`;
|
||||
}
|
||||
|
||||
function msToHuman(ms: number | null) {
|
||||
if (ms === null) return "unknown";
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
function issueUiLink(issue: { identifier: string | null; id: string }, prefix: string) {
|
||||
const label = issue.identifier ?? issue.id;
|
||||
return `[${label}](/${prefix}/issues/${label})`;
|
||||
}
|
||||
|
||||
function runUiLink(run: { id: string; agentId: string }, prefix: string) {
|
||||
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
|
||||
}
|
||||
|
||||
function truncateInline(value: string | null | undefined, max = 260) {
|
||||
if (!value) return "";
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max - 3)}...`;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
||||
}
|
||||
|
||||
function coerceDate(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function buildThresholds(overrides?: Partial<ProductivityReviewThresholds>): ProductivityReviewThresholds {
|
||||
return {
|
||||
noCommentStreakRuns: readPositiveInteger(
|
||||
overrides?.noCommentStreakRuns ?? DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
),
|
||||
longActiveMs: readPositiveInteger(
|
||||
overrides?.longActiveMs ?? DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
|
||||
),
|
||||
highChurnHourly: readPositiveInteger(
|
||||
overrides?.highChurnHourly ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
|
||||
),
|
||||
highChurnSixHours: readPositiveInteger(
|
||||
overrides?.highChurnSixHours ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
|
||||
),
|
||||
resolvedSnoozeMs: readPositiveInteger(
|
||||
overrides?.resolvedSnoozeMs ?? DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
|
||||
),
|
||||
refreshIntervalMs: readPositiveInteger(
|
||||
overrides?.refreshIntervalMs ?? DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
|
||||
),
|
||||
maxRefreshComments: readPositiveInteger(
|
||||
overrides?.maxRefreshComments ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
|
||||
),
|
||||
creationWindowMs: readPositiveInteger(
|
||||
overrides?.creationWindowMs ?? DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS,
|
||||
),
|
||||
maxCreationsPerWindow: readPositiveInteger(
|
||||
overrides?.maxCreationsPerWindow ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
|
||||
),
|
||||
maxConsecutiveNoActionReviews: readPositiveInteger(
|
||||
overrides?.maxConsecutiveNoActionReviews ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function choosePrimaryTrigger(input: {
|
||||
noComment: boolean;
|
||||
longActive: boolean;
|
||||
highChurn: boolean;
|
||||
}): ProductivityReviewTrigger | null {
|
||||
if (input.noComment) return "no_comment_streak";
|
||||
if (input.highChurn) return "high_churn";
|
||||
if (input.longActive) return "long_active_duration";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSoftStopTrigger(trigger: ProductivityReviewTrigger) {
|
||||
return trigger === "no_comment_streak" || trigger === "high_churn";
|
||||
}
|
||||
|
||||
function formatTrigger(trigger: ProductivityReviewTrigger) {
|
||||
if (trigger === "no_comment_streak") return "No-comment streak";
|
||||
if (trigger === "high_churn") return "High churn";
|
||||
return "Long active duration";
|
||||
}
|
||||
|
||||
export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: EnqueueWakeup }) {
|
||||
const issuesSvc = issueService(db);
|
||||
const budgets = budgetService(db);
|
||||
|
||||
async function getCompanyIssuePrefix(companyId: string) {
|
||||
return db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0]?.issuePrefix ?? "PAP");
|
||||
}
|
||||
|
||||
async function getAgent(agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function isAgentInvokable(agent: AgentRow | null | undefined) {
|
||||
return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
|
||||
}
|
||||
|
||||
async function isProductivityReviewDescendant(issue: Pick<IssueRow, "companyId" | "parentId">) {
|
||||
let parentId = issue.parentId;
|
||||
let depth = 0;
|
||||
while (parentId && depth < MAX_PARENT_WALK_DEPTH) {
|
||||
const parent = await db
|
||||
.select({ id: issues.id, parentId: issues.parentId, originKind: issues.originKind })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parent) return false;
|
||||
if (parent.originKind === PRODUCTIVITY_REVIEW_ORIGIN_KIND) return true;
|
||||
parentId = parent.parentId;
|
||||
depth += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findOpenProductivityReview(companyId: string, sourceIssueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
visibleIssueCondition(),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findRecentTerminalProductivityReview(
|
||||
companyId: string,
|
||||
sourceIssueId: string,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
now: Date,
|
||||
) {
|
||||
const cutoff = new Date(now.getTime() - thresholds.resolvedSnoozeMs);
|
||||
return db
|
||||
.select({ id: issues.id, identifier: issues.identifier, status: issues.status, updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
inArray(issues.status, ["done", "cancelled"]),
|
||||
gt(issues.updatedAt, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function countRecentProductivityReviews(
|
||||
companyId: string,
|
||||
sourceIssueId: string,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
now: Date,
|
||||
) {
|
||||
const cutoff = new Date(now.getTime() - thresholds.creationWindowMs);
|
||||
return db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
visibleIssueCondition(),
|
||||
sql`${issues.status} <> 'cancelled'`,
|
||||
sql`${issues.createdAt} >= ${cutoff.toISOString()}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
}
|
||||
|
||||
async function countConsecutiveNoActionProductivityReviews(
|
||||
companyId: string,
|
||||
sourceIssueId: string,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
) {
|
||||
const completedReviews = await db
|
||||
.select({
|
||||
createdAt: issues.createdAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
eq(issues.status, "done"),
|
||||
visibleIssueCondition(),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id))
|
||||
.limit(thresholds.maxConsecutiveNoActionReviews);
|
||||
|
||||
const earliestReviewCreatedAt = completedReviews.at(-1)?.createdAt;
|
||||
if (!earliestReviewCreatedAt) return 0;
|
||||
const sourceActions = await db
|
||||
.select({ createdAt: activityLog.createdAt })
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, sourceIssueId),
|
||||
gte(activityLog.createdAt, earliestReviewCreatedAt),
|
||||
),
|
||||
);
|
||||
|
||||
let streak = 0;
|
||||
for (const [index, review] of completedReviews.entries()) {
|
||||
const nextNewerReviewCreatedAt = completedReviews[index - 1]?.createdAt ?? null;
|
||||
const sourceAction = sourceActions.some((activity) => {
|
||||
if (activity.createdAt < review.createdAt) return false;
|
||||
return !nextNewerReviewCreatedAt || activity.createdAt < nextNewerReviewCreatedAt;
|
||||
});
|
||||
if (sourceAction) break;
|
||||
streak += 1;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
async function getRefreshCommentState(companyId: string, reviewIssueId: string) {
|
||||
return db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestCreatedAt: sql<Date | null>`max(${issueComments.createdAt})`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, reviewIssueId),
|
||||
sql`${issueComments.body} like ${`${PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX}%`}`,
|
||||
),
|
||||
)
|
||||
.then((rows) => {
|
||||
const row = rows[0];
|
||||
return {
|
||||
count: Number(row?.count ?? 0),
|
||||
latestCreatedAt: coerceDate(row?.latestCreatedAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function addRefreshComment(
|
||||
reviewIssueId: string,
|
||||
body: string,
|
||||
generatedAt: Date,
|
||||
) {
|
||||
const comment = await issuesSvc.addComment(reviewIssueId, body, {});
|
||||
await db
|
||||
.update(issueComments)
|
||||
.set({ createdAt: generatedAt, updatedAt: generatedAt })
|
||||
.where(eq(issueComments.id, comment.id));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ updatedAt: generatedAt })
|
||||
.where(eq(issues.id, reviewIssueId));
|
||||
return comment;
|
||||
}
|
||||
|
||||
async function countIssueRunsSince(companyId: string, agentId: string, issueId: string, since: Date) {
|
||||
return db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
issueRunScopeSql(issueId),
|
||||
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${since.toISOString()}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function countIssueCommentsSince(companyId: string, issueId: string, agentId: string, since?: Date) {
|
||||
return db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueComments)
|
||||
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.authorAgentId, agentId),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
issueRunScopeSql(issueId),
|
||||
since ? sql`${issueComments.createdAt} >= ${since.toISOString()}::timestamptz` : undefined,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function collectEvidence(
|
||||
sourceIssue: IssueRow,
|
||||
sourceAgent: AgentRow,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
now: Date,
|
||||
): Promise<ProductivityReviewEvidence | null> {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
|
||||
const latestRuns = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
usageJson: heartbeatRuns.usageJson,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, sourceIssue.companyId),
|
||||
eq(heartbeatRuns.agentId, sourceAgent.id),
|
||||
issueRunScopeSql(sourceIssue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
|
||||
.limit(MAX_RUNS_FOR_STREAK);
|
||||
|
||||
const runIds = latestRuns.map((run) => run.id);
|
||||
const commentRunIds = new Set<string>();
|
||||
if (runIds.length > 0) {
|
||||
const commentRows = await db
|
||||
.select({ createdByRunId: issueComments.createdByRunId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, sourceIssue.companyId),
|
||||
eq(issueComments.issueId, sourceIssue.id),
|
||||
inArray(issueComments.createdByRunId, runIds),
|
||||
),
|
||||
);
|
||||
for (const row of commentRows) {
|
||||
if (row.createdByRunId) commentRunIds.add(row.createdByRunId);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalRuns = latestRuns.filter((run) =>
|
||||
TERMINAL_RUN_STATUSES.includes(run.status as (typeof TERMINAL_RUN_STATUSES)[number]),
|
||||
);
|
||||
let noCommentStreak = 0;
|
||||
for (const run of terminalRuns) {
|
||||
if (commentRunIds.has(run.id)) break;
|
||||
noCommentStreak += 1;
|
||||
}
|
||||
|
||||
const [
|
||||
runCountLastHour,
|
||||
runCountLastSixHours,
|
||||
assigneeRunCommentCount,
|
||||
assigneeRunCommentCountLastHour,
|
||||
assigneeRunCommentCountLastSixHours,
|
||||
latestComments,
|
||||
costRow,
|
||||
] = await Promise.all([
|
||||
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, oneHourAgo),
|
||||
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, sixHoursAgo),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, oneHourAgo),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, sixHoursAgo),
|
||||
db
|
||||
.select({ comment: issueComments })
|
||||
.from(issueComments)
|
||||
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, sourceIssue.companyId),
|
||||
eq(issueComments.issueId, sourceIssue.id),
|
||||
eq(issueComments.authorAgentId, sourceAgent.id),
|
||||
eq(heartbeatRuns.companyId, sourceIssue.companyId),
|
||||
eq(heartbeatRuns.agentId, sourceAgent.id),
|
||||
issueRunScopeSql(sourceIssue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(5)
|
||||
.then((rows) => rows.map((row) => row.comment)),
|
||||
db
|
||||
.select({ costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int` })
|
||||
.from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, sourceIssue.companyId), eq(costEvents.issueId, sourceIssue.id)))
|
||||
.then((rows) => rows[0] ?? { costCents: 0 }),
|
||||
]);
|
||||
|
||||
const activeRunCount = latestRuns.filter((run) =>
|
||||
ACTIVE_RUN_STATUSES.includes(run.status as (typeof ACTIVE_RUN_STATUSES)[number]),
|
||||
).length;
|
||||
const activeStartedAt = sourceIssue.startedAt ?? sourceIssue.executionLockedAt ?? null;
|
||||
const elapsedMs = sourceIssue.status === "in_progress" && activeStartedAt
|
||||
? Math.max(0, now.getTime() - activeStartedAt.getTime())
|
||||
: null;
|
||||
|
||||
const noComment = noCommentStreak >= thresholds.noCommentStreakRuns;
|
||||
const longActive = elapsedMs !== null && elapsedMs >= thresholds.longActiveMs;
|
||||
const highChurn =
|
||||
runCountLastHour >= thresholds.highChurnHourly ||
|
||||
assigneeRunCommentCountLastHour >= thresholds.highChurnHourly ||
|
||||
runCountLastSixHours >= thresholds.highChurnSixHours ||
|
||||
assigneeRunCommentCountLastSixHours >= thresholds.highChurnSixHours;
|
||||
const trigger = choosePrimaryTrigger({ noComment, longActive, highChurn });
|
||||
if (!trigger) return null;
|
||||
|
||||
const triggerReasons: string[] = [];
|
||||
if (noComment) triggerReasons.push(`${noCommentStreak} consecutive completed issue-linked runs had no run-created issue comment`);
|
||||
if (longActive) triggerReasons.push(`current active episode has lasted ${msToHuman(elapsedMs)}`);
|
||||
if (highChurn) {
|
||||
triggerReasons.push(
|
||||
`${runCountLastHour} runs/${assigneeRunCommentCountLastHour} assignee-run comments in 1h; ${runCountLastSixHours} runs/${assigneeRunCommentCountLastSixHours} assignee-run comments in 6h`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
triggerReasons,
|
||||
sourceIssue,
|
||||
sourceAgent,
|
||||
noCommentStreak,
|
||||
totalRunCount: latestRuns.length,
|
||||
terminalRunCount: terminalRuns.length,
|
||||
activeRunCount,
|
||||
runCountLastHour,
|
||||
runCountLastSixHours,
|
||||
commentCount: assigneeRunCommentCount,
|
||||
commentCountLastHour: assigneeRunCommentCountLastHour,
|
||||
commentCountLastSixHours: assigneeRunCommentCountLastSixHours,
|
||||
elapsedMs,
|
||||
latestRuns: latestRuns.slice(0, 5),
|
||||
latestComments,
|
||||
costCents: costRow.costCents,
|
||||
usageSamples: latestRuns
|
||||
.filter((run) => run.usageJson)
|
||||
.slice(0, 3)
|
||||
.map((run) => ({ runId: run.id, usageJson: run.usageJson ?? null })),
|
||||
nextAction: latestRuns.find((run) => run.nextAction)?.nextAction ?? null,
|
||||
thresholds,
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReviewOwnerAgentId(sourceIssue: IssueRow, sourceAgent: AgentRow) {
|
||||
const candidateIds: string[] = [];
|
||||
if (sourceAgent.reportsTo) candidateIds.push(sourceAgent.reportsTo);
|
||||
if (sourceIssue.createdByAgentId) candidateIds.push(sourceIssue.createdByAgentId);
|
||||
if (sourceIssue.projectId) {
|
||||
const project = await db
|
||||
.select({ leadAgentId: projects.leadAgentId })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, sourceIssue.companyId), eq(projects.id, sourceIssue.projectId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project?.leadAgentId) candidateIds.push(project.leadAgentId);
|
||||
}
|
||||
const roleCandidates = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, sourceIssue.companyId), inArray(agents.role, ["cto", "ceo"])))
|
||||
.orderBy(sql`case when ${agents.role} = 'cto' then 0 else 1 end`, asc(agents.createdAt), asc(agents.id));
|
||||
candidateIds.push(...roleCandidates.map((agent) => agent.id));
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const agentId of candidateIds) {
|
||||
if (seen.has(agentId)) continue;
|
||||
seen.add(agentId);
|
||||
const candidate = await getAgent(agentId);
|
||||
if (!candidate || candidate.companyId !== sourceIssue.companyId || !isAgentInvokable(candidate)) continue;
|
||||
const budgetBlock = await budgets.getInvocationBlock(sourceIssue.companyId, candidate.id, {
|
||||
issueId: sourceIssue.id,
|
||||
projectId: sourceIssue.projectId ?? null,
|
||||
});
|
||||
if (!budgetBlock) return candidate.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildReviewMarkdown(evidence: ProductivityReviewEvidence, prefix: string) {
|
||||
const latestRuns = evidence.latestRuns.length > 0
|
||||
? evidence.latestRuns.map((run) =>
|
||||
`- ${runUiLink(run, prefix)} \`${run.status}\` liveness \`${run.livenessState ?? "unknown"}\`, created ${run.createdAt.toISOString()}${run.nextAction ? `, next action: ${truncateInline(run.nextAction, 160)}` : ""}`,
|
||||
).join("\n")
|
||||
: "- none";
|
||||
const latestComments = evidence.latestComments.length > 0
|
||||
? evidence.latestComments.map((comment) =>
|
||||
`- ${comment.createdAt.toISOString()}${comment.createdByRunId ? ` run \`${comment.createdByRunId}\`` : ""}: ${truncateInline(comment.body)}`,
|
||||
).join("\n")
|
||||
: "- none";
|
||||
const usage = evidence.usageSamples.length > 0
|
||||
? evidence.usageSamples.map((sample) => `- \`${sample.runId}\`: \`${JSON.stringify(sample.usageJson).slice(0, 500)}\``).join("\n")
|
||||
: "- no usage payloads on sampled runs";
|
||||
return [
|
||||
"Paperclip detected an unusual productivity/progression pattern on an assigned issue.",
|
||||
"",
|
||||
"## Source",
|
||||
"",
|
||||
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
|
||||
`- Assigned agent: ${evidence.sourceAgent.name} (${evidence.sourceAgent.role})`,
|
||||
`- Primary trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
|
||||
`- Trigger reasons: ${evidence.triggerReasons.join("; ")}`,
|
||||
`- Generated at: ${evidence.generatedAt.toISOString()}`,
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
`- Total sampled issue-linked runs: ${evidence.totalRunCount}`,
|
||||
`- Terminal sampled runs: ${evidence.terminalRunCount}`,
|
||||
`- Active queued/running/scheduled runs: ${evidence.activeRunCount}`,
|
||||
`- No-comment completed-run streak: ${evidence.noCommentStreak}`,
|
||||
`- Current active elapsed time: ${msToHuman(evidence.elapsedMs)}`,
|
||||
`- Runs in rolling windows: ${evidence.runCountLastHour}/1h, ${evidence.runCountLastSixHours}/6h`,
|
||||
`- Assignee run-linked comments total/window: ${evidence.commentCount} total, ${evidence.commentCountLastHour}/1h, ${evidence.commentCountLastSixHours}/6h`,
|
||||
`- Cost events total: ${evidence.costCents} cents`,
|
||||
`- Current next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 500) : "none recorded"}`,
|
||||
"",
|
||||
"## Thresholds",
|
||||
"",
|
||||
`- No-comment streak: ${evidence.thresholds.noCommentStreakRuns} completed runs`,
|
||||
`- Long active duration: ${msToHuman(evidence.thresholds.longActiveMs)}`,
|
||||
`- High churn: ${evidence.thresholds.highChurnHourly}/1h or ${evidence.thresholds.highChurnSixHours}/6h runs/assignee-run comments`,
|
||||
`- Resolved-review snooze: ${msToHuman(evidence.thresholds.resolvedSnoozeMs)}`,
|
||||
"",
|
||||
"## Latest Runs",
|
||||
"",
|
||||
latestRuns,
|
||||
"",
|
||||
"## Latest Assignee Run Comments",
|
||||
"",
|
||||
latestComments,
|
||||
"",
|
||||
"## Usage Samples",
|
||||
"",
|
||||
usage,
|
||||
"",
|
||||
"## Manager Decision",
|
||||
"",
|
||||
"- Close as productive if this pattern is expected.",
|
||||
"- Continue with a snooze window if the current work should keep running without repeat review spam.",
|
||||
"- Request decomposition, reroute, block with an unblock owner, or stop/cancel the source work if the work is inefficient.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRefreshComment(evidence: ProductivityReviewEvidence, prefix: string) {
|
||||
return [
|
||||
"Productivity review evidence refreshed.",
|
||||
"",
|
||||
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
|
||||
`- Trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
|
||||
`- Reasons: ${evidence.triggerReasons.join("; ")}`,
|
||||
`- No-comment streak: ${evidence.noCommentStreak}`,
|
||||
`- Runs/assignee comments: ${evidence.runCountLastHour}/${evidence.commentCountLastHour} in 1h, ${evidence.runCountLastSixHours}/${evidence.commentCountLastSixHours} in 6h`,
|
||||
`- Next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 300) : "none recorded"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function createOrUpdateReview(
|
||||
evidence: ProductivityReviewEvidence,
|
||||
opts: { prefix: string; thresholds: ProductivityReviewThresholds },
|
||||
) {
|
||||
const existing = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
|
||||
if (existing) {
|
||||
const refreshState = await getRefreshCommentState(evidence.sourceIssue.companyId, existing.id);
|
||||
const lastRefreshOrCreationAt = refreshState.latestCreatedAt ?? existing.createdAt;
|
||||
if (
|
||||
refreshState.count >= opts.thresholds.maxRefreshComments ||
|
||||
evidence.generatedAt.getTime() - lastRefreshOrCreationAt.getTime() < opts.thresholds.refreshIntervalMs
|
||||
) {
|
||||
return { kind: "existing" as const, reviewIssueId: existing.id };
|
||||
}
|
||||
await addRefreshComment(existing.id, buildRefreshComment(evidence, opts.prefix), evidence.generatedAt);
|
||||
await logActivity(db, {
|
||||
companyId: evidence.sourceIssue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.productivity_review_updated",
|
||||
entityType: "issue",
|
||||
entityId: existing.id,
|
||||
agentId: existing.assigneeAgentId,
|
||||
details: {
|
||||
source: "productivity_review.reconcile",
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
noCommentStreak: evidence.noCommentStreak,
|
||||
runCountLastHour: evidence.runCountLastHour,
|
||||
commentCountLastHour: evidence.commentCountLastHour,
|
||||
},
|
||||
});
|
||||
return { kind: "updated" as const, reviewIssueId: existing.id };
|
||||
}
|
||||
|
||||
const recentCreationCount = await countRecentProductivityReviews(
|
||||
evidence.sourceIssue.companyId,
|
||||
evidence.sourceIssue.id,
|
||||
opts.thresholds,
|
||||
evidence.generatedAt,
|
||||
);
|
||||
if (recentCreationCount >= opts.thresholds.maxCreationsPerWindow) {
|
||||
return { kind: "creation_capped" as const, reviewIssueId: null };
|
||||
}
|
||||
|
||||
const consecutiveNoActionReviews = await countConsecutiveNoActionProductivityReviews(
|
||||
evidence.sourceIssue.companyId,
|
||||
evidence.sourceIssue.id,
|
||||
opts.thresholds,
|
||||
);
|
||||
if (consecutiveNoActionReviews >= opts.thresholds.maxConsecutiveNoActionReviews) {
|
||||
return { kind: "no_action_suppressed" as const, reviewIssueId: null };
|
||||
}
|
||||
|
||||
const ownerAgentId = await resolveReviewOwnerAgentId(evidence.sourceIssue, evidence.sourceAgent);
|
||||
let review: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||
try {
|
||||
review = await issuesSvc.create(evidence.sourceIssue.companyId, {
|
||||
title: `Review productivity for ${evidence.sourceIssue.identifier ?? evidence.sourceIssue.title}`,
|
||||
description: buildReviewMarkdown(evidence, opts.prefix),
|
||||
status: "todo",
|
||||
priority: evidence.trigger === "long_active_duration" ? "medium" : "high",
|
||||
parentId: evidence.sourceIssue.id,
|
||||
projectId: evidence.sourceIssue.projectId,
|
||||
goalId: evidence.sourceIssue.goalId,
|
||||
billingCode: evidence.sourceIssue.billingCode,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: evidence.sourceIssue.id,
|
||||
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
|
||||
requestDepth: clampIssueRequestDepth(evidence.sourceIssue.requestDepth + 1),
|
||||
});
|
||||
} catch (error) {
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
const uniqueConflict = maybe.code === "23505" &&
|
||||
(
|
||||
maybe.constraint === "issues_active_productivity_review_uq" ||
|
||||
typeof maybe.message === "string" && maybe.message.includes("issues_active_productivity_review_uq")
|
||||
);
|
||||
if (!uniqueConflict) throw error;
|
||||
const raced = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
|
||||
if (!raced) throw error;
|
||||
return { kind: "existing" as const, reviewIssueId: raced.id };
|
||||
}
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ createdAt: evidence.generatedAt, updatedAt: evidence.generatedAt })
|
||||
.where(eq(issues.id, review.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: evidence.sourceIssue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.productivity_review_created",
|
||||
entityType: "issue",
|
||||
entityId: review.id,
|
||||
agentId: ownerAgentId,
|
||||
details: {
|
||||
source: "productivity_review.reconcile",
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
noCommentStreak: evidence.noCommentStreak,
|
||||
runCountLastHour: evidence.runCountLastHour,
|
||||
commentCountLastHour: evidence.commentCountLastHour,
|
||||
},
|
||||
});
|
||||
|
||||
if (ownerAgentId && deps?.enqueueWakeup) {
|
||||
await deps.enqueueWakeup(ownerAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: withRecoveryContext({
|
||||
issueId: review.id,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
}, "status_only"),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "productivity_review",
|
||||
contextSnapshot: withRecoveryContext({
|
||||
issueId: review.id,
|
||||
taskId: review.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
productivityReviewTrigger: evidence.trigger,
|
||||
}, "status_only"),
|
||||
});
|
||||
}
|
||||
|
||||
return { kind: "created" as const, reviewIssueId: review.id };
|
||||
}
|
||||
|
||||
async function reconcileProductivityReviews(opts?: {
|
||||
now?: Date;
|
||||
companyId?: string;
|
||||
thresholds?: Partial<ProductivityReviewThresholds>;
|
||||
issueCreatedAtGte?: Date | null;
|
||||
}) {
|
||||
const now = opts?.now ?? new Date();
|
||||
const thresholds = buildThresholds(opts?.thresholds);
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined,
|
||||
visibleIssueCondition(),
|
||||
isNull(issues.assigneeUserId),
|
||||
inArray(issues.status, ["todo", "in_progress"]),
|
||||
sql`${issues.assigneeAgentId} is not null`,
|
||||
sql`${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`,
|
||||
opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(issues.updatedAt), asc(issues.id))
|
||||
.limit(MAX_CANDIDATE_ISSUES);
|
||||
|
||||
const result = {
|
||||
scanned: candidates.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
existing: 0,
|
||||
snoozed: 0,
|
||||
creationCapped: 0,
|
||||
noActionSuppressed: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
reviewIssueIds: [] as string[],
|
||||
failedIssueIds: [] as string[],
|
||||
};
|
||||
|
||||
const prefixCache = new Map<string, string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.assigneeAgentId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (await isProductivityReviewDescendant(candidate)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (await findRecentTerminalProductivityReview(candidate.companyId, candidate.id, thresholds, now)) {
|
||||
result.snoozed += 1;
|
||||
continue;
|
||||
}
|
||||
const sourceAgent = await getAgent(candidate.assigneeAgentId);
|
||||
if (!sourceAgent || sourceAgent.companyId !== candidate.companyId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// A paused assignee cannot act on a review, so raising one only creates noise.
|
||||
if (sourceAgent.status === "paused") {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const evidence = await collectEvidence(candidate, sourceAgent, thresholds, now);
|
||||
if (!evidence) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let prefix = prefixCache.get(candidate.companyId);
|
||||
if (!prefix) {
|
||||
prefix = await getCompanyIssuePrefix(candidate.companyId);
|
||||
prefixCache.set(candidate.companyId, prefix);
|
||||
}
|
||||
try {
|
||||
const outcome = await createOrUpdateReview(evidence, { prefix, thresholds });
|
||||
if (outcome.kind === "created") result.created += 1;
|
||||
else if (outcome.kind === "updated") result.updated += 1;
|
||||
else if (outcome.kind === "creation_capped") result.creationCapped += 1;
|
||||
else if (outcome.kind === "no_action_suppressed") result.noActionSuppressed += 1;
|
||||
else result.existing += 1;
|
||||
if (outcome.reviewIssueId) result.reviewIssueIds.push(outcome.reviewIssueId);
|
||||
} catch (err) {
|
||||
result.failed += 1;
|
||||
result.failedIssueIds.push(candidate.id);
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
companyId: candidate.companyId,
|
||||
issueId: candidate.id,
|
||||
requestDepth: candidate.requestDepth,
|
||||
},
|
||||
"productivity review reconciliation skipped malformed candidate",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function isProductivityReviewContinuationHoldActive(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
now?: Date;
|
||||
thresholds?: Partial<ProductivityReviewThresholds>;
|
||||
}) {
|
||||
const now = input.now ?? new Date();
|
||||
const thresholds = buildThresholds(input.thresholds);
|
||||
const [sourceIssue, sourceAgent, openReview] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
getAgent(input.agentId),
|
||||
findOpenProductivityReview(input.companyId, input.issueId),
|
||||
]);
|
||||
if (!sourceIssue || !sourceAgent || !openReview) return { held: false as const };
|
||||
if (sourceAgent.companyId !== input.companyId) return { held: false as const };
|
||||
const evidence = await collectEvidence(sourceIssue, sourceAgent, thresholds, now);
|
||||
if (!evidence || !isSoftStopTrigger(evidence.trigger)) return { held: false as const };
|
||||
return {
|
||||
held: true as const,
|
||||
reviewIssueId: openReview.id,
|
||||
reviewIdentifier: openReview.identifier,
|
||||
trigger: evidence.trigger,
|
||||
reason: evidence.triggerReasons.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
async function recordContinuationHold(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
reviewIssueId: string;
|
||||
trigger: ProductivityReviewTrigger;
|
||||
reason: string;
|
||||
}) {
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.productivity_review_continuation_held",
|
||||
entityType: "issue",
|
||||
entityId: input.issueId,
|
||||
details: {
|
||||
source: "productivity_review.continuation_hold",
|
||||
reviewIssueId: input.reviewIssueId,
|
||||
trigger: input.trigger,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reconcileProductivityReviews,
|
||||
isProductivityReviewContinuationHoldActive,
|
||||
recordContinuationHold,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const RECOVERY_ORIGIN_KINDS = {
|
||||
issueGraphLivenessEscalation: "harness_liveness_escalation",
|
||||
// Historical tasks retain their origin and recovery-recursion exclusion.
|
||||
issueProductivityReview: "issue_productivity_review",
|
||||
strandedIssueRecovery: "stranded_issue_recovery",
|
||||
staleActiveRunEvaluation: "stale_active_run_evaluation",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { environmentLeases, type Db } from "@paperclipai/db";
|
||||
|
||||
type LeaseIdentity = {
|
||||
id: string; companyId: string; heartbeatRunId: string | null;
|
||||
provider: string | null; providerLeaseId: string | null;
|
||||
};
|
||||
|
||||
/** Bind a provider receipt to the exact host-owned lease and run. Old plugins
|
||||
* return void; that remains supported but grants no continuation authority. */
|
||||
export function remoteTerminationReceipt(lease: LeaseIdentity, value: unknown) {
|
||||
const receipt = value as { providerLeaseId?: unknown; state?: unknown } | null;
|
||||
if (!lease.heartbeatRunId || !lease.provider || lease.provider === "local" ||
|
||||
!lease.providerLeaseId || receipt?.providerLeaseId !== lease.providerLeaseId ||
|
||||
!["stopped", "destroyed"].includes(String(receipt?.state))) return undefined;
|
||||
return {
|
||||
schema: "paperclip.remote-termination.v1", companyId: lease.companyId,
|
||||
runId: lease.heartbeatRunId, leaseId: lease.id, provider: lease.provider,
|
||||
providerLeaseId: lease.providerLeaseId, state: receipt!.state,
|
||||
confirmedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRemoteTerminationReceipt(lease: LeaseIdentity & {
|
||||
releasedAt: unknown; cleanupStatus: string | null; status: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
const receipt = lease.metadata?.remoteExecutionTermination as Record<string, unknown> | undefined;
|
||||
return Boolean(lease.releasedAt && lease.cleanupStatus === "success" &&
|
||||
["released", "expired", "failed"].includes(lease.status) && receipt &&
|
||||
receipt.schema === "paperclip.remote-termination.v1" &&
|
||||
receipt.companyId === lease.companyId && receipt.runId === lease.heartbeatRunId &&
|
||||
receipt.leaseId === lease.id && receipt.provider === lease.provider &&
|
||||
remoteTerminationReceipt(lease, receipt));
|
||||
}
|
||||
|
||||
export function remoteLeaseCleanupScope(lease: Pick<LeaseIdentity, "provider" | "providerLeaseId">) {
|
||||
return lease.provider && lease.provider !== "local" && lease.providerLeaseId
|
||||
? JSON.stringify([lease.provider, lease.providerLeaseId]) : undefined;
|
||||
}
|
||||
|
||||
export async function stoppedRemoteCleanupScopes(db: Db, companyId: string, runId: string) {
|
||||
const leases = await db.select().from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, runId),
|
||||
));
|
||||
if (leases.length === 0 || !leases.every(hasRemoteTerminationReceipt)) return null;
|
||||
return [...new Set(leases.map(lease => remoteLeaseCleanupScope(lease)!))];
|
||||
}
|
||||
|
||||
export async function remoteExecutionHasStopped(db: Db, companyId: string, runId: string) {
|
||||
return await stoppedRemoteCleanupScopes(db, companyId, runId) !== null;
|
||||
}
|
||||
@@ -205,6 +205,7 @@ import {
|
||||
splitRemoteUrlCredential,
|
||||
} from "./remote-url-credentials.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { agentmailApi } from "./agentmail-api.js";
|
||||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||||
import {
|
||||
readSignedToolArgumentsPayload,
|
||||
@@ -7363,11 +7364,48 @@ export function toolAccessService(
|
||||
};
|
||||
}
|
||||
|
||||
function isAgentMailConnection(connection: typeof toolConnections.$inferSelect) {
|
||||
return connection.transport === "rest_api" && connection.config.provider === "agentmail";
|
||||
}
|
||||
|
||||
async function validateAgentMailConnection(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
) {
|
||||
const configPath = connection.config.emailCredential
|
||||
? "credentials.controlKey"
|
||||
: "credentials.apiKey";
|
||||
const ref = connection.credentialSecretRefs.find(
|
||||
(candidate) => candidate.configPath === configPath,
|
||||
);
|
||||
if (!ref) {
|
||||
throw unprocessable("Reconnect AgentMail to restore its API key", {
|
||||
code: "missing_secret",
|
||||
});
|
||||
}
|
||||
const key = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
ref.secretId,
|
||||
ref.versionSelector ?? "latest",
|
||||
{
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
},
|
||||
);
|
||||
await agentmailApi(key).whoami();
|
||||
}
|
||||
|
||||
async function discoverTools(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
credentialHeaders?: Record<string, string>,
|
||||
actor?: ActorInfo,
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
if (isAgentMailConnection(connection)) {
|
||||
await validateAgentMailConnection(connection);
|
||||
return [];
|
||||
}
|
||||
if (connection.transport === "mcp_remote")
|
||||
return remoteTools(connection, credentialHeaders, actor);
|
||||
if (isComposioConnection(connection)) {
|
||||
@@ -7495,6 +7533,8 @@ export function toolAccessService(
|
||||
});
|
||||
for (const grant of grantsToCheck)
|
||||
await refreshManagedGitHubGrantAccess(connection, grant, actor);
|
||||
} else if (isAgentMailConnection(connection)) {
|
||||
await validateAgentMailConnection(connection);
|
||||
} else if (connection.transport === "mcp_remote") {
|
||||
await assertComposioConnectedAccountActive(connection);
|
||||
const credentialHeaders =
|
||||
@@ -7516,11 +7556,13 @@ export function toolAccessService(
|
||||
config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isComposioConnection(connection)
|
||||
? "Composio accepted the API key and returned its toolkits."
|
||||
: connection.transport === "local_stdio"
|
||||
? "Approved stdio template is ready."
|
||||
: "Remote MCP server responded to tools/list.",
|
||||
: isAgentMailConnection(connection)
|
||||
? "AgentMail API key is connected."
|
||||
: isComposioConnection(connection)
|
||||
? "Composio accepted the API key and returned its toolkits."
|
||||
: connection.transport === "local_stdio"
|
||||
? "Approved stdio template is ready."
|
||||
: "Remote MCP server responded to tools/list.",
|
||||
);
|
||||
const runtimeSlot = await ensureRuntimeSlot(updated);
|
||||
await audit({
|
||||
@@ -7759,7 +7801,9 @@ export function toolAccessService(
|
||||
config: normalizedConfig,
|
||||
transportConfig: normalizedTransportConfig,
|
||||
healthStatus: "ok",
|
||||
healthMessage: "Tool catalog refreshed.",
|
||||
healthMessage: isAgentMailConnection(connection)
|
||||
? "AgentMail API key is connected."
|
||||
: "Tool catalog refreshed.",
|
||||
healthCheckedAt: refreshedAt,
|
||||
lastHealthAt: refreshedAt,
|
||||
lastCatalogRefreshAt: refreshedAt,
|
||||
|
||||
@@ -356,7 +356,9 @@ function startInput(options?: {
|
||||
command: options?.command ?? serviceCommand(),
|
||||
env: { PAPERCLIP_PUBLIC_URL: "http://127.0.0.1:3100" },
|
||||
port: options?.port ?? { type: "auto", envKey: "PORT" },
|
||||
readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 5 },
|
||||
// These lifecycle tests spawn real servers; cold CI startup can take
|
||||
// five seconds before the first listener is ready.
|
||||
readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10 },
|
||||
...(expose ? { expose } : {}),
|
||||
}],
|
||||
},
|
||||
|
||||
+2
@@ -122,3 +122,5 @@ export const validatePrpStructuredRunResult =
|
||||
runner.validatePrpStructuredRunResult;
|
||||
|
||||
export const NativeProviderTerminalFailure = runner.NativeProviderTerminalFailure;
|
||||
|
||||
export const completeTerminatedRemoteNativeSessionCleanup = runner.completeTerminatedRemoteNativeSessionCleanup;
|
||||
Reference in new issue
Block a user