mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Merge mainline and record final Cursor installed-release qualification
Resolve the two Runner fixture conflicts while preserving selected native response waits. Record all 18 passing local/Daytona cells, five original failed attempts, immutable package/image identities and clean installation evidence. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
67aff7ad16
101 files changed
+2592
-255
No files matched your search
@@ -101,7 +101,7 @@ async function requestApp(
|
||||
}
|
||||
}
|
||||
|
||||
describe.sequential("activity routes", () => {
|
||||
describe("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mockActivityService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
|
||||
|
||||
@@ -150,7 +150,7 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]);
|
||||
});
|
||||
|
||||
it("returns compact usage and result summaries for issue runs", async () => {
|
||||
it.each([null, "native_provider_model_rejected", "adapter_failed"])("returns compact issue runs with bounded model rejection details: %s", async (errorCode) => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
@@ -180,7 +180,9 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
status: errorCode ? "failed" : "succeeded",
|
||||
errorCode,
|
||||
error: "provider-error".repeat(200),
|
||||
contextSnapshot: { issueId },
|
||||
usageJson: {
|
||||
inputTokens: 11,
|
||||
@@ -215,6 +217,7 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
contextIssueId: issueId,
|
||||
error: errorCode === "native_provider_model_rejected" ? "provider-error".repeat(200).slice(0, 2000) : null,
|
||||
});
|
||||
expect(runs[0]?.usageJson).toEqual({
|
||||
inputTokens: 11,
|
||||
|
||||
@@ -258,7 +258,7 @@ function resetInstalledExternalAdapterState() {
|
||||
setOverridePaused("claude_local", false);
|
||||
}
|
||||
|
||||
describe.sequential("adapter management route authorization", () => {
|
||||
describe("adapter management route authorization", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/adapter-plugin-store.js");
|
||||
|
||||
@@ -343,7 +343,7 @@ function resetMockDefaults() {
|
||||
mockLogActivity.mockImplementation(async () => undefined);
|
||||
}
|
||||
|
||||
describe.sequential("agent cross-tenant route authorization", () => {
|
||||
describe("agent cross-tenant route authorization", () => {
|
||||
beforeEach(() => {
|
||||
resetMockDefaults();
|
||||
});
|
||||
|
||||
@@ -267,7 +267,7 @@ async function requestApp(
|
||||
}
|
||||
}
|
||||
|
||||
describe.sequential("agent permission routes", () => {
|
||||
describe("agent permission routes", () => {
|
||||
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
|
||||
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
||||
@@ -249,7 +249,7 @@ function makeAgent(adapterType: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe.sequential("agent skill routes", () => {
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
|
||||
@@ -51,7 +51,7 @@ function createApp(actor: Express.Request["actor"], row: Record<string, unknown>
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.sequential("auth routes", () => {
|
||||
describe("auth routes", () => {
|
||||
const baseUser = {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
|
||||
@@ -37,7 +37,7 @@ import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -198,7 +198,7 @@ const embeddedPostgresSupport = externalTestDatabaseUrl
|
||||
? { supported: true }
|
||||
: await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe.sequential
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
@@ -1088,7 +1088,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: `Chat Test ${companyId.slice(0, 8)}`,
|
||||
issuePrefix: `C${companyId.replaceAll("-", "").slice(0, 7).toUpperCase()}`,
|
||||
issuePrefix: `C${companyId.replace(/-/g, "").toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const now = new Date();
|
||||
|
||||
@@ -90,7 +90,7 @@ async function createApp(actor: any, db: any = {} as any) {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.sequential("cli auth routes", () => {
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CloudPortfolioError } from "../services/cloud-portfolio-error.js";
|
||||
|
||||
const input = { phase: "fetch" as const, elapsedMs: 270.2, upstreamStatus: null };
|
||||
|
||||
describe("CloudPortfolioError", () => {
|
||||
it("retains only an allowlisted nested network code, not the original exception", () => {
|
||||
const cause = Object.assign(new Error("private upstream payload"), { code: "ECONNRESET", url: "https://private.example.test" });
|
||||
const error = new CloudPortfolioError("upstream", input, new TypeError("fetch failed", { cause }));
|
||||
expect(error.diagnostics).toEqual({ phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" });
|
||||
expect(error).not.toHaveProperty("cause");
|
||||
expect(JSON.stringify(error)).not.toContain("private");
|
||||
expect(Object.isFrozen(error.diagnostics)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Error("ECONNRESET in a private message is not evidence"),
|
||||
{ code: "private-provider-code" },
|
||||
{ get code() { throw new Error("private getter"); }, get cause() { throw new Error("private getter"); } },
|
||||
new Proxy({}, { getOwnPropertyDescriptor() { throw new Error("private proxy"); } }),
|
||||
])("fails closed for unknown errors without invoking getters", (cause) => {
|
||||
expect(new CloudPortfolioError("upstream", input, cause).diagnostics.networkCode).toBe("unknown");
|
||||
});
|
||||
|
||||
it("bounds cause traversal and handles cycles", () => {
|
||||
const cycle: { cause?: unknown } = {};
|
||||
cycle.cause = cycle;
|
||||
expect(new CloudPortfolioError("upstream", input, cycle).diagnostics.networkCode).toBe("unknown");
|
||||
const deep = { cause: { cause: { cause: { cause: { code: "ECONNRESET" } } } } };
|
||||
expect(new CloudPortfolioError("upstream", input, deep).diagnostics.networkCode).toBe("unknown");
|
||||
});
|
||||
|
||||
it("normalizes unsafe diagnostics and keeps non-network phases distinct", () => {
|
||||
const error = new CloudPortfolioError("upstream", {
|
||||
phase: "private-phase" as never, elapsedMs: Infinity, upstreamStatus: 900,
|
||||
}, { code: "ECONNRESET" });
|
||||
expect(error.diagnostics).toEqual({ phase: "unknown", elapsedMs: null, upstreamStatus: null, networkCode: "unknown" });
|
||||
for (const phase of ["http_response", "response_write"] as const) {
|
||||
expect(new CloudPortfolioError("upstream", { ...input, phase, upstreamStatus: 503, deadlineExceeded: true }, { code: "ECONNRESET" }).diagnostics)
|
||||
.toMatchObject({ phase, upstreamStatus: 503, networkCode: "unknown" });
|
||||
}
|
||||
expect(new CloudPortfolioError("upstream", { ...input, elapsedMs: 60_001 }).diagnostics.elapsedMs).toBeNull();
|
||||
expect(new CloudPortfolioError("upstream", { ...input, elapsedMs: -1 }).diagnostics.elapsedMs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,13 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { cloudRoutes } from "../routes/cloud.js";
|
||||
import { CloudPortfolioError } from "../services/cloud-portfolio-error.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const cloudEnv = {
|
||||
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-secret",
|
||||
@@ -51,6 +56,98 @@ function jsonResponse(payload: unknown, status = 200) {
|
||||
}
|
||||
|
||||
describe("GET /api/cloud/stacks", () => {
|
||||
it.each([401, 403, 429, 503])("preserves the response contract for upstream HTTP %s without reading the body", async (status) => {
|
||||
const upstream = jsonResponse({ secret: "private upstream payload" }, status);
|
||||
const json = vi.spyOn(upstream, "json");
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
|
||||
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks").set("Cookie", "client-session=private-cookie");
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(new Headers(fetchImpl.mock.calls[0]![1]!.headers).has("cookie")).toBe(false);
|
||||
expect(json).not.toHaveBeenCalled();
|
||||
expect(capture).toHaveBeenCalledWith(expect.any(CloudPortfolioError));
|
||||
const error = capture.mock.calls[0]![0] as CloudPortfolioError;
|
||||
expect(error.diagnostics).toMatchObject({ phase: "http_response", upstreamStatus: status, networkCode: "unknown", elapsedMs: expect.any(Number) });
|
||||
expect(error.stack).toContain("routes/cloud.ts:");
|
||||
expect(error.stack).not.toContain("at portfolioError");
|
||||
expect(warn).toHaveBeenCalledWith({ cloudPortfolio: error.diagnostics }, "Paperclip Cloud portfolio request failed");
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("private");
|
||||
});
|
||||
|
||||
it("classifies a fetch reset without retrying or caching the failure", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockRejectedValueOnce(new TypeError("private fetch message", { cause: Object.assign(new Error("private cause"), { code: "ECONNRESET" }) }))
|
||||
.mockResolvedValueOnce(jsonResponse({ stacks: [] }));
|
||||
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const app = createApp({ fetchImpl });
|
||||
const failure = await request(app).get("/api/cloud/stacks");
|
||||
expect(failure.status).toBe(502);
|
||||
expect(failure.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const error = capture.mock.calls[0]![0] as CloudPortfolioError;
|
||||
expect(error.diagnostics).toMatchObject({ phase: "fetch", upstreamStatus: null, networkCode: "ECONNRESET" });
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("private");
|
||||
expect((await request(app).get("/api/cloud/stacks")).body).toEqual({ stacks: [] });
|
||||
expect((await request(app).get("/api/cloud/stacks")).body).toEqual({ stacks: [] });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["invalid_json", "body_reset", "deadline"] as const)("keeps body-phase %s failures separate from fetch failures", async (kind) => {
|
||||
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
|
||||
const upstream = jsonResponse({});
|
||||
vi.spyOn(upstream, "json").mockImplementation(async () => {
|
||||
if (kind === "deadline") controller.abort(new DOMException("private timeout", "TimeoutError"));
|
||||
throw kind === "body_reset" ? Object.assign(new Error("private body"), { code: "ECONNRESET" }) : new SyntaxError("private body");
|
||||
});
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
|
||||
const app = createApp({ fetchImpl });
|
||||
for (let count = 1; count <= 2; count++) {
|
||||
const response = await request(app).get("/api/cloud/stacks");
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio returned invalid JSON", code: "cloud_portfolio_invalid_response", details: { code: "cloud_portfolio_invalid_response" } });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(count);
|
||||
}
|
||||
expect(timeout).toHaveBeenCalledWith(10_000);
|
||||
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({
|
||||
phase: "response_body", upstreamStatus: 200,
|
||||
networkCode: kind === "deadline" ? "DEADLINE_EXCEEDED" : kind === "body_reset" ? "ECONNRESET" : "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the owned fetch deadline without changing the ten-second signal", async () => {
|
||||
const signal = AbortSignal.abort(new DOMException("private timeout", "TimeoutError"));
|
||||
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(signal);
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(signal.reason);
|
||||
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
||||
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks");
|
||||
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(signal);
|
||||
expect(timeout).toHaveBeenCalledWith(10_000);
|
||||
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({ phase: "fetch", networkCode: "DEADLINE_EXCEEDED" });
|
||||
});
|
||||
|
||||
it("does not mislabel a response serialization failure as an upstream fetch error", async () => {
|
||||
const payload: { cycle?: unknown } = {};
|
||||
payload.cycle = payload;
|
||||
const upstream = jsonResponse({});
|
||||
vi.spyOn(upstream, "json").mockResolvedValue(payload);
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
|
||||
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
||||
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks");
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({ phase: "response_write", upstreamStatus: 200, networkCode: "unknown" });
|
||||
});
|
||||
|
||||
it("returns the actor's portfolio without forwarding client-supplied identity", async () => {
|
||||
const portfolio = { stacks: [{ slug: "current", displayName: "Current" }] };
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(portfolio));
|
||||
|
||||
@@ -215,7 +215,7 @@ function boardActor(input: {
|
||||
};
|
||||
}
|
||||
|
||||
describe.sequential("company route cross-company authorization", () => {
|
||||
describe("company route cross-company authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock("../services/company-import-transfers.js", async (importOriginal) => {
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe.sequential
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const companyId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@@ -334,7 +334,7 @@ const importMeta = {
|
||||
collisionStrategy: importRequest.collisionStrategy,
|
||||
};
|
||||
|
||||
describe.sequential("company portability routes", () => {
|
||||
describe("company portability routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => ({
|
||||
|
||||
@@ -26,7 +26,7 @@ import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -164,7 +164,7 @@ async function closeServer(server: Server | null) {
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("execution environment route guards", () => {
|
||||
describe("execution environment route guards", () => {
|
||||
afterAll(async () => {
|
||||
await closeServer(projectServer);
|
||||
await closeServer(issueServer);
|
||||
|
||||
@@ -236,7 +236,7 @@ function mockVerifiedReseed(
|
||||
* stable 409 while a control is genuinely live, and a start that fails must leave the workspace
|
||||
* stopped and retryable instead of "desired running" with residue.
|
||||
*/
|
||||
describe.sequential("execution workspace runtime control conflict and failure reconciliation", () => {
|
||||
describe("execution workspace runtime control conflict and failure reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSpawn.mockReset();
|
||||
|
||||
@@ -88,7 +88,7 @@ function createApp(actor: Record<string, unknown> = {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.sequential("execution workspace routes", () => {
|
||||
describe("execution workspace routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres.sequential("GitHub connection event delivery", () => {
|
||||
describeEmbeddedPostgres("GitHub connection event delivery", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ async function createApp(
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.sequential("GET /invites/:token/test-resolution", () => {
|
||||
describe("GET /invites/:token/test-resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ async function assertNoBackgroundClearWithinRetryWindow() {
|
||||
expect(mockExecutionWorkspaceService.clearReopenPendingConsumptionForUnconsumedReopen).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe.sequential("closed isolated workspace issue routes", () => {
|
||||
describe("closed isolated workspace issue routes", () => {
|
||||
const routeModules = hoistModuleGraph(registerServiceMocks, async () => {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
|
||||
@@ -191,7 +191,7 @@ function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe.sequential("issue comment cancel routes", () => {
|
||||
describe("issue comment cancel routes", () => {
|
||||
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
|
||||
@@ -43,7 +43,7 @@ const embeddedPostgresSupport = externalTestDatabaseUrl
|
||||
? { supported: true }
|
||||
: await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe.sequential
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
|
||||
@@ -317,7 +317,7 @@ async function waitForWakeup(assertion: () => void) {
|
||||
await vi.waitFor(assertion);
|
||||
}
|
||||
|
||||
describe.sequential("issue comment reopen routes", () => {
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockReset();
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("../services/native-runtime/native-session-executor.js", async (importOr
|
||||
const { NativeSessionSteeringError } = await import("../services/native-runtime/native-session-executor.js");
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -308,7 +308,7 @@ async function resolveMockInteraction(
|
||||
return interaction;
|
||||
}
|
||||
|
||||
describe.sequential("issue thread interaction routes", () => {
|
||||
describe("issue thread interaction routes", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
|
||||
@@ -190,7 +190,7 @@ const projectGoal = {
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
|
||||
describe.sequential("issue goal context routes", () => {
|
||||
describe("issue goal context routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("merged pull-request confirmation extraction", () => {
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres.sequential("merged pull-request confirmation sweep", () => {
|
||||
describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { issueRoutes } from "../routes/issues.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -126,7 +126,7 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
const companyBranding = {
|
||||
name: "Acme AI",
|
||||
logoAssetId: "logo-1",
|
||||
|
||||
@@ -73,7 +73,7 @@ function idleStream<T>(): TypedEventStream<T> {
|
||||
async () => close(),
|
||||
);
|
||||
}
|
||||
describe.sequential("iMessage Photon channel control plane", () => {
|
||||
describe("iMessage Photon channel control plane", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let secrets: string;
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { preparePlanSkills, selectPlanSkills, verifyPlanSkills, verifyPlanSelection } from "../../../tests/runner-e2e/plan-task-skills.js";
|
||||
import { planInvocations, planInvocationPrompt } from "../../../tests/runner-e2e/plan-task-exposure.js";
|
||||
import { nativeTaskSkillInputs } from "../../../packages/paperclip-runner/src/backends/runtime-context.js";
|
||||
import { resolveRunnerdCodexSkillInputs } from "../../../packages/paperclip-runner/src/live/runnerd-codex-transport.js";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../../packages/paperclip-runner/src/contracts/runtime-context.js";
|
||||
import { PLAN_SKILLS, PLAN_VARIANTS, planHash } from "../../../tests/runner-e2e/plan-task-cases.js";
|
||||
import type { RunnerApi } from "../../../tests/runner-e2e/api.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
if (!support.supported) console.warn(`Planning fixture API calibration skipped: ${support.reason}`);
|
||||
(support.supported ? describe : describe.skip)("planning fixture public API calibration (no providers)", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
const savedHome = process.env.PAPERCLIP_HOME, savedInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
let api: Pick<RunnerApi, "get" | "post">;
|
||||
let app: express.Express;
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plan-fixture-");
|
||||
root = await mkdtemp(path.join(os.tmpdir(), "paperclip-plan-fixture-home-"));
|
||||
process.env.PAPERCLIP_HOME = root;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
db = createDb(tempDb.connectionString);
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).actor = { type: "board", source: "local_implicit", isInstanceAdmin: true, userId: "local-board" }; next(); });
|
||||
app.use("/api", companySkillRoutes(db));
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use(errorHandler);
|
||||
api = {
|
||||
async get<T>(url: string): Promise<T> {
|
||||
const response = await request(app).get(url);
|
||||
if (response.status !== 200) throw new Error(`GET ${url}: ${response.status} ${JSON.stringify(response.body)}`);
|
||||
return response.body;
|
||||
},
|
||||
async post<T>(url: string, body?: unknown): Promise<T> {
|
||||
const response = await request(app).post(url).send(body as object);
|
||||
if (response.status < 200 || response.status >= 300) throw new Error(`POST ${url}: ${response.status} ${JSON.stringify(response.body)}`);
|
||||
return response.body;
|
||||
},
|
||||
};
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (savedHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = savedHome;
|
||||
if (savedInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; else process.env.PAPERCLIP_INSTANCE_ID = savedInstance;
|
||||
await tempDb?.cleanup();
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
it.each(PLAN_VARIANTS)("admits %s through real creation, readback and selection APIs", async variant => {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Planning calibration", issuePrefix: `P${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture Lead", role: "general", adapterType: "paperclip_runner", adapterConfig: {} });
|
||||
const before = await api.get<any[]>(`/api/companies/${companyId}/skills`);
|
||||
const bundled = before.find(s => s.key === PLAN_SKILLS[0].key);
|
||||
expect(bundled).toBeDefined();
|
||||
const sources = await preparePlanSkills(api, companyId, variant);
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources.every(s => s.key.startsWith(`company/${companyId}/eval-`))).toBe(true);
|
||||
const selected = await selectPlanSkills(api, companyId, agentId, sources);
|
||||
const invocations = planInvocations(sources, selected);
|
||||
const contextSkills = [];
|
||||
for (const source of sources.filter(s => s.selected)) {
|
||||
const entry = (selected as any).entries.find((e: any) => e.key === source.key && e.desired);
|
||||
const bytes = await readFile(path.join(entry.sourcePath, "SKILL.md"));
|
||||
expect(planHash(bytes)).toBe(source.sha256);
|
||||
const mode = (await stat(path.join(entry.sourcePath, "SKILL.md"))).mode & 0o555;
|
||||
expect(mode).toBe(0o444);
|
||||
const digest = planHash(JSON.stringify([{ path: "SKILL.md", sha256: planHash(bytes), mode, size: bytes.length }]));
|
||||
expect(invocations.find(i => i.name === entry.runtimeName)?.assetDigest).toBe(digest);
|
||||
contextSkills.push({ key: source.key, runtimeName: entry.runtimeName, versionId: entry.currentVersionId,
|
||||
bundle: { rootPath: path.join(root, "runtime-context-assets/bundles", digest) } });
|
||||
}
|
||||
const context = { skills: contextSkills } as NativeRuntimeContextSnapshot;
|
||||
const inputs = nativeTaskSkillInputs(planInvocationPrompt("Business task", invocations), context);
|
||||
expect(inputs).toHaveLength(variant === "disabled" ? 0 : 2);
|
||||
expect(inputs.map(i => i.name)).toEqual(invocations.map(i => i.name));
|
||||
const providerHome = path.join(root, "isolated-codex-home");
|
||||
expect(resolveRunnerdCodexSkillInputs(inputs, context, providerHome)).toEqual(inputs.map(i => ({
|
||||
...i, path: path.join(providerHome, "skills", i.name, "SKILL.md"),
|
||||
})));
|
||||
await verifyPlanSelection(api, companyId, agentId, sources);
|
||||
await verifyPlanSkills(api, companyId, sources);
|
||||
const after = await api.get<any[]>(`/api/companies/${companyId}/skills`);
|
||||
expect(after.find(s => s.key === bundled.key)?.id).toBe(bundled.id);
|
||||
expect(after.find(s => s.key === bundled.key)?.markdown).toBe(bundled.markdown);
|
||||
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
|
||||
// Fail closed if a later actor changes either selection or source bytes.
|
||||
const swapped = sources.map(s => ({ ...s, selected: !s.selected }));
|
||||
await expect(verifyPlanSelection(api, companyId, agentId, swapped)).rejects.toThrow("selection differs");
|
||||
await expect(verifyPlanSkills(api, companyId, [{ ...sources[0]!, sha256: "wrong" }])).rejects.toThrow("guidance changed");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ vi.mock("../services/live-events.js", () => ({
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock("../services/live-events.js", () => ({
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -144,7 +144,7 @@ function readyPlugin() {
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("plugin install and upgrade authz", () => {
|
||||
describe("plugin install and upgrade authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -410,7 +410,7 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe.sequential("scoped plugin API routes", () => {
|
||||
describe("scoped plugin API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -476,7 +476,7 @@ describe.sequential("scoped plugin API routes", () => {
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe.sequential("plugin local folder routes", () => {
|
||||
describe("plugin local folder routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRegistry.getCompanySettings.mockResolvedValue(null);
|
||||
@@ -531,7 +531,7 @@ describe.sequential("plugin local folder routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("plugin tool and bridge authz", () => {
|
||||
describe("plugin tool and bridge authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -1104,7 +1104,7 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("operator-hidden plugin management floor", () => {
|
||||
describe("operator-hidden plugin management floor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins";
|
||||
|
||||
@@ -94,7 +94,7 @@ async function createApp(input: {
|
||||
return { app, workerManager };
|
||||
}
|
||||
|
||||
describe.sequential("plugin scoped API routes", () => {
|
||||
describe("plugin scoped API routes", () => {
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
@@ -23,7 +23,7 @@ import { secretService } from "../services/secrets.js";
|
||||
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -198,10 +198,10 @@ describePostgres("Resource lifecycle events", () => {
|
||||
await db.insert(projects).values({ id: oldProjectId, companyId, name: "Existing project" });
|
||||
await agentService(db).update(oldAgentId, { name: "Renamed agent" });
|
||||
await projectService(db).update(oldProjectId, { name: "Renamed project" });
|
||||
expect(await events()).toEqual([]);
|
||||
expect(await events()).toEqual([expect.objectContaining({ action: "update", resourceId: oldProjectId })]);
|
||||
const agent = await createAgent();
|
||||
const project = await projectService(db).create(companyId, { name: "New project" });
|
||||
expect((await events()).map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort());
|
||||
expect((await events()).filter(row => row.action === "create").map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort());
|
||||
});
|
||||
|
||||
it("scopes resource identities by company and type", async () => {
|
||||
@@ -218,4 +218,47 @@ describePostgres("Resource lifecycle events", () => {
|
||||
await db.delete(companies).where(eq(companies.id, otherCompanyId));
|
||||
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("captures project and workspace updates, batches repository replacement, and ignores archive-only changes", async () => {
|
||||
const service = projectService(db);
|
||||
const project = await service.createWithRepositories(companyId, { name: "Mutable project" }, [
|
||||
{ id: "1", fullName: "fixture/one", url: "https://github.com/fixture/one", connections: [] },
|
||||
]);
|
||||
expect((await events()).map(row => row.action)).toEqual(["create"]);
|
||||
await service.update(project.id, { name: "Updated project" });
|
||||
const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/two" });
|
||||
await service.updateWorkspace(project.id, workspace!.id, { repoRef: "main" });
|
||||
await service.removeWorkspace(project.id, workspace!.id);
|
||||
const beforeReplace = (await events()).length;
|
||||
await service.replaceRepositories(project.id, [
|
||||
{ id: "3", fullName: "fixture/three", url: "https://github.com/fixture/three", connections: [] },
|
||||
{ id: "4", fullName: "fixture/four", url: "https://github.com/fixture/four", connections: [] },
|
||||
]);
|
||||
expect(await events()).toHaveLength(beforeReplace + 1);
|
||||
const beforeArchive = await events();
|
||||
await service.update(project.id, { archivedAt: new Date(), name: undefined });
|
||||
expect(await events()).toEqual(beforeArchive);
|
||||
expect((await service.getById(project.id))?.workspaces).toHaveLength(2);
|
||||
expect(beforeArchive.sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "update", "update", "update", "update", "update"]);
|
||||
});
|
||||
|
||||
it("rolls back project and repository mutations when their update record fails", async () => {
|
||||
const service = projectService(db);
|
||||
const project = await service.create(companyId, { name: "Retained project" });
|
||||
const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/retained" });
|
||||
const before = await events();
|
||||
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_update CHECK (action <> 'update') NOT VALID`);
|
||||
try {
|
||||
await expect(service.update(project.id, { name: "Rejected update" })).rejects.toThrow();
|
||||
await expect(service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/rejected" })).rejects.toThrow();
|
||||
await expect(service.updateWorkspace(project.id, workspace!.id, { repoRef: "rejected" })).rejects.toThrow();
|
||||
await expect(service.removeWorkspace(project.id, workspace!.id)).rejects.toThrow();
|
||||
await expect(service.replaceRepositories(project.id, [])).rejects.toThrow();
|
||||
expect(await service.getById(project.id)).toMatchObject({ name: "Retained project", workspaces: [expect.objectContaining({ id: workspace!.id, repoRef: null })] });
|
||||
expect(await events()).toEqual(before);
|
||||
} finally {
|
||||
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_update`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -87,7 +87,7 @@ function registerRoutineServiceMock() {
|
||||
}
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -42,6 +42,48 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => {
|
||||
it("keeps portfolio diagnostics bounded and isolated from unrelated captures", async () => {
|
||||
const Sentry = sentryPackage!;
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1");
|
||||
vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) }));
|
||||
vi.doMock("@sentry/node", () => ({
|
||||
...Sentry,
|
||||
init: (options: Record<string, unknown>) => Sentry.init({
|
||||
...options,
|
||||
transport: () => ({ send: async () => ({}), flush: async () => true }),
|
||||
beforeSend: (event: Record<string, unknown>) => { events.push(event); return event; },
|
||||
}),
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { sentryReady, captureException } = await import("../sentry.js");
|
||||
const { CloudPortfolioError } = await import("../services/cloud-portfolio-error.js");
|
||||
await sentryReady;
|
||||
const failure = new CloudPortfolioError("upstream", {
|
||||
phase: "fetch", elapsedMs: 270, upstreamStatus: null,
|
||||
}, new TypeError("private-portfolio-message", { cause: { code: "ECONNRESET", token: "private-portfolio-token" } }));
|
||||
Object.assign(failure, {
|
||||
cause: new Error("private-portfolio-cause"),
|
||||
request: { url: "https://private-portfolio.invalid", headers: { cookie: "private-portfolio-cookie" } },
|
||||
});
|
||||
Object.defineProperty(failure, "diagnostics", { value: { extra: "private-portfolio-replacement" } });
|
||||
captureException(failure);
|
||||
captureException(new Error("unrelated portfolio fixture"));
|
||||
await Sentry.flush(2000);
|
||||
expect(events).toHaveLength(2);
|
||||
const captured = events.find((event) => (event.exception as { values: Array<{ value: string }> }).values.some((entry) => entry.value === failure.message));
|
||||
expect(captured).toMatchObject({
|
||||
tags: { error_code: "cloud_portfolio_failure" }, fingerprint: ["{{ default }}"],
|
||||
contexts: { cloud_portfolio: { phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" } },
|
||||
});
|
||||
expect((captured!.exception as { values: unknown[] }).values).toHaveLength(1);
|
||||
expect(JSON.stringify(events)).not.toContain("private-portfolio-");
|
||||
const unrelated = events.find((event) => event !== captured)!;
|
||||
expect(unrelated).not.toHaveProperty("contexts.cloud_portfolio");
|
||||
expect(unrelated).not.toHaveProperty("tags.error_code");
|
||||
expect(unrelated).not.toHaveProperty("fingerprint");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ phase: "workspace_restore", phaseElapsedMs: 60_123, expectedPhase: "workspace_restore", expectedElapsedMs: 60_123 },
|
||||
{ phase: "private-provider-phase", phaseElapsedMs: 100, expectedPhase: "unknown", expectedElapsedMs: null },
|
||||
|
||||
@@ -127,6 +127,29 @@ describe("sentryReady", () => {
|
||||
});
|
||||
|
||||
describe("captureException", () => {
|
||||
it("sends only private normalized portfolio diagnostics on the matching event", async () => {
|
||||
process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1";
|
||||
const sdk = mockSentryPackage();
|
||||
const { captureException, sentryReady } = await importFreshSentry();
|
||||
const { CloudPortfolioError } = await import("../services/cloud-portfolio-error.js");
|
||||
await sentryReady;
|
||||
const error = new CloudPortfolioError("upstream", { phase: "fetch", elapsedMs: 270, upstreamStatus: null }, { code: "ECONNRESET" });
|
||||
Object.assign(error, { cause: new Error("private cause"), headers: { authorization: "private token" } });
|
||||
Object.defineProperty(error, "diagnostics", { value: { token: "private replacement" } });
|
||||
captureException(error);
|
||||
const unrelated = new Error("unrelated");
|
||||
captureException(unrelated);
|
||||
expect(sdk.captureException.mock.calls[0]).toEqual([
|
||||
expect.objectContaining({ message: error.message, stack: error.stack }),
|
||||
{ tags: { error_code: "cloud_portfolio_failure" }, fingerprint: ["{{ default }}"], contexts: {
|
||||
cloud_portfolio: { phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" },
|
||||
} },
|
||||
]);
|
||||
expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private");
|
||||
expect(sdk.captureException.mock.calls[0]![0]).not.toHaveProperty("cause");
|
||||
expect(sdk.captureException.mock.calls[1]).toEqual([unrelated]);
|
||||
});
|
||||
|
||||
it("adds bounded Stop timeout context only to that event", async () => {
|
||||
process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1";
|
||||
const sdk = mockSentryPackage();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { teamsCatalogService } from "../services/teams-catalog.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
||||
@@ -202,7 +202,7 @@ function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe.sequential("workspace runtime service route authorization", () => {
|
||||
describe("workspace runtime service route authorization", () => {
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const workspaceId = "22222222-2222-4222-8222-222222222222";
|
||||
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
@@ -116,7 +116,7 @@ function resetMocks() {
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
}
|
||||
|
||||
describe.sequential("write-path membership checks (viewer / inactive)", () => {
|
||||
describe("write-path membership checks (viewer / inactive)", () => {
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
});
|
||||
|
||||
+31
-13
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { forbidden, HttpError, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { CloudPortfolioError, type CloudPortfolioPhase } from "../services/cloud-portfolio-error.js";
|
||||
import {
|
||||
getCloudStackContext,
|
||||
type CloudInstanceEnv,
|
||||
@@ -67,7 +68,24 @@ export function cloudRoutes(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let phase: CloudPortfolioPhase = "fetch";
|
||||
let upstreamStatus: number | null = null;
|
||||
let upstreamSignal: AbortSignal | undefined;
|
||||
const portfolioError = (kind: "upstream" | "invalid_response", error?: unknown) => {
|
||||
const reportableError = new CloudPortfolioError(kind, {
|
||||
phase,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
upstreamStatus,
|
||||
deadlineExceeded: upstreamSignal?.aborted === true,
|
||||
}, error);
|
||||
// Preserve the route callsite instead of grouping by this helper frame.
|
||||
Error.captureStackTrace?.(reportableError, portfolioError);
|
||||
return reportableError;
|
||||
};
|
||||
|
||||
try {
|
||||
upstreamSignal = AbortSignal.timeout(10_000);
|
||||
const upstream = await fetchImpl(portfolioUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -76,26 +94,27 @@ export function cloudRoutes(opts: {
|
||||
"x-paperclip-cloud-user-id": actorUserId,
|
||||
"x-paperclip-cloud-stack-id": context.stackId,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: upstreamSignal,
|
||||
});
|
||||
phase = "http_response";
|
||||
upstreamStatus = upstream.status;
|
||||
if (!upstream.ok) {
|
||||
const error = portfolioError("upstream");
|
||||
logger.warn(
|
||||
{ status: upstream.status, stackId: context.stackId },
|
||||
{ cloudPortfolio: error.diagnostics },
|
||||
"Paperclip Cloud portfolio request failed",
|
||||
);
|
||||
throw new HttpError(502, "Paperclip Cloud portfolio request failed", {
|
||||
code: "cloud_portfolio_upstream_error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
phase = "response_body";
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await upstream.json();
|
||||
} catch {
|
||||
throw new HttpError(502, "Paperclip Cloud portfolio returned invalid JSON", {
|
||||
code: "cloud_portfolio_invalid_response",
|
||||
});
|
||||
} catch (error) {
|
||||
throw portfolioError("invalid_response", error);
|
||||
}
|
||||
phase = "response_write";
|
||||
portfolioCache.set(cacheKey, {
|
||||
expiresAt: currentTime + cacheTtlMs,
|
||||
payload,
|
||||
@@ -104,13 +123,12 @@ export function cloudRoutes(opts: {
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
const reportableError = portfolioError("upstream", error);
|
||||
logger.warn(
|
||||
{ err: error, stackId: context.stackId },
|
||||
{ cloudPortfolio: reportableError.diagnostics },
|
||||
"Paperclip Cloud portfolio request failed",
|
||||
);
|
||||
throw new HttpError(502, "Paperclip Cloud portfolio request failed", {
|
||||
code: "cloud_portfolio_upstream_error",
|
||||
});
|
||||
throw reportableError;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
import os from "node:os";
|
||||
import { AdapterStopTimeoutError } from "./services/adapter-stop-timeout.js";
|
||||
import { CloudPortfolioError } from "./services/cloud-portfolio-error.js";
|
||||
import type { RunFailureDiagnostics } from "./services/run-failure-diagnostics.js";
|
||||
import { readBuildCommit } from "./build-commit.js";
|
||||
import { checkExactPeerVersions } from "./peer-version-check.js";
|
||||
@@ -116,6 +117,14 @@ export function captureException(error: unknown): void {
|
||||
contexts: { adapter_stop: { ...error.diagnostics } },
|
||||
fingerprint: ["{{ default }}"],
|
||||
});
|
||||
} else if (error instanceof CloudPortfolioError) {
|
||||
const exception = new Error(error.message);
|
||||
exception.stack = error.stack;
|
||||
sentryHandle.captureException(exception, {
|
||||
tags: { error_code: "cloud_portfolio_failure" },
|
||||
contexts: { cloud_portfolio: { ...CloudPortfolioError.diagnosticsFor(error) } },
|
||||
fingerprint: ["{{ default }}"],
|
||||
});
|
||||
} else {
|
||||
sentryHandle.captureException(error);
|
||||
}
|
||||
|
||||
@@ -403,6 +403,9 @@ export function activityService(db: Db) {
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
error: sql<string | null>`case when ${heartbeatRuns.status} = 'failed'
|
||||
and ${heartbeatRuns.errorCode} = 'native_provider_model_rejected'
|
||||
then left(${heartbeatRuns.error}, 2000) else null end`,
|
||||
usageJson: summarizedUsageJson,
|
||||
resultJson: summarizedResultJson,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
|
||||
@@ -32,7 +32,7 @@ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = external
|
||||
? { supported: true }
|
||||
: await getEmbeddedPostgresTestSupport();
|
||||
const suite = support.supported ? describe.sequential : describe.skip;
|
||||
const suite = support.supported ? describe : describe.skip;
|
||||
const token = "PRIVATE-DISCORD-REGISTRATION-TOKEN";
|
||||
let serial = 0n;
|
||||
const appId = () =>
|
||||
|
||||
@@ -29,7 +29,7 @@ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = external
|
||||
? { supported: true }
|
||||
: await getEmbeddedPostgresTestSupport();
|
||||
const suite = support.supported ? describe.sequential : describe.skip;
|
||||
const suite = support.supported ? describe : describe.skip;
|
||||
suite("Teams same-transaction publication projection (real PostgreSQL)", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temporary:
|
||||
|
||||
@@ -48,7 +48,7 @@ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = external
|
||||
? { supported: true }
|
||||
: await getEmbeddedPostgresTestSupport();
|
||||
const suite = support.supported ? describe.sequential : describe.skip;
|
||||
const suite = support.supported ? describe : describe.skip;
|
||||
const bytes = Buffer.from("Exact Teams file bytes\n");
|
||||
const uploadUrl =
|
||||
"https://tenant-my.sharepoint.com/personal/user/_api/upload?token=PRIVATE-TEAMS-UPLOAD-CANARY";
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { HttpError } from "../errors.js";
|
||||
|
||||
const phases = ["fetch", "http_response", "response_body", "response_write"] as const;
|
||||
export type CloudPortfolioPhase = typeof phases[number];
|
||||
const networkCodes = new Set([
|
||||
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN",
|
||||
"ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID", "CERT_HAS_EXPIRED",
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "ABORT_ERR",
|
||||
]);
|
||||
|
||||
// Inspect data properties only. A provider error can contain getters, cycles,
|
||||
// arbitrary messages or nested request objects; none are diagnostic payloads.
|
||||
function ownValue(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function networkCode(error: unknown): string {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const code = ownValue(current, "code");
|
||||
if (typeof code === "string" && networkCodes.has(code)) return code;
|
||||
current = ownValue(current, "cause");
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Server-only diagnostics. The existing HTTP response details stay unchanged. */
|
||||
export class CloudPortfolioError extends HttpError {
|
||||
readonly #diagnostics: Readonly<Record<string, string | number | null>>;
|
||||
|
||||
constructor(
|
||||
kind: "upstream" | "invalid_response",
|
||||
input: { phase: CloudPortfolioPhase; elapsedMs: number; upstreamStatus: number | null; deadlineExceeded?: boolean },
|
||||
error?: unknown,
|
||||
) {
|
||||
super(502, kind === "invalid_response"
|
||||
? "Paperclip Cloud portfolio returned invalid JSON"
|
||||
: "Paperclip Cloud portfolio request failed", {
|
||||
code: kind === "invalid_response" ? "cloud_portfolio_invalid_response" : "cloud_portfolio_upstream_error",
|
||||
});
|
||||
Error.captureStackTrace?.(this, CloudPortfolioError);
|
||||
const phase = phases.includes(input.phase) ? input.phase : "unknown";
|
||||
this.#diagnostics = Object.freeze({
|
||||
phase,
|
||||
upstreamStatus: Number.isInteger(input.upstreamStatus) && input.upstreamStatus! >= 100 && input.upstreamStatus! <= 599
|
||||
? input.upstreamStatus : null,
|
||||
elapsedMs: Number.isFinite(input.elapsedMs) && input.elapsedMs >= 0 && input.elapsedMs <= 60_000
|
||||
? Math.round(input.elapsedMs) : null,
|
||||
networkCode: phase === "fetch" || phase === "response_body"
|
||||
? input.deadlineExceeded === true ? "DEADLINE_EXCEEDED" : networkCode(error)
|
||||
: "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
get diagnostics() {
|
||||
return this.#diagnostics;
|
||||
}
|
||||
|
||||
static diagnosticsFor(error: CloudPortfolioError) {
|
||||
return error.#diagnostics;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -44,7 +44,7 @@ const managedFixtureDirectory =
|
||||
process.env.PAPERCLIP_WARM_TRANSITION_MANAGED_FIXTURE_DIRECTORY;
|
||||
const databaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const generated =
|
||||
fixtureDirectory && databaseUrl ? describe.sequential : describe.skip;
|
||||
fixtureDirectory && databaseUrl ? describe : describe.skip;
|
||||
|
||||
type Identity = {
|
||||
runnerInstanceId: string;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runt
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { recordProjectUpdateEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
@@ -568,7 +568,7 @@ async function ensureSinglePrimaryWorkspace(
|
||||
);
|
||||
}
|
||||
|
||||
export function projectService(db: Db) {
|
||||
export function projectService(db: Db, options: { captureWorkspaceUpdates?: boolean } = {}) {
|
||||
const createProject = async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
@@ -838,7 +838,7 @@ export function projectService(db: Db) {
|
||||
|
||||
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals> => {
|
||||
return db.transaction(async (tx) => {
|
||||
const service = projectService(tx as unknown as Db);
|
||||
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||
const project = await service.create(companyId, data);
|
||||
for (const repo of repositories) {
|
||||
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
|
||||
@@ -851,7 +851,7 @@ export function projectService(db: Db) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
if (!project) return null;
|
||||
const service = projectService(tx as unknown as Db);
|
||||
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||
const existing = await service.listWorkspaces(projectId);
|
||||
const ids = new Set(repositories.map((repo) => repo.id));
|
||||
for (const workspace of existing) {
|
||||
@@ -878,6 +878,7 @@ export function projectService(db: Db) {
|
||||
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
|
||||
}
|
||||
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
|
||||
await recordProjectUpdateEvent(tx as unknown as Db, project.companyId, projectId);
|
||||
return service.getById(projectId);
|
||||
});
|
||||
},
|
||||
@@ -887,13 +888,14 @@ export function projectService(db: Db) {
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals | null> => {
|
||||
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.where(eq(projects.id, id)).for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
if (ids && ids.length > 0) {
|
||||
@@ -935,10 +937,14 @@ export function projectService(db: Db) {
|
||||
await syncGoalLinks(db, id, row.companyId, ids);
|
||||
}
|
||||
|
||||
if (Object.entries(projectData).some(([key, value]) => value !== undefined && key !== "archivedAt" && key !== "updatedAt") || ids !== undefined) {
|
||||
await recordProjectUpdateEvent(db, row.companyId, id);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched ?? null;
|
||||
},
|
||||
}),
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
const rows = await db
|
||||
@@ -954,16 +960,9 @@ export function projectService(db: Db) {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
|
||||
if (policy?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
executionWorkspacePolicy: {
|
||||
...policy,
|
||||
environmentId: null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projects.id, row.id));
|
||||
await projectService(db).update(row.id, {
|
||||
executionWorkspacePolicy: { ...policy, environmentId: null },
|
||||
});
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
@@ -1004,11 +1003,12 @@ export function projectService(db: Db) {
|
||||
createWorkspace: async (
|
||||
projectId: string,
|
||||
data: CreateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
const project = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.where(eq(projects.id, projectId)).for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!project) return null;
|
||||
|
||||
@@ -1079,14 +1079,19 @@ export function projectService(db: Db) {
|
||||
return row;
|
||||
});
|
||||
|
||||
if (created && options.captureWorkspaceUpdates !== false) {
|
||||
await recordProjectUpdateEvent(db, created.companyId, projectId);
|
||||
}
|
||||
return created ? toWorkspace(created) : null;
|
||||
},
|
||||
}),
|
||||
|
||||
updateWorkspace: async (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
data: UpdateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
@@ -1231,10 +1236,15 @@ export function projectService(db: Db) {
|
||||
return row;
|
||||
});
|
||||
|
||||
if (updated && options.captureWorkspaceUpdates !== false) {
|
||||
await recordProjectUpdateEvent(db, updated.companyId, projectId);
|
||||
}
|
||||
return updated ? toWorkspace(updated) : null;
|
||||
},
|
||||
}),
|
||||
|
||||
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => {
|
||||
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
@@ -1281,8 +1291,11 @@ export function projectService(db: Db) {
|
||||
return row;
|
||||
});
|
||||
|
||||
if (removed && options.captureWorkspaceUpdates !== false) {
|
||||
await recordProjectUpdateEvent(db, removed.companyId, projectId);
|
||||
}
|
||||
return removed ? toWorkspace(removed) : null;
|
||||
},
|
||||
}),
|
||||
|
||||
resolveByReference: async (companyId: string, reference: string) => {
|
||||
const raw = reference.trim();
|
||||
|
||||
@@ -23,3 +23,8 @@ export async function recordAgentStatusEvent(db: Db, companyId: string, agentId:
|
||||
if (!action) return;
|
||||
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action });
|
||||
}
|
||||
|
||||
/** Call while holding the project row lock, in the mutation transaction. */
|
||||
export async function recordProjectUpdateEvent(db: Db, companyId: string, projectId: string): Promise<void> {
|
||||
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: projectId, action: "update" });
|
||||
}
|
||||
@@ -39,6 +39,9 @@ export default defineConfig({
|
||||
maxWorkers: 1,
|
||||
minWorkers: 1,
|
||||
pool: "forks",
|
||||
// Server suites share process state and one embedded Postgres instance,
|
||||
// so tests inside a file must run one at a time. Do not set
|
||||
// sequence.concurrent to true.
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
hooks: "list",
|
||||
|
||||
Reference in new issue
Block a user