Merge 6602bd6445 into c9e3bb7ca4
This commit is contained in:
commit
7a7167d829
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue