mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Merge origin/master into master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
3af8b7f88b
35 files changed
+1060
-141
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
buildBundleFromLocalCompany,
|
||||
cloudCommandExitCodes,
|
||||
connectCloud,
|
||||
resolveDeviceCodeExpiresAt,
|
||||
} from "../commands/client/cloud.js";
|
||||
import {
|
||||
LocalUpstreamPushCoordinator,
|
||||
@@ -82,6 +83,15 @@ describe("cloud CLI helpers", () => {
|
||||
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
|
||||
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
|
||||
const validExpiry = "2026-05-22T13:05:00.000Z";
|
||||
|
||||
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
|
||||
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
|
||||
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
|
||||
});
|
||||
|
||||
it("builds deterministic chunks with validated payload hashes", async () => {
|
||||
const bundle = await buildTestBundle();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
|
||||
const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
|
||||
const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
|
||||
const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
|
||||
|
||||
interface CloudConnectOptions extends BaseClientOptions {
|
||||
noBrowser?: boolean;
|
||||
@@ -253,6 +254,11 @@ export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
|
||||
const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
|
||||
}
|
||||
|
||||
export async function buildBundleFromLocalCompany(input: {
|
||||
localCompanyId: string;
|
||||
connection: CloudConnection;
|
||||
@@ -388,7 +394,7 @@ async function authorizeWithDeviceCode(
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: string;
|
||||
expiresAt?: string;
|
||||
intervalSeconds?: number;
|
||||
}>(device.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
@@ -405,9 +411,9 @@ async function authorizeWithDeviceCode(
|
||||
console.error(`Code: ${response.userCode}`);
|
||||
if (opts.openBrowser) openUrl(response.verificationUri);
|
||||
|
||||
const expiresAt = Date.parse(response.expiresAt);
|
||||
const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
|
||||
const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
|
||||
while (!Number.isFinite(expiresAt) || Date.now() < expiresAt) {
|
||||
while (Date.now() < expiresAt) {
|
||||
await sleep(intervalMs);
|
||||
try {
|
||||
return await requestCloudJson<TokenResponse>(device.tokenUrl, {
|
||||
|
||||
@@ -10,10 +10,12 @@ export const cloudUpstreamConnections = pgTable(
|
||||
sourceInstanceId: text("source_instance_id").notNull(),
|
||||
sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(),
|
||||
sourcePublicKey: text("source_public_key").notNull(),
|
||||
// Stored through the Cloud Upstream service as an encrypted credential envelope.
|
||||
privateKeyPem: text("private_key_pem").notNull(),
|
||||
tokenStatus: text("token_status").notNull(),
|
||||
scopes: text("scopes").array().notNull().default([]),
|
||||
authorizedGlobalUserId: text("authorized_global_user_id"),
|
||||
// Stored through the Cloud Upstream service as an encrypted credential envelope.
|
||||
accessToken: text("access_token"),
|
||||
tokenId: text("token_id"),
|
||||
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
|
||||
|
||||
@@ -79,7 +79,7 @@ export class CapabilityDeniedError extends Error {
|
||||
*/
|
||||
export class InvocationScopeDeniedError extends Error {
|
||||
override readonly name = "InvocationScopeDeniedError";
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, message: string) {
|
||||
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
|
||||
|
||||
@@ -246,6 +246,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
|
||||
TIMEOUT: -32003,
|
||||
/** The worker does not implement the requested optional method. */
|
||||
METHOD_NOT_IMPLEMENTED: -32004,
|
||||
/** The worker→host call attempted to escape the current invocation company scope. */
|
||||
INVOCATION_SCOPE_DENIED: -32005,
|
||||
/** A catch-all for errors that do not fit other categories. */
|
||||
UNKNOWN: -32099,
|
||||
} as const;
|
||||
|
||||
@@ -54,6 +54,7 @@ export type {
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
|
||||
@@ -1534,13 +1534,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
const rawActor = params.actorContext && typeof params.actorContext === "object"
|
||||
? params.actorContext
|
||||
: null;
|
||||
const legacyCompanyId = stringOrNull(params.companyId) ?? stringOrNull(params.params.companyId);
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(rawActor?.type),
|
||||
userId: stringOrNull(rawActor?.userId),
|
||||
agentId: stringOrNull(rawActor?.agentId),
|
||||
runId: stringOrNull(rawActor?.runId),
|
||||
companyId: stringOrNull(rawActor?.companyId) ?? legacyCompanyId,
|
||||
companyId: stringOrNull(rawActor?.companyId),
|
||||
});
|
||||
return Object.freeze({
|
||||
actor,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createHostClientHandlers,
|
||||
InvocationScopeDeniedError,
|
||||
} from "../src/host-client-factory.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
|
||||
|
||||
describe("createHostClientHandlers invocation company scope", () => {
|
||||
it("rejects company-scoped host calls outside the current invocation company", async () => {
|
||||
@@ -28,6 +29,14 @@ describe("createHostClientHandlers invocation company scope", () => {
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
await expect(
|
||||
handlers["projects.list"](
|
||||
{ companyId: "company-b" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
expect(projectsList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,90 @@ describe("isWorkerEntrypoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker performAction context", () => {
|
||||
it("does not derive context companyId from caller params without host actor context", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
let nextRequestId = 1;
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("inspect", async (params, context) => ({
|
||||
paramsCompanyId: params.companyId,
|
||||
actor: context.actor,
|
||||
companyId: context.companyId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, (response) => {
|
||||
if ("error" in response && response.error) {
|
||||
reject(new Error(response.error.message));
|
||||
return;
|
||||
}
|
||||
resolve((response as { result?: unknown }).result);
|
||||
});
|
||||
});
|
||||
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (!isJsonRpcResponse(message)) return;
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.test-worker-context",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Worker Context Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
},
|
||||
config: {},
|
||||
databaseNamespace: null,
|
||||
})).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await expect(callWorker("performAction", {
|
||||
key: "inspect",
|
||||
params: { companyId: "spoofed-company" },
|
||||
})).resolves.toEqual({
|
||||
paramsCompanyId: "spoofed-company",
|
||||
actor: {
|
||||
type: "system",
|
||||
userId: null,
|
||||
agentId: null,
|
||||
runId: null,
|
||||
companyId: null,
|
||||
},
|
||||
companyId: null,
|
||||
});
|
||||
} finally {
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
hostToWorker.destroy();
|
||||
workerToHost.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker invocation scope propagation", () => {
|
||||
it("keeps overlapping company scopes local to each getData invocation", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
|
||||
@@ -1095,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
|
||||
export const PLUGIN_BRIDGE_ERROR_CODES = [
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CAPABILITY_DENIED",
|
||||
"INVOCATION_SCOPE_DENIED",
|
||||
"WORKER_ERROR",
|
||||
"TIMEOUT",
|
||||
"UNKNOWN",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createDb,
|
||||
documents,
|
||||
documentRevisions,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
@@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueReadStates);
|
||||
await db.delete(issueComments);
|
||||
@@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes heartbeat events by run id before deleting company-owned runs", async () => {
|
||||
const { agentId, companyId, runId } = await seedFixture();
|
||||
const otherCompanyId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: otherCompanyId,
|
||||
name: "Other Company",
|
||||
issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId: otherCompanyId,
|
||||
runId,
|
||||
agentId,
|
||||
seq: 1,
|
||||
eventType: "output",
|
||||
message: "event with mismatched company scope",
|
||||
});
|
||||
|
||||
const removed = await companyService(db).remove(companyId);
|
||||
|
||||
expect(removed?.id).toBe(companyId);
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db";
|
||||
|
||||
import { HttpError } from "../errors.js";
|
||||
import { cloudUpstreamRemoteFailureReport } from "../services/cloud-upstreams.js";
|
||||
import {
|
||||
cloudUpstreamRemoteFailureReport,
|
||||
cloudUpstreamService,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
sealCloudUpstreamCredential,
|
||||
unsealCloudUpstreamCredential,
|
||||
} from "../services/cloud-upstreams.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("cloud upstream remote failures", () => {
|
||||
it("preserves the cloud response body and message on run reports", () => {
|
||||
@@ -24,3 +45,290 @@ describe("cloud upstream remote failures", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud upstream credential storage", () => {
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
|
||||
const sealed = await sealCloudUpstreamCredential("cloud-access-token");
|
||||
|
||||
expect(sealed).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(sealed).not.toContain("cloud-access-token");
|
||||
await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token");
|
||||
await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("cloud upstream persistence", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(cloudUpstreamRuns);
|
||||
await db.delete(cloudUpstreamConnections);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("encrypts stored upstream credentials while keeping connection flows usable", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const tokenUrl = "https://cloud.example.test/oauth/token";
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) {
|
||||
return jsonResponse({
|
||||
product: "Paperclip Cloud",
|
||||
stack: {
|
||||
id: "stack-1",
|
||||
companyId: "cloud-company-1",
|
||||
origin: "https://cloud.example.test",
|
||||
primaryHost: "cloud.example.test",
|
||||
},
|
||||
transfer: {
|
||||
supportedSchemaMajor: 1,
|
||||
maxChunkBytes: 8192,
|
||||
},
|
||||
auth: {
|
||||
scopes: ["upstream_import:write"],
|
||||
pkce: {
|
||||
authorizeUrl: "https://cloud.example.test/oauth/authorize",
|
||||
tokenUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === tokenUrl && init?.method === "POST") {
|
||||
const payload = JSON.parse(String(init.body));
|
||||
expect(payload.codeVerifier).toEqual(expect.any(String));
|
||||
expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:");
|
||||
return jsonResponse({
|
||||
accessToken: "cloud-access-token",
|
||||
token: {
|
||||
id: "token-1",
|
||||
expiresAt: "2026-05-22T13:00:00.000Z",
|
||||
globalUserId: "user-1",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const service = cloudUpstreamService(db, { instanceId: "test" });
|
||||
const started = await service.startConnect({
|
||||
companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
redirectUri: "http://localhost:3100/callback",
|
||||
});
|
||||
await service.finishConnect({
|
||||
pendingConnectionId: started.pendingConnectionId,
|
||||
code: "auth-code",
|
||||
state: new URL(started.authorizationUrl).searchParams.get("state") ?? "",
|
||||
});
|
||||
|
||||
const [row] = await db.select().from(cloudUpstreamConnections);
|
||||
expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY");
|
||||
expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/);
|
||||
expect(row.accessToken).not.toContain("cloud-access-token");
|
||||
});
|
||||
|
||||
it("marks orphaned running runs failed during startup reconciliation", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const runningRunId = randomUUID();
|
||||
const succeededRunId = randomUUID();
|
||||
const reconciledAt = new Date("2026-05-22T13:00:00.000Z");
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values({
|
||||
id: connectionId,
|
||||
companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
sourceInstanceId: "source-1",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
sourcePublicKey: "public-key",
|
||||
privateKeyPem: "legacy-private-key",
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
accessToken: "legacy-token",
|
||||
tokenId: "token-1",
|
||||
targetStackId: "stack-1",
|
||||
targetCompanyId: "cloud-company-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
targetPrimaryHost: "cloud.example.test",
|
||||
targetProduct: "Paperclip Cloud",
|
||||
targetSchemaMajor: 1,
|
||||
targetMaxChunkBytes: 8192,
|
||||
});
|
||||
await db.insert(cloudUpstreamRuns).values([
|
||||
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
|
||||
cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }),
|
||||
]);
|
||||
|
||||
await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 });
|
||||
|
||||
const rows = await db.select().from(cloudUpstreamRuns);
|
||||
const running = rows.find((row) => row.id === runningRunId);
|
||||
const succeeded = rows.find((row) => row.id === succeededRunId);
|
||||
expect(running?.status).toBe("failed");
|
||||
expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString());
|
||||
expect(running?.events.at(-1)?.message).toContain("server startup");
|
||||
expect(running?.report).toMatchObject({
|
||||
error: "orphaned_running_run",
|
||||
reconciledAt: reconciledAt.toISOString(),
|
||||
});
|
||||
expect(succeeded?.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("rejects a new run when the connection already has a running run", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const runningRunId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
|
||||
await db.insert(cloudUpstreamRuns).values(
|
||||
cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }),
|
||||
);
|
||||
|
||||
await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { runId: runningRunId },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a cancelled run when an in-flight createRun tries to finish", async () => {
|
||||
const companyId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId }));
|
||||
|
||||
const service = cloudUpstreamService(db);
|
||||
const remoteCalls: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
remoteCalls.push(path);
|
||||
if (path.endsWith("/upstream-imports/runs")) {
|
||||
return jsonResponse({ run: { id: "remote-run-1" } });
|
||||
}
|
||||
if (path.endsWith("/chunks")) {
|
||||
const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]);
|
||||
expect(run?.status).toBe("running");
|
||||
await service.cancelRun(connectionId, run.id, companyId);
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/cancel")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/apply")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/events")) {
|
||||
return jsonResponse({ events: [] });
|
||||
}
|
||||
return jsonResponse({ error: "not_found" }, 404);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await service.createRun({ connectionId, companyId });
|
||||
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false);
|
||||
const rows = await db.select().from(cloudUpstreamRuns);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
async function seedCompany(companyId: string) {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function cloudConnectionRow(input: { id: string; companyId: string }) {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return {
|
||||
id: input.id,
|
||||
companyId: input.companyId,
|
||||
remoteUrl: "https://cloud.example.test",
|
||||
sourceInstanceId: "source-1",
|
||||
sourceInstanceFingerprint: "sha256:test",
|
||||
sourcePublicKey: "public-key",
|
||||
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
accessToken: "legacy-token",
|
||||
tokenId: "token-1",
|
||||
targetStackId: "stack-1",
|
||||
targetCompanyId: "cloud-company-1",
|
||||
targetOrigin: "https://cloud.example.test",
|
||||
targetPrimaryHost: "cloud.example.test",
|
||||
targetProduct: "Paperclip Cloud",
|
||||
targetSchemaMajor: 1,
|
||||
targetMaxChunkBytes: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
function cloudRunRow(input: {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
completedAt?: Date;
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
connectionId: input.connectionId,
|
||||
companyId: input.companyId,
|
||||
status: input.status,
|
||||
activeStep: "push",
|
||||
progressPercent: input.status === "running" ? 45 : 100,
|
||||
dryRun: false,
|
||||
summary: [],
|
||||
warnings: [],
|
||||
conflicts: [],
|
||||
events: [],
|
||||
report: {},
|
||||
idempotencyKey: `key-${input.id}`,
|
||||
manifestHash: `sha256:${input.id.replace(/-/g, "")}`,
|
||||
targetUrl: "https://cloud.example.test",
|
||||
completedAt: input.completedAt,
|
||||
};
|
||||
}
|
||||
@@ -507,6 +507,7 @@ describe.sequential("company portability routes", () => {
|
||||
expect(succeeded.status).toBe(200);
|
||||
expect(succeeded.body.job.status).toBe("succeeded");
|
||||
expect(succeeded.body.job.result.companyId).toBe(companyId);
|
||||
expect(succeeded.body.retryAfterMs).toBeUndefined();
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.imported",
|
||||
companyId,
|
||||
@@ -516,6 +517,15 @@ describe.sequential("company portability routes", () => {
|
||||
companyAction: "updated",
|
||||
}),
|
||||
}));
|
||||
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1);
|
||||
try {
|
||||
const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
|
||||
expect(expired.status).toBe(404);
|
||||
expect(expired.body.error).toBe("Import job not found");
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => {
|
||||
@@ -534,6 +544,7 @@ describe.sequential("company portability routes", () => {
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.body.job.status).toBe("failed");
|
||||
expect(failed.body.job.error.message).toBe("tenant import exploded");
|
||||
expect(failed.body.retryAfterMs).toBeUndefined();
|
||||
expect(failed.body.message).toBe("tenant import exploded");
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -68,13 +68,13 @@ rl.on("line", (line) => {
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["getData"],
|
||||
supportedMethods: ["getData", "performAction"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "getData") {
|
||||
if (method === "getData" || method === "performAction") {
|
||||
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -715,6 +715,29 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses null for board actor userId when no authenticated user id is present", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(boardActor({ userId: undefined }), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/actions/sync`)
|
||||
.send({ companyId: companyA });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({
|
||||
actorContext: expect.objectContaining({
|
||||
type: "user",
|
||||
userId: null,
|
||||
companyId: companyA,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows agent-scoped plugin actions with authenticated actor context", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
@@ -186,6 +186,58 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes performAction invocation scope to nested worker host calls", async () => {
|
||||
const companiesGet = vi.fn(async (
|
||||
params: { companyId: string },
|
||||
context?: { invocationScope?: { companyId?: string | null } | null },
|
||||
) => ({
|
||||
id: params.companyId,
|
||||
scopedCompanyId: context?.invocationScope?.companyId ?? null,
|
||||
}));
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {
|
||||
"companies.get": companiesGet as never,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
mode: "echo",
|
||||
requestedCompanyId: "company-a",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).resolves.toEqual({
|
||||
id: "company-a",
|
||||
scopedCompanyId: "company-a",
|
||||
});
|
||||
expect(companiesGet).toHaveBeenCalledWith(
|
||||
{ companyId: "company-a" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
@@ -223,6 +275,104 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects performAction nested host calls that omit the invocation id", async () => {
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "test.plugin",
|
||||
capabilities: ["companies.read"],
|
||||
services: {
|
||||
companies: {
|
||||
list: vi.fn(async () => []),
|
||||
get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
|
||||
},
|
||||
} as unknown as HostServices,
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: handlers,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
requestedCompanyId: "company-b",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
message: expect.stringContaining("unknown invocation scope"),
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects nested worker host calls that forge an unknown invocation id", async () => {
|
||||
const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "test.plugin",
|
||||
capabilities: ["companies.read"],
|
||||
services: {
|
||||
companies: {
|
||||
get: companiesGet,
|
||||
},
|
||||
} as unknown as HostServices,
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: handlers,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("performAction", {
|
||||
key: "probe",
|
||||
params: {
|
||||
mode: "unknown",
|
||||
requestedCompanyId: "company-a",
|
||||
},
|
||||
actorContext: {
|
||||
type: "agent",
|
||||
userId: null,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-a",
|
||||
},
|
||||
renderEnvironment: null,
|
||||
})).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
message: expect.stringContaining("unknown invocation scope"),
|
||||
});
|
||||
expect(companiesGet).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
|
||||
const hostHandlers = createHostClientHandlers({
|
||||
@@ -258,7 +408,7 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
requestedCompanyId: "company-2",
|
||||
},
|
||||
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ vi.mock("../services/index.js", () => ({
|
||||
},
|
||||
})),
|
||||
})),
|
||||
reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
routineService: vi.fn(() => ({
|
||||
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
backfillPrincipalAccessCompatibility,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
routineService,
|
||||
} from "./services/index.js";
|
||||
@@ -701,6 +702,19 @@ export async function startServer(): Promise<StartedServer> {
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
|
||||
});
|
||||
|
||||
void reconcileCloudUpstreamRunsOnStartup(db as any)
|
||||
.then((result) => {
|
||||
if (result.reconciled > 0) {
|
||||
logger.warn(
|
||||
{ reconciled: result.reconciled },
|
||||
"reconciled cloud upstream runs from a previous server process",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of cloud upstream runs failed");
|
||||
});
|
||||
|
||||
if (config.heartbeatSchedulerEnabled) {
|
||||
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
|
||||
|
||||
@@ -45,7 +45,7 @@ export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.preview(req.params.connectionId));
|
||||
res.json(await service.preview(req.params.connectionId, stringBody(req.body, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => {
|
||||
@@ -53,6 +53,7 @@ export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {
|
||||
await assertEnabled();
|
||||
res.json(await service.createRun({
|
||||
connectionId: req.params.connectionId,
|
||||
companyId: stringBody(req.body, "companyId"),
|
||||
retryOfRunId: optionalString(req.body?.retryOfRunId),
|
||||
}));
|
||||
});
|
||||
@@ -60,13 +61,13 @@ export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {
|
||||
router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.readRun(req.params.connectionId, req.params.runId));
|
||||
res.json(await service.readRun(req.params.connectionId, req.params.runId, stringQuery(req.query.companyId, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
await assertEnabled();
|
||||
res.json(await service.cancelRun(req.params.connectionId, req.params.runId));
|
||||
res.json(await service.cancelRun(req.params.connectionId, req.params.runId, stringBody(req.body, "companyId")));
|
||||
});
|
||||
|
||||
router.post("/cloud-upstreams/:connectionId/push-runs/:runId/activation", async (req, res) => {
|
||||
@@ -75,6 +76,7 @@ export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {
|
||||
res.json(await service.activateRunEntities({
|
||||
connectionId: req.params.connectionId,
|
||||
runId: req.params.runId,
|
||||
companyId: stringBody(req.body, "companyId"),
|
||||
entityType: activationEntityTypeBody(req.body),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
const budgets = budgetService(db);
|
||||
const feedback = feedbackService(db);
|
||||
const importJobs = new Map<string, ImportJobRecord>();
|
||||
const importJobTerminalRetentionMs = 5 * 60 * 1000;
|
||||
|
||||
function parseBooleanQuery(value: unknown) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
@@ -181,6 +182,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
|
||||
router.get("/import/jobs/:jobId", async (req, res) => {
|
||||
assertCloudTenantCaller(req);
|
||||
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
|
||||
const job = importJobs.get(req.params.jobId as string);
|
||||
if (!job || job.cloudTenantKey !== cloudTenantRequestKey(req)) {
|
||||
res.status(404).json({ error: "Import job not found" });
|
||||
@@ -196,6 +198,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
||||
const boardUserId = req.actor.type === "board" ? req.actor.userId : null;
|
||||
if (req.header("x-paperclip-cloud-async-import") === "1") {
|
||||
assertCloudTenantCaller(req);
|
||||
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
|
||||
const job = createImportJob(cloudTenantRequestKey(req));
|
||||
importJobs.set(job.id, job);
|
||||
const operation = async () => {
|
||||
@@ -568,6 +571,7 @@ function importJobAcceptedResponse(job: ImportJobRecord) {
|
||||
}
|
||||
|
||||
function importJobResponse(job: ImportJobRecord) {
|
||||
const isTerminal = job.status === "succeeded" || job.status === "failed";
|
||||
const response: Record<string, unknown> = {
|
||||
job: {
|
||||
id: job.id,
|
||||
@@ -578,7 +582,7 @@ function importJobResponse(job: ImportJobRecord) {
|
||||
...(job.error ? { error: job.error } : {}),
|
||||
...(job.result ? { result: job.result } : {}),
|
||||
},
|
||||
retryAfterMs: 1000,
|
||||
...(isTerminal ? {} : { retryAfterMs: 1000 }),
|
||||
};
|
||||
if (job.error?.message) {
|
||||
response.error = job.error.message;
|
||||
@@ -588,6 +592,16 @@ function importJobResponse(job: ImportJobRecord) {
|
||||
return response;
|
||||
}
|
||||
|
||||
function cleanupTerminalImportJobs(importJobs: Map<string, ImportJobRecord>, terminalRetentionMs: number) {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of importJobs) {
|
||||
if (job.status === "running" || !job.completedAt) continue;
|
||||
if (now - Date.parse(job.completedAt) > terminalRetentionMs) {
|
||||
importJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error && error.message.trim() ? error.message : String(error);
|
||||
}
|
||||
@@ -586,7 +586,7 @@ export function pluginRoutes(
|
||||
if (req.actor.type === "board") {
|
||||
return {
|
||||
type: "user",
|
||||
userId: req.actor.userId ?? "board",
|
||||
userId: req.actor.userId ?? null,
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
companyId: scopedCompanyId,
|
||||
@@ -1021,6 +1021,12 @@ export function pluginRoutes(
|
||||
message: err.message,
|
||||
details: err.data,
|
||||
};
|
||||
case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED:
|
||||
return {
|
||||
code: "INVOCATION_SCOPE_DENIED",
|
||||
message: err.message,
|
||||
details: err.data,
|
||||
};
|
||||
case PLUGIN_RPC_ERROR_CODES.TIMEOUT:
|
||||
return {
|
||||
code: "TIMEOUT",
|
||||
@@ -1254,7 +1260,6 @@ export function pluginRoutes(
|
||||
"performAction",
|
||||
{
|
||||
key: body.key,
|
||||
...(companyId ? { companyId } : {}),
|
||||
params: actionParamsWithAuthorizedCompanyScope(body.params, companyId),
|
||||
actorContext: performActionActorContext(req, companyId),
|
||||
renderEnvironment: body.renderEnvironment ?? null,
|
||||
@@ -1439,7 +1444,6 @@ export function pluginRoutes(
|
||||
"performAction",
|
||||
{
|
||||
key,
|
||||
...(companyId ? { companyId } : {}),
|
||||
params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId),
|
||||
actorContext: performActionActorContext(req, companyId),
|
||||
renderEnvironment: body?.renderEnvironment ?? null,
|
||||
@@ -1645,7 +1649,10 @@ export function pluginRoutes(
|
||||
} catch (err) {
|
||||
const status = typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status
|
||||
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED
|
||||
: err instanceof JsonRpcCallError && (
|
||||
err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED ||
|
||||
err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
|
||||
)
|
||||
? 403
|
||||
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
|
||||
? 501
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto, { sign } from "node:crypto";
|
||||
import { and, count, desc, eq } from "drizzle-orm";
|
||||
import { and, count, desc, eq, sql } from "drizzle-orm";
|
||||
import type {
|
||||
CloudUpstreamConnectStartResponse,
|
||||
CloudUpstreamActivationDecision,
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
projects,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { badRequest, HttpError, notFound } from "../errors.js";
|
||||
import { badRequest, conflict, HttpError, notFound } from "../errors.js";
|
||||
import { companyPortabilityService } from "./company-portability.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
|
||||
const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
|
||||
const TRANSFER_SCHEMA = {
|
||||
@@ -39,6 +40,9 @@ const TRANSFER_SCHEMA = {
|
||||
minor: 0,
|
||||
} as const;
|
||||
const DEFAULT_MAX_ENTITIES_PER_CHUNK = 100;
|
||||
const DISCOVERY_FETCH_TIMEOUT_MS = 30_000;
|
||||
const REMOTE_FETCH_TIMEOUT_MS = 120_000;
|
||||
const CLOUD_CREDENTIAL_PREFIX = "paperclip-cloud-credential:";
|
||||
|
||||
type NormalizedSha256 = `sha256:${string}`;
|
||||
|
||||
@@ -75,7 +79,6 @@ type LocalUpstreamExportChunk = {
|
||||
totalChunks: number;
|
||||
byteLength: number;
|
||||
sha256: NormalizedSha256;
|
||||
manifestHash: NormalizedSha256;
|
||||
payload: {
|
||||
entityKeys: SourceEntityKey[];
|
||||
};
|
||||
@@ -102,7 +105,7 @@ type UpstreamTransferManifest = {
|
||||
entityCount: number;
|
||||
perEntityTypeCounts: Record<string, number>;
|
||||
entities: UpstreamTransferEntityRecord[];
|
||||
chunks: Array<Omit<LocalUpstreamExportChunk, "payload">>;
|
||||
chunks: Array<Omit<LocalUpstreamExportChunk, "payload"> & { manifestHash: NormalizedSha256 }>;
|
||||
warnings: UpstreamTransferWarning[];
|
||||
featureFlags: string[];
|
||||
manifestHash: NormalizedSha256;
|
||||
@@ -133,7 +136,8 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
.select()
|
||||
.from(cloudUpstreamRuns)
|
||||
.where(eq(cloudUpstreamRuns.companyId, companyId))
|
||||
.orderBy(desc(cloudUpstreamRuns.createdAt)),
|
||||
.orderBy(desc(cloudUpstreamRuns.createdAt))
|
||||
.limit(50),
|
||||
]);
|
||||
return {
|
||||
connections: connectionRows.map(connectionFromRow),
|
||||
@@ -170,7 +174,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
sourceInstanceId,
|
||||
sourceInstanceFingerprint,
|
||||
sourcePublicKey,
|
||||
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
privateKeyPem: await sealCloudUpstreamCredential(privateKey.export({ type: "pkcs8", format: "pem" }).toString()),
|
||||
tokenStatus: "pending",
|
||||
scopes: scopesFromDiscovery(discovery),
|
||||
targetStackId: target.stackId,
|
||||
@@ -183,7 +187,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
targetSchemaMajor: target.schemaMajor,
|
||||
targetMaxChunkBytes: target.maxChunkBytes,
|
||||
pendingState: state,
|
||||
pendingCodeVerifier: codeVerifier,
|
||||
pendingCodeVerifier: await sealCloudUpstreamCredential(codeVerifier),
|
||||
pendingRedirectUri: input.redirectUri,
|
||||
pendingTokenUrl: tokenUrlFromDiscovery(discovery),
|
||||
}).returning();
|
||||
@@ -221,7 +225,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
grantType: "authorization_code",
|
||||
code: input.code,
|
||||
redirectUri: pending.pendingRedirectUri,
|
||||
codeVerifier: pending.pendingCodeVerifier,
|
||||
codeVerifier: await unsealCloudUpstreamCredential(pending.pendingCodeVerifier),
|
||||
});
|
||||
const accessToken = stringField(tokenResponse, "accessToken");
|
||||
const token = objectField(tokenResponse, "token");
|
||||
@@ -231,7 +235,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
.set({
|
||||
tokenStatus: "connected",
|
||||
authorizedGlobalUserId: optionalString(token.globalUserId),
|
||||
accessToken,
|
||||
accessToken: await sealCloudUpstreamCredential(accessToken),
|
||||
tokenId: optionalString(token.id),
|
||||
tokenExpiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
pendingState: null,
|
||||
@@ -246,8 +250,8 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
return connectionFromRow(updated);
|
||||
},
|
||||
|
||||
preview: async (connectionId: string): Promise<CloudUpstreamPreview> => {
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
preview: async (connectionId: string, companyId: string): Promise<CloudUpstreamPreview> => {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
const basePreview = await localPreview(connection);
|
||||
if (!basePreview.schemaCompatible || connection.tokenStatus !== "connected") {
|
||||
return basePreview;
|
||||
@@ -271,11 +275,12 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
};
|
||||
},
|
||||
|
||||
createRun: async (input: { connectionId: string; retryOfRunId?: string | null }): Promise<CloudUpstreamRun> => {
|
||||
const connection = await getConnectionRow(input.connectionId);
|
||||
createRun: async (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }): Promise<CloudUpstreamRun> => {
|
||||
const connection = await getConnectionRow(input.connectionId, input.companyId);
|
||||
if (connection.tokenStatus !== "connected") {
|
||||
throw badRequest("Cloud upstream connection is not connected");
|
||||
}
|
||||
await assertNoRunningRun(input.connectionId, input.companyId, db);
|
||||
const preview = await localPreview(connection);
|
||||
if (!preview.schemaCompatible) {
|
||||
throw badRequest("Cloud stack schema is not compatible with this local Paperclip version");
|
||||
@@ -292,26 +297,33 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
? [event(now.toISOString(), "push", "retrying", `Retrying run ${input.retryOfRunId} with the same import ledger idempotency key.`)]
|
||||
: []),
|
||||
];
|
||||
const [created] = await db.insert(cloudUpstreamRuns).values({
|
||||
id: runId,
|
||||
connectionId: connection.id,
|
||||
companyId: connection.companyId,
|
||||
status: "running",
|
||||
activeStep: "push",
|
||||
progressPercent: 45,
|
||||
dryRun: false,
|
||||
retryOfRunId: input.retryOfRunId ?? null,
|
||||
summary: preview.summary,
|
||||
warnings: preview.warnings,
|
||||
conflicts: preview.conflicts,
|
||||
events: initialEvents,
|
||||
report: {},
|
||||
idempotencyKey: bundle.manifest.idempotencyKey,
|
||||
manifestHash: bundle.manifest.manifestHash,
|
||||
targetUrl: connection.targetOrigin,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
const created = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select ${cloudUpstreamConnections.id} from ${cloudUpstreamConnections} where ${cloudUpstreamConnections.id} = ${connection.id} and ${cloudUpstreamConnections.companyId} = ${connection.companyId} for update`,
|
||||
);
|
||||
await assertNoRunningRun(input.connectionId, input.companyId, tx);
|
||||
const [row] = await tx.insert(cloudUpstreamRuns).values({
|
||||
id: runId,
|
||||
connectionId: connection.id,
|
||||
companyId: connection.companyId,
|
||||
status: "running",
|
||||
activeStep: "push",
|
||||
progressPercent: 45,
|
||||
dryRun: false,
|
||||
retryOfRunId: input.retryOfRunId ?? null,
|
||||
summary: preview.summary,
|
||||
warnings: preview.warnings,
|
||||
conflicts: preview.conflicts,
|
||||
events: initialEvents,
|
||||
report: {},
|
||||
idempotencyKey: bundle.manifest.idempotencyKey,
|
||||
manifestHash: bundle.manifest.manifestHash,
|
||||
targetUrl: connection.targetOrigin,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return row;
|
||||
});
|
||||
if (!created) throw badRequest("Failed to create cloud upstream run");
|
||||
|
||||
try {
|
||||
@@ -321,7 +333,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
entities: bundle.entities,
|
||||
});
|
||||
const remoteRunId = remoteRunIdFromResponse(remoteRun);
|
||||
await updateRun(runId, {
|
||||
const pushedRun = await updateRunIfRunning(runId, {
|
||||
remoteRunId,
|
||||
activeStep: "push",
|
||||
progressPercent: 60,
|
||||
@@ -330,11 +342,12 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
event(new Date().toISOString(), "push", "updated", "Created or resumed the cloud import ledger run."),
|
||||
],
|
||||
});
|
||||
if (pushedRun.status !== "running") return pushedRun;
|
||||
|
||||
for (const chunk of bundle.chunks) {
|
||||
await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/chunks`, chunk);
|
||||
}
|
||||
await updateRun(runId, {
|
||||
const verifiedRun = await updateRunIfRunning(runId, {
|
||||
activeStep: "verify",
|
||||
progressPercent: 82,
|
||||
events: [
|
||||
@@ -342,6 +355,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
event(new Date().toISOString(), "push", "completed", `Uploaded ${bundle.chunks.length} manifest chunk${bundle.chunks.length === 1 ? "" : "s"}.`),
|
||||
],
|
||||
});
|
||||
if (verifiedRun.status !== "running") return verifiedRun;
|
||||
|
||||
const applied = await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/apply`, {});
|
||||
const remoteEvents = await remoteGet(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/events`).catch(() => null);
|
||||
@@ -353,7 +367,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."),
|
||||
...eventsFromRemote(remoteEvents),
|
||||
];
|
||||
const finalRun = await updateRun(runId, {
|
||||
const finalRun = await updateRunIfRunning(runId, {
|
||||
remoteRunId,
|
||||
status: "succeeded",
|
||||
activeStep: "activate",
|
||||
@@ -373,15 +387,17 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
},
|
||||
completedAt,
|
||||
});
|
||||
await db
|
||||
.update(cloudUpstreamConnections)
|
||||
.set({ lastRunId: finalRun.id, updatedAt: new Date() })
|
||||
.where(eq(cloudUpstreamConnections.id, connection.id));
|
||||
if (finalRun.status === "succeeded") {
|
||||
await db
|
||||
.update(cloudUpstreamConnections)
|
||||
.set({ lastRunId: finalRun.id, updatedAt: new Date() })
|
||||
.where(eq(cloudUpstreamConnections.id, connection.id));
|
||||
}
|
||||
return finalRun;
|
||||
} catch (error) {
|
||||
const failedAt = new Date();
|
||||
const failure = cloudUpstreamRemoteFailureReport(error);
|
||||
return updateRun(runId, {
|
||||
return updateRunIfRunning(runId, {
|
||||
status: "failed",
|
||||
activeStep: "push",
|
||||
progressPercent: 100,
|
||||
@@ -402,15 +418,15 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
}
|
||||
},
|
||||
|
||||
readRun: async (connectionId: string, runId: string): Promise<CloudUpstreamRun> => {
|
||||
const row = await getRunRow(connectionId, runId);
|
||||
readRun: async (connectionId: string, runId: string, companyId: string): Promise<CloudUpstreamRun> => {
|
||||
const row = await getRunRow(connectionId, runId, companyId);
|
||||
return runFromRow(row);
|
||||
},
|
||||
|
||||
cancelRun: async (connectionId: string, runId: string): Promise<CloudUpstreamRun> => {
|
||||
const row = await getRunRow(connectionId, runId);
|
||||
cancelRun: async (connectionId: string, runId: string, companyId: string): Promise<CloudUpstreamRun> => {
|
||||
const row = await getRunRow(connectionId, runId, companyId);
|
||||
if (row.status !== "running") return runFromRow(row);
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
if (row.remoteRunId) {
|
||||
await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(row.remoteRunId)}/cancel`, {}).catch(() => null);
|
||||
}
|
||||
@@ -429,9 +445,10 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
activateRunEntities: async (input: {
|
||||
connectionId: string;
|
||||
runId: string;
|
||||
companyId: string;
|
||||
entityType: CloudUpstreamActivationEntityType;
|
||||
}): Promise<CloudUpstreamRun> => {
|
||||
const row = await getRunRow(input.connectionId, input.runId);
|
||||
const row = await getRunRow(input.connectionId, input.runId, input.companyId);
|
||||
assertActivationEntityType(input.entityType);
|
||||
if (row.status !== "succeeded") {
|
||||
throw badRequest("Only succeeded cloud upstream runs can activate imported entities");
|
||||
@@ -470,26 +487,51 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
if (!row) throw notFound("Company was not found");
|
||||
}
|
||||
|
||||
async function getConnectionRow(connectionId: string): Promise<ConnectionRow> {
|
||||
async function getConnectionRow(connectionId: string, companyId?: string): Promise<ConnectionRow> {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(cloudUpstreamConnections)
|
||||
.where(eq(cloudUpstreamConnections.id, connectionId))
|
||||
.where(companyId
|
||||
? and(eq(cloudUpstreamConnections.id, connectionId), eq(cloudUpstreamConnections.companyId, companyId))
|
||||
: eq(cloudUpstreamConnections.id, connectionId))
|
||||
.then((rows) => rows[0]);
|
||||
if (!row) throw notFound("Cloud upstream connection was not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function getRunRow(connectionId: string, runId: string): Promise<RunRow> {
|
||||
async function getRunRow(connectionId: string, runId: string, companyId: string): Promise<RunRow> {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(cloudUpstreamRuns)
|
||||
.where(and(eq(cloudUpstreamRuns.id, runId), eq(cloudUpstreamRuns.connectionId, connectionId)))
|
||||
.where(and(
|
||||
eq(cloudUpstreamRuns.id, runId),
|
||||
eq(cloudUpstreamRuns.connectionId, connectionId),
|
||||
eq(cloudUpstreamRuns.companyId, companyId),
|
||||
))
|
||||
.then((rows) => rows[0]);
|
||||
if (!row) throw notFound("Cloud upstream run was not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function assertNoRunningRun(
|
||||
connectionId: string,
|
||||
companyId: string,
|
||||
database: Pick<Db, "select">,
|
||||
) {
|
||||
const [running] = await database
|
||||
.select({ id: cloudUpstreamRuns.id })
|
||||
.from(cloudUpstreamRuns)
|
||||
.where(and(
|
||||
eq(cloudUpstreamRuns.connectionId, connectionId),
|
||||
eq(cloudUpstreamRuns.companyId, companyId),
|
||||
eq(cloudUpstreamRuns.status, "running"),
|
||||
))
|
||||
.limit(1);
|
||||
if (running) {
|
||||
throw conflict("A cloud upstream run is already running for this connection", { runId: running.id });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRun(runId: string, patch: Partial<typeof cloudUpstreamRuns.$inferInsert>): Promise<CloudUpstreamRun> {
|
||||
const [updated] = await db
|
||||
.update(cloudUpstreamRuns)
|
||||
@@ -500,6 +542,23 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
|
||||
return runFromRow(updated);
|
||||
}
|
||||
|
||||
async function updateRunIfRunning(runId: string, patch: Partial<typeof cloudUpstreamRuns.$inferInsert>): Promise<CloudUpstreamRun> {
|
||||
const [updated] = await db
|
||||
.update(cloudUpstreamRuns)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(and(eq(cloudUpstreamRuns.id, runId), eq(cloudUpstreamRuns.status, "running")))
|
||||
.returning();
|
||||
if (updated) return runFromRow(updated);
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(cloudUpstreamRuns)
|
||||
.where(eq(cloudUpstreamRuns.id, runId))
|
||||
.limit(1);
|
||||
if (!current) throw notFound("Cloud upstream run was not found");
|
||||
return runFromRow(current);
|
||||
}
|
||||
|
||||
async function localPreview(connection: ConnectionRow): Promise<CloudUpstreamPreview> {
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
@@ -596,13 +655,53 @@ async function fetchDiscovery(remoteUrl: string): Promise<Record<string, unknown
|
||||
if (stackId) {
|
||||
discoveryUrl.searchParams.set("stackId", stackId);
|
||||
}
|
||||
const response = await fetch(discoveryUrl);
|
||||
const response = await fetchWithTimeout(discoveryUrl, undefined, DISCOVERY_FETCH_TIMEOUT_MS);
|
||||
if (!response.ok) {
|
||||
throw badRequest(`Cloud upstream discovery failed: ${response.status}`);
|
||||
}
|
||||
return await response.json() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function reconcileCloudUpstreamRunsOnStartup(db: Db, now = new Date()): Promise<{ reconciled: number }> {
|
||||
const runningRows = await db
|
||||
.select()
|
||||
.from(cloudUpstreamRuns)
|
||||
.where(eq(cloudUpstreamRuns.status, "running"));
|
||||
if (runningRows.length === 0) return { reconciled: 0 };
|
||||
|
||||
for (const row of runningRows) {
|
||||
const report = asRecord(row.report);
|
||||
await db
|
||||
.update(cloudUpstreamRuns)
|
||||
.set({
|
||||
status: "failed",
|
||||
activeStep: row.activeStep,
|
||||
progressPercent: 100,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
events: [
|
||||
...safeRunEvents(row.events),
|
||||
event(
|
||||
now.toISOString(),
|
||||
cloudUpstreamStep(row.activeStep),
|
||||
"failed",
|
||||
"Marked failed on server startup because the previous process stopped while the cloud upstream run was in progress.",
|
||||
),
|
||||
],
|
||||
report: {
|
||||
...report,
|
||||
error: optionalString(report.error) ?? "orphaned_running_run",
|
||||
errorMessage: optionalString(report.errorMessage)
|
||||
?? "The server restarted while this cloud upstream run was running, so Paperclip marked it failed instead of leaving it stuck.",
|
||||
reconciledAt: now.toISOString(),
|
||||
},
|
||||
})
|
||||
.where(eq(cloudUpstreamRuns.id, row.id));
|
||||
}
|
||||
|
||||
return { reconciled: runningRows.length };
|
||||
}
|
||||
|
||||
function firstPathSegment(pathname: string): string | null {
|
||||
const segment = pathname.split("/").find(Boolean);
|
||||
return segment && segment.toLowerCase() !== "dashboard" ? segment : null;
|
||||
@@ -818,20 +917,19 @@ function buildLocalUpstreamExportBundle(input: {
|
||||
entityCount: entities.length,
|
||||
perEntityTypeCounts: countEntityTypesForManifest(entities),
|
||||
entities: entities.map((entity) => entity.record),
|
||||
chunks: chunksWithoutManifestHash.map(({ payload: _payload, manifestHash: _manifestHash, ...chunk }) => chunk),
|
||||
chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk),
|
||||
warnings: input.warnings ?? [],
|
||||
featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
|
||||
};
|
||||
const manifestHash = normalizedContentHash(manifestWithoutHash);
|
||||
const chunks = chunksWithoutManifestHash.map((chunk) => ({ ...chunk, manifestHash }));
|
||||
return {
|
||||
manifest: {
|
||||
...manifestWithoutHash,
|
||||
chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
|
||||
chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => ({ ...chunk, manifestHash })),
|
||||
manifestHash,
|
||||
},
|
||||
entities,
|
||||
chunks,
|
||||
chunks: chunksWithoutManifestHash,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -864,36 +962,37 @@ function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerC
|
||||
totalChunks: groups.length,
|
||||
byteLength: Buffer.byteLength(canonicalJson(payload)),
|
||||
sha256: normalizedContentHash(payload),
|
||||
manifestHash: "sha256:" as NormalizedSha256,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function remoteGet(connection: ConnectionRow, path: string): Promise<unknown> {
|
||||
const response = await fetch(`${connection.targetOrigin}${path}`, {
|
||||
const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, {
|
||||
method: "GET",
|
||||
headers: proofHeaders(connection, "GET", path),
|
||||
});
|
||||
headers: await proofHeaders(connection, "GET", path),
|
||||
}, REMOTE_FETCH_TIMEOUT_MS);
|
||||
return parseRemoteResponse(response);
|
||||
}
|
||||
|
||||
async function remotePost(connection: ConnectionRow, path: string, body: unknown): Promise<unknown> {
|
||||
const response = await fetch(`${connection.targetOrigin}${path}`, {
|
||||
const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...proofHeaders(connection, "POST", path),
|
||||
...await proofHeaders(connection, "POST", path),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, REMOTE_FETCH_TIMEOUT_MS);
|
||||
return parseRemoteResponse(response);
|
||||
}
|
||||
|
||||
function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch: string): Record<string, string> {
|
||||
async function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch: string): Promise<Record<string, string>> {
|
||||
if (!connection.accessToken || !connection.tokenId) {
|
||||
throw badRequest("Cloud upstream connection is missing an import token");
|
||||
}
|
||||
const accessToken = await unsealCloudUpstreamCredential(connection.accessToken);
|
||||
const privateKeyPem = await unsealCloudUpstreamCredential(connection.privateKeyPem);
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = crypto.randomUUID();
|
||||
const payload = [
|
||||
@@ -906,14 +1005,14 @@ function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch:
|
||||
nonce,
|
||||
].join("\n");
|
||||
return {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
|
||||
"X-Paperclip-Upstream-Proof-Timestamp": timestamp,
|
||||
"X-Paperclip-Upstream-Proof-Nonce": nonce,
|
||||
"X-Paperclip-Upstream-Proof-Signature": sign(
|
||||
null,
|
||||
Buffer.from(payload, "utf8"),
|
||||
connection.privateKeyPem,
|
||||
privateKeyPem,
|
||||
).toString("base64url"),
|
||||
};
|
||||
}
|
||||
@@ -930,6 +1029,32 @@ async function parseRemoteResponse(response: Response): Promise<unknown> {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit | undefined, timeoutMs: number): Promise<Response> {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sealCloudUpstreamCredential(value: string): Promise<string> {
|
||||
const prepared = await localEncryptedProvider.createSecret({ value });
|
||||
return `${CLOUD_CREDENTIAL_PREFIX}${JSON.stringify(prepared.material)}`;
|
||||
}
|
||||
|
||||
export async function unsealCloudUpstreamCredential(value: string): Promise<string> {
|
||||
if (!value.startsWith(CLOUD_CREDENTIAL_PREFIX)) return value;
|
||||
const encoded = value.slice(CLOUD_CREDENTIAL_PREFIX.length);
|
||||
const parsed = safeParseJson(encoded);
|
||||
const material = optionalObject(parsed);
|
||||
if (!material) {
|
||||
throw badRequest("Invalid encrypted cloud upstream credential material");
|
||||
}
|
||||
return localEncryptedProvider.resolveVersion({
|
||||
material,
|
||||
externalRef: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function cloudUpstreamRemoteFailureReport(error: unknown): {
|
||||
error: string;
|
||||
errorMessage?: string;
|
||||
@@ -957,11 +1082,11 @@ function remoteErrorBody(details: unknown): { error?: string; message?: string }
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, DISCOVERY_FETCH_TIMEOUT_MS);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`);
|
||||
@@ -1026,6 +1151,10 @@ function eventsFromRemote(value: unknown): CloudUpstreamRunEvent[] {
|
||||
});
|
||||
}
|
||||
|
||||
function safeRunEvents(value: unknown): CloudUpstreamRunEvent[] {
|
||||
return Array.isArray(value) ? value as CloudUpstreamRunEvent[] : [];
|
||||
}
|
||||
|
||||
function assertActivationEntityType(value: string): asserts value is CloudUpstreamActivationEntityType {
|
||||
if (value !== "agents" && value !== "routines" && value !== "monitors") {
|
||||
throw badRequest("entityType must be agents, routines, or monitors");
|
||||
|
||||
@@ -265,7 +265,17 @@ export function companyService(db: Db) {
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
// Delete from child tables in dependency order
|
||||
const companyRunIds = await tx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
if (companyRunIds.length > 0) {
|
||||
await tx
|
||||
.delete(heartbeatRunEvents)
|
||||
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
|
||||
}
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
@@ -59,7 +59,7 @@ export type {
|
||||
} from "./authorization.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { cloudUpstreamService } from "./cloud-upstreams.js";
|
||||
export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./cloud-upstreams.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { environmentService } from "./environments.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
|
||||
@@ -437,6 +437,14 @@ export function createPluginWorkerHandle(
|
||||
childProcess.stdin.write(serialized);
|
||||
}
|
||||
|
||||
function errorCodeForWorkerHostError(err: unknown): number {
|
||||
const code = (err as { code?: unknown } | null)?.code;
|
||||
const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES);
|
||||
return typeof code === "number" && pluginErrorCodes.includes(code)
|
||||
? code
|
||||
: JSONRPC_ERROR_CODES.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Incoming message handling
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -505,6 +513,11 @@ export function createPluginWorkerHandle(
|
||||
const directCompanyId = readNonEmptyString(params.companyId);
|
||||
if (directCompanyId) return { companyId: directCompanyId };
|
||||
|
||||
if (method === "performAction" && isRecord(params.actorContext)) {
|
||||
const companyId = readNonEmptyString(params.actorContext.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
}
|
||||
|
||||
if (method === "executeTool" && isRecord(params.runContext)) {
|
||||
const companyId = readNonEmptyString(params.runContext.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
@@ -589,15 +602,12 @@ export function createPluginWorkerHandle(
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorCode = typeof (err as { code?: unknown }).code === "number"
|
||||
? (err as { code: number }).code
|
||||
: JSONRPC_ERROR_CODES.INTERNAL_ERROR;
|
||||
log.error({ method, err: errorMessage }, "host handler error");
|
||||
try {
|
||||
sendMessage(
|
||||
createErrorResponse(
|
||||
request.id,
|
||||
errorCode,
|
||||
errorCodeForWorkerHostError(err),
|
||||
errorMessage,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ function boardRoutes() {
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
|
||||
@@ -15,23 +15,23 @@ export const cloudUpstreamsApi = {
|
||||
api.post<CloudUpstreamConnectStartResponse>("/cloud-upstreams/connect/start", input),
|
||||
finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) =>
|
||||
api.post<CloudUpstreamConnection>("/cloud-upstreams/connect/finish", input),
|
||||
preview: (connectionId: string) =>
|
||||
api.post<CloudUpstreamPreview>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, {}),
|
||||
createRun: (connectionId: string, input?: { retryOfRunId?: string | null }) =>
|
||||
preview: (connectionId: string, input: { companyId: string }) =>
|
||||
api.post<CloudUpstreamPreview>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, input),
|
||||
createRun: (connectionId: string, input: { companyId: string; retryOfRunId?: string | null }) =>
|
||||
api.post<CloudUpstreamRun>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
|
||||
getRun: (connectionId: string, runId: string) =>
|
||||
getRun: (connectionId: string, runId: string, companyId: string) =>
|
||||
api.get<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}`,
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}?companyId=${encodeURIComponent(companyId)}`,
|
||||
),
|
||||
cancelRun: (connectionId: string, runId: string) =>
|
||||
cancelRun: (connectionId: string, runId: string, input: { companyId: string }) =>
|
||||
api.post<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
|
||||
{},
|
||||
input,
|
||||
),
|
||||
activateEntities: (
|
||||
connectionId: string,
|
||||
runId: string,
|
||||
input: { entityType: CloudUpstreamActivationEntityType },
|
||||
input: { companyId: string; entityType: CloudUpstreamActivationEntityType },
|
||||
) =>
|
||||
api.post<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/activation`,
|
||||
|
||||
@@ -108,6 +108,9 @@ describe("CompanySettingsSidebar", () => {
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -137,6 +140,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
@@ -258,4 +262,36 @@ describe("CompanySettingsSidebar", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cloud upstream only when cloud sync is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: true,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Cloud upstream");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/cloud-upstream",
|
||||
label: "Cloud upstream",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -361,7 +361,7 @@ describe("Layout", () => {
|
||||
expect(selector?.textContent).toContain("General");
|
||||
expect(selector?.textContent).toContain("Environments");
|
||||
expect(selector?.textContent).toContain("Cloud upstream");
|
||||
expect(selector?.textContent).toContain("Access");
|
||||
expect(selector?.textContent).toContain("Members");
|
||||
expect(selector?.textContent).toContain("Invites");
|
||||
expect(selector?.textContent).toContain("Secrets");
|
||||
|
||||
|
||||
@@ -110,21 +110,6 @@ function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProper
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCodeBlockStyle(
|
||||
style: React.CSSProperties | undefined,
|
||||
wrapped: boolean,
|
||||
): React.CSSProperties {
|
||||
if (!wrapped) return mergeScrollableBlockStyle(style);
|
||||
return {
|
||||
...style,
|
||||
maxWidth: "100%",
|
||||
overflowX: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
}
|
||||
|
||||
function flattenText(value: ReactNode): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
@@ -425,11 +410,24 @@ function CodeBlock({
|
||||
<pre
|
||||
{...preProps}
|
||||
ref={preRef}
|
||||
style={mergeCodeBlockStyle(preProps.style as React.CSSProperties | undefined, wrapLines)}
|
||||
style={{
|
||||
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
|
||||
...(wrapLines
|
||||
? {
|
||||
overflowX: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "break-word",
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
<div className="paperclip-markdown-codeblock-actions">
|
||||
<div
|
||||
className="paperclip-markdown-codeblock-actions"
|
||||
data-active={copied || failed || wrapLines || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
|
||||
@@ -60,12 +60,17 @@ describe("MarkdownBody code block wrapping", () => {
|
||||
});
|
||||
|
||||
const pre = container.querySelector("pre");
|
||||
const actions = container.querySelector<HTMLDivElement>(
|
||||
".paperclip-markdown-codeblock-actions",
|
||||
);
|
||||
const wrapButton = container.querySelector<HTMLButtonElement>(
|
||||
".paperclip-markdown-codeblock-wrap",
|
||||
);
|
||||
|
||||
expect(pre).not.toBeNull();
|
||||
expect(actions).not.toBeNull();
|
||||
expect(wrapButton).not.toBeNull();
|
||||
expect(actions?.getAttribute("data-active")).toBeNull();
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
expect(pre?.style.overflowX).toBe("auto");
|
||||
@@ -77,6 +82,7 @@ describe("MarkdownBody code block wrapping", () => {
|
||||
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
|
||||
expect(actions?.getAttribute("data-active")).toBe("true");
|
||||
expect(pre?.style.overflowX).toBe("hidden");
|
||||
expect(pre?.style.whiteSpace).toBe("pre-wrap");
|
||||
expect(pre?.style.overflowWrap).toBe("anywhere");
|
||||
@@ -87,6 +93,7 @@ describe("MarkdownBody code block wrapping", () => {
|
||||
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
expect(actions?.getAttribute("data-active")).toBeNull();
|
||||
expect(pre?.style.overflowX).toBe("auto");
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
});
|
||||
|
||||
@@ -89,14 +89,14 @@ describe("CompanySettingsNav", () => {
|
||||
expect(pageTabBarMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "members",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
],
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+1
-2
@@ -752,8 +752,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
|
||||
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
|
||||
.paperclip-markdown-codeblock-actions:focus-within,
|
||||
.paperclip-markdown-codeblock-action[data-copied],
|
||||
.paperclip-markdown-codeblock-action[data-active] {
|
||||
.paperclip-markdown-codeblock-actions[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
|
||||
@@ -65,6 +64,12 @@ vi.mock("@/lib/router", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
await callback();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -142,7 +147,7 @@ describe("CloudUpstream", () => {
|
||||
expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
|
||||
"connection-1",
|
||||
"run-1",
|
||||
{ entityType: "agents" },
|
||||
{ companyId: "company-1", entityType: "agents" },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -260,6 +265,39 @@ describe("CloudUpstream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry the OAuth callback finish mutation after an error", async () => {
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("state expired");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps retry only for failed or cancelled runs", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -119,16 +119,22 @@ export function CloudUpstream() {
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
|
||||
});
|
||||
const {
|
||||
mutate: finishConnect,
|
||||
isError: finishConnectFailed,
|
||||
isPending: finishConnectPending,
|
||||
isSuccess: finishConnectSucceeded,
|
||||
} = finishMutation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudSyncEnabled || !code || !state || finishMutation.isPending || finishMutation.isSuccess) return;
|
||||
if (!cloudSyncEnabled || !code || !state || finishConnectPending || finishConnectSucceeded || finishConnectFailed) return;
|
||||
const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
|
||||
if (!pendingConnectionId) {
|
||||
setActionError("No pending cloud upstream connection was found. Start the connection again.");
|
||||
return;
|
||||
}
|
||||
finishMutation.mutate({ pendingConnectionId, code, state });
|
||||
}, [cloudSyncEnabled, code, state, finishMutation]);
|
||||
finishConnect({ pendingConnectionId, code, state });
|
||||
}, [cloudSyncEnabled, code, finishConnect, finishConnectFailed, finishConnectPending, finishConnectSucceeded, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackError) {
|
||||
@@ -152,7 +158,8 @@ export function CloudUpstream() {
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (connectionId: string) => cloudUpstreamsApi.preview(connectionId),
|
||||
mutationFn: (input: { connectionId: string; companyId: string }) =>
|
||||
cloudUpstreamsApi.preview(input.connectionId, { companyId: input.companyId }),
|
||||
onSuccess: (nextPreview) => {
|
||||
setPreview(nextPreview);
|
||||
setActionError(null);
|
||||
@@ -161,8 +168,11 @@ export function CloudUpstream() {
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (input: { connectionId: string; retryOfRunId?: string | null }) =>
|
||||
cloudUpstreamsApi.createRun(input.connectionId, { retryOfRunId: input.retryOfRunId ?? null }),
|
||||
mutationFn: (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }) =>
|
||||
cloudUpstreamsApi.createRun(input.connectionId, {
|
||||
companyId: input.companyId,
|
||||
retryOfRunId: input.retryOfRunId ?? null,
|
||||
}),
|
||||
onSuccess: async (run) => {
|
||||
setActiveRun(run);
|
||||
setNotice(run.status === "succeeded"
|
||||
@@ -175,7 +185,10 @@ export function CloudUpstream() {
|
||||
});
|
||||
const activationMutation = useMutation({
|
||||
mutationFn: (input: { run: CloudUpstreamRun; entityType: CloudUpstreamActivationEntityType }) =>
|
||||
cloudUpstreamsApi.activateEntities(input.run.connectionId, input.run.id, { entityType: input.entityType }),
|
||||
cloudUpstreamsApi.activateEntities(input.run.connectionId, input.run.id, {
|
||||
companyId: input.run.companyId,
|
||||
entityType: input.entityType,
|
||||
}),
|
||||
onSuccess: async (run) => {
|
||||
setActiveRun(run);
|
||||
setNotice("Activation checklist updated.");
|
||||
@@ -271,7 +284,7 @@ export function CloudUpstream() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => previewMutation.mutate(connection.id)}
|
||||
onClick={() => previewMutation.mutate({ connectionId: connection.id, companyId: connection.companyId })}
|
||||
disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
|
||||
>
|
||||
{previewMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||
@@ -302,7 +315,7 @@ export function CloudUpstream() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
|
||||
<Button
|
||||
onClick={() => runMutation.mutate({ connectionId: preview.connectionId })}
|
||||
onClick={() => runMutation.mutate({ connectionId: preview.connectionId, companyId: preview.sourceCompanyId })}
|
||||
disabled={runMutation.isPending || !preview.schemaCompatible}
|
||||
>
|
||||
{runMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
|
||||
@@ -328,7 +341,11 @@ export function CloudUpstream() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, retryOfRunId: latestRun.id })}
|
||||
onClick={() => runMutation.mutate({
|
||||
connectionId: latestRun.connectionId,
|
||||
companyId: latestRun.companyId,
|
||||
retryOfRunId: latestRun.id,
|
||||
})}
|
||||
disabled={runMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
@@ -338,7 +355,7 @@ export function CloudUpstream() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId })}
|
||||
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, companyId: latestRun.companyId })}
|
||||
disabled={runMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
|
||||
Reference in new issue
Block a user