mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge upstream and regenerate Boat migration as 0333
This commit is contained in:
commit
d7d7808302
32 files changed
+4114
-845
No files matched your search
@@ -5,7 +5,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
|
||||
import {
|
||||
agentInstructionWorkingCopies, agents, agentApiKeys, agentWakeupRequests, companies, companySecretProposals, createDb,
|
||||
adapterAuthSessions, environments, environmentLeases, executionWorkspaces,
|
||||
heartbeatRuns, issues, issueWatchdogs, projects, routines, type Db,
|
||||
heartbeatRuns, issues, issueWatchdogs, issueThreadInteractions, issueQuestionResponseDeliveries,
|
||||
projects, routines, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { readIdleSleepSafety, type IdleSleepDrainStatus } from "../services/idle-sleep-safety.js";
|
||||
@@ -184,6 +185,152 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
|
||||
return { companyId, agentId, runId };
|
||||
}
|
||||
|
||||
async function seedQuestionDelivery(change: Partial<typeof issueQuestionResponseDeliveries.$inferInsert> = {}) {
|
||||
const seeded = await seed();
|
||||
await db.update(heartbeatRuns).set({ finishedAt: new Date(now) }).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
const issueId = randomUUID();
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId: seeded.companyId, title: "Finished question", status: "done" });
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId, companyId: seeded.companyId, issueId,
|
||||
kind: "ask_user_questions", status: "answered", payload: {},
|
||||
});
|
||||
await db.insert(issueQuestionResponseDeliveries).values({
|
||||
companyId: seeded.companyId, issueId, interactionId, sourceRunId: seeded.runId,
|
||||
targetRunId: seeded.runId, correlationId: randomUUID(), payloadSha256: "fixture",
|
||||
status: "fallback_queued", deliveryMode: "wake_fallback", acknowledgedAt: new Date(now), ...change,
|
||||
});
|
||||
return { ...seeded, issueId, interactionId };
|
||||
}
|
||||
|
||||
async function seedQuestionWake(
|
||||
delivery: Awaited<ReturnType<typeof seedQuestionDelivery>>,
|
||||
change: Partial<typeof agentWakeupRequests.$inferInsert> = {},
|
||||
) {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: delivery.companyId, agentId: delivery.agentId, source: "automation",
|
||||
idempotencyKey: `question-response:${delivery.interactionId}`,
|
||||
status: "completed", finishedAt: new Date(now), ...change,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["pending", "delivering"])("keeps a %s question delivery awake", async status => {
|
||||
await seedQuestionDelivery({ status, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each(["delivered", "fallback_queued"])("permits acknowledged %s history after its target run finishes", async status => {
|
||||
await seedQuestionDelivery({ status });
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it.each(["delivered", "fallback_queued"])("keeps an unacknowledged %s question delivery awake", async status => {
|
||||
await seedQuestionDelivery({ status, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", { status: "queued", finishedAt: null }],
|
||||
["running", { status: "running", finishedAt: null }],
|
||||
["unfinished", { finishedAt: null }],
|
||||
["retry", { scheduledRetryAt: new Date(now + 1000) }],
|
||||
["accounting", { costAccountingPending: true }],
|
||||
] as const)("keeps an acknowledged question delivery awake with %s target evidence", async (_name, change) => {
|
||||
const { runId } = await seedQuestionDelivery();
|
||||
await db.update(heartbeatRuns).set(change).where(eq(heartbeatRuns.id, runId));
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("does not accept a foreign company's finished question target", async () => {
|
||||
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
|
||||
await seedQuestionDelivery({ targetRunId: foreign.runId });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps delivered history with a missing target awake", async () => {
|
||||
await seedQuestionDelivery({ status: "delivered", targetRunId: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps fallback history without a durable wake receipt awake", async () => {
|
||||
await seedQuestionDelivery({ targetRunId: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each(["completed", "failed", "cancelled", "skipped", "timed_out"])(
|
||||
"permits fallback history after its exact durable wake is %s and finished", async status => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status });
|
||||
expect(await read()).toEqual(none);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["queued", "deferred_issue_execution", "coalesced"])("keeps an unresolved %s question wake awake", async status => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status, finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps a terminal question wake without completion evidence awake", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("permits a deferred question fallback after its coalesced continuation finishes", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status: "deferred_issue_execution", finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
await db.update(agentWakeupRequests).set({
|
||||
status: "coalesced", runId: delivery.runId, finishedAt: new Date(now),
|
||||
}).where(eq(agentWakeupRequests.companyId, delivery.companyId));
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it.each(["missing_run", "wrong_company", "wrong_agent", "unfinished", "retry", "accounting"])(
|
||||
"keeps a coalesced question fallback awake with %s linked-run evidence", async kind => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
let runId = delivery.runId;
|
||||
if (kind === "missing_run") runId = randomUUID();
|
||||
if (kind === "wrong_company") runId = (await seedQuestionDelivery({ status: "failed", targetRunId: null })).runId;
|
||||
if (kind === "wrong_agent") {
|
||||
const otherAgent = randomUUID();
|
||||
await db.insert(agents).values({ id: otherAgent, companyId: delivery.companyId, name: "Other", role: "engineer", status: "idle", adapterType: "process" });
|
||||
await db.update(heartbeatRuns).set({ agentId: otherAgent }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
if (kind === "unfinished") await db.update(heartbeatRuns).set({ finishedAt: null }).where(eq(heartbeatRuns.id, runId));
|
||||
if (kind === "retry") await db.update(heartbeatRuns).set({ scheduledRetryAt: new Date(now + 1000) }).where(eq(heartbeatRuns.id, runId));
|
||||
if (kind === "accounting") await db.update(heartbeatRuns).set({ costAccountingPending: true }).where(eq(heartbeatRuns.id, runId));
|
||||
await seedQuestionWake(delivery, { status: "coalesced", runId });
|
||||
expect(await read()).toEqual(present);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not accept another interaction's or company's question wake receipt", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { idempotencyKey: `question-response:${randomUUID()}` });
|
||||
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
|
||||
await seedQuestionWake(delivery, { companyId: foreign.companyId, agentId: foreign.agentId });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("permits failed question-delivery history without a pending target", async () => {
|
||||
await seedQuestionDelivery({ status: "failed", targetRunId: null, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it("still blocks other work after a question handoff finishes", async () => {
|
||||
const { issueId } = await seedQuestionDelivery();
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("requires a current quiet owner even with completed question history", async () => {
|
||||
await seedQuestionDelivery();
|
||||
expect(await readIdleSleepSafety(db, owned, () => now, "stale-owner", emptyLocal)).toEqual(unknown);
|
||||
expect(await readIdleSleepSafety(db, owned, () => now + 60_001, ownerId, emptyLocal)).toEqual(unknown);
|
||||
});
|
||||
|
||||
it("keeps saved agent files awake until deferred cleanup completes", async () => {
|
||||
const { companyId, agentId, runId } = await seed();
|
||||
await db.insert(agentInstructionWorkingCopies).values({
|
||||
|
||||
@@ -12952,6 +12952,91 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["new", "saved"])("starts Sentry MCP sign-in for a %s connection without a manual OAuth app", async (state) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "sentry", connectionMethodKey: "mcp-oauth", name: "Sentry",
|
||||
});
|
||||
const redirectUri = "http://localhost:3200/api/tools/oauth/callback";
|
||||
const issuer = "https://mcp.sentry.dev";
|
||||
const resource = `${issuer}/mcp`;
|
||||
const scopes = ["org:read", "project:write", "team:write", "event:write", "alerts:write"];
|
||||
if (state === "saved") {
|
||||
const [connection] = await db.select().from(toolConnections)
|
||||
.where(eq(toolConnections.id, connected.connectionId));
|
||||
// An existing MCP registration must survive catalog changes. This is the
|
||||
// state before a reconnect, with endpoints cached by successful discovery.
|
||||
await db.update(toolConnections).set({ config: {
|
||||
...connection.config,
|
||||
oauth: {
|
||||
...connection.config.oauth as Record<string, unknown>,
|
||||
provider: "sentry", issuer, resource,
|
||||
authorizationUrl: `${issuer}/oauth/authorize`, tokenUrl: `${issuer}/oauth/token`,
|
||||
metadataUrl: `${issuer}/.well-known/oauth-authorization-server`,
|
||||
clientId: "saved-sentry-client", clientRegistrationSource: "dcr",
|
||||
clientIssuer: issuer, clientResource: resource,
|
||||
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
||||
clientTokenEndpointAuthMethod: "none",
|
||||
},
|
||||
} }).where(eq(toolConnections.id, connected.connectionId));
|
||||
}
|
||||
const calls: string[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
calls.push(href);
|
||||
// Both hosts publish valid OAuth metadata. The web/API issuer cannot
|
||||
// register an MCP client; discovery must select the resource's issuer.
|
||||
if (href === "https://sentry.io/.well-known/oauth-authorization-server") {
|
||||
return mcpHttpResponse({
|
||||
issuer: "https://sentry.io", authorization_endpoint: "https://sentry.io/oauth/authorize/",
|
||||
token_endpoint: "https://sentry.io/oauth/token/", code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
}
|
||||
if (href === `${issuer}/.well-known/oauth-protected-resource/mcp`) {
|
||||
return mcpHttpResponse({ resource, authorization_servers: [issuer], scopes_supported: scopes });
|
||||
}
|
||||
if (href === `${issuer}/.well-known/oauth-authorization-server`) {
|
||||
return mcpHttpResponse({
|
||||
issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`,
|
||||
registration_endpoint: `${issuer}/oauth/register`, scopes_supported: scopes,
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
||||
client_id_metadata_document_supported: true,
|
||||
});
|
||||
}
|
||||
if (href === `${issuer}/oauth/register`) {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
|
||||
});
|
||||
return mcpHttpResponse({
|
||||
client_id: "new-sentry-client", redirect_uris: [redirectUri],
|
||||
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`);
|
||||
});
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri, actor: { actorType: "user", actorId: "board" },
|
||||
});
|
||||
const authorization = new URL(started.authorizationUrl);
|
||||
expect(authorization.origin + authorization.pathname).toBe(`${issuer}/oauth/authorize`);
|
||||
expect(authorization.searchParams.get("resource")).toBe(resource);
|
||||
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes);
|
||||
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(authorization.searchParams.get("client_id")).toBe(`${state}-sentry-client`);
|
||||
expect(calls).not.toContain("https://sentry.io/.well-known/oauth-authorization-server");
|
||||
expect(calls.filter((url) => url === `${issuer}/oauth/register`)).toHaveLength(state === "new" ? 1 : 0);
|
||||
const [saved] = await db.select().from(toolConnections)
|
||||
.where(eq(toolConnections.id, connected.connectionId));
|
||||
expect(saved.config.oauth).toMatchObject({
|
||||
clientId: `${state}-sentry-client`, clientIssuer: issuer, clientResource: resource,
|
||||
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
@@ -93,13 +93,38 @@ const WORK_CHECKS = [
|
||||
THEN s.provider_lease_id::uuid END)
|
||||
AND (l.status IN ('released', 'expired') AND l.cleanup_status = 'success'
|
||||
AND l.released_at IS NOT NULL) IS NOT TRUE))`,
|
||||
// Answer delivery history is not work once its acknowledged handoff has
|
||||
// finished. Keep pending deliveries, unfinished target runs, and missing or
|
||||
// unfinished fallback wake receipts awake. Other work checks still apply.
|
||||
`SELECT 1 FROM issue_question_response_deliveries d WHERE
|
||||
d.status NOT IN ('delivered', 'fallback_queued', 'failed')
|
||||
OR (d.status IN ('delivered', 'fallback_queued') AND d.acknowledged_at IS NULL)
|
||||
OR (d.status = 'delivered' AND d.target_run_id IS NULL)
|
||||
OR (d.target_run_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM heartbeat_runs r WHERE r.id = d.target_run_id
|
||||
AND r.company_id = d.company_id
|
||||
AND r.status IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted')
|
||||
AND r.finished_at IS NOT NULL AND r.scheduled_retry_at IS NULL
|
||||
AND r.cost_accounting_pending = false))
|
||||
OR (d.status = 'fallback_queued' AND d.target_run_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM agent_wakeup_requests w WHERE w.company_id = d.company_id
|
||||
AND w.idempotency_key = 'question-response:' || d.interaction_id::text
|
||||
AND w.idempotency_key LIKE 'question-response:%'
|
||||
AND w.finished_at IS NOT NULL
|
||||
AND (w.status IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
|
||||
OR (w.status = 'coalesced' AND EXISTS (
|
||||
SELECT 1 FROM heartbeat_runs r WHERE r.id = w.run_id
|
||||
AND r.company_id = w.company_id AND r.agent_id = w.agent_id
|
||||
AND r.status IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted')
|
||||
AND r.finished_at IS NOT NULL AND r.scheduled_retry_at IS NULL
|
||||
AND r.cost_accounting_pending = false)))))`,
|
||||
// These less common work sources fail closed on any retained state. Their
|
||||
// terminal-state exceptions can be added with tests for the owning service.
|
||||
...[
|
||||
"chat_endpoints", "chat_deliveries", "chat_publications", "chat_actions",
|
||||
"chat_completion_deliveries", "connection_event_deliveries",
|
||||
"connection_intent_deliveries", "tool_action_requests", "tool_action_deliveries",
|
||||
"issue_question_response_deliveries", "workspace_runtime_services",
|
||||
"workspace_runtime_services",
|
||||
"execution_workspace_runtime_leases", "pipeline_automation_executions",
|
||||
"native_run_finalizations", "company_transfer_runs", "decisions", "decision_effect_executions",
|
||||
"decision_archive_notification_outbox", "browser_use_sessions", "browser_use_runs",
|
||||
|
||||
Reference in new issue
Block a user