This commit is contained in:
Michel Tomas 2026-09-13 10:09:13 +00:00 committed by GitHub
commit 7a7167d829
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 128 additions and 30 deletions

View File

@ -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)

View File

@ -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) {

View File

@ -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);
}

View File

@ -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();

View File

@ -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,

View File

@ -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);
});

View File

@ -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);

View File

@ -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;
}

View File

@ -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) {