diff --git a/scripts/link-plugin-dev-sdk.mjs b/scripts/link-plugin-dev-sdk.mjs index 512b67f860..2f943997f1 100644 --- a/scripts/link-plugin-dev-sdk.mjs +++ b/scripts/link-plugin-dev-sdk.mjs @@ -43,8 +43,16 @@ export function linkExcludedPlugins() { // repo-internal scripts (e.g. the standalone package builder) can relink a // single package after a fresh install. if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const { linked, skipped } = linkExcludedPlugins(); - console.log(` ✓ Linked @paperclipai/plugin-sdk into ${linked} excluded plugin(s) (skipped ${skipped})`); + if (process.argv.length > 2) { + const packageDir = resolve(process.argv[2]); + if (process.argv.length !== 3 || !excludedPluginDirs().includes(packageDir)) { + throw new Error("SDK linking target must be an excluded repository plugin package"); + } + linkSdkInto(packageDir); + } else { + const { linked, skipped } = linkExcludedPlugins(); + console.log(` ✓ Linked @paperclipai/plugin-sdk into ${linked} excluded plugin(s) (skipped ${skipped})`); + } } // Recursively collect package directories (those containing a package.json) diff --git a/scripts/link-plugin-dev-sdk.test.js b/scripts/link-plugin-dev-sdk.test.js index 178a16acfc..7e6830ea9b 100644 --- a/scripts/link-plugin-dev-sdk.test.js +++ b/scripts/link-plugin-dev-sdk.test.js @@ -1,19 +1,49 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { after, before, test } from "node:test"; import { linkSdkInto, readPluginsUnder } from "./link-plugin-dev-sdk.mjs"; let workDir; +let repoPluginDir; before(() => { workDir = mkdtempSync(join(tmpdir(), "link-plugin-dev-sdk-")); + const script = fileURLToPath(new URL("./link-plugin-dev-sdk.mjs", import.meta.url)); + const providers = join(dirname(script), "..", "packages", "plugins", "sandbox-providers"); + repoPluginDir = mkdtempSync(join(providers, "plugin-link-test-")); }); after(() => { rmSync(workDir, { force: true, recursive: true }); + if (repoPluginDir) rmSync(repoPluginDir, { force: true, recursive: true }); +}); + +test("single-package CLI links only the requested excluded plugin", () => { + const script = fileURLToPath(new URL("./link-plugin-dev-sdk.mjs", import.meta.url)); + const target = makePackage(join(repoPluginDir, "target")); + const sibling = makePackage(join(repoPluginDir, "sibling")); + const result = spawnSync(process.execPath, [script, target], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.ok(lstatSync(join(target, "node_modules", "@paperclipai", "plugin-sdk")).isSymbolicLink()); + assert.equal(existsSync(join(sibling, "node_modules")), false); +}); + +test("single-package CLI rejects outside packages and symlinked provider directories", () => { + const script = fileURLToPath(new URL("./link-plugin-dev-sdk.mjs", import.meta.url)); + const outside = makePackage(join(workDir, "outside-cli")); + const alias = join(repoPluginDir, "alias"); + symlinkSync(outside, alias, "dir"); + for (const target of [outside, alias]) { + const result = spawnSync(process.execPath, [script, target], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /target must be an excluded repository plugin/); + assert.equal(existsSync(join(outside, "node_modules")), false); + } }); function makePackage(dir) { diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 27009fa58d..c94734cf22 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -14024,13 +14024,15 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .select() .from(chatConversations) .where(eq(chatConversations.endpointId, endpoint.id)); + // Eight ordered admissions commit separately. Allow the asynchronous drain + // to finish; the default one-second wait can observe only its first half. await vi.waitFor(async () => { const rows = await db .select({ id: issueComments.id }) .from(issueComments) .where(eq(issueComments.issueId, conversation.issueId)); expect(rows).toHaveLength(8); - }); + }, { timeout: 5_000 }); const comments = await db .select({ id: issueComments.id, body: issueComments.body }) .from(issueComments) @@ -45434,6 +45436,17 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ), ); if (!action) throw new Error("Expected admitted source action"); + if (admitted) { + // An admitted action can be visible before its scheduler receipt + // commits. The synthetic run must reference that durable receipt. + await vi.waitFor(async () => { + const [receipt] = await db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, action.id)); + expect(receipt?.id).toBe(action.id); + }, { timeout: 5_000 }); + } const runId = randomUUID(); // The fixture heartbeat does not execute a model. Persist the exact // scheduler linkage for the already admitted source, then exercise @@ -59560,7 +59573,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect( deliveries.every((delivery) => delivery.state === "processed"), ).toBe(true); - }); + }, { timeout: 5_000 }); const [conversation] = await db .select() @@ -59588,7 +59601,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ).resolves.toMatchObject({ ok: true }); expect(deferred).toHaveLength(1); await drainDeferred(); - await vi.waitFor(() => expect(deferred).toHaveLength(1)); + await vi.waitFor(() => expect(deferred).toHaveLength(1), { timeout: 5_000 }); await drainDeferred(); await vi.waitFor(async () => { await expect( @@ -59614,7 +59627,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { state: "filtered", }, ]); - }); + }, { timeout: 5_000 }); await expect( db .select({ body: issueComments.body }) @@ -61092,7 +61105,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await vi.waitFor(() => expect(dm.post).toHaveBeenCalledWith(visibleFailure), ); - await expect( + // Provider output is observable before its durable action is settled. + await vi.waitFor(() => expect( db .select({ kind: chatActions.kind, status: chatActions.status }) .from(chatActions) @@ -61102,7 +61116,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { kind: "inbound_wakeup", status: "failed" }, { kind: "provider_effect", status: "processed" }, ]), - ); + ), { timeout: 5_000 }); } finally { await retirePublicationFixture(service, endpoint.id); } diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index 92dd1f3217..1115d0251f 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { companies, createDb, @@ -86,6 +86,37 @@ describeEmbeddedPostgres("documentService system issue documents", () => { return { issueId }; } + it.each([ + "issue_documents_company_issue_key_uq", + "issue_documents_document_uq", + "document_revisions_document_revision_uq", + ])("only translates document-key conflicts for %s", async (constraintName) => { + const { issueId } = await createIssueWithDocuments(); + const failure = new Error("Failed query", { + cause: { code: "23505", constraint_name: constraintName }, + }); + const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(failure); + try { + const result = svc.upsertIssueDocument({ + issueId, + key: "plan", + format: "markdown", + body: "Updated plan", + }); + if (constraintName === "issue_documents_company_issue_key_uq") { + await expect(result).rejects.toMatchObject({ + status: 409, + message: "Document key already exists on this issue", + details: { key: "plan" }, + }); + } else { + await expect(result).rejects.toBe(failure); + } + } finally { + transaction.mockRestore(); + } + }); + it("filters continuation summaries from default document lists and issue payload summaries", async () => { const { issueId } = await createIssueWithDocuments(); diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index d7a8fe5e2b..5daa683284 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -114,11 +114,9 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = } await new Promise((resolve) => setTimeout(resolve, 50)); } - const runIds = await db - .select({ id: heartbeatRuns.id }) - .from(heartbeatRuns) - .then((runs) => runs.map((run) => run.id)); - await Promise.all(runIds.map((runId) => heartbeat.waitForRunExecutionDrain(runId))); + // The live-run registry can clear before trailing event writes finish. + // Await the execution promises themselves before deleting their rows. + await heartbeat.drainActiveRunExecutions(); mockAdapterExecute.mockReset(); mockAdapterExecute.mockImplementation(async () => ({ exitCode: 0, diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index fc71329a30..865f4cb6dc 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -193,7 +193,9 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { } await new Promise((resolve) => setTimeout(resolve, 50)); } - await new Promise((resolve) => setTimeout(resolve, 50)); + // Terminal run status can precede final event writes and follow-up wakeups. + // Join those writers before TRUNCATE acquires locks on their tables. + await heartbeat.drainActiveRunExecutions(); await cleanupHeartbeatInvalidationFixture(db); }); diff --git a/server/src/__tests__/plugin-install-autobuild.test.ts b/server/src/__tests__/plugin-install-autobuild.test.ts index d9d3a59961..890b9b90e2 100644 --- a/server/src/__tests__/plugin-install-autobuild.test.ts +++ b/server/src/__tests__/plugin-install-autobuild.test.ts @@ -65,12 +65,11 @@ async function createBundledPluginFixture( const pluginKey = `paperclip.${slug.replace(/^plugin-/, "").replace(/-/g, "_")}`; const packageRoot = path.join(options.rootDir ?? repoPluginRoot, slug); const distDir = path.join(packageRoot, "dist"); - const isStandaloneFixture = (options.rootDir ?? repoPluginRoot) === standaloneRepoPluginRoot; - const postinstallScript = isStandaloneFixture - ? `node ${path.relative(packageRoot, path.join(REPO_ROOT, "scripts", "link-plugin-dev-sdk.mjs"))}` - : null; await mkdir(path.join(packageRoot, "scripts"), { recursive: true }); + if ((options.rootDir ?? repoPluginRoot) === standaloneRepoPluginRoot) { + await writeFile(path.join(packageRoot, ".npmrc"), "ignore-scripts=true\n"); + } await writeFile( path.join(packageRoot, "package.json"), JSON.stringify({ @@ -79,7 +78,6 @@ async function createBundledPluginFixture( private: true, type: "module", scripts: { - ...(postinstallScript ? { postinstall: postinstallScript } : {}), build: "node ./scripts/build.mjs", }, paperclipPlugin: { @@ -241,7 +239,7 @@ describe("ensureLocalPluginBuilt", () => { { execFileAsyncImpl: execStub }, ); - expect(execStub).toHaveBeenCalledTimes(2); + expect(execStub).toHaveBeenCalledTimes(3); expect(execStub).toHaveBeenNthCalledWith( 1, "pnpm", @@ -250,6 +248,12 @@ describe("ensureLocalPluginBuilt", () => { ); expect(execStub).toHaveBeenNthCalledWith( 2, + process.execPath, + [path.join(REPO_ROOT, "scripts", "link-plugin-dev-sdk.mjs"), fixture.packageRoot], + { cwd: fixture.packageRoot, timeout: 120_000 }, + ); + expect(execStub).toHaveBeenNthCalledWith( + 3, "pnpm", ["build"], { cwd: fixture.packageRoot, timeout: 120_000 }, @@ -273,13 +277,19 @@ describe("ensureLocalPluginBuilt", () => { { execFileAsyncImpl: execStub }, ); - expect(execStub).toHaveBeenCalledTimes(1); + expect(execStub).toHaveBeenCalledTimes(2); expect(execStub).toHaveBeenNthCalledWith( 1, "pnpm", ["install", "--ignore-workspace", "--no-lockfile"], { cwd: fixture.packageRoot, timeout: 120_000 }, ); + expect(execStub).toHaveBeenNthCalledWith( + 2, + process.execPath, + [path.join(REPO_ROOT, "scripts", "link-plugin-dev-sdk.mjs"), fixture.packageRoot], + { cwd: fixture.packageRoot, timeout: 120_000 }, + ); }); }); @@ -401,7 +411,7 @@ describeEmbeddedPostgres("plugin install auto-build route", () => { expect(res.status).toBe(400); expect(res.body.error).toContain("does not appear to be a Paperclip plugin (no manifest found)"); expect(res.body.error).toContain(path.relative(REPO_ROOT, fixture.packageRoot)); - expect(res.body.error).toContain("pnpm install --ignore-workspace --no-lockfile && pnpm build"); + expect(res.body.error).toContain("pnpm install --ignore-workspace --no-lockfile && node ../../../../scripts/link-plugin-dev-sdk.mjs . && pnpm build"); expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(false); expect(mockLifecycle.load).not.toHaveBeenCalled(); }, 20_000); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 9517456b53..1ee5dcc2f2 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { isUniqueViolation } from "../db-errors.js"; import { insertRowsInChunks } from "./batch-insert.js"; import type { ImportIssueDocumentRow } from "./import-write-types.js"; @@ -16,10 +17,6 @@ function normalizeDocumentKey(key: string) { return parsed.data; } -function isUniqueViolation(error: unknown): boolean { - return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505"; -} - function nextAvailableDocumentKey(sourceKey: string, existingKeys: string[]) { const usedKeys = new Set(existingKeys); for (let index = 2; index < 1000; index += 1) { @@ -502,7 +499,7 @@ export function documentService(db: Db) { }; }); } catch (error) { - if (isUniqueViolation(error)) { + if (isUniqueViolation(error, "issue_documents_company_issue_key_uq")) { if (input.lockedDocumentStrategy === "create_new_document" && attempt < maxAttempts - 1) { continue; } diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index a72e5db2ef..706baeb842 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -815,7 +815,8 @@ function buildLocalPluginRecoveryCommand( const repoRoot = options.repoRoot ?? REPO_ROOT; const relativePath = path.relative(repoRoot, packageRoot) || "."; const installCommand = buildStandaloneBundledPluginInstallCommand(packageRoot); - return `cd ${relativePath} && ${installCommand} && pnpm build`; + const sdkLinkScript = path.relative(packageRoot, path.join(repoRoot, "scripts", "link-plugin-dev-sdk.mjs")); + return `cd ${relativePath} && ${installCommand} && node ${sdkLinkScript} . && pnpm build`; } return buildLocalPluginBuildCommand(pkgJson); @@ -842,6 +843,13 @@ function buildLocalPluginBuildCommands( args: buildStandaloneBundledPluginInstallArgs(packageRoot), cwd: packageRoot, }); + // Local SDK linking is a repository bootstrap step. It must also work + // when the operator disables dependency lifecycle scripts. + commands.push({ + file: process.execPath, + args: [path.join(options.repoRoot ?? REPO_ROOT, "scripts", "link-plugin-dev-sdk.mjs"), packageRoot], + cwd: packageRoot, + }); } if (options.needsBuild !== false) {